Python-编程学习手册第二版(全)

Python 编程学习手册第二版(全)

原文:zh.annas-archive.org/md5/406733548F67B770B962DA4756270D5F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当我开始写这本书的第一版时,我对预期的内容知之甚少。渐渐地,我学会了如何将每个主题转化为一个故事。我想通过提供有用、简单、易于理解的例子来谈论 Python,但与此同时,我也想将自己的经验倾注到书页中,我在这些年学到的任何我认为对读者有价值的东西——一些值得思考、反思,并且希望能够吸收的东西。读者可能会持不同意见,提出不同的做事方式,但希望是更好的方式。

我希望这本书不仅仅是关于语言,而是关于编程。事实上,编程的艺术包括许多方面,语言只是其中之一。

编程的另一个关键方面是独立性。当你遇到难题,不知道如何解决面临的问题时,能够自我解锁的能力。没有一本书可以教会这一点,所以我想,我不会试图教授这一方面,而是尝试训练读者。因此,我在整本书中留下了评论、问题和备注,希望能激发读者的灵感。我希望他们能花时间浏览网页或官方文档,深入挖掘,学到更多,发现自己独立解决问题的乐趣。

最后,我希望写一本即使在呈现方式上也稍有不同的书。因此,我决定与我的编辑一起,以理论的方式写第一部分,介绍描述 Python 特性的主题,并且有第二部分由各种真实项目组成,向读者展示使用这种语言可以实现多少。

在考虑了所有这些目标之后,我不得不面对最艰巨的挑战:将我想写的所有内容放入允许的页数范围内。这是很困难的,也做出了牺牲。

我的努力得到了回报:至今,近 3 年过去了,我仍然不时地收到读者的可爱留言,感谢我并告诉我诸如“你的书让我变得更有力量”。对我来说,这是最美的赞美。我知道语言可能会改变和消逝,但我已经成功地与读者分享了一些我的知识,这些知识将与他们保持在一起。

现在,我已经写了这本书的第二版,这一次,我有了更多的空间。所以我决定增加一章关于 IO,这是非常需要的,我甚至有机会增加两章,一章关于秘密,一章关于并发执行。后者绝对是整本书中最具挑战性的章节,其目的是激励读者达到一个能够轻松消化其中代码并理解其概念的水平。

我保留了所有原始章节,除了最后一个略显多余的章节。它们都已经得到更新,符合最新版本的 Python,即写作时的 3.7 版本。

当我看着这本书时,我看到了一个更加成熟的产品。有更多的章节,内容已重新组织以更好地适应叙述,但书的灵魂仍在那里。最重要的一点,赋予读者力量,仍然完整无缺。

我希望这个版本会比上一个版本更成功,并且能帮助读者成为优秀的程序员。我希望帮助他们发展批判性思维、优秀的技能,并且能够随着时间的推移适应,这都要归功于他们从这本书中获得的坚实基础。

这本书是为谁写的

Python 是美国顶尖计算机科学大学最受欢迎的入门教学语言,因此如果你是软件开发新手,或者经验不足并希望从正确的角度开始,那么这门语言和这本书就是你需要的。它惊人的设计和可移植性将帮助你在选择工作环境时变得更加高效。

如果你已经使用过 Python 或其他任何语言,这本书仍然对你有用,既可以作为 Python 基础知识的参考,也可以提供二十年经验积累的各种考虑和建议。

本书涵盖的内容

第一章,“Python 的初步介绍”,向你介绍了基本的编程概念。它指导你如何在计算机上运行 Python,并向你介绍了一些构造。

第二章,“内置数据类型”,向你介绍了 Python 的内置数据类型。Python 拥有非常丰富的本地数据类型,本章将为你介绍每种类型的描述和简短示例。

第三章,“迭代和决策”,教你如何通过检查条件、应用逻辑和执行循环来控制代码流程。

第四章,“函数,代码的构建块”,教会你如何编写函数。函数是重用代码的关键,可以减少调试时间,并且通常可以编写更好的代码。

第五章,“节省时间和内存”,向你介绍了 Python 编程的功能方面。本章教你如何编写理解和生成器,这是你可以用来加快代码速度和节省内存的强大工具。

第六章,“面向对象编程、装饰器和迭代器”,教你使用 Python 的面向对象编程基础知识。它向你展示了这种范式的关键概念和所有潜力。它还向你展示了 Python 最受喜爱的特性之一:装饰器。最后,它还涵盖了迭代器的概念。

第七章,“文件和数据持久性”,教你如何处理文件、流、数据交换格式和数据库等内容。

第八章,“测试、性能分析和处理异常”,教你如何使用测试和性能分析等技术使你的代码更加健壮、快速和稳定。它还正式定义了异常的概念。

第九章,“加密和令牌”,涉及安全、哈希、加密和令牌等概念,这些是当今日常编程的一部分。

第十章,“并发执行”,是一个具有挑战性的章节,描述了如何同时做很多事情。它介绍了这个主题的理论方面,然后提供了三个不同技术开发的好练习,从而使读者能够理解所呈现的范式之间的差异。

第十一章,“调试和故障排除”,向你展示了调试代码的主要方法,以及如何应用这些方法的一些示例。

第十二章,“GUI 和脚本”,从两个不同的角度指导你完成一个示例。它们处于光谱的两端:一个实现是脚本,另一个是一个合适的图形用户界面应用程序。

第十三章,数据科学,介绍了一些关键概念和一个非常特殊的工具,Jupyter Notebook。

第十四章,Web 开发,介绍了 Web 开发的基础知识,并使用 Django Web 框架提供了一个项目。示例将基于正则表达式。

为了充分利用本书

鼓励您按照本书中的示例进行操作。为此,您需要一台计算机,一个互联网连接和一个浏览器。本书是用 Python 3.7 编写的,但在很大程度上,它也适用于任何最近的 Python 3.*版本。我已经给出了如何在您的操作系统上安装 Python 的指南。这些程序一直在变化,因此您需要参考网络上最新的指南,以找到精确的设置说明。我还解释了如何安装各种示例中使用的所有额外库,并在读者在安装它们时遇到任何问题时提供了建议。不需要特定的编辑器来输入代码;但是,我建议那些有兴趣跟随示例的人考虑采用适当的编码环境。我在第一章中对此问题提出了建议。

下载示例代码文件

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

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

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

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

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

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

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Python-Programming-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这是一个例子:“在learn.pp文件夹中,我们将创建一个名为learnpp的虚拟环境。”

代码块设置如下:

# we define a function, called local
def local():
    m = 7
    print(m)

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

# key.points.mutable.assignment.py
x = [1, 2, 3]
def func(x):
    x[1] = 42  # this changes the caller!
    x = 'something else'  # this points x to a new string object

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

>>> import sys
>>> print(sys.version)

粗体:表示新术语,重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“要在 Windows 中打开控制台,转到“开始”菜单,选择“运行”,然后输入cmd。”

警告或重要说明会出现在这样的地方。提示和技巧会出现在这样的地方。

第一章:Python 的简介

“授人以鱼不如授人以渔”-中国谚语

根据维基百科,计算机编程是:

“……从最初的计算问题表述到可执行的计算机程序的过程。编程涉及活动,如分析、开发理解、生成算法、验证算法的要求,包括它们的正确性和资源消耗,以及在目标编程语言中实现(通常称为编码)算法。”

简而言之,编码就是用计算机能理解的语言告诉计算机做某事。

计算机是非常强大的工具,但不幸的是,它们无法自行思考。它们需要被告知一切:如何执行任务,如何评估条件以决定要遵循哪条路径,如何处理来自设备的数据,比如网络或磁盘,以及在发生意外情况时如何做出反应,比如,某物坏了或丢失了。

你可以用许多不同的风格和语言编写代码。难吗?我会说不是。这有点像写作。每个人都可以学会写作,你也可以。但是,如果你想成为一名诗人呢?那么仅仅写作是不够的。你还需要掌握一整套其他技能,这将需要更长时间和更大的努力。

最终,一切都取决于你想走多远。编码不仅仅是将一些有效的指令组合在一起。它远不止如此!

良好的代码是简短、快速、优雅、易于阅读和理解、简单、易于修改和扩展、易于扩展和重构、易于测试。要能够同时具备所有这些品质的代码需要时间,但好消息是,通过阅读这本书,你正在迈出迈向这个目标的第一步。我毫不怀疑你能做到。任何人都可以;事实上,我们都在不知不觉中一直在编程。

你想要一个例子吗?

假设你想泡速溶咖啡。你需要一个杯子,速溶咖啡罐,一茶匙,水和水壶。即使你没有意识到,你正在评估大量的数据。你要确保水壶里有水,水壶已经插上电,杯子是干净的,罐子里有足够的咖啡。然后,你煮水,也许在此期间,你把一些咖啡放在杯子里。当水煮好时,你把它倒进杯子里,然后搅拌。

那么,这和编程有什么关系呢?

嗯,我们收集了资源(水壶、咖啡、水、茶匙和杯子),并验证了一些关于它们的条件(水壶已插上电,杯子是干净的,罐子里有足够的咖啡)。然后我们开始了两个动作(煮水和把咖啡放在杯子里),当它们都完成时,我们最终通过把水倒进杯子里并搅拌来结束了这个过程。

你能看到吗?我刚刚描述了一个咖啡程序的高级功能。这并不难,因为这就是大脑整天在做的事情:评估条件,决定采取行动,执行任务,重复其中一些,并在某个时刻停下来。清理物品,放回去,等等。

现在你所需要做的就是学会如何分解你在现实生活中自动执行的所有这些动作,以便计算机实际上能够理解它们。你还需要学习一种语言,来指导它。

这就是这本书的目的。我会告诉你如何做,我会尝试通过许多简单但专注的例子来做到这一点(我最喜欢的类型)。

在本章中,我们将涵盖以下内容:

  • Python 的特点和生态系统

  • 如何开始并运行 Python 和虚拟环境的指南

  • 如何运行 Python 程序

  • 如何组织 Python 代码和 Python 的执行模型

一个适当的介绍

我喜欢在教编码时引用现实世界;我相信这有助于人们更好地记住概念。然而,现在是时候更严谨地从技术角度看待编码是什么了。

当我们编写代码时,我们正在指示计算机要做的事情。动作发生在哪里?在许多地方:计算机内存、硬盘、网络电缆、CPU 等等。这是一个完整的世界,大多数时候是真实世界的一个子集的表示。

如果您编写了一个允许人们在线购买衣服的软件,那么您将不得不在程序的范围内代表真实的人、真实的衣服、真实的品牌、尺寸等等。

为了做到这一点,您需要在编写的程序中创建和处理对象。一个人可以是一个对象。一辆汽车是一个对象。一双袜子是一个对象。幸运的是,Python 非常了解对象。

任何对象具有的两个主要特征是属性和方法。让我们以一个人对象为例。在计算机程序中,您通常将人表示为顾客或员工。您存储在他们身上的属性是姓名、社会安全号码、年龄、是否有驾照、电子邮件、性别等等。在计算机程序中,您存储了您需要的所有数据,以便使用对象来实现您的目的。如果您正在编写一个销售服装的网站,您可能还想存储客户的身高和体重以及其他测量数据,以便为他们推荐合适的衣服。因此,属性是对象的特征。我们一直在使用它们:你能把那支笔递给我吗?哪一支?黑色的那支。在这里,我们使用了笔的黑色属性来识别它(很可能是在蓝色和红色中)。

方法是对象可以执行的操作。作为一个人,我有诸如说话走路睡觉醒来吃饭做梦写作阅读等方法。我能做的所有事情都可以看作是代表我的对象的方法。

所以,现在您知道对象是什么,它们公开了可以运行的方法和可以检查的属性,您已经准备好开始编码了。实际上,编码只是简单地管理我们在软件中再现的世界子集中生活的那些对象。您可以随意创建、使用、重用和删除对象。

根据官方 Python 文档上的数据模型章节(docs.python.org/3/reference/datamodel.html):

“对象是 Python 程序中数据的抽象。Python 程序中的所有数据都由对象或对象之间的关系表示。”

我们将在第六章中更仔细地研究 Python 对象,OOP、装饰器和迭代器。目前,我们需要知道的是 Python 中的每个对象都有一个 ID(或标识)、类型和值。

一旦创建,对象的 ID 就永远不会改变。这是它的唯一标识符,并且 Python 在幕后使用它来检索我们想要使用的对象。

类型也永远不会改变。类型告诉对象支持哪些操作,以及可以分配给它的可能值。

我们将在第二章中看到 Python 最重要的数据类型,内置数据类型

值可以改变,也可以不改变。如果可以改变,对象被称为可变,而当它不能改变时,对象被称为不可变

我们如何使用对象?当然是给它一个名字!当您给对象一个名字时,然后您可以使用该名称检索对象并使用它。

在更一般的意义上,诸如数字、字符串(文本)、集合等对象都与一个名称相关联。通常,我们说这个名称是变量的名称。你可以把变量看作是一个盒子,你可以用它来存储数据。

那么,你已经拥有了所有你需要的对象;现在呢?嗯,我们需要使用它们,对吧?也许我们想要通过网络连接发送它们,或者将它们存储在数据库中。也许在网页上显示它们,或者将它们写入文件。为了做到这一点,我们需要对用户填写表单、按下按钮、打开网页并执行搜索做出反应。我们通过运行我们的代码来做出反应,评估条件以选择执行哪些部分,多少次,以及在哪些情况下。

而要做到这一切,基本上我们需要一种语言。这就是 Python 的用途。Python 是我们在本书中一起使用的语言,用来指示计算机为我们做一些事情。

现在,够了这些理论的东西;让我们开始吧。

进入 Python

Python 是 Guido Van Rossum 的杰作,他是一位荷兰计算机科学家和数学家,决定在 1989 年圣诞节期间把他玩耍的项目送给世界。这种语言大约在 1991 年左右出现在公众面前,从那时起,它已经发展成为当今世界上使用最广泛的编程语言之一。

我 7 岁开始学习编程,用的是 Commodore VIC-20,后来换成了它的大哥 Commodore 64。它的语言是 BASIC。后来,我接触了 Pascal、Assembly、C、C++、Java、JavaScript、Visual Basic、PHP、ASP、ASP .NET、C#,还有其他一些我甚至都记不起来的小语言,但直到我接触到 Python,我才有了那种在商店里找到合适的沙发时的感觉。当你的全身部位都在呼喊着,“买这个!这个对我们来说完美!”

我大约花了一天的时间来适应它。它的语法与我以前习惯的有点不同,但在克服了最初的不适感之后(就像穿上新鞋一样),我就深深地爱上了它。让我们看看为什么。

关于 Python

在我们深入了解细节之前,让我们先了解一下为什么有人会想要使用 Python(我建议你阅读维基百科上的 Python 页面,以获得更详细的介绍)。

在我看来,Python 体现了以下特质。

可移植性

Python 可以在任何地方运行,将程序从 Linux 移植到 Windows 或 Mac 通常只是修复路径和设置的问题。Python 被设计用于可移植性,并且它会处理特定操作系统(OS)的怪癖,这些接口会让你免于编写针对特定平台的代码的痛苦。

连贯性

Python 是非常逻辑和连贯的。你可以看出它是由一位杰出的计算机科学家设计的。大多数时候,如果你不知道一个方法该怎么调用,你可以猜一下。

你现在可能没有意识到这一点有多重要,特别是如果你是刚开始学习的话,但这是一个重要的特点。这意味着你的头脑中没有那么多杂乱,也不需要在文档中浏览那么多,编码时也不需要在大脑中进行那么多映射。

开发者生产力

根据 Mark Lutz(《学习 Python,第 5 版》,O'Reilly Media)的说法,Python 程序通常只有等效的 Java 或 C++代码的五分之一到三分之一大小。这意味着工作可以更快地完成。更快是好的。更快意味着市场上更快的反应。更少的代码不仅意味着写的代码更少,而且意味着阅读的代码更少(专业的程序员读的比写的多),维护的代码更少,调试的代码更少,重构的代码更少。

另一个重要的方面是 Python 可以在不需要冗长和耗时的编译和链接步骤的情况下运行,因此你不必等待看到你的工作成果。

丰富的库

Python 有一个非常广泛的标准库(据说它是带有内置电池的)。如果这还不够,全世界的 Python 社区维护着一系列针对特定需求定制的第三方库,你可以在Python Package IndexPyPI)上免费访问。当你编写 Python 代码时,当你意识到你需要某个特定功能时,大多数情况下,至少有一个库已经为你实现了这个功能。

软件质量

Python 非常注重可读性、连贯性和质量。语言的统一性使得高可读性成为可能,这在当今编码更多是集体努力而不是个人努力的情况下至关重要。Python 的另一个重要方面是其固有的多范式特性。你可以将它用作脚本语言,但也可以利用面向对象、命令式和函数式编程风格。它是多才多艺的。

软件集成

Python 有一个重要的方面是它可以扩展和与许多其他语言集成,这意味着即使一家公司正在使用不同的语言作为他们的主流工具,Python 可以作为一个粘合剂在复杂的应用程序之间起到沟通的作用。这是一个高级话题,但在现实世界中,这个特性非常重要。

满足和享受

最后,但同样重要的是,这很有趣!使用 Python 很有趣。我可以编码 8 个小时,离开办公室时感到快乐和满意,对于其他程序员必须忍受的挣扎来说,他们使用的语言没有提供同样数量的精心设计的数据结构和构造。毫无疑问,Python 让编码变得有趣。有趣促进了动力和生产力。

这些是我为什么会向每个人推荐 Python 的主要方面。当然,还有许多其他技术和高级特性,我本可以谈论,但它们并不真正属于像这样的入门部分。它们会在这本书的每一章中自然地出现。

有什么缺点?

也许,唯一的缺点是 Python 的执行速度,这不是由于个人偏好造成的。通常情况下,Python 比它的编译兄弟慢。Python 的标准实现在运行应用程序时会产生一个称为字节码的源代码的编译版本(扩展名为.pyc),然后由 Python 解释器运行。这种方法的优势是可移植性,但由于 Python 没有像其他语言那样编译到机器级别,我们付出了速度减慢的代价。

然而,Python 的速度在今天很少是一个问题,因此它被广泛使用,尽管有这个次优特性。实际上,硬件成本已经不再是一个问题,通常很容易通过并行化任务来提高速度。此外,许多程序花费大部分时间等待 IO 操作完成;因此,原始执行速度通常是整体性能的次要因素。不过,当涉及到大量计算时,人们可以切换到更快的 Python 实现,比如 PyPy,通过实现先进的编译技术,它提供了平均五倍的加速(参考pypy.org/)。

在进行数据科学时,你很可能会发现你使用的 Python 库,如PandasNumPy,由于它们的实现方式,实现了本地速度。

如果这不是一个足够好的论点,你可以考虑 Python 已经被用来驱动 Spotify 和 Instagram 等服务的后端,其中性能是一个问题。尽管如此,Python 已经完美地完成了它的工作。

今天谁在使用 Python?

还不确定?让我们简要看一下今天正在使用 Python 的公司:Google、YouTube、Dropbox、Yahoo!、Zope Corporation、Industrial Light & Magic、Walt Disney Feature Animation、Blender 3D、Pixar、NASA、NSA、Red Hat、Nokia、IBM、Netflix、Yelp、Intel、Cisco、HP、Qualcomm 和 JPMorgan Chase 等等。

甚至像Battlefield 2Civilization IVQuArK这样的游戏也是用 Python 实现的。

Python 在许多不同的领域中被使用,如系统编程、Web 编程、GUI 应用程序、游戏和机器人技术、快速原型设计、系统集成、数据科学、数据库应用等等。一些知名的大学也已经将 Python 作为他们计算机科学课程的主要语言。

设置环境

在我们讨论如何在你的系统上安装 Python 之前,让我告诉你我在本书中将使用的 Python 版本。

Python 2 与 Python 3

Python 有两个主要版本:Python 2 是过去,Python 3 是现在。尽管两个版本非常相似,但在某些方面是不兼容的。

在现实世界中,Python 2 实际上离过去还相当遥远。简而言之,尽管 Python 3 自 2008 年以来就已经发布,但从版本 2 过渡到版本 3 的阶段仍然远未结束。这主要是因为 Python 2 在工业中被广泛使用,当然,公司并不急于仅仅为了更新系统而更新系统,遵循“如果它没坏,就不要修理它”的理念。你可以在网上阅读关于这两个版本之间过渡的所有信息。

另一个阻碍过渡的问题是第三方库的可用性。通常,一个 Python 项目依赖于数十个外部库,当你开始一个新项目时,你需要确保已经有一个与 Version-3 兼容的库来满足任何可能出现的业务需求。如果不是这样的话,在 Python 3 中开始一个全新的项目意味着引入潜在的风险,而许多公司并不愿意冒这个风险。

在撰写本文时,大多数最广泛使用的库已经移植到 Python 3,并且对于大多数情况来说,在 Python 3 中启动项目是相当安全的。许多库已经重写,以便与两个版本兼容,主要利用了six库的功能(名称来源于 2 x 3 的乘法,因为从版本 2 到 3 的移植),它可以帮助内省并根据使用的版本调整行为。根据 PEP 373(legacy.python.org/dev/peps/pep-0373/),Python 2.7 的生命周期EOL)已经设定为 2020 年,不会有 Python 2.8,因此对于在 Python 2 中运行项目的公司来说,现在是需要开始制定升级策略并在太迟之前转移到 Python 3 的时候了。

在我的电脑上(MacBook Pro),这是我拥有的最新 Python 版本:

>>> import sys
>>> print(sys.version)
3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)]

所以你可以看到,这个版本是 Python 3.7 的 alpha 版本,将于 2018 年 6 月发布。前面的文本是我在控制台中输入的一小段 Python 代码。我们稍后会谈论它。

本书中的所有示例都将使用 Python 3.7 运行。即使此刻最终版本可能与我所拥有的略有不同,但我会确保所有的代码和示例在书籍出版时都是最新的 3.7 版本。

一些代码也可以在 Python 2.7 中运行,要么就是原样,要么进行一些微小的调整,但在这个时间点上,我认为最好是学习 Python 3,然后,如果需要的话,再学习它与 Python 2 的区别,而不是反过来。

不过,不要担心这个版本问题;实际上在实践中并不是那么大的问题。

安装 Python

我从来没有真正理解过书中为什么要有一个设置部分,无论您需要设置什么。大多数情况下,作者编写说明和您实际尝试它们之间已经过去了几个月。也就是说,如果您很幸运的话。一旦版本更改,书中描述的方式可能无法正常工作。幸运的是,现在我们有了网络,为了帮助您启动和运行,我只会给您一些指引和目标。

我知道大多数读者可能更喜欢在书中获得指南。我怀疑这是否会让他们的生活变得更轻松,因为我坚信,如果您想要开始学习 Python,您必须付出最初的努力,以熟悉这个生态系统。这非常重要,它将增强您面对后面章节中的材料时的信心。如果遇到困难,请记住,谷歌是您的朋友。

设置 Python 解释器

首先,让我们谈谈您的操作系统。Python 已经完全集成,并且基本上几乎每个 Linux 发行版中都已经安装了。如果您使用 macOS,很可能 Python 也已经安装好了(但可能只有 Python 2.7),而如果您使用 Windows,您可能需要安装它。

获取 Python 和所需的库并使其运行需要一些技巧。对于 Python 程序员来说,Linux 和 macOS 似乎是最用户友好的操作系统;另一方面,Windows 需要最大的努力。

我的当前系统是 MacBook Pro,这是我在整本书中将使用的系统,还有 Python 3.7。

您想要开始的地方是官方 Python 网站:www.python.org。这个网站托管了官方 Python 文档和许多其他资源,您会发现非常有用。花点时间去探索一下。

另一个关于 Python 及其生态系统的优秀资源网站是docs.python-guide.org。您可以找到使用不同方法在不同操作系统上设置 Python 的说明。

找到下载部分,并选择适合您操作系统的安装程序。如果您使用 Windows,请确保在运行安装程序时选中安装 pip选项(实际上,我建议进行完整安装,以确保安装程序包含的所有组件都安装了)。我们稍后会讨论pip

现在 Python 已经安装在您的系统中,目标是能够打开控制台并通过输入python来运行 Python 交互式 shell。

请注意,我通常将Python 交互式 shell简单地称为Python 控制台

要在 Windows 中打开控制台,转到开始菜单,选择运行,然后输入cmd。如果您在使用本书中的示例时遇到类似权限问题的情况,请确保以管理员权限运行控制台。

在 macOS X 上,您可以通过转到应用程序|实用程序|终端来启动终端。

如果您使用 Linux,您对控制台的了解应该已经非常全面。

我将使用术语控制台来交替指代 Linux 控制台,Windows 命令提示符和 Macintosh 终端。我还将用 Linux 默认格式指示命令行提示符,就像这样:

$ sudo apt-get update

如果您对此不熟悉,请花些时间学习控制台的基础知识。简而言之,在$符号后,通常会有您需要输入的指令。注意大小写和空格,它们非常重要。

无论您打开哪个控制台,请在提示符处键入python,确保 Python 交互式 shell 显示出来。键入exit()退出。请记住,如果您的操作系统预装了 Python 2.*,您可能需要指定python3

当您运行 Python 时,大致会看到以下内容(根据版本和操作系统的不同,某些细节可能会有所变化):

$ python3.7
Python 3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

现在 Python 已经设置好了,你可以运行它,是时候确保你有另一个在本书中跟随示例时不可或缺的工具:virtualenv。

关于 virtualenv

你可能已经猜到了,virtualenv的名字,它与虚拟环境有关。让我通过一个简单的例子来解释它们是什么,为什么我们需要它们。

你在系统上安装了 Python,并开始为 X 客户端开发网站。你创建了一个项目文件夹并开始编码。在这个过程中,你还安装了一些库;例如,Django 框架,我们将在第十四章 Web Development中深入了解。假设你为 X 项目安装的 Django 版本是 1.7.1。

现在,你的网站做得很好,你得到了另一个客户 Y。她希望你建立另一个网站,所以你开始了 Y 项目,并在这个过程中需要再次安装 Django。唯一的问题是现在 Django 的版本是 1.8,你不能在系统上安装它,因为这会替换你为 X 项目安装的版本。你不想冒险引入不兼容的问题,所以你有两个选择:要么你坚持使用你当前机器上的版本,要么你升级它,并确保第一个项目仍然能够正确地使用新版本。

坦率地说,这两个选项都不是很吸引人,对吧?绝对不是。所以,这里有一个解决方案:virtualenv!

virtualenv 是一个允许你创建虚拟环境的工具。换句话说,它是一个创建隔离的 Python 环境的工具,每个环境都是一个包含了一个 Python 项目所需的所有必要可执行文件的文件夹(暂时把包想象成库)。

所以你为 X 项目创建一个虚拟环境,安装所有的依赖,然后你为 Y 项目创建一个虚拟环境,安装所有它的依赖,而不用担心,因为你安装的每个库最终都会在适当的虚拟环境的范围内。在我们的例子中,X 项目将持有 Django 1.7.1,而 Y 项目将持有 Django 1.8。

非常重要的是,你永远不要直接在系统级别安装库。例如,Linux 依赖于 Python 来执行许多不同的任务和操作,如果你在系统安装的 Python 上搞砸了,你就有可能危及整个系统的完整性(猜猜这是发生在谁身上的...)。所以把这当作一个规则,就像睡觉前刷牙一样:每当你开始一个新项目时,一定要创建一个虚拟环境

要在系统上安装 virtualenv,有几种不同的方法。例如,在基于 Debian 的 Linux 发行版上,你可以使用以下命令安装它:

$ sudo apt-get install python-virtualenv

可能,最简单的方法是按照你可以在 virtualenv 官方网站上找到的说明进行操作:virtualenv.pypa.io

你会发现,安装 virtualenv 的最常见方法之一是使用pip,这是一个用于安装和管理用 Python 编写的软件包的软件包管理系统。

从 Python 3.5 开始,创建虚拟环境的建议方法是使用venv模块。请参阅官方文档以获取更多信息。然而,在撰写本文时,virtualenv 仍然是创建虚拟环境最常用的工具。

你的第一个虚拟环境

创建虚拟环境非常容易,但根据系统配置和您想要虚拟环境运行的 Python 版本,您需要正确运行命令。当您想要使用 virtualenv 时,另一件需要做的事情是激活它。激活 virtualenv 基本上在幕后进行一些路径操作,这样当您调用 Python 解释器时,实际上调用的是活动的虚拟环境,而不是单纯的系统环境。

我将在我的 Macintosh 控制台上展示一个完整的示例。我们将:

  1. 在您的项目根目录下创建一个名为learn.pp的文件夹(在我的情况下是一个名为srv的文件夹,在我的主文件夹中)。请根据您在系统上喜欢的设置调整路径。

  2. learn.pp文件夹中,我们将创建一个名为learnpp的虚拟环境。

一些开发人员更喜欢使用相同的名称来调用所有虚拟环境(例如.venv)。这样他们就可以通过知道项目名称来运行脚本来针对任何虚拟环境。.venv中的点是因为在 Linux/macOS 中,用点作为名称的前缀会使该文件或文件夹变为不可见。

  1. 创建虚拟环境后,我们将激活它。在 Linux、macOS 和 Windows 之间的方法略有不同。

  2. 然后,我们将通过运行 Python 交互式 shell 来确保我们正在运行所需的 Python 版本(3.7.*)。

  3. 最后,我们将使用deactivate命令取消激活虚拟环境。

这五个简单的步骤将向您展示启动和使用项目所需做的一切。

以下是这些步骤可能的示例(请注意,根据您的操作系统、Python 版本等,您可能会得到略有不同的结果)在 macOS 上(以#开头的命令是注释,空格是为了可读性引入的,表示由于空间不足而换行的位置):

fabmp:srv fab$ # step 1 - create folder
fabmp:srv fab$ mkdir learn.pp
fabmp:srv fab$ cd learn.pp

fabmp:learn.pp fab$ # step 2 - create virtual environment
fabmp:learn.pp fab$ which python3.7
/Users/fab/.pyenv/shims/python3.7
fabmp:learn.pp fab$ virtualenv -p
⇢ /Users/fab/.pyenv/shims/python3.7 learnpp
Running virtualenv with interpreter /Users/fab/.pyenv/shims/python3.7
Using base prefix '/Users/fab/.pyenv/versions/3.7.0a3'
New python executable in /Users/fab/srv/learn.pp/learnpp/bin/python3.7
Also creating executable in /Users/fab/srv/learn.pp/learnpp/bin/python
Installing setuptools, pip, wheel...done.

fabmp:learn.pp fab$ # step 3 - activate virtual environment
fabmp:learn.pp fab$ source learnpp/bin/activate

(learnpp) fabmp:learn.pp fab$ # step 4 - verify which python
(learnpp) fabmp:learn.pp fab$ which python
/Users/fab/srv/learn.pp/learnpp/bin/python

(learnpp) fabmp:learn.pp fab$ python
Python 3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> exit()

(learnpp) fabmp:learn.pp fab$ # step 5 - deactivate
(learnpp) fabmp:learn.pp fab$ deactivate
fabmp:learn.pp fab$

请注意,我不得不明确告诉 virtualenv 使用 Python 3.7 解释器,因为在我的系统上 Python 2.7 是默认的。如果我没有这样做,我将得到一个带有 Python 2.7 而不是 Python 3.7 的虚拟环境。

您可以将步骤 2 的两条指令合并为一条命令,如下所示:

$ virtualenv -p $( which python3.7 ) learnpp

在这种情况下,我选择明确详细地解释,以帮助您理解每个步骤。

另一件需要注意的事情是,为了激活虚拟环境,我们需要运行/bin/activate脚本,这需要被源化。当脚本被源化时,意味着它在当前 shell 中执行,因此其效果在执行后持续存在。这非常重要。还要注意,在激活虚拟环境后提示符的变化,左边显示了其名称(以及在取消激活时它是如何消失的)。在 Linux 上,步骤是相同的,所以我不会在这里重复。在 Windows 上,事情略有变化,但概念是相同的。请参考官方 virtualenv 网站以获取指导。

此时,您应该能够创建和激活虚拟环境。请尝试在没有我的指导下创建另一个。熟悉这个过程,因为这是您将一直在做的事情:我们永远不会在系统范围内使用 Python,记住?这非常重要。

因此,一旦搭建完成,我们就准备好更多地谈论 Python 以及您如何使用它。在我们这样做之前,让我简要谈一下控制台。

你的朋友,控制台

在这个 GUI 和触摸屏设备的时代,当一切都只是一个点击之遥时,似乎有点荒谬要求使用控制台这样的工具。

但事实是,每次你把右手从键盘上移开(或者左手,如果你是左撇子),拿鼠标并移动光标到你想点击的位置,你都在浪费时间。用控制台完成任务,尽管可能有些违反直觉,但会提高生产力和速度。我知道,你得相信我。

速度和生产力很重要,就我个人而言,我并不反对鼠标,但还有另一个非常好的理由,你可能想要熟悉控制台:当你开发的代码最终部署到某个服务器上时,控制台可能是唯一可用的工具。如果你和它交朋友,我向你保证,当你不得不迅速调查网站崩溃时,你绝对不会迷失方向。

所以这真的取决于你。如果你还没有决定,请给我一点怀疑的余地,试一试。比你想象的要容易,你绝不会后悔。没有什么比一个优秀的开发人员因为习惯了自己的一套定制工具而在 SSH 连接到服务器时迷失更令人遗憾。

现在,让我们回到 Python。

你可以如何运行 Python 程序

有几种不同的方法可以运行 Python 程序。

运行 Python 脚本

Python 可以用作脚本语言。事实上,它总是非常有用。脚本是文件(通常很小),通常用来执行某项任务。许多开发人员最终都会拥有自己的工具库,需要时就会使用。例如,你可以有脚本来解析数据并将其呈现为另一种不同的格式。或者你可以使用脚本来处理文件和文件夹。你可以创建或修改配置文件,等等。从技术上讲,几乎没有什么是脚本无法完成的。

在服务器上定时运行脚本是非常常见的。例如,如果你的网站数据库每 24 小时需要清理一次(例如,存储用户会话的表,这些会话很快就会过期,但不会自动清理),你可以设置一个 Cron 作业,每天凌晨 3 点运行你的脚本。

根据维基百科,软件实用程序 Cron 是 Unix 类计算机操作系统中基于时间的作业调度程序。设置和维护软件环境的人使用 Cron 来安排作业(命令或 shell 脚本)定期在固定的时间、日期或间隔运行。

我有 Python 脚本来完成所有那些如果手动完成会花费我几分钟甚至更长时间的琐碎任务,最终我决定自动化。我们将在第十二章 GUIs and Scripts中的 Python 脚本部分进行讨论。

运行 Python 交互式 shell

另一种运行 Python 的方法是调用交互式 shell。这是我们在控制台命令行中输入python时已经看到的东西。

所以,打开控制台,激活你的虚拟环境(现在应该已经成为你的第二天性了,对吧?),然后输入python。你会看到几行文字,应该是这样的:

$ python
Python 3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

那些>>>是 shell 的提示符。它告诉你 Python 正在等待你输入。如果你输入一个简单的指令,一行就能搞定,你会看到这个。但是,如果你输入的是需要多行代码的东西,shell 会把提示符改成...,这样你就知道你正在输入多行语句(或者需要多行代码的任何东西)。

继续,试一试吧;让我们做一些基本的数学:

>>> 2 + 4
6
>>> 10 / 4
2.5
>>> 2 ** 1024
179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216

最后的操作向你展示了一些令人难以置信的东西。我们计算21024次方,Python 毫不费力地完成了这个任务。试着在 Java、C++或 C#中做这个,不会成功,除非你使用特殊的库来处理这么大的数字。

我每天都使用交互式外壳。它非常有用,可以快速调试,例如,检查数据结构是否支持某个操作。或者检查或运行一段代码。

当您使用 Django(一个 Web 框架)时,交互式外壳与之耦合,并允许您通过框架工具,检查数据库中的数据等方式来工作。您会发现交互式外壳很快会成为您在即将开始的旅程中最亲密的朋友之一。

另一个解决方案,以更美观的图形布局呈现,是使用集成开发环境(IDE)。这是一个相当简单的 IDE,主要面向初学者。它具有比控制台中获得的裸交互式外壳更多的功能,因此您可能希望探索它。它在 Windows Python 安装程序中免费提供,并且您可以轻松在任何其他系统中安装它。您可以在 Python 网站上找到有关它的信息。

Guido Van Rossum 以英国喜剧团体 Monty Python 的名字命名了 Python,因此有传言称 IDLE 的名字是为了纪念 Monty Python 的创始成员之一 Eric Idle 而选择的。

作为服务运行 Python

除了作为脚本运行,并且在 shell 的边界内,Python 还可以编码并作为应用程序运行。我们将在本书中看到许多关于这种模式的示例。我们将在稍后讨论 Python 代码是如何组织和运行的时候更多地了解它。

作为 GUI 应用程序运行的 Python

Python 也可以作为图形用户界面(GUI)运行。有几个可用的框架,其中一些是跨平台的,另一些是特定于平台的。在第十二章《GUI 和脚本》中,我们将看到使用 Tkinter 创建的 GUI 应用程序的示例,Tkinter 是一个面向对象的层,位于 Tk(Tkinter 表示 Tk 界面)之上。

Tk 是一个 GUI 工具包,它将桌面应用程序开发提升到比传统方法更高的水平。它是Tool Command Language(Tcl)的标准 GUI,但也是许多其他动态语言的标准 GUI,并且可以生成在 Windows、Linux、macOS X 等系统下无缝运行的丰富本机应用程序。

Tkinter 与 Python 捆绑在一起;因此,它为程序员提供了轻松访问 GUI 世界的便利,并且出于这些原因,我选择它作为我将在本书中呈现的 GUI 示例的框架。

在其他 GUI 框架中,我们发现以下是最广泛使用的:

  • PyQt

  • wxPython

  • PyGTK

详细描述它们超出了本书的范围,但您可以在 Python 网站上找到所有您需要的信息(https://docs.python.org/3/faq/gui.html)在“Python 存在哪些平台无关的 GUI 工具包?”部分。如果 GUI 是您要寻找的内容,请记住根据一些原则选择您想要的。确保它们:

  • 提供您可能需要开发项目的所有功能

  • 在您可能需要支持的所有平台上运行

  • 依赖于尽可能广泛和活跃的社区

  • 包装图形驱动程序/工具,您可以轻松安装/访问

Python 代码是如何组织的?

让我们稍微谈谈 Python 代码是如何组织的。在本节中,我们将开始更深入地探讨一些技术名称和概念。

从基础开始,Python 代码是如何组织的?当然,您将代码写入文件中。当您使用扩展名.py保存文件时,该文件被称为 Python 模块。

如果您使用的是通常会向用户隐藏文件扩展名的 Windows 或 macOS,请确保更改配置,以便您可以看到完整的文件名。这不是严格要求,而是一个建议。

将所有软件工作所需的代码保存在一个文件中是不切实际的。这种解决方案适用于脚本,通常不超过几百行(而且通常要比这短得多)。

一个完整的 Python 应用程序可能由数十万行代码组成,因此你将不得不将它分散到不同的模块中,这样做更好,但还不够好。事实证明,即使像这样,使用这些代码仍然是不切实际的。因此,Python 给了你另一个结构,称为,它允许你将模块组合在一起。包实际上就是一个文件夹,其中必须包含一个特殊的文件__init__.py,它不需要包含任何代码,但其存在是必需的,以告诉 Python 这个文件夹不仅仅是一个文件夹,而实际上是一个包(需要注意的是,从 Python 3.3 开始,__init__.py模块不再是严格必需的)。

就像往常一样,举个例子会让这一切更加清晰。我在我的书项目中创建了一个示例结构,当我在控制台中输入时:

$ tree -v example

我得到了ch1/example文件夹内容的树形表示,其中包含本章示例的代码。一个非常简单应用程序的结构可能如下所示:

example
├── core.py
├── run.py
└── util
 ├── __init__.py
 ├── db.py
 ├── math.py
 └── network.py

你可以看到在这个示例的根目录中,我们有两个模块,core.pyrun.py,还有一个包:util。在core.py中,可能包含我们应用程序的核心逻辑。另一方面,在run.py模块中,我们可能会找到启动应用程序的逻辑。在util包中,我希望找到各种实用工具,实际上,我们可以猜到那里的模块是根据它们所持有的工具类型命名的:db.py将持有与数据库工作相关的工具,math.py当然将持有数学工具(也许我们的应用程序处理财务数据),network.py可能将持有在网络上发送/接收数据的工具。

如前所述,__init__.py文件只是告诉 Pythonutil是一个包,而不仅仅是一个普通的文件夹。

如果这个软件只是在模块中组织,要推断它的结构将会更加困难。我在ch1/files_only文件夹下放了一个只有模块的例子;你自己看看吧:

$ tree -v files_only

这给我们展示了一个完全不同的画面:

files_only/
├── core.py
├── db.py
├── math.py
├── network.py
└── run.py

猜测每个模块的功能可能有点困难,对吧?现在,考虑到这只是一个简单的例子,你可以猜想如果我们不能将代码组织成包和模块,要理解一个真实应用程序会有多困难。

我们如何使用模块和包?

当开发人员编写应用程序时,很可能需要在不同的部分应用相同的逻辑。例如,当编写一个解析器来解析来自用户可以在网页上填写的表单的数据时,应用程序将需要验证某个字段是否包含数字。无论这种验证逻辑是如何编写的,它很可能会在多个地方被需要。

例如,在一个调查应用程序中,用户被问及许多问题,很可能其中几个问题需要一个数字答案。例如:

  • 你的年龄是多少?

  • 你拥有多少宠物?

  • 你有多少个孩子?

  • 你结婚了多少次?

在我们期望得到一个数字答案的每个地方复制/粘贴(或者更准确地说:重复)验证逻辑是非常不好的做法。这将违反不要重复自己DRY)原则,该原则规定你在应用程序中不应该重复相同的代码片段。我感到有必要强调这一原则的重要性:你在应用程序中不应该重复相同的代码片段(这是双关语)。

重复相同逻辑的几个原因可能非常糟糕,最重要的原因有:

  • 逻辑可能存在错误,因此,您将不得不在应用逻辑的每个地方进行更正。

  • 您可能希望修改验证的方式,再次需要在应用它的每个地方进行更改。

  • 您可能会忘记修复/修改一个逻辑片段,因为在搜索所有出现时错过了它。这将在您的应用程序中留下错误/不一致的行为。

  • 您的代码会比需要的更长,没有好的理由。

Python 是一种很棒的语言,为您提供了应用所有编码最佳实践所需的所有工具。对于这个特定的例子,我们需要能够重用一段代码。为了能够重用一段代码,我们需要一个构造,它将为我们保存代码,以便我们可以在需要重复其中的逻辑时调用该构造。这个构造存在,它被称为函数

我在这里不会深入讨论具体内容,请记住函数是一块有组织的可重用代码,用于执行任务。函数可以根据它们所属的环境的不同形式和名称,但现在这并不重要。我们将在书中稍后能够欣赏它们时看到细节。函数是应用程序中模块化的构建块,几乎是不可或缺的。除非您正在编写一个超级简单的脚本,否则您将一直使用函数。我们将在第四章中探讨函数,函数,代码的构建块

Python 有一个非常广泛的库,就像我之前说过的那样。现在,也许是定义什么是库的好时机:是一组函数和对象的集合,提供丰富语言功能的功能。

例如,在 Python 的math库中,我们可以找到大量的函数,其中之一是factorial函数,它当然计算一个数字的阶乘。

在数学中,非负整数N阶乘,表示为N!,被定义为小于或等于N的所有正整数的乘积。例如,计算5的阶乘为:

5!= 5 * 4 * 3 * 2 * 1 = 120

0的阶乘是0!= 1,以尊重空乘积的约定。

因此,如果您想在代码中使用此函数,您只需导入它并使用正确的输入值调用它。如果输入值和调用的概念现在不太清楚,不要太担心;请专注于导入部分。我们通过从中导入所需的内容来使用库,然后使用它。

在 Python 中,要计算数字5的阶乘,我们只需要以下代码:

>>> from math import factorial
>>> factorial(5)
120

无论我们在 shell 中输入什么,只要它有可打印的表示,就会在控制台上为我们打印出来(在这种情况下,函数调用的结果:120)。

所以,让我们回到我们的例子,那个有core.pyrun.pyutil等等的例子。

在我们的例子中,包util是我们的实用库。我们自定义的实用工具包,其中包含我们应用程序中需要的所有可重用工具(即函数)。其中一些将处理数据库(db.py),一些将处理网络(network.py),一些将执行数学计算(math.py),这些都超出了 Python 标准math库的范围,因此我们必须自己编写它们。

我们将详细了解如何导入函数并在它们专用的章节中使用它们。现在让我们谈谈另一个非常重要的概念:Python 的执行模型

Python 的执行模型

在这一节中,我想向你介绍一些非常重要的概念,比如作用域、名称和命名空间。当然,你可以阅读官方语言参考手册中关于 Python 执行模型的所有内容,但我认为那相当技术和抽象,所以让我先给你一个不太正式的解释。

名称和命名空间

假设你正在寻找一本书,所以你去图书馆,向某人询问你想要取的书。他们告诉你类似于二楼,X 区,第三排。所以你上楼梯,寻找 X 区,依此类推。

如果进入一个图书馆,那里所有的书都堆在一起,没有顺序地放在一个大房间里,那将会非常不同。没有楼层,没有区域,没有排,没有顺序。找一本书将会非常困难。

当我们编写代码时,我们有同样的问题:我们必须尝试组织它,以便对于没有先验知识的人来说,能够轻松找到他们正在寻找的东西。当软件结构正确时,它也促进了代码的重用。另一方面,组织混乱的软件更有可能暴露分散的重复逻辑片段。

首先,让我们从书开始。我们通过它的标题来引用一本书,在 Python 术语中,那将是一个名称。Python 名称是其他语言称为变量的最接近的抽象。名称基本上是指对象,并且是通过名称绑定操作引入的。让我们举个快速的例子(请注意,跟在#后面的任何内容都是注释):

>>> n = 3  # integer number
>>> address = "221b Baker Street, NW1 6XE, London"  # Sherlock Holmes' address
>>> employee = {
...     'age': 45,
...     'role': 'CTO',
...     'SSN': 'AB1234567',
... }
>>> # let's print them
>>> n
3
>>> address
'221b Baker Street, NW1 6XE, London'
>>> employee
{'age': 45, 'role': 'CTO', 'SSN': 'AB1234567'}
>>> other_name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'other_name' is not defined 

我们在前面的代码中定义了三个对象(你还记得每个 Python 对象具有的三个特征是什么吗?):

  • 一个整数n(类型:int,值:3

  • 一个字符串address(类型:str,值:福尔摩斯的地址)

  • 一个字典employee(类型:dict,值:包含三个键/值对的字典)

不要担心,我知道你不应该知道什么是字典。我们将在第二章中看到,内置数据类型,它是 Python 数据结构的王者。

你是否注意到,当我输入 employee 的定义时,提示从>>>变成了...?那是因为定义跨越了多行。

那么,naddressemployee是什么?它们是名称。我们可以使用这些名称在我们的代码中检索数据。它们需要被保存在某个地方,这样每当我们需要检索这些对象时,我们可以使用它们的名称来获取它们。我们需要一些空间来保存它们,因此:命名空间!

命名空间因此是从名称到对象的映射。例如内置名称集(包含在任何 Python 程序中始终可访问的函数)、模块中的全局名称和函数中的局部名称。甚至对象的属性集也可以被视为命名空间。

命名空间的美妙之处在于它们允许您以清晰的方式定义和组织您的名称,而不会重叠或干扰。例如,与我们在图书馆寻找的那本书相关联的命名空间可以用来导入书本本身,就像这样:

from library.second_floor.section_x.row_three import book 

我们从library命名空间开始,通过点(.)运算符,我们进入该命名空间。在这个命名空间中,我们寻找second_floor,再次使用.运算符进入它。然后我们进入section_x,最后在最后一个命名空间row_three中,我们找到了我们要找的名称:book

当我们处理真实的代码示例时,通过命名空间的遍历将更加清晰。现在,只需记住命名空间是名称与对象相关联的地方。

还有一个概念,与命名空间的概念密切相关,我想简要谈谈:作用域

作用域

根据 Python 的文档:

作用域是 Python 程序的文本区域,其中命名空间是直接可访问的。”

直接可访问意味着当你寻找一个未经修饰的名称引用时,Python 会尝试在命名空间中找到它。

作用域是静态确定的,但实际上在运行时是动态使用的。这意味着通过检查源代码,你可以知道对象的作用域是什么,但这并不阻止软件在运行时改变它。Python 提供了四种不同的作用域(当然不一定同时都存在):

  • local作用域,是最内层的作用域,包含局部名称。

  • enclosing作用域,即任何封闭函数的作用域。它包含非局部名称和非全局名称。

  • global作用域包含全局名称。

  • built-in作用域包含内置名称。Python 带有一组函数,可以直接使用,例如printallabs等。它们存在于内置作用域中。

规则是:当我们引用一个名称时,Python 会从当前命名空间开始查找它。如果找不到该名称,Python 会继续搜索封闭作用域,直到搜索内置作用域。如果在搜索内置作用域后仍然找不到名称,那么 Python 会引发一个NameError异常,这基本上意味着该名称未被定义(你在前面的例子中看到了这一点)。

因此,在查找名称时,命名空间的扫描顺序是:localenclosingglobalbuilt-inLEGB)。

这些都是非常理论性的,所以让我们看一个例子。为了向你展示局部和封闭命名空间,我将不得不定义一些函数。如果你暂时不熟悉它们的语法,不要担心。我们将在第四章中学习函数,函数,代码的构建块。只要记住,在下面的代码中,当你看到def时,这意味着我正在定义一个函数:

# scopes1.py
# Local versus Global

# we define a function, called local
def local():
    m = 7
    print(m)

m = 5
print(m)

# we call, or `execute` the function local
local()

在前面的例子中,我们在全局作用域和局部作用域(由local函数定义的作用域)中定义了相同的名称m。当我们使用以下命令执行此程序时(你已经激活了你的虚拟环境吗?):

$ python scopes1.py

我们在控制台上看到两个数字打印出来:57

发生的情况是,Python 解释器从文件顶部向下解析文件。首先,它找到了一对注释行,然后解析了函数local的定义。当调用时,该函数执行两件事:它将一个名称设置为代表数字7的对象,并将其打印出来。Python 解释器继续前进,找到另一个名称绑定。这次绑定发生在全局作用域中,值为5。下一行是对print函数的调用,它被执行(因此我们在控制台上得到了第一个打印出来的值:5)。

之后,有一个对local函数的调用。此时,Python 执行该函数,因此此时发生了绑定m = 7并打印出来。

有一件非常重要的事情需要注意,那就是属于local函数定义的代码部分在右侧缩进了四个空格。实际上,Python 通过缩进代码来定义作用域。通过缩进进入作用域,通过取消缩进退出作用域。一些编码人员使用两个空格,其他人使用三个空格,但建议使用的空格数是四个。这是一个很好的措施,可以最大程度地提高可读性。我们稍后会谈论在编写 Python 代码时应该遵循的所有惯例。

如果我们删除了m = 7这一行会发生什么?记住 LEGB 规则。Python 会从局部作用域(函数local)开始查找m,如果找不到,它会继续到下一个封闭作用域。在这种情况下,下一个作用域是全局作用域,因为local周围没有封闭函数。因此,我们会在控制台上看到两个数字5打印出来。让我们实际看一下代码会是什么样子:

# scopes2.py
# Local versus Global

def local():
    # m doesn't belong to the scope defined by the local function
    # so Python will keep looking into the next enclosing scope.
    # m is finally found in the global scope
    print(m, 'printing from the local scope')

m = 5
print(m, 'printing from the global scope')

local()

运行scopes2.py将打印出这个:

$ python scopes2.py
5 printing from the global scope
5 printing from the local scope

正如预期的那样,Python 首次打印m,然后当调用函数local时,在它的作用域中找不到m,所以 Python 沿着 LEGB 链寻找,直到在全局作用域中找到m

让我们看一个带有额外层的例子,封闭作用域:

# scopes3.py
# Local, Enclosing and Global

def enclosing_func():
    m = 13

    def local():
        # m doesn't belong to the scope defined by the local
        # function so Python will keep looking into the next
        # enclosing scope. This time m is found in the enclosing
        # scope
        print(m, 'printing from the local scope')

    # calling the function local
    local()

m = 5
print(m, 'printing from the global scope')

enclosing_func()

运行scopes3.py将在控制台上打印:

$ python scopes3.py
(5, 'printing from the global scope')
(13, 'printing from the local scope')

正如你所看到的,函数local中的print指令仍然是之前的mm仍然没有在函数本身中定义,所以 Python 按照 LEGB 顺序遍历作用域。这次m在封闭作用域中找到了。

现在如果这还不是很清楚,不要担心。随着我们在书中的例子,你会慢慢理解的。Python 教程的Classes部分(docs.python.org/3/tutorial/classes.html)有一段有趣的关于作用域和命名空间的段落。如果你想更深入地理解这个主题,一定要在某个时候阅读它。

在我们结束本章之前,我想再多谈谈对象。毕竟,基本上 Python 中的一切都是对象,所以我认为它们值得更多的关注。

对象和类

当我在本章的A proper introduction部分介绍对象时,我说我们用它们来代表现实生活中的对象。例如,我们现在在网上销售各种商品,我们需要能够适当地处理、存储和表示它们。但对象实际上远不止于此。在 Python 中,你将要做的大部分事情都与操作对象有关。

因此,不要深入细节(我们将在第六章中做),我想给你一个关于类和对象的简明扼要的解释。

我们已经看到对象是 Python 对数据的抽象。事实上,Python 中的一切都是对象,包括数字,字符串(保存文本的数据结构),容器,集合,甚至函数。你可以把它们想象成至少具有三个特征的盒子:一个 ID(唯一),一个类型和一个值。

但是它们是如何产生的呢?我们如何创建它们?我们如何编写我们自己的自定义对象?答案就在一个简单的词中:classes

事实上,对象是类的实例。Python 的美妙之处在于类本身也是对象,但我们不要深入研究这一点。这会导致这种语言中最先进的概念之一:元类。现在,你理解类和对象之间的区别最好的方法是通过一个例子。

比如一个朋友告诉你,我买了一辆新自行车! 你立刻明白她在说什么。你看到了自行车吗?没有。你知道它是什么颜色吗?不知道。品牌呢?也不知道。你对它了解多少?一无所知。但与此同时,你知道你需要了解的一切,以理解你的朋友告诉你她买了一辆新自行车的意思。你知道自行车有两个轮子连接在一个框架上,有一个鞍座,脚踏板,把手,刹车等等。换句话说,即使你没有看到自行车本身,你知道自行车的概念。一组抽象的特征和特性共同形成了所谓的自行车

在计算机编程中,这就是所谓的class。就是这么简单。类用于创建对象。实际上,对象被称为类的实例

换句话说,我们都知道自行车是什么;我们知道这个类。但是我有自己的自行车,它是自行车类的一个实例。我的自行车是一个具有自己特征和方法的对象。你也有自己的自行车。同一个类,但不同的实例。世界上制造的每一辆自行车都是自行车类的一个实例。

让我们看一个例子。我们将编写一个定义自行车的类,然后我们将创建两辆自行车,一辆红色,一辆蓝色。我会保持代码非常简单,但如果你不完全理解它,不要担心;你现在需要关心的是理解类和对象(或类的实例)之间的区别:

# bike.py
# let's define the class Bike
class Bike:

    def __init__(self, colour, frame_material):
        self.colour = colour
        self.frame_material = frame_material

    def brake(self):
        print("Braking!")

# let's create a couple of instances
red_bike = Bike('Red', 'Carbon fiber')
blue_bike = Bike('Blue', 'Steel')

# let's inspect the objects we have, instances of the Bike class.
print(red_bike.colour)  # prints: Red
print(red_bike.frame_material)  # prints: Carbon fiber
print(blue_bike.colour)  # prints: Blue
print(blue_bike.frame_material)  # prints: Steel

# let's brake!
red_bike.brake()  # prints: Braking!

我希望到现在为止我不需要告诉你每次都要运行文件,对吧?代码块的第一行指示了文件名。只需运行$ python filename,你就没问题了。但记得要激活你的虚拟环境!

这里有很多有趣的事情要注意。首先,类的定义是通过class语句完成的。class语句之后的任何代码,并且缩进,被称为类的主体。在我们的例子中,属于类定义的最后一行是print("Braking!")

在定义了类之后,我们准备创建实例。你可以看到类主体中定义了两种方法。方法基本上(并且简单地)是属于类的函数。

第一个方法__init__是一个初始化器。它使用一些 Python 魔法来使用我们在创建时传递的值设置对象。

在 Python 中,每个具有前导和尾随双下划线的方法都被称为魔术方法。Python 使用魔术方法来实现多种不同的目的;因此,使用两个前导和尾随下划线命名自定义方法绝不是一个好主意。最好将这种命名约定留给 Python。

我们定义的另一种方法brake只是一个额外方法的示例,如果我们想要刹车,我们可以调用它。当然,它只包含一个print语句;这只是一个例子。

我们创建了两辆自行车。一辆是红色的,有碳纤维车架,另一辆是蓝色的,有钢车架。我们在创建时传递这些值。创建后,我们打印出红色自行车的颜色属性和车架类型,以及蓝色自行车的车架类型,只是一个例子。我们还调用了red_bikebrake方法。

最后要注意的一件事。你还记得我告诉过你对象的属性集被认为是一个命名空间吗?我希望现在我说的更清楚了。你可以看到通过不同的命名空间(red_bikeblue_bike)获取frame_type属性,我们得到不同的值。没有重叠,没有混淆。

点(.)运算符当然是我们用来进入命名空间的手段,对于对象也是如此。

如何编写良好代码的指南

编写良好的代码并不像看起来那么容易。正如我之前所说,良好的代码具有一长串相当难以组合的特质。在某种程度上,编写良好的代码是一种艺术。无论你愿意在哪个阶段停下来,有一件事可以让你的代码立即变得更好:PEP 8

根据维基百科:

“Python 的开发主要通过 Python Enhancement Proposal(PEP)过程进行。PEP 过程是提出重大新功能、收集社区对问题的意见以及记录 Python 设计决策的主要机制。”

PEP 8 可能是所有 PEP 中最著名的。它提出了一套简单但有效的指南,以定义 Python 的美学,使我们编写优美的 Python 代码。如果你从本章中得到一个建议,请让它成为这样:使用它。拥抱它。以后你会感谢我的。

今天的编码不再是简单的签入/签出业务。相反,它更像是一种社会努力。几个开发人员通过 Git 和 Mercurial 等工具共同协作一段代码,结果是由许多不同的手所创造的代码。

Git 和 Mercurial 可能是今天使用最多的分布式版本控制系统。它们是旨在帮助开发团队协作开发相同软件的基本工具。

如今,我们更需要有一种一致的编写代码的方式,以便最大限度地提高可读性。当公司的所有开发人员都遵守 PEP 8 时,他们中的任何一个人落在一段代码上时,都不会觉得这是别人写的。这实际上经常发生在我身上(我总是忘记我写的代码)。

这有一个巨大的优势:当你阅读自己可以写的代码时,你会很容易地阅读它。没有约定,每个编码者都会按照他们最喜欢的方式或者他们被教导或者习惯的方式来构建代码,这意味着必须根据别人的风格来解释每一行。这意味着必须花费更多的时间来理解它。由于 PEP 8,我们可以避免这种情况。我是它的忠实粉丝,如果代码不遵守它,我就不会签署代码审查。所以,请花时间学习它;这非常重要。

在本书的示例中,我会尽量尊重它。不幸的是,我没有 79 个字符的奢侈(这是 PEP 8 建议的最大行长度),我将不得不减少空行和其他东西,但我向你保证我会尽量布置我的代码,使其尽可能可读。

Python 文化

Python 已经被广泛应用于所有编码行业。许多不同的公司用于许多不同的目的,它也被用于教育(因为它的许多优点和易学性,它是一个非常出色的语言)。

Python 如今如此受欢迎的一个原因是其周围的社区是庞大、充满活力和充满才华的人。世界各地都组织了许多活动,主要是围绕 Python 或其主要的 Web 框架 Django。

Python 是开放的,而且经常接受它的人的思想也是开放的。在 Python 网站的社区页面上查看更多信息并参与其中!

Python 的另一个方面是围绕着Pythonic的概念。这与 Python 允许你使用一些在其他地方找不到的习语有关,至少不是以同样的形式或者不是那么容易使用(现在我在使用不是 Python 的语言编码时感到相当幽闭恐惧)。

无论如何,多年来,Pythonic 的概念已经出现了,我理解的方式是按照 Python 应该被完成的方式来做事

为了帮助你更好地了解 Python 的文化和 Pythonic 的含义,我将向你展示Python 之禅。这是一个非常受欢迎的可爱彩蛋。打开 Python 控制台,输入import this。接下来是这行代码的结果:

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

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

这里有两个阅读层次。一个是将其视为一组以有趣的方式提出的指导方针。另一个是牢记在心,并可能偶尔阅读一次,试图理解它是如何指涉更深层次的东西的:一些你必须深入理解的 Python 特性,以便按照它应该被编写的方式来编写 Python。从有趣的层次开始,然后深入挖掘。始终深入挖掘。

关于 IDE 的一点说明

关于 IDE 的一些话。为了跟随本书中的示例,你不需要一个;任何文本编辑器都可以。如果你想要更多高级功能,比如语法着色和自动补全,你就需要找一个 IDE。你可以在 Python 网站上找到一份全面的开源 IDE 列表(只需谷歌 Python IDEs)。我个人使用 Sublime 文本编辑器。它是免费试用的,只需几美元。我一生中尝试过许多 IDE,但这是让我最有效率的一个。

两个重要的建议:

  • 无论你选择使用哪种 IDE,都要努力学会它,以便能够充分利用它的优势,但不要依赖它。偶尔练习使用 VIM(或任何其他文本编辑器);学会在任何平台上使用任何一套工具进行工作。

  • 无论你使用什么文本编辑器/集成开发环境,在编写 Python 时,缩进是四个空格。不要使用制表符,也不要将它们与空格混合使用。使用四个空格,不是两个,不是三个,也不是五个。就使用四个。整个世界都是这样工作的,你不想因为你喜欢三个空格的布局而成为局外人。

总结

在本章中,我们开始探索编程和 Python 的世界。我们只是略微触及了一些概念,这些概念将在本书的后面更详细地讨论。

我们谈到了 Python 的主要特性,谁在使用它以及为什么使用它,以及我们可以用哪些不同的方式编写 Python 程序。

在本章的最后部分,我们简要介绍了命名空间、作用域、类和对象的基本概念。我们还看到了如何使用模块和包组织 Python 代码。

在实际操作中,我们学会了如何在系统上安装 Python,如何确保我们拥有所需的工具pip和 virtualenv,并创建并激活了我们的第一个虚拟环境。这将使我们能够在一个独立的环境中工作,而不会危及 Python 系统安装。

现在你已经准备好和我一起开始这段旅程了。你所需要的只是热情、一个激活的虚拟环境、这本书、你的手指和一些咖啡。

尝试跟着示例走;我会让它们简单而简短。如果你把它们放在指尖下,你会比仅仅阅读它们更好地记住它们。

在下一章中,我们将探索 Python 丰富的内置数据类型。有很多内容需要涵盖和学习!

第二章:内置数据类型

“数据!数据!数据!”他不耐烦地喊道。“没有黏土,我就无法制造砖块。”– 福尔摩斯 – 铜山毛榉的冒险

你在计算机上所做的一切都是在管理数据。数据有许多不同的形状和风味。这是你听的音乐,你流媒体的电影,你打开的 PDF。甚至你正在阅读的本章的来源只是一个文件,也就是数据。

数据可以是简单的,比如用整数表示年龄,也可以是复杂的,比如在网站上下的订单。它可以是关于单个对象或关于它们的集合。数据甚至可以是关于数据的,也就是元数据。描述其他数据结构的设计或描述应用程序数据或其上下文的数据。在 Python 中,对象是数据的抽象,Python 有各种各样的数据结构,你可以用它们来表示数据,或者组合它们来创建自己的自定义数据。

在这一章中,我们将涵盖以下内容:

  • Python 对象的结构

  • 可变性和不可变性

  • 内置数据类型:数字、字符串、序列、集合和映射类型

  • 集合模块

  • 枚举

一切都是对象

在我们深入具体内容之前,我希望你对 Python 中的对象非常清楚,所以让我们再多谈一点关于它们。正如我们已经说过的,Python 中的一切都是对象。但是当你在 Python 模块中输入age = 42这样的指令时,真正发生了什么呢?

如果你去pythontutor.com/,你可以在文本框中输入该指令并获得其可视化表示。记住这个网站;它对于巩固你对幕后发生的事情的理解非常有用。

因此,创建了一个对象。它得到了一个idtype设置为int(整数),value设置为42。一个名为age的名称被放置在全局命名空间中,指向该对象。因此,每当我们在全局命名空间中,在执行该行之后,我们可以通过简单地通过其名称访问它来检索该对象:age

如果你要搬家,你会把所有的刀、叉和勺子放在一个盒子里,贴上“餐具”的标签。你能看到这正是相同的概念吗?这是一个可能看起来像这样的屏幕截图(你可能需要调整设置以获得相同的视图):

因此,在本章的其余部分,每当你读到诸如name = some_value这样的内容时,想象一下一个放置在命名空间中的名称,它与写入该指令的范围相关联,并且有一个漂亮的箭头指向具有idtypevalue的对象。关于这个机制还有一些要说的,但是通过一个例子来谈论它要容易得多,所以我们稍后再回到这个问题。

可变还是不可变?这是个问题

Python 对数据所做的第一个基本区分是关于对象的值是否会改变。如果值可以改变,对象称为可变,而如果值不能改变,对象称为不可变

非常重要的是,你要理解可变和不可变之间的区别,因为它会影响你编写的代码,所以这里有一个问题:

>>> age = 42
>>> age
42
>>> age = 43  #A
>>> age
43

在前面的代码中,在#A行,我改变了 age 的值吗?嗯,没有。但现在是43(我听到你说...)。是的,是43,但42是一个整数,类型是int,是不可变的。因此,真正发生的是在第一行,age是一个名称,它被设置为指向一个int对象,其值为42。当我们输入age = 43时,真正发生的是创建了另一个对象,类型为int,值为43(此外,id将不同),并且名称age被设置为指向它。因此,我们并没有将42改为43。实际上,我们只是将age指向了一个不同的位置:值为43的新int对象。让我们看看相同的代码也打印出 ID:

>>> age = 42
>>> id(age)
4377553168
>>> age = 43
>>> id(age)
4377553200

请注意,我们通过调用内置的id函数打印了这些 ID。如你所见,它们是不同的,这是预期的。请记住,age一次只指向一个对象:首先是42,然后是43。从来不会同时存在。

现在,让我们看看使用可变对象的相同例子。对于这个例子,让我们只使用一个Person对象,它有一个age属性(现在不用担心类的声明;它只是为了完整起见):

>>> class Person():
...     def __init__(self, age):
...         self.age = age
...
>>> fab = Person(age=42)
>>> fab.age
42
>>> id(fab)
4380878496
>>> id(fab.age)
4377553168
>>> fab.age = 25  # I wish!
>>> id(fab)  # will be the same
4380878496
>>> id(fab.age)  # will be different
4377552624

在这种情况下,我设置了一个fab对象,它的typePerson(一个自定义类)。在创建时,对象被赋予42age。我打印它,以及对象的id,以及age的 ID。请注意,即使我将age更改为25fab的 ID 仍然保持不变(当然,age的 ID 已经改变了)。Python 中的自定义对象是可变的(除非你编写代码使它们不可变)。记住这个概念;这是非常重要的。我会在本章的其余部分提醒你。

数字

让我们从探索 Python 内置的数字数据类型开始。Python 是由一位数学和计算机科学硕士设计的,因此它对数字有很好的支持是合乎逻辑的。

数字是不可变对象。

整数

Python 整数具有无限范围,只受可用虚拟内存的限制。这意味着你想要存储的数字有多大并不重要:只要它能适应计算机的内存,Python 就会处理它。整数可以是正数、负数和 0(零)。它们支持所有基本的数学运算,如下例所示:

>>> a = 14
>>> b = 3
>>> a + b  # addition
17
>>> a - b  # subtraction
11
>>> a * b  # multiplication
42
>>> a / b  # true division
4.666666666666667
>>> a // b  # integer division
4
>>> a % b  # modulo operation (reminder of division)
2
>>> a ** b  # power operation
2744

前面的代码应该很容易理解。只要注意一件重要的事情:Python 有两个除法运算符,一个执行所谓的真除法(//),返回操作数的商,另一个是所谓的整数除法(//),返回操作数的向下取整商。值得注意的是,在 Python 2 中,除法运算符/的行为与 Python 3 中不同。看看对于正数和负数的不同之处:

>>> 7 / 4  # true division
1.75
>>> 7 // 4  # integer division, truncation returns 1
1
>>> -7 / 4  # true division again, result is opposite of previous
-1.75
>>> -7 // 4  # integer div., result not the opposite of previous
-2

这是一个有趣的例子。如果你在最后一行期望得到-1,不要感到难过,这只是 Python 的工作方式。在 Python 中,整数除法的结果总是向负无穷大舍入。如果你想要将一个数字截断为整数,而不是向下取整,你可以使用内置的int函数,如下例所示:

>>> int(1.75)
1
>>> int(-1.75)
-1

注意截断是朝着0进行的。

还有一个运算符可以计算除法的余数。它被称为模运算符,用百分号(%)表示:

>>> 10 % 3  # remainder of the division 10 // 3
1
>>> 10 % 4  # remainder of the division 10 // 4
2

Python 3.6 中引入的一个很好的特性是在数字文字中添加下划线的能力(在数字或基数指示符之间,但不是在前导或尾随)。目的是帮助使一些数字更易读,比如1_000_000_000

>>> n = 1_024
>>> n
1024
>>> hex_n = 0x_4_0_0  # 0x400 == 1024
>>> hex_n
1024 

布尔值

布尔代数是代数的一个子集,在其中变量的值是真值:真和假。在 Python 中,TrueFalse是两个关键字,用于表示真值。布尔值是整数的一个子类,分别像10一样行为。布尔值的等价于int类的是bool类,它返回TrueFalse。每个内置的 Python 对象在布尔上下文中都有一个值,这意味着当它们被传递给bool函数时,它们基本上会被评估为TrueFalse。我们将在第三章中详细了解这一切,迭代和做决定

布尔值可以使用逻辑运算符andornot组合成布尔表达式。我们将在下一章中详细介绍它们,所以现在让我们看一个简单的例子:

>>> int(True)  # True behaves like 1
1
>>> int(False)  # False behaves like 0
0
>>> bool(1)  # 1 evaluates to True in a boolean context
True
>>> bool(-42)  # and so does every non-zero number
True
>>> bool(0)  # 0 evaluates to False
False
>>> # quick peak at the operators (and, or, not)
>>> not True
False
>>> not False
True
>>> True and True
True
>>> False or True
True

你可以看到,当你尝试将TrueFalse相加时,它们是整数的子类。Python 将它们提升为整数并执行加法:

>>> 1 + True
2
>>> False + 42
42
>>> 7 - True
6 

向上转型是一种从子类到其父类的类型转换操作。在这里介绍的例子中,TrueFalse属于从整数类派生的类,当需要时会转换回整数。这个主题涉及继承,将在第六章 OOP, Decorators, and Iterators中详细解释。

实数

实数,或浮点数,根据 IEEE 754 双精度二进制浮点格式在 Python 中表示,该格式存储在 64 位信息中,分为三个部分:符号、指数和尾数。

在维基百科上了解有关这种格式的知识:en.wikipedia.org/wiki/Double-precision_floating-point_format

通常,编程语言给程序员提供两种不同的格式:单精度和双精度。前者占用 32 位内存,后者占用 64 位。Python 仅支持双精度格式。让我们看一个简单的例子:

>>> pi = 3.1415926536  # how many digits of PI can you remember?
>>> radius = 4.5
>>> area = pi * (radius ** 2)
>>> area
63.617251235400005 

在计算面积时,我在radius ** 2外面加了括号。尽管这并不是必要的,因为幂运算符的优先级高于乘法运算符,但我认为这样公式读起来更容易。此外,如果你对面积得到了稍微不同的结果,不要担心。这可能取决于你的操作系统,Python 是如何编译的等等。只要前几位小数正确,你就知道这是正确的结果。

sys.float_info结构序列包含有关浮点数在您的系统上的行为的信息。这是我在我的电脑上看到的:

>>> import sys
>>> sys.float_info
sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308, min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307, dig=15, mant_dig=53, epsilon=2.220446049250313e-16, radix=2, rounds=1)

让我们在这里做一些考虑:我们有 64 位来表示浮点数。这意味着我们最多可以用2 ** 64 == 18,446,744,073,709,551,616个数字来表示这些位数。查看浮点数的maxepsilon值,你会意识到不可能表示它们所有。空间不够,因此它们被近似到最接近的可表示数字。你可能认为只有极大或极小的数字才会受到这个问题的影响。好吧,再想一想,尝试在你的控制台上输入以下内容:

>>> 0.3 - 0.1 * 3  # this should be 0!!!
-5.551115123125783e-17 

这告诉你什么?它告诉你,双精度数甚至在处理简单的数字如0.10.3时也会受到近似问题的影响。为什么这很重要?如果你处理价格、金融计算或任何不需要近似的数据,这可能是一个大问题。不用担心,Python 给了你decimal类型,它不会受到这些问题的影响;我们马上就会看到它们。

复数

Python 为您提供了复数支持。如果你不知道什么是复数,它们是可以用a + ib的形式表示的数字,其中ab是实数,i(或者如果你是工程师,是j)是虚数单位,即-1的平方根。ab分别被称为数字的实部虚部

实际上,你可能不会经常使用它们,除非你在编写科学代码。让我们看一个小例子:

>>> c = 3.14 + 2.73j
>>> c.real  # real part
3.14
>>> c.imag  # imaginary part
2.73
>>> c.conjugate()  # conjugate of A + Bj is A - Bj
(3.14-2.73j)
>>> c * 2  # multiplication is allowed
(6.28+5.46j)
>>> c ** 2  # power operation as well
(2.4067000000000007+17.1444j)
>>> d = 1 + 1j  # addition and subtraction as well
>>> c - d
(2.14+1.73j)

分数和小数

让我们结束数字部分的介绍,看一看分数和小数。分数以最简形式保存有理数的分子和分母。让我们看一个快速的例子:

>>> from fractions import Fraction
>>> Fraction(10, 6)  # mad hatter?
Fraction(5, 3)  # notice it's been simplified
>>> Fraction(1, 3) + Fraction(2, 3)  # 1/3 + 2/3 == 3/3 == 1/1
Fraction(1, 1)
>>> f = Fraction(10, 6)
>>> f.numerator
5
>>> f.denominator
3

尽管它们有时可能非常有用,但在商业软件中很少见。相反,更容易看到小数被用在所有那些需要精度的情境中;例如,在科学和金融计算中。

重要的是要记住,任意精度的十进制数当然会影响性能。每个数字要存储的数据量远远大于分数或浮点数,以及它们的处理方式,这会导致 Python 解释器在幕后做更多的工作。另一个有趣的事情是,您可以通过访问decimal.getcontext().prec来获取和设置精度。

让我们用十进制数看一个快速的例子:

>>> from decimal import Decimal as D  # rename for brevity
>>> D(3.14)  # pi, from float, so approximation issues
Decimal('3.140000000000000124344978758017532527446746826171875')
>>> D('3.14')  # pi, from a string, so no approximation issues
Decimal('3.14')
>>> D(0.1) * D(3) - D(0.3)  # from float, we still have the issue
Decimal('2.775557561565156540423631668E-17')
>>> D('0.1') * D(3) - D('0.3')  # from string, all perfect
Decimal('0.0')
>>> D('1.4').as_integer_ratio()  # 7/5 = 1.4 (isn't this cool?!)
(7, 5)

请注意,当我们从float构造一个Decimal数字时,它会带有所有可能出现的近似问题。另一方面,当Decimal没有近似问题时(例如,当我们将intstring表示传递给构造函数时),则计算没有古怪的行为。在涉及货币时,请使用小数。

这结束了我们对内置数字类型的介绍。现在让我们来看看序列。

不可变序列

让我们从不可变序列开始:字符串、元组和字节。

字符串和字节

Python 中的文本数据是使用str对象处理的,更常见的是字符串。它们是Unicode 代码点的不可变序列。Unicode 代码点可以表示一个字符,但也可以有其他含义,例如格式化数据。与其他语言不同,Python 没有char类型,因此单个字符只是一个长度为1的字符串。

Unicode 是处理数据的一种出色方式,应该用于任何应用程序的内部。但是,当涉及存储文本数据或在网络上传输文本数据时,您可能希望对其进行编码,使用适合您使用的介质的适当编码。编码的结果会产生一个bytes对象,其语法和行为类似于字符串。Python 中的字符串文字使用单引号、双引号或三引号(单引号或双引号)编写。如果使用三引号构建,字符串可以跨多行。一个例子将澄清这一点:

>>> # 4 ways to make a string
>>> str1 = 'This is a string. We built it with single quotes.'
>>> str2 = "This is also a string, but built with double quotes."
>>> str3 = '''This is built using triple quotes,
... so it can span multiple lines.'''
>>> str4 = """This too
... is a multiline one
... built with triple double-quotes."""
>>> str4  #A
'This too\nis a multiline one\nbuilt with triple double-quotes.'
>>> print(str4)  #B
This too
is a multiline one
built with triple double-quotes.

#A#B中,我们打印str4,首先是隐式地,然后是显式地,使用print函数。一个很好的练习是找出它们为什么不同。您敢挑战吗?(提示:查找str函数。)

字符串,像任何序列一样,都有一个长度。您可以通过调用len函数来获得这个长度:

>>> len(str1)
49

编码和解码字符串

使用encode/decode方法,我们可以对 Unicode 字符串进行编码和解码字节对象。UTF-8是一种可变长度的字符编码,能够编码所有可能的 Unicode 代码点。它是网络的主要编码。还要注意,通过在字符串声明前面添加文字b,我们正在创建一个字节对象:

>>> s = "This is üŋíc0de"  # unicode string: code points
>>> type(s)
<class 'str'>
>>> encoded_s = s.encode('utf-8')  # utf-8 encoded version of s
>>> encoded_s
b'This is \xc3\xbc\xc5\x8b\xc3\xadc0de'  # result: bytes object
>>> type(encoded_s)  # another way to verify it
<class 'bytes'>
>>> encoded_s.decode('utf-8')  # let's revert to the original
'This is üŋíc0de'
>>> bytes_obj = b"A bytes object"  # a bytes object
>>> type(bytes_obj)
<class 'bytes'>

索引和切片字符串

在操作序列时,非常常见的是必须在一个精确的位置访问它们(索引),或者从它们中获取一个子序列(切片)。在处理不可变序列时,这两种操作都是只读的。

虽然索引以一种形式出现,即零为基础的访问序列中的任何位置,但切片以不同的形式出现。当您获取序列的一部分时,可以指定startstop位置以及step。它们用冒号(:)分隔,就像这样:my_sequence[start:stop:step]。所有参数都是可选的,start是包含的,stop是排他的。最好通过示例来展示,而不是用更多的文字来解释它们:

>>> s = "The trouble is you think you have time."
>>> s[0]  # indexing at position 0, which is the first char
'T'
>>> s[5]  # indexing at position 5, which is the sixth char
'r'
>>> s[:4]  # slicing, we specify only the stop position
'The '
>>> s[4:]  # slicing, we specify only the start position
'trouble is you think you have time.'
>>> s[2:14]  # slicing, both start and stop positions
'e trouble is'
>>> s[2:14:3]  # slicing, start, stop and step (every 3 chars)
'erb '
>>> s[:]  # quick way of making a copy
'The trouble is you think you have time.'

在所有行中,最后一行可能是最有趣的。如果您不指定参数,Python 将为您填充默认值。在这种情况下,start将是字符串的开头,stop将是字符串的结尾,step将是默认值1。这是一种轻松快速地获取字符串s的副本的方法(相同的值,但不同的对象)。您能找到一种使用切片获取字符串的反向副本的方法吗(不要查找,自己找找)?

字符串格式化

字符串具有的一个特性是可以用作模板。有几种不同的格式化字符串的方法,对于所有可能性的完整列表,我鼓励您查阅文档。以下是一些常见的例子:

>>> greet_old = 'Hello %s!'
>>> greet_old % 'Fabrizio'
'Hello Fabrizio!'
>>> greet_positional = 'Hello {} {}!'
>>> greet_positional.format('Fabrizio', 'Romano')
'Hello Fabrizio Romano!' 
>>> greet_positional_idx = 'This is {0}! {1} loves {0}!'
>>> greet_positional_idx.format('Python', 'Fabrizio')
'This is Python! Fabrizio loves Python!'
>>> greet_positional_idx.format('Coffee', 'Fab')
'This is Coffee! Fab loves Coffee!'
 >>> keyword = 'Hello, my name is {name} {last_name}'
>>> keyword.format(name='Fabrizio', last_name='Romano')
'Hello, my name is Fabrizio Romano'

在上一个例子中,您可以看到四种不同的格式化字符串的方法。第一种依赖于%运算符,已经被弃用,不应再使用。格式化字符串的当前、现代方式是使用format字符串方法。从不同的例子中可以看出,一对大括号在字符串中充当占位符。当我们调用format时,我们提供替换占位符的数据。我们可以在大括号中指定索引(以及更多),甚至名称,这意味着我们将不得不使用关键字参数而不是位置参数来调用format

注意greet_positional_idx通过向format调用提供不同的数据而呈现出不同的方式。显然,我喜欢 Python 和咖啡...大惊喜!

我想向您展示的最后一个特性是 Python 的一个相对较新的添加(版本 3.6),它被称为格式化字符串文字。这个特性非常酷:字符串以f为前缀,并包含用大括号括起来的替换字段。替换字段是在运行时评估的表达式,然后使用format协议进行格式化:

>>> name = 'Fab'
>>> age = 42
>>> f"Hello! My name is {name} and I'm {age}"
"Hello! My name is Fab and I'm 42"
>>> from math import pi
>>> f"No arguing with {pi}, it's irrational..."
"No arguing with 3.141592653589793, it's irrational..."

查看官方文档,了解有关字符串格式化以及它的强大功能的一切。

元组

我们将看到的最后一个不可变序列类型是元组。元组是任意 Python 对象的序列。在元组中,项目用逗号分隔。它们在 Python 中随处可见,因为它们允许在其他语言中难以复制的模式。有时元组被隐式使用;例如,一次设置多个变量,或者允许函数返回多个不同的对象(通常函数只返回一个对象,在许多其他语言中),甚至在 Python 控制台中,您可以隐式使用元组以一条指令打印多个元素。我们将看到所有这些情况的例子:

>>> t = ()  # empty tuple
>>> type(t)
<class 'tuple'>
>>> one_element_tuple = (42, )  # you need the comma!
>>> three_elements_tuple = (1, 3, 5)  # braces are optional here
>>> a, b, c = 1, 2, 3  # tuple for multiple assignment
>>> a, b, c  # implicit tuple to print with one instruction
(1, 2, 3)
>>> 3 in three_elements_tuple  # membership test
True

注意,成员运算符in也可以与列表、字符串、字典以及一般的集合和序列对象一起使用。

注意,要创建一个只有一个项目的元组,我们需要在项目后面加上逗号。原因是,没有逗号,该项目只是自己包裹在大括号中,有点像冗余的数学表达式。还要注意,赋值时,大括号是可选的,所以my_tuple = 1, 2, 3my_tuple = (1, 2, 3)是一样的。

元组赋值允许我们一行交换,不需要第三个临时变量。让我们首先看一种更传统的方法:

>>> a, b = 1, 2
>>> c = a  # we need three lines and a temporary var c
>>> a = b
>>> b = c
>>> a, b  # a and b have been swapped
(2, 1)

现在让我们看看我们如何在 Python 中做到这一点:

>>> a, b = 0, 1
>>> a, b = b, a  # this is the Pythonic way to do it
>>> a, b
(1, 0)

看一下显示 Python 交换两个值的 Pythonic 方式。你还记得我在第一章中写的吗?Python 程序通常只有等价的 Java 或 C++代码的五分之一到三分之一大小,像一行交换这样的特性有助于实现这一点。Python 是优雅的,这里的优雅也意味着经济。

由于它们是不可变的,元组可以用作字典的键(我们很快就会看到这一点)。对我来说,元组是 Python 内置数据,最接近数学向量的。这并不意味着这就是它们被创建的原因。元组通常包含异构序列的元素,而另一方面,列表大多数情况下是同构的。此外,元组通常通过解包或索引访问,而列表通常是迭代的。

可变序列

可变序列与它们的不可变姐妹们的不同之处在于它们可以在创建后进行更改。Python 中有两种可变序列类型:列表和字节数组。我之前说过字典是 Python 中数据结构的王者。我猜这使得列表成为它合法的女王。

列表

Python 列表是可变序列。它们与元组非常相似,但没有不可变性的限制。列表通常用于存储同类对象的集合,但没有什么阻止你存储异类集合。列表可以用许多不同的方式创建。让我们看一个例子:

>>> []  # empty list
[]
>>> list()  # same as []
[]
>>> [1, 2, 3]  # as with tuples, items are comma separated
[1, 2, 3]
>>> [x + 5 for x in [2, 3, 4]]  # Python is magic
[7, 8, 9]
>>> list((1, 3, 5, 7, 9))  # list from a tuple
[1, 3, 5, 7, 9]
>>> list('hello')  # list from a string
['h', 'e', 'l', 'l', 'o']

在前面的例子中,我向你展示了如何使用不同的技术创建列表。我希望你仔细看一下那一行,上面写着Python is magic,我不指望你现在完全理解它(除非你作弊了,你不是新手!)。这被称为列表推导,是 Python 非常强大的函数特性,我们将在第五章中详细讨论,节省时间和内存。我只是想在这一点上让你垂涎三尺。

创建列表是好的,但真正有趣的是当我们使用它们时,所以让我们看看它们赋予我们的主要方法:

>>> a = [1, 2, 1, 3]
>>> a.append(13)  # we can append anything at the end
>>> a
[1, 2, 1, 3, 13]
>>> a.count(1)  # how many `1` are there in the list?
2
>>> a.extend([5, 7])  # extend the list by another (or sequence)
>>> a
[1, 2, 1, 3, 13, 5, 7]
>>> a.index(13)  # position of `13` in the list (0-based indexing)
4
>>> a.insert(0, 17)  # insert `17` at position 0
>>> a
[17, 1, 2, 1, 3, 13, 5, 7]
>>> a.pop()  # pop (remove and return) last element
7
>>> a.pop(3)  # pop element at position 3
1
>>> a
[17, 1, 2, 3, 13, 5]
>>> a.remove(17)  # remove `17` from the list
>>> a
[1, 2, 3, 13, 5]
>>> a.reverse()  # reverse the order of the elements in the list
>>> a
[5, 13, 3, 2, 1]
>>> a.sort()  # sort the list
>>> a
[1, 2, 3, 5, 13]
>>> a.clear()  # remove all elements from the list
>>> a
[]

前面的代码为你提供了列表主要方法的概述。我想向你展示它们有多强大,以extend为例。你可以使用任何序列类型来扩展列表:

>>> a = list('hello')  # makes a list from a string
>>> a
['h', 'e', 'l', 'l', 'o']
>>> a.append(100)  # append 100, heterogeneous type
>>> a
['h', 'e', 'l', 'l', 'o', 100]
>>> a.extend((1, 2, 3))  # extend using tuple
>>> a
['h', 'e', 'l', 'l', 'o', 100, 1, 2, 3]
>>> a.extend('...')  # extend using string
>>> a
['h', 'e', 'l', 'l', 'o', 100, 1, 2, 3, '.', '.', '.']

现在,让我们看看你可以用列表做哪些最常见的操作:

>>> a = [1, 3, 5, 7]
>>> min(a)  # minimum value in the list
1
>>> max(a)  # maximum value in the list
7
>>> sum(a)  # sum of all values in the list
16
>>> len(a)  # number of elements in the list
4
>>> b = [6, 7, 8]
>>> a + b  # `+` with list means concatenation
[1, 3, 5, 7, 6, 7, 8]
>>> a * 2  # `*` has also a special meaning
[1, 3, 5, 7, 1, 3, 5, 7]

前面代码中的最后两行非常有趣,因为它们向我们介绍了一个叫做运算符重载的概念。简而言之,这意味着+-*%等运算符根据它们所用的上下文可能代表不同的操作。对两个列表求和没有任何意义,对吧?因此,+号用于连接它们。因此,*号用于根据右操作数将列表连接到自身。

现在,让我们再进一步,看一些更有趣的东西。我想向你展示sorted方法有多强大,以及在 Python 中实现需要在其他语言中付出很大努力才能实现的结果有多容易:

>>> from operator import itemgetter
>>> a = [(5, 3), (1, 3), (1, 2), (2, -1), (4, 9)]
>>> sorted(a)
[(1, 2), (1, 3), (2, -1), (4, 9), (5, 3)]
>>> sorted(a, key=itemgetter(0))
[(1, 3), (1, 2), (2, -1), (4, 9), (5, 3)]
>>> sorted(a, key=itemgetter(0, 1))
[(1, 2), (1, 3), (2, -1), (4, 9), (5, 3)]
>>> sorted(a, key=itemgetter(1))
[(2, -1), (1, 2), (5, 3), (1, 3), (4, 9)]
>>> sorted(a, key=itemgetter(1), reverse=True)
[(4, 9), (5, 3), (1, 3), (1, 2), (2, -1)]

前面的代码值得解释一下。首先,a是一个元组的列表。这意味着a中的每个元素都是一个元组(准确地说是一个 2 元组)。当我们调用sorted(some_list)时,我们得到了some_list的排序版本。在这种情况下,对 2 元组的排序是通过对元组中的第一个项目进行排序,当第一个项目相同时,再对第二个项目进行排序。你可以在sorted(a)的结果中看到这种行为,它产生了[(1, 2), (1, 3), ...]。Python 还让我们有能力控制排序必须针对元组的哪个元素。请注意,当我们指示sorted函数在每个元组的第一个元素上工作时(通过key=itemgetter(0)),结果是不同的:[(1, 3), (1, 2), ...]。排序仅在每个元组的第一个元素上进行(即在位置 0 的元素上)。如果我们想复制简单的sorted(a)调用的默认行为,我们需要使用key=itemgetter(0, 1),这告诉 Python 首先对元组中位置 0 的元素进行排序,然后对位置 1 的元素进行排序。比较结果,你会发现它们是匹配的。

为了完整起见,我包括了一个仅对位置 1 的元素进行排序的示例,以及相同但顺序相反的示例。如果你曾经见过 Java 中的排序,我相信你此刻会感到非常震惊。

Python 排序算法非常强大,由 Tim Peters 编写(我们已经见过这个名字,你还记得吗?)。 它被称为Timsort,它是mergeinsertion sort之间的混合,并且比大多数其他用于主流编程语言的算法具有更好的时间性能。 Timsort 是一种稳定的排序算法,这意味着当多个记录具有相同的键时,它们的原始顺序被保留。 我们在sorted(a,key=itemgetter(0))的结果中看到了这一点,它产生了[(1, 3),(1, 2),...],其中这两个元组的顺序已被保留,因为它们在位置 0 处具有相同的值。

字节数组

为了总结我们对可变序列类型的概述,让我们花几分钟时间来了解bytearray类型。 基本上,它们代表了bytes对象的可变版本。 它们公开了大多数可变序列的常用方法以及bytes类型的大多数方法。 项目是范围内的整数[0, 256)。

当涉及到间隔时,我将使用开/闭范围的标准表示法。 一端的方括号表示包括该值,而圆括号表示不包括该值。 粒度通常由边缘元素的类型推断,因此,例如,间隔[3, 7]表示 3 和 7 之间的所有整数,包括。 另一方面,(3, 7)表示 3 和 7 之间的所有整数不包括(因此 4、5 和 6)。 bytearray类型中的项目是介于 0 和 256 之间的整数; 0 包括在内,256 不包括在内。 表达间隔的一个原因通常是为了便于编码。 如果我们将范围[a,b)分成N个连续范围,我们可以轻松地将原始范围表示为这样的连接:

[a,k[1])+[k[1],k[2])+[k[2],k[3])+...+[k[N-1],b)

中间点(k[i])在一端被排除,在另一端被包括,这样在代码处理间隔时就可以轻松进行连接和拆分。

让我们看一个bytearray类型的快速示例:

>>> bytearray()  # empty bytearray object
bytearray(b'')
>>> bytearray(10)  # zero-filled instance with given length
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
>>> bytearray(range(5)) # bytearray from iterable of integers
bytearray(b'\x00\x01\x02\x03\x04')
>>> name = bytearray(b'Lina')  #A - bytearray from bytes
>>> name.replace(b'L', b'l')
bytearray(b'lina')
>>> name.endswith(b'na')
True
>>> name.upper()
bytearray(b'LINA')
>>> name.count(b'L')
1

正如您在前面的代码中所看到的,有几种方法可以创建bytearray对象。 它们在许多情况下都很有用; 例如,当通过套接字接收数据时,它们消除了在轮询时连接数据的需要,因此它们可能非常方便。 在#A行,我创建了一个名为namebytearray,从字节文字b'Lina'中显示了bytearray对象如何公开来自序列和字符串的方法,这非常方便。 如果您仔细考虑,它们可以被视为可变字符串。

集合类型

Python 还提供了两种集合类型,setfrozensetset类型是可变的,而frozenset是不可变的。 它们是不可变对象的无序集合。 可哈希性是一个特性,允许对象被用作集合成员以及字典的键,我们很快就会看到。

来自官方文档:如果对象具有在其生命周期内永远不会更改的哈希值,并且可以与其他对象进行比较,则对象是可哈希的。 可哈希性使对象可用作字典键和集合成员,因为这些数据结构在内部使用哈希值。 所有 Python 的不可变内置对象都是可哈希的,而可变容器则不是。

对象比较相等必须具有相同的哈希值。 集合非常常用于测试成员资格,因此让我们在以下示例中引入in运算符:

>>> small_primes = set()  # empty set
>>> small_primes.add(2)  # adding one element at a time
>>> small_primes.add(3)
>>> small_primes.add(5)
>>> small_primes
{2, 3, 5}
>>> small_primes.add(1)  # Look what I've done, 1 is not a prime!
>>> small_primes
{1, 2, 3, 5}
>>> small_primes.remove(1)  # so let's remove it
>>> 3 in small_primes # membership test
True
>>> 4 in small_primes
False
>>> 4 not in small_primes  # negated membership test
True
>>> small_primes.add(3)  # trying to add 3 again
>>> small_primes
{2, 3, 5}  # no change, duplication is not allowed
>>> bigger_primes = set([5, 7, 11, 13])  # faster creation
>>> small_primes | bigger_primes # union operator `|`
{2, 3, 5, 7, 11, 13}
>>> small_primes & bigger_primes  # intersection operator `&`
{5}
>>> small_primes - bigger_primes  # difference operator `-`
{2, 3}

在前面的代码中,您可以看到创建集合的两种不同方法。 一个是创建一个空集合,然后逐个添加元素。 另一个是使用数字列表作为构造函数的参数创建集合,这样我们就可以完成所有工作。 当然,您可以从列表或元组(或任何可迭代对象)创建集合,然后可以随意添加和删除集合中的成员。

我们将在下一章中查看可迭代对象和迭代。现在,只需知道可迭代对象是可以按照某个方向进行迭代的对象。

另一种创建集合的方法是简单地使用大括号表示法,就像这样:

>>> small_primes = {2, 3, 5, 5, 3}
>>> small_primes
{2, 3, 5}

请注意,我添加了一些重复,以强调结果集不会有任何重复。让我们看一个关于集合类型的不可变对应物frozenset的例子:

>>> small_primes = frozenset([2, 3, 5, 7])
>>> bigger_primes = frozenset([5, 7, 11])
>>> small_primes.add(11)  # we cannot add to a frozenset
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'add'
>>> small_primes.remove(2)  # neither we can remove
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'remove'
>>> small_primes & bigger_primes  # intersect, union, etc. allowed
frozenset({5, 7})

正如你所看到的,frozenset对象在其可变对应物方面相当有限。它们仍然非常有效地用于成员测试、并集、交集和差集操作,出于性能原因。

映射类型 - 字典

在所有内置的 Python 数据类型中,字典很容易是最有趣的一个。它是唯一的标准映射类型,也是每个 Python 对象的支柱。

字典将键映射到值。键需要是可哈希的对象,而值可以是任意类型。字典是可变对象。有很多不同的方法来创建字典,所以让我给你一个简单的例子,演示如何以五种不同的方式创建一个等于{'A': 1, 'Z': -1}的字典:

>>> a = dict(A=1, Z=-1)
>>> b = {'A': 1, 'Z': -1}
>>> c = dict(zip(['A', 'Z'], [1, -1]))
>>> d = dict([('A', 1), ('Z', -1)])
>>> e = dict({'Z': -1, 'A': 1})
>>> a == b == c == d == e  # are they all the same?
True  # They are indeed

你有没有注意到那些双等号?赋值是用一个等号来完成的,而要检查一个对象是否与另一个对象相同(或者在这种情况下一次检查五个对象),我们使用双等号。还有另一种比较对象的方法,涉及is运算符,并检查两个对象是否相同(如果它们具有相同的 ID,而不仅仅是值),但除非你有充分的理由使用它,否则应该使用双等号。在前面的代码中,我还使用了一个很好的函数:zip。它的名字来源于现实生活中的拉链,它将两个东西粘合在一起,每次取一个元素。让我给你举个例子:

>>> list(zip(['h', 'e', 'l', 'l', 'o'], [1, 2, 3, 4, 5]))
[('h', 1), ('e', 2), ('l', 3), ('l', 4), ('o', 5)]
>>> list(zip('hello', range(1, 6)))  # equivalent, more Pythonic
[('h', 1), ('e', 2), ('l', 3), ('l', 4), ('o', 5)]

在前面的例子中,我以两种不同的方式创建了相同的列表,一种更加明确,另一种稍微更加 Pythonic。暂时忘记我不得不在zip调用周围包装list构造函数的事实(原因是因为zip返回一个迭代器,而不是list,所以如果我想看到结果,我需要将该迭代器耗尽到某个东西中 - 在这种情况下是一个列表),并专注于结果。看看zip是如何将其两个参数的第一个元素配对在一起的,然后是第二个元素,然后是第三个元素,依此类推?看看你的裤子(或者如果你是女士,看看你的钱包),你会看到你的拉链也有相同的行为。但让我们回到字典,看看它们为我们提供了多少精彩的方法来允许我们按照自己的意愿对它们进行操作。

让我们从基本操作开始:

>>> d = {}
>>> d['a'] = 1  # let's set a couple of (key, value) pairs
>>> d['b'] = 2
>>> len(d)  # how many pairs?
2
>>> d['a']  # what is the value of 'a'?
1
>>> d  # how does `d` look now?
{'a': 1, 'b': 2}
>>> del d['a']  # let's remove `a`
>>> d
{'b': 2}
>>> d['c'] = 3  # let's add 'c': 3
>>> 'c' in d  # membership is checked against the keys
True
>>> 3 in d  # not the values
False
>>> 'e' in d
False
>>> d.clear()  # let's clean everything from this dictionary
>>> d
{}

请注意,无论我们执行何种类型的操作,访问字典的键都是通过方括号进行的。你还记得字符串、列表和元组吗?我们之前也是通过方括号访问某个位置的元素,这是 Python 一致性的又一个例子。

现在让我们来看看三个特殊的对象,称为字典视图:keysvaluesitems。这些对象提供了字典条目的动态视图,并且随着字典的更改而更改。keys()返回字典中的所有键,values()返回字典中的所有值,items()返回字典中的所有(键,值)对。

根据 Python 文档:“键和值以任意顺序进行迭代,这个顺序是非随机的,在 Python 的不同实现中会有所变化,并且取决于字典插入和删除的历史。如果在对键、值和项视图进行迭代时没有对字典进行干预修改,那么项的顺序将直接对应”。

够啰嗦了,让我们把这一切都写成代码:

>>> d = dict(zip('hello', range(5)))
>>> d
{'h': 0, 'e': 1, 'l': 3, 'o': 4}
>>> d.keys()
dict_keys(['h', 'e', 'l', 'o'])
>>> d.values()
dict_values([0, 1, 3, 4])
>>> d.items()
dict_items([('h', 0), ('e', 1), ('l', 3), ('o', 4)])
>>> 3 in d.values()
True
>>> ('o', 4) in d.items()
True

在前面的代码中有几件事情需要注意。首先,注意我们是如何通过迭代字符串'hello'和列表[0, 1, 2, 3, 4]的压缩版本来创建字典的。字符串'hello'里有两个'l'字符,它们分别与zip函数的值23配对。请注意,在字典中,'l'键的第二次出现(值为3)覆盖了第一次出现(值为2)。另一个需要注意的是,当请求任何视图时,原始顺序现在被保留,而在 3.6 版本之前,没有这样的保证。

从 Python 3.6 开始,dict类型已经重新实现以使用更紧凑的表示。与 Python 3.5 相比,这导致字典使用的内存减少了 20%到 25%。此外,在 Python 3.6 中,作为一个副作用,字典是本地有序的。这个特性受到了社区的欢迎,在 3.7 中它已经成为语言的合法特性,而不是实现的副作用。如果dict记住了首次插入键的顺序,那么它就是有序的。

当我们讨论对集合进行迭代时,我们将看到这些视图是基本工具。现在让我们来看一下 Python 字典暴露的一些其他方法;它们有很多,而且非常有用:

>>> d
{'e': 1, 'h': 0, 'o': 4, 'l': 3}
>>> d.popitem()  # removes a random item (useful in algorithms)
('o', 4)
>>> d
{'h': 0, 'e': 1, 'l': 3}
>>> d.pop('l')  # remove item with key `l`
3
>>> d.pop('not-a-key')  # remove a key not in dictionary: KeyError
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'not-a-key'
>>> d.pop('not-a-key', 'default-value')  # with a default value?
'default-value'  # we get the default value
>>> d.update({'another': 'value'})  # we can update dict this way
>>> d.update(a=13)  # or this way (like a function call)
>>> d
{'h': 0, 'e': 1, 'another': 'value', 'a': 13}
>>> d.get('a')  # same as d['a'] but if key is missing no KeyError
13
>>> d.get('a', 177)  # default value used if key is missing
13
>>> d.get('b', 177)  # like in this case
177
>>> d.get('b')  # key is not there, so None is returned

所有这些方法都很容易理解,但值得一提的是None。Python 中的每个函数都返回None,除非return语句被明确用于返回其他内容,但我们将在探讨函数时再看到这一点。None经常用于表示值的缺失,并且在函数声明的参数中经常用作默认值。一些经验不足的编程人员有时会编写返回FalseNone的代码。FalseNone在布尔上下文中都会评估为False,因此它们之间似乎没有太大的区别。但实际上,我认为它们之间有一个非常重要的区别:False表示我们有信息,而我们拥有的信息是FalseNone表示没有信息。没有信息与信息为False是非常不同的。通俗地说,如果你问你的机械师,我的车准备好了吗?,答案之间有很大的区别,不,还没有False)和,我不知道None)。

我非常喜欢字典的最后一个方法setdefault。它的行为类似于get,但如果键不存在,它还会将给定值设置为键。让我们看一个例子:

>>> d = {}
>>> d.setdefault('a', 1)  # 'a' is missing, we get default value
1
>>> d
{'a': 1}  # also, the key/value pair ('a', 1) has now been added
>>> d.setdefault('a', 5)  # let's try to override the value
1
>>> d
{'a': 1}  # no override, as expected

所以,我们现在已经到了这次旅行的尽头。通过尝试预测这行之后d的样子,来测试你对字典的了解:

>>> d = {}
>>> d.setdefault('a', {}).setdefault('b', []).append(1)

如果你不能立刻理解,不要担心。我只是想鼓励你尝试使用字典。

这结束了我们对内置数据类型的介绍。在我讨论本章中所见内容的一些注意事项之前,我想简要地看一下collections模块。

collections 模块

当 Python 通用内置容器(tuplelistsetdict)不够用时,我们可以在collections模块中找到专门的容器数据类型。它们是:

数据类型 描述
namedtuple() 用于创建具有命名字段的元组子类的工厂函数
deque 具有快速附加和弹出的类似列表的容器
ChainMap 用于创建多个映射的单个视图的类似字典的类
Counter 用于计算可散列对象的字典子类
OrderedDict 记住条目添加顺序的字典子类
defaultdict 调用工厂函数以提供缺失值的字典子类
UserDict 用于更轻松地对字典子类进行封装的字典对象
UserList 用于更轻松地对列表子类进行封装的列表对象
UserString 用于更轻松地对字符串子类进行封装的字符串对象

我们没有空间来涵盖所有这些,但您可以在官方文档中找到大量的例子,所以在这里我只给出一个小例子,向您展示namedtupledefaultdictChainMap

namedtuple

namedtuple是一个类似于元组的对象,可以通过属性查找访问字段,也可以通过索引和迭代访问(实际上它是tuple的子类)。这在完整对象和元组之间是一种折衷,可以在那些不需要自定义对象的全部功能,但又希望代码更易读的情况下很有用,避免奇怪的索引。另一个用例是在重构后元组中的项目可能需要改变位置的情况下,迫使编码人员也要重构所有涉及的逻辑,这可能非常棘手。通常情况下,例子胜过千言万语(还是图片?)。假设我们正在处理关于患者左眼和右眼的数据。我们在常规元组中为左眼(位置 0)保存一个值,右眼(位置 1)保存一个值。如下所示:

>>> vision = (9.5, 8.8)
>>> vision
(9.5, 8.8)
>>> vision[0]  # left eye (implicit positional reference)
9.5
>>> vision[1]  # right eye (implicit positional reference)
8.8

现在让我们假装我们一直处理vision对象,并且在某个时候设计师决定通过添加组合视觉的信息来增强它们,以便vision对象以这种格式存储数据:(左眼,组合,右眼)

现在你看到我们遇到的问题了吗?我们可能有很多依赖于vision[0]是左眼信息(它仍然是)和vision[1]是右眼信息(这不再是情况)的代码。我们必须在处理这些对象的任何地方重构我们的代码,将vision[1]更改为vision[2],这可能很痛苦。也许我们最初可以更好地处理这个问题,使用namedtuple。让我告诉你我的意思:

>>> from collections import namedtuple
>>> Vision = namedtuple('Vision', ['left', 'right'])
>>> vision = Vision(9.5, 8.8)
>>> vision[0]
9.5
>>> vision.left  # same as vision[0], but explicit
9.5
>>> vision.right  # same as vision[1], but explicit
8.8

如果在我们的代码中,我们使用vision.leftvision.right来引用左眼和右眼,我们只需要改变我们的工厂和创建实例的方式来解决新的设计问题。代码的其余部分不需要更改:

>>> Vision = namedtuple('Vision', ['left', 'combined', 'right'])
>>> vision = Vision(9.5, 9.2, 8.8)
>>> vision.left  # still correct
9.5
>>> vision.right  # still correct (though now is vision[2])
8.8
>>> vision.combined  # the new vision[1]
9.2

您可以看到,按名称而不是按位置引用这些值是多么方便。毕竟,有智慧的人曾经写道,明确胜于隐晦(你能回忆起在哪里吗?如果你不能,想想)。这个例子可能有点极端;当然,我们的代码设计师不太可能做出这样的改变,但您会惊讶地看到在专业环境中经常发生类似这种问题,以及重构它们是多么痛苦。

defaultdict

defaultdict数据类型是我最喜欢的之一。它允许您避免检查字典中是否存在键,只需在第一次访问时为您插入它,使用您在创建时传递的默认值类型。在某些情况下,这个工具可能非常方便,可以稍微缩短您的代码。让我们看一个快速的例子。假设我们正在更新age的值,增加一岁。如果age不存在,我们假设它是0,然后将其更新为1

>>> d = {}
>>> d['age'] = d.get('age', 0) + 1  # age not there, we get 0 + 1
>>> d
{'age': 1}
>>> d = {'age': 39}
>>> d['age'] = d.get('age', 0) + 1  # age is there, we get 40
>>> d
{'age': 40}

现在让我们看看defaultdict数据类型如何工作。第二行实际上是一个四行长的if子句的简短版本,如果字典没有get方法,我们将不得不编写它(我们将在第三章中看到所有关于if子句的内容,迭代和做决定):

>>> from collections import defaultdict
>>> dd = defaultdict(int)  # int is the default type (0 the value)
>>> dd['age'] += 1  # short for dd['age'] = dd['age'] + 1
>>> dd
defaultdict(<class 'int'>, {'age': 1})  # 1, as expected

请注意,我们只需要指示defaultdict工厂,我们希望在键丢失时使用int数字(我们将得到0,这是int类型的默认值)。还要注意,即使在这个例子中行数没有减少,但可读性肯定有所提高,这是非常重要的。您还可以使用不同的技术来实例化defaultdict数据类型,这涉及创建一个工厂对象。要深入了解,请参考官方文档。

ChainMap

ChainMap是 Python 3.3 中引入的一种非常好的数据类型。它的行为类似于普通字典,但根据 Python 文档的说法:“用于快速链接多个映射,以便它们可以被视为单个单元”。这通常比创建一个字典并对其运行多个更新调用要快得多。ChainMap可用于模拟嵌套作用域,在模板中非常有用。底层映射存储在列表中。该列表是公共的,可以使用 maps 属性进行访问或更新。查找依次搜索底层映射,直到找到一个键。相比之下,写入、更新和删除只对第一个映射进行操作。

一个非常常见的用例是提供默认值,所以让我们看一个例子:

>>> from collections import ChainMap
>>> default_connection = {'host': 'localhost', 'port': 4567}
>>> connection = {'port': 5678}
>>> conn = ChainMap(connection, default_connection)  # map creation
>>> conn['port']  # port is found in the first dictionary
5678
>>> conn['host']  # host is fetched from the second dictionary
'localhost'
>>> conn.maps  # we can see the mapping objects
[{'port': 5678}, {'host': 'localhost', 'port': 4567}]
>>> conn['host'] = 'packtpub.com'  # let's add host
>>> conn.maps
[{'port': 5678, 'host': 'packtpub.com'},
 {'host': 'localhost', 'port': 4567}]
>>> del conn['port']  # let's remove the port information
>>> conn.maps
[{'host': 'packtpub.com'}, {'host': 'localhost', 'port': 4567}]
>>> conn['port']  # now port is fetched from the second dictionary
4567
>>> dict(conn)  # easy to merge and convert to regular dictionary
{'host': 'packtpub.com', 'port': 4567}

我只是喜欢 Python 如何让你的生活变得轻松。你可以在ChainMap对象上工作,配置第一个映射,然后当你需要一个包含所有默认项以及自定义项的完整字典时,你只需将ChainMap对象提供给dict构造函数。如果你从未在其他语言(如 Java 或 C++)中编写过代码,你可能无法完全欣赏到这有多么宝贵,以及 Python 如何让你的生活变得更加轻松。我可以,每次我不得不在其他语言中编写代码时,我都感到有一种幽闭恐惧症。

枚举

从技术上讲,枚举不是内置数据类型,因为你必须从enum模块中导入它们,但绝对值得一提的是枚举。它们是在 Python 3.4 中引入的,虽然在专业代码中看到它们并不那么常见(但),但我还是想给你举个例子。

官方定义如下:“枚举是一组 符号 名称(成员)绑定到唯一的、常量值。在枚举中,成员可以通过标识进行比较,枚举本身可以被迭代。”

假设你需要表示交通信号灯。在你的代码中,你可能会这样做:

>>> GREEN = 1
>>> YELLOW = 2
>>> RED = 4
>>> TRAFFIC_LIGHTS = (GREEN, YELLOW, RED)
>>> # or with a dict
>>> traffic_lights = {'GREEN': 1, 'YELLOW': 2, 'RED': 4}

前面的代码没有什么特别之处。事实上,这是非常常见的。但是,考虑改为这样做:

>>> from enum import Enum
>>> class TrafficLight(Enum):
...     GREEN = 1
...     YELLOW = 2
...     RED = 4
...
>>> TrafficLight.GREEN
<TrafficLight.GREEN: 1>
>>> TrafficLight.GREEN.name
'GREEN'
>>> TrafficLight.GREEN.value
1
>>> TrafficLight(1)
<TrafficLight.GREEN: 1>
>>> TrafficLight(4)
<TrafficLight.RED: 4>

暂时忽略类定义的(相对)复杂性,你可以欣赏到这可能更有优势。数据结构更清晰,提供的 API 更强大。我鼓励你查看官方文档,探索在enum模块中可以找到的所有出色功能。我认为值得探索,至少一次。

最终考虑

就是这样。现在你已经看到了你将在 Python 中使用的数据结构的很大一部分。我鼓励你深入阅读 Python 文档,并进一步尝试本章中我们所见过的每一种数据类型。值得的,相信我。你将写的一切都与处理数据有关,所以确保你对它的知识是非常扎实的。

在我们跳入第三章 迭代和决策之前,我想分享一些关于不同方面的最终考虑,我认为这些方面很重要,不容忽视。

小值缓存

当我们在本章开头讨论对象时,我们看到当我们将一个名称分配给一个对象时,Python 会创建对象,设置其值,然后将名称指向它。我们可以将不同的名称分配给相同的值,并且我们期望创建不同的对象,就像这样:

>>> a = 1000000
>>> b = 1000000
>>> id(a) == id(b)
False

在前面的例子中,ab被分配给了两个int对象,它们具有相同的值,但它们不是同一个对象,你可以看到,它们的id不同。所以让我们再做一次:

>>> a = 5
>>> b = 5
>>> id(a) == id(b)
True

哦,哦!Python 出问题了吗?为什么现在两个对象是相同的?我们没有执行a = b = 5,我们分别设置它们。嗯,答案是性能。Python 缓存短字符串和小数字,以避免它们的副本堵塞系统内存。一切都在幕后妥善处理,因此你不需要担心,但请确保在您的代码需要处理 ID 时记住这种行为。

如何选择数据结构

正如我们所见,Python 为您提供了几种内置数据类型,有时,如果您没有那么多经验,选择最适合您的数据类型可能会很棘手,特别是当涉及到集合时。例如,假设您有许多字典要存储,每个字典代表一个客户。在每个客户字典中,都有一个'id': 'code'唯一标识代码。您会将它们放在什么样的集合中?嗯,除非我更多地了解这些客户,否则很难回答。我将需要什么样的访问?我将需要对它们执行什么样的操作,以及多少次?集合会随时间改变吗?我需要以任何方式修改客户字典吗?我将在集合上执行的最频繁的操作是什么?

如果您能回答前面的问题,那么您就会知道该选择什么。如果集合永远不会缩小或增长(换句话说,在创建后不需要添加/删除任何客户对象)或洗牌,那么元组是一个可能的选择。否则,列表是一个不错的选择。不过,每个客户字典都有一个唯一标识符,因此甚至字典也可以工作。让我为您草拟这些选项:

# example customer objects 
customer1 = {'id': 'abc123', 'full_name': 'Master Yoda'} 
customer2 = {'id': 'def456', 'full_name': 'Obi-Wan Kenobi'} 
customer3 = {'id': 'ghi789', 'full_name': 'Anakin Skywalker'} 
# collect them in a tuple 
customers = (customer1, customer2, customer3) 
# or collect them in a list 
customers = [customer1, customer2, customer3] 
# or maybe within a dictionary, they have a unique id after all 
customers = { 
    'abc123': customer1, 
    'def456': customer2, 
    'ghi789': customer3, 
} 

我们有一些客户在那里,对吧?我可能不会选择元组选项,除非我想强调集合不会改变。我会说通常列表更好,因为它更灵活。

另一个要记住的因素是元组和列表是有序集合。如果使用字典(Python 3.6 之前)或集合,你会失去排序,因此你需要知道排序在你的应用程序中是否重要。

性能如何?例如,在列表中,插入和成员资格等操作可能需要O(n)的时间,而对于字典来说则是O(1)。不过,并不总是可以使用字典,如果我们不能保证可以通过其属性之一唯一标识集合中的每个项目,并且该属性是可散列的(因此可以成为dict中的键)。

如果你想知道O(n)O(1)的含义,请谷歌大 O 符号。在这种情况下,让我们只说,如果对数据结构执行操作Op需要O(f(n))的时间,这意味着Op最多需要时间t ≤ c * f(n)来完成,其中c是某个正常数,n是输入的大小,f是某个函数。因此,将O(...)视为操作运行时间的上限(当然也可以用于其他可测量的数量)。

另一种了解是否选择了正确的数据结构的方法是查看您必须编写的代码以便对其进行操作。如果一切都很顺利,自然流畅,那么你可能选择正确了,但如果你发现自己认为代码变得不必要复杂,那么你可能应该尝试并决定是否需要重新考虑你的选择。不过,没有实际案例很难给出建议,因此当你为数据选择数据结构时,请记住考虑易用性和性能,并优先考虑在你所处的环境中最重要的事情。

关于索引和切片

在本章的开头,我们看到了字符串上的切片应用。切片通常适用于序列:元组、列表、字符串等。对于列表,切片也可以用于赋值。我几乎从未在专业代码中看到过这种用法,但是您知道您可以这样做。您可以对字典或集合进行切片吗?我听到您在尖叫,当然不行!。太好了;我看到我们在同一个页面上,所以让我们谈谈索引。

有一个关于 Python 索引的特点我之前没有提到。我将通过一个示例向您展示。如何寻址集合的最后一个元素?让我们看一下:

>>> a = list(range(10))  # `a` has 10 elements. Last one is 9.
>>> a
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> len(a)  # its length is 10 elements
10
>>> a[len(a) - 1]  # position of last one is len(a) - 1
9
>>> a[-1]  # but we don't need len(a)! Python rocks!
9
>>> a[-2]  # equivalent to len(a) - 2
8
>>> a[-3]  # equivalent to len(a) - 3
7

如果列表a有 10 个元素,由于 Python 的 0 索引定位系统,第一个元素位于位置 0,最后一个元素位于位置 9。在前面的例子中,元素方便地放置在与它们的值相等的位置:0位于位置 0,1位于位置 1,依此类推。

因此,为了获取最后一个元素,我们需要知道整个列表(或元组、字符串等)的长度,然后减去1。因此:len(a) - 1。这是一个非常常见的操作,Python 提供了一种使用负索引检索元素的方法。这在进行数据操作时非常有用。下面是一个关于字符串"HelloThere"(这是 Obi-Wan Kenobi 讽刺地向 Grievous 将军问候)的索引工作的漂亮图表:

尝试处理大于9或小于-10的索引将引发IndexError,这是预期的。

关于名称

您可能已经注意到,为了使示例尽可能简短,我使用了简单的字母来调用许多对象,如abcd等。当您在控制台上调试或显示a + b == 7时,这是完全可以的,但是在专业编码(或任何类型的编码)中是不好的做法。如果我有时这样做,希望您能谅解;原因是为了以更紧凑的方式呈现代码。

在真实环境中,当您为数据选择名称时,您应该仔细选择名称,并且名称应该反映数据的内容。因此,如果您有一组Customer对象,customers是一个非常好的名称。customers_listcustomers_tuplecustomers_collection也可以吗?想一想。将集合的名称与数据类型绑定在一起好吗?我认为大多数情况下不好。因此,我会说如果您有充分的理由这样做,请继续;否则,不要这样做。原因是,一旦customers_tuple开始在代码的不同位置使用,并且您意识到实际上想要使用列表而不是元组,您将需要进行一些有趣的重构(也称为浪费时间)。数据的名称应该是名词,函数的名称应该是动词。名称应该尽可能具有表现力。实际上,Python 在命名方面是一个非常好的例子。大多数情况下,如果您知道函数的作用,您可以猜出函数的名称。疯狂,对吧?

《代码整洁之道》《有意义的命名》第二章,Robert C. MartinPrentice Hall完全致力于名称。这是一本了不起的书,它帮助我以许多不同的方式改进了我的编码风格,如果您想将编码提升到下一个水平,这是一本必读的书。

总结

在本章中,我们探讨了 Python 的内置数据类型。我们已经看到了有多少种类型,以及仅仅通过不同的组合就可以实现多少。

我们已经看到了数字类型、序列、集合、映射、集合(以及Enum的特别客串),我们已经知道了一切都是对象,我们已经学会了可变和不可变的区别,我们还学会了切片和索引(以及自豪地学会了负索引)。

我们提供了简单的示例,但是您可以学到更多关于这个主题的知识,所以请深入研究官方文档并探索。

最重要的是,我鼓励你自己尝试所有的练习,让你的手指使用那些代码,建立一些肌肉记忆,并且不断尝试,实验,实验。学习当你除以零时会发生什么,当你将不同的数字类型组合成一个表达式时会发生什么,当你处理字符串时会发生什么。玩转所有的数据类型。锻炼它们,打破它们,发现它们所有的方法,享受它们,并且非常非常好地学习它们。

如果你的基础不是非常牢固的,你的代码能有多好呢?而数据是一切的基础。数据塑造了其周围的一切。

随着你在书中的进展,很可能会发现我的代码(或你的代码)中偶尔会出现一些不一致或小错误。你会收到错误消息,有些东西会出错。这很棒!编码时,事情经常会出错,你会一直进行调试和修复,所以把错误视为学习有关你正在使用的语言的新知识的有用练习,而不是失败或问题。错误会一直出现,直到你的最后一行代码,这是肯定的,所以最好现在就开始接受它们。

下一章是关于迭代和做决策的。我们将看到如何实际运用这些集合,并根据我们所提供的数据做出决策。现在你的知识正在积累,我们将开始加快速度,所以在你进入下一章之前,请确保你对本章的内容感到舒适。再次强调,玩得开心,探索,打破一切。这是学习的好方法。

第三章:迭代和做决定

“疯狂:一遍又一遍地做同样的事情,却期待不同的结果。”- 阿尔伯特·爱因斯坦

在上一章中,我们看了 Python 的内置数据类型。现在你已经熟悉了数据的各种形式和形状,是时候开始看看程序如何使用它了。

根据维基百科:

在计算机科学中,控制流(或者叫控制流程)是指规定命令式程序的各个语句、指令或函数调用的执行或评估顺序。

为了控制程序的流程,我们有两个主要的工具:条件编程(也称为分支)和循环。我们可以以许多不同的组合和变体使用它们,但在本章中,我不打算以文档的方式介绍这两个结构的所有可能形式,而是给你一些基础知识,然后和你一起编写一些小脚本。在第一个脚本中,我们将看到如何创建一个简单的素数生成器,而在第二个脚本中,我们将看到如何根据优惠券给顾客打折。这样,你应该更好地了解条件编程和循环如何使用。

在本章中,我们将涵盖以下内容:

  • 条件编程

  • Python 中的循环

  • 快速浏览itertools模块

条件编程

条件编程,或者分支,是你每天、每时每刻都在做的事情。它涉及评估条件:如果交通灯是绿色的,那么我可以过去; 如果下雨,那么我会带伞; 如果我上班迟到了,那么我会打电话给我的经理

主要工具是if语句,它有不同的形式和颜色,但基本上它评估一个表达式,并根据结果选择要执行的代码部分。像往常一样,让我们看一个例子:

# conditional.1.py
late = True 
if late: 
    print('I need to call my manager!') 

这可能是最简单的例子:当传递给if语句时,late充当条件表达式,在布尔上下文中进行评估(就像我们调用bool(late)一样)。如果评估的结果是True,那么我们就进入if语句后面的代码体。请注意,print指令是缩进的:这意味着它属于由if子句定义的作用域。执行这段代码会产生:

$ python conditional.1.py
I need to call my manager!

由于lateTrueprint语句被执行了。让我们扩展一下这个例子:

# conditional.2.py
late = False 
if late: 
    print('I need to call my manager!')  #1 
else: 
    print('no need to call my manager...')  #2 

这次我设置了late = False,所以当我执行代码时,结果是不同的:

$ python conditional.2.py
no need to call my manager...

根据评估late表达式的结果,我们可以进入块#1或块#2但不能同时进入。当late评估为True时,执行块#1,而当late评估为False时,执行块#2。尝试给late名称分配False/True值,并看看这段代码的输出如何相应地改变。

前面的例子还介绍了else子句,当我们想要在if子句中的表达式评估为False时提供一组备用指令时,它非常方便。else子句是可选的,通过比较前面的两个例子可以明显看出。

一个专门的 else - elif

有时,您只需要在满足条件时执行某些操作(简单的if子句)。在其他时候,您需要提供一个替代方案,以防条件为Falseif/else子句),但有时您可能有更多的选择路径,因此,由于调用经理(或不调用他们)是一种二进制类型的示例(要么您打电话,要么您不打电话),让我们改变示例的类型并继续扩展。这次,我们决定税收百分比。如果我的收入低于$10,000,我将不支付任何税。如果在$10,000 和$30,000 之间,我将支付 20%的税。如果在$30,000 和$100,000 之间,我将支付 35%的税,如果超过$100,000,我将(很高兴)支付 45%的税。让我们把这一切都写成漂亮的 Python 代码:

# taxes.py
income = 15000 
if income < 10000: 
    tax_coefficient = 0.0  #1 
elif income < 30000: 
    tax_coefficient = 0.2  #2 
elif income < 100000: 
    tax_coefficient = 0.35  #3 
else: 
    tax_coefficient = 0.45  #4 

print('I will pay:', income * tax_coefficient, 'in taxes') 

执行上述代码产生的结果:

$ python taxes.py
I will pay: 3000.0 in taxes

让我们逐行通过这个例子:我们首先设置收入值。在这个例子中,我的收入是$15,000。我们进入if子句。请注意,这一次我们还引入了elif子句,它是else-if的缩写,与裸的else子句不同,它也有自己的条件。因此,income < 10000if表达式评估为False,因此块#1不被执行。

控制传递给下一个条件评估器:elif income < 30000。这个评估为True,因此块#2被执行,因此,Python 在整个if/elif/elif/else子句之后恢复执行(我们现在可以称之为if子句)。在if子句之后只有一条指令,即print调用,它告诉我们今年我将支付3000.0的税(15,000 * 20%)。请注意,顺序是强制的:if首先出现,然后(可选)是尽可能多的elif子句,然后(可选)是一个else子句。

有趣,对吧?无论每个块内有多少行代码,当其中一个条件评估为True时,相关的块将被执行,然后在整个子句之后恢复执行。如果没有一个条件评估为True(例如,income = 200000),那么else子句的主体将被执行(块#4)。这个例子扩展了我们对else子句行为的理解。当之前的if/elif/.../elif表达式没有评估为True时,它的代码块被执行。

尝试修改income的值,直到您可以随意执行所有块(每次执行一个)。然后尝试边界。这是至关重要的,每当您将条件表达为相等不等==!=<><=>=)时,这些数字代表边界。彻底测试边界是至关重要的。我是否允许您在 18 岁或 17 岁时开车?我是用age < 18还是age <= 18来检查您的年龄?您无法想象有多少次我不得不修复由于使用错误的运算符而产生的微妙错误,因此继续并尝试修改上述代码。将一些<更改为<=,并将收入设置为边界值之一(10,000,30,000,100,000)以及之间的任何值。看看结果如何变化,并在继续之前对其有一个很好的理解。

现在让我们看另一个示例,向我们展示如何嵌套if子句。假设您的程序遇到错误。如果警报系统是控制台,我们打印错误。如果警报系统是电子邮件,我们根据错误的严重程度发送它。如果警报系统不是控制台或电子邮件之外的任何其他东西,我们不知道该怎么办,因此我们什么也不做。让我们把这写成代码:

# errorsalert.py
alert_system = 'console'  # other value can be 'email' 
error_severity = 'critical'  # other values: 'medium' or 'low' 
error_message = 'OMG! Something terrible happened!' 

if alert_system == 'console': 
    print(error_message)  #1 
elif alert_system == 'email': 
    if error_severity == 'critical': 
        send_email('admin@example.com', error_message)  #2 
    elif error_severity == 'medium': 
        send_email('support.1@example.com', error_message)  #3 
    else: 
        send_email('support.2@example.com', error_message)  #4 

上面的例子非常有趣,因为它很愚蠢。它向我们展示了两个嵌套的if子句(外部内部)。它还向我们展示了外部if子句没有任何else,而内部if子句有。请注意,缩进是允许我们将一个子句嵌套在另一个子句中的原因。

如果alert_system == 'console',则执行#1部分,其他情况则不执行。另一方面,如果alert_system == 'email',那么我们进入另一个if子句,我们称之为内部。在内部if子句中,根据error_severity,我们向管理员、一级支持或二级支持发送电子邮件(块#2#3#4)。在此示例中未定义send_email函数,因此尝试运行它会导致错误。在本书的源代码中,您可以从网站下载,我包含了一个技巧,将该调用重定向到常规的print函数,这样您就可以在控制台上进行实验,而不必实际发送电子邮件。尝试更改值,看看它是如何工作的。

三元运算符

在转移到下一个主题之前,我想向您展示的最后一件事是三元运算符,或者通俗地说,是if/else子句的简短版本。当根据某个条件来分配名称的值时,有时使用三元运算符而不是适当的if子句更容易阅读。在下面的示例中,两个代码块完全做同样的事情:

# ternary.py
order_total = 247  # GBP 

# classic if/else form 
if order_total > 100: 
    discount = 25  # GBP 
else: 
    discount = 0  # GBP 
print(order_total, discount) 

# ternary operator 
discount = 25 if order_total > 100 else 0 
print(order_total, discount) 

对于这样简单的情况,我发现能够用一行代码来表达这种逻辑非常好,而不是用四行。记住,作为编码人员,您花在阅读代码上的时间远远多于编写代码的时间,因此 Python 的简洁性是无价的。

您清楚三元运算符是如何工作的吗?基本上,name = something if condition else something-else。因此,如果condition评估为True,则name被分配为something,如果condition评估为False,则为something-else

现在您已经了解了如何控制代码的路径,让我们继续下一个主题:循环

循环

如果您在其他编程语言中有循环的经验,您会发现 Python 的循环方式有些不同。首先,什么是循环?循环意味着能够根据给定的循环参数多次重复执行代码块。有不同的循环结构,它们有不同的目的,Python 已将它们全部简化为只有两种,您可以使用它们来实现您需要的一切。这些是forwhile语句。

虽然使用它们中的任何一个都可以做你需要做的一切,但它们有不同的目的,因此它们通常在不同的上下文中使用。我们将在本章中深入探讨这种差异。

for循环

当循环遍历序列时,例如列表、元组或对象集合时,使用for循环。让我们从一个简单的示例开始,扩展概念,看看 Python 语法允许我们做什么:

# simple.for.py
for number in [0, 1, 2, 3, 4]: 
    print(number) 

这段简单的代码片段在执行时打印从04的所有数字。for循环接收到列表[0, 1, 2, 3, 4],在每次迭代时,number从序列中获得一个值(按顺序迭代),然后执行循环体(打印行)。number的值在每次迭代时都会更改,根据序列中接下来的值。当序列耗尽时,for循环终止,代码的执行在循环后恢复正常。

遍历范围

有时我们需要遍历一系列数字,将其硬编码到某个地方将会很不方便。在这种情况下,range函数就派上用场了。让我们看看前面代码片段的等价物:

# simple.for.py
for number in range(5): 
    print(number) 

在 Python 程序中,range函数在创建序列时被广泛使用:您可以通过传递一个值来调用它,该值充当stop(从0开始计数),或者您可以传递两个值(startstop),甚至三个值(startstopstep)。查看以下示例:

>>> list(range(10))  # one value: from 0 to value (excluded)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(range(3, 8))  # two values: from start to stop (excluded)
[3, 4, 5, 6, 7]
>>> list(range(-10, 10, 4))  # three values: step is added
[-10, -6, -2, 2, 6]

暂时忽略我们需要在range(...)内部包装一个listrange对象有点特殊,但在这种情况下,我们只对了解它将向我们返回什么值感兴趣。您可以看到,切片的处理方式与之相同:start包括在内,stop排除在外,还可以添加一个step参数,其默认值为1

尝试修改我们的simple.for.py代码中range()调用的参数,并查看它打印出什么。熟悉它。

在序列上进行迭代

现在我们有了迭代序列的所有工具,所以让我们在此基础上构建示例:

# simple.for.2.py
surnames = ['Rivest', 'Shamir', 'Adleman'] 
for position in range(len(surnames)): 
    print(position, surnames[position]) 

前面的代码给游戏增加了一点复杂性。执行将显示此结果:

$ python simple.for.2.py
0 Rivest
1 Shamir
2 Adleman

让我们使用从内到外的技术来分解它,好吗?我们从我们试图理解的最内部部分开始,然后向外扩展。因此,len(surnames)surnames列表的长度:3。因此,range(len(surnames))实际上被转换为range(3)。这给我们提供了范围[0, 3),基本上是一个序列(012)。这意味着for循环将运行三次迭代。在第一次迭代中,position将取值0,而在第二次迭代中,它将取值1,最后在第三次和最后一次迭代中取值2。如果不是surnames列表的可能索引位置(012),那是什么?在位置0,我们找到'Rivest',在位置1'Shamir',在位置2'Adleman'。如果您对这三个人一起创造了什么感到好奇,请将print(position, surnames[position])更改为print(surnames[position][0], end=''),在循环之外添加最后一个print(),然后再次运行代码。

现在,这种循环的风格实际上更接近于 Java 或 C++等语言。在 Python 中,很少见到这样的代码。您可以只是迭代任何序列或集合,因此没有必要在每次迭代时获取位置列表并从序列中检索元素。这是昂贵的,没有必要的昂贵。让我们将示例改为更符合 Python 风格的形式:

# simple.for.3.py
surnames = ['Rivest', 'Shamir', 'Adleman'] 
for surname in surnames: 
    print(surname) 

现在这就是了!它几乎是英语。for循环可以在surnames列表上进行迭代,并且在每次交互中按顺序返回每个元素。运行此代码将逐个打印出三个姓氏。阅读起来更容易,对吧?

但是,如果您想要打印位置呢?或者如果您实际上需要它呢?您应该回到range(len(...))形式吗?不。您可以使用enumerate内置函数,就像这样:

# simple.for.4.py
surnames = ['Rivest', 'Shamir', 'Adleman'] 
for position, surname in enumerate(surnames): 
    print(position, surname) 

这段代码也非常有趣。请注意,enumerate在每次迭代时都会返回一个两元组(position,surname),但仍然比range(len(...))示例更可读(更有效)。您可以使用start参数调用enumerate,例如enumerate(iterable, start),它将从start开始,而不是0。这只是另一件小事,向您展示了 Python 在设计时考虑了多少,以便使您的生活更轻松。

您可以使用for循环来迭代列表、元组和一般 Python 称为可迭代的任何东西。这是一个非常重要的概念,所以让我们再谈一谈。

迭代器和可迭代对象

根据 Python 文档(docs.python.org/3/glossary.html)的说法,可迭代对象是:

能够逐个返回其成员的对象。可迭代对象的示例包括所有序列类型(如列表、str 和元组)和一些非序列类型,如字典、文件对象和您使用 iter()或 getitem()方法定义的任何类的对象。可迭代对象可以在 for 循环和许多其他需要序列的地方使用(zip()、map()等)。当将可迭代对象作为参数传递给内置函数 iter()时,它会返回该对象的迭代器。该迭代器对值集合进行一次遍历。在使用可迭代对象时,通常不需要调用 iter()或自己处理迭代器对象。for 语句会自动为您执行这些操作,为循环的持续时间创建一个临时的无名变量来保存迭代器。

简而言之,当你写for k in sequence: ... body ...时,for循环会向sequence请求下一个元素,它会得到一些返回值,将返回值称为k,然后执行其主体。然后,再次,for循环会向sequence请求下一个元素,再次将其称为k,并再次执行主体,依此类推,直到序列耗尽。空序列将导致主体执行零次。

一些数据结构在进行迭代时按顺序产生它们的元素,例如列表、元组和字符串,而另一些则不会,例如集合和字典(Python 3.6 之前)。Python 让我们能够迭代可迭代对象,使用一种称为迭代器的对象类型。

根据官方文档(docs.python.org/3/glossary.html)的说法,迭代器是:

代表数据流的对象。对迭代器的 next()方法进行重复调用(或将其传递给内置函数 next())会返回数据流中的连续项目。当没有更多数据可用时,会引发 StopIteration 异常。此时,迭代器对象已耗尽,对其 next()方法的任何进一步调用都会再次引发 StopIteration。迭代器需要具有一个返回迭代器对象本身的 iter()方法,因此每个迭代器也是可迭代的,并且可以在大多数其他可接受可迭代对象的地方使用。一个值得注意的例外是尝试多次迭代传递的代码。容器对象(如列表)每次将其传递给 iter()函数或在 for 循环中使用时都会产生一个全新的迭代器。尝试对迭代器执行此操作将只返回上一次迭代传递中使用的相同耗尽的迭代器对象,使其看起来像一个空容器。

如果你不完全理解前面的法律术语,不要担心,你以后会理解的。我把它放在这里作为将来的方便参考。

实际上,整个可迭代/迭代器机制在代码后面有些隐藏。除非出于某种原因需要编写自己的可迭代或迭代器,否则你不必过多担心这个问题。但是理解 Python 如何处理这一关键的控制流方面非常重要,因为它将塑造你编写代码的方式。

遍历多个序列

让我们看另一个例子,如何迭代两个相同长度的序列,以便处理它们各自的元素对。假设我们有一个人员列表和一个代表第一个列表中人员年龄的数字列表。我们想要打印所有人员的姓名/年龄对。让我们从一个例子开始,然后逐渐完善它:

# multiple.sequences.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
for position in range(len(people)):
    person = people[position]
    age = ages[position]
    print(person, age)

到目前为止,这段代码对你来说应该很容易理解。我们需要遍历位置列表(0123),因为我们想要从两个不同的列表中检索元素。执行后,我们得到以下结果:

$ python multiple.sequences.py
Conrad 29
Deepak 30
Heinrich 34
Tom 36

这段代码既低效又不符合 Python 风格。它是低效的,因为根据位置检索元素可能是一个昂贵的操作,并且我们在每次迭代时都是从头开始做的。邮递员在递送信件时不会每次都回到路的起点,对吧?他们从一户到另一户。让我们尝试使用enumerate使其更好:

# multiple.sequences.enumerate.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
for position, person in enumerate(people):
    age = ages[position]
    print(person, age)

这样做更好,但还不完美。而且还有点丑陋。我们在people上进行了适当的迭代,但仍然使用位置索引获取age,我们也想要摆脱。别担心,Python 给了你zip函数,记得吗?让我们使用它:

# multiple.sequences.zip.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
for person, age in zip(people, ages):
    print(person, age)

啊!好多了!再次将前面的代码与第一个示例进行比较,并欣赏 Python 的优雅之处。我想展示这个例子的原因有两个。一方面,我想让您了解 Python 中较短的代码与其他语言相比有多么简洁,其他语言的语法不允许您像这样轻松地迭代序列或集合。另一方面,更重要的是,请注意,当for循环请求zip(sequenceA, sequenceB)的下一个元素时,它会得到一个元组,而不仅仅是一个单一对象。它会得到一个元组,其中包含与我们提供给zip函数的序列数量一样多的元素。让我们通过两种方式扩展前面的示例,使用显式和隐式赋值:

# multiple.sequences.explicit.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
nationalities = ['Poland', 'India', 'South Africa', 'England']
for person, age, nationality in zip(people, ages, nationalities):
    print(person, age, nationality)

在前面的代码中,我们添加了国籍列表。现在我们向zip函数提供了三个序列,for 循环在每次迭代时都会返回一个三元组。请注意,元组中元素的位置与zip调用中序列的位置相对应。执行代码将产生以下结果:

$ python multiple.sequences.explicit.py
Conrad 29 Poland
Deepak 30 India
Heinrich 34 South Africa
Tom 36 England

有时,由于在前面的简单示例中可能不太清楚的原因,您可能希望在for循环的主体中分解元组。如果这是您的愿望,完全可以做到:

# multiple.sequences.implicit.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
nationalities = ['Poland', 'India', 'South Africa', 'England']
for data in zip(people, ages, nationalities):
    person, age, nationality = data
    print(person, age, nationality)

它基本上是在某些情况下自动为您执行for循环的操作,但是在某些情况下,您可能希望自己执行。在这里,来自zip(...)的三元组datafor循环的主体中被分解为三个变量:personagenationality

while 循环

在前面的页面中,我们看到了for循环的运行情况。当您需要循环遍历一个序列或集合时,它非常有用。需要记住的关键点是,当您需要能够区分使用哪种循环结构时,for循环在必须迭代有限数量的元素时非常有效。它可以是一个巨大的数量,但是仍然是在某个点结束的东西。

然而,还有其他情况,当您只需要循环直到满足某个条件,甚至无限循环直到应用程序停止时,例如我们实际上没有东西可以迭代,因此for循环将是一个不好的选择。但是不用担心,对于这些情况,Python 为我们提供了while循环。

while循环类似于for循环,因为它们都循环,并且在每次迭代时执行一组指令。它们之间的不同之处在于while循环不会循环遍历一个序列(它可以,但您必须手动编写逻辑,而且这没有任何意义,您只想使用for循环),而是只要满足某个条件就会循环。当条件不再满足时,循环结束。

像往常一样,让我们看一个示例,以便更好地理解。我们想要打印一个正数的二进制表示。为了做到这一点,我们可以使用一个简单的算法,它收集除以2的余数(以相反的顺序),结果就是数字本身的二进制表示:

6 / 2 = 3 (remainder: 0) 
3 / 2 = 1 (remainder: 1) 
1 / 2 = 0 (remainder: 1) 
List of remainders: 0, 1, 1\. 
Inverse is 1, 1, 0, which is also the binary representation of 6: 110

让我们编写一些代码来计算数字 39 的二进制表示:100111[2]:

# binary.py
n = 39
remainders = []
while n > 0:
    remainder = n % 2  # remainder of division by 2
    remainders.insert(0, remainder)  # we keep track of remainders
    n //= 2  # we divide n by 2

print(remainders)

在前面的代码中,我突出显示了n > 0,这是保持循环的条件。我们可以通过使用divmod函数使代码变得更短(更符合 Python 风格),该函数使用一个数字和一个除数调用,并返回一个包含整数除法结果及其余数的元组。例如,divmod(13, 5)将返回(2, 3),确实5 * 2 + 3 = 13

# binary.2.py
n = 39
remainders = []
while n > 0:
    n, remainder = divmod(n, 2)
    remainders.insert(0, remainder)

print(remainders)

在前面的代码中,我们已经将n重新分配为除以2的结果,并在一行中得到了余数。

请注意,在while循环中的条件是继续循环的条件。如果评估为True,则执行主体,然后进行另一个评估,依此类推,直到条件评估为False。当发生这种情况时,循环立即退出,而不执行其主体。

如果条件永远不会评估为False,则循环变成所谓的无限循环。无限循环用于例如从网络设备轮询:您询问套接字是否有任何数据,如果有任何数据,则对其进行某些操作,然后您休眠一小段时间,然后再次询问套接字,一遍又一遍,永远不停止。

拥有循环条件或无限循环的能力,这就是为什么仅使用for循环是不够的原因,因此 Python 提供了while循环。

顺便说一句,如果您需要数字的二进制表示,请查看bin函数。

只是为了好玩,让我们使用while逻辑来调整一个例子(multiple.sequences.py):

# multiple.sequences.while.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
position = 0
while position < len(people):
    person = people[position]
    age = ages[position]
    print(person, age)
    position += 1

在前面的代码中,我突出显示了position变量的初始化条件更新,这使得可以通过手动处理迭代变量来模拟等效的for循环代码。所有可以使用for循环完成的工作也可以使用while循环完成,尽管您可以看到为了实现相同的结果,您需要经历一些样板文件。反之亦然,但除非您有理由这样做,否则您应该使用正确的工具来完成工作,99.9%的时间您都会没问题。

因此,总结一下,当您需要遍历可迭代对象时,请使用for循环,当您需要根据满足或不满足的条件循环时,请使用while循环。如果您记住了两种目的之间的区别,您将永远不会选择错误的循环结构。

现在让我们看看如何改变循环的正常流程。

中断和继续语句

根据手头的任务,有时您需要改变循环的常规流程。您可以跳过单个迭代(任意次数),或者完全退出循环。跳过迭代的常见用例是,例如,当您遍历项目列表并且只有在验证了某些条件时才需要处理每个项目时。另一方面,如果您正在遍历项目集,并且找到了满足您某些需求的项目,您可能决定不继续整个循环,因此退出循环。有无数种可能的情况,因此最好看一些例子。

假设您想对购物篮列表中所有今天到期的产品应用 20%的折扣。您实现这一点的方式是使用continue语句,它告诉循环结构(forwhile)立即停止执行主体并继续下一个迭代(如果有的话)。这个例子将带我们深入了解,所以准备好跳下去:

# discount.py
from datetime import date, timedelta

today = date.today()
tomorrow = today + timedelta(days=1)  # today + 1 day is tomorrow
products = [
    {'sku': '1', 'expiration_date': today, 'price': 100.0},
    {'sku': '2', 'expiration_date': tomorrow, 'price': 50},
    {'sku': '3', 'expiration_date': today, 'price': 20},
]

for product in products:
    if product['expiration_date'] != today:
        continue
    product['price'] *= 0.8  # equivalent to applying 20% discount
    print(
        'Price for sku', product['sku'],
        'is now', product['price'])

我们首先导入datetimedelta对象,然后设置我们的产品。sku13的产品具有“今天”的到期日期,这意味着我们希望对它们应用 20%的折扣。我们遍历每个产品并检查到期日期。如果它不是(不等运算符,!=)“今天”,我们不希望执行其余的主体套件,因此我们continue

请注意,在代码块中放置continue语句的位置并不重要(甚至可以使用多次)。当到达它时,执行停止并返回到下一次迭代。如果我们运行discount.py模块,这是输出:

$ python discount.py
Price for sku 1 is now 80.0
Price for sku 3 is now 16.0

这向你展示了循环体的最后两行没有被执行,对于sku编号2

现在让我们看一个中断循环的例子。假设我们想要判断列表中的至少一个元素在传递给bool函数时是否评估为True。鉴于我们需要知道是否至少有一个,当我们找到它时,就不需要继续扫描列表。在 Python 代码中,这意味着使用break语句。让我们把这写成代码:

# any.py
items = [0, None, 0.0, True, 0, 7]  # True and 7 evaluate to True

found = False  # this is called "flag"
for item in items:
    print('scanning item', item)
    if item:
        found = True  # we update the flag
        break

if found:  # we inspect the flag
    print('At least one item evaluates to True')
else:
    print('All items evaluate to False')

前面的代码在编程中是一个常见的模式,你会经常看到它。当你以这种方式检查项目时,基本上你是设置一个flag变量,然后开始检查。如果你找到一个符合你标准的元素(在这个例子中,评估为True),然后你更新标志并停止迭代。迭代后,你检查标志并相应地采取行动。执行结果是:

$ python any.py
scanning item 0
scanning item None
scanning item 0.0
scanning item True
At least one item evaluates to True

看到了吗?在找到True后执行停止了吗?break语句的作用与continue相同,即立即停止循环体的执行,但也阻止其他迭代运行,有效地跳出循环。continuebreak语句可以在forwhile循环结构中一起使用,数量上没有限制。

顺便说一句,没有必要编写代码来检测序列中是否至少有一个元素评估为True。只需查看内置的any函数。

特殊的 else 子句

我在 Python 语言中看到的一个特性是在whilefor循环后面有else子句的能力。它很少被使用,但绝对是一个不错的功能。简而言之,你可以在forwhile循环后面有一个else代码块。如果循环正常结束,因为迭代器耗尽(for循环)或者因为条件最终不满足(while循环),那么else代码块(如果存在)会被执行。如果执行被break语句中断,else子句就不会被执行。让我们来看一个for循环的例子,它遍历一组项目,寻找一个满足某些条件的项目。如果我们找不到至少一个满足条件的项目,我们想要引发一个异常。这意味着我们想要中止程序的正常执行,并且表示出现了一个错误或异常,我们无法处理。异常将在第八章中讨论,测试、分析和处理异常,所以如果你现在不完全理解它们,不用担心。只要记住它们会改变代码的正常流程。

现在让我向你展示两个做同样事情的例子,但其中一个使用了特殊的for...else语法。假设我们想在一群人中找到一个能开车的人:

# for.no.else.py
class DriverException(Exception):
    pass

people = [('James', 17), ('Kirk', 9), ('Lars', 13), ('Robert', 8)]
driver = None
for person, age in people:
    if age >= 18:
        driver = (person, age)
        break

if driver is None:
    raise DriverException('Driver not found.')

再次注意flag模式。我们将驾驶员设置为None,然后如果我们找到一个,我们会更新driver标志,然后在循环结束时检查它是否找到了。我有一种感觉,那些孩子可能会开一辆非常金属感的车,但无论如何,请注意,如果找不到驾驶员,将会引发DriverException,向程序表示执行无法继续(我们缺少驾驶员)。

相同的功能可以使用以下代码更加优雅地重写:

# for.else.py
class DriverException(Exception):
    pass

people = [('James', 17), ('Kirk', 9), ('Lars', 13), ('Robert', 8)]
for person, age in people:
    if age >= 18:
        driver = (person, age)
        break
else:
    raise DriverException('Driver not found.')

请注意,我们不再被迫使用flag模式。异常是作为for循环逻辑的一部分引发的,这是合理的,因为for循环正在检查某些条件。我们只需要在找到一个时设置一个driver对象,因为代码的其余部分将在某个地方使用该信息。请注意,代码更短、更优雅,因为逻辑现在正确地组合在一起。

将代码转换为优美、成语化的 Python视频中,Raymond Hettinger 建议为与 for 循环关联的else语句取一个更好的名字:nobreak。如果你在记住elsefor循环中的工作原理方面有困难,只需记住这个事实就应该能帮助你。

把所有这些放在一起

现在你已经看到了关于条件和循环的所有内容,是时候稍微调剂一下,看看我在本章开头预期的那两个例子。我们将在这里混合搭配,这样你就可以看到如何将所有这些概念结合起来使用。让我们先写一些代码来生成一个质数列表,直到某个限制为止。请记住,我将写一个非常低效和基本的算法来检测质数。对你来说重要的是集中精力关注代码中属于本章主题的部分。

质数生成器

根据维基百科:

质数(或质数)是大于 1 的自然数,除了 1 和它本身之外没有其他正因子。大于 1 的自然数如果不是质数,则称为合数。

根据这个定义,如果我们考虑前 10 个自然数,我们可以看到 2、3、5 和 7 是质数,而 1、4、6、8、9 和 10 不是。为了让计算机告诉你一个数N是否是质数,你可以将该数除以范围[2,N)内的所有自然数。如果其中任何一个除法的余数为零,那么这个数就不是质数。废话够多了,让我们开始吧。我将写两个版本,第二个版本将利用for...else语法:

# primes.py
primes = []  # this will contain the primes in the end
upto = 100  # the limit, inclusive
for n in range(2, upto + 1):
    is_prime = True  # flag, new at each iteration of outer for
    for divisor in range(2, n):
        if n % divisor == 0:
            is_prime = False
            break
    if is_prime:  # check on flag
        primes.append(n)
print(primes)

在前面的代码中有很多需要注意的事情。首先,我们设置了一个空的primes列表,它将在最后包含质数。限制是100,你可以看到我们在外部循环中调用range()的方式是包容的。如果我们写range(2, upto),那么是[2, upto),对吧?因此range(2, upto + 1)给我们[2, upto + 1) == [2, upto]

因此,有两个for循环。在外部循环中,我们循环遍历候选质数,即从2upto的所有自然数。在外部循环的每次迭代中,我们设置一个标志(在每次迭代时设置为True),然后开始将当前的n除以从2n-1的所有数字。如果我们找到n的一个适当的除数,那么意味着n是合数,因此我们将标志设置为False并中断循环。请注意,当我们中断内部循环时,外部循环会继续正常进行。我们之所以在找到n的适当除数后中断,是因为我们不需要任何进一步的信息就能判断n不是质数。

当我们检查is_prime标志时,如果它仍然是True,这意味着我们在[2,n)中找不到任何是n的适当除数的数字,因此n是质数。我们将n添加到primes列表中,然后继续下一个迭代,直到n等于100

运行这段代码会产生:

$ python primes.py
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97] 

在我们继续之前,有一个问题:在外部循环的所有迭代中,其中一个与其他所有迭代不同。你能告诉哪一个,以及为什么吗?想一想,回到代码,试着自己找出答案,然后继续阅读。

你弄清楚了吗?如果没有,不要感到难过,这是完全正常的。我让你做这个小练习,因为这是程序员一直在做的事情。通过简单地查看代码来理解代码的功能是您随着时间建立的技能。这非常重要,所以尽量在您能做到的时候进行练习。我现在告诉你答案:与所有其他迭代不同的是第一个迭代。原因是因为在第一次迭代中,n2。因此,最内层的for循环甚至不会运行,因为它是一个迭代range(2, 2)for循环,那不就是[2, 2)吗?自己试一下,用这个可迭代对象编写一个简单的for循环,将print放在主体套件中,看看是否会发生任何事情(不会...)。

现在,从算法的角度来看,这段代码是低效的,所以让我们至少让它更美观一些:

# primes.else.py
primes = []
upto = 100
for n in range(2, upto + 1):
    for divisor in range(2, n):
        if n % divisor == 0:
            break
    else:
        primes.append(n)
print(primes)

漂亮多了,对吧?is_prime标志消失了,当我们知道内部for循环没有遇到任何break语句时,我们将n附加到primes列表中。看看代码看起来更清晰,阅读起来更好了吗?

应用折扣

在这个例子中,我想向你展示一种我非常喜欢的技术。在许多编程语言中,除了if/elif/else结构之外,无论以什么形式或语法,你都可以找到另一个语句,通常称为switch/case,在 Python 中缺少。它相当于一系列if/elif/.../elif/else子句,其语法类似于这样(警告!JavaScript 代码!):

/* switch.js */
switch (day_number) {
    case 1:
    case 2:
    case 3:
    case 4:
    case 5:
        day = "Weekday";
        break;
    case 6:
        day = "Saturday";
        break;
    case 0:
        day = "Sunday";
        break;
    default:
        day = "";
        alert(day_number + ' is not a valid day number.')
}

在前面的代码中,我们根据名为day_number的变量进行switch。这意味着我们获取它的值,然后决定它适用于哪种情况(如果有的话)。从15有一个级联,这意味着无论数字如何,[15]都会进入将day设置为“工作日”的逻辑部分。然后我们有06的单个情况,以及一个default情况来防止错误,它会提醒系统day_number不是有效的日期数字,即不在[06]中。Python 完全能够使用if/elif/else语句实现这样的逻辑:

# switch.py
if 1 <= day_number <= 5:
    day = 'Weekday'
elif day_number == 6:
    day = 'Saturday'
elif day_number == 0:
    day = 'Sunday'
else:
    day = ''
    raise ValueError(
        str(day_number) + ' is not a valid day number.')

在前面的代码中,我们使用if/elif/else语句在 Python 中复制了 JavaScript 片段的相同逻辑。我最后提出了ValueError异常,如果day_number不在[06]中,这只是一个例子。这是将switch/case逻辑转换的一种可能方式,但还有另一种方式,有时称为分派,我将在下一个示例的最后版本中向您展示。

顺便说一下,你有没有注意到前面片段的第一行?你有没有注意到 Python 可以进行双重(实际上甚至多重)比较?这太棒了!

让我们通过简单地编写一些代码来开始新的示例,根据客户的优惠券价值为他们分配折扣。我会尽量保持逻辑的最低限度,记住我们真正关心的是理解条件和循环:

# coupons.py
customers = [
    dict(id=1, total=200, coupon_code='F20'),  # F20: fixed, £20
    dict(id=2, total=150, coupon_code='P30'),  # P30: percent, 30%
    dict(id=3, total=100, coupon_code='P50'),  # P50: percent, 50%
    dict(id=4, total=110, coupon_code='F15'),  # F15: fixed, £15
]
for customer in customers:
    code = customer['coupon_code']
    if code == 'F20':
        customer['discount'] = 20.0
    elif code == 'F15':
        customer['discount'] = 15.0
    elif code == 'P30':
        customer['discount'] = customer['total'] * 0.3
    elif code == 'P50':
        customer['discount'] = customer['total'] * 0.5
    else:
        customer['discount'] = 0.0

for customer in customers:
    print(customer['id'], customer['total'], customer['discount'])

我们首先设置一些客户。他们有订单总额、优惠券代码和 ID。我编造了四种不同类型的优惠券,两种是固定的,两种是基于百分比的。你可以看到,在if/elif/else级联中,我相应地应用折扣,并将其设置为customer字典中的'discount'键。

最后,我只打印出部分数据,看看我的代码是否正常工作:

$ python coupons.py
1 200 20.0
2 150 45.0
3 100 50.0
4 110 15.0

这段代码很容易理解,但所有这些子句有点混乱。一眼看上去很难看出发生了什么,我不喜欢。在这种情况下,你可以利用字典来发挥你的优势,就像这样:

# coupons.dict.py
customers = [
    dict(id=1, total=200, coupon_code='F20'),  # F20: fixed, £20
    dict(id=2, total=150, coupon_code='P30'),  # P30: percent, 30%
    dict(id=3, total=100, coupon_code='P50'),  # P50: percent, 50%
    dict(id=4, total=110, coupon_code='F15'),  # F15: fixed, £15
]
discounts = {
    'F20': (0.0, 20.0),  # each value is (percent, fixed)
    'P30': (0.3, 0.0),
    'P50': (0.5, 0.0),
    'F15': (0.0, 15.0),
}
for customer in customers:
    code = customer['coupon_code']
    percent, fixed = discounts.get(code, (0.0, 0.0))
    customer['discount'] = percent * customer['total'] + fixed

for customer in customers:
    print(customer['id'], customer['total'], customer['discount'])

运行前面的代码产生了与之前片段相同的结果。我们节省了两行,但更重要的是,我们在可读性上获得了很多好处,因为for循环的主体现在只有三行,而且非常容易理解。这里的概念是将字典用作分发器。换句话说,我们尝试从字典中根据代码(我们的coupon_code)获取一些东西,并通过dict.get(key, default),我们确保当code不在字典中时,我们也需要一个默认值。

请注意,我必须应用一些非常简单的线性代数来正确计算折扣。字典中的每个折扣都有一个百分比和固定部分,由一个二元组表示。通过应用percent * total + fixed,我们得到正确的折扣。当percent0时,该公式只给出固定金额,当固定为0时,它给出percent * total

这种技术很重要,因为它也用在其他情境中,比如函数,它实际上比我们在前面片段中看到的要强大得多。使用它的另一个优势是,你可以以这样的方式编码,使得discounts字典的键和值可以动态获取(例如,从数据库中获取)。这将使代码能够适应你所拥有的任何折扣和条件,而无需修改任何内容。

如果你不完全明白它是如何工作的,我建议你花点时间来试验一下。更改值并添加打印语句,看看程序运行时发生了什么。

快速浏览 itertools 模块

关于可迭代对象、迭代器、条件逻辑和循环的章节,如果没有提到itertools模块,就不完整了。如果你喜欢迭代,这是一种天堂。

根据 Python 官方文档(docs.python.org/2/library/itertools.html),itertools模块是:

这个模块实现了一些受 APL、Haskell 和 SML 构造启发的迭代器构建块。每个都已经被重塑成适合 Python 的形式。该模块标准化了一组核心的快速、内存高效的工具,这些工具本身或组合在一起都很有用。它们一起形成了一个“迭代器代数”,使得可以在纯 Python 中简洁高效地构建专门的工具。

在这里我无法向你展示这个模块中所有的好东西,所以我鼓励你自己去查看,我保证你会喜欢的。简而言之,它为您提供了三种广泛的迭代器类别。我将给你一个非常小的例子,来自每一个迭代器,只是为了让你稍微流口水。

无限迭代器

无限迭代器允许您以不同的方式使用for循环,就像它是一个while循环一样:

# infinite.py
from itertools import count

for n in count(5, 3):
    if n > 20:
        break
    print(n, end=', ') # instead of newline, comma and space

运行代码会得到这个结果:

$ python infinite.py
5, 8, 11, 14, 17, 20,

count工厂类创建一个迭代器,它只是不断地计数。它从5开始,然后不断加3。如果我们不想陷入无限循环,我们需要手动中断它。

在最短输入序列上终止的迭代器

这个类别非常有趣。它允许您基于多个迭代器创建一个迭代器,根据某种逻辑组合它们的值。关键点在于,在这些迭代器中,如果有任何一个比其余的短,那么生成的迭代器不会中断,它将在最短的迭代器耗尽时停止。这非常理论化,我知道,所以让我用compress给你举个例子。这个迭代器根据选择器中的相应项目是True还是False,给你返回数据:

compress('ABC', (1, 0, 1))会返回'A''C',因为它们对应于1。让我们看一个简单的例子:

# compress.py
from itertools import compress
data = range(10)
even_selector = [1, 0] * 10
odd_selector = [0, 1] * 10

even_numbers = list(compress(data, even_selector))
odd_numbers = list(compress(data, odd_selector))

print(odd_selector)
print(list(data))
print(even_numbers)
print(odd_numbers)

请注意,odd_selectoreven_selector的长度为 20 个元素,而data只有 10 个元素。compress将在data产生最后一个元素时停止。运行此代码会产生以下结果:

$ python compress.py
[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 2, 4, 6, 8]
[1, 3, 5, 7, 9]

这是一种非常快速和方便的从可迭代对象中选择元素的方法。代码非常简单,只需注意,我们使用list()而不是使用for循环来迭代压缩调用返回的每个值,list()做的事情是一样的,但是它不执行一系列指令,而是将所有的值放入一个列表并返回它。

组合生成器

最后但并非最不重要的,组合生成器。如果你对这种事情感兴趣,这些真的很有趣。让我们看一个关于排列的简单例子。

根据 Wolfram Mathworld:

排列,也称为“排列数”或“顺序”,是有序列表 S 的元素重新排列成与 S 本身一一对应的过程。

例如,ABC 有六种排列:ABC,ACB,BAC,BCA,CAB 和 CBA。

如果一个集合有N个元素,那么它们的排列数就是N!N的阶乘)。对于 ABC 字符串,排列数为3! = 3 * 2 * 1 = 6。让我们用 Python 来做一下:

# permutations.py
from itertools import permutations 
print(list(permutations('ABC'))) 

这段非常简短的代码产生了以下结果:

$ python permutations.py
[('A', 'B', 'C'), ('A', 'C', 'B'), ('B', 'A', 'C'), ('B', 'C', 'A'), ('C', 'A', 'B'), ('C', 'B', 'A')]

当你玩排列时要非常小心。它们的数量增长速度与你进行排列的元素的阶乘成正比,而这个数字可能会变得非常大,非常快。

总结

在本章中,我们又迈出了一步,扩展了我们的编码词汇。我们已经看到如何通过评估条件来驱动代码的执行,以及如何循环和迭代序列和对象集合。这赋予了我们控制代码运行时发生的事情的能力,这意味着我们正在了解如何塑造代码,使其按照我们的意愿进行操作,并对动态变化的数据做出反应。

我们还看到了如何在几个简单的例子中将所有东西结合在一起,最后,我们简要地看了一下itertools模块,这个模块充满了有趣的迭代器,可以进一步丰富我们使用 Python 的能力。

现在是时候换个方式,向前迈进一步,谈谈函数。下一章将全面讨论它们,因为它们非常重要。确保你对到目前为止所涵盖的内容感到舒适。我想为你提供有趣的例子,所以我将不得不加快速度。准备好了吗?翻页吧。

第四章:函数,代码的构建块

“创建建筑就是整理。整理什么?函数和对象。” - 勒·柯布西耶

在前几章中,我们已经看到在 Python 中一切都是对象,函数也不例外。但是,函数究竟是什么?函数是一系列执行任务的指令,作为一个单元捆绑在一起。然后可以导入这个单元并在需要的地方使用。在代码中使用函数有许多优点,我们很快就会看到。

在本章中,我们将涵盖以下内容:

  • 函数-它们是什么,为什么我们应该使用它们

  • 作用域和名称解析

  • 函数签名-输入参数和返回值

  • 递归和匿名函数

  • 导入对象以便重用代码

我相信这句话,一张图片胜过一千言语,在向一个对这个概念新手解释函数时尤其正确,所以请看一下下面的图表:

如你所见,函数是一系列指令的块,作为一个整体打包,就像一个盒子。函数可以接受输入参数并产生输出值。这两者都是可选的,正如我们将在本章的例子中看到的那样。

在 Python 中,函数是通过使用def关键字来定义的,随后是函数的名称,后面跟着一对括号(可能包含输入参数,也可能不包含),冒号(:)表示函数定义行的结束。紧接着,缩进四个空格,我们找到函数的主体,这是函数在调用时将执行的一系列指令。

请注意,缩进四个空格不是强制性的,但这是PEP 8建议的空格数量,并且在实践中是最广泛使用的间距度量。

函数可能会返回输出,也可能不会。如果函数想要返回输出,它会使用return关键字,后面跟着期望的输出。如果你有鹰眼,你可能已经注意到在前面图表的输出部分中Optional后面的小*****。这是因为在 Python 中,函数总是返回一些东西,即使你没有明确使用return子句。如果函数体中没有return语句,或者return语句本身没有给出值,函数将返回None。这种设计选择背后的原因超出了介绍章节的范围,所以你需要知道的是这种行为会让你的生活更轻松。一如既往,感谢 Python。

为什么使用函数?

函数是任何语言中最重要的概念和构造之一,所以让我给你一些我们需要它们的原因:

  • 它们减少了程序中的代码重复。通过让一个特定的任务由一个好的打包代码块来处理,我们可以导入并在需要时调用它,而不需要复制它的实现。

  • 它们有助于将复杂的任务或过程分割成较小的块,每个块都成为一个函数。

  • 它们隐藏了实现细节,使用户看不到。

  • 它们提高了可追溯性。

  • 它们提高了可读性。

让我们看几个例子,以更好地理解每一点。

减少代码重复

想象一下,你正在编写一款科学软件,需要计算素数直到一个限制,就像我们在上一章中所做的那样。你有一个很好的算法来计算它们,所以你把它复制粘贴到你需要的地方。然而,有一天,你的朋友,B.黎曼,给了你一个更好的算法来计算素数,这将节省你很多时间。在这一点上,你需要检查整个代码库,并用新的代码替换旧的代码。

这实际上是一个不好的做法。这容易出错,你永远不知道你是不是误删或遗漏了哪些行,当你将代码剪切和粘贴到其他代码中时,你也可能会错过其中进行质数计算的地方之一,导致软件处于不一致的状态,同样的操作在不同地方以不同的方式执行。如果你需要用更好的版本替换代码,而不是修复错误,你会错过其中一个地方吗?那将更糟糕。

那么,你应该怎么做呢?简单!你写一个函数,get_prime_numbers(upto),并在任何需要质数列表的地方使用它。当 B. Riemann 给你新代码时,你只需要用新实现替换该函数的主体,然后就完成了!软件的其余部分将自动适应,因为它只是调用函数。

你的代码会更短,不会受到在执行任务的旧方法和新方法之间的不一致性的影响,也不会因为复制粘贴失败或疏忽而导致未检测到的错误。使用函数,你只会从中获益,我保证。

分解复杂任务

函数还非常有用,可以将长或复杂的任务分解为较小的任务。最终结果是,代码从中受益的方式有很多,例如可读性、可测试性和可重用性。举个简单的例子,想象一下你正在准备一份报告。你的代码需要从数据源获取数据,解析数据,过滤数据,整理数据,然后需要运行一系列算法来产生将供Report类使用的结果。阅读这样的程序通常只有一个大的do_report(data_source)函数。有数十行或数百行代码以return report结束。

这些情况在科学代码中更常见,科学代码在算法上往往很出色,但有时在编写风格方面缺乏经验丰富的程序员的触感。现在,想象一下几百行代码。很难跟进,找到事情在改变上下文的地方(比如完成一个任务并开始下一个任务)。你有心中的画面了吗?好。不要这样做!相反,看看这段代码:

# data.science.example.py
def do_report(data_source):
    # fetch and prepare data
    data = fetch_data(data_source)
    parsed_data = parse_data(data)
    filtered_data = filter_data(parsed_data)
    polished_data = polish_data(filtered_data)

    # run algorithms on data
    final_data = analyse(polished_data)

    # create and return report
    report = Report(final_data)
    return report

前面的例子当然是虚构的,但你能看出通过代码会有多容易吗?如果最终结果看起来不对,逐个调试do_report函数中的每个单个数据输出将非常容易。此外,暂时从整个过程中排除部分过程也更容易(你只需要注释掉需要暂停的部分)。这样的代码更容易处理。

隐藏实现细节

让我们继续使用前面的例子来谈谈这一点。你可以看到,通过查看do_report函数的代码,即使不阅读一行实现代码,你也能很好地理解。这是因为函数隐藏了实现细节。这个特性意味着,如果你不需要深入了解细节,你就不会被迫这样做,就像如果do_report只是一个庞大的函数一样。为了理解发生了什么,你必须阅读每一行代码。但使用函数,你就不需要这样做。这减少了你阅读代码的时间,而在专业环境中,阅读代码所花费的时间远远超过编写代码的时间,因此尽可能减少这一时间非常重要。

提高可读性

编码人员有时看不出编写一个只有一两行代码的函数的意义,所以让我们看一个示例,告诉你为什么你应该这样做。

想象一下,你需要将两个矩阵相乘:

你更喜欢阅读这段代码吗:

# matrix.multiplication.nofunc.py
a = [[1, 2], [3, 4]]
b = [[5, 1], [2, 1]]

c = [[sum(i * j for i, j in zip(r, c)) for c in zip(*b)]
     for r in a]

或者你更喜欢这个:

# matrix.multiplication.func.py
# this function could also be defined in another module
def matrix_mul(a, b):
    return [[sum(i * j for i, j in zip(r, c)) for c in zip(*b)]
            for r in a]

a = [[1, 2], [3, 4]]
b = [[5, 1], [2, 1]]
c = matrix_mul(a, b)

在第二个例子中,更容易理解cab之间乘法的结果。通过代码更容易阅读,如果你不需要修改乘法逻辑,甚至不需要深入了解实现细节。因此,在这里提高了可读性,而在第一个片段中,你将不得不花时间去理解那个复杂的列表推导在做什么。

如果你不理解列表推导,不要担心,我们将在第五章中学习它们,节省时间和内存

提高可追踪性

想象一下,你已经写了一个电子商务网站。你在页面上展示了产品价格。假设你的数据库中的价格是不含增值税(销售税)的,但你想在网站上以 20%的增值税显示它们。以下是从不含增值税价格计算含增值税价格的几种方式:

# vat.py
price = 100  # GBP, no VAT
final_price1 = price * 1.2
final_price2 = price + price / 5.0
final_price3 = price * (100 + 20) / 100.0
final_price4 = price + price * 0.2

这四种不同的计算增值税含价的方式都是完全可以接受的,我向你保证,这些方式我在多年的同事代码中都找到过。现在,想象一下,你已经开始在不同的国家销售你的产品,其中一些国家有不同的增值税率,所以你需要重构你的代码(整个网站)以使增值税计算动态化。

你如何追踪所有进行增值税计算的地方?编码今天是一个协作的任务,你不能确定增值税是使用这些形式中的一个进行计算的。相信我,这将是一场噩梦。

因此,让我们编写一个函数,它接受输入值vatprice(不含增值税),并返回含增值税的价格:

# vat.function.py
def calculate_price_with_vat(price, vat):
    return price * (100 + vat) / 100

现在你可以导入该函数,并在网站中任何需要计算含增值税价格的地方使用它,当你需要追踪这些调用时,你可以搜索calculate_price_with_vat

请注意,在前面的例子中,假定price是不含增值税的,vat是一个百分比值(例如,19、20 或 23)。

作用域和名称解析

还记得我们在第一章中讨论作用域和命名空间吗,Python 简介?我们现在要扩展这个概念。最后,我们可以谈谈函数,这将使一切更容易理解。让我们从一个非常简单的例子开始:

# scoping.level.1.py
def my_function():
    test = 1  # this is defined in the local scope of the function
    print('my_function:', test)

test = 0  # this is defined in the global scope
my_function()
print('global:', test)

在前面的例子中,我在两个不同的地方定义了test名称。它实际上在两个不同的作用域中。一个是全局作用域(test = 0),另一个是my_function函数的局部作用域(test = 1)。如果你执行这段代码,你会看到这个:

$ python scoping.level.1.py
my_function: 1
global: 0

很明显,test = 1覆盖了my_function中的test = 0赋值。在全局上下文中,test仍然是0,正如你从程序的输出中看到的那样,但是我们在函数体中重新定义了test名称,并将其指向值为1的整数。因此,这两个test名称都存在,一个在全局范围内,指向值为0int对象,另一个在my_function范围内,指向值为1int对象。让我们注释掉test = 1的那一行。Python 会在下一个封闭的命名空间中搜索test名称(回想一下LEGB规则:localenclosingglobalbuilt-in,在第一章中描述的Python 简介),在这种情况下,我们将看到值0被打印两次。在你的代码中试一下。

现在,让我们提高一下难度:

# scoping.level.2.py
def outer():
    test = 1  # outer scope
    def inner():
        test = 2  # inner scope
        print('inner:', test)

    inner()
    print('outer:', test)

test = 0  # global scope
outer()
print('global:', test)

在前面的代码中,我们有两个级别的遮蔽。一个级别在函数outer中,另一个级别在函数inner中。这并不是什么高深的科学,但可能会有些棘手。如果我们运行这段代码,我们会得到:

$ python scoping.level.2.py
inner: 2
outer: 1
global: 0

试着注释掉test = 1这一行。你能猜到结果会是什么吗?嗯,当到达print('outer:', test)这一行时,Python 将不得不在下一个封闭作用域中查找test,因此它会找到并打印0,而不是1。确保你也注释掉test = 2,看看你是否理解发生了什么,以及 LEGB 规则是否清楚,然后再继续。

还有一点要注意的是,Python 允许你在另一个函数中定义一个函数。内部函数的名称是在外部函数的命名空间中定义的,就像其他任何名称一样。

全局和非局部语句

回到前面的例子,我们可以通过使用这两个特殊语句之一:globalnonlocal,来改变对test名称的遮蔽。正如你从前面的例子中看到的,当我们在inner函数中定义test = 2时,我们既没有覆盖outer函数中的test,也没有覆盖全局作用域中的test。如果我们在没有定义它们的嵌套作用域中使用它们,我们可以获得对这些名称的读取访问权限,但我们不能修改它们,因为当我们写一个赋值指令时,实际上是在当前作用域中定义一个新名称。

我们如何改变这种行为呢?嗯,我们可以使用nonlocal语句。根据官方文档:

“非局部语句使得列出的标识符引用最近的封闭作用域中先前绑定的变量,不包括全局变量。”

让我们在inner函数中引入它,看看会发生什么:

# scoping.level.2.nonlocal.py
def outer():
    test = 1  # outer scope
    def inner():
        nonlocal test
        test = 2  # nearest enclosing scope (which is 'outer')
        print('inner:', test)

    inner()
    print('outer:', test)

test = 0  # global scope
outer()
print('global:', test)

请注意,在inner函数的主体中,我已经声明了test名称为nonlocal。运行这段代码会产生以下结果:

$ python scoping.level.2.nonlocal.py
inner: 2
outer: 2
global: 0

哇,看看那个结果!这意味着,通过在inner函数中声明testnonlocal,我们实际上将test名称绑定到了在outer函数中声明的test。如果我们从inner函数中删除nonlocal test行,并在outer函数中尝试相同的技巧,我们将得到一个SyntaxError,因为nonlocal语句只在不包括全局作用域的封闭作用域中起作用。

那么有没有办法访问全局命名空间中的test = 0呢?当然有,我们只需要使用global语句:

# scoping.level.2.global.py
def outer():
    test = 1  # outer scope
    def inner():
        global test
        test = 2  # global scope
        print('inner:', test)

    inner()
    print('outer:', test)

test = 0  # global scope
outer()
print('global:', test)

请注意,我们现在已经声明了test名称为global,这基本上将其绑定到我们在全局命名空间中定义的那个(test = 0)。运行代码,你应该会得到以下结果:

$ python scoping.level.2.global.py
inner: 2
outer: 1
global: 2

这表明受test = 2赋值影响的名称现在是global。这个技巧在outer函数中也会起作用,因为在这种情况下,我们是在引用全局作用域。试一试,看看有什么变化,熟悉一下作用域和名称解析,这很重要。另外,你能告诉我在前面的例子中如果在outer之外定义inner会发生什么吗?

输入参数

在本章的开头,我们看到函数可以接受输入参数。在我们深入讨论所有可能类型的参数之前,让我们确保你清楚地理解了将参数传递给函数意味着什么。有三个关键点需要记住:

  • 参数传递只不过是将一个对象分配给一个局部变量名

  • 在函数内部将对象分配给参数名称不会影响调用者

  • 在函数中更改可变对象参数会影响调用者

让我们分别看一下每个观点的例子。

参数传递

看一下下面的代码。我们在全局作用域中声明了一个名称x,然后我们声明了一个函数func(y),最后我们调用它,传递了x

# key.points.argument.passing.py
x = 3
def func(y):
    print(y)
func(x)  # prints: 3

funcx调用时,在它的局部作用域中,创建了一个名称y,它指向了x指向的相同对象。这通过下图更好地解释了(不用担心 Python 3.3,这是一个没有改变的特性):

前面图的右侧部分描述了程序在执行到最后(func返回None后)的状态。看一下 Frames 列,注意全局命名空间(全局帧)中有两个名称,xfunc,分别指向一个int(值为3)和一个function对象。在其下方的名为func的矩形中,我们可以看到函数的局部命名空间,其中只定义了一个名称y。因为我们用x调用了func(图的左侧第 5 行),y指向与x指向的相同的对象。这就是在将参数传递给函数时发生的情况。如果我们在函数定义中使用名称x而不是y,情况将完全相同(可能一开始有点混乱),函数中会有一个局部的x,外部会有一个全局的x,就像我们在本章前面看到的作用域和名称解析部分一样。

总之,实际发生的是函数在其局部范围内创建了作为参数定义的名称,当我们调用它时,我们基本上告诉 Python 这些名称必须指向哪些对象。

分配给参数名称不会影响调用者

这一点一开始可能会难以理解,所以让我们看一个例子:

# key.points.assignment.py
x = 3
def func(x):
    x = 7  # defining a local x, not changing the global one
func(x)
print(x)  # prints: 3

在前面的代码中,当执行x = 7时,在func函数的局部范围内,名称x指向一个值为7的整数,而全局的x保持不变。

改变可变对象会影响调用者

这是最后一点,非常重要,因为 Python 在处理可变对象时表现出不同的行为(尽管只是表面上)。让我们看一个例子:

# key.points.mutable.py
x = [1, 2, 3]
def func(x):
    x[1] = 42  # this affects the caller!

func(x)
print(x)  # prints: [1, 42, 3]

哇,我们实际上改变了原始对象!如果你仔细想想,这种行为并不奇怪。函数调用中的x名称被设置为指向调用者对象,并且在函数体内,我们没有改变x,也就是说,我们没有改变它的引用,换句话说,我们没有改变x指向的对象。我们正在访问该对象在位置 1 的元素,并改变它的值。

记住输入参数部分的第 2 点:在函数内将对象分配给参数名称不会影响调用者。如果这对你来说很清楚,下面的代码就不会让人感到惊讶:

# key.points.mutable.assignment.py
x = [1, 2, 3]
def func(x):
    x[1] = 42  # this changes the caller!
    x = 'something else'  # this points x to a new string object

func(x)
print(x)  # still prints: [1, 42, 3]

看一下我标记的两行。一开始,就像以前一样,我们再次访问调用者对象,在位置 1 处将其值更改为数字42。然后,我们重新分配x指向'something else'字符串。这不会改变调用者,并且实际上输出与前面片段的输出相同。

花点时间来玩弄这个概念,并尝试使用打印和调用id函数,直到你的思维中一切都清楚为止。这是 Python 的一个关键方面,必须非常清楚,否则你可能会在代码中引入微妙的错误。再一次,Python Tutor 网站(www.pythontutor.com/)将通过可视化这些概念来帮助你很多。

现在我们对输入参数及其行为有了很好的理解,让我们看看如何指定它们。

如何指定输入参数

有五种不同的指定输入参数的方式:

  • 位置参数

  • 关键字参数

  • 可变位置参数

  • 可变关键字参数

  • 仅限关键字参数

让我们逐个来看看它们。

位置参数

位置参数是从左到右读取的,它们是最常见的参数类型:

# arguments.positional.py
def func(a, b, c):
    print(a, b, c)
func(1, 2, 3)  # prints: 1 2 3

没有太多其他的事情可说。它们可以是任意多的,并且按位置分配。在函数调用中,1先出现,2第二出现,3第三出现,因此它们分别分配给abc

关键字参数和默认值

关键字参数是使用name=value语法按关键字分配的:

# arguments.keyword.py
def func(a, b, c):
    print(a, b, c)
func(a=1, c=2, b=3)  # prints: 1 3 2

关键字参数是根据名称匹配的,即使它们不遵守定义的原始位置(当我们混合和匹配不同类型的参数时,我们将看到这种行为有一个限制)。

关键字参数的对应物,在定义方面,是默认值。语法是相同的,name=value,并且允许我们不必提供参数,如果我们对给定的默认值满意的话:

# arguments.default.py
def func(a, b=4, c=88):
    print(a, b, c)

func(1)  # prints: 1 4 88
func(b=5, a=7, c=9)  # prints: 7 5 9
func(42, c=9)  # prints: 42 4 9
func(42, 43, 44)  # prints: 42, 43, 44

有两件很重要的事情需要注意。首先,你不能在位置参数的左边指定默认参数。其次,在这些例子中,当一个参数被传递而没有使用argument_name=value语法时,它必须是列表中的第一个,并且总是被赋值给a。还要注意,以位置方式传递值仍然有效,并且遵循函数签名的顺序(例子的最后一行)。

尝试混淆这些参数,看看会发生什么。Python 的错误消息非常擅长告诉你出了什么问题。所以,例如,如果你尝试这样做:

# arguments.default.error.py
def func(a, b=4, c=88):
    print(a, b, c)
func(b=1, c=2, 42)  # positional argument after keyword one

你会得到以下错误:

$ python arguments.default.error.py
 File "arguments.default.error.py", line 4
 func(b=1, c=2, 42) # positional argument after keyword one
 ^
SyntaxError: positional argument follows keyword argument

这会告诉你你调用函数的方式不正确。

可变位置参数

有时候你可能想要向函数传递可变数量的位置参数,Python 提供了这样的能力。让我们看一个非常常见的用例,minimum函数。这是一个计算其输入值的最小值的函数:

# arguments.variable.positional.py
def minimum(*n):
    # print(type(n))  # n is a tuple
    if n:  # explained after the code
        mn = n[0]
        for value in n[1:]:
            if value < mn:
                mn = value
        print(mn)

minimum(1, 3, -7, 9)  # n = (1, 3, -7, 9) - prints: -7
minimum()             # n = () - prints: nothing

正如你所看到的,当我们在参数名前面加上*时,我们告诉 Python 该参数将根据函数的调用方式收集可变数量的位置参数。在函数内部,n是一个元组。取消注释print(type(n)),自己看看并玩弄一下。

你是否注意到我们如何用简单的if n:检查n是否为空?这是因为在 Python 中,集合对象在非空时求值为True,否则为False。这对于元组、集合、列表、字典等都是成立的。

还有一件事需要注意的是,当我们调用函数时没有传递参数时,我们可能希望抛出一个错误,而不是默默地什么都不做。在这种情况下,我们不关心使这个函数健壮,而是要理解可变位置参数。

让我们举个例子来展示两件事,根据我的经验,这对于新手来说是令人困惑的:

# arguments.variable.positional.unpacking.py
def func(*args):
    print(args)

values = (1, 3, -7, 9)
func(values)   # equivalent to: func((1, 3, -7, 9))
func(*values)  # equivalent to: func(1, 3, -7, 9)

仔细看一下前面例子的最后两行。在第一个例子中,我们用一个参数调用func,一个四元组。在第二个例子中,通过使用*语法,我们在做一种叫做解包的操作,这意味着四元组被解包,函数被调用时有四个参数:1, 3, -7, 9

这种行为是 Python 为了让你在动态调用函数时做一些惊人的事情而做的魔术的一部分。

可变关键字参数

可变关键字参数与可变位置参数非常相似。唯一的区别是语法(**而不是*)以及它们被收集在一个字典中。收集和解包的工作方式相同,让我们看一个例子:

# arguments.variable.keyword.py
def func(**kwargs):
    print(kwargs)

# All calls equivalent. They print: {'a': 1, 'b': 42}
func(a=1, b=42)
func(**{'a': 1, 'b': 42})
func(**dict(a=1, b=42))

在前面的例子中,所有的调用都是等价的。你可以看到,在函数定义中在参数名前面添加**告诉 Python 使用该名称来收集可变数量的关键字参数。另一方面,当我们调用函数时,我们可以显式传递name=value参数,或者使用相同的**语法解包字典。

能够传递可变数量的关键字参数的重要性可能目前还不明显,那么,来看一个更现实的例子如何?让我们定义一个连接到数据库的函数。我们希望通过简单调用这个函数而连接到默认数据库。我们还希望通过传递适当的参数来连接到任何其他数据库。在继续阅读之前,试着花几分钟时间自己想出一个解决方案:

# arguments.variable.db.py
def connect(**options):
    conn_params = {
        'host': options.get('host', '127.0.0.1'),
        'port': options.get('port', 5432),
        'user': options.get('user', ''),
        'pwd': options.get('pwd', ''),
    }
    print(conn_params)
    # we then connect to the db (commented out)
    # db.connect(**conn_params)

connect()
connect(host='127.0.0.42', port=5433)
connect(port=5431, user='fab', pwd='gandalf')

注意在函数中,我们可以准备一个连接参数的字典(conn_params),使用默认值作为回退,允许在函数调用中提供这些参数时覆盖它们。有更少行代码的更好的方法来做到这一点,但我们现在不关心这个。运行前面的代码产生了以下结果:

$ python arguments.variable.db.py
{'host': '127.0.0.1', 'port': 5432, 'user': '', 'pwd': ''}
{'host': '127.0.0.42', 'port': 5433, 'user': '', 'pwd': ''}
{'host': '127.0.0.1', 'port': 5431, 'user': 'fab', 'pwd': 'gandalf'}

注意函数调用和输出之间的对应关系。注意默认值是如何根据传递给函数的内容被覆盖的。

仅限关键字参数

Python 3 允许一种新类型的参数:仅限关键字参数。我们只会简要地研究它们,因为它们的使用情况并不那么频繁。有两种指定它们的方式,要么在可变位置参数之后,要么在一个裸的*之后。让我们看一下两种方式的例子:

# arguments.keyword.only.py
def kwo(*a, c):
    print(a, c)

kwo(1, 2, 3, c=7)  # prints: (1, 2, 3) 7
kwo(c=4)  # prints: () 4
# kwo(1, 2)  # breaks, invalid syntax, with the following error
# TypeError: kwo() missing 1 required keyword-only argument: 'c'

def kwo2(a, b=42, *, c):
    print(a, b, c)

kwo2(3, b=7, c=99)  # prints: 3 7 99
kwo2(3, c=13)  # prints: 3 42 13
# kwo2(3, 23)  # breaks, invalid syntax, with the following error
# TypeError: kwo2() missing 1 required keyword-only argument: 'c'

正如预期的那样,函数kwo接受可变数量的位置参数(a)和一个仅限关键字的参数c。调用的结果很直接,你可以取消注释第三个调用以查看 Python 返回的错误。

相同的规则适用于函数kwo2,它与kwo不同之处在于它接受一个位置参数a,一个关键字参数b,然后是一个仅限关键字参数c。你可以取消注释第三个调用以查看错误。

现在你知道如何指定不同类型的输入参数,让我们看看如何在函数定义中组合它们。

组合输入参数

你可以组合输入参数,只要遵循这些顺序规则:

  • 在定义函数时,普通的位置参数首先出现(name),然后是任意的默认参数(name=value),然后是可变位置参数(*name或简单的*),然后是任意的仅限关键字参数(namename=value形式都可以),最后是任意的可变关键字参数(**name)。

  • 另一方面,在调用函数时,参数必须按照以下顺序给出:首先是位置参数(value),然后是任意组合的关键字参数(name=value),可变位置参数(*name),然后是可变关键字参数(**name)。

由于这在理论世界中留下来可能有点棘手,让我们看一些快速的例子:

# arguments.all.py
def func(a, b, c=7, *args, **kwargs):
    print('a, b, c:', a, b, c)
    print('args:', args)
    print('kwargs:', kwargs)

func(1, 2, 3, *(5, 7, 9), **{'A': 'a', 'B': 'b'})
func(1, 2, 3, 5, 7, 9, A='a', B='b')  # same as previous one

注意函数定义中参数的顺序,以及两个调用是等价的。在第一个调用中,我们使用了可迭代对象和字典的解包操作符,而在第二个调用中,我们使用了更明确的语法。执行这个代码产生了以下结果(我只打印了一个调用的结果,另一个是一样的):

$ python arguments.all.py
a, b, c: 1 2 3
args: (5, 7, 9)
kwargs: {'A': 'a', 'B': 'b'}

现在让我们看一个带有仅限关键字参数的例子:

# arguments.all.kwonly.py
def func_with_kwonly(a, b=42, *args, c, d=256, **kwargs):
    print('a, b:', a, b)
    print('c, d:', c, d)
    print('args:', args)
    print('kwargs:', kwargs)

# both calls equivalent
func_with_kwonly(3, 42, c=0, d=1, *(7, 9, 11), e='E', f='F')
func_with_kwonly(3, 42, *(7, 9, 11), c=0, d=1, e='E', f='F')

注意我在函数声明中突出显示了仅限关键字参数。它们出现在*args变量位置参数之后,如果它们直接出现在单个*之后的话,情况也是一样的(在这种情况下就不会有变量位置参数了)。执行这个代码产生了以下结果(我只打印了一个调用的结果):

$ python arguments.all.kwonly.py
a, b: 3 42
c, d: 0 1
args: (7, 9, 11)
kwargs: {'e': 'E', 'f': 'F'}

另一个需要注意的事情是我给变量位置和关键字参数的名称。你可以自由选择不同的名称,但要注意argskwargs是至少在一般情况下给这些参数的常规名称。

额外的解包概括

Python 3.5 中引入的最近的新特性之一是能够扩展可迭代(*)和字典(**)解包操作符,以允许在更多位置、任意次数和额外情况下进行解包。我将给你一个关于函数调用的例子:

# additional.unpacking.py
def additional(*args, **kwargs):
    print(args)
    print(kwargs)

args1 = (1, 2, 3)
args2 = [4, 5]
kwargs1 = dict(option1=10, option2=20)
kwargs2 = {'option3': 30}
additional(*args1, *args2, **kwargs1, **kwargs2)

在前面的例子中,我们定义了一个简单的函数,打印它的输入参数argskwargs。新特性在于我们调用这个函数的方式。注意我们如何解包多个可迭代对象和字典,并且它们在argskwargs下正确地合并。这个特性之所以重要的原因是它允许我们不必在代码中合并args1args2,以及kwargs1kwargs2。运行代码会产生:

$ python additional.unpacking.py
(1, 2, 3, 4, 5)
{'option1': 10, 'option2': 20, 'option3': 30}

请参考 PEP 448(www.python.org/dev/peps/pep-0448/)了解这个新特性的全部内容,并查看更多例子。

避免陷阱!可变默认值

在 Python 中需要非常注意的一件事是,默认值是在def时创建的,因此,对同一个函数的后续调用可能会根据它们的默认值的可变性而有所不同。让我们看一个例子:

# arguments.defaults.mutable.py
def func(a=[], b={}):
    print(a)
    print(b)
    print('#' * 12)
    a.append(len(a))  # this will affect a's default value
    b[len(a)] = len(a)  # and this will affect b's one

func()
func()
func()

两个参数都有可变的默认值。这意味着,如果你影响这些对象,任何修改都会在后续的函数调用中保留下来。看看你能否理解这些调用的输出:

$ python arguments.defaults.mutable.py
[]
{}
############
[0]
{1: 1}
############
[0, 1]
{1: 1, 2: 2}
############

很有趣,不是吗?虽然这种行为一开始可能看起来很奇怪,但实际上是有道理的,而且非常方便,例如,在使用记忆化技术时(如果你感兴趣的话,可以搜索一个例子)。更有趣的是,当我们在调用之间引入一个不使用默认值的调用时会发生什么,比如这样:

# arguments.defaults.mutable.intermediate.call.py
func()
func(a=[1, 2, 3], b={'B': 1})
func()

当我们运行这段代码时,输出如下:

$ python arguments.defaults.mutable.intermediate.call.py
[]
{}
############
[1, 2, 3]
{'B': 1}
############
[0]
{1: 1}
############

这个输出告诉我们,即使我们用其他值调用函数,默认值仍然保留。一个让人想到的问题是,我怎样才能每次都得到一个全新的空值呢?嗯,约定是这样的:

# arguments.defaults.mutable.no.trap.py
def func(a=None):
    if a is None:
        a = []
    # do whatever you want with `a` ...

请注意,通过使用前面的技术,如果在调用函数时没有传递a,你总是会得到一个全新的空列表。

好了,输入就到此为止,让我们看看另一面,输出。

返回值

函数的返回值是 Python 领先于大多数其他语言的东西之一。通常函数只允许返回一个对象(一个值),但在 Python 中,你可以返回一个元组,这意味着你可以返回任何你想要的东西。这个特性允许程序员编写在其他语言中要难得多或者肯定更加繁琐的软件。我们已经说过,要从函数中返回一些东西,我们需要使用return语句,后面跟着我们想要返回的东西。在函数体中可以有多个 return 语句。

另一方面,如果在函数体内部我们没有返回任何东西,或者我们调用一个裸的return语句,函数将返回None。这种行为是无害的,尽管我在这里没有足够的空间来详细解释为什么 Python 被设计成这样,但我只想告诉你,这个特性允许出现几种有趣的模式,并确认 Python 是一种非常一致的语言。

我说它是无害的,因为你从来不会被迫收集函数调用的结果。我会用一个例子来说明我的意思:

# return.none.py
def func():
    pass
func()  # the return of this call won't be collected. It's lost.
a = func()  # the return of this one instead is collected into `a`
print(a)  # prints: None

请注意,函数的整个主体只由pass语句组成。正如官方文档告诉我们的那样,pass是一个空操作。当它被执行时,什么都不会发生。当语法上需要一个语句,但不需要执行任何代码时,它是有用的。在其他语言中,我们可能会用一对花括号({})来表示这一点,它定义了一个空作用域,但在 Python 中,作用域是通过缩进代码来定义的,因此pass这样的语句是必要的。

还要注意,func函数的第一个调用返回一个值(None),我们没有收集。正如我之前所说的,收集函数调用的返回值并不是强制性的。

现在,这很好但不是很有趣,那么我们来写一个有趣的函数吧?记住,在第一章中,Python 的初步介绍,我们谈到了一个函数的阶乘。让我们在这里写一个(为简单起见,我将假设函数总是以适当的值正确调用,因此我不会对输入参数进行检查):

# return.single.value.py
def factorial(n):
    if n in (0, 1):
        return 1
    result = n
    for k in range(2, n):
        result *= k
    return result

f5 = factorial(5)  # f5 = 120

注意我们有两个返回点。如果n01(在 Python 中,通常使用in类型的检查,就像我所做的那样,而不是更冗长的if n == 0 or n == 1:),我们返回1。否则,我们执行所需的计算,然后返回result。让我们尝试以更简洁的方式编写这个函数:

# return.single.value.2.py from functools import reduce
from operator import mul

def factorial(n):
    return reduce(mul, range(1, n + 1), 1)

f5 = factorial(5)  # f5 = 120

我知道你在想什么:一行?Python 是优雅而简洁的!我认为这个函数是可读的,即使你从未见过reducemul,但如果你不能读懂或理解它,花几分钟时间在 Python 文档中进行一些研究,直到它的行为对你清晰明了。能够在文档中查找函数并理解他人编写的代码是每个开发人员都需要执行的任务,所以把它当作一个挑战。

为此,请确保查找help函数,在控制台上探索时非常有帮助。

返回多个值

与大多数其他语言不同,在 Python 中很容易从函数中返回多个对象。这个特性打开了一个全新的可能性世界,并允许你以其他语言难以复制的风格编码。我们的思维受到我们使用的工具的限制,因此当 Python 给你比其他语言更多的自由时,实际上也在提高你自己的创造力。返回多个值非常容易,你只需使用元组(显式或隐式)。让我们看一个简单的例子,模仿divmod内置函数:

# return.multiple.py
def moddiv(a, b):
    return a // b, a % b

print(moddiv(20, 7))  # prints (2, 6)

我本可以将前面代码中的突出部分用括号括起来,使其成为一个显式元组,但没有必要。前面的函数同时返回除法的结果和余数。

在这个例子的源代码中,我留下了一个简单的测试函数的例子,以确保我的代码进行了正确的计算。

一些建议

在编写函数时,遵循指南非常有用,这样你就可以很好地编写它们。我会快速指出其中一些:

  • 函数应该只做一件事:只做一件事的函数很容易用一句简短的话来描述。做多件事的函数可以拆分成做一件事的小函数。这些小函数通常更容易阅读和理解。记住我们几页前看到的数据科学例子。

  • 函数应该小而精:它们越小,测试它们和编写它们就越容易,以便它们只做一件事。

  • 输入参数越少越好:需要大量参数的函数很快就变得难以管理(还有其他问题)。

  • 函数在返回值上应该保持一致:返回FalseNone并不相同,即使在布尔上下文中它们都评估为FalseFalse意味着我们有信息(False),而None意味着没有信息。尝试编写函数,无论在函数体中发生什么,都以一致的方式返回。

  • 函数不应该有副作用:换句话说,函数不应该影响你调用它们时的值。这可能是最难理解的陈述,所以我会给你一个例子,使用列表。在下面的代码中,请注意numbers没有被sorted函数排序,实际上sorted函数返回的是numbers的排序副本。相反,list.sort()方法是作用于numbers对象本身的,这是可以的,因为它是一个方法(属于对象的函数,因此有权修改它):

>>> numbers = [4, 1, 7, 5]
>>> sorted(numbers)  # won't sort the original `numbers` list
[1, 4, 5, 7]
>>> numbers  # let's verify
[4, 1, 7, 5]  # good, untouched
>>> numbers.sort()  # this will act on the list
>>> numbers
[1, 4, 5, 7]

遵循这些准则,你将会写出更好的函数,这将对你有所帮助。

Robert C. Martin 的《代码整洁之道》中的第三章函数专门讲述了函数,这可能是我读过的关于这个主题的最好的一套准则。

递归函数

当一个函数调用自身来产生结果时,它被称为递归。有时递归函数非常有用,因为它们使编写代码变得更容易。有些算法使用递归范式编写起来非常容易,而其他一些则不是。没有递归函数不能以迭代方式重写,因此通常由程序员来选择最佳的方法来处理当前情况。

递归函数的主体通常有两个部分:一个是返回值取决于对自身的后续调用,另一个是不取决于对自身的调用(称为基本情况)。

举个例子,我们可以考虑(希望现在已经熟悉的)factorial函数,N!。基本情况是当N01时。函数返回1,无需进一步计算。另一方面,在一般情况下,N!返回乘积1 * 2 * ... * (N-1) * N。如果你仔细想一想,N!可以这样重写:N! = (N-1)! * N。作为一个实际的例子,考虑5! = 1 * 2 * 3 * 4 * 5 = (1 * 2 * 3 * 4) * 5 = 4! * 5

让我们把这个写成代码:

# recursive.factorial.py
def factorial(n):
    if n in (0, 1):  # base case
        return 1
    return factorial(n - 1) * n  # recursive case

在编写递归函数时,始终要考虑你进行了多少嵌套调用,因为有一个限制。有关此信息,请查看sys.getrecursionlimit()sys.setrecursionlimit()

递归函数在编写算法时经常使用,而且编写起来真的很有趣。作为练习,尝试使用递归和迭代方法解决一些简单的问题。

匿名函数

我想谈谈的最后一种函数类型是匿名函数。这些函数在 Python 中被称为lambda,通常在需要一个完全成熟的函数及其自己的名称会显得过度的情况下使用,我们只需要一个快速、简单的一行代码来完成工作。

假设你想要一个包含* N *的所有倍数的列表。假设你想使用filter函数来过滤掉那些元素,该函数接受一个函数和一个可迭代对象,并构造一个过滤器对象,你可以从中迭代,从可迭代对象中返回True的元素。如果不使用匿名函数,你可能会这样做:

# filter.regular.py
def is_multiple_of_five(n):
    return not n % 5

def get_multiples_of_five(n):
    return list(filter(is_multiple_of_five, range(n)))

请注意我们如何使用is_multiple_of_five来过滤前n个自然数。这似乎有点多余,任务很简单,我们不需要保留is_multiple_of_five函数以供其他用途。让我们使用 lambda 函数重新编写它:

# filter.lambda.py
def get_multiples_of_five(n):
    return list(filter(lambda k: not k % 5, range(n)))

逻辑完全相同,但过滤函数现在是一个 lambda。定义 lambda 非常容易,遵循这种形式:func_name = lambda [parameter_list]: expression。返回一个函数对象,等同于这个:def func_name([parameter_list]): return expression

请注意,可选参数遵循常见的语法,用方括号括起来表示。

让我们再看看两种形式定义的等效函数的另外一些例子:

# lambda.explained.py
# example 1: adder
def adder(a, b):
    return a + b

# is equivalent to:
adder_lambda = lambda a, b: a + b

# example 2: to uppercase
def to_upper(s):
    return s.upper()

# is equivalent to:
to_upper_lambda = lambda s: s.upper()

前面的例子非常简单。第一个例子是两个数字相加,第二个例子是产生字符串的大写版本。请注意,我将lambda表达式返回的内容赋给了一个名称(adder_lambdato_upper_lambda),但当你像我们在filter示例中那样使用 lambda 时,没有必要这样做。

函数属性

每个函数都是一个完整的对象,因此它们有许多属性。其中一些是特殊的,可以用内省的方式在运行时检查函数对象。以下脚本是一个示例,显示了其中一部分属性以及如何显示示例函数的值:

# func.attributes.py
def multiplication(a, b=1):
    """Return a multiplied by b. """
    return a * b

special_attributes = [
    "__doc__", "__name__", "__qualname__", "__module__",
    "__defaults__", "__code__", "__globals__", "__dict__",
    "__closure__", "__annotations__", "__kwdefaults__",
]

for attribute in special_attributes:
    print(attribute, '->', getattr(multiplication, attribute))

我使用了内置的getattr函数来获取这些属性的值。getattr(obj, attribute)等同于obj.attribute,在我们需要使用字符串名称在运行时获取属性时非常方便。运行这个脚本会产生:

$ python func.attributes.py
__doc__ -> Return a multiplied by b.
__name__ -> multiplication
__qualname__ -> multiplication
__module__ -> __main__
__defaults__ -> (1,)
__code__ -> <code object multiplication at 0x10caf7660, file "func.attributes.py", line 1>
__globals__ -> {...omitted...}
__dict__ -> {}
__closure__ -> None
__annotations__ -> {}
__kwdefaults__ -> None

我已省略了__globals__属性的值,因为它太大了。关于这个属性的含义解释可以在Python 数据模型文档页面的可调用**类型部分找到(docs.python.org/3/reference/datamodel.html#the-standard-type-hierarchy)。如果你想要查看对象的所有属性,只需调用dir(object_name),就会得到所有属性的列表。

内置函数

Python 自带了许多内置函数。它们随处可用,你可以通过检查builtins模块的dir(__builtins__)来获取它们的列表,或者查看官方 Python 文档。不幸的是,我没有足够的空间在这里介绍它们所有。我们已经见过其中一些,比如anybinbooldivmodfilterfloatgetattridintlenlistminprintsettupletypezip,但还有许多其他的,你至少应该阅读一次。熟悉它们,进行实验,为每一个编写一小段代码,并确保你能随时使用它们。

最后一个例子

在我们结束本章之前,最后一个例子怎么样?我在想我们可以编写一个函数来生成一个小于某个限制的质数列表。我们已经看到了这个代码,所以让我们把它变成一个函数,并且为了保持趣味性,让我们对它进行优化一下。

原来你不需要将N除以从2N-1 的所有数字来判断一个数N是否是质数。你可以停在√N。此外,你不需要测试从2√N的所有数字的除法,你可以只使用该范围内的质数。如果你感兴趣,我会留给你去弄清楚为什么这样做有效。让我们看看代码如何改变:

# primes.py
from math import sqrt, ceil

def get_primes(n):
    """Calculate a list of primes up to n (included). """
    primelist = []
    for candidate in range(2, n + 1):
        is_prime = True
        root = ceil(sqrt(candidate))  # division limit
        for prime in primelist:  # we try only the primes
            if prime > root:  # no need to check any further
                break
            if candidate % prime == 0:
                is_prime = False
                break
        if is_prime:
            primelist.append(candidate)
    return primelist

这段代码和上一章的代码是一样的。我们改变了除法算法,以便只使用先前计算的质数来测试可整除性,并且一旦测试除数大于候选数的平方根,我们就停止了。我们使用了primelist结果列表来获取除法的质数。我们使用了一个花哨的公式来计算根值,即候选数的根的天花板的整数值。虽然一个简单的int(k ** 0.5) + 1同样可以满足我们的目的,但我选择的公式更简洁,并且需要我使用一些导入,我想向你展示。查看math模块中的函数,它们非常有趣!

代码文档化

我非常喜欢不需要文档的代码。当您正确编写程序,选择正确的名称并处理细节时,您的代码应该是不言自明的,不需要文档。有时注释非常有用,文档也是如此。您可以在PEP 257 - Docstring conventionswww.python.org/dev/peps/pep-0257/)中找到有关 Python 文档的指南,但我会在这里向您展示基础知识。

Python 是用字符串记录的,这些字符串被称为文档字符串。任何对象都可以被记录,你可以使用单行或多行文档字符串。单行文档字符串非常简单。它们不应该为函数提供另一个签名,而是清楚地说明其目的。

# docstrings.py
def square(n):
    """Return the square of a number n. """
    return n ** 2

def get_username(userid):
    """Return the username of a user given their id. """
    return db.get(user_id=userid).username

使用三个双引号的字符串允许您以后轻松扩展。使用句子以句点结束,并且不要在之前或之后留下空行。

多行注释的结构方式类似。应该有一个简短的单行说明对象要点的一行,然后是更详细的描述。例如,我已经使用 Sphinx 符号对一个虚构的connect函数进行了文档记录,如下例所示:

def connect(host, port, user, password):
    """Connect to a database.

    Connect to a PostgreSQL database directly, using the given
    parameters.

    :param host: The host IP.
    :param port: The desired port.
    :param user: The connection username.
    :param password: The connection password.
    :return: The connection object.
    """
    # body of the function here...
    return connection

Sphinx可能是创建 Python 文档最广泛使用的工具。事实上,官方 Python 文档就是用它编写的。值得花一些时间去了解它。

导入对象

现在您已经对函数有了很多了解,让我们看看如何使用它们。编写函数的整个目的是以后能够重复使用它们,在 Python 中,这意味着将它们导入到需要它们的命名空间中。有许多不同的方法可以将对象导入到命名空间中,但最常见的是import module_namefrom module_name import function_name。当然,这些都是相当简单的例子,但请暂时忍耐。

import module_name形式会找到module_name模块,并在执行import语句的本地命名空间中为其定义一个名称。from module_name import identifier形式比这略微复杂一些,但基本上做的是相同的事情。它找到module_name,并搜索属性(或子模块),并在本地命名空间中存储对identifier的引用。

两种形式都可以使用as子句更改导入对象的名称:

from mymodule import myfunc as better_named_func 

为了让您了解导入的样子,这是我一个项目的测试模块的一个例子(请注意,导入块之间的空行遵循 PEP 8 的指南:标准库、第三方库和本地代码):

from datetime import datetime, timezone  # two imports on the same line
from unittest.mock import patch  # single import

import pytest  # third party library

from core.models import (  # multiline import
    Exam,
    Exercise,
    Solution,
)

当您拥有从项目根目录开始的文件结构时,您可以使用点表示法来获取要导入到当前命名空间的对象,无论是包、模块、类、函数还是其他任何东西。from module import语法还允许使用一个全捕子句from module import *,有时用于一次性将模块中的所有名称导入当前命名空间,但出于多种原因,如性能和潜在的静默屏蔽其他名称的风险,这是不被赞同的。您可以在官方 Python 文档中阅读有关导入的所有内容,但在我们离开这个主题之前,让我给您一个更好的例子。

假设您已经在一个名为lib的文件夹中定义了一对函数:square(n)cube(n),并且想要在lib文件夹的同一级别的一对模块func_import.pyfunc_from.py中使用它们。显示该项目的树结构会产生以下内容:

├── func_from.py
├── func_import.py
├── lib
 ├── funcdef.py
 └── __init__.py

在我展示每个模块的代码之前,请记住,为了告诉 Python 它实际上是一个包,我们需要在其中放置一个__init__.py模块。

关于__init__.py文件有两点需要注意。首先,它是一个完整的 Python 模块,因此您可以像对待任何其他模块一样在其中放置代码。其次,从 Python 3.3 开始,不再需要它的存在来使文件夹被解释为 Python 包。

代码如下:

# funcdef.py
def square(n): 
    return n ** 2 
def cube(n): 
    return n ** 3 

# func_import.py import lib.funcdef 
print(lib.funcdef.square(10)) 
print(lib.funcdef.cube(10)) 

# func_from.py
from lib.funcdef import square, cube 
print(square(10)) 
print(cube(10)) 

这两个文件在执行时都会打印出1001000。您可以看到我们如何根据当前作用域中导入的内容以及导入的方式来访问squarecube函数的不同之处。

相对导入

到目前为止,我们看到的导入被称为绝对导入,即它们定义了我们要导入的模块的整个路径,或者我们要从中导入对象的模块。在 Python 中还有另一种导入对象的方式,称为相对导入。在需要重新排列大型包的结构而无需编辑子包的情况下,或者当我们希望使包内的模块能够自我导入时,相对导入非常有用。相对导入是通过在模块前面添加与我们需要回溯的文件夹数量相同的前导点来完成的,以便找到我们正在搜索的内容。简而言之,就是这样的:

from .mymodule import myfunc 

有关相对导入的完整解释,请参阅 PEP 328(www.python.org/dev/peps/pep-0328/)。在后面的章节中,我们将使用不同的库创建项目,并使用多种不同类型的导入,包括相对导入,因此请确保您花点时间在官方 Python 文档中了解相关内容。

总结

在本章中,我们探索了函数的世界。它们非常重要,从现在开始,我们基本上会在任何地方使用它们。我们讨论了使用它们的主要原因,其中最重要的是代码重用和实现隐藏。

我们看到函数对象就像一个接受可选输入并产生输出的盒子。我们可以以许多不同的方式向函数提供输入值,使用位置参数和关键字参数,并对两种类型都使用变量语法。

现在您应该知道如何编写函数、对其进行文档化、将其导入到您的代码中并调用它。

下一章将迫使我更加加速,因此我建议您抓住任何机会,通过深入研究 Python 官方文档来巩固和丰富您迄今为止所获得的知识。

第五章:节省时间和内存

“不是每天增加,而是每天减少。砍掉不必要的部分。”- 李小龙

我喜欢李小龙的这句话。他是一个很聪明的人!特别是第二部分,“砍掉不必要的部分”,对我来说是使计算机程序优雅的原因。毕竟,如果有更好的方法来做事情,这样我们就不会浪费时间或内存,为什么不呢?

有时,不将我们的代码推向最大限度是有合理的原因的:例如,有时为了实现微不足道的改进,我们必须牺牲可读性或可维护性。当我们可以用可读性强、清晰的代码在 1.05 秒内提供网页,而不是用难以理解、复杂的代码在 1 秒内提供网页时,这是没有意义的。

另一方面,有时候从一个函数中削减一毫秒是完全合理的,特别是当这个函数被调用数千次时。你在那里节省的每一毫秒意味着每一千次调用节省一秒,这对你的应用可能是有意义的。

鉴于这些考虑,本章的重点不是为你提供将代码推向性能和优化的绝对极限的工具,“不管怎样”,而是使你能够编写高效、优雅的代码,读起来流畅,运行快速,并且不会明显浪费资源。

在本章中,我们将涵盖以下内容:

  • map、zip 和 filter 函数

  • 推导式

  • 生成器

我将进行几项测量和比较,并谨慎得出一些结论。请记住,在一个不同的盒子上,使用不同的设置或不同的操作系统,结果可能会有所不同。看看这段代码:

# squares.py
def square1(n):
    return n ** 2  # squaring through the power operator

def square2(n):
    return n * n  # squaring through multiplication

这两个函数都返回n的平方,但哪个更快?从我对它们进行的简单基准测试来看,第二个似乎稍微更快。如果你仔细想想,这是有道理的:计算一个数字的幂涉及乘法,因此,无论你使用什么算法来执行幂运算,它都不太可能击败square2中的简单乘法。

我们在乎这个结果吗?在大多数情况下,不在乎。如果你正在编写一个电子商务网站,很可能你甚至不需要将一个数字提高到二次方,如果你需要,这可能是一个零星的操作。你不需要担心在你调用几次的函数上节省一小部分微秒。

那么,优化什么时候变得重要呢?一个非常常见的情况是当你必须处理大量的数据集时。如果你在一百万个“客户”对象上应用相同的函数,那么你希望你的函数调整到最佳状态。在一个被调用一百万次的函数上节省 1/10 秒,可以节省你 100,000 秒,大约 27.7 小时。这不一样,对吧?所以,让我们专注于集合,让我们看看 Python 给你提供了哪些工具来高效优雅地处理它们。

我们将在本章中看到的许多概念都是基于迭代器和可迭代对象的概念。简单地说,当要求一个对象返回其下一个元素时,以及在耗尽时引发StopIteration异常的能力。我们将看到如何在第六章中编写自定义迭代器和可迭代对象,面向对象编程、装饰器和迭代器

由于我们将在本章中探讨的对象的性质,我经常被迫将代码包装在list构造函数中。这是因为将迭代器/生成器传递给list(...)会耗尽它,并将所有生成的项目放入一个新创建的列表中,我可以轻松地打印出来显示它的内容。这种技术会影响可读性,所以让我介绍一个list的别名:

# alias.py
>>> range(7)
range(0, 7)
>>> list(range(7))  # put all elements in a list to view them
[0, 1, 2, 3, 4, 5, 6]
>>> _ = list  # create an "alias" to list
>>> _(range(7))  # same as list(range(7))
[0, 1, 2, 3, 4, 5, 6]

我已经突出显示的三个部分中,第一个是我们需要执行的调用,以便显示range(7)生成的内容,第二个是我创建别名到list的时刻(我选择了希望不引人注目的下划线),第三个是等效的调用,当我使用别名而不是list时。

希望这样做可以提高可读性,请记住,我将假设这个别名已经在本章的所有代码中定义了。

map、zip 和 filter 函数

我们将从回顾mapfilterzip开始,这些是处理集合时可以使用的主要内置函数,然后我们将学习如何使用两个非常重要的构造来实现相同的结果:推导生成器。系好安全带!

地图

根据官方 Python 文档:

map(function, iterable, ...)返回一个迭代器,它将函数应用于可迭代对象的每个项目,产生结果。如果传递了额外的可迭代参数,函数必须接受相同数量的参数,并且会并行应用于所有可迭代对象的项目。对于多个可迭代对象,当最短的可迭代对象耗尽时,迭代器会停止。

我们将在本章后面解释 yielding 的概念。现在,让我们将其翻译成代码——我们将使用一个接受可变数量的位置参数的lambda函数,并将它们返回为一个元组:

# map.example.py
>>> map(lambda *a: a, range(3))  # 1 iterable
<map object at 0x10acf8f98>  # Not useful! Let's use alias
>>> _(map(lambda *a: a, range(3)))  # 1 iterable
[(0,), (1,), (2,)]
>>> _(map(lambda *a: a, range(3), 'abc'))  # 2 iterables
[(0, 'a'), (1, 'b'), (2, 'c')]
>>> _(map(lambda *a: a, range(3), 'abc', range(4, 7)))  # 3
[(0, 'a', 4), (1, 'b', 5), (2, 'c', 6)]
>>> # map stops at the shortest iterator
>>> _(map(lambda *a: a, (), 'abc'))  # empty tuple is shortest
[]
>>> _(map(lambda *a: a, (1, 2), 'abc'))  # (1, 2) shortest
[(1, 'a'), (2, 'b')]
>>> _(map(lambda *a: a, (1, 2, 3, 4), 'abc'))  # 'abc' shortest
[(1, 'a'), (2, 'b'), (3, 'c')]

在前面的代码中,你可以看到为什么我们必须用list(...)(或者在这种情况下使用它的别名_)来包装调用。没有它,我会得到一个map对象的字符串表示,这在这种情况下并不真正有用,是吗?

你还可以注意到每个可迭代对象的元素是如何应用于函数的;首先是每个可迭代对象的第一个元素,然后是每个可迭代对象的第二个元素,依此类推。还要注意,map在我们调用它的可迭代对象中最短的一个耗尽时停止。这实际上是一种非常好的行为;它不强迫我们将所有可迭代对象平齐到一个公共长度,并且如果它们的长度不相同时也不会中断。

当你必须将相同的函数应用于一个或多个对象集合时,map非常有用。作为一个更有趣的例子,让我们看看装饰-排序-解除装饰惯用法(也称为Schwartzian transform)。这是一种在 Python 排序没有提供key-functions时非常流行的技术,因此今天使用较少,但偶尔还是会派上用场的一个很酷的技巧。

让我们在下一个例子中看一个变体:我们想按照学生所累积的学分总和降序排序,以便将最好的学生放在位置 0。我们编写一个函数来生成一个装饰对象,然后进行排序,然后进行 undecorate。每个学生在三个(可能不同的)科目中都有学分。在这种情况下,装饰对象意味着以一种允许我们按照我们想要的方式对原始对象进行排序的方式来转换它,无论是向其添加额外数据,还是将其放入另一个对象中。这种技术与 Python 装饰器无关,我们将在本书后面探讨。

在排序之后,我们将装饰的对象恢复为它们的原始对象。这被称为 undecorate:

# decorate.sort.undecorate.py
students = [
    dict(id=0, credits=dict(math=9, physics=6, history=7)),
    dict(id=1, credits=dict(math=6, physics=7, latin=10)),
    dict(id=2, credits=dict(history=8, physics=9, chemistry=10)),
    dict(id=3, credits=dict(math=5, physics=5, geography=7)),
]

def decorate(student):
    # create a 2-tuple (sum of credits, student) from student dict
    return (sum(student['credits'].values()), student)

def undecorate(decorated_student):
    # discard sum of credits, return original student dict
    return decorated_student[1]

students = sorted(map(decorate, students), reverse=True)
students = _(map(undecorate, students))

让我们首先了解每个学生对象是什么。实际上,让我们打印第一个:

{'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0}

你可以看到它是一个具有两个键的字典:idcreditscredits的值也是一个字典,在其中有三个科目/成绩键/值对。正如你在数据结构世界中所记得的,调用dict.values()会返回一个类似于iterable的对象,只有值。因此,第一个学生的sum(student['credits'].values())等同于sum((9, 6, 7))

让我们打印调用 decorate 与第一个学生的结果:

>>> decorate(students[0])
(22, {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0})

如果我们对所有学生都这样装饰,我们可以通过仅对元组列表进行排序来按学分总额对它们进行排序。为了将装饰应用到 students 中的每个项目,我们调用map(decorate, students)。然后我们对结果进行排序,然后以类似的方式进行解除装饰。如果你已经正确地阅读了之前的章节,理解这段代码不应该太难。

运行整个代码后打印学生:

$ python decorate.sort.undecorate.py
[{'credits': {'chemistry': 10, 'history': 8, 'physics': 9}, 'id': 2},
 {'credits': {'latin': 10, 'math': 6, 'physics': 7}, 'id': 1},
 {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0},
 {'credits': {'geography': 7, 'math': 5, 'physics': 5}, 'id': 3}]

你可以看到,根据学生对象的顺序,它们确实已经按照他们的学分总和进行了排序。

有关装饰-排序-解除装饰习惯用法的更多信息,请参阅官方 Python 文档的排序指南部分(docs.python.org/3.7/howto/sorting.html#the-old-way-using-decorate-sort-undecorate)。

关于排序部分要注意的一件事是:如果两个或更多的学生总分相同怎么办?排序算法将继续通过比较student对象来对元组进行排序。这没有任何意义,在更复杂的情况下,可能会导致不可预测的结果,甚至错误。如果你想确保避免这个问题,一个简单的解决方案是创建一个三元组而不是两元组,将学分总和放在第一个位置,students列表中student对象的位置放在第二个位置,student对象本身放在第三个位置。这样,如果学分总和相同,元组将根据位置进行排序,位置总是不同的,因此足以解决任何一对元组之间的排序问题。

zip

我们已经在之前的章节中介绍了zip,所以让我们正确定义它,然后我想向你展示如何将它与map结合起来使用。

根据 Python 文档:

zip(*iterables)返回一个元组的迭代器,其中第 i 个元组包含来自每个参数序列或可迭代对象的第 i 个元素。当最短的输入可迭代对象耗尽时,迭代器停止。使用单个可迭代对象参数时,它返回一个 1 元组的迭代器。没有参数时,它返回一个空的迭代器。

让我们看一个例子:

# zip.grades.py
>>> grades = [18, 23, 30, 27]
>>> avgs = [22, 21, 29, 24]
>>> _(zip(avgs, grades))
[(22, 18), (21, 23), (29, 30), (24, 27)]
>>> _(map(lambda *a: a, avgs, grades))  # equivalent to zip
[(22, 18), (21, 23), (29, 30), (24, 27)]

在上面的代码中,我们将每个学生的平均值和最后一次考试的成绩进行了zip。注意使用map来复制zip是多么容易(示例的最后两条指令)。同样,在可视化结果时,我们必须使用我们的_别名。

mapzip的结合使用的一个简单例子可能是计算序列中每个元素的最大值,即每个序列的第一个元素的最大值,然后是第二个元素的最大值,依此类推:

# maxims.py
>>> a = [5, 9, 2, 4, 7]
>>> b = [3, 7, 1, 9, 2]
>>> c = [6, 8, 0, 5, 3]
>>> maxs = map(lambda n: max(*n), zip(a, b, c))
>>> _(maxs)
[6, 9, 2, 9, 7]

注意计算三个序列的最大值是多么容易。当然,严格来说并不一定需要zip,我们可以使用map。有时候在展示一个简单的例子时,很难理解为什么使用某种技术可能是好的或坏的。我们忘记了我们并不总是能控制源代码,我们可能必须使用第三方库,而我们无法按照自己的意愿进行更改。因此,有不同的方法来处理数据真的很有帮助。

筛选

根据 Python 文档:

filter(function, iterable)从可迭代对象中构建一个迭代器,其中包含函数返回 True 的那些元素。可迭代对象可以是序列、支持迭代的容器,或者是迭代器。如果函数为 None,则假定为恒等函数,即删除可迭代对象中所有为假的元素。

让我们看一个非常快速的例子:

# filter.py
>>> test = [2, 5, 8, 0, 0, 1, 0]
>>> _(filter(None, test))
[2, 5, 8, 1]
>>> _(filter(lambda x: x, test))  # equivalent to previous one
[2, 5, 8, 1]
>>> _(filter(lambda x: x > 4, test))  # keep only items > 4
[5, 8]

在上面的代码中,注意第二次调用filter等同于第一次调用。如果我们传递一个接受一个参数并返回参数本身的函数,只有那些为True的参数才会使函数返回True,因此这种行为与传递None完全相同。模仿一些内置的 Python 行为通常是一个很好的练习。当你成功时,你可以说你完全理解了 Python 在特定情况下的行为。

有了mapzipfilter(以及 Python 标准库中的其他几个函数),我们可以非常有效地处理序列。但这些函数并不是唯一的方法。所以让我们看看 Python 最好的特性之一:推导。

推导

推导是一种简洁的表示法,既对一组元素执行某些操作,又/或选择满足某些条件的子集。它们借鉴自函数式编程语言 Haskell(www.haskell.org/),并且与迭代器和生成器一起为 Python 增添了函数式风味。

Python 为您提供不同类型的推导:listdictset。我们现在将集中在第一个上,然后解释另外两个将会很容易。

让我们从一个非常简单的例子开始。我想计算一个包含前 10 个自然数的平方的列表。你会怎么做?有几种等效的方法:

# squares.map.py
# If you code like this you are not a Python dev! ;)
>>> squares = []
>>> for n in range(10):
...     squares.append(n ** 2)
...
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# This is better, one line, nice and readable
>>> squares = map(lambda n: n**2, range(10))
>>> _(squares)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

前面的例子对你来说应该不是什么新鲜事。让我们看看如何使用list推导来实现相同的结果:

# squares.comprehension.py
>>> [n ** 2 for n in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

就是这么简单。是不是很优雅?基本上我们在方括号内放了一个for循环。现在让我们过滤掉奇数平方。我将首先向你展示如何使用mapfilter,然后再次使用list推导:

# even.squares.py
# using map and filter
sq1 = list(
    map(lambda n: n ** 2, filter(lambda n: not n % 2, range(10)))
)
# equivalent, but using list comprehensions
sq2 = [n ** 2 for n in range(10) if not n % 2]

print(sq1, sq1 == sq2)  # prints: [0, 4, 16, 36, 64] True

我认为现在可读性的差异是明显的。列表推导读起来好多了。它几乎是英语:如果 n 是偶数,给我所有 0 到 9 之间的 n 的平方(n ** 2)。

根据 Python 文档:

列表推导由包含表达式的括号组成,后面跟着一个 for 子句,然后是零个或多个 for 或 if 子句。结果将是一个新列表,由在 for 和 if 子句的上下文中评估表达式得出。

嵌套推导

让我们看一个嵌套循环的例子。在处理算法时,经常需要使用两个占位符对序列进行迭代是很常见的。第一个占位符从左到右遍历整个序列。第二个也是如此,但它从第一个开始,而不是从 0 开始。这个概念是为了测试所有对而不重复。让我们看看经典的for循环等价:

# pairs.for.loop.py
items = 'ABCD'
pairs = []

for a in range(len(items)):
    for b in range(a, len(items)):
        pairs.append((items[a], items[b]))

如果你在最后打印出对,你会得到:

$ python pairs.for.loop.py
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'B'), ('B', 'C'), ('B', 'D'), ('C', 'C'), ('C', 'D'), ('D', 'D')]

所有具有相同字母的元组都是ba处于相同位置的元组。现在,让我们看看如何将其转换为list推导:

# pairs.list.comprehension.py
items = 'ABCD'
pairs = [(items[a], items[b])
    for a in range(len(items)) for b in range(a, len(items))]

这个版本只有两行长,但实现了相同的结果。请注意,在这种特殊情况下,因为for循环在b上有一个对a的依赖,所以它必须在推导中跟在a上的for循环之后。如果你交换它们,你会得到一个名称错误。

过滤推导

我们可以对推导应用过滤。让我们首先用filter来做。让我们找出所有勾股数的短边小于 10 的三元组。显然,我们不想测试两次组合,因此我们将使用与我们在上一个例子中看到的类似的技巧:

# pythagorean.triple.py
from math import sqrt
# this will generate all possible pairs
mx = 10
triples = [(a, b, sqrt(a**2 + b**2))
    for a in range(1, mx) for b in range(a, mx)]
# this will filter out all non pythagorean triples
triples = list(
    filter(lambda triple: triple[2].is_integer(), triples))

print(triples)  # prints: [(3, 4, 5.0), (6, 8, 10.0)]

勾股数是满足整数方程 a² + b² = c²的整数三元组(a,b,c)。

在前面的代码中,我们生成了一个三元组列表triples。每个元组包含两个整数(腿)和勾股定理三角形的斜边,其腿是元组中的前两个数字。例如,当a3b4时,元组将是(3, 4, 5.0),当a5b7时,元组将是(5, 7, 8.602325267042627)

在完成所有triples之后,我们需要过滤掉所有没有整数斜边的三元组。为了做到这一点,我们基于float_number.is_integer()True进行过滤。这意味着在我之前向您展示的两个示例元组中,具有5.0斜边的元组将被保留,而具有8.602325267042627斜边的元组将被丢弃。

这很好,但我不喜欢三元组有两个整数和一个浮点数。它们应该都是整数,所以让我们使用map来修复这个问题:

# pythagorean.triple.int.py
from math import sqrt
mx = 10
triples = [(a, b, sqrt(a**2 + b**2))
    for a in range(1, mx) for b in range(a, mx)]
triples = filter(lambda triple: triple[2].is_integer(), triples)
# this will make the third number in the tuples integer
triples = list(
    map(lambda triple: triple[:2] + (int(triple[2]), ), triples))

print(triples)  # prints: [(3, 4, 5), (6, 8, 10)]

注意我们添加的步骤。我们取triples中的每个元素,并对其进行切片,仅取其中的前两个元素。然后,我们将切片与一个一元组连接起来,在其中放入我们不喜欢的那个浮点数的整数版本。看起来像是很多工作,对吧?确实是。让我们看看如何使用list推导来完成所有这些工作:

# pythagorean.triple.comprehension.py
from math import sqrt
# this step is the same as before
mx = 10
triples = [(a, b, sqrt(a**2 + b**2))
    for a in range(1, mx) for b in range(a, mx)]
# here we combine filter and map in one CLEAN list comprehension
triples = [(a, b, int(c)) for a, b, c in triples if c.is_integer()]
print(triples)  # prints: [(3, 4, 5), (6, 8, 10)]

我知道。这样会好得多,不是吗?它干净、可读、更短。换句话说,它是优雅的。

我在这里走得很快,就像在第四章的摘要中预期的那样,函数,代码的构建块。您在玩这个代码吗?如果没有,我建议您这样做。非常重要的是,您要玩耍,打破事物,改变事物,看看会发生什么。确保您清楚地了解发生了什么。您想成为一个忍者,对吧?

dict 推导

字典和set推导的工作方式与列表推导完全相同,只是语法上有一点不同。以下示例足以解释您需要了解的所有内容:

# dictionary.comprehensions.py
from string import ascii_lowercase
lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))

如果打印lettermap,您将看到以下内容(我省略了中间结果,您会明白的):

$ python dictionary.comprehensions.py
{'a': 1,
 'b': 2,
 ...
 'y': 25,
 'z': 26}

在前面的代码中发生的是,我们正在用推导(技术上是生成器表达式,我们稍后会看到)向dict构造函数提供数据。我们告诉dict构造函数从推导中的每个元组中制作/对。我们使用enumerate列举所有小写 ASCII 字母的序列,从1开始。小菜一碟。还有另一种做同样事情的方法,更接近其他字典语法:

lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)} 

它确实做了完全相同的事情,只是语法略有不同,更突出了键:值部分。

字典不允许键中有重复,如下例所示:

# dictionary.comprehensions.duplicates.py
word = 'Hello'
swaps = {c: c.swapcase() for c in word}
print(swaps)  # prints: {'H': 'h', 'e': 'E', 'l': 'L', 'o': 'O'}

我们创建一个字典,其中键是'Hello'字符串中的字母,值是相同的字母,但大小写不同。请注意只有一个'l': 'L'对。构造函数不会抱怨,它只是将重复的键重新分配给最新的值。让我们通过另一个例子来更清楚地说明这一点;让我们为每个键分配其在字符串中的位置:

# dictionary.comprehensions.positions.py
word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions)  # prints: {'H': 0, 'e': 1, 'l': 3, 'o': 4}

请注意与字母'l'关联的值:3'l': 2对不在那里;它已被'l': 3覆盖。

set 推导

set推导非常类似于列表和字典推导。Python 允许使用set()构造函数,或显式的{}语法。让我们看一个快速的例子:

# set.comprehensions.py
word = 'Hello'
letters1 = set(c for c in word)
letters2 = {c for c in word}
print(letters1)  # prints: {'H', 'o', 'e', 'l'}
print(letters1 == letters2)  # prints: True

请注意,对于set推导和字典推导,不允许重复,因此生成的集合只有四个字母。还要注意,分配给letters1letters2的表达式产生了等效的集合。

用于创建letters2的语法与用于创建字典推导的语法非常相似。您只能通过字典需要使用冒号分隔的键和值来区分它们,而集合则不需要。

生成器

生成器是 Python 赋予我们的非常强大的工具。它们基于迭代的概念,正如我们之前所说的,它们允许结合优雅和高效的编码模式。

生成器有两种类型:

  • 生成器函数:这些与常规函数非常相似,但是它们不是通过返回语句返回结果,而是使用 yield,这使它们能够在每次调用之间暂停和恢复它们的状态。

  • 生成器表达式:这些与我们在本章中看到的list推导非常相似,但是它们不是返回一个列表,而是返回一个逐个产生结果的对象。

生成器函数

生成器函数在所有方面都像常规函数一样,只有一个区别。它们不是一次性收集结果并返回它们,而是在每次调用next时自动转换为产生结果的迭代器。

这一切都是非常理论的,所以让我们清楚地说明为什么这样的机制是如此强大,然后让我们看一个例子。

假设我让你大声数数从 1 数到 1,000,000。你开始了,然后在某个时候我让你停下来。过了一段时间,我让你继续。在这一点上,你需要记住能够正确恢复的最小信息是什么?嗯,你需要记住你最后一个叫的数字。如果我在 31,415 后停止了你,你就会继续 31,416,依此类推。

重点是,你不需要记住 31,415 之前说的所有数字,也不需要它们被写在某个地方。嗯,你可能不知道,但你已经像一个生成器一样行为了!

仔细看一下以下代码:

# first.n.squares.py
def get_squares(n): # classic function approach
    return [x ** 2 for x in range(n)]
print(get_squares(10))

def get_squares_gen(n):  # generator approach
    for x in range(n):
        yield x ** 2  # we yield, we don't return
print(list(get_squares_gen(10)))

两个print语句的结果将是相同的:[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]。但是这两个函数之间有很大的区别。get_squares是一个经典函数,它收集[0,n)范围内所有数字的平方,并将其返回为列表。另一方面,get_squares_gen是一个生成器,行为非常不同。每当解释器到达yield行时,它的执行就会被暂停。这些print语句返回相同结果的唯一原因是因为我们将get_squares_gen传递给list构造函数,它通过请求下一个元素直到引发StopIteration来完全耗尽生成器。让我们详细看一下:

# first.n.squares.manual.py
def get_squares_gen(n):
    for x in range(n):
        yield x ** 2

squares = get_squares_gen(4)  # this creates a generator object
print(squares)  # <generator object get_squares_gen at 0x10dd...>
print(next(squares))  # prints: 0
print(next(squares))  # prints: 1
print(next(squares))  # prints: 4
print(next(squares))  # prints: 9
# the following raises StopIteration, the generator is exhausted,
# any further call to next will keep raising StopIteration
print(next(squares))

在前面的代码中,每次我们在生成器对象上调用next时,要么启动它(第一个next),要么使它从上次暂停的地方恢复(任何其他next)。

第一次在它上面调用next时,我们得到0,这是0的平方,然后是1,然后是4,然后是9,由于for循环在那之后停止了(n4),然后生成器自然结束了。经典函数在那一点上只会返回None,但为了符合迭代协议,生成器将会引发StopIteration异常。

这解释了for循环的工作原理。当你调用for k in range(n)时,在幕后发生的是for循环从range(n)中获取一个迭代器,并开始在其上调用next,直到引发StopIteration,这告诉for循环迭代已经结束。

Python 的每个迭代方面内置了这种行为,这使得生成器更加强大,因为一旦我们编写它们,我们就能够将它们插入到我们想要的任何迭代机制中。

此时,你可能会问自己为什么要使用生成器而不是普通函数。好吧,本章的标题应该暗示了答案。稍后我会谈论性能,所以现在让我们集中在另一个方面:有时生成器允许你做一些用简单列表无法做到的事情。例如,假设你想分析一个序列的所有排列。如果序列的长度为N,那么它的排列数就是N!。这意味着如果序列长度为 10 个元素,排列数就是 3,628,800。但是 20 个元素的序列将有 2,432,902,008,176,640,000 个排列。它们呈阶乘增长。

现在想象一下,你有一个经典函数,它试图计算所有的排列,把它们放在一个列表中,并返回给你。对于 10 个元素,可能需要几十秒,但对于 20 个元素,根本不可能完成。

另一方面,一个生成器函数将能够开始计算并返回第一个排列,然后是第二个,依此类推。当然你没有时间解析它们所有,因为太多了,但至少你能够处理其中的一些。

还记得我们在谈论for循环中的break语句吗?当我们找到一个能整除候选素数的数时,我们就打破了循环,没有必要继续下去了。

有时情况完全相同,只是你需要迭代的数据量太大,无法将其全部保存在列表中。在这种情况下,生成器是非常宝贵的:它们使得原本不可能的事情成为可能。

因此,为了节省内存(和时间),尽可能使用生成器函数。

值得注意的是,你可以在生成器函数中使用 return 语句。它将产生一个StopIteration异常被引发,有效地结束迭代。这是非常重要的。如果return语句实际上使函数返回了什么东西,它将打破迭代协议。Python 的一致性防止了这种情况,并且在编码时为我们提供了极大的便利。让我们看一个快速的例子:

# gen.yield.return.py
def geometric_progression(a, q):
    k = 0
    while True:
        result = a * q**k
        if result <= 100000:
            yield result
        else:
            return
        k += 1

for n in geometric_progression(2, 5):
    print(n)

前面的代码产生了等比级数的所有项,aaqaq²aq³,.... 当级数产生一个大于100000的项时,生成器就会停止(使用return语句)。 运行代码会产生以下结果:

$ python gen.yield.return.py
2
10
50
250
1250
6250
31250

下一个项本来会是156250,这太大了。

说到StopIteration,从 Python 3.5 开始,生成器中异常处理的方式已经发生了变化。在这一点上理解这种变化的影响可能要求你付出太多,所以只需知道你可以在 PEP 479 中阅读有关它的所有内容即可(legacy.python.org/dev/peps/pep-0479/)。

超越 next

在本章的开头,我告诉过你生成器对象是基于迭代协议的。我们将在第六章中看到一个完整的例子,说明如何编写自定义的迭代器/可迭代对象。现在,我只是希望你理解next()是如何工作的。

当你调用next(generator)时,你调用了generator.__next__()方法。记住,方法只是属于对象的函数,而 Python 中的对象可以有特殊的方法。__next__()只是其中之一,它的目的是返回迭代的下一个元素,或者在迭代结束时引发StopIteration,并且没有更多的元素可以返回。

如果你还记得,在 Python 中,对象的特殊方法也被称为魔术方法,或者dunder(来自“双下划线”)方法

当我们编写一个生成器函数时,Python 会自动将其转换为一个与迭代器非常相似的对象,当我们调用next(generator)时,该调用会转换为generator.__next__()。让我们重新讨论一下关于生成平方数的先前示例:

# first.n.squares.manual.method.py
def get_squares_gen(n):
    for x in range(n):
        yield x ** 2

squares = get_squares_gen(3)
print(squares.__next__())  # prints: 0
print(squares.__next__())  # prints: 1
print(squares.__next__())  # prints: 4
# the following raises StopIteration, the generator is exhausted,
# any further call to next will keep raising StopIteration

结果与前面的示例完全相同,只是这次我们直接调用squares.__next__(),而不是使用next(squares)代理调用。

生成器对象还有另外三种方法,允许我们控制它们的行为:sendthrowclosesend允许我们向生成器对象发送一个值,而throwclose分别允许我们在生成器内部引发异常并关闭它。它们的使用非常高级,我不会在这里详细介绍它们,但我想在send上花几句话,举个简单的例子:

# gen.send.preparation.py
def counter(start=0):
    n = start
    while True:
        yield n
        n += 1

c = counter()
print(next(c))  # prints: 0
print(next(c))  # prints: 1
print(next(c))  # prints: 2

前面的迭代器创建了一个将永远运行的生成器对象。您可以不断调用它,它永远不会停止。或者,您可以将其放入for循环中,例如,for n in counter(): ...,它也将永远运行。但是,如果您想在某个时刻停止它怎么办?一种解决方案是使用变量来控制while循环。例如:

# gen.send.preparation.stop.py
stop = False
def counter(start=0):
    n = start
    while not stop:
        yield n
        n += 1

c = counter()
print(next(c))  # prints: 0
print(next(c))  # prints: 1
stop = True
print(next(c))  # raises StopIteration

这样就可以了。我们从stop = False开始,直到我们将其更改为True,生成器将像以前一样继续运行。然而,一旦我们将stop更改为Truewhile循环将退出,并且下一次调用将引发StopIteration异常。这个技巧有效,但我不喜欢它。我们依赖于一个外部变量,这可能会导致问题:如果另一个函数改变了stop会怎么样?此外,代码是分散的。简而言之,这还不够好。

我们可以通过使用generator.send()来改进它。当我们调用generator.send()时,我们向send提供的值将被传递给生成器,执行将恢复,我们可以通过yield表达式获取它。用文字解释这一切都很复杂,所以让我们看一个例子:

# gen.send.py
def counter(start=0):
    n = start
    while True:
        result = yield n             # A
        print(type(result), result)  # B
        if result == 'Q':
            break
        n += 1

c = counter()
print(next(c))         # C
print(c.send('Wow!'))  # D
print(next(c))         # E
print(c.send('Q'))     # F

执行上述代码会产生以下结果:

$ python gen.send.py
0
<class 'str'> Wow!
1
<class 'NoneType'> None
2
<class 'str'> Q
Traceback (most recent call last):
 File "gen.send.py", line 14, in <module>
 print(c.send('Q')) # F
StopIteration

我认为逐行阅读这段代码是值得的,就好像我们在执行它一样,看看我们是否能理解发生了什么。

我们通过调用next(#C)开始生成器执行。在生成器中,n被设置为与start相同的值。进入while循环,执行停止(#A),n0)被返回给调用者。0被打印在控制台上。

然后我们调用send(#D),执行恢复,result被设置为'Wow!'(仍然是#A),然后它的类型和值被打印在控制台上(#B)。result不是'Q',因此n增加了1,执行返回到while条件,这时,True被评估为True(这不难猜到,对吧?)。另一个循环开始,执行再次停止(#A),n1)被返回给调用者。1被打印在控制台上。

在这一点上,我们调用next(#E),执行再次恢复(#A),因为我们没有明确向生成器发送任何内容,Python 的行为与不使用return语句的函数完全相同;yield n表达式(#A)返回None。因此,result被设置为None,其类型和值再次被打印在控制台上(#B)。执行继续,result不是'Q',所以n增加了1,我们再次开始另一个循环。执行再次停止(#A),n2)被返回给调用者。2被打印在控制台上。

现在到了大结局:我们再次调用send#F),但这次我们传入了'Q',因此当执行恢复时,result被设置为'Q'#A)。它的类型和值被打印在控制台上(#B),最后if子句评估为Truewhile循环被break语句停止。生成器自然终止,这意味着会引发StopIteration异常。您可以在控制台上看到它的回溯打印在最后几行上。

这一开始并不容易理解,所以如果对您来说不清楚,不要气馁。您可以继续阅读,然后过一段时间再回到这个例子。

使用send允许有趣的模式,值得注意的是send也可以用于启动生成器的执行(只要您用None调用它)。

yield from表达式

另一个有趣的构造是yield from表达式。这个表达式允许您从子迭代器中产生值。它的使用允许相当高级的模式,所以让我们快速看一个非常快速的例子:

# gen.yield.for.py def print_squares(start, end):
    for n in range(start, end):
        yield n ** 2

for n in print_squares(2, 5):
    print(n)

前面的代码在控制台上打印出数字4916(分别在不同的行上)。到现在为止,我希望您能够自己理解它,但让我们快速回顾一下发生了什么。函数外部的for循环从print_squares(2, 5)获取一个迭代器,并在其上调用next,直到迭代结束。每次调用生成器时,执行都会被暂停(稍后恢复)在yield n ** 2上,它返回当前n的平方。让我们看看如何利用yield from表达式改变这段代码:

# gen.yield.from.py
def print_squares(start, end):
    yield from (n ** 2 for n in range(start, end))

for n in print_squares(2, 5):
    print(n)

这段代码产生了相同的结果,但是您可以看到yield from实际上正在运行一个子迭代器(n ** 2 ...)yield from表达式将子迭代器产生的每个值返回给调用者。它更短,阅读起来更好。

生成器表达式

现在让我们谈谈其他一次生成值的技术。

语法与list推导完全相同,只是,不是用方括号包装推导,而是用圆括号包装。这就是所谓的生成器表达式

通常,生成器表达式的行为类似于等效的list推导,但有一件非常重要的事情要记住:生成器只允许一次迭代,然后它们将被耗尽。让我们看一个例子:

# generator.expressions.py
>>> cubes = [k**3 for k in range(10)]  # regular list
>>> cubes
[0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
>>> type(cubes)
<class 'list'>
>>> cubes_gen = (k**3 for k in range(10))  # create as generator
>>> cubes_gen
<generator object <genexpr> at 0x103fb5a98>
>>> type(cubes_gen)
<class 'generator'>
>>> _(cubes_gen)  # this will exhaust the generator
[0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
>>> _(cubes_gen)  # nothing more to give
[]

看看生成器表达式被创建并分配名称cubes_gen的行。您可以看到它是一个生成器对象。为了看到它的元素,我们可以使用for循环,手动调用next,或者简单地将其传递给list构造函数,这就是我所做的(记住我使用_作为别名)。

请注意,一旦生成器被耗尽,就没有办法再次从中恢复相同的元素。如果我们想要再次从头开始使用它,我们需要重新创建它。

在接下来的几个例子中,让我们看看如何使用生成器表达式复制mapfilter

# gen.map.py
def adder(*n):
    return sum(n)
s1 = sum(map(lambda *n: adder(*n), range(100), range(1, 101)))
s2 = sum(adder(*n) for n in zip(range(100), range(1, 101)))

在前面的例子中,s1s2完全相同:它们是adder(0, 1), adder(1, 2), adder(2, 3)的和,依此类推,这对应于sum(1, 3, 5, ...)。尽管语法不同,但我发现生成器表达式更易读:

# gen.filter.py
cubes = [x**3 for x in range(10)]

odd_cubes1 = filter(lambda cube: cube % 2, cubes)
odd_cubes2 = (cube for cube in cubes if cube % 2)

在前面的例子中,odd_cubes1odd_cubes2是相同的:它们生成奇数立方体的序列。当事情变得有点复杂时,我再次更喜欢生成器语法。这应该在事情变得有点复杂时显而易见:

# gen.map.filter.py
N = 20
cubes1 = map(
    lambda n: (n, n**3),
    filter(lambda n: n % 3 == 0 or n % 5 == 0, range(N))
)
cubes2 = (
    (n, n**3) for n in range(N) if n % 3 == 0 or n % 5 == 0)

前面的代码创建了两个生成器,cubes1cubes2。它们完全相同,当n35的倍数时返回两个元组(n,n³)。

如果打印列表(cubes1),您会得到:[(0, 0), (3, 27), (5, 125), (6, 216), (9, 729), (10, 1000), (12, 1728), (15, 3375), (18, 5832)]

看看生成器表达式读起来好多了?当事情非常简单时,这可能是值得商榷的,但是一旦你开始嵌套函数一点,就像我们在这个例子中所做的那样,生成器语法的优越性就显而易见了。它更短,更简单,更优雅。

现在,让我问你一个问题——以下代码的区别是什么:

# sum.example.py
s1 = sum([n**2 for n in range(10**6)])
s2 = sum((n**2 for n in range(10**6)))
s3 = sum(n**2 for n in range(10**6))

严格来说,它们都产生相同的总和。获取s2s3的表达式完全相同,因为s2中的括号是多余的。它们都是sum函数中的生成器表达式。然而,获取s1的表达式是不同的。在sum中,我们找到了一个list理解。这意味着为了计算s1sum函数必须在列表上调用一百万次next

你看到我们在浪费时间和内存吗?在sum可以开始在列表上调用next之前,列表需要被创建,这是一种浪费时间和空间。对于sum来说,在一个简单的生成器表达式上调用next要好得多。没有必要将range(10**6)中的所有数字存储在列表中。

因此,在编写表达式时要注意额外的括号:有时很容易忽略这些细节,这使得我们的代码非常不同。如果你不相信我,看看下面的代码:

# sum.example.2.py
s = sum([n**2 for n in range(10**8)])  # this is killed
# s = sum(n**2 for n in range(10**8))    # this succeeds
print(s)  # prints: 333333328333333350000000

尝试运行前面的例子。如果我在我的旧 Linux 框上运行第一行,内存为 8GB,这就是我得到的:

$ python sum.example.2.py
Killed  

另一方面,如果我注释掉第一行,并取消注释第二行,这就是结果:

$ python sum.example.2.py
333333328333333350000000  

甜蜜的生成器表达式。两行之间的区别在于,在第一行中,必须先制作一个前一亿个数字的平方的列表,然后才能将它们相加。那个列表很大,我们的内存用完了(至少,我的内存用完了,如果你的内存没有用完,试试更大的数字),因此 Python 为我们终止了进程。悲伤的脸。

但是当我们去掉方括号时,我们不再有一个列表。sum函数接收0149,直到最后一个,然后将它们相加。没有问题,开心脸。

一些性能考虑

因此,我们已经看到了实现相同结果的许多不同方法。我们可以使用mapzipfilter的任何组合,或者选择使用理解,或者可能选择使用生成器,无论是函数还是表达式。我们甚至可以决定使用for循环;当要应用于每个运行参数的逻辑不简单时,它们可能是最佳选择。

除了可读性问题之外,让我们谈谈性能。在性能方面,通常有两个因素起着重要作用:空间时间

空间意味着数据结构要占用的内存大小。选择的最佳方法是问自己是否真的需要一个列表(或元组),或者一个简单的生成器函数是否同样有效。如果答案是肯定的,那就选择生成器,它会节省很多空间。对于函数也是一样;如果你实际上不需要它们返回一个列表或元组,那么你也可以将它们转换为生成器函数。

有时,你将不得不使用列表(或元组),例如有一些算法使用多个指针扫描序列,或者可能多次运行序列。生成器函数(或表达式)只能迭代一次,然后就用完了,所以在这些情况下,它不是正确的选择。

时间比空间更难,因为它取决于更多的变量,因此不可能绝对肯定地说X 比 Y 更快对于所有情况。然而,基于今天在 Python 上运行的测试,我们可以说,平均而言,map表现出与list理解和生成器表达式类似的性能,而for循环一直较慢。

为了充分理解这些陈述背后的推理,我们需要了解 Python 的工作原理,这有点超出了本书的范围,因为它在技术细节上太复杂。让我们只说maplist理解在解释器内以 C 语言速度运行,而 Python for循环作为 Python 虚拟机内的 Python 字节码运行,通常要慢得多。

Python 有几种不同的实现。最初的,也是最常见的一个是 CPython (github.com/python/cpython),它是用 C 语言编写的。C 语言是今天仍然使用的最强大和流行的编程语言之一。

我们来做一个小练习,试着找出我所说的是否准确?我将编写一小段代码,收集divmod(a, b)的结果,对于一定的整数对(a, b)。我将使用time模块中的time函数来计算我将执行的操作的经过时间:

# performances.py
from time import time
mx = 5000

t = time()  # start time for the for loop
floop = []
for a in range(1, mx):
    for b in range(a, mx):
        floop.append(divmod(a, b))
print('for loop: {:.4f} s'.format(time() - t))  # elapsed time

t = time()  # start time for the list comprehension
compr = [
    divmod(a, b) for a in range(1, mx) for b in range(a, mx)]
print('list comprehension: {:.4f} s'.format(time() - t))

t = time()  # start time for the generator expression
gener = list(
    divmod(a, b) for a in range(1, mx) for b in range(a, mx))
print('generator expression: {:.4f} s'.format(time() - t))

你可以看到,我们创建了三个列表:floopcomprgener。运行代码会产生以下结果:

$ python performances.py
for loop: 4.4814 s
list comprehension: 3.0210 s
generator expression: 3.4334 s

list理解运行时间约为for循环时间的 67%。这令人印象深刻。生成器表达式接近这个时间,约为for循环时间的 77%。生成器表达式较慢的原因是我们需要将其提供给list()构造函数,这与纯粹的list理解相比有更多的开销。如果我不必保留这些计算的结果,生成器可能是更合适的选择。

有趣的是,在for循环的主体中,我们正在向列表中添加数据。这意味着 Python 在幕后做着工作,不时地调整大小,为要添加的项目分配空间。我猜想创建一个零列表,并简单地用结果填充它,可能会加快for循环的速度,但我错了。你自己检查一下,你只需要预分配mx * (mx - 1) // 2个元素。

让我们看一个类似的例子,比较一下for循环和map调用:

# performances.map.py
from time import time
mx = 2 * 10 ** 7

t = time()
absloop = []
for n in range(mx):
    absloop.append(abs(n))
print('for loop: {:.4f} s'.format(time() - t))

t = time()
abslist = [abs(n) for n in range(mx)]
print('list comprehension: {:.4f} s'.format(time() - t))

t = time()
absmap = list(map(abs, range(mx)))
print('map: {:.4f} s'.format(time() - t))

这段代码在概念上与前面的例子非常相似。唯一改变的是我们应用了abs函数而不是divmod,并且我们只有一个循环而不是两个嵌套的循环。执行后得到以下结果:

$ python performances.map.py
for loop: 3.8948 s
list comprehension: 1.8594 s
map: 1.1548 s

map赢得了比赛:约为list理解时间的 62%,for循环时间的 30%。这些结果可能会有所不同,因为各种因素,如操作系统和 Python 版本。但总的来说,我认为这些结果足够好,可以让我们对编写性能代码有一个概念。

尽管有一些个案的小差异,很明显for循环选项是最慢的,所以让我们看看为什么我们仍然想要使用它。

不要过度使用理解和生成器

我们已经看到了list理解和生成器表达式有多么强大。它们确实如此,不要误会我的意思,但当我处理它们时的感觉是,它们的复杂性呈指数增长。你尝试在一个单一的理解或生成器表达式中做的越多,它就越难以阅读、理解,因此也就越难以维护或更改。

如果你再次查看 Python 之禅,有几行我认为值得在处理优化代码时牢记:

>>> import this
...
Explicit is better than implicit.
Simple is better than complex.
...
Readability counts.
...
If the implementation is hard to explain, it's a bad idea.
...

理解和生成器表达式比较隐式而不是显式,可能相当难以阅读和理解,也很难解释。有时你必须使用由内而外的技术来分解它们,以理解发生了什么。

举个例子,让我们再谈谈毕达哥拉斯三元组。只是提醒一下,毕达哥拉斯三元组是一组正整数元组(a, b, c),使得a² + b² = c²

我们在过滤理解部分看到了如何计算它们,但我们以一种非常低效的方式进行了,因为我们正在扫描所有低于某个阈值的数字对,计算斜边,并过滤掉那些没有产生三元组的数字对。

获得勾股数三元组的更好方法是直接生成它们。有许多不同的公式可以用来做到这一点,我们将使用欧几里得公式

这个公式表明,任何三元组(abc),其中a = m² - n²b = 2mnc = m² + n²mn是正整数,满足m > n,都是勾股数三元组。例如,当m = 2n = 1时,我们找到了最小的三元组:(345)。

然而,有一个问题:考虑一下三元组(6810),它就像(345)一样,只是所有数字都乘以2。这个三元组肯定是勾股数三元组,因为6² + 8² = 10²,但我们可以通过简单地将其每个元素乘以2来从(345)派生出它。对于所有可以写成(3k4k5k)的三元组,其中k是大于1的正整数,情况也是如此。

不能通过将另一个三元组的元素乘以某个因子k获得的三元组称为原始。另一种陈述这一点的方式是:如果三元组的三个元素是互质的,那么这个三元组就是原始的。当两个数在它们的除数中没有共享任何质因数时,它们就是互质的,也就是说,它们的最大公约数GCD)是1。例如,3 和 5 是互质的,而 3 和 6 不是,因为它们都可以被 3 整除。

因此,欧几里得公式告诉我们,如果mn是互质的,并且m - n是奇数,那么它们生成的三元组是原始的。在下面的例子中,我们将编写一个生成器表达式,计算所有原始的勾股数三元组,其斜边(c)小于或等于某个整数N。这意味着我们希望所有满足m² + n² ≤ N的三元组。当n1时,公式如下:m² ≤ N - 1,这意味着我们可以用m ≤ N^(1/2)的上限来近似计算。

因此,总结一下:m必须大于n,它们也必须互质,它们的差异m - n必须是奇数。此外,为了避免无用的计算,我们将m的上限设定为floor(sqrt(N)) + 1

实数xfloor函数给出最大整数n,使得n < x,例如,floor(3.8) = 3floor(13.1) = 13。取floor(sqrt(N)) + 1意味着取N的平方根的整数部分,并添加一个最小的边距,以确保我们不会错过任何数字。

让我们一步一步地将所有这些放入代码中。让我们首先编写一个使用欧几里得算法的简单gcd函数:

# functions.py
def gcd(a, b):
    """Calculate the Greatest Common Divisor of (a, b). """
    while b != 0:
        a, b = b, a % b
    return a

欧几里得算法的解释可以在网上找到,所以我不会在这里花时间谈论它;我们需要专注于生成器表达式。下一步是利用之前收集的知识来生成一个原始勾股数三元组的列表:

# pythagorean.triple.generation.py
from functions import gcd
N = 50

triples = sorted(                                    # 1
    ((a, b, c) for a, b, c in (                      # 2
        ((m**2 - n**2), (2 * m * n), (m**2 + n**2))  # 3
        for m in range(1, int(N**.5) + 1)            # 4
        for n in range(1, m)                         # 5
        if (m - n) % 2 and gcd(m, n) == 1            # 6
    ) if c <= N), key=lambda *triple: sum(*triple)   # 7
)

这就是了。它不容易阅读,所以让我们逐行进行解释。在#3处,我们开始一个生成器表达式,用于创建三元组。从#4#5可以看出,我们在[1,M]中循环m,其中Msqrt(N)的整数部分,再加上1。另一方面,n[1,m)中循环,以遵守m > n的规则。值得注意的是我如何计算sqrt(N),即N**.5,这只是另一种我想向你展示的方法。

#6,您可以看到使三元组原始的过滤条件:当(m - n)为奇数时,(m - n)%2的值为True,而gcd(m, n) == 1表示mn是互质的。有了这些条件,我们知道三元组将是原始的。这照顾了最内层的生成器表达式。最外层的生成器表达式从#2开始,结束于#7。我们取(a, b, c)在(...最内层生成器...)中,使得c <= N

最后,在#1我们应用排序,以按顺序呈现列表。在最外层生成器表达式关闭后的#7处,您可以看到我们指定排序键为和的总和a + b + c。这只是我的个人偏好,没有数学原因。

那么,你觉得呢?阅读起来简单吗?我不这么认为。相信我,这仍然是一个简单的例子;在我的职业生涯中,我见过更糟糕的情况。这种代码难以理解、调试和修改。它不应该出现在专业环境中。

所以,让我们看看是否可以将这段代码重写成更易读的形式:

# pythagorean.triple.generation.for.py
from functions import gcd

def gen_triples(N):
    for m in range(1, int(N**.5) + 1):                  # 1
        for n in range(1, m):                           # 2
            if (m - n) % 2 and gcd(m, n) == 1:          # 3
                c = m**2 + n**2                         # 4
                if c <= N:                              # 5
                    a = m**2 - n**2                     # 6
                    b = 2 * m * n                       # 7
                    yield (a, b, c)                     # 8

triples = sorted(
    gen_triples(50), key=lambda *triple: sum(*triple))  # 9

这好多了。让我们逐行看一下。你会看到它有多容易理解。

我们从#1#2开始循环,方式与之前的示例中的循环方式完全相同。在第#3行,我们对原始三元组进行了过滤。在第#4行,我们有了一点偏离之前的做法:我们计算了c,在第#5行,我们对c小于或等于N进行了过滤。只有当c满足这个条件时,我们才计算ab,并产生结果的元组。尽可能延迟所有计算总是很好的,这样我们就不会浪费时间和 CPU。在最后一行,我们使用了与生成器表达式示例中相同的键进行排序。

希望你同意,这个例子更容易理解。我向你保证,如果有一天你不得不修改这段代码,你会发现修改这个代码很容易,而修改另一个版本将需要更长的时间(而且容易出错)。

如果打印两个示例的结果(它们是相同的),你会得到这个:

[(3, 4, 5), (5, 12, 13), (15, 8, 17), (7, 24, 25), (21, 20, 29), (35, 12, 37), (9, 40, 41)]  

故事的寓意是,尽量使用理解和生成器表达式,但如果代码开始变得复杂,难以修改或阅读,你可能需要将其重构为更易读的形式。你的同事会感谢你。

名称本地化

既然我们熟悉了所有类型的理解和生成器表达式,让我们谈谈它们内部的名称本地化。Python 3.*在所有四种理解形式中都将循环变量本地化:listdictset和生成器表达式。这种行为与for循环的行为不同。让我们看一个简单的例子来展示所有情况:

# scopes.py
A = 100
ex1 = [A for A in range(5)]
print(A)  # prints: 100

ex2 = list(A for A in range(5))
print(A)  # prints: 100

ex3 = dict((A, 2 * A) for A in range(5))
print(A)  # prints: 100

ex4 = set(A for A in range(5))
print(A)  # prints: 100

s = 0
for A in range(5):
    s += A
print(A)  # prints: 4

在前面的代码中,我们声明了一个全局名称A = 100,然后我们使用了四种理解方式:list、生成器表达式、字典和set。它们都没有改变全局名称A。相反,您可以在最后看到for循环修改了它。最后的打印语句打印出4

让我们看看如果没有A会发生什么:

# scopes.noglobal.py
ex1 = [A for A in range(5)]
print(A)  # breaks: NameError: name 'A' is not defined

前面的代码可以使用任何四种理解方式来完成相同的工作。运行第一行后,A在全局命名空间中未定义。再次,for循环的行为不同:

# scopes.for.py
s = 0
for A in range(5):
    s += A
print(A) # prints: 4
print(globals())

前面的代码表明,在for循环之后,如果循环变量在之前没有定义,我们可以在全局框架中找到它。为了确保这一点,让我们调用globals()内置函数来一探究竟:

$ python scopes.for.py
4
{'__name__': '__main__', '__doc__': None, ..., 's': 10, 'A': 4}

除了我省略的大量样板之外,我们可以发现'A': 4

内置生成行为

在内置类型中,生成行为现在非常普遍。这是 Python 2 和 Python 3 之间的一个重大区别。许多函数,如mapzipfilter,都已经改变,以便它们返回像可迭代对象一样的对象。这种改变背后的想法是,如果你需要制作这些结果的列表,你可以总是将调用包装在list()类中,然后你就完成了。另一方面,如果你只需要迭代,并希望尽可能减少对内存的影响,你可以安全地使用这些函数。

另一个显著的例子是range函数。在 Python 2 中,它返回一个列表,还有另一个叫做xrange的函数,它返回一个你可以迭代的对象,它会动态生成数字。在 Python 3 中,这个函数已经消失了,range现在的行为就像它。

但是,这个概念,总的来说,现在是相当普遍的。你可以在open()函数中找到它,这个函数用于操作文件对象(我们将在第七章中看到它,文件和数据持久性),但也可以在enumerate、字典keysvaluesitems方法以及其他一些地方找到它。

这一切都是有道理的:Python 的目标是尽可能减少内存占用,尽量避免浪费空间,特别是在大多数情况下广泛使用的那些函数和方法中。

你还记得本章开头吗?我说过,优化那些必须处理大量对象的代码的性能比每天调用两次的函数节省几毫秒更有意义。

最后一个例子

在我们结束本章之前,我会向你展示一个我曾经在一家我曾经工作过的公司提交给 Python 开发人员角色的一个简单问题。

问题是:给定序列0 1 1 2 3 5 8 13 21 ...,编写一个函数,它将返回这个序列的项直到某个限制N

如果你没有意识到,那就是斐波那契数列,它被定义为F(0) = 0F(1) = 1,对于任何n > 1F(n) = F(n-1) + F(n-2)。这个序列非常适合测试关于递归、记忆化技术和其他技术细节的知识,但在这种情况下,这是一个检查候选人是否了解生成器的好机会。

让我们从一个基本版本的函数开始,然后对其进行改进:

# fibonacci.first.py
def fibonacci(N):
    """Return all fibonacci numbers up to N. """
    result = [0]
    next_n = 1
    while next_n <= N:
        result.append(next_n)
        next_n = sum(result[-2:])
    return result

print(fibonacci(0))   # [0]
print(fibonacci(1))   # [0, 1, 1]
print(fibonacci(50))  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

从头开始:我们将result列表设置为起始值[0]。然后我们从下一个元素(next_n)开始迭代,即1。只要下一个元素不大于N,我们就不断将它附加到列表中并计算下一个。我们通过取result列表中最后两个元素的切片并将其传递给sum函数来计算下一个元素。如果这对你来说不清楚,可以在这里和那里添加一些print语句,但到现在我希望这不会成为一个问题。

while循环的条件评估为False时,我们退出循环并返回result。你可以在每个print语句旁边的注释中看到这些print语句的结果。

在这一点上,我会问候选人以下问题:如果我只想迭代这些数字怎么办? 一个好的候选人会改变代码,你会在这里找到(一个优秀的候选人会从这里开始!):

# fibonacci.second.py
def fibonacci(N):
    """Return all fibonacci numbers up to N. """
    yield 0
    if N == 0:
        return
    a = 0
    b = 1
    while b <= N:
        yield b
        a, b = b, a + b

print(list(fibonacci(0)))   # [0]
print(list(fibonacci(1)))   # [0, 1, 1]
print(list(fibonacci(50)))  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

这实际上是我得到的解决方案之一。我不知道为什么我保存了它,但我很高兴我这样做了,这样我就可以向你展示它。现在,fibonacci函数是一个生成器函数。首先我们产生0,然后如果N0,我们返回(这将导致引发StopIteration异常)。如果不是这种情况,我们开始迭代,每个循环周期产生b,然后更新ab。为了能够产生序列的下一个元素,我们只需要过去的两个:ab

这段代码好多了,内存占用更少,我们只需要用list()将调用包装起来,就像往常一样,就可以得到一个斐波那契数列。但是优雅呢?我不能就这样把它留下吧?让我们试试下面的方法:

# fibonacci.elegant.py
def fibonacci(N):
    """Return all fibonacci numbers up to N. """
    a, b = 0, 1
    while a <= N:
        yield a
        a, b = b, a + b

好多了。这个函数的整个主体只有四行,如果算上文档字符串的话就是五行。请注意,在这种情况下,使用元组赋值(a, b = 0, 1a, b = b, a + b)有助于使代码更短、更易读。

摘要

在本章中,我们更深入地探讨了迭代和生成的概念。我们详细研究了mapzipfilter函数,并学会了如何将它们作为常规for循环方法的替代方法。

然后我们讨论了列表、字典和集合的理解概念。我们探讨了它们的语法以及如何将它们作为传统的for循环方法和mapzipfilter函数的替代方法来使用。

最后,我们讨论了生成的概念,有两种形式:生成器函数和表达式。我们学会了如何通过使用生成技术来节省时间和空间,并看到它们如何使得通常情况下无法实现的事情成为可能。

我们谈到了性能,并看到for循环在速度上是最慢的,但它们提供了最佳的可读性和灵活性。另一方面,诸如mapfilter以及list推导这样的函数可能会快得多。

使用这些技术编写的代码复杂度呈指数级增长,因此,为了更有利于可读性和易维护性,我们仍然需要有时使用传统的for循环方法。另一个区别在于名称本地化,其中for循环的行为与所有其他类型的推导不同。

下一章将全面讨论对象和类。它在结构上与本章类似,我们不会探讨许多不同的主题,只是其中的一些,但我们会尝试更深入地探讨它们。

在继续下一章之前,请确保您理解了本章的概念。我们正在一砖一瓦地建造一堵墙,如果基础不牢固,我们将走不远。

第六章:OOP,装饰器和迭代器

La classe non è acqua.(类似水)- 意大利谚语

我可能会写一本关于面向对象编程OOP)和类的整本书。在本章中,我面临着在广度和深度之间找到平衡的艰难挑战。有太多的事情要讲述,其中很多如果我深入描述的话,将会占用整个章节以上的篇幅。因此,我将尽量给你一个我认为是对基础知识的良好全景视图,再加上一些在接下来的章节中可能会派上用场的东西。Python 的官方文档将有助于填补这些空白。

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

  • 装饰器

  • Python 中的 OOP

  • 迭代器

装饰器

在第五章《节省时间和内存》中,我测量了各种表达式的执行时间。如果你还记得的话,我不得不初始化一个变量到开始时间,并在执行后从当前时间中减去它,以计算经过的时间。我还在每次测量后在控制台上打印出来。那太麻烦了。

每当你发现自己重复做某些事情时,警报应该响起。你能把那段代码放到一个函数中,避免重复吗?大多数情况下答案是,所以让我们看一个例子:

# decorators/time.measure.start.py
from time import sleep, time

def f():
    sleep(.3)

def g():
    sleep(.5)

t = time()
f()
print('f took:', time() - t)  # f took: 0.3001396656036377

t = time()
g()
print('g took:', time() - t)  # g took: 0.5039339065551758

在前面的代码中,我定义了两个函数fg,它们除了休眠(分别为 0.3 和 0.5 秒)之外什么也不做。我使用sleep函数来暂停执行所需的时间。请注意时间测量非常准确。现在,我们如何避免重复那段代码和那些计算呢?一个潜在的方法可能是:

# decorators/time.measure.dry.py
from time import sleep, time

def f():
    sleep(.3)

def g():
    sleep(.5)

def measure(func):
    t = time()
    func()
    print(func.__name__, 'took:', time() - t)

measure(f)  # f took: 0.30434322357177734
measure(g)  # g took: 0.5048270225524902

啊,现在好多了。整个计时机制已经封装到一个函数中,所以我们不需要重复代码。我们可以动态打印函数名称,编码起来也很容易。如果我们需要将参数传递给我们测量的函数呢?这段代码可能会变得有点复杂,所以让我们看一个例子:

# decorators/time.measure.arguments.py
from time import sleep, time

def f(sleep_time=0.1):
    sleep(sleep_time)

def measure(func, *args, **kwargs):
    t = time()
    func(*args, **kwargs)
    print(func.__name__, 'took:', time() - t)

measure(f, sleep_time=0.3)  # f took: 0.30056095123291016
measure(f, 0.2)  # f took: 0.2033553123474121

现在,f期望被提供sleep_time(默认值为0.1),所以我们不再需要g。我还必须更改measure函数,使其现在接受一个函数、任意变量位置参数和任意变量关键字参数。这样,无论我们用什么调用measure,我们都会将这些参数重定向到我们在内部调用func的调用中。

这很好,但我们可以再推进一点。假设我们想要在f函数中内置这种计时行为,这样我们就可以直接调用它并进行测量。我们可以这样做:

# decorators/time.measure.deco1.py
from time import sleep, time

def f(sleep_time=0.1):
    sleep(sleep_time)

def measure(func):
    def wrapper(*args, **kwargs):
        t = time()
        func(*args, **kwargs)
        print(func.__name__, 'took:', time() - t)
    return wrapper

f = measure(f)  # decoration point

f(0.2)  # f took: 0.20372915267944336
f(sleep_time=0.3)  # f took: 0.30455899238586426
print(f.__name__)  # wrapper <- ouch!

前面的代码可能并不那么直接。让我们看看这里发生了什么。魔法在于装饰点。当我们用f作为参数调用measure时,我们基本上用measure返回的任何东西重新分配了f。在measure中,我们定义了另一个函数wrapper,然后返回它。因此,在装饰点之后的效果是,当我们调用f时,实际上是在调用wrapper。由于内部的wrapper调用了func,也就是f,我们实际上是这样关闭了循环。如果你不相信我,看看最后一行。

wrapper实际上是...一个包装器。它接受变量和位置参数,并用它们调用f。它还在调用周围进行时间测量计算。

这种技术称为装饰,而measure实际上是一个装饰器。这种范式变得如此受欢迎和广泛使用,以至于 Python 在某个时候添加了一个专门的语法(查看www.python.org/dev/peps/pep-0318/)。让我们探讨三种情况:一个装饰器,两个装饰器和一个带参数的装饰器:

# decorators/syntax.py
def func(arg1, arg2, ...):
    pass
func = decorator(func)

# is equivalent to the following:

@decorator
def func(arg1, arg2, ...):
    pass

基本上,我们不需要手动将函数重新分配给装饰器返回的内容,而是在函数的定义前面加上特殊的语法@decorator_name

我们可以以以下方式将多个装饰器应用于同一个函数:

# decorators/syntax.py
def func(arg1, arg2, ...):
    pass
func = deco1(deco2(func))

# is equivalent to the following:

@deco1
@deco2
def func(arg1, arg2, ...):
    pass

在应用多个装饰器时,要注意顺序。在上面的例子中,首先用deco2装饰func,然后用deco1装饰结果。一个很好的经验法则是:装饰器离函数越近,越早应用

有些装饰器可以接受参数。这种技术通常用于生成其他装饰器。让我们先看一下语法,然后再看一个例子:

# decorators/syntax.py
def func(arg1, arg2, ...):
    pass
func = decoarg(arg_a, arg_b)(func)

# is equivalent to the following:

@decoarg(arg_a, arg_b)
def func(arg1, arg2, ...):
    pass

正如你所看到的,这种情况有点不同。首先,使用给定的参数调用decoarg,然后调用它的返回值(实际的装饰器)与func。在我给你另一个例子之前,让我们解决一个让我困扰的问题。我不想在装饰函数时丢失原始函数名称和文档字符串(以及其他属性,具体细节请查看文档)。但是因为在我们的装饰器内部返回了wrapper,来自func的原始属性就丢失了,f最终被分配了wrapper的属性。functools模块有一个简单的解决方法。我将修复最后一个例子,并且还将重写其语法以使用@运算符:

# decorators/time.measure.deco2.py
from time import sleep, time
from functools import wraps

def measure(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        t = time()
        func(*args, **kwargs)
        print(func.__name__, 'took:', time() - t)
    return wrapper

@measure
def f(sleep_time=0.1):
    """I'm a cat. I love to sleep! """
    sleep(sleep_time)

f(sleep_time=0.3)  # f took: 0.3010902404785156
print(f.__name__, ':', f.__doc__)  # f : I'm a cat. I love to sleep!

现在我们说得通了!正如你所看到的,我们所需要做的就是告诉 Pythonwrapper实际上包装了func(通过wraps函数),你可以看到原始名称和文档字符串现在得到了保留。

让我们看另一个例子。我想要一个装饰器,在函数的结果大于一定阈值时打印错误消息。我还将利用这个机会向你展示如何同时应用两个装饰器:

# decorators/two.decorators.py
from time import sleep, time
from functools import wraps

def measure(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        t = time()
        result = func(*args, **kwargs)
        print(func.__name__, 'took:', time() - t)
        return result
    return wrapper

def max_result(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        if result > 100:
            print('Result is too big ({0}). Max allowed is 100.'
                  .format(result))
        return result
    return wrapper

@measure
@max_result
def cube(n):
    return n ** 3

print(cube(2))
print(cube(5))

花点时间来研究上面的例子,直到你确信你理解得很好。如果你理解了,我认为现在没有任何装饰器是你写不出来的。

我不得不增强measure装饰器,使得它的wrapper现在返回对func的调用结果。max_result装饰器也是这样做的,但在返回之前,它检查result是否大于100,这是允许的最大值。我用这两个装饰器装饰了cube。首先应用max_result,然后是measure。运行这段代码会产生这个结果:

$ python two.decorators.py
cube took: 3.0994415283203125e-06
8 
Result is too big (125). Max allowed is 100.
cube took: 1.0013580322265625e-05
125

为了方便起见,我用一个空行分隔了两次调用的结果。在第一次调用中,结果是8,通过了阈值检查。运行时间被测量并打印。最后,我们打印结果(8)。

在第二次调用中,结果是125,所以错误消息被打印,结果被返回,然后轮到measure,再次打印运行时间,最后,我们打印结果(125)。

如果我用不同顺序的相同两个装饰器装饰cube函数,错误消息将会在打印运行时间的行之后而不是之前。

装饰器工厂

现在让我们简化这个例子,回到一个单一的装饰器:max_result。我想让它这样做,以便我可以用不同的阈值装饰不同的函数,我不想为每个阈值编写一个装饰器。让我们修改max_result,以便它允许我们动态地指定阈值来装饰函数:

# decorators/decorators.factory.py
from functools import wraps

def max_result(threshold):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            if result > threshold:
                print(
                    'Result is too big ({0}). Max allowed is {1}.'
                    .format(result, threshold))
            return result
        return wrapper
    return decorator

@max_result(75)
def cube(n):
    return n ** 3

print(cube(5))

上面的代码向你展示了如何编写装饰器工厂。如果你还记得,用带参数的装饰器装饰函数与编写func = decorator(argA, argB)(func)是一样的,所以当我们用max_result(75)装饰cube时,我们实际上是在做cube = max_result(75)(cube)

让我们一步步地看发生了什么。当我们调用max_result(75)时,我们进入它的主体。在里面定义了一个decorator函数,它以函数作为唯一的参数。在该函数内部执行了通常的装饰器技巧。我们定义了wrapper,在其中我们检查原始函数调用的结果。这种方法的美妙之处在于,从最内层,我们仍然可以动态地引用functhreshold,这使我们能够动态地设置阈值。

wrapper返回resultdecorator返回wrappermax_result返回decorator。这意味着我们的cube = max_result(75)(cube)调用实际上变成了cube = decorator(cube)。不仅仅是任何decorator,而是threshold的值为75decorator。这是通过一种称为闭包的机制实现的,这超出了本章的范围,但仍然非常有趣,所以我提到它让您进行一些研究。

运行上一个示例会产生以下结果:

$ python decorators.factory.py
Result is too big (125). Max allowed is 75.
125

前面的代码允许我随心所欲地使用max_result装饰器,就像这样:

# decorators/decorators.factory.py
@max_result(75)
def cube(n):
    return n ** 3

@max_result(100)
def square(n):
    return n ** 2

@max_result(1000)
def multiply(a, b):
    return a * b

请注意,每个装饰都使用不同的threshold值。

装饰器在 Python 中非常受欢迎。它们经常被使用,并且大大简化(我敢说还美化)了代码。

面向对象编程(OOP)

这是一个相当漫长而希望愉快的旅程,到现在为止,我们应该准备好去探索面向对象编程了。我将使用 Kindler, E.; Krivy, I. (2011)的定义。Object-oriented simulation of systems with sophisticated control by International Journal of General Systems,并将其适应到 Python 中:

面向对象编程(OOP)是一种基于“对象”概念的编程范式,对象是包含数据(属性形式)和代码(方法形式)的数据结构。对象的一个显著特征是对象的方法可以访问并经常修改与其关联的数据属性(对象具有“self”的概念)。在面向对象编程中,计算机程序是通过使它们由相互交互的对象构成来设计的。

Python 完全支持这种范式。实际上,正如我们已经说过的,Python 中的一切都是对象,因此这表明 OOP 不仅受到 Python 的支持,而且它是其核心的一部分。

面向对象编程的两个主要角色是对象。类用于创建对象(对象是从它们创建的类的实例),因此我们可以将它们视为实例工厂。当对象由类创建时,它们继承类的属性和方法。它们代表程序领域中的具体项目。

最简单的 Python 类

我将从您可以在 Python 中编写的最简单的类开始:

# oop/simplest.class.py
class Simplest():  # when empty, the braces are optional
    pass

print(type(Simplest))  # what type is this object?
simp = Simplest()  # we create an instance of Simplest: simp
print(type(simp))  # what type is simp?
# is simp an instance of Simplest?
print(type(simp) == Simplest)  # There's a better way for this

让我们运行前面的代码,并逐行解释它:

$ python simplest.class.py
<class 'type'>
<class '__main__.Simplest'>
True

我定义的Simplest类在其主体中只有pass指令,这意味着它没有任何自定义属性或方法。如果为空,则名称后面的括号是可选的。我将打印它的类型(__main__是顶层代码执行的范围的名称),我知道,在注释中,我写的是object而不是class。事实证明,正如您可以从print的结果中看到的那样,类实际上是对象。准确地说,它们是type的实例。解释这个概念将导致我们讨论元类元编程,这些是需要牢固掌握基本原理才能理解的高级概念,超出了本章的范围。像往常一样,我提到它是为了给您留下一个指针,以便在您准备深入了解时使用。

让我们回到这个例子:我使用Simplest创建了一个实例simp。您可以看到创建实例的语法与我们调用函数的方式相同。然后我们打印simp属于的类型,并验证simp实际上是Simplest的一个实例。我将在本章后面向您展示更好的方法。

到目前为止,一切都很简单。但是,当我们写class ClassName(): pass时会发生什么呢?嗯,Python 所做的是创建一个类对象并为其分配一个名称。这与我们使用def声明函数时发生的情况非常相似。

类和对象的命名空间

在类对象创建后(通常在模块首次导入时发生),它基本上代表一个命名空间。我们可以调用该类来创建其实例。每个实例都继承了类属性和方法,并被赋予了自己的命名空间。我们已经知道,要遍历命名空间,我们只需要使用点(.)运算符。

让我们看另一个例子:

# oop/class.namespaces.py
class Person:
    species = 'Human'

print(Person.species)  # Human
Person.alive = True  # Added dynamically!
print(Person.alive)  # True

man = Person()
print(man.species)  # Human (inherited)
print(man.alive)  # True (inherited)

Person.alive = False
print(man.alive)  # False (inherited)

man.name = 'Darth'
man.surname = 'Vader'
print(man.name, man.surname)  # Darth Vader

在上面的例子中,我定义了一个名为species的类属性。在类的主体中定义的任何变量都是属于该类的属性。在代码中,我还定义了Person.alive,这是另一个类属性。你可以看到从类中访问该属性没有限制。你可以看到manPerson的一个实例,它继承了它们两个,并在它们改变时立即反映出来。

man也有两个属于它自己命名空间的属性,因此被称为实例属性namesurname

类属性在所有实例之间共享,而实例属性不共享;因此,你应该使用类属性来提供所有实例共享的状态和行为,并使用实例属性来存储只属于一个特定对象的数据。

属性遮蔽

当你在对象中搜索属性时,如果没有找到,Python 会继续在用于创建该对象的类中搜索(并一直搜索,直到找到或达到继承链的末尾)。这导致了一个有趣的遮蔽行为。让我们看另一个例子:

# oop/class.attribute.shadowing.py
class Point:
    x = 10
    y = 7

p = Point()
print(p.x)  # 10 (from class attribute)
print(p.y)  # 7 (from class attribute)

p.x = 12  # p gets its own `x` attribute
print(p.x)  # 12 (now found on the instance)
print(Point.x)  # 10 (class attribute still the same)

del p.x  # we delete instance attribute
print(p.x)  # 10 (now search has to go again to find class attr)

p.z = 3  # let's make it a 3D point
print(p.z)  # 3

print(Point.z)
# AttributeError: type object 'Point' has no attribute 'z'

前面的代码非常有趣。我们定义了一个名为Point的类,其中有两个类属性xy。当我们创建一个实例p时,你可以看到我们可以从p的命名空间(p.xp.y)打印出xy。当我们这样做时,Python 在实例上找不到任何xy属性,因此搜索类,并在那里找到它们。

然后我们通过分配p.x = 12p赋予了它自己的x属性。这种行为一开始可能看起来有点奇怪,但是如果你仔细想想,它与在函数中发生的事情完全相同,当外部有一个全局x = 10时,函数声明x = 12。我们知道x = 12不会影响全局变量,对于类和实例来说,情况也是一样的。

在分配p.x = 12之后,当我们打印它时,搜索不需要读取类属性,因为x在实例中找到了,因此我们得到了12的输出。我们还打印了Point.x,它指的是类命名空间中的x

然后,我们从p的命名空间中删除x,这意味着在下一行,当我们再次打印它时,Python 将再次在类中搜索它,因为它在实例中不再被找到。

最后三行向你展示了将属性分配给实例并不意味着它们将在类中被找到。实例得到了类中的所有内容,但反之则不成立。

你认为将xy坐标作为类属性是一个好主意吗?如果你添加另一个Point的实例会怎么样?这是否有助于说明类属性为什么非常有用?

我自己和我 - 使用 self 变量

在类方法内部,我们可以通过一个特殊的参数self来引用一个实例,按照惯例称之为selfself始终是实例方法的第一个属性。让我们一起研究这种行为,以及我们如何可以与所有实例共享,不仅是属性,还有方法:

# oop/class.self.py
class Square:
    side = 8
    def area(self):  # self is a reference to an instance
        return self.side ** 2

sq = Square()
print(sq.area())  # 64 (side is found on the class)
print(Square.area(sq))  # 64 (equivalent to sq.area())

sq.side = 10
print(sq.area())  # 100 (side is found on the instance)

注意area方法如何被sq使用。两个调用Square.area(sq)sq.area()是等价的,并教会我们机制是如何工作的。你可以将实例传递给方法调用(Square.area(sq)),在方法内部将使用名称self,或者你可以使用更舒适的语法sq.area(),Python 会在幕后为你翻译它。

让我们看一个更好的例子:

# oop/class.price.py
class Price:
    def final_price(self, vat, discount=0):
        """Returns price after applying vat and fixed discount."""
        return (self.net_price * (100 + vat) / 100) - discount

p1 = Price()
p1.net_price = 100
print(Price.final_price(p1, 20, 10))  # 110 (100 * 1.2 - 10)
print(p1.final_price(20, 10))  # equivalent

前面的代码向您展示了,在声明方法时没有任何阻止我们使用参数。我们可以使用与函数相同的语法,但需要记住第一个参数始终是实例。我们不一定需要将其称为self,但这是约定,这是为数不多的非常重要遵守的情况之一。

初始化实例

您是否注意到,在调用p1.final_price(...)之前,我们必须将net_price赋给p1?有更好的方法可以做到这一点。在其他语言中,这将被称为构造函数,但在 Python 中不是。它实际上是一个初始化程序,因为它在已创建的实例上工作,因此被称为__init__。它是一个魔术方法,在对象创建后立即运行。Python 对象还有一个__new__方法,这才是真正的构造函数。实际上,我们通常不需要覆盖它,这种做法在编写元类时才会用到,正如我们提到的,这是一个相当高级的主题,我们不会在本书中探讨:

# oop/class.init.py
class Rectangle:
    def __init__(self, side_a, side_b):
        self.side_a = side_a
        self.side_b = side_b

    def area(self):
        return self.side_a * self.side_b

r1 = Rectangle(10, 4)
print(r1.side_a, r1.side_b)  # 10 4
print(r1.area())  # 40

r2 = Rectangle(7, 3)
print(r2.area())  # 21

事情终于开始有了眉目。当一个对象被创建时,__init__方法会自动运行。在这种情况下,我编写了这样一个代码,当我们创建一个对象(通过调用类名像调用函数一样),我们传递参数给创建调用,就像我们在任何常规函数调用中一样。我们传递参数的方式遵循__init__方法的签名,因此,在两个创建语句中,107将分别成为r1r2side_a,而43将成为side_b。您可以看到从r1r2调用area()反映了它们具有不同的实例参数。以这种方式设置对象更加美观和方便。

OOP 是关于代码重用的

到目前为止,应该很清楚:OOP 是关于代码重用的。我们定义一个类,创建实例,这些实例使用仅在类中定义的方法。它们将根据初始化程序设置实例的方式而表现出不同的行为。

继承和组合

但这只是故事的一半,OOP 更加强大。我们有两个主要的设计构造可以利用:继承和组合。

继承意味着两个对象通过是一个类型的关系相关联。另一方面,组合意味着两个对象通过有一个类型的关系相关联。这一切都很容易通过一个例子来解释:

# oop/class_inheritance.py
class Engine:
    def start(self):
        pass

    def stop(self):
        pass

class ElectricEngine(Engine):  # Is-A Engine
    pass

class V8Engine(Engine):  # Is-A Engine
    pass

class Car:
    engine_cls = Engine

    def __init__(self):
        self.engine = self.engine_cls()  # Has-A Engine

    def start(self):
        print(
            'Starting engine {0} for car {1}... Wroom, wroom!'
            .format(
                self.engine.__class__.__name__,
                self.__class__.__name__)
        )
        self.engine.start()

    def stop(self):
        self.engine.stop()

class RaceCar(Car):  # Is-A Car
    engine_cls = V8Engine

class CityCar(Car):  # Is-A Car
    engine_cls = ElectricEngine

class F1Car(RaceCar):  # Is-A RaceCar and also Is-A Car
    pass  # engine_cls same as parent

car = Car()
racecar = RaceCar()
citycar = CityCar()
f1car = F1Car()
cars = [car, racecar, citycar, f1car]

for car in cars:
    car.start()

""" Prints:
Starting engine Engine for car Car... Wroom, wroom!
Starting engine V8Engine for car RaceCar... Wroom, wroom!
Starting engine ElectricEngine for car CityCar... Wroom, wroom!
Starting engine V8Engine for car F1Car... Wroom, wroom!
"""

前面的例子向您展示了对象之间是一个有一个类型的关系。首先,让我们考虑Engine。这是一个简单的类,有两个方法,startstop。然后我们定义了ElectricEngineV8Engine,它们都继承自Engine。您可以看到,当我们定义它们时,在类名后面的括号中放入了Engine,这表明它们继承自Engine

这意味着ElectricEngineV8Engine都继承自Engine类的属性和方法,这被称为它们的基类

汽车也是如此。CarRaceCarCityCar的基类。RaceCar也是F1Car的基类。另一种说法是,F1Car继承自RaceCarRaceCar继承自Car。因此,F1Car是一个RaceCarRaceCar是一个Car。由于传递性,我们也可以说F1Car是一个CarCityCar也是是一个Car

当我们定义class A(B): pass时,我们说AB子类,而BA父类父类基类是同义词,子类派生类也是。此外,我们说一个类从另一个类继承,或者扩展它。

这就是继承机制。

另一方面,让我们回到代码。每个类都有一个类属性engine_cls,它是我们想要分配给每种类型汽车的发动机类的引用。Car有一个通用的Engine,而两辆赛车有一个强大的 V8 发动机,城市车有一个电动发动机。

当在初始化方法__init__中创建汽车时,我们创建分配给汽车的任何发动机类的实例,并将其设置为engine实例属性。

engine_cls在所有类实例之间共享是有道理的,因为很可能同一辆车的实例会有相同类型的发动机。另一方面,将一个单一的发动机(任何Engine类的实例)作为类属性是不好的,因为我们会在所有实例之间共享一个发动机,这是不正确的。

汽车和发动机之间的关系类型是Has-A类型。汽车Has-A发动机。这被称为组合,反映了对象可以由许多其他对象组成的事实。汽车Has-A发动机、齿轮、车轮、车架、车门、座椅等等。

在设计面向对象的代码时,以这种方式描述对象非常重要,这样我们才能正确地使用继承和组合来最佳地构造我们的代码。

请注意,我必须避免在class_inheritance.py脚本名称中使用点,因为模块名称中的点使导入变得困难。书中源代码中的大多数模块都是作为独立脚本运行的,因此我选择在可能的情况下添加点以增强可读性,但一般来说,你应该避免在模块名称中使用点。

在我们离开这一段之前,让我们通过另一个示例来检查我是否告诉了你真相:

# oop/class.issubclass.isinstance.py
from class_inheritance import Car, RaceCar, F1Car

car = Car()
racecar = RaceCar()
f1car = F1Car()
cars = [(car, 'car'), (racecar, 'racecar'), (f1car, 'f1car')]
car_classes = [Car, RaceCar, F1Car]

for car, car_name in cars:
    for class_ in car_classes:
        belongs = isinstance(car, class_)
        msg = 'is a' if belongs else 'is not a'
        print(car_name, msg, class_.__name__)

""" Prints:
car is a Car
car is not a RaceCar
car is not a F1Car
racecar is a Car
racecar is a RaceCar
racecar is not a F1Car
f1car is a Car
f1car is a RaceCar
f1car is a F1Car
"""

正如你所看到的,car只是Car的一个实例,而racecarRaceCar的一个实例(通过扩展也是Car的一个实例),f1carF1Car的一个实例(通过扩展也是RaceCarCar的一个实例)。bananabanana的一个实例。但是,它也是Fruit。同时,它也是Food,对吧?这是相同的概念。要检查对象是否是类的实例,请使用isinstance方法。它比纯粹的类型比较更可取:(type(object) == Class)

请注意,我没有在实例化汽车时留下打印信息。我们在上一个示例中看到了它们。

让我们也来检查继承-相同的设置,不同的逻辑在for循环中:

# oop/class.issubclass.isinstance.py
for class1 in car_classes:
    for class2 in car_classes:
        is_subclass = issubclass(class1, class2)
        msg = '{0} a subclass of'.format(
            'is' if is_subclass else 'is not')
        print(class1.__name__, msg, class2.__name__)

""" Prints:
Car is a subclass of Car
Car is not a subclass of RaceCar
Car is not a subclass of F1Car
RaceCar is a subclass of Car
RaceCar is a subclass of RaceCar
RaceCar is not a subclass of F1Car
F1Car is a subclass of Car
F1Car is a subclass of RaceCar
F1Car is a subclass of F1Car
"""

有趣的是,我们了解到一个类是其自身的子类。检查前面示例的输出,看看它是否与我提供的解释相匹配。

关于惯例的一件事要注意的是,类名始终使用CapWords编写,这意味着ThisWayIsCorrect,而不是函数和方法,它们是this_way_is_correct。此外,在代码中,如果要使用 Python 保留的关键字或内置函数或类的名称,惯例是在名称后添加下划线。在第一个for循环示例中,我正在使用for class_ in ...循环遍历类名,因为class是一个保留字。但你已经知道这一切,因为你已经彻底研究了 PEP8,对吧?

为了帮助你理解Is-AHas-A之间的区别,请看下面的图表:

访问基类

我们已经看到了类声明,比如class ClassA: passclass ClassB(BaseClassName): pass。当我们不明确指定基类时,Python 会将特殊的object类设置为我们正在定义的类的基类。最终,所有类都源自一个对象。请注意,如果不指定基类,括号是可选的。

因此,编写class A: passclass A(): passclass A(object): pass都是完全相同的。object类是一个特殊的类,它具有所有 Python 类共有的方法,并且不允许你在其上设置任何属性。

让我们看看如何从类内部访问基类:

# oop/super.duplication.py
class Book:
    def __init__(self, title, publisher, pages):
        self.title = title
        self.publisher = publisher
        self.pages = pages

class Ebook(Book):
    def __init__(self, title, publisher, pages, format_):
        self.title = title
        self.publisher = publisher
        self.pages = pages
        self.format_ = format_

看一下前面的代码。Ebook中有三个输入参数是重复的。这是非常糟糕的做法,因为我们现在有两组执行相同操作的指令。此外,Book.__init__签名的任何更改都不会反映在Ebook中。我们知道Ebook是一个Book,因此我们可能希望更改能够反映在子类中。

让我们看一种解决这个问题的方法:

# oop/super.explicit.py
class Book:
    def __init__(self, title, publisher, pages):
        self.title = title
        self.publisher = publisher
        self.pages = pages

class Ebook(Book):
    def __init__(self, title, publisher, pages, format_):
        Book.__init__(self, title, publisher, pages)
        self.format_ = format_

ebook = Ebook(
    'Learn Python Programming', 'Packt Publishing', 500, 'PDF')
print(ebook.title)  # Learn Python Programming
print(ebook.publisher)  # Packt Publishing
print(ebook.pages)  # 500
print(ebook.format_)  # PDF

现在好多了。我们去掉了那个讨厌的重复。基本上,我们告诉 Python 调用Book类的__init__方法,并将self传递给调用,确保将该调用绑定到当前实例。

如果我们修改Book__init__方法中的逻辑,我们不需要触及Ebook,它将自动适应更改。

这种方法很好,但我们仍然可以做得更好一点。假设我们将Book的名称更改为Liber,因为我们爱上了拉丁语。我们必须修改Ebook__init__方法以反映这一变化。这可以通过使用super来避免:

# oop/super.implicit.py
class Book:
    def __init__(self, title, publisher, pages):
        self.title = title
        self.publisher = publisher
        self.pages = pages

class Ebook(Book):
    def __init__(self, title, publisher, pages, format_):
        super().__init__(title, publisher, pages)
        # Another way to do the same thing is:
        # super(Ebook, self).__init__(title, publisher, pages)
        self.format_ = format_

ebook = Ebook(
    'Learn Python Programming', 'Packt Publishing', 500, 'PDF')
print(ebook.title) # Learn Python Programming
print(ebook.publisher) # Packt Publishing
print(ebook.pages) # 500
print(ebook.format_) # PDF

super是一个返回代理对象的函数,该代理对象将方法调用委托给父类或同级类。在这种情况下,它将该调用委托给Book类的__init__,这种方法的美妙之处在于现在我们甚至可以自由地将Book更改为Liber,而不必触及Ebook__init__方法中的逻辑。

现在我们知道如何从子类访问基类,让我们来探索 Python 的多重继承。

多重继承

除了使用多个基类来组成一个类之外,这里感兴趣的是属性搜索是如何执行的。看一下下面的图表:

正如你所看到的,ShapePlotter充当了所有其他类的基类。Polygon直接从它们继承,RegularPolygonPolygon继承,而RegularHexagonSquare都从RegulaPolygon继承。还要注意,ShapePlotter隐式地从object继承,因此我们有了所谓的菱形,或者更简单地说,有多条路径到达基类。我们将在几分钟后看到为什么这很重要。让我们将其翻译成一些简单的代码:

# oop/multiple.inheritance.py
class Shape:
    geometric_type = 'Generic Shape'
    def area(self):  # This acts as placeholder for the interface
        raise NotImplementedError
    def get_geometric_type(self):
        return self.geometric_type

class Plotter:
    def plot(self, ratio, topleft):
        # Imagine some nice plotting logic here...
        print('Plotting at {}, ratio {}.'.format(
            topleft, ratio))

class Polygon(Shape, Plotter):  # base class for polygons
    geometric_type = 'Polygon'

class RegularPolygon(Polygon):  # Is-A Polygon
    geometric_type = 'Regular Polygon'
    def __init__(self, side):
        self.side = side

class RegularHexagon(RegularPolygon):  # Is-A RegularPolygon
    geometric_type = 'RegularHexagon'
    def area(self):
        return 1.5 * (3 ** .5 * self.side ** 2)

class Square(RegularPolygon):  # Is-A RegularPolygon
    geometric_type = 'Square'
    def area(self):
        return self.side * self.side

hexagon = RegularHexagon(10)
print(hexagon.area())  # 259.8076211353316
print(hexagon.get_geometric_type())  # RegularHexagon
hexagon.plot(0.8, (75, 77))  # Plotting at (75, 77), ratio 0.8.

square = Square(12)
print(square.area())  # 144
print(square.get_geometric_type())  # Square
square.plot(0.93, (74, 75))  # Plotting at (74, 75), ratio 0.93.

看一下前面的代码:Shape类有一个属性geometric_type和两个方法:areaget_geometric_type。通常使用基类(例如我们的例子中的Shape)来定义一个接口是很常见的,子类必须提供这些方法的实现。有不同和更好的方法来做到这一点,但我想尽可能地保持这个例子简单。

我们还有Plotter类,它添加了plot方法,从而为任何继承它的类提供绘图功能。当然,在这个例子中,plot的实现只是一个虚拟的print。第一个有趣的类是Polygon,它同时继承自ShapePlotter

有许多类型的多边形,其中之一是正多边形,它既是等角的(所有角度相等),又是等边的(所有边相等),因此我们创建了从Polygon继承的RegularPolygon类。对于正多边形,我们可以在RegularPolygon上实现一个简单的__init__方法,它接受边长。最后,我们创建了RegularHexagonSquare类,它们都继承自RegularPolygon

这个结构相当长,但希望能让你了解在设计代码时如何专门化对象的分类。

现在,请看最后八行。请注意,当我在hexagonsquare上调用area方法时,我得到了两者的正确面积。这是因为它们都提供了正确的实现逻辑。此外,我可以在它们两个上调用get_geometric_type,即使它没有在它们的类上定义,Python 也必须一直到Shape才能找到它的实现。请注意,即使实现是在Shape类中提供的,用于返回值的self.geometric_type也是从调用实例中正确获取的。

plot方法的调用也很有趣,并且向您展示了如何为对象增加它们本来没有的功能。这种技术在诸如 Django(我们将在第十四章中探索Web Development)这样的 Web 框架中非常受欢迎,它提供了称为mixins的特殊类,您可以直接使用其功能。您只需要将所需的 mixin 定义为自己的基类之一,就可以了。

多重继承很强大,但也可能变得非常混乱,因此我们需要确保了解在使用它时会发生什么。

方法解析顺序

到目前为止,我们知道当您要求someobject.attribute,并且在该对象上找不到attribute时,Python 会开始在创建someobject的类中搜索。如果那里也找不到,Python 会沿着继承链向上搜索,直到找到attribute或者到达object类。如果继承链只由单继承步骤组成,这是很容易理解的,这意味着类只有一个父类。然而,当涉及到多重继承时,有时很难预测如果找不到属性,下一个将被搜索的类是什么。

Python 提供了一种始终了解类在属性查找中被搜索顺序的方法:Method Resolution OrderMRO)。

MRO 是在查找期间搜索成员的基类的顺序。从 2.3 版本开始,Python 使用一种称为C3的算法,它保证了单调性。

在 Python 2.2 中引入了新式类。在 Python 2.中编写新式类的方式是使用显式的object基类进行定义。经典类没有明确继承自object,并且在 Python 3 中已被移除。Python 2.中经典类和新式类之间的一个区别是新式类使用新的 MRO 进行搜索。

关于前面的例子,让我们看看Square类的 MRO:

# oop/multiple.inheritance.py
print(square.__class__.__mro__)
# prints:
# (<class '__main__.Square'>, <class '__main__.RegularPolygon'>,
# <class '__main__.Polygon'>, <class '__main__.Shape'>,
# <class '__main__.Plotter'>, <class 'object'>)

要获得类的 MRO,我们可以从实例到其__class__属性,然后从那里到其__mro__属性。或者,我们可以直接调用Square.__mro__Square.mro(),但如果你必须动态地执行它,更有可能你会有一个对象而不是一个类。

请注意,唯一的疑点是在Polygon之后的二分,继承链分为两种方式:一种通向Shape,另一种通向Plotter。通过扫描Square类的 MRO,我们知道ShapePlotter之前被搜索。

为什么这很重要?好吧,考虑以下代码:

# oop/mro.simple.py
class A:
    label = 'a'

class B(A):
    label = 'b'

class C(A):
    label = 'c'

class D(B, C):
    pass

d = D()
print(d.label)  # Hypothetically this could be either 'b' or 'c'

BC都继承自AD同时继承自BC。这意味着查找label属性可以通过BC到达顶部(A)。根据首先到达的位置,我们会得到不同的结果。

因此,在前面的例子中,我们得到了'b',这是我们所期望的,因为BD的基类中最左边的一个。但是如果我从B中删除label属性会发生什么呢?这将是一个令人困惑的情况:算法会一直到达A还是首先到达C?让我们找出来:

# oop/mro.py
class A:
    label = 'a'

class B(A):
    pass  # was: label = 'b'

class C(A):
    label = 'c'

class D(B, C):
    pass

d = D()
print(d.label)  # 'c'
print(d.__class__.mro())  # notice another way to get the MRO
# prints:
# [<class '__main__.D'>, <class '__main__.B'>,
# <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

因此,我们了解到 MRO 是D-B-C-A-object,这意味着当我们要求d.label时,我们得到的是'c',这是正确的。

在日常编程中,通常不常见需要处理 MRO,但第一次与框架中的一些混合物作斗争时,我向您保证,您会很高兴我花了一段时间来解释它。

类和静态方法

到目前为止,我们已经编写了具有数据和实例方法形式属性的类,但是还有两种类型的方法可以放在类中:静态方法类方法

静态方法

您可能还记得,当您创建一个类对象时,Python 会为其分配一个名称。该名称充当命名空间,有时将功能分组在其下是有意义的。静态方法非常适合这种用例,因为与实例方法不同,它们不会传递任何特殊参数。让我们看一个虚构的StringUtil类的示例:

# oop/static.methods.py
class StringUtil:

    @staticmethod
    def is_palindrome(s, case_insensitive=True):
        # we allow only letters and numbers
        s = ''.join(c for c in s if c.isalnum())  # Study this!
        # For case insensitive comparison, we lower-case s
        if case_insensitive:
            s = s.lower()
        for c in range(len(s) // 2):
            if s[c] != s[-c -1]:
                return False
        return True

    @staticmethod
    def get_unique_words(sentence):
        return set(sentence.split())

print(StringUtil.is_palindrome(
    'Radar', case_insensitive=False))  # False: Case Sensitive
print(StringUtil.is_palindrome('A nut for a jar of tuna'))  # True
print(StringUtil.is_palindrome('Never Odd, Or Even!'))  # True
print(StringUtil.is_palindrome(
    'In Girum Imus Nocte Et Consumimur Igni')  # Latin! Show-off!
)  # True

print(StringUtil.get_unique_words(
    'I love palindromes. I really really love them!'))
# {'them!', 'really', 'palindromes.', 'I', 'love'}

前面的代码非常有趣。首先,我们了解到静态方法是通过简单地将staticmethod装饰器应用于它们来创建的。您可以看到它们没有传递任何特殊参数,因此除了装饰之外,它们看起来确实就像函数。

我们有一个名为StringUtil的类,它充当函数的容器。另一种方法是使用内部函数的单独模块。大多数情况下,这实际上是一种偏好。

is_palindrome 中的逻辑现在应该对您来说很简单,但以防万一,让我们来看一下。首先,我们从s中删除所有既不是字母也不是数字的字符。为了做到这一点,我们使用字符串对象(在本例中是空字符串对象)的join方法。通过在空字符串上调用join,结果是将传递给join的可迭代对象中的所有元素连接在一起。我们向join提供了一个生成器表达式,该表达式表示如果字符是字母数字或数字,则从s中取任何字符。这是因为在回文句子中,我们希望丢弃任何不是字符或数字的内容。

如果case_insensitiveTrue,我们将转换s为小写,然后继续检查它是否是回文。为了做到这一点,我们比较第一个和最后一个字符,然后比较第二个和倒数第二个字符,依此类推。如果我们在任何时候发现差异,这意味着字符串不是回文,因此我们可以返回False。另一方面,如果我们正常退出for循环,这意味着没有发现任何差异,因此我们可以说字符串是回文。

请注意,无论字符串的长度是奇数还是偶数,此代码都能正确工作。len(s) // 2 可以达到s的一半,如果s的字符数量是奇数,中间的字符不会被检查(比如在 RaDaR 中,D 不会被检查),但我们不在乎;它将与自身进行比较,因此始终通过该检查。

get_unique_words要简单得多:它只返回一个集合,我们向其中提供了一个句子中的单词列表。set类为我们删除了任何重复项,因此我们不需要做其他任何事情。

StringUtil类为我们提供了一个很好的容器命名空间,用于处理字符串的方法。我本可以编写一个类似的示例,使用MathUtil类和一些静态方法来处理数字,但我想向您展示一些不同的东西。

类方法

类方法与静态方法略有不同,因为与实例方法一样,它们也需要一个特殊的第一个参数,但在这种情况下,它是类对象本身。编写类方法的一个非常常见的用例是为类提供工厂功能。让我们看一个示例:

# oop/class.methods.factory.py
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @classmethod
    def from_tuple(cls, coords):  # cls is Point
        return cls(*coords)

    @classmethod
    def from_point(cls, point):  # cls is Point
        return cls(point.x, point.y)

p = Point.from_tuple((3, 7))
print(p.x, p.y)  # 3 7
q = Point.from_point(p)
print(q.x, q.y)  # 3 7

在前面的代码中,我向你展示了如何使用类方法来创建类的工厂。在这种情况下,我们希望通过传递两个坐标(常规创建p = Point(3, 7))来创建一个Point实例,但我们也希望能够通过传递一个元组(Point.from_tuple)或另一个实例(Point.from_point)来创建一个实例。

在这两个类方法中,cls参数指的是Point类。与实例方法一样,实例方法以self作为第一个参数,类方法以cls作为参数。selfcls都是根据约定命名的,你不是被强制遵循,但强烈鼓励尊重。这是没有 Python 程序员会更改的东西,因为它是一个如此强大的约定,解析器、linter 和任何自动处理代码的工具都会期望,所以最好坚持遵循它。

类方法和静态方法很好地配合。静态方法实际上在分解类方法的逻辑以改进其布局方面非常有帮助。让我们通过重构StringUtil类来看一个例子:

# oop/class.methods.split.py
class StringUtil:

    @classmethod
    def is_palindrome(cls, s, case_insensitive=True):
        s = cls._strip_string(s)
        # For case insensitive comparison, we lower-case s
        if case_insensitive:
            s = s.lower()
        return cls._is_palindrome(s)

    @staticmethod
    def _strip_string(s):
        return ''.join(c for c in s if c.isalnum())

    @staticmethod
    def _is_palindrome(s):
        for c in range(len(s) // 2):
            if s[c] != s[-c -1]:
                return False
        return True

    @staticmethod
    def get_unique_words(sentence):
        return set(sentence.split())

print(StringUtil.is_palindrome('A nut for a jar of tuna'))  # True
print(StringUtil.is_palindrome('A nut for a jar of beans'))  # False

将这段代码与以前的版本进行比较。首先,请注意,即使is_palindrome现在是一个类方法,我们调用它的方式与它是静态方法时的调用方式相同。我们将它更改为类方法的原因是,在提取出一些逻辑片段(_strip_string_is_palindrome)之后,我们需要引用它们,如果我们的方法中没有cls,唯一的选择就是这样调用它们:StringUtil._strip_string(...)StringUtil._is_palindrome(...),这不是一个好的做法,因为我们会在is_palindrome方法中硬编码类名,这样我们就会置自己于在想要更改类名时必须修改它的位置。使用cls将作为类名,这意味着我们的代码不需要任何修改。

注意新的逻辑读起来比以前的版本好得多。此外,注意,通过在提取出来的方法前加下划线,我暗示这些方法不应该从类外部调用,但这将是下一段的主题。

私有方法和名称混淆

如果你有 Java、C#或 C++等语言的背景,那么你知道它们允许程序员为属性(数据和方法)分配隐私状态。每种语言对此有自己略有不同的特点,但主要是公共属性可以从代码中的任何地方访问,而私有属性只能在其定义的范围内访问。

在 Python 中,没有这样的东西。一切都是公开的;因此,我们依赖于约定和一种称为名称混淆的机制。

约定如下:如果属性的名称没有下划线,它被认为是公共的。这意味着你可以自由访问和修改它。当名称有一个下划线时,属性被认为是私有的,这意味着它可能是用于内部使用的,你不应该从外部使用或修改它。私有属性的一个非常常见的用例是辅助方法,它们应该被公共方法使用(可能与其他方法一起调用链),以及内部数据,例如缩放因子,或者理想情况下我们会将其放在一个常量中(一个不能改变的变量,但是,惊讶的是,Python 也没有这些)。

这个特性通常会吓跑其他背景的人;他们会因为缺乏隐私而感到受到威胁。老实说,在我整个与 Python 的专业经验中,我从来没有听说过有人因为 Python 缺乏私有属性而尖叫“哦,天哪,我们因为 Python 缺乏私有属性而有了一个可怕的错误!”我发誓没有一次。

也就是说,对隐私的呼吁实际上是有道理的,因为没有它,你会真正地在你的代码中引入错误。让我告诉你我的意思:

# oop/private.attrs.py
class A:
    def __init__(self, factor):
        self._factor = factor

    def op1(self):
        print('Op1 with factor {}...'.format(self._factor))

class B(A):
    def op2(self, factor):
        self._factor = factor
        print('Op2 with factor {}...'.format(self._factor))

obj = B(100)
obj.op1()    # Op1 with factor 100...
obj.op2(42)  # Op2 with factor 42...
obj.op1()    # Op1 with factor 42... <- This is BAD

在前面的代码中,我们有一个名为_factor的属性,假设它非常重要,不希望在创建实例后在运行时被修改,因为op1依赖于它才能正确运行。我们用一个前导下划线命名它,但问题在于当我们调用obj.op2(42)时,我们修改了它,并且这在后续调用op1时也会反映出来。

让我们通过添加另一个前导下划线来修复这种不良行为:

# oop/private.attrs.fixed.py
class A:
    def __init__(self, factor):
        self.__factor = factor

    def op1(self):
        print('Op1 with factor {}...'.format(self.__factor))

class B(A):
    def op2(self, factor):
        self.__factor = factor
        print('Op2 with factor {}...'.format(self.__factor))

obj = B(100)
obj.op1()    # Op1 with factor 100...
obj.op2(42)  # Op2 with factor 42...
obj.op1()    # Op1 with factor 100... <- Wohoo! Now it's GOOD!

哇,看那个!现在它按预期工作了。Python 有点神奇,在这种情况下,发生的是名称修饰机制已经启动。

名称修饰意味着任何属性名称至少有两个前导下划线和最多一个尾随下划线,例如__my_attr,将被替换为一个包含下划线和类名的名称,然后是实际名称,例如_ClassName__my_attr

这意味着当你从一个类继承时,修饰机制会在基类和子类中给你的私有属性取两个不同的名称,以避免名称冲突。每个类和实例对象都在一个特殊的属性__dict__中存储对它们的属性的引用,所以让我们检查obj.__dict__来看看名称修饰是如何起作用的:

# oop/private.attrs.py
print(obj.__dict__.keys())
# dict_keys(['_factor'])

这是我们在这个例子的有问题版本中找到的_factor属性。但是看看使用__factor的那个:

# oop/private.attrs.fixed.py
print(obj.__dict__.keys())
# dict_keys(['_A__factor', '_B__factor'])

看到了吗?obj现在有两个属性,_A__factor(在A类中修饰)和_B__factor(在B类中修饰)。这就是确保当你执行obj.__factor = 42时,A中的__factor不会改变的机制,因为你实际上是在触及_B__factor,这样就保留了_A__factor的安全和完整。

如果你正在设计一个希望被其他开发人员使用和扩展的类库,你需要牢记这一点,以避免意外覆盖你的属性。这样的错误可能相当微妙,很难发现。

属性装饰器

还有一件不得不提的事情是property装饰器。想象一下,你在一个Person类中有一个age属性,而且在某个时候你想要确保当你改变它的值时,你也要检查age是否在一个合适的范围内,比如[18, 99]。你可以编写访问器方法,比如get_age()set_age(...)(也称为gettersetter),并在那里放置逻辑。get_age()很可能只是返回age,而set_age(...)也会进行范围检查。问题是,你可能已经有很多直接访问age属性的代码,这意味着你现在需要进行一些繁琐的重构。像 Java 这样的语言通过默认使用访问器模式来解决这个问题。许多 Java 集成开发环境IDE)会在你写属性声明时自动为你编写 getter 和 setter 访问器方法的存根。

Python 更聪明,它可以使用property装饰器来实现这一点。当你用property装饰一个方法时,你可以像使用数据属性一样使用方法的名称。因此,最好不要在这样的方法中放入需要花费一些时间才能完成的逻辑,因为通过访问它们作为属性,我们不希望等待。

让我们看一个例子:

# oop/property.py
class Person:
    def __init__(self, age):
        self.age = age  # anyone can modify this freely

class PersonWithAccessors:
    def __init__(self, age):
        self._age = age

    def get_age(self):
        return self._age

    def set_age(self, age):
        if 18 <= age <= 99:
            self._age = age
        else:
            raise ValueError('Age must be within [18, 99]')

class PersonPythonic:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, age):
        if 18 <= age <= 99:
            self._age = age
        else:
            raise ValueError('Age must be within [18, 99]')

person = PersonPythonic(39)
print(person.age)  # 39 - Notice we access as data attribute
person.age = 42    # Notice we access as data attribute
print(person.age)  # 42
person.age = 100   # ValueError: Age must be within [18, 99]

Person类可能是我们编写的第一个版本。然后我们意识到我们需要放置范围逻辑,所以,用另一种语言,我们需要将Person重写为PersonWithAccessors类,并重构所有使用Person.age的代码。在 Python 中,我们将Person重写为PersonPythonic(当然通常不会更改名称),这样年龄就存储在私有的_age变量中,并且我们使用装饰器定义属性的 getter 和 setter,这使我们可以像以前一样继续使用person实例。getter 是在我们读取属性时调用的方法。另一方面,setter 是在我们写入属性时调用的方法。在其他语言中,比如 Java,习惯上将它们定义为get_age()set_age(int value),但我觉得 Python 的语法更整洁。它允许你开始编写简单的代码,以后再进行重构,只有在需要时才需要,没有必要因为它们可能在将来有用而污染你的代码。

property装饰器还允许只读数据(没有 setter)以及在属性被删除时进行特殊操作。请参考官方文档以深入了解。

运算符重载

我发现 Python 对运算符重载的处理方式非常出色。重载运算符意味着根据使用的上下文给它赋予不同的含义。例如,当处理数字时,+运算符表示加法,但当处理序列时,它表示连接。

在 Python 中,当你使用操作符时,你很可能在幕后调用一些对象的特殊方法。例如,a[k]的调用大致相当于type(a).__getitem__(a, k)

举个例子,让我们创建一个类,它存储一个字符串,并且如果该字符串中包含'42',则求值为True,否则为False。此外,让我们给这个类一个长度属性,它对应于存储的字符串的长度:

# oop/operator.overloading.py
class Weird:
    def __init__(self, s):
        self._s = s

    def __len__(self):
        return len(self._s)

    def __bool__(self):
        return '42' in self._s

weird = Weird('Hello! I am 9 years old!')
print(len(weird))  # 24
print(bool(weird))  # False

weird2 = Weird('Hello! I am 42 years old!')
print(len(weird2))  # 25
print(bool(weird2))  # True

那很有趣,不是吗?要了解可以重写的魔术方法的完整列表,以便为您的类提供自定义的操作符实现,请参考官方文档中的 Python 数据模型。

多态-简要概述

多态一词来自希腊语polys(许多,多)和morphē(形式,形状),它的意思是为不同类型的实体提供单一接口。

在我们的汽车示例中,我们调用engine.start(),无论引擎是什么类型。只要它公开了 start 方法,我们就可以调用它。这就是多态的实际应用。

在其他语言中,比如 Java,为了让函数能够接受不同类型并调用它们的方法,这些类型需要以一种方式编码,使它们共享一个接口。这样,编译器就知道无论函数输入的对象类型是什么(只要它扩展了正确的接口),方法都将可用。

在 Python 中,情况就不同了。多态是隐式的,没有任何东西阻止你在对象上调用方法;因此,从技术上讲,没有必要实现接口或其他模式。

还有一种特殊的多态称为特定多态,这就是我们在上一段看到的:运算符重载。这是运算符根据其输入的数据类型而改变形状的能力。

多态还允许 Python 程序员简单地使用对象暴露的接口(方法和属性),而无需检查它是从哪个类实例化的。这使得代码更加紧凑,感觉更加自然。

我不能在多态上花太多时间,但我鼓励你自己去了解,这将扩展你对面向对象编程的理解。祝你好运!

数据类

在我们离开面向对象编程领域之前,我想提一件事:数据类。在 Python 3.7 中由 PEP557 引入(www.python.org/dev/peps/pep-0557/),它们可以被描述为带有默认值的可变命名元组。让我们深入一个例子:

# oop/dataclass.py
from dataclasses import dataclass

@dataclass
class Body:
    '''Class to represent a physical body.'''
    name: str
    mass: float = 0\.  # Kg
    speed: float = 1\.  # m/s

    def kinetic_energy(self) -> float:
        return (self.mass * self.speed ** 2) / 2

body = Body('Ball', 19, 3.1415)
print(body.kinetic_energy())  # 93.755711375 Joule
print(body)  # Body(name='Ball', mass=19, speed=3.1415)

在上面的代码中,我创建了一个表示物体的类,其中有一个方法允许我计算它的动能(使用著名的公式E[k]=½mv²)。请注意,name应该是一个字符串,而massspeed都是浮点数,并且都有默认值。有趣的是,我不需要编写任何__init__方法,它由dataclass装饰器为我完成,还有用于比较和生成对象的字符串表示的方法(在最后一行由print隐式调用)。

如果你感兴趣,你可以在 PEP557 中阅读所有的规范,但现在只需记住数据类可能提供一个更好的,稍微更强大的替代命名元组,以防你需要它。

编写自定义迭代器

现在我们有了所有的工具来欣赏我们如何编写自己的自定义迭代器。让我们首先定义一个可迭代对象和一个迭代器:

  • 可迭代对象:如果一个对象能够一次返回其成员,那么它被称为可迭代对象。列表、元组、字符串和字典都是可迭代对象。定义了__iter____getitem__方法的自定义对象也是可迭代对象。

  • 迭代器:如果一个对象代表数据流,那么它被称为迭代器。自定义迭代器需要为__iter__提供一个返回对象本身的实现,并为__next__提供一个实现,该实现返回数据流的下一个项目,直到数据流耗尽,此时所有后续对__next__的调用都会简单地引发StopIteration异常。内置函数,如iternext,在幕后调用__iter____next__

让我们编写一个迭代器,首先返回字符串中所有的奇数字符,然后返回偶数字符:

# iterators/iterator.py
class OddEven:

    def __init__(self, data):
        self._data = data
        self.indexes = (list(range(0, len(data), 2)) +
            list(range(1, len(data), 2)))

    def __iter__(self):
        return self

    def __next__(self):
        if self.indexes:
            return self._data[self.indexes.pop(0)]
        raise StopIteration

oddeven = OddEven('ThIsIsCoOl!')
print(''.join(c for c in oddeven))  # TIICO!hssol

oddeven = OddEven('HoLa')  # or manually...
it = iter(oddeven)  # this calls oddeven.__iter__ internally
print(next(it))  # H
print(next(it))  # L
print(next(it))  # o
print(next(it))  # a

因此,我们需要为__iter__提供一个返回对象本身的实现,然后为__next__提供一个实现。让我们来看看。需要发生的是返回_data[0]_data[2]_data[4],...,_data[1]_data[3]_data[5,...直到我们返回了数据中的每一项。为了做到这一点,我们准备了一个列表和索引,比如[0246,...,135,...],并且只要其中至少有一个元素,我们就弹出第一个元素并返回数据中该位置的元素,从而实现我们的目标。当indexes为空时,我们引发StopIteration,这是迭代器协议所要求的。

还有其他方法可以实现相同的结果,所以继续尝试编写不同的方法。确保最终结果适用于所有边缘情况、空序列、长度为12等的序列。

总结

在本章中,我们研究了装饰器,发现了拥有装饰器的原因,并涵盖了一些同时使用一个或多个装饰器的示例。我们还看到了接受参数的装饰器,通常用作装饰器工厂。

我们在 Python 中只是触及了面向对象编程的表面。我们涵盖了所有的基础知识,所以现在你应该能够理解未来章节中的代码。我们讨论了类中可以编写的各种方法和属性,我们探讨了继承与组合,方法重写,属性,运算符重载和多态性。

最后,我们简要地涉及了迭代器,所以现在你更深入地理解了生成器。

在下一章中,我们将看到如何处理文件以及如何以多种不同的方式和格式持久化数据。

第七章:文件和数据持久化

"持久是我们称之为生活的冒险的关键。" - Torsten Alexander Lange

在前几章中,我们已经探讨了 Python 的几个不同方面。由于示例具有教学目的,我们在简单的 Python shell 中运行它们,或者以 Python 模块的形式运行。它们运行,可能在控制台上打印一些内容,然后终止,不留下它们短暂存在的痕迹。

现实世界的应用通常大不相同。当然,它们仍然在内存中运行,但它们与网络、磁盘和数据库进行交互。它们还使用适合情况的格式与其他应用程序和设备交换信息。

在本章中,我们将开始接近真实世界,探索以下内容:

  • 文件和目录

  • 压缩

  • 网络和流

  • JSON 数据交换格式

  • 使用 pickle 和 shelve 进行数据持久化,来自标准库

  • 使用 SQLAlchemy 进行数据持久化

像往常一样,我会努力平衡广度和深度,以便在本章结束时,您将对基本原理有扎实的理解,并且将知道如何在网络上获取更多信息。

处理文件和目录

在处理文件和目录时,Python 提供了许多有用的工具。特别是在以下示例中,我们将利用osshutil模块。由于我们将在磁盘上读写,我将使用一个名为fear.txt的文件,其中包含了《恐惧》(Thich Nhat Hanh 著)的摘录,作为我们的一些示例的试验品。

打开文件

在 Python 中打开文件非常简单和直观。实际上,我们只需要使用open函数。让我们看一个快速的例子:

# files/open_try.py
fh = open('fear.txt', 'rt')  # r: read, t: text

for line in fh.readlines():
    print(line.strip())  # remove whitespace and print

fh.close()

前面的代码非常简单。我们调用open,传递文件名,并告诉open我们要以文本模式读取它。文件名之前没有路径信息;因此,open将假定文件在运行脚本的同一文件夹中。这意味着如果我们从files文件夹外部运行此脚本,那么fear.txt将找不到。

一旦文件被打开,我们就会得到一个文件对象fh,我们可以用它来处理文件的内容。在这种情况下,我们使用readlines()方法来迭代文件中的所有行,并打印它们。我们对每一行调用strip()来去除内容周围的任何额外空格,包括末尾的行终止字符,因为print会为我们添加一个。这是一个快速而粗糙的解决方案,在这个例子中有效,但是如果文件的内容包含需要保留的有意义的空格,那么您将需要在清理数据时稍微小心。在脚本的结尾,我们刷新并关闭流。

关闭文件非常重要,因为我们不希望冒着无法释放对文件的控制的风险。因此,我们需要采取一些预防措施,并将前面的逻辑包装在try/finally块中。这样做的效果是,无论我们尝试打开和读取文件时可能发生什么错误,我们都可以放心地确保close()会被调用:

# files/open_try.py
try:
    fh = open('fear.txt', 'rt')
    for line in fh.readlines():
        print(line.strip())
finally:
    fh.close()

逻辑完全相同,但现在也是安全的。

如果您现在不理解try/finally,不要担心。我们将在下一章中探讨如何处理异常。现在,可以说在try块的主体中放置代码会为该代码添加一个机制,允许我们检测错误(称为异常)并决定发生错误时该怎么办。在这种情况下,如果发生错误,我们实际上不做任何事情,但是通过在finally块中关闭文件,我们确保该行无论是否发生错误都会被执行。

我们可以通过以下方式简化前面的示例:

# files/open_try.py
try:
    fh = open('fear.txt')  # rt is default
    for line in fh:  # we can iterate directly on fh
        print(line.strip())
finally:
    fh.close()

如您所见,rt是打开文件的默认模式,因此我们不需要指定它。此外,我们可以直接在fh上进行迭代,而不需要显式调用readlines()。Python 非常友好,为我们提供了简写,使我们的代码更短,更容易阅读。

所有前面的示例都会在控制台上打印文件的内容(查看源代码以阅读整个内容):

An excerpt from Fear - By Thich Nhat Hanh

The Present Is Free from Fear

When we are not fully present, we are not really living. We’re not really there, either for our loved ones or for ourselves. If we’re not there, then where are we? We are running, running, running, even during our sleep. We run because we’re trying to escape from our fear.
...

使用上下文管理器打开文件

让我们承认吧:不得不使用try/finally块来传播我们的代码并不是最好的选择。通常情况下,Python 给我们提供了一种更好的方式以安全的方式打开文件:使用上下文管理器。让我们先看看代码:

# files/open_with.py
with open('fear.txt') as fh:
    for line in fh:
        print(line.strip())

前面的示例等同于前面的一个示例,但读起来更好。with语句支持由上下文管理器定义的运行时上下文的概念。这是使用一对方法__enter____exit__实现的,允许用户定义的类定义在执行语句体之前进入的运行时上下文,并在语句结束时退出。open函数在由上下文管理器调用时能够产生一个文件对象,但它真正的美妙之处在于fh.close()将会自动为我们调用,即使在出现错误的情况下也是如此。

上下文管理器在多种不同的场景中使用,比如线程同步、文件或其他对象的关闭,以及网络和数据库连接的管理。你可以在contextlib文档页面中找到关于它们的信息(docs.python.org/3.7/library/contextlib.html)。

读写文件

现在我们知道如何打开文件了,让我们看看我们有几种不同的方式可以读写文件:

# files/print_file.py
with open('print_example.txt', 'w') as fw:
    print('Hey I am printing into a file!!!', file=fw)

第一种方法使用了print函数,你在前几章中已经见过很多次。在获取文件对象之后,这次指定我们打算向其写入("w"),我们可以告诉print调用将其效果定向到文件,而不是默认的sys.stdout,当在控制台上执行时,它会映射到它。

前面的代码的效果是,如果print_example.txt文件不存在,则创建它,或者如果存在,则将其截断,并将行Hey I am printing into a file!!!写入其中。

这很简单易懂,但并不是我们通常写文件时所做的。让我们看一个更常见的方法:

# files/read_write.py
with open('fear.txt') as f:
    lines = [line.rstrip() for line in f]

with open('fear_copy.txt', 'w') as fw:
    fw.write('\n'.join(lines))

在前面的示例中,我们首先打开fear.txt并逐行将其内容收集到一个列表中。请注意,这次我调用了一个更精确的方法rstrip(),作为一个例子,以确保我只去掉每行右侧的空白。

在代码片段的第二部分中,我们创建了一个新文件fear_copy.txt,并将原始文件中的所有行写入其中,用换行符\n连接起来。Python 很慷慨,并且默认使用通用换行符,这意味着即使原始文件的换行符可能与\n不同,它也会在返回行之前自动转换为我们。当然,这种行为是可以自定义的,但通常它正是你想要的。说到换行符,你能想到副本中可能缺少的换行符吗?

读写二进制模式

请注意,通过在选项中传递t来打开文件(或者省略它,因为它是默认值),我们是以文本模式打开文件。这意味着文件的内容被视为文本进行处理和解释。如果你希望向文件写入字节,可以以二进制模式打开它。当你处理不仅包含原始文本的文件时,这是一个常见的要求,比如图像、音频/视频以及一般的任何其他专有格式。

为了处理二进制模式的文件,只需在打开它们时指定b标志,就像以下示例中所示:

# files/read_write_bin.py
with open('example.bin', 'wb') as fw:
    fw.write(b'This is binary data...')

with open('example.bin', 'rb') as f:
    print(f.read())  # prints: b'This is binary data...'

在这个示例中,我仍然使用文本作为二进制数据,但它可以是任何你想要的。你可以看到它被视为二进制数据的事实,因为在输出中你会得到b'This ...'前缀。

防止覆盖现有文件

Python 允许我们打开文件进行写入。通过使用w标志,我们打开一个文件并截断其内容。这意味着文件被覆盖为一个空文件,原始内容丢失。如果您希望仅在文件不存在时打开文件进行写入,可以在下面的示例中使用x标志:

# files/write_not_exists.py
with open('write_x.txt', 'x') as fw:
    fw.write('Writing line 1')  # this succeeds

with open('write_x.txt', 'x') as fw:
    fw.write('Writing line 2')  # this fails

如果您运行前面的片段,您将在目录中找到一个名为write_x.txt的文件,其中只包含一行文本。事实上,片段的第二部分未能执行。这是我在控制台上得到的输出:

$ python write_not_exists.py
Traceback (most recent call last):
 File "write_not_exists.py", line 6, in <module>
 with open('write_x.txt', 'x') as fw:
FileExistsError: [Errno 17] File exists: 'write_x.txt'

检查文件和目录的存在

如果您想确保文件或目录存在(或不存在),则需要使用os.path模块。让我们看一个小例子:

# files/existence.py
import os

filename = 'fear.txt'
path = os.path.dirname(os.path.abspath(filename))

print(os.path.isfile(filename))  # True
print(os.path.isdir(path))  # True
print(path)  # /Users/fab/srv/lpp/ch7/files

前面的片段非常有趣。在使用相对引用声明文件名之后(因为缺少路径信息),我们使用abspath来计算文件的完整绝对路径。然后,我们通过调用dirname来获取路径信息(删除末尾的文件名)。正如您所看到的,结果在最后一行打印出来。还要注意我们如何通过调用isfileisdir来检查文件和目录的存在。在os.path模块中,您可以找到处理路径名所需的所有函数。

如果您需要以不同的方式处理路径,可以查看pathlib。虽然os.path使用字符串,但pathlib提供了表示适合不同操作系统语义的文件系统路径的类。这超出了本章的范围,但如果您感兴趣,请查看 PEP428(www.python.org/dev/peps/pep-0428/)以及标准库中的页面。

操作文件和目录

让我们看几个快速示例,演示如何操作文件和目录。第一个示例操作内容:

# files/manipulation.py
from collections import Counter
from string import ascii_letters

chars = ascii_letters + ' '

def sanitize(s, chars):
    return ''.join(c for c in s if c in chars)

def reverse(s):
    return s[::-1]

with open('fear.txt') as stream:
    lines = [line.rstrip() for line in stream]

with open('raef.txt', 'w') as stream:
    stream.write('\n'.join(reverse(line) for line in lines))

# now we can calculate some statistics
lines = [sanitize(line, chars) for line in lines]
whole = ' '.join(lines)
cnt = Counter(whole.lower().split())
print(cnt.most_common(3))

前面的示例定义了两个函数:sanitizereverse。它们是简单的函数,其目的是从字符串中删除任何不是字母或空格的内容,并分别生成字符串的反向副本。

我们打开fear.txt并将其内容读入列表。然后我们创建一个新文件raef.txt,其中包含原始文件的水平镜像版本。我们使用join在新行字符上写入lines的所有内容。也许更有趣的是最后的部分。首先,我们通过列表推导将lines重新分配为其经过清理的版本。然后我们将它们放在whole字符串中,最后将结果传递给Counter。请注意,我们拆分字符串并将其转换为小写。这样,每个单词都将被正确计数,而不管其大小写如何,并且由于split,我们不需要担心任何额外的空格。当我们打印出最常见的三个单词时,我们意识到真正的 Thich Nhat Hanh 的重点在于其他人,因为we是文本中最常见的单词:

$ python manipulation.py
[('we', 17), ('the', 13), ('were', 7)]

现在让我们看一个更加面向磁盘操作的操作示例,其中我们使用shutil模块:

# files/ops_create.py
import shutil
import os

BASE_PATH = 'ops_example'  # this will be our base path
os.mkdir(BASE_PATH)

path_b = os.path.join(BASE_PATH, 'A', 'B')
path_c = os.path.join(BASE_PATH, 'A', 'C')
path_d = os.path.join(BASE_PATH, 'A', 'D')

os.makedirs(path_b)
os.makedirs(path_c)

for filename in ('ex1.txt', 'ex2.txt', 'ex3.txt'):
    with open(os.path.join(path_b, filename), 'w') as stream:
        stream.write(f'Some content here in {filename}\n')

shutil.move(path_b, path_d)

shutil.move(
    os.path.join(path_d, 'ex1.txt'),
    os.path.join(path_d, 'ex1d.txt')
)

在前面的代码中,我们首先声明一个基本路径,该路径将安全地包含我们将要创建的所有文件和文件夹。然后我们使用makedirs创建两个目录:ops_example/A/Bops_example/A/C。(您能想到使用map来创建这两个目录的方法吗?)。

我们使用os.path.join来连接目录名称,因为使用/会使代码专门运行在目录分隔符为/的平台上,但是代码将在具有不同分隔符的平台上失败。让我们委托给join来确定适当的分隔符。

创建目录后,在一个简单的for循环中,我们放入一些代码,创建目录B中的三个文件。然后,我们将文件夹B及其内容移动到另一个名称:D。最后,我们将ex1.txt重命名为ex1d.txt。如果您打开该文件,您会看到它仍然包含来自for循环的原始文本。对结果调用tree会产生以下结果:

$ tree ops_example/
ops_example/
└── A
 ├── C
 └── D
 ├── ex1d.txt
 ├── ex2.txt
 └── ex3.txt 

操作路径名

让我们通过一个简单的例子来更多地探索os.path的能力:

# files/paths.py
import os

filename = 'fear.txt'
path = os.path.abspath(filename)

print(path)
print(os.path.basename(path))
print(os.path.dirname(path))
print(os.path.splitext(path))
print(os.path.split(path))

readme_path = os.path.join(
    os.path.dirname(path), '..', '..', 'README.rst')

print(readme_path)
print(os.path.normpath(readme_path))

阅读结果可能是对这个简单例子的足够好的解释:

/Users/fab/srv/lpp/ch7/files/fear.txt           # path
fear.txt                                        # basename
/Users/fab/srv/lpp/ch7/files                    # dirname
('/Users/fab/srv/lpp/ch7/files/fear', '.txt')   # splitext
('/Users/fab/srv/lpp/ch7/files', 'fear.txt')    # split
/Users/fab/srv/lpp/ch7/files/../../README.rst   # readme_path
/Users/fab/srv/lpp/README.rst                   # normalized

临时文件和目录

有时,在运行一些代码时,能够创建临时目录或文件非常有用。例如,在编写影响磁盘的测试时,您可以使用临时文件和目录来运行您的逻辑并断言它是正确的,并确保在测试运行结束时,测试文件夹中没有剩余物品。让我们看看在 Python 中如何做到这一点:

# files/tmp.py
import os
from tempfile import NamedTemporaryFile, TemporaryDirectory

with TemporaryDirectory(dir='.') as td:
    print('Temp directory:', td)
    with NamedTemporaryFile(dir=td) as t:
        name = t.name
        print(os.path.abspath(name))

前面的例子非常简单:我们在当前目录(.)中创建一个临时目录,并在其中创建一个命名临时文件。我们打印文件名以及其完整路径:

$ python tmp.py
Temp directory: ./tmpwa9bdwgo
/Users/fab/srv/lpp/ch7/files/tmpwa9bdwgo/tmp3d45hm46 

运行此脚本将每次产生不同的结果。毕竟,这里我们创建的是一个临时随机名称,对吧?

目录内容

使用 Python,您还可以检查目录的内容。我将向您展示两种方法:

# files/listing.py
import os

with os.scandir('.') as it:
    for entry in it:
        print(
            entry.name, entry.path,
            'File' if entry.is_file() else 'Folder'
        )

此片段使用os.scandir,在当前目录上调用。我们对结果进行迭代,每个结果都是os.DirEntry的一个实例,这是一个暴露有用属性和方法的好类。在代码中,我们访问了其中的一部分:namepathis_file()。运行代码会产生以下结果(为简洁起见,我省略了一些结果):

$ python listing.py
fixed_amount.py ./fixed_amount.py File
existence.py ./existence.py File
...
ops_example ./ops_example Folder
...

扫描目录树的更强大的方法是由os.walk给我们的。让我们看一个例子:

# files/walking.py
import os

for root, dirs, files in os.walk('.'):
    print(os.path.abspath(root))
    if dirs:
        print('Directories:')
        for dir_ in dirs:
            print(dir_)
        print()
    if files:
        print('Files:')
        for filename in files:
            print(filename)
        print()

运行前面的片段将产生当前目录中所有文件和目录的列表,并且对每个子目录都会执行相同的操作。

文件和目录压缩

在我们离开这一部分之前,让我给你举个创建压缩文件的例子。在书的源代码中,我有两个例子:一个创建一个 ZIP 文件,而另一个创建一个tar.gz文件。Python 允许您以几种不同的方式和格式创建压缩文件。在这里,我将向您展示如何创建最常见的一种,ZIP:

# files/compression/zip.py
from zipfile import ZipFile

with ZipFile('example.zip', 'w') as zp:
    zp.write('content1.txt')
    zp.write('content2.txt')
    zp.write('subfolder/content3.txt')
    zp.write('subfolder/content4.txt')

with ZipFile('example.zip') as zp:
    zp.extract('content1.txt', 'extract_zip')
    zp.extract('subfolder/content3.txt', 'extract_zip')

在前面的代码中,我们导入ZipFile,然后在上下文管理器中,我们向其中写入四个虚拟上下文文件(其中两个在子文件夹中,以显示 ZIP 保留了完整路径)。之后,作为示例,我们打开压缩文件并从中提取了一些文件,放入extract_zip目录中。如果您有兴趣了解更多关于数据压缩的信息,请确保查看标准库中的数据压缩和存档部分(docs.python.org/3.7/library/archiving.html),在那里您将能够学习有关此主题的所有内容。

数据交换格式

现代软件架构倾向于将应用程序拆分为几个组件。无论您是否采用面向服务的架构范例,或者将其推进到微服务领域,这些组件都必须交换数据。但即使您正在编写一个单体应用程序,其代码库包含在一个项目中,也有可能您必须与 API、其他程序交换数据,或者简单地处理网站前端和后端部分之间的数据流,这些部分很可能不会使用相同的语言。

选择正确的格式来交换信息至关重要。特定于语言的格式的优势在于,语言本身很可能会为您提供使序列化和反序列化变得轻而易举的所有工具。但是,您将失去与使用不同版本的相同语言或完全不同语言编写的其他组件进行通信的能力。无论未来看起来如何,只有在给定情况下这是唯一可能的选择时,才应选择特定于语言的格式。

一个更好的方法是选择一种语言无关的格式,可以被所有(或至少大多数)语言使用。在我领导的团队中,我们有来自英格兰、波兰、南非、西班牙、希腊、印度、意大利等国家的人。我们都说英语,所以无论我们的母语是什么,我们都可以理解彼此(嗯...大多数情况下!)。

在软件世界中,一些流行的格式在最近几年已经成为事实上的标准。最著名的可能是 XML、YAML 和 JSON。Python 标准库包括xmljson模块,而在 PyPI(docs.python.org/3.7/library/archiving.html)上,您可以找到一些不同的包来处理 YAML。

在 Python 环境中,JSON 可能是最常用的格式。它胜过其他两种格式,因为它是标准库的一部分,而且它很简单。如果您曾经使用过 XML,您就知道它可能是多么可怕。

处理 JSON

JSONJavaScript 对象表示法的缩写,它是 JavaScript 语言的一个子集。它已经存在了将近二十年,因此它是众所周知的,并且被基本上所有语言广泛采用,尽管它实际上是与语言无关的。您可以在其网站上阅读有关它的所有信息(www.json.org/),但我现在要给您一个快速介绍。

JSON 基于两种结构:一组名称/值对和一个有序值列表。您会立即意识到,这两个对象分别映射到 Python 中的字典和列表数据类型。作为数据类型,它提供字符串、数字、对象和值,如 true、false 和 null。让我们看一个快速的例子来开始:

# json_examples/json_basic.py
import sys
import json

data = {
    'big_number': 2 ** 3141,
    'max_float': sys.float_info.max,
    'a_list': [2, 3, 5, 7],
}

json_data = json.dumps(data)
data_out = json.loads(json_data)
assert data == data_out  # json and back, data matches

我们首先导入sysjson模块。然后我们创建一个包含一些数字和一个列表的简单字典。我想测试使用非常大的数字进行序列化和反序列化,所以我放了2³¹⁴¹和我的系统可以处理的最大浮点数。

我们使用json.dumps进行序列化,它将数据转换为 JSON 格式的字符串。然后将该数据输入json.loads,它执行相反的操作:从 JSON 格式的字符串中,将数据重构为 Python。在最后一行,我们确保原始数据和通过 JSON 进行序列化/反序列化的结果匹配。

让我们看看下一个例子中,如果我们打印 JSON 数据会是什么样子:

# json_examples/json_basic.py
import json

info = {
    'full_name': 'Sherlock Holmes',
    'address': {
        'street': '221B Baker St',
        'zip': 'NW1 6XE',
        'city': 'London',
        'country': 'UK',
    }
}

print(json.dumps(info, indent=2, sort_keys=True))

在这个例子中,我们创建了一个包含福尔摩斯数据的字典。如果您像我一样是福尔摩斯的粉丝,并且在伦敦,您会在那个地址找到他的博物馆(我建议您去参观,它虽小但非常好)。

请注意我们如何调用json.dumps。我们已经告诉它用两个空格缩进,并按字母顺序排序键。结果是这样的:

$ python json_basic.py
{
 "address": {
 "city": "London",
 "country": "UK",
 "street": "221B Baker St",
 "zip": "NW1 6XE"
 },
 "full_name": "Sherlock Holmes"
}

与 Python 的相似性非常大。唯一的区别是,如果您在字典的最后一个元素上放置逗号,就像我在 Python 中所做的那样(因为这是习惯的做法),JSON 会抱怨。

让我给您展示一些有趣的东西:

# json_examples/json_tuple.py
import json

data_in = {
    'a_tuple': (1, 2, 3, 4, 5),
}

json_data = json.dumps(data_in)
print(json_data)  # {"a_tuple": [1, 2, 3, 4, 5]}
data_out = json.loads(json_data)
print(data_out)  # {'a_tuple': [1, 2, 3, 4, 5]}

在这个例子中,我们放了一个元组,而不是一个列表。有趣的是,从概念上讲,元组也是一个有序的项目列表。它没有列表的灵活性,但从 JSON 的角度来看,它仍然被视为相同的。因此,正如您可以从第一个print中看到的那样,在 JSON 中,元组被转换为列表。因此,它是一个元组的信息丢失了,当进行反序列化时,data_out中的a_tuple实际上是一个列表。在处理数据时,重要的是要记住这一点,因为经历一个涉及仅包括您可以使用的数据结构子集的格式的转换过程意味着会有信息丢失。在这种情况下,我们丢失了类型(元组与列表)的信息。

这实际上是一个常见的问题。例如,您不能将所有 Python 对象序列化为 JSON,因为不清楚 JSON 是否应该还原它(或者如何还原)。想想datetime,例如。该类的实例是 JSON 不允许序列化的 Python 对象。如果我们将其转换为字符串,比如2018-03-04T12:00:30Z,这是带有日期、时间和时区信息的 ISO 8601 表示,当进行反序列化时,JSON 应该怎么做?它应该说这实际上可以反序列化为一个 datetime 对象,所以最好这样做,还是应该简单地将其视为字符串并保留它?那些可以以多种方式解释的数据类型又该怎么办?

答案是,在处理数据交换时,我们经常需要在将对象序列化为 JSON 之前将其转换为更简单的格式。这样,当我们对其进行反序列化时,我们将知道如何正确地重建它们。

然而,在某些情况下,主要是为了内部使用,能够序列化自定义对象是很有用的,因此,只是为了好玩,我将向您展示两个例子:复数(因为我喜欢数学)和datetime对象。

自定义编码/解码与 JSON

在 JSON 世界中,我们可以将编码/解码这样的术语视为序列化/反序列化的同义词。它们基本上都意味着转换为 JSON,然后再从 JSON 转换回来。在下面的例子中,我将向您展示如何编码复数:

# json_examples/json_cplx.py
import json

class ComplexEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, complex):
            return {
                '_meta': '_complex',
                'num': [obj.real, obj.imag],
            }
        return json.JSONEncoder.default(self, obj)

data = {
    'an_int': 42,
    'a_float': 3.14159265,
    'a_complex': 3 + 4j,
}

json_data = json.dumps(data, cls=ComplexEncoder)
print(json_data)

def object_hook(obj):
    try:
        if obj['_meta'] == '_complex':
            return complex(*obj['num'])
    except (KeyError, TypeError):
        return obj

data_out = json.loads(json_data, object_hook=object_hook)
print(data_out)

首先,我们定义一个ComplexEncoder类,它需要实现default方法。这个方法被传递给所有需要被序列化的对象,一个接一个地,在obj变量中。在某个时候,obj将是我们的复数3+4j。当这种情况发生时,我们返回一个带有一些自定义元信息的字典,以及一个包含实部和虚部的列表。这就是我们需要做的,以避免丢失复数的信息。

然后我们调用json.dumps,但这次我们使用cls参数来指定我们的自定义编码器。结果被打印出来:

{"an_int": 42, "a_float": 3.14159265, "a_complex": {"_meta": "_complex", "num": [3.0, 4.0]}}

一半的工作已经完成。对于反序列化部分,我们本可以编写另一个从JSONDecoder继承的类,但是,只是为了好玩,我使用了一种更简单的技术,使用了一个小函数:object_hook

object_hook的主体中,我们找到另一个try块,但现在不要担心它。我将在下一章节中详细解释它。重要的是try块本身的主体中的两行。该函数接收一个对象(请注意,只有当obj是一个字典时才调用该函数),如果元数据与我们的复数约定匹配,我们将实部和虚部传递给complex函数。try/except块只是为了防止格式不正确的 JSON 破坏整个过程(如果发生这种情况,我们只需返回对象本身)。

最后的打印返回:

{'an_int': 42, 'a_float': 3.14159265, 'a_complex': (3+4j)}

您可以看到a_complex已经被正确反序列化。

现在让我们看一个稍微更复杂(没有刻意的双关语)的例子:处理datetime对象。我将把代码分成两个部分,序列化部分和之后的反序列化部分:

# json_examples/json_datetime.py
import json
from datetime import datetime, timedelta, timezone

now = datetime.now()
now_tz = datetime.now(tz=timezone(timedelta(hours=1)))

class DatetimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            try:
                off = obj.utcoffset().seconds
            except AttributeError:
                off = None

            return {
                '_meta': '_datetime',
                'data': obj.timetuple()[:6] + (obj.microsecond, ),
                'utcoffset': off,
            }
        return json.JSONEncoder.default(self, obj)

data = {
    'an_int': 42,
    'a_float': 3.14159265,
    'a_datetime': now,
    'a_datetime_tz': now_tz,
}

json_data = json.dumps(data, cls=DatetimeEncoder)
print(json_data)

这个例子稍微复杂的原因在于 Python 中的datetime对象可以是时区感知的或者不是;因此,我们需要更加小心。流程基本上和之前一样,只是处理不同的数据类型。我们首先获取当前的日期和时间信息,我们既不带(now)也带(now_tz)时区感知,只是为了确保我们的脚本工作。然后我们继续像之前一样定义一个自定义编码器,并再次实现default方法。在该方法中重要的部分是我们如何获取时间偏移(off)信息,以秒为单位,并且我们如何构造返回数据的字典。这一次,元数据表示它是datetime信息,然后我们保存时间元组的前六个项目(年、月、日、小时、分钟和秒),加上data键中的微秒,然后是偏移。你能看出data的值是元组的连接吗?如果你能,干得好!

当我们有了自定义编码器后,我们继续创建一些数据,然后进行序列化。print语句返回(在我进行了一些美化之后):

{
 "a_datetime": {
 "_meta": "_datetime",
 "data": [2018, 3, 18, 17, 57, 27, 438792],
 "utcoffset": null
 },
 "a_datetime_tz": {
 "_meta": "_datetime",
 "data": [2018, 3, 18, 18, 57, 27, 438810],
 "utcoffset": 3600
 },
 "a_float": 3.14159265,
 "an_int": 42
}

有趣的是,我们发现None被翻译为null,它的 JavaScript 等价物。此外,我们可以看到我们的数据似乎已经被正确编码。让我们继续进行脚本的第二部分:

# json_examples/json_datetime.py
def object_hook(obj):
    try:
        if obj['_meta'] == '_datetime':
            if obj['utcoffset'] is None:
                tz = None
            else:
                tz = timezone(timedelta(seconds=obj['utcoffset']))
            return datetime(*obj['data'], tzinfo=tz)
    except (KeyError, TypeError):
        return obj

data_out = json.loads(json_data, object_hook=object_hook)

再次,我们首先验证元数据告诉我们它是一个datetime,然后我们继续获取时区信息。一旦我们有了时区信息,我们将 7 元组(使用*来解包其值)和时区信息传递给datetime调用,得到我们的原始对象。让我们通过打印data_out来验证它:

{
 'a_datetime': datetime.datetime(2018, 3, 18, 18, 1, 46, 54693),
 'a_datetime_tz': datetime.datetime(
 2018, 3, 18, 19, 1, 46, 54711,
 tzinfo=datetime.timezone(datetime.timedelta(seconds=3600))),
 'a_float': 3.14159265,
 'an_int': 42
}

正如你所看到的,我们正确地得到了一切。作为一个练习,我想挑战你写相同的逻辑,但是对于一个date对象,这应该更简单。

在我们继续下一个主题之前,我想提个小心。也许这是违反直觉的,但是处理datetime对象可能是最棘手的事情之一,所以,虽然我很确定这段代码正在做它应该做的事情,我想强调我只是轻微地测试了它。所以如果你打算使用它,请彻底测试它。测试不同的时区,测试夏令时的开启和关闭,测试纪元前的日期等等。你可能会发现这一部分的代码需要一些修改来适应你的情况。

现在让我们转到下一个主题,IO。

IO、流和请求

IO代表输入/输出,它广泛地指的是计算机与外部世界之间的通信。有几种不同类型的 IO,这超出了本章的范围来解释所有这些,但我仍然想给你举几个例子。

使用内存流

第一个将向你展示io.StringIO类,它是用于文本 IO 的内存流。而第二个则会逃离我们计算机的局限,并向你展示如何执行 HTTP 请求。让我们看看第一个例子:

# io_examples/string_io.py
import io

stream = io.StringIO()
stream.write('Learning Python Programming.\n')
print('Become a Python ninja!', file=stream)

contents = stream.getvalue()
print(contents)

stream.close()

在前面的代码片段中,我们从标准库中导入了io模块。这是一个非常有趣的模块,其中包含许多与流和 IO 相关的工具。其中之一是StringIO,它是一个内存缓冲区,我们将使用两种不同的方法在其中写入两个句子,就像我们在本章的第一个例子中使用文件一样。我们既可以调用StringIO.write,也可以使用print,并告诉它将数据定向到我们的流中。

通过调用getvalue,我们可以获取流的内容(并打印它),最后我们关闭它。调用close会导致文本缓冲立即被丢弃。

有一种更优雅的方法来编写前面的代码(在你看之前,你能猜到吗?):

# io_examples/string_io.py
with io.StringIO() as stream:
    stream.write('Learning Python Programming.\n')
    print('Become a Python ninja!', file=stream)
    contents = stream.getvalue()
    print(contents)

是的,这又是一个上下文管理器。像open一样,io.StringIO在上下文管理器块内工作得很好。注意与open的相似之处:在这种情况下,我们也不需要手动关闭流。

内存对象在许多情况下都很有用。内存比磁盘快得多,对于少量数据来说,可能是完美的选择。

运行脚本时,输出为:

$ python string_io.py
Learning Python Programming.
Become a Python ninja!

进行 HTTP 请求

现在让我们探索一些关于 HTTP 请求的例子。我将在这些例子中使用requests库,你可以使用pip安装它。我们将对httpbin.org API 执行 HTTP 请求,有趣的是,这个 API 是由requests库的创建者 Kenneth Reitz 开发的。这个库在全世界范围内被广泛采用:

import requests

urls = {
    'get': 'https://httpbin.org/get?title=learn+python+programming',
    'headers': 'https://httpbin.org/headers',
    'ip': 'https://httpbin.org/ip',
    'now': 'https://now.httpbin.org/',
    'user-agent': 'https://httpbin.org/user-agent',
    'UUID': 'https://httpbin.org/uuid',
}

def get_content(title, url):
    resp = requests.get(url)
    print(f'Response for {title}')
    print(resp.json())

for title, url in urls.items():
    get_content(title, url)
    print('-' * 40)

前面的片段应该很容易理解。我声明了一个 URL 字典,对这些 URL 我想执行requests。我将执行请求的代码封装到一个小函数get_content中:如你所见,我们很简单地执行了一个 GET 请求(通过使用requests.get),并打印了响应的标题和 JSON 解码版本的响应体。让我多说一句关于最后一点。

当我们对网站或 API 执行请求时,我们会得到一个响应对象,这个对象很简单,就是服务器返回的内容。所有来自httpbin.org的响应体都是 JSON 编码的,所以我们不是通过resp.text获取响应体本身,然后手动解码它,而是通过在响应对象上利用json方法将两者结合起来。requests包变得如此广泛被采用有很多原因,其中一个绝对是它的易用性。

现在,当你在应用程序中执行请求时,你会希望有一个更加健壮的方法来处理错误等等,但是在本章中,一个简单的例子就足够了。别担心,我会在第十四章 Web Development中给你一个更全面的 HTTP 请求介绍。

回到我们的代码,最后,我们运行一个for循环并获取所有的 URL。当你运行它时,你会在控制台上看到每次调用的结果,就像这样(经过美化和简化):

$ python reqs.py
Response for get
{
  "args": {
    "title": "learn python programming"
  },
  "headers": {
    "Accept": "*/*",
    "Accept-Encoding": "gzip, deflate",
    "Connection": "close",
    "Host": "httpbin.org",
    "User-Agent": "python-requests/2.19.0"
  },
  "origin": "82.47.175.158",
  "url": "https://httpbin.org/get?title=learn+python+programming"
}
... rest of the output omitted ... 

请注意,版本号和 IP 方面的输出可能会有些不同,这没关系。现在,GET 只是 HTTP 动词中的一个,它绝对是最常用的。第二个是无处不在的 POST,当你需要向服务器发送数据时,就会发起这种类型的请求。每当你在网上提交表单时,你基本上就是在发起一个 POST 请求。所以,让我们尝试以编程方式进行一个:

# io_examples/reqs_post.py
import requests

url = 'https://httpbin.org/post'
data = dict(title='Learn Python Programming')

resp = requests.post(url, data=data)
print('Response for POST')
print(resp.json())

前面的代码与之前看到的代码非常相似,只是这一次我们不调用get,而是调用post,因为我们想发送一些数据,我们在调用中指定了这一点。requests库提供的远不止这些,它因其美丽的 API 而受到社区的赞扬。这是一个我鼓励你去了解和探索的项目,因为无论如何你最终都会一直使用它。

运行前面的脚本(并对输出进行一些美化处理)会产生以下结果:

$ python reqs_post.py
Response for POST
{ 'args': {},
 'data': '',
 'files': {},
 'form': {'title': 'Learn Python Programming'},
 'headers': { 'Accept': '*/*',
 'Accept-Encoding': 'gzip, deflate',
 'Connection': 'close',
 'Content-Length': '30',
 'Content-Type': 'application/x-www-form-urlencoded',
 'Host': 'httpbin.org',
 'User-Agent': 'python-requests/2.7.0 CPython/3.7.0b2 '
 'Darwin/17.4.0'},
 'json': None,
 'origin': '82.45.123.178',
 'url': 'https://httpbin.org/post'}

注意现在头部不同了,我们在响应体的form键值对中找到了发送的数据。

我希望这些简短的例子足以让你开始,特别是对于请求。网络每天都在变化,所以值得学习基础知识,然后不时地进行复习。

现在让我们转向本章的最后一个主题:以不同格式在磁盘上持久化数据。

在磁盘上持久化数据

在本章的最后一节中,我们将探讨如何以三种不同的格式将数据持久化到磁盘。我们将探索pickleshelve,以及一个涉及使用 SQLAlchemy 访问数据库的简短示例,SQLAlchemy 是 Python 生态系统中最广泛采用的 ORM 库。

使用 pickle 序列化数据

Python 标准库中的pickle模块提供了将 Python 对象转换为字节流以及反之的工具。尽管picklejson公开的 API 存在部分重叠,但两者是完全不同的。正如我们在本章前面看到的,JSON 是一种文本格式,人类可读,语言无关,并且仅支持 Python 数据类型的受限子集。另一方面,pickle模块不是人类可读的,转换为字节,是特定于 Python 的,并且由于 Python 的精彩内省能力,它支持大量的数据类型。

尽管存在这些差异,当你考虑使用其中一个时,你应该知道这些差异,我认为关于pickle最重要的关注点在于当你使用它时所面临的安全威胁。从不受信任的来源unpickling错误或恶意数据可能非常危险,因此如果你决定在你的应用程序中采用它,你需要格外小心。

话虽如此,让我们通过一个简单的例子来看它的运作方式:

# persistence/pickler.py
import pickle
from dataclasses import dataclass

@dataclass
class Person:
    first_name: str
    last_name: str
    id: int

    def greet(self):
        print(f'Hi, I am {self.first_name} {self.last_name}'
              f' and my ID is {self.id}'
        )

people = [
    Person('Obi-Wan', 'Kenobi', 123),
    Person('Anakin', 'Skywalker', 456),
]

# save data in binary format to a file
with open('data.pickle', 'wb') as stream:
    pickle.dump(people, stream)

# load data from a file
with open('data.pickle', 'rb') as stream:
    peeps = pickle.load(stream)

for person in peeps:
    person.greet()

在前面的例子中,我们使用dataclass装饰器创建了一个Person类,我们在第六章中已经见过,OOP,Decorators 和 Iterators。我之所以用数据类写这个例子,只是为了向你展示pickle如何毫不费力地处理它,而不需要我们为了更简单的数据类型而做任何事情。

该类有三个属性:first_namelast_nameid。它还公开了一个greet方法,简单地打印出带有数据的 hello 消息。

我们创建了一个实例列表,然后将其保存到文件中。为了这样做,我们使用pickle.dump,将要pickled的内容和要写入的流传递给它。在那之后,我们立即从同一个文件中读取,并通过使用pickle.load,将该流的整个内容转换回 Python。为了确保对象已经被正确转换,我们在两个对象上都调用了greet方法。结果如下:

$ python pickler.py
Hi, I am Obi-Wan Kenobi and my ID is 123
Hi, I am Anakin Skywalker and my ID is 456 

pickle模块还允许你通过dumpsloads函数(注意这两个名称末尾的s)将数据转换为(和从)字节对象。在日常应用中,当我们需要持久化不应该与另一个应用程序交换的 Python 数据时,通常会使用pickle。我最近遇到的一个例子是flask插件中的会话管理,它在将会话对象发送到 Redis 之前对其进行pickle。不过,在实践中,你不太可能经常使用这个库。

另一个可能使用得更少,但在资源短缺时非常有用的工具是shelve

使用 shelve 保存数据

shelf是一个持久的类似字典的对象。它的美妙之处在于,你保存到shelf中的值可以是任何你可以pickle的对象,因此你不像使用数据库时那样受限制。尽管有趣且有用,但shelve模块在实践中很少使用。为了完整起见,让我们快速看一下它是如何工作的:

# persistence/shelf.py
import shelve

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

with shelve.open('shelf1.shelve') as db:
    db['obi1'] = Person('Obi-Wan', 123)
    db['ani'] = Person('Anakin', 456)
    db['a_list'] = [2, 3, 5]
    db['delete_me'] = 'we will have to delete this one...'

    print(list(db.keys()))  # ['ani', 'a_list', 'delete_me', 'obi1']

    del db['delete_me']  # gone!

    print(list(db.keys()))  # ['ani', 'a_list', 'obi1']

    print('delete_me' in db)  # False
    print('ani' in db)  # True

    a_list = db['a_list']
    a_list.append(7)
    db['a_list'] = a_list
    print(db['a_list'])  # [2, 3, 5, 7]

除了接线和围绕它的样板之外,前面的例子类似于使用字典进行练习。我们创建一个简单的Person类,然后在上下文管理器中打开一个shelve文件。正如你所看到的,我们使用字典语法来存储四个对象:两个Person实例,一个列表和一个字符串。如果我们打印keys,我们会得到一个包含我们使用的四个键的列表。打印完后,我们立即从架子上删除了(恰如其名的)delete_me键/值对。再次打印keys显示删除已成功。然后我们测试了一对键的成员资格,最后,我们将数字7附加到a_list上。请注意,我们必须从架子上提取列表,修改它,然后再次保存它。

如果这种行为是不希望的,我们可以做一些事情:

# persistence/shelf.py
with shelve.open('shelf2.shelve', writeback=True) as db:
    db['a_list'] = [11, 13, 17]
    db['a_list'].append(19)  # in-place append!
    print(db['a_list'])  # [11, 13, 17, 19]

通过以writeback=True打开架子,我们启用了writeback功能,这使我们可以简单地将a_list附加到其中,就好像它实际上是常规字典中的一个值。这个功能之所以不是默认激活的原因是,它会以内存消耗和更慢的关闭架子的方式付出代价。

现在我们已经向与数据持久性相关的标准库模块致敬,让我们来看看 Python 生态系统中最广泛采用的 ORM:SQLAlchemy

将数据保存到数据库

对于这个例子,我们将使用内存数据库,这将使事情对我们来说更简单。在书的源代码中,我留下了一些注释,以向您展示如何生成一个 SQLite 文件,所以我希望您也会探索这个选项。

您可以在sqlitebrowser.org找到一个免费的 SQLite 数据库浏览器。如果您对此不满意,您将能够找到各种工具,有些是免费的,有些不是免费的,您可以用来访问和操作数据库文件。

在我们深入代码之前,让我简要介绍一下关系数据库的概念。

关系数据库是一种允许您按照 1969 年由 Edgar F. Codd 发明的关系模型保存数据的数据库。在这个模型中,数据存储在一个或多个表中。每个表都有行(也称为记录元组),每个行代表表中的一个条目。表还有列(也称为属性),每个列代表记录的一个属性。每个记录通过一个唯一键来标识,更常见的是主键,它是表中一个或多个列的联合。举个例子:想象一个名为Users的表,有列idusernamepasswordnamesurname。这样的表非常适合包含我们系统的用户。每一行代表一个不同的用户。例如,具有值3gianchubmy_wonderful_pwdFabrizioRomano的行将代表我在系统中的用户。

模型被称为关系型的原因是因为您可以在表之间建立关系。例如,如果您向我们虚构的数据库添加一个名为PhoneNumbers的表,您可以向其中插入电话号码,然后通过关系建立哪个电话号码属于哪个用户。

为了查询关系数据库,我们需要一种特殊的语言。主要标准称为SQL,代表结构化查询语言。它源自一种称为关系代数的东西,这是一组用于模拟按照关系模型存储的数据并对其进行查询的非常好的代数。你通常可以执行的最常见操作包括对行或列进行过滤、连接表、根据某些标准对结果进行聚合等。举个英文例子,对我们想象中的数据库的查询可能是:获取所有用户名以“m”开头,最多有一个电话号码的用户(用户名、名字、姓氏)。在这个查询中,我们要求获取User表中的一部分列。我们通过筛选用户,只选择用户名以字母“m”开头的用户,甚至进一步,只选择最多有一个电话号码的用户。

在我在帕多瓦大学上学的时候,我花了一个学期的时间学习关系代数语义和标准 SQL(还有其他东西)。如果不是因为我在考试当天遭遇了一次严重的自行车事故,我会说这是我准备过的最有趣的考试之一。

现在,每个数据库都有自己的 SQL“口味”。它们都在一定程度上遵守标准,但没有一个完全遵守,并且它们在某些方面彼此不同。这在现代软件开发中构成了一个问题。如果我们的应用程序包含 SQL 代码,那么如果我们决定使用不同的数据库引擎,或者可能是同一引擎的不同版本,很可能会发现我们的 SQL 代码需要修改。

这可能会很痛苦,特别是因为 SQL 查询很快就会变得非常复杂。为了稍微减轻这种痛苦,计算机科学家们(感谢他们)创建了将特定语言的对象映射到关系数据库表的代码。毫不奇怪,这种工具的名称是对象关系映射(ORM)。

在现代应用程序开发中,通常会通过使用 ORM 来开始与数据库交互,如果你发现自己无法通过 ORM 执行需要执行的查询,那么你会转而直接使用 SQL。这是在完全没有 SQL 和不使用 ORM 之间的一个很好的折衷,这最终意味着专门化与数据库交互的代码,带来了前面提到的缺点。

在这一部分,我想展示一个利用 SQLAlchemy 的例子,这是最流行的 Python ORM。我们将定义两个模型(PersonAddress),它们分别映射到一个表,然后我们将填充数据库并对其执行一些查询。

让我们从模型声明开始:

# persistence/alchemy_models.py
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import (
    Column, Integer, String, ForeignKey, create_engine)
from sqlalchemy.orm import relationship

一开始,我们导入一些函数和类型。然后,我们需要创建一个引擎。这个引擎告诉 SQLAlchemy 我们选择的数据库类型是什么。

# persistence/alchemy_models.py
engine = create_engine('sqlite:///:memory:')
Base = declarative_base()

class Person(Base):
    __tablename__ = 'person'

    id = Column(Integer, primary_key=True)
    name = Column(String)
    age = Column(Integer)

    addresses = relationship(
        'Address',
        back_populates='person',
        order_by='Address.email',
        cascade='all, delete-orphan'
    )

    def __repr__(self):
        return f'{self.name}(id={self.id})'

class Address(Base):
    __tablename__ = 'address'

    id = Column(Integer, primary_key=True)
    email = Column(String)
    person_id = Column(ForeignKey('person.id'))
    person = relationship('Person', back_populates='addresses')

    def __str__(self):
        return self.email
    __repr__ = __str__

Base.metadata.create_all(engine)

然后,每个模型都继承自Base表,在这个例子中,它由declarative_base()返回的默认值组成。我们定义了Person,它映射到一个名为person的表,并公开idnameage属性。我们还声明了与Address模型的关系,通过声明访问addresses属性将获取与我们正在处理的特定Person实例相关的address表中的所有条目。cascade选项影响创建和删除的工作方式,但这是一个更高级的概念,所以我建议你现在先略过它,以后可能再进行更深入的调查。

我们声明的最后一件事是__repr__方法,它为我们提供了对象的官方字符串表示。这应该是一个可以用来完全重建对象的表示,但在这个例子中,我只是用它来提供输出。Python 将repr(obj)重定向到对obj.__repr__()的调用。

我们还声明了Address模型,它将包含电子邮件地址,以及它们所属的人的引用。你可以看到person_idperson属性都是关于设置AddressPerson实例之间的关系。注意我在Address上声明了__str__方法,然后给它分配了一个别名,叫做__repr__。这意味着在Address对象上调用reprstr最终都会调用__str__方法。这在 Python 中是一种常见的技术,所以我在这里有机会向你展示。

在最后一行,我们告诉引擎根据我们的模型在数据库中创建表。

对这段代码的更深理解需要比我能提供的空间更多,所以我鼓励你阅读数据库管理系统DBMS)、SQL、关系代数和 SQLAlchemy。

现在我们有了我们的模型,让我们使用它们来保存一些数据!

让我们看看下面的例子:

# persistence/alchemy.py
from alchemy_models import Person, Address, engine
from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=engine)
session = Session()

首先我们创建会话,这是我们用来管理数据库的对象。接下来,我们创建了两个人:

anakin = Person(name='Anakin Skywalker', age=32)
obi1 = Person(name='Obi-Wan Kenobi', age=40)

然后我们为他们都添加电子邮件地址,使用了两种不同的技术。一种将它们分配给一个列表,另一种只是简单地将它们附加到列表中:

obi1.addresses = [
    Address(email='obi1@example.com'),
    Address(email='wanwan@example.com'),
]

anakin.addresses.append(Address(email='ani@example.com'))
anakin.addresses.append(Address(email='evil.dart@example.com'))
anakin.addresses.append(Address(email='vader@example.com'))

我们还没有触及数据库。只有当我们使用会话对象时,它才会发生实际的变化:

session.add(anakin)
session.add(obi1)
session.commit()

添加这两个Person实例足以添加它们的地址(这要归功于级联效应)。调用commit实际上告诉 SQLAlchemy 提交事务并将数据保存到数据库中。事务是在数据库上下文中提供类似沙盒的操作。只要事务没有提交,我们就可以回滚对数据库所做的任何修改,从而恢复到事务开始之前的状态。SQLAlchemy 提供了更复杂和细粒度的处理事务的方式,你可以在它的官方文档中学习,因为这是一个非常高级的话题。现在我们通过使用like查询所有以Obi开头的人的名字,这将连接到 SQL 中的LIKE操作符:

obi1 = session.query(Person).filter(
    Person.name.like('Obi%')
).first()
print(obi1, obi1.addresses)

我们获取该查询的第一个结果(我们知道我们只有 Obi-Wan),然后打印出来。然后我们通过使用他的名字进行精确匹配来获取anakin(只是为了向你展示另一种过滤的方式):

anakin = session.query(Person).filter(
    Person.name=='Anakin Skywalker'
).first()
print(anakin, anakin.addresses)

然后我们捕获了 Anakin 的 ID,并从全局框架中删除了anakin对象:

anakin_id = anakin.id
del anakin

我们这样做是因为我想向你展示如何通过 ID 获取对象。在我们这样做之前,我们编写了display_info函数,我们将使用它来显示数据库的全部内容(从地址开始获取,以演示如何通过使用 SQLAlchemy 中的关系属性来获取对象):

def display_info():
    # get all addresses first
    addresses = session.query(Address).all()

    # display results
    for address in addresses:
        print(f'{address.person.name} <{address.email}>')

    # display how many objects we have in total
    print('people: {}, addresses: {}'.format(
        session.query(Person).count(),
        session.query(Address).count())
    )

display_info函数打印出所有的地址,以及相应的人的名字,并且最后产生了关于数据库中对象数量的最终信息。我们调用这个函数,然后获取并删除anakin(想想Darth Vader,你就不会因为删除他而难过),然后再次显示信息,以验证他确实已经从数据库中消失了:

display_info()

anakin = session.query(Person).get(anakin_id)
session.delete(anakin)
session.commit()

display_info()

所有这些片段一起运行的输出如下(为了方便起见,我已经将输出分成四个块,以反映实际产生该输出的四个代码块):

$ python alchemy.py
Obi-Wan Kenobi(id=2) [obi1@example.com, wanwan@example.com] 
Anakin Skywalker(id=1) [ani@example.com, evil.dart@example.com, vader@example.com]
 Anakin Skywalker <ani@example.com>
Anakin Skywalker <evil.dart@example.com>
Anakin Skywalker <vader@example.com>
Obi-Wan Kenobi <obi1@example.com>
Obi-Wan Kenobi <wanwan@example.com>
people: 2, addresses: 5
 Obi-Wan Kenobi <obi1@example.com>
Obi-Wan Kenobi <wanwan@example.com>
people: 1, addresses: 2

从最后两个代码块可以看出,删除anakin已经删除了一个Person对象和与之相关的三个地址。再次强调,这是因为在删除anakin时发生了级联。

这就结束了我们对数据持久性的简要介绍。这是一个广阔且有时复杂的领域,我鼓励您尽可能多地学习理论。在涉及数据库系统时,缺乏知识或适当的理解可能会带来真正的麻烦。

总结

在本章中,我们探讨了如何处理文件和目录。我们学会了如何打开文件进行读写,以及如何通过使用上下文管理器更加优雅地进行操作。我们还探讨了目录:如何递归和非递归地列出它们的内容。我们还学习了路径名,这是访问文件和目录的入口。

我们随后简要介绍了如何创建 ZIP 存档,并提取其内容。该书的源代码还包含了一个不同压缩格式的示例:tar.gz

我们谈到了数据交换格式,并深入探讨了 JSON。我们在为特定的 Python 数据类型编写自定义编码器和解码器时玩得很开心。

然后,我们探讨了 IO,包括内存流和 HTTP 请求。

最后,我们看到了如何使用pickleshelve和 SQLAlchemy ORM 库来持久化数据。

现在,您应该对如何处理文件和数据持久性有了相当好的了解,我希望您会花时间自己更深入地探索这些主题。

下一章将讨论测试、性能分析和处理异常。

第八章:测试、性能分析和处理异常

“就像智者在加热、切割和摩擦后接受黄金一样,我的话语在经过检验后才应该被接受,而不是因为尊重我。” - 佛陀

我喜欢佛陀的这句话。在软件世界中,它完美地诠释了一个健康的习惯,即永远不要因为某个聪明人编写了代码或者它长期以来一直运行良好就信任代码。如果没有经过测试,代码就不值得信任。

为什么测试如此重要?首先,它们给您可预测性。或者至少,它们帮助您实现高度可预测性。不幸的是,总会有一些错误潜入代码中。但我们绝对希望我们的代码尽可能可预测。我们不希望出现意外,换句话说,我们的代码表现出不可预测的方式。您会乐意知道负责检查飞机传感器的软件有时会出现故障吗?可能不会。

因此,我们需要测试我们的代码;我们需要检查其行为是否正确,当处理边缘情况时是否按预期工作,当其所连接的组件出现故障或不可访问时是否不会挂起,性能是否在可接受范围内等等。

本章就是关于这个的 - 确保您的代码准备好面对可怕的外部世界,它足够快,并且可以处理意外或异常情况。

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

  • 测试(包括对测试驱动开发的简要介绍)。

  • 异常处理

  • 性能分析和表现

让我们首先了解测试是什么。

测试您的应用程序

有许多不同类型的测试,实际上有很多,以至于公司通常会有一个专门的部门,称为质量保证QA),由一些人组成,他们整天都在测试公司开发人员生产的软件。

为了开始进行初步分类,我们可以将测试分为两大类:白盒测试和黑盒测试。

白盒测试是对代码内部进行测试的测试;它们详细检查代码的内部。另一方面,黑盒测试是将被测试的软件视为一个盒子,其中的内部被忽略。甚至盒子内部使用的技术或语言对于黑盒测试也不重要。它们所做的就是将输入插入盒子的一端,并验证另一端的输出,就是这样。

还有一个中间类别,称为灰盒测试,它涉及以与黑盒方法相同的方式测试系统,但对编写软件所使用的算法和数据结构有一定了解,并且只能部分访问其源代码。

在这些类别中有许多不同类型的测试,每种测试都有不同的目的。举个例子,以下是其中一些:

  • 前端测试:确保应用程序的客户端展示应该展示的信息,所有链接、按钮、广告,所有需要展示给客户端的内容。它还可以验证通过用户界面走特定路径是否可能。

  • 场景测试:利用故事(或场景)来帮助测试人员解决复杂问题或测试系统的一部分。

  • 集成测试:验证应用程序各个组件在一起工作并通过接口发送消息时的行为。

  • 冒烟测试:在应用程序部署新更新时特别有用。它们检查应用程序最基本、最重要的部分是否仍然按照预期工作,并且它们没有着火。这个术语来源于工程师通过确保没有任何东西冒烟来测试电路的情况。

  • 验收测试,或用户验收测试(UAT):开发人员与产品所有者(例如,在 SCRUM 环境中)一起确定委托的工作是否正确完成。

  • 功能测试:验证软件的特性或功能。

  • 破坏性测试:摧毁系统的部分,模拟故障,以确定系统的其余部分的表现。这些类型的测试被需要提供极其可靠服务的公司广泛进行,例如亚马逊和 Netflix。

  • 性能测试:旨在验证系统在特定数据或流量负载下的性能,以便工程师可以更好地了解系统中可能导致其在高负载情况下崩溃的瓶颈,或者阻止可扩展性的瓶颈。

  • 可用性测试,以及与之密切相关的用户体验(UX)测试:旨在检查用户界面是否简单易懂、易于理解和使用。它们旨在为设计师提供输入,以改善用户体验。

  • 安全和渗透测试:旨在验证系统对攻击和入侵的保护程度。

  • 单元测试:帮助开发人员以健壮和一致的方式编写代码,提供第一条反馈线并防范编码错误、重构错误等。

  • 回归测试:在更新后,为开发人员提供有关系统中功能受损的有用信息。系统被认为有回归的原因包括旧错误重新出现、现有功能受损或引入新问题。

关于测试已经有许多书籍和文章,如果你对了解所有不同类型的测试感兴趣,我不得不指向这些资源。在本章中,我们将集中讨论单元测试,因为它们是软件开发的支柱,并且是开发人员编写的绝大多数测试。

测试是一门艺术,一门你恐怕无法从书本中学到的艺术。你可以学习所有的定义(你应该这样做),并尝试收集尽可能多的关于测试的知识,但只有当你在领域中做了足够长时间的测试时,你才可能能够正确地测试你的软件。

当你试图重构一小段代码时遇到困难,因为你触及的每一个小细节都会导致测试失败,你会学会如何编写不那么严格和限制性的测试,这些测试仍然验证代码的正确性,但同时允许你自由地玩耍,按照自己的意愿塑造它。

当你被频繁调用来修复代码中的意外错误时,你会学会如何更彻底地编写测试,如何列出更全面的边界情况列表,并学会在它们变成错误之前应对它们的策略。

当你花费太多时间阅读测试并尝试重构它们以更改代码中的小功能时,你会学会编写更简单、更短、更专注的测试。

我可以继续这个“当你...你会学会...”,但我想你已经明白了。你需要动手并积累经验。我的建议?尽可能多地学习理论,然后尝试使用不同的方法进行实验。此外,尝试向经验丰富的编程人员学习;这是非常有效的。

测试的解剖

在我们集中讨论单元测试之前,让我们看看测试是什么,它的目的是什么。

测试是一个代码片段,其目的是验证系统中的某些内容。可能是我们调用一个函数传递两个整数,一个对象有一个名为donald_duck的属性,或者当你在某个 API 上下订单后,一分钟后你可以看到它被分解成其基本元素,存储在数据库中。

测试通常由三个部分组成:

  • 准备:这是您设置场景的地方。您准备所有数据、对象和服务,以便它们准备好在需要它们的地方使用。

  • 执行:这是您执行要检查的逻辑的地方。您使用准备阶段设置的数据和接口执行一个操作。

  • 验证:这是您验证结果并确保它们符合您的期望的地方。您检查函数的返回值,或者数据库中是否有一些数据,有一些没有,有一些已经改变,是否已经发出请求,是否发生了某些事情,是否已经调用了某个方法,等等。

虽然测试通常遵循这种结构,在测试套件中,您通常会发现一些其他参与测试的构造:

  • 设置:这是在几种不同的测试中经常发现的东西。这是可以定制为每个测试、类、模块,甚至整个会话运行的逻辑。在这个阶段,通常开发人员建立与数据库的连接,也许用测试需要的数据填充数据库,等等。

  • 拆卸:这与设置相反;拆卸阶段发生在测试运行后。与设置一样,它可以定制为每个测试、类或模块,或会话。通常在这个阶段,我们销毁为测试套件创建的任何工件,并在测试后进行清理。

  • 固定装置:它们是测试中使用的数据片段。通过使用特定的固定装置集,结果是可预测的,因此测试可以对其进行验证。

在本章中,我们将使用 Python 库pytest。这是一个非常强大的工具,使测试变得更容易,并提供了大量的辅助功能,使测试逻辑可以更多地专注于实际测试而不是围绕它的连接。当我们开始编写代码时,您会看到pytest的一个特点是固定装置、设置和拆卸通常融为一体。

测试指南

像软件一样,测试可以是好的或坏的,在中间有各种不同的情况。要编写好的测试,以下是一些指南:

  • 尽可能保持简单。违反一些良好的编码规则,如硬编码值或重复代码是可以的。测试首先需要尽可能可读和易于理解。当测试难以阅读或理解时,您永远无法确信它们实际上是否确保您的代码执行正确。

  • 测试应该验证一件事情,而且只有一件事情。非常重要的是,您要保持测试简短和集中。编写多个测试来测试单个对象或函数是完全可以的。只需确保每个测试只有一个目的。

  • 测试在验证数据时不应做出任何不必要的假设。这一点起初很难理解,但很重要。验证函数调用的结果是[1, 2, 3]并不等同于说输出是包含数字123的列表。在前者中,我们还假设了顺序;在后者中,我们只假设了列表中有哪些项。这些差异有时相当微妙,但仍然非常重要。

  • 测试应该关注的是“什么”,而不是“如何”。测试应该专注于检查函数应该做什么,而不是它是如何做的。例如,专注于它计算一个数字的平方根(“什么”),而不是它调用math.sqrt来做到这一点(“如何”)。除非你正在编写性能测试或者有特定需要验证某个操作是如何执行的,尽量避免这种类型的测试,专注于“什么”。测试“如何”会导致限制性测试,并使重构变得困难。此外,当您经常修改软件时,专注于“如何”时必须编写的测试类型更有可能降低测试代码库的质量。

  • 测试应该使用最少量的固定装置来完成工作。这是另一个关键点。固定装置往往会随着时间的推移而增长。它们也往往会不时地发生变化。如果您使用大量的固定装置并忽略测试中的冗余,重构将需要更长的时间。发现错误将更加困难。尽量使用足够大的固定装置集来正确执行测试,但不要使用过多。

  • 测试应该尽可能快地运行。一个良好的测试代码库最终可能比被测试的代码本身要长得多。根据情况和开发人员的不同,长度可能会有所不同,但无论长度如何,您最终会有数百甚至数千个测试需要运行,这意味着它们运行得越快,您就能越快地回到编写代码。例如,在使用 TDD 时,您经常运行测试,因此速度至关重要。

  • 测试应该尽量使用最少的资源。原因是每个检出您代码的开发人员都应该能够运行您的测试,无论他们的计算机有多强大。它可能是一个瘦小的虚拟机或一个被忽视的 Jenkins 盒子,您的测试应该在不消耗太多资源的情况下运行。

Jenkins盒子是运行 Jenkins 软件的机器,该软件能够自动运行您的测试,除此之外还有许多其他功能。Jenkins 经常用于那些开发人员使用持续集成和极限编程等实践的公司。

单元测试

现在您已经了解了测试是什么以及为什么我们需要它,让我们介绍开发人员最好的朋友:单元测试

在我们继续示例之前,让我分享一些警告:我会尝试向您介绍有关单元测试的基础知识,但我并没有完全遵循任何特定的思想或方法。多年来,我尝试了许多不同的测试方法,最终形成了自己的做事方式,这种方式不断发展。用李小龙的话来说:

“吸收有用的东西,抛弃无用的东西,添加特别属于你自己的东西。”

编写单元测试

单元测试得名于它们用于测试代码的小单元。为了解释如何编写单元测试,让我们看一个简单的代码片段:

# data.py
def get_clean_data(source): 
    data = load_data(source) 
    cleaned_data = clean_data(data) 
    return cleaned_data 

get_clean_data函数负责从source获取数据,清理数据,并将其返回给调用者。我们如何测试这个函数呢?

一种做法是调用它,然后确保load_data只调用了一次,参数是source。然后我们需要验证clean_data被调用了一次,参数是load_data的返回值。最后,我们需要确保clean_data的返回值也是get_clean_data函数返回的值。

为了做到这一点,我们需要设置源并运行此代码,这可能是一个问题。单元测试的黄金法则之一是任何跨越应用程序边界的东西都需要被模拟。我们不想与真实的数据源交谈,也不想实际运行真实的函数,如果它们与我们应用程序中不包含的任何东西进行通信。一些例子包括数据库、搜索服务、外部 API 和文件系统中的文件。

我们需要这些限制来充当屏障,以便我们始终可以安全地运行我们的测试,而不必担心在真实数据源中破坏任何东西。

另一个原因是,对于单个开发人员来说,复制整个架构可能会非常困难。它可能需要设置数据库、API、服务、文件和文件夹等等,这可能很困难、耗时,有时甚至不可能。

非常简单地说,应用程序编程接口API)是一组用于构建软件应用程序的工具。API 以其操作、输入和输出以及底层类型来表达软件组件。例如,如果您创建一个需要与数据提供者服务进行接口的软件,很可能您将不得不通过他们的 API 来访问数据。

因此,在我们的单元测试中,我们需要以某种方式模拟所有这些事物。单元测试需要由任何开发人员运行,而无需在他们的计算机上设置整个系统。

另一种方法,我总是在可能的情况下更喜欢的方法是,模拟实体而不使用伪造对象,而是使用专门的测试对象。例如,如果您的代码与数据库交互,我宁愿生成一个测试数据库,设置我需要的表和数据,然后修补连接设置,以便我的测试运行真正的代码,针对测试数据库,从而不会造成任何伤害。内存数据库是这些情况的绝佳选择。

允许您为测试生成数据库的应用程序之一是 Django。在django.test包中,您可以找到几个工具,这些工具可以帮助您编写测试,以便您无需模拟与数据库的对话。通过这种方式编写测试,您还可以检查事务、编码和编程的所有其他与数据库相关的方面。这种方法的另一个优势在于能够检查可能会从一个数据库更改到另一个数据库的事物。

有时候,这仍然是不可能的,我们需要使用伪造的东西,所以让我们来谈谈它们。

模拟对象和修补

首先,在 Python 中,这些伪造的对象被称为mocks。直到 3.3 版本,mock库是一个第三方库,基本上每个项目都会通过pip安装,但是从 3.3 版本开始,它已经包含在标准库中的unittest模块下,这是理所当然的,考虑到它的重要性和普及程度。

用伪造对象替换真实对象或函数(或者一般来说,任何数据结构的一部分)的行为被称为修补mock库提供了patch工具,它可以作为函数或类装饰器,甚至可以作为上下文管理器,您可以使用它来模拟事物。一旦您用合适的伪造对象替换了您不需要运行的一切,您可以进入测试的第二阶段并运行您正在测试的代码。执行后,您将能够检查这些伪造对象,以验证您的代码是否正确运行。

断言

验证阶段是通过断言来完成的。断言是一个函数(或方法),你可以用它来验证对象之间的相等性,以及其他条件。当条件不满足时,断言将引发一个异常,使你的测试失败。你可以在unittest模块文档中找到一系列的断言;然而,当使用pytest时,你通常会使用通用的assert语句,这样事情会更简单。

测试 CSV 生成器

现在让我们采取一个实际的方法。我将向你展示如何测试一段代码,我们将涉及到关于单元测试的其他重要概念,以这个例子为背景。

我们想要编写一个export函数,它执行以下操作:接受一个字典列表,每个字典代表一个用户。它创建一个 CSV 文件,在其中放入一个标题,然后继续添加所有根据某些规则被视为有效的用户。export函数还接受一个文件名,这将是输出的 CSV 的名称。最后,它接受一个指示,是否允许覆盖同名的现有文件。

至于用户,他们必须遵守以下规定:每个用户至少有一个电子邮件、一个名称和一个年龄。可以有第四个字段代表角色,但是它是可选的。用户的电子邮件地址需要是有效的,名称需要是非空的,年龄必须是 18 到 65 之间的整数。

这是我们的任务,所以现在我要向你展示代码,然后我们将分析我为它编写的测试。但首先,在以下代码片段中,我将使用两个第三方库:marshmallowpytest。它们都在本书源代码的要求中,所以确保你已经用pip安装了它们。

marshmallow 是一个很棒的库,它为我们提供了序列化和反序列化对象的能力,最重要的是,它让我们能够定义一个模式,我们可以用它来验证用户字典。pytest 是我见过的最好的软件之一。现在它随处可见,并且已经取代了其他工具,比如nose。它为我们提供了很好的工具来编写简洁的测试。

但让我们来看看代码。我将它称为api.py,只是因为它公开了一个我们可以用来做事情的函数。我会把它分块展示给你:

# api.py
import os
import csv
from copy import deepcopy

from marshmallow import Schema, fields, pre_load
from marshmallow.validate import Length, Range

class UserSchema(Schema):
    """Represent a *valid* user. """

    email = fields.Email(required=True)
    name = fields.String(required=True, validate=Length(min=1))
    age = fields.Integer(
        required=True, validate=Range(min=18, max=65)
    )
    role = fields.String()

    @pre_load(pass_many=False)
    def strip_name(self, data):
        data_copy = deepcopy(data)

        try:
            data_copy['name'] = data_copy['name'].strip()
        except (AttributeError, KeyError, TypeError):
            pass

        return data_copy

schema = UserSchema()

这第一部分是我们导入所需的所有模块(oscsv),以及从marshmallow中导入一些工具,然后我们为用户定义模式。正如你所看到的,我们继承自marshmallow.Schema,然后设置了四个字段。请注意,我们使用了两个String字段,EmailInteger。这些将已经为我们提供了一些来自marshmallow的验证。请注意,在role字段中没有required=True

不过,我们需要添加一些自定义的代码。我们需要添加validate_age来确保值在我们想要的范围内。如果不是,我们会引发ValidationError。而且marshmallow会很好地处理除了整数之外的任何值。

接下来,我们添加validate_name,因为字典中存在name键并不保证名称实际上是非空的。所以我们取它的值,去除所有前导和尾随的空白字符,如果结果为空,我们再次引发ValidationError。请注意,我们不需要为email字段添加自定义验证器。这是因为marshmallow会验证它,而有效的电子邮件不能为空。

然后我们实例化schema,这样我们就可以用它来验证数据。所以让我们编写export函数:

# api.py
def export(filename, users, overwrite=True):
    """Export a CSV file.

    Create a CSV file and fill with valid users. If `overwrite`
    is False and file already exists, raise IOError.
    """
    if not overwrite and os.path.isfile(filename):
        raise IOError(f"'{filename}' already exists.")

    valid_users = get_valid_users(users)
    write_csv(filename, valid_users)

如你所见,它的内部非常简单。如果overwriteFalse并且文件已经存在,我们会引发一个带有文件已经存在的消息的IOError。否则,如果我们可以继续,我们只需获取有效用户列表并将其提供给write_csv,后者负责实际完成工作。让我们看看这些函数是如何定义的:

# api.py
def get_valid_users(users):
    """Yield one valid user at a time from users. """
    yield from filter(is_valid, users)

def is_valid(user):
    """Return whether or not the user is valid. """
    return not schema.validate(user)

事实证明,我将get_valid_users编码为生成器,因为没有必要为了将其放入文件而制作一个潜在的大列表。我们可以逐个验证和保存它们。验证的核心是简单地委托给schema.validate,它使用marshmallow的验证引擎。这样的工作方式是返回一个字典,如果验证成功则为空,否则将包含错误信息。对于这个任务,我们并不真正关心收集错误信息,所以我们简单地忽略它,在is_valid中,如果schema.validate的返回值为空,我们基本上返回True,否则返回False

还缺少最后一部分;在这里:

# api.py
def write_csv(filename, users):
    """Write a CSV given a filename and a list of users.

    The users are assumed to be valid for the given CSV structure.
    """
    fieldnames = ['email', 'name', 'age', 'role']

    with open(filename, 'x', newline='') as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for user in users:
            writer.writerow(user)

同样,逻辑很简单。我们在fieldnames中定义标题,然后打开filename进行写入,并指定newline='',这在处理 CSV 文件时在文档中是推荐的。文件创建后,我们通过使用csv.DictWriter类来获取一个writer对象。这个工具的美妙之处在于它能够将用户字典映射到字段名,因此我们不需要关心排序。

我们首先写入标题,然后循环遍历用户并逐个添加它们。请注意,此函数假定它被提供一个有效用户列表,如果这个假设是错误的(使用默认值,如果任何用户字典有额外字段,它将会出错)。

这就是你需要记住的全部代码。我建议你花一点时间再次阅读它。没有必要记住它,而且我使用有意义的名称编写了小的辅助函数,这将使你更容易跟随测试。

现在让我们来到有趣的部分:测试我们的export函数。再次,我将把代码分成几部分给你看:

# tests/test_api.py
import os
from unittest.mock import patch, mock_open, call
import pytest
from ..api import is_valid, export, write_csv

让我们从导入开始:我们需要os、临时目录(我们在第七章中已经看到了,“文件和数据持久性”),然后是pytest,最后,我们使用相对导入来获取我们想要实际测试的三个函数:is_validexportwrite_csv

然而,在我们可以编写测试之前,我们需要制作一些固定装置。正如你将看到的,一个fixture是一个被pytest.fixture装饰的函数。在大多数情况下,我们期望fixture返回一些东西,这样我们就可以在测试中使用它。我们对用户字典有一些要求,所以让我们写一些用户:一个具有最低要求的用户,一个具有完整要求的用户。两者都需要有效。以下是代码:

# tests/test_api.py
@pytest.fixture
def min_user():
    """Represent a valid user with minimal data. """
    return {
        'email': 'minimal@example.com',
        'name': 'Primus Minimus',
        'age': 18,
    }

@pytest.fixture
def full_user():
    """Represent valid user with full data. """
    return {
        'email': 'full@example.com',
        'name': 'Maximus Plenus',
        'age': 65,
        'role': 'emperor',
    }

在这个例子中,唯一的区别是存在role键,但这足以向你展示我希望的观点。请注意,我们实际上编写了两个返回字典的函数,并且用pytest.fixture装饰了它们,而不是简单地在模块级别声明字典。这是因为当你在模块级别声明一个字典,它应该在你的测试中使用,你需要确保在每个测试的开始时复制它。如果不这样做,你可能会有一个修改它的测试,这将影响所有随后的测试,从而损害它们的完整性。

通过使用这些固定装置,pytest将在每次测试运行时为我们提供一个新的字典,因此我们不需要自己费心去做。请注意,如果一个固定装置返回另一种类型,而不是字典,那么你将在测试中得到这种类型。固定装置也是可组合的,这意味着它们可以相互使用,这是pytest的一个非常强大的特性。为了向你展示这一点,让我们为一个用户列表编写一个固定装置,其中我们放入了我们已经有的两个用户,再加上一个因为没有年龄而无法通过验证的用户。让我们看一下下面的代码:

# tests/test_api.py
@pytest.fixture
def users(min_user, full_user):
    """List of users, two valid and one invalid. """
    bad_user = {
        'email': 'invalid@example.com',
        'name': 'Horribilis',
    }
    return [min_user, bad_user, full_user]

不错。现在我们有两个可以单独使用的用户,但是我们也有一个包含三个用户的列表。第一轮测试将测试我们如何验证用户。我们将把这个任务的所有测试分组到一个类中。这不仅有助于给相关的测试提供一个命名空间,一个位置,而且,正如我们后面将看到的,它允许我们声明类级别的固定装置,这些装置仅为属于该类的测试定义。看一下这段代码:

# tests/test_api.py
class TestIsValid:
    """Test how code verifies whether a user is valid or not. """
    def test_minimal(self, min_user):
        assert is_valid(min_user)

    def test_full(self, full_user):
        assert is_valid(full_user)

我们从非常简单的开始,确保我们的固定装置实际上通过了验证。这非常重要,因为这些固定装置将被用在各个地方,所以我们希望它们是完美的。接下来,我们测试年龄。这里有两件事需要注意:我不会重复类签名,所以接下来的代码缩进了四个空格,因为这些都是同一个类中的方法,好吗?其次,我们将大量使用参数化。

参数化是一种技术,它使我们能够多次运行相同的测试,但提供不同的数据。这是非常有用的,因为它允许我们只编写一次测试而没有重复,而pytest会非常智能地处理结果,当测试失败时会运行所有这些测试,从而为我们提供清晰的错误消息。如果你手动进行参数化,你将失去这个特性,相信我你不会高兴。让我们看看如何测试年龄:

# tests/test_api.py
    @pytest.mark.parametrize('age', range(18))
    def test_invalid_age_too_young(self, age, min_user):
        min_user['age'] = age
        assert not is_valid(min_user)

好的,所以我们首先编写一个测试,检查当用户年龄太小时验证失败。根据我们的规定,当用户年龄小于 18 岁时,用户年龄太小。我们通过使用range检查 0 到 17 岁之间的每个年龄。

如果你看一下参数化是如何工作的,你会看到我们声明了一个对象的名称,然后将其传递给方法的签名,然后指定这个对象将采用哪些值。对于每个值,测试将运行一次。在这个第一个测试的情况下,对象的名称是age,值是由range(18)返回的所有整数,这意味着从017的所有整数都包括在内。请注意,我们在self之后将age传递给测试方法,然后我们还做了另一件有趣的事情。我们传递了一个固定装置给这个方法:min_user。这将激活该固定装置进行测试运行,以便我们可以使用它,并且可以在测试中引用它。在这种情况下,我们只是改变了min_user字典中的年龄,然后我们验证is_valid(min_user)的结果是否为False

我们通过断言not FalseTrue来完成最后一部分。在pytest中,这是你检查某事的方式。你只需断言某事是真实的。如果是这样,测试就成功了。如果相反,测试将失败。

让我们继续添加所有需要使年龄验证失败的测试:

# tests/test_api.py
    @pytest.mark.parametrize('age', range(66, 100))
    def test_invalid_age_too_old(self, age, min_user):
        min_user['age'] = age
        assert not is_valid(min_user)

    @pytest.mark.parametrize('age', ['NaN', 3.1415, None])
    def test_invalid_age_wrong_type(self, age, min_user):
        min_user['age'] = age
        assert not is_valid(min_user)

接下来是另外两个测试。一个负责处理年龄范围的另一端,从 66 岁到 99 岁。另一个则确保当年龄不是整数时是无效的,因此我们传递一些值,比如字符串、浮点数和None,只是为了确保。请注意测试的结构基本上总是相同的,但是由于参数化的原因,我们向其提供了非常不同的输入参数。

现在我们已经解决了年龄验证失败的问题,让我们添加一个实际检查年龄是否在有效范围内的测试:

# tests/test_api.py
    @pytest.mark.parametrize('age', range(18, 66))
    def test_valid_age(self, age, min_user):
        min_user['age'] = age
        assert is_valid(min_user)

就是这么简单。我们传递了正确的范围,从1865,并在断言中去掉了not。请注意,所有测试都以test_前缀开头,并且具有不同的名称。

我们可以考虑年龄已经被照顾到了。让我们继续编写关于必填字段的测试:

# tests/test_api.py
    @pytest.mark.parametrize('field', ['email', 'name', 'age'])
    def test_mandatory_fields(self, field, min_user):
        min_user.pop(field)
        assert not is_valid(min_user)

    @pytest.mark.parametrize('field', ['email', 'name', 'age'])
    def test_mandatory_fields_empty(self, field, min_user):
        min_user[field] = ''
        assert not is_valid(min_user)

    def test_name_whitespace_only(self, min_user):
        min_user['name'] = ' \n\t'
        assert not is_valid(min_user)

前面的三个测试仍然属于同一个类。第一个测试检查当必填字段中有一个缺失时用户是否无效。请注意,在每次测试运行时,min_user fixture 都会被恢复,因此每次测试运行只有一个缺失字段,这是检查必填字段的适当方式。我们只需从字典中弹出键。这次参数化对象采用了field名称,并且通过查看第一个测试,您可以看到参数化装饰器中的所有必填字段:emailnameage

在第二个测试中,情况有些不同。我们不是弹出键,而是简单地将它们(一个接一个)设置为空字符串。最后,在第三个测试中,我们检查姓名是否只由空格组成。

前面的测试处理了必填字段的存在和非空,以及用户的name键周围的格式。很好。现在让我们为这个类编写最后两个测试。我们想要检查电子邮件的有效性,以及电子邮件,姓名和角色的类型:

# tests/test_api.py
    @pytest.mark.parametrize(
        'email, outcome',
        [
            ('missing_at.com', False),
            ('@missing_start.com', False),
            ('missing_end@', False),
            ('missing_dot@example', False),

            ('good.one@example.com', True),
            ('δοκιμή@παράδειγμα.δοκιμή', True),
            ('аджай@экзампл.рус', True),
        ]
    )
    def test_email(self, email, outcome, min_user):
        min_user['email'] = email
        assert is_valid(min_user) == outcome

这次,参数化略微复杂。我们定义了两个对象(emailoutcome),然后我们将一个元组的列表,而不是一个简单的列表,传递给装饰器。发生的情况是每次运行测试时,其中一个元组将被解包以填充emailoutcome的值。这使我们能够为有效和无效的电子邮件地址编写一个测试,而不是两个单独的测试。我们定义了一个电子邮件地址,并指定了我们期望的验证结果。前四个是无效的电子邮件地址,但最后三个实际上是有效的。我使用了一些包含 Unicode 的例子,只是为了确保我们没有忘记在验证中包括来自世界各地的朋友。

注意验证是如何进行的,断言调用的结果需要与我们设置的结果匹配。

现在让我们编写一个简单的测试,以确保当我们向字段提供错误类型时验证失败(再次强调,年龄已经单独处理):

# tests/test_api.py
    @pytest.mark.parametrize(
        'field, value',
        [
            ('email', None),
            ('email', 3.1415),
            ('email', {}),

            ('name', None),
            ('name', 3.1415),
            ('name', {}),

            ('role', None),
            ('role', 3.1415),
            ('role', {}),
        ]
    )
    def test_invalid_types(self, field, value, min_user):
        min_user[field] = value
        assert not is_valid(min_user)

就像以前一样,只是为了好玩,我们传递了三个不同的值,其中没有一个实际上是字符串。这个测试可以扩展到包括更多的值,但是,老实说,我们不应该需要编写这样的测试。我在这里包括它只是为了向您展示可能的情况。

在我们转到下一个测试类之前,让我谈谈我们在检查年龄时看到的一些东西。

边界和粒度

在检查年龄时,我们编写了三个测试来覆盖三个范围:0-17(失败),18-65(成功),66-99(失败)。为什么我们要这样做呢?答案在于我们正在处理两个边界:18 和 65。因此,我们的测试需要集中在这两个边界定义的三个区域上:18 之前,18 和 65 之间,以及 65 之后。你如何做并不重要,只要确保正确测试边界。这意味着如果有人将模式中的验证从18 <= value <= 65更改为18 <= value < 65(注意缺少=),必须有一个测试在65处失败。

这个概念被称为边界,非常重要的是你能够在代码中识别它们,以便你可以针对它们进行测试。

另一个重要的事情是要理解我们想要接近边界的缩放级别。换句话说,我应该使用哪个单位来在其周围移动?在年龄的情况下,我们处理整数,因此单位1将是完美的选择(这就是为什么我们使用1617181920等)。但如果你要测试时间戳呢?嗯,在这种情况下,正确的粒度可能会有所不同。如果代码必须根据您的时间戳以不同方式运行,并且该时间戳代表秒,则您的测试的粒度应该缩小到秒。如果时间戳代表年份,则年份应该是您使用的单位。希望你明白了。这个概念被称为粒度,需要与边界的概念结合起来,这样通过以正确的粒度绕过边界,您可以确保您的测试不会留下任何机会。

现在让我们继续我们的例子,并测试export函数。

测试导出函数

在同一个测试模块中,我定义了另一个类,代表了export函数的测试套件。在这里:

# tests/test_api.py
class TestExport:

    @pytest.fixture
    def csv_file(self, tmpdir):
        yield tmpdir.join("out.csv")

    @pytest.fixture
    def existing_file(self, tmpdir):
        existing = tmpdir.join('existing.csv')
        existing.write('Please leave me alone...')
        yield existing

让我们开始理解装置。这次我们在类级别定义了它们,这意味着它们只在类中的测试运行时存在。我们在这个类之外不需要这些装置,所以在模块级别声明它们就没有意义,就像我们在用户装置中所做的那样。

因此,我们需要两个文件。如果您回忆一下我在本章开头写的内容,当涉及与数据库、磁盘、网络等的交互时,我们应该将所有东西都模拟出来。但是,如果可能的话,我更喜欢使用一种不同的技术。在这种情况下,我将使用临时文件夹,它们将在装置内诞生,并在其中死去,不留下任何痕迹。如果可以避免模拟,我会更加开心。模拟是很棒的,但除非做得正确,否则它可能会很棘手,并且是错误的源泉。

现在,第一个装置csv_file定义了一个受控上下文,在其中我们获得了对临时文件夹的引用。我们可以认为逻辑直到yield为止的部分是设置阶段。就数据而言,装置本身由临时文件名表示。文件本身尚不存在。当测试运行时,装置被创建,并且在测试结束时,装置代码的其余部分(如果有的话)被执行。这部分可以被认为是拆卸阶段。在这种情况下,它包括退出上下文管理器,这意味着临时文件夹被删除(以及其所有内容)。您可以在任何装置的每个阶段中放入更多内容,并且通过经验,我相信您很快就能掌握以这种方式进行设置和拆卸的艺术。这实际上非常自然地很快就会掌握。

第二个装置与第一个非常相似,但我们将用它来测试当我们使用overwrite=False调用export时是否可以防止覆盖。因此,我们在临时文件夹中创建一个文件,并将一些内容放入其中,以便验证它没有被修改。

请注意,两个装置都返回了带有完整路径信息的文件名,以确保我们实际上在我们的代码中使用了临时文件夹。现在让我们看看测试:

# tests/test_api.py
    def test_export(self, users, csv_file):
        export(csv_file, users)

        lines = csv_file.readlines()

        assert [
            'email,name,age,role\n',
            'minimal@example.com,Primus Minimus,18,\n',
            'full@example.com,Maximus Plenus,65,emperor\n',
        ] == lines

这个测试使用了userscsv_file装置,并立即调用了export。我们期望已经创建了一个文件,并用我们拥有的两个有效用户填充了它(记住列表包含三个用户,但一个是无效的)。

为了验证这一点,我们打开临时文件,并将其所有行收集到一个列表中。然后,我们将文件的内容与我们期望在其中的行的列表进行比较。请注意,我们只按正确顺序放置了标题和两个有效用户。

现在我们需要另一个测试,以确保如果一个值中有逗号,我们的 CSV 仍然可以正确生成。作为逗号分隔值CSV)文件,我们需要确保数据中的逗号不会导致问题:

# tests/test_api.py
    def test_export_quoting(self, min_user, csv_file):
        min_user['name'] = 'A name, with a comma'

        export(csv_file, [min_user])

        lines = csv_file.readlines()
        assert [
            'email,name,age,role\n',
            'minimal@example.com,"A name, with a comma",18,\n',
        ] == lines

这一次,我们不需要整个用户列表,我们只需要一个,因为我们正在测试一个特定的事情,并且我们有之前的测试来确保我们正确生成了包含所有用户的文件。记住,尽量在测试中最小化你的工作。

因此,我们使用min_user,并在其名称中放一个漂亮的逗号。然后我们重复之前测试的过程,这与前一个测试非常相似,最后我们确保名称被放入由双引号括起来的 CSV 文件中。这对于任何良好的 CSV 解析器来说已经足够了,它们不会因为双引号内的逗号而出错。

现在我想再做一个测试,需要检查文件是否存在,如果我们不想覆盖它,我们的代码就不会触及它:

# tests/test_api.py
    def test_does_not_overwrite(self, users, existing_file):
        with pytest.raises(IOError) as err:
            export(existing_file, users, overwrite=False)

        assert err.match(
            r"'{}' already exists\.".format(existing_file)
        )

        # let's also verify the file is still intact
        assert existing_file.read() == 'Please leave me alone...'

这是一个很好的测试,因为它让我可以向你展示如何告诉pytest你期望一个函数调用引发一个异常。我们在pytest.raises给我们的上下文管理器中这样做,我们在这个上下文管理器的主体内部调用中提供我们期望的异常。如果异常没有被引发,测试将失败。

我喜欢在我的测试中做到彻底,所以我不想止步于此。我还通过使用方便的err.match助手来断言消息(注意,它接受正则表达式,而不是简单的字符串-我们将在第十四章中看到正则表达式,Web Development)。

最后,让我们确保文件仍然包含其原始内容(这就是我创建existing_file fixture 的原因),方法是打开它,并将其所有内容与应该是的字符串进行比较。

最后的考虑

在我们继续下一个话题之前,让我用一些考虑来总结。

首先,我希望您已经注意到我没有测试我编写的所有函数。具体来说,我没有测试get_valid_usersvalidatewrite_csv。原因是因为这些函数已经被我们的测试套件隐式测试过了。我们已经测试了is_validexport,这已经足够确保我们的模式正确验证用户,并且export函数在需要时正确处理过滤无效用户,并正确地写入 CSV。我们没有测试的函数是内部函数,它们提供的逻辑已经在我们彻底测试过的操作中发挥了作用。为这些函数添加额外的测试是好还是坏?请思考一下。

答案实际上很难。你测试得越多,你就越不能重构那段代码。就目前而言,我可以轻松地决定以另一个名称调用is_valid,而不必更改任何测试。如果你仔细想想,这是有道理的,因为只要is_valid正确验证get_valid_users函数,我就不需要知道它的具体情况。这对你有意义吗?

如果我要测试validate函数,那么如果我决定以不同的方式调用它(或者以某种方式更改其签名),我将不得不更改它们。

那么,应该做什么?测试还是不测试?这取决于你。你必须找到合适的平衡。我个人对这个问题的看法是,一切都需要经过彻底的测试,无论是直接还是间接地。我希望测试套件尽可能小,但能够保证我有很好的覆盖率。这样,我将拥有一个很好的测试套件,但不会比必要的更大。你需要维护这些测试!

我希望这个例子对您有意义,我认为它让我触及了重要的话题。

如果你查看本书的源代码,在test_api.py模块中,我添加了几个额外的测试类,这将展示如果我决定完全使用模拟测试,不同的测试方式会是什么样子。确保你阅读并充分理解这段代码。它非常直接,将为你提供一个与我个人方法的良好比较。

现在,我们来运行这些测试吧?(输出已重新排列以适应本书的格式):

$ pytest tests
====================== test session starts ======================
platform darwin -- Python 3.7.0b2, pytest-3.5.0, py-1.5.3, ...
rootdir: /Users/fab/srv/lpp/ch8, inifile:
collected 132 items

tests/test_api.py ...............................................
.................................................................
.................... [100%]

================== 132 passed in 0.41 seconds ===================

确保你在ch8文件夹中运行$ pytest test(添加-vv标志以获得详细输出,显示参数化如何修改测试名称)。正如你所看到的,少于半秒内运行了132个测试,它们全部都成功了。我强烈建议你查看这段代码并进行调试。更改代码中的某些内容,看看是否有任何测试失败。理解为什么会失败。这是因为测试不够好的重要原因吗?还是因为一些愚蠢的原因导致测试失败?所有这些看似无害的问题都将帮助你深入了解测试的艺术。

我还建议你学习unittest模块和pytest。这些是你将经常使用的工具,所以你需要非常熟悉它们。

现在让我们来看看测试驱动开发!

测试驱动开发

让我们简要谈谈测试驱动开发TDD)。这是一种方法论,由肯特·贝克重新发现,他写了《通过示例驱动开发》,Addison Wesley, 2002,我鼓励你查看一下,如果你想学习这个主题的基础知识。

TDD 是一种基于非常短的开发周期的持续重复的软件开发方法论。

首先,开发人员编写一个测试,并使其运行。测试应该检查代码中尚未存在的功能。也许是要添加的新功能,或者要删除或修改的内容。运行测试会使其失败,因此这个阶段被称为红色

当测试失败时,开发人员编写最少量的代码使其通过。当运行测试成功时,我们进入了所谓的绿色阶段。在这个阶段,编写欺骗性代码只是为了让测试通过是可以接受的。这种技术被称为假装直到你成功为止。在第二个阶段,测试用例会丰富起来,包括不同的边界情况,然后欺骗性代码必须用适当的逻辑进行重写。添加其他测试用例被称为三角测量

循环的最后一部分是开发人员在不同的时间处理代码和测试,并对它们进行重构,直到它们达到期望的状态。这最后阶段被称为重构

因此,TDD的口头禅是红-绿-重构

一开始,先编写代码然后再编写测试会感觉非常奇怪,我必须承认我花了一段时间才习惯。然而,如果你坚持下去,并强迫自己学习这种略微违反直觉的工作方式,某个时刻几乎会发生一些近乎神奇的事情,你会看到你的代码质量以一种其他方式不可能的方式提高。

当你在编写代码之前编写测试时,你必须同时关注代码的作用如何实现它。另一方面,当你在编写代码之前编写测试时,你可以在编写测试时只专注于作用部分。当你之后编写代码时,你将主要关注代码如何实现测试所需的作用。这种关注焦点的转变允许你的大脑在不同的时刻专注于作用如何部分,从而提供了一种令人惊讶的大脑能量提升。

采用这种技术还有其他几个好处:

  • 您将更有信心地进行重构:如果引入错误,测试将会失败。此外,架构重构也将受益于具有充当守护者的测试。

  • 代码将更易读:在我们这个时代,编码是一种社交活动,每个专业开发人员花在阅读代码上的时间远远超过编写代码的时间。

  • 代码将更松散耦合且更易于测试和维护:首先编写测试会迫使您更深入地思考代码结构。

  • 首先编写测试要求您对业务需求有更好的理解:如果您对需求的理解缺乏信息,您会发现编写测试非常具有挑战性,这种情况对您来说是一个警示。

  • 拥有完整的单元测试意味着代码将更容易调试:此外,小测试非常适合提供替代文档。英语可能会误导,但在简单测试中的五行 Python 很难误解。

  • 更高的速度:编写测试和代码比先编写代码然后花时间调试要快。如果您不编写测试,您可能会更快地交付代码,但然后您将不得不追踪错误并解决它们(可以肯定会有错误)。编写代码然后调试所花费的时间通常比使用 TDD 开发代码的时间长,因为在编写代码之前运行测试,确保其中的错误数量要比另一种情况下少得多。

另一方面,这种技术的主要缺点如下:

  • 整个公司都需要相信它:否则,您将不得不不断地与老板争论,他不会理解为什么您花费这么长时间交付。事实是,短期内您可能需要更长时间才能交付,但从长远来看,您会因 TDD 获得很多。然而,很难看到长期效果,因为它不像短期效果那样显而易见。在我的职业生涯中,我与固执的老板进行了激烈的斗争,以便能够使用 TDD 进行编码。有时这是痛苦的,但总是值得的,我从未后悔,因为最终结果的质量总是受到赞赏。

  • 如果您未能理解业务需求,这将反映在您编写的测试中,因此也将反映在代码中:这种问题很难发现,直到进行用户验收测试,但您可以做的一件事是与另一位开发人员合作。合作将不可避免地需要讨论业务需求,讨论将带来澄清,这将有助于编写正确的测试。

  • 糟糕编写的测试很难维护:这是事实。测试中有太多的模拟或额外的假设或结构不良的数据很快就会成为负担。不要让这使您灰心;继续尝试并改变编写测试的方式,直到找到一种不需要您每次触及代码时都需要大量工作的方式。

我对 TDD 非常热衷。当我面试工作时,我总是问公司是否采用它。我鼓励你去了解并使用它。使用它直到你觉得有所领悟。我保证你不会后悔。

例外情况

尽管我还没有正式向您介绍它们,但我现在希望您至少对异常有一个模糊的概念。在前几章中,我们已经看到当迭代器耗尽时,调用next会引发StopIteration异常。当我们尝试访问列表中超出有效范围的位置时,我们遇到了IndexError。当我们尝试访问对象上没有的属性时,我们也遇到了AttributeError,当我们尝试使用键和字典时,我们遇到了KeyError

现在是时候谈论异常了。

有时,即使操作或代码是正确的,也有可能出现某些条件会出错。例如,如果我们将用户输入从string转换为int,用户可能会意外地在数字的位置上输入字母,这样我们就无法将该值转换为数字。在进行数字除法时,我们可能事先不知道是否会尝试进行除以零的除法。在打开文件时,文件可能丢失或损坏。

在执行过程中检测到错误时,称为异常。异常并不一定是致命的;事实上,我们已经看到StopIteration深度集成在 Python 生成器和迭代器机制中。不过,通常情况下,如果您不采取必要的预防措施,异常将导致应用程序中断。有时,这是期望的行为,但在其他情况下,我们希望预防和控制这样的问题。例如,我们可能会警告用户,他们试图打开的文件损坏或丢失,以便他们可以修复它或提供另一个文件,而无需因此问题而使应用程序中断。让我们看一些异常的例子:

# exceptions/first.example.py
>>> gen = (n for n in range(2))
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration
>>> print(undefined_name)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
NameError: name 'undefined_name' is not defined
>>> mylist = [1, 2, 3]
>>> mylist[5]
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
IndexError: list index out of range
>>> mydict = {'a': 'A', 'b': 'B'}
>>> mydict['c']
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'c'
>>> 1 / 0
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

正如您所看到的,Python shell 非常宽容。我们可以看到Traceback,这样我们就可以获得有关错误的信息,但程序不会中断。这是一种特殊的行为,通常情况下,如果没有处理异常,常规程序或脚本通常会中断。

要处理异常,Python 为您提供了try语句。当进入try子句时,Python 将监视一个或多个不同类型的异常(根据您的指示),如果它们被引发,它将允许您做出反应。try语句由try子句组成,它打开语句,一个或多个except子句(全部可选),定义了在捕获异常时要执行的操作,一个else子句(可选),当try子句在没有引发任何异常的情况下退出时执行,以及一个finally子句(可选),其代码无论其他子句中发生了什么都会执行。finally子句通常用于清理资源(我们在第七章中看到过,文件和数据持久性,当我们在没有使用上下文管理器的情况下打开文件时)。

注意顺序——这很重要。此外,try后面必须至少跟一个except子句或一个finally子句。让我们看一个例子:

# exceptions/try.syntax.py
def try_syntax(numerator, denominator):
    try:
        print(f'In the try block: {numerator}/{denominator}')
        result = numerator / denominator
    except ZeroDivisionError as zde:
        print(zde)
    else:
        print('The result is:', result)
        return result
    finally:
        print('Exiting')

print(try_syntax(12, 4))
print(try_syntax(11, 0))

前面的例子定义了一个简单的try_syntax函数。我们执行两个数字的除法。如果我们用denominator = 0调用函数,我们准备捕获ZeroDivisionError异常。最初,代码进入try块。如果denominator不是0,则计算result并在离开try块后在else块中恢复执行。我们打印result并返回它。看一下输出,你会注意到在返回result之前,也就是函数的退出点,Python 执行finally子句。

denominator0时,情况就会改变。我们进入except块并打印zdeelse块不会被执行,因为在try块中引发了异常。在(隐式)返回None之前,我们仍然执行finally块。看一下输出,看看它对您是否有意义:

$ python try.syntax.py
In the try block: 12/4     # try
The result is: 3.0         # else
Exiting                    # finally
3.0                        # return within else

In the try block: 11/0     # try
division by zero           # except
Exiting                    # finally
None                       # implicit return end of function

当执行try块时,您可能希望捕获多个异常。例如,当尝试解码 JSON 对象时,可能会遇到ValueError(JSON 格式不正确)或TypeError(传递给json.loads()的数据类型不是字符串)。在这种情况下,您可以像这样构造代码:

# exceptions/json.example.py
import json
json_data = '{}'

try:
    data = json.loads(json_data)
except (ValueError, TypeError) as e:
    print(type(e), e)

这段代码将捕获ValueErrorTypeError。尝试将json_data = '{}'更改为json_data = 2json_data = '{{',您将看到不同的输出。

如果要以不同方式处理多个异常,只需添加更多的except子句,就像这样:

# exceptions/multiple.except.py
try:
    # some code
except Exception1:
    # react to Exception1
except (Exception2, Exception3):
    # react to Exception2 or Exception3
except Exception4:
    # react to Exception4
...

请记住,异常在首次定义该异常类或其任何基类的块中处理。因此,当您像我们刚刚做的那样堆叠多个except子句时,请确保将特定的异常放在顶部,将通用的异常放在底部。在面向对象编程术语中,子类在顶部,祖先类在底部。此外,请记住,当引发异常时,只有一个except处理程序被执行。

您还可以编写自定义异常。要做到这一点,您只需从任何其他异常类继承。Python 内置的异常太多,无法在此列出,因此我必须指向官方文档。要知道的一件重要的事情是,每个 Python 异常都派生自BaseException,但您的自定义异常不应直接从它继承。原因是处理这样的异常也会捕获系统退出异常,例如SystemExitKeyboardInterrupt,它们派生自BaseException,这可能会导致严重问题。在灾难发生时,您希望能够通过Ctrl + C退出应用程序。

您可以通过从Exception继承来轻松解决这个问题,它从BaseException继承,但在其子类中不包括任何系统退出异常,因为它们在内置异常层次结构中是同级的(参见docs.python.org/3/library/exceptions.html#exception-hierarchy)。

使用异常进行编程可能非常棘手。您可能会无意中消除错误,或者捕获不应该处理的异常。通过牢记一些准则来确保安全:只在try子句中放入可能引发您想要处理的异常的代码。当编写except子句时,尽可能具体,不要只是使用except Exception,因为这样很容易。使用测试来确保您的代码以需要尽可能少的异常处理来处理边缘情况。编写except语句而不指定任何异常将捕获任何异常,因此使您的代码面临与将自定义异常从BaseException派生时一样的风险。

您几乎可以在网上的任何地方找到有关异常的信息。一些程序员大量使用它们,而其他人则节俭使用。通过从其他人的源代码中获取示例,找到自己处理异常的方法。在 GitHub(github.com)和 Bitbucket(bitbucket.org/)等网站上有许多有趣的开源项目。

在谈论性能分析之前,让我向您展示异常的非常规用法,以便为您提供一些帮助,帮助您扩展对它们的看法。它们不仅仅是简单的错误:

# exceptions/for.loop.py
n = 100
found = False
for a in range(n):
    if found: break
    for b in range(n):
        if found: break
        for c in range(n):
            if 42 * a + 17 * b + c == 5096:
                found = True
                print(a, b, c)  # 79 99 95

前面的代码是处理数字时的常见习语。您必须迭代几个嵌套范围,并寻找满足条件的特定abc的组合。在示例中,条件是一个微不足道的线性方程,但想象一些比这更酷的东西。让我困扰的是每次循环开始时都要检查解决方案是否已找到,以便在找到解决方案时尽快跳出循环。跳出逻辑会干扰其他代码,我不喜欢这样,所以我想出了另一种解决方案。看看它,并看看你是否也可以将其适应到其他情况:

# exceptions/for.loop.py
class ExitLoopException(Exception):
    pass

try:
    n = 100
    for a in range(n):
        for b in range(n):
            for c in range(n):
                if 42 * a + 17 * b + c == 5096:
                    raise ExitLoopException(a, b, c)
except ExitLoopException as ele:
    print(ele)  # (79, 99, 95)

您能看到它有多么优雅吗?现在,跳出逻辑完全由一个简单的异常处理,甚至其名称都暗示了其目的。一旦找到结果,我们就会引发它,立即将控制权交给处理它的except子句。这是一个值得思考的问题。这个例子间接地向您展示了如何引发自己的异常。阅读官方文档,深入了解这个主题的美丽细节。

此外,如果你想挑战一下,你可能想尝试将最后一个例子转换为嵌套for循环的上下文管理器。祝你好运!

Python 分析

有几种不同的方法来分析 Python 应用程序。分析意味着在应用程序运行时跟踪几个不同的参数,例如函数被调用的次数和在其中花费的时间。分析可以帮助我们找到应用程序中的瓶颈,以便我们只改进真正拖慢我们的部分。

如果你查看标准库官方文档中的分析部分,你会看到同一分析接口的几种不同实现——profilecProfile

  • cProfile建议大多数用户使用,它是一个 C 扩展,具有合理的开销,适用于对长时间运行的程序进行分析

  • profile是一个纯 Python 模块,其接口被cProfile模仿,但对被分析的程序增加了显著的开销

这个接口进行确定性分析,这意味着所有函数调用、函数返回和异常事件都受到监视,并且对这些事件之间的时间间隔进行了精确的计时。另一种方法,称为统计分析,随机抽样有效指令指针,并推断时间花费在哪里。

后者通常的开销较小,但提供的结果只是近似的。此外,由于 Python 解释器运行代码的方式,确定性分析并没有增加太多开销,所以我会向你展示一个简单的例子,使用命令行中的cProfile

我们将使用以下代码计算勾股数(我知道,你们已经错过了它们...):

# profiling/triples.py
def calc_triples(mx):
    triples = []
    for a in range(1, mx + 1):
        for b in range(a, mx + 1):
            hypotenuse = calc_hypotenuse(a, b)
            if is_int(hypotenuse):
                triples.append((a, b, int(hypotenuse)))
    return triples

def calc_hypotenuse(a, b):
    return (a**2 + b**2) ** .5

def is_int(n):  # n is expected to be a float
    return n.is_integer()

triples = calc_triples(1000)

这个脚本非常简单;我们用ab(通过设置b >= a来避免对成对的重复)迭代区间[1, mx],并检查它们是否属于直角三角形。我们使用calc_hypotenuse来获取abhypotenuse,然后,用is_int,我们检查它是否是一个整数,这意味着(a, b, c)是一个勾股数。当我们对这个脚本进行分析时,我们得到了表格形式的信息。列是ncallstottimepercallcumtimepercallfilename:lineno(function)。它们代表我们对一个函数的调用次数,我们在其中花费的时间等等。我会删除一些列以节省空间,所以如果你自己运行分析——不要担心如果你得到不同的结果。这是代码:

$ python -m cProfile triples.py
1502538 function calls in 0.704 seconds
Ordered by: standard name

ncalls tottime percall filename:lineno(function)
500500   0.393   0.000 triples.py:17(calc_hypotenuse)
500500   0.096   0.000 triples.py:21(is_int)
 1   0.000   0.000 triples.py:4(<module>)
 1   0.176   0.176 triples.py:4(calc_triples)
 1   0.000   0.000 {built-in method builtins.exec}
 1034   0.000   0.000 {method 'append' of 'list' objects}
 1   0.000   0.000 {method 'disable' of '_lsprof.Profil...
500500   0.038   0.000 {method 'is_integer' of 'float' objects}

即使有限的数据,我们仍然可以推断出关于这段代码的一些有用信息。首先,我们可以看到我们选择的算法的时间复杂度随着输入规模的平方增长。我们进入内部循环体的次数恰好是mx (mx + 1) / 2。我们使用mx = 1000运行脚本,这意味着我们在内部的for循环中进入了500500次。在循环内发生了三件主要的事情:我们调用calc_hypotenuse,我们调用is_int,并且如果条件满足,我们将其附加到triples列表中。

查看分析报告,我们注意到算法在calc_hypotenuse内花费了0.393秒,这比在is_int内花费的0.096秒要多得多,考虑到它们被调用了相同的次数,所以让我们看看是否可以稍微提高calc_hypotenuse

事实证明,我们可以。正如我在本书前面提到的,**幂运算符是非常昂贵的,在calc_hypotenuse中,我们使用了三次。幸运的是,我们可以很容易地将其中两个转换为简单的乘法,就像这样:

def calc_hypotenuse(a, b): 
    return (a*a + b*b) ** .5 

这个简单的改变应该会改善事情。如果我们再次运行分析,我们会看到0.393现在降到了0.137。不错!这意味着现在我们只花费了大约 37%的时间在calc_hypotenuse内,这比以前少了。

让我们看看是否我们也可以改进is_int,通过像这样改变它:

def is_int(n): 
    return n == int(n) 

这个实现是不同的,它的优势在于当n是整数时也能工作。然而,当我们对其进行性能分析时,我们发现is_int函数内部所花费的时间增加到了0.135秒,因此在这种情况下,我们需要恢复到先前的实现。你可以在本书的源代码中找到这三个版本。

当然,这个例子很琐碎,但足以向你展示如何对应用程序进行性能分析。了解针对函数执行的调用数量有助于我们更好地理解算法的时间复杂度。例如,你不会相信有多少程序员没有意识到这两个for循环与输入大小的平方成比例地运行。

需要提到的一点是:根据你使用的系统不同,结果可能会有所不同。因此,能够在尽可能接近软件部署的系统上进行软件性能分析非常重要,如果可能的话,甚至直接在部署的系统上进行。

何时进行性能分析?

性能分析非常酷,但我们需要知道何时适当进行性能分析,以及我们需要如何处理从中得到的结果。

唐纳德·克努斯曾说过,"过早优化是万恶之源",尽管我不会用这么激烈的措辞来表达,但我同意他的观点。毕竟,我有什么资格不同意那个给我们带来计算机编程艺术TeX以及我在大学时期学习过的一些最酷的算法的人呢?

因此,首要的是正确性。你希望你的代码能够提供正确的结果,因此编写测试,找到边缘情况,并以你认为有意义的每种方式来测试你的代码。不要保守,不要把事情放在脑后,因为你认为它们不太可能发生。要彻底。

第二,要注意编码最佳实践。记住以下内容——可读性、可扩展性、松散耦合、模块化和设计。应用面向对象的原则:封装、抽象、单一责任、开闭原则等等。深入了解这些概念。它们将为你打开新的视野,扩展你对代码的思考方式。

第三,像野兽一样重构!童子军规则说:

"永远把营地留得比你找到时更干净。"

将这条规则应用到你的代码中。

最后,当所有这些都已经处理好了,那么并且只有那时,才开始优化和性能分析。

运行你的性能分析器并识别瓶颈。当你有了需要解决的瓶颈的想法时,首先从最严重的问题开始。有时,修复一个瓶颈会引起连锁反应,会扩展和改变代码的工作方式。有时这只是一点点,有时更多一些,这取决于你的代码是如何设计和实现的。因此,首先解决最大的问题。

Python 如此受欢迎的一个原因是可以用许多不同的方式来实现它。因此,如果你发现自己在纯粹使用 Python 时遇到了困难,没有什么能阻止你卷起袖子,买上 200 升咖啡,然后用 C 语言重写代码中的慢部分——保证会很有趣!

总结

在本章中,我们探讨了测试、异常和性能分析的世界。

我试图为你提供一个相当全面的测试概述,特别是单元测试,这是开发人员主要进行的测试类型。我希望我已经成功地传达了测试不是一件可以从书本上完美定义并学习的事情。在你感到舒适之前,你需要大量地进行实验。在所有程序员必须进行的学习和实验中,我认为测试是最重要的。

我们简要地看到了如何防止我们的程序因为运行时发生的错误(称为异常)而死掉。为了远离通常的领域,我给了你一个有点不寻常的异常使用的例子,用来跳出嵌套的for循环。这并不是唯一的情况,我相信随着你作为编程人员的成长,你会发现其他情况。

最后,我们简要地触及了性能分析,给出了一个简单的例子和一些指导方针。我想谈谈性能分析是为了完整起见,这样至少你可以尝试一下。

在下一章中,我们将探索神奇的秘密世界,哈希和创建令牌。

我知道在本章中我给了你很多指针,但没有链接或方向。我害怕这是有意为之的。作为一个编程人员,在工作中不会有一天你不需要在文档页面、手册、网站等上查找信息。我认为对于一个编程人员来说,能够有效地搜索他们需要的信息是至关重要的,所以希望你能原谅我这额外的训练。毕竟,这都是为了你的利益。

第九章:加密和令牌

"三人可以保守一个秘密,如果其中两人已经死了。" – 本杰明·富兰克林,《穷查理年鉴》

在这一简短的章节中,我将简要概述 Python 标准库提供的加密服务。我还将涉及一种称为 JSON Web Token 的东西,这是一种非常有趣的标准,用于在两个方之间安全地表示声明。

特别是,我们将探讨以下内容:

  • Hashlib

  • 秘密

  • HMAC

  • 使用 PyJWT 的 JSON Web Tokens,这似乎是处理 JWTs 最流行的 Python 库。

让我们花点时间谈谈加密以及为什么它如此重要。

加密的需求

根据网上可以找到的统计数据,2019 年智能手机用户的估计数量将达到 25 亿左右。这些人中的每一个都知道解锁手机的 PIN 码,登录到我们所有用来做基本上所有事情的应用程序的凭据,从购买食物到找到一条街,从给朋友发消息到查看我们的比特币钱包自上次检查 10 秒钟前是否增值。

如果你是一个应用程序开发者,你必须非常、非常认真地对待安全性。无论你的应用程序有多小或者看似不重要:安全性应该始终是你关注的问题。

信息技术中的安全性是通过采用多种不同的手段来实现的,但到目前为止,最重要的手段是加密。你在电脑或手机上做的每件事情都应该包括一个加密发生的层面(如果没有,那真的很糟糕)。它用于用信用卡在线支付,以一种方式在网络上传输消息,即使有人截获了它们,他们也无法阅读,它用于在你将文件备份到云端时对文件进行加密(因为你会这样做,对吧?)。例子的列表是无穷无尽的。

现在,本章的目的并不是教你区分哈希和加密的区别,因为我可以写一本完全不同的书来讨论这个话题。相反,它的目的是向你展示如何使用 Python 提供的工具来创建摘要、令牌,以及在一般情况下,当你需要实现与加密相关的东西时,如何更安全地操作。

有用的指导方针

永远记住以下规则:

  • 规则一:不要尝试创建自己的哈希或加密函数。真的不要。使用已经存在的工具和函数。要想出一个好的、稳固的算法来进行哈希或加密是非常困难的,所以最好将其留给专业的密码学家。

  • 规则二:遵循规则一。

这就是你需要的唯一两条规则。除此之外,了解加密是非常有用的,所以你需要尽量多地了解这个主题。网上有大量的信息,但为了方便起见,我会在本章末尾放一些有用的参考资料。

现在,让我们深入研究我想向你展示的标准库模块中的第一个:hashlib

Hashlib

这个模块向许多不同的安全哈希和消息摘要算法公开了一个通用接口。这两个术语的区别只是历史上的:旧算法被称为摘要,而现代算法被称为哈希

一般来说,哈希函数是指任何可以将任意大小的数据映射到固定大小数据的函数。它是一种单向加密,也就是说,不希望能够根据其哈希值恢复消息。

有几种算法可以用来计算哈希值,所以让我们看看如何找出你的系统支持哪些算法(注意,你的结果可能与我的不同):

>>> import hashlib
>>> hashlib.algorithms_available
{'SHA512', 'SHA256', 'shake_256', 'sha3_256', 'ecdsa-with-SHA1',
 'DSA-SHA', 'sha1', 'sha384', 'sha3_224', 'whirlpool', 'mdc2',
 'RIPEMD160', 'shake_128', 'MD4', 'dsaEncryption', 'dsaWithSHA',
 'SHA1', 'blake2s', 'md5', 'sha', 'sha224', 'SHA', 'MD5',
 'sha256', 'SHA384', 'sha3_384', 'md4', 'SHA224', 'MDC2',
 'sha3_512', 'sha512', 'blake2b', 'DSA', 'ripemd160'}
>>> hashlib.algorithms_guaranteed
{'blake2s', 'md5', 'sha224', 'sha3_512', 'shake_256', 'sha3_256',
 'shake_128', 'sha256', 'sha1', 'sha512', 'blake2b', 'sha3_384',
 'sha384', 'sha3_224'}

通过打开 Python shell,我们可以获取系统中可用的算法列表。如果我们的应用程序必须与第三方应用程序通信,最好从那些有保证的算法中选择一个,因为这意味着每个平台实际上都支持它们。注意到很多算法都以sha开头,这意味着安全哈希算法。让我们在同一个 shell 中继续:我们将为二进制字符串b'Hash me now!'创建一个哈希,我们将以两种方式进行:

>>> h = hashlib.blake2b()
>>> h.update(b'Hash me')
>>> h.update(b' now!')
>>> h.hexdigest()
'56441b566db9aafcf8cdad3a4729fa4b2bfaab0ada36155ece29f52ff70e1e9d'
'7f54cacfe44bc97c7e904cf79944357d023877929430bc58eb2dae168e73cedf'
>>> h.digest()
b'VD\x1bVm\xb9\xaa\xfc\xf8\xcd\xad:G)\xfaK+\xfa\xab\n\xda6\x15^'
b'\xce)\xf5/\xf7\x0e\x1e\x9d\x7fT\xca\xcf\xe4K\xc9|~\x90L\xf7'
b'\x99D5}\x028w\x92\x940\xbcX\xeb-\xae\x16\x8es\xce\xdf'
>>> h.block_size
128
>>> h.digest_size
64
>>> h.name
'blake2b'

我们使用了blake2b加密函数,这是一个相当复杂的函数,它是在 Python 3.6 中添加的。创建哈希对象h后,我们以两步更新其消息。虽然我们不需要,但有时我们需要对不一次性可用的数据进行哈希,所以知道我们可以分步进行是很好的。

当消息符合我们的要求时,我们得到摘要的十六进制表示。这将使用每个字节两个字符(因为每个字符代表 4 位,即半个字节)。我们还得到摘要的字节表示,然后检查其细节:它有一个块大小(哈希算法的内部块大小,以字节为单位)为 128 字节,一个摘要大小(结果哈希的大小,以字节为单位)为 64 字节,还有一个名称。所有这些是否可以在一行中完成?是的,当然:

>>> hashlib.blake2b(b'Hash me now!').hexdigest()
'56441b566db9aafcf8cdad3a4729fa4b2bfaab0ada36155ece29f52ff70e1e9d'
'7f54cacfe44bc97c7e904cf79944357d023877929430bc58eb2dae168e73cedf'

注意相同的消息产生相同的哈希,这当然是预期的。

让我们看看如果我们使用sha256而不是blake2b函数会得到什么:

>>> hashlib.sha256(b'Hash me now!').hexdigest()
'10d561fa94a89a25ea0c7aa47708bdb353bbb062a17820292cd905a3a60d6783'

生成的哈希较短(因此不太安全)。

哈希是一个非常有趣的话题,当然,我们迄今为止看到的简单示例只是开始。blake2b函数允许我们在定制方面有很大的灵活性。这对于防止某些类型的攻击非常有用(有关这些威胁的完整解释,请参考标准文档:docs.python.org/3.7/library/hashlib.html中的hashlib模块)。让我们看另一个例子,我们通过添加keysaltperson来定制一个哈希。所有这些额外信息将导致哈希与我们没有提供它们时得到的哈希不同,并且在为我们系统处理的数据添加额外安全性方面至关重要:

>>> h = hashlib.blake2b(
...   b'Important payload', digest_size=16, key=b'secret-key',
...   salt=b'random-salt', person=b'fabrizio'
... )
>>> h.hexdigest()
'c2d63ead796d0d6d734a5c3c578b6e41'

生成的哈希只有 16 字节长。在定制参数中,salt可能是最著名的一个。它是用作哈希数据的额外输入的随机数据。通常与生成的哈希一起存储,以便提供恢复相同哈希的手段,给定相同的消息。

如果你想确保正确地哈希一个密码,你可以使用pbkdf2_hmac,这是一种密钥派生算法,它允许你指定算法本身使用的salt和迭代次数。随着计算机变得越来越强大,增加随时间进行的迭代次数非常重要,否则随着时间的推移,成功的暴力破解攻击的可能性会增加。以下是你如何使用这样的算法:

>>> import os
>>> dk = hashlib.pbkdf2_hmac(
...   'sha256', b'Password123', os.urandom(16), 100000
... )
>>> dk.hex()
'f8715c37906df067466ce84973e6e52a955be025a59c9100d9183c4cbec27a9e'

请注意,我已经使用os.urandom提供了一个 16 字节的随机盐,这是文档推荐的。

我鼓励你去探索和尝试这个模块,因为迟早你会不得不使用它。现在,让我们继续secrets

秘密

这个小巧的模块用于生成密码强度的随机数,适用于管理密码、账户认证、安全令牌和相关秘密。它是在 Python 3.6 中添加的,基本上处理三件事:随机数、令牌和摘要比较。让我们快速地探索一下它们。

随机数

我们可以使用三个函数来处理随机数:

# secrs/secr_rand.py
import secrets
print(secrets.choice('Choose one of these words'.split()))
print(secrets.randbelow(10 ** 6))
print(secrets.randbits(32))

第一个函数choice从非空序列中随机选择一个元素。第二个函数randbelow生成一个介于0和您调用它的参数之间的随机整数,第三个函数randbits生成一个具有n个随机位的整数。运行该代码会产生以下输出(始终不同):

$ python secr_rand.py
one
504156
3172492450

在需要在密码学环境中需要随机性时,您应该使用这些函数,而不是random模块中的函数,因为这些函数是专门为此任务设计的。让我们看看模块为我们提供了什么样的令牌。

令牌生成

同样,我们有三个函数,它们都以不同的格式生成令牌。让我们看一个例子:

# secrs/secr_rand.py
print(secrets.token_bytes(16))
print(secrets.token_hex(32))
print(secrets.token_urlsafe(32))

第一个函数token_bytes简单地返回一个包含n个字节(在本例中为16)的随机字节字符串。另外两个函数也是如此,但token_hex以十六进制格式返回一个令牌,而token_urlsafe返回一个仅包含适合包含在 URL 中的字符的令牌。让我们看看输出(这是上一次运行的延续):

b'\xda\x863\xeb\xbb|\x8fk\x9b\xbd\x14Q\xd4\x8d\x15}'
9f90fd042229570bf633e91e92505523811b45e1c3a72074e19bbeb2e5111bf7
bl4qz_Av7QNvPEqZtKsLuTOUsNLFmXW3O03pn50leiY 

这一切都很好,那么为什么我们不用这些工具写一个随机密码生成器来玩一下呢?

# secrs/secr_gen.py
import secrets
from string import digits, ascii_letters

def generate_pwd(length=8):
    chars = digits + ascii_letters
    return ''.join(secrets.choice(chars) for c in range(length))

def generate_secure_pwd(length=16, upper=3, digits=3):
    if length < upper + digits + 1:
        raise ValueError('Nice try!')
    while True:
        pwd = generate_pwd(length)
        if (any(c.islower() for c in pwd)
            and sum(c.isupper() for c in pwd) >= upper
            and sum(c.isdigit() for c in pwd) >= digits):
            return pwd

print(generate_secure_pwd())
print(generate_secure_pwd(length=3, upper=1, digits=1))

在前面的代码中,我们定义了两个函数。generate_pwd简单地通过从包含字母表(小写和大写)和 10 个十进制数字的字符串中随机选择length个字符,并将它们连接在一起来生成给定长度的随机字符串。

然后,我们定义另一个函数generate_secure_pwd,它简单地不断调用generate_pwd,直到我们得到的随机字符串符合要求,这些要求非常简单。密码必须至少有一个小写字符,upper个大写字符,digits个数字,和length长度。

在我们进入while循环之前,值得注意的是,如果我们将要求(大写、小写和数字)相加,而这个和大于密码的总长度,那么我们永远无法在循环内满足条件。因此,为了避免陷入无限循环,我在主体的第一行放了一个检查子句,并在需要时引发ValueError。你能想到如何为这种边缘情况编写测试吗?

while循环的主体很简单:首先我们生成随机密码,然后我们使用anysum来验证条件。any如果可迭代的项目中有任何一个评估为True,则返回True。在这里,使用 sum 实际上稍微棘手一些,因为它利用了多态性。在继续阅读之前,你能看出我在说什么吗?

嗯,这很简单:在 Python 中,TrueFalse是整数数字的子类,因此在True/False值的可迭代上求和时,它们将自动被sum函数解释为整数。这被称为多态性,我们在第六章中简要讨论过,OOP,装饰器和迭代器

运行示例会产生以下结果:

$ python secr_gen.py
nsL5voJnCi7Ote3F
J5e

第二个密码可能不太安全...

在我们进入下一个模块之前,最后一个例子。让我们生成一个重置密码的 URL:

# secrs/secr_reset.py
import secrets

def get_reset_pwd_url(token_length=16):
    token = secrets.token_urlsafe(token_length)
    return f'https://fabdomain.com/reset-pwd/{token}'

print(get_reset_pwd_url())

这个函数非常简单,我只会向你展示输出:

$ python secr_reset.py
https://fabdomain.com/reset-pwd/m4jb7aKgzTGuyjs9lTIspw

摘要比较

这可能相当令人惊讶,但在secrets中,您可以找到compare_digest(a, b)函数,它相当于通过简单地执行a == b来比较两个摘要。那么,为什么我们需要该函数呢?因为它旨在防止时序攻击。这种攻击可以根据比较失败所需的时间推断出两个摘要开始不同的位置。因此,compare_digest通过消除时间和失败之间的相关性来防止此类攻击。我认为这是一个很好的例子,说明了攻击方法可以有多么复杂。如果您因惊讶而挑起了眉毛,也许现在我说过永远不要自己实现加密函数的原因更加清楚了。

就是这样!现在,让我们来看看hmac

HMAC

该模块实现了 HMAC 算法,如 RFC 2104 所述(tools.ietf.org/html/rfc2104.html)。由于它非常小,但仍然很重要,我将为您提供一个简单的示例:

# hmc.py
import hmac
import hashlib

def calc_digest(key, message):
    key = bytes(key, 'utf-8')
    message = bytes(message, 'utf-8')
    dig = hmac.new(key, message, hashlib.sha256)
    return dig.hexdigest()

digest = calc_digest('secret-key', 'Important Message')

正如您所看到的,接口始终是相同或相似的。我们首先将密钥和消息转换为字节,然后创建一个digest实例,我们将使用它来获取哈希的十六进制表示。没有什么别的可说的,但我还是想添加这个模块,以保持完整性。

现在,让我们转向不同类型的令牌:JWT。

JSON Web Tokens

JSON Web Token,或JWT,是用于创建断言某些声明的令牌的基于 JSON 的开放标准。您可以在网站上了解有关此技术的所有信息(jwt.io/)。简而言之,这种类型的令牌由三个部分组成,用点分隔,格式为A.B.CB是有效载荷,其中我们放置数据和声明。C是签名,用于验证令牌的有效性,A是用于计算签名的算法。ABC都使用 URL 安全的 Base64 编码(我将其称为 Base64URL)进行编码。

Base64 是一种非常流行的二进制到文本编码方案,它通过将二进制数据转换为基 64 表示形式来以 ASCII 字符串格式表示二进制数据。基 64 表示法使用字母A-Za-z和数字0-9,再加上两个符号+/,总共共 64 个符号。因此,毫不奇怪,Base64 字母表由这 64 个符号组成。例如,Base64 用于编码电子邮件中附加的图像。这一切都是无缝进行的,因此绝大多数人完全不知道这一事实。

JWT 使用 Base64URL 进行编码的原因是因为在 URL 上下文中,字符+/分别表示空格和路径分隔符。因此,在 URL 安全版本中,它们被替换为-_。此外,任何填充字符(=),通常在 Base64 中使用,都被删除,因为在 URL 中它也具有特定含义。

因此,这种类型的令牌的工作方式与我们在处理哈希时习惯的方式略有不同。实际上,令牌携带的信息始终是可见的。您只需要解码AB以获取算法和有效载荷。但是,安全性部分在于C,它是令牌的 HMAC 哈希。如果您尝试通过编辑有效载荷,将其重新编码为 Base64,并替换令牌中的有效载荷,那么签名将不再匹配,因此令牌将无效。

这意味着我们可以构建一个带有声明的有效载荷,例如作为管理员登录,或类似的内容,只要令牌有效,我们就知道我们可以信任该用户实际上是作为管理员登录的。

处理 JWT 时,您希望确保已经研究了如何安全处理它们。诸如不接受未签名的令牌,或限制您用于编码和解码的算法列表,以及其他安全措施等事项非常重要,您应该花时间调查和学习它们。

对于代码的这一部分,您需要安装PyJWTcryptography Python 包。与往常一样,您将在本书源代码的要求中找到它们。

让我们从一个简单的例子开始:

# tok.py
import jwt

data = {'payload': 'data', 'id': 123456789}

token = jwt.encode(data, 'secret-key')
data_out = jwt.decode(token, 'secret-key')
print(token)
print(data_out)

我们定义了包含 ID 和一些有效载荷数据的data有效载荷。然后,我们使用jwt.encode函数创建一个令牌,该函数至少需要有效载荷和一个用于计算签名的秘钥。用于计算令牌的默认算法是HS256。让我们看一下输出:

$ python tok.py
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJwYXlsb2FkIjoiZGF0YSIsImlkIjoxMjM0NTY3ODl9.WFRY-uoACMoNYX97PXXjEfXFQO1rCyFCyiwxzOVMn40'
{'payload': 'data', 'id': 123456789}

因此,正如您所看到的,令牌是 Base64URL 编码的数据片段的二进制字符串。我们调用了jwt.decode,提供了正确的秘钥。如果我们做了其他操作,解码将会失败。

有时,您可能希望能够检查令牌的内容而不进行验证。您可以通过简单地调用decode来实现:

# tok.py
jwt.decode(token, verify=False)

例如,当需要使用令牌有效载荷中的值来恢复秘钥时,这是很有用的,但是这种技术相当高级,所以在这种情况下我不会花时间讨论它。相反,让我们看看如何指定一个不同的算法来计算签名:

# tok.py
token512 = jwt.encode(data, 'secret-key', algorithm='HS512')
data_out = jwt.decode(token512, 'secret-key', algorithm='HS512')
print(data_out)

输出是我们的原始有效载荷字典。如果您想在解码阶段允许多个算法,您甚至可以指定一个算法列表,而不仅仅是一个。

现在,虽然您可以在令牌有效载荷中放入任何您想要的内容,但有一些声明已经被标准化,并且它们使您能够对令牌有很大的控制权。

已注册的声明

在撰写本书时,这些是已注册的声明:

  • iss:令牌的发行者

  • sub:关于此令牌所携带信息的主题信息

  • aud:令牌的受众

  • exp过期时间,在此时间之后,令牌被视为无效

  • nbf不早于(时间),或者在此时间之前,令牌被视为尚未有效

  • iat:令牌发行的时间

  • jti:令牌ID

声明也可以被归类为公共或私有:

  • 私有:由 JWT 的用户(消费者和生产者)定义的声明。换句话说,这些是用于特定情况的临时声明。因此,必须小心防止碰撞。

  • 公共:是在 IANA JSON Web Token 声明注册表中注册的声明(用户可以在其中注册他们的声明,从而防止碰撞),或者使用具有碰撞抵抗名称的名称(例如,通过在其名称前加上命名空间)。

要了解有关声明的所有内容,请参考官方网站。现在,让我们看一些涉及这些声明子集的代码示例。

与时间相关的声明

让我们看看如何使用与时间相关的声明:

# claims_time.py
from datetime import datetime, timedelta
from time import sleep
import jwt

iat = datetime.utcnow()
nfb = iat + timedelta(seconds=1)
exp = iat + timedelta(seconds=3)
data = {'payload': 'data', 'nbf': nfb, 'exp': exp, 'iat': iat}

def decode(token, secret):
    print(datetime.utcnow().time().isoformat())
    try:
        print(jwt.decode(token, secret))
    except (
        jwt.ImmatureSignatureError, jwt.ExpiredSignatureError
    ) as err:
        print(err)
        print(type(err))

secret = 'secret-key'
token = jwt.encode(data, secret)

decode(token, secret)
sleep(2)
decode(token, secret)
sleep(2)
decode(token, secret)

在此示例中,我们将iat声明设置为当前的 UTC 时间(UTC代表协调世界时)。然后,我们将nbfexp设置为分别从现在开始的13秒。然后,我们定义了一个解码辅助函数,它会对尚未有效或已过期的令牌做出反应,通过捕获适当的异常,然后我们调用它三次,中间隔着两次调用睡眠。这样,我们将尝试在令牌尚未有效时解码它,然后在它有效时解码,最后在它已经过期时解码。此函数还在尝试解密之前打印了一个有用的时间戳。让我们看看它是如何执行的(为了可读性已添加了空行):

$ python claims_time.py
14:04:13.469778
The token is not yet valid (nbf)
<class 'jwt.exceptions.ImmatureSignatureError'>

14:04:15.475362
{'payload': 'data', 'nbf': 1522591454, 'exp': 1522591456, 'iat': 1522591453}

14:04:17.476948
Signature has expired
<class 'jwt.exceptions.ExpiredSignatureError'>

正如您所看到的,一切都如预期执行。我们从异常中得到了很好的描述性消息,并且在令牌实际有效时得到了原始有效载荷。

与认证相关的声明

让我们看另一个涉及发行者(iss)和受众(aud)声明的快速示例。代码在概念上与上一个示例非常相似,我们将以相同的方式进行练习:

# claims_auth.py
import jwt

data = {'payload': 'data', 'iss': 'fab', 'aud': 'learn-python'}
secret = 'secret-key'
token = jwt.encode(data, secret)

def decode(token, secret, issuer=None, audience=None):
    try:
        print(jwt.decode(
            token, secret, issuer=issuer, audience=audience))
    except (
        jwt.InvalidIssuerError, jwt.InvalidAudienceError
    ) as err:
        print(err)
        print(type(err))

decode(token, secret)
# not providing the issuer won't break
decode(token, secret, audience='learn-python')
# not providing the audience will break
decode(token, secret, issuer='fab')
# both will break
decode(token, secret, issuer='wrong', audience='learn-python')
decode(token, secret, issuer='fab', audience='wrong')

decode(token, secret, issuer='fab', audience='learn-python')

正如您所看到的,这一次我们指定了issueraudience。事实证明,如果我们在解码令牌时不提供发行者,它不会导致解码失败。但是,提供错误的发行者将导致解码失败。另一方面,未提供受众,或提供错误的受众,都将导致解码失败。

与上一个示例一样,我编写了一个自定义解码函数,以响应适当的异常。看看您是否能跟上调用和随后的输出(我会在一些空行上帮助):

$ python claims_auth.py
Invalid audience
<class 'jwt.exceptions.InvalidAudienceError'>

{'payload': 'data', 'iss': 'fab', 'aud': 'learn-python'}

Invalid audience
<class 'jwt.exceptions.InvalidAudienceError'>

Invalid issuer
<class 'jwt.exceptions.InvalidIssuerError'>

Invalid audience
<class 'jwt.exceptions.InvalidAudienceError'>

{'payload': 'data', 'iss': 'fab', 'aud': 'learn-python'}

现在,让我们看一个更复杂的用例的最后一个例子。

使用非对称(公钥)算法

有时,使用共享密钥并不是最佳选择。在这种情况下,采用不同的技术可能会很有用。在这个例子中,我们将使用一对 RSA 密钥创建一个令牌(并解码它)。

公钥密码学,或非对称密码学,是使用公钥(可以广泛传播)和私钥(只有所有者知道)的密钥对的任何加密系统。如果您有兴趣了解更多关于这个主题的内容,请参阅本章末尾的推荐书目。

现在,让我们创建两对密钥。一对将没有密码,另一对将有密码。为了创建它们,我将使用 OpenSSH 的ssh-keygen工具(www.ssh.com/ssh/keygen/)。在我为本章编写脚本的文件夹中,我创建了一个rsa子文件夹。在其中,运行以下命令:

$ ssh-keygen -t rsa

将路径命名为key(它将保存在当前文件夹中),并在要求密码时简单地按下Enter键。完成后,再做一次相同的操作,但这次使用keypwd作为密钥的名称,并给它设置一个密码。我选择的密码是经典的Password123。完成后,切换回ch9文件夹,并运行以下代码:

# token_rsa.py
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

data = {'payload': 'data'}

def encode(data, priv_filename, priv_pwd=None, algorithm='RS256'):
    with open(priv_filename, 'rb') as key:
        private_key = serialization.load_pem_private_key(
            key.read(),
            password=priv_pwd,
            backend=default_backend()
        )
    return jwt.encode(data, private_key, algorithm=algorithm)

def decode(data, pub_filename, algorithm='RS256'):
    with open(pub_filename, 'rb') as key:
        public_key = key.read()
    return jwt.decode(data, public_key, algorithm=algorithm)

# no pwd
token = encode(data, 'rsa/key')
data_out = decode(token, 'rsa/key.pub')
print(data_out)

# with pwd
token = encode(data, 'rsa/keypwd', priv_pwd=b'Password123')
data_out = decode(token, 'rsa/keypwd.pub')
print(data_out)

在上一个示例中,我们定义了一对自定义函数来使用私钥/公钥对编码和解码令牌。正如您在encode函数的签名中所看到的,这次我们使用了RS256算法。我们需要使用特殊的load_pem_private_key函数打开私钥文件,该函数允许我们指定内容、密码和后端。.pem是我们的密钥创建的格式的名称。如果您查看这些文件,您可能会认出它们,因为它们非常流行。

逻辑非常简单,我鼓励您至少考虑一个使用这种技术可能比使用共享密钥更合适的用例。

有用的参考资料

在这里,您可以找到一些有用的参考资料,如果您想深入了解密码学的迷人世界:

网络上还有更多内容,还有很多书籍可以学习,但我建议您从主要概念开始,然后逐渐深入研究您想更全面了解的具体内容。

总结

在这一短章中,我们探索了 Python 标准库中的密码学世界。我们学会了如何使用不同的密码学函数为消息创建哈希(或摘要)。我们还学会了如何在密码学上下文中创建令牌并处理随机数据。

然后,我们在标准库之外进行了小小的探索,了解了 JSON Web 令牌,这在现代系统和应用程序中的认证和声明相关功能中被广泛使用。

最重要的是要明白,在涉及密码学时,手动操作可能非常危险,因此最好还是把它交给专业人士,简单地使用我们现有的工具。

下一章将完全关于摆脱单行软件执行。我们将学习软件在现实世界中的运行方式,探索并发执行,并了解 Python 提供给我们的线程、进程和工具,以便同时执行多项任务,可以这么说。

第十章:并发执行

“我们想要什么?现在!我们什么时候想要?更少的竞争条件!”- Anna Melzer

在这一章中,我打算稍微提高一下游戏水平,无论是在我将要介绍的概念上,还是在我将向你展示的代码片段的复杂性上。如果你觉得任务太艰巨,或者在阅读过程中意识到它变得太困难,可以随时跳过。等你准备好了再回来。

计划是离开熟悉的单线程执行范式,深入探讨可以描述为并发执行的内容。我只能浅尝这个复杂的主题,所以我不指望你在阅读完之后就成为并发性的大师,但我会像往常一样,尽量给你足够的信息,这样你就可以继续“走上这条路”,可以这么说。

我们将学习适用于这个编程领域的所有重要概念,并且我会尝试向你展示以不同风格编码的示例,以便让你对这些主题的基础有扎实的理解。要深入研究这个具有挑战性和有趣的编程分支,你将不得不参考 Python 文档中的并发执行部分(docs.python.org/3.7/library/concurrency.html),也许还要通过学习相关书籍来补充你的知识。

特别是,我们将探讨以下内容:

  • 线程和进程背后的理论

  • 编写多线程代码

  • 编写多进程代码

  • 使用执行器来生成线程和进程

  • 使用asyncio进行编程的简短示例

让我们先把理论搞清楚。

并发与并行

并发和并行经常被误解为相同的事物,但它们之间有区别。并发是同时运行多个任务的能力,不一定是并行的。并行是同时做多件事情的能力。

想象一下,你带着另一半去剧院。有两条队列:VIP 和普通票。只有一个工作人员检查票,为了避免阻塞两个队列中的任何一个,他们先检查 VIP 队列的一张票,然后检查普通队列的一张票。随着时间的推移,两个队列都被处理了。这是并发的一个例子。

现在想象一下,另一个工作人员加入了,所以现在每个队列都有一个工作人员。这样,每个队列都将由自己的工作人员处理。这是并行的一个例子。

现代笔记本电脑处理器具有多个核心(通常是两到四个)。核心是属于处理器的独立处理单元。拥有多个核心意味着所讨论的 CPU 实际上具有并行执行任务的物理能力。在每个核心内部,通常会有一系列工作流的不断交替,这是并发执行。

请记住,我在这里故意保持讨论的泛化。根据你使用的系统,执行处理方式会有所不同,所以我将集中讨论那些对所有系统或至少大多数系统都通用的概念。

线程和进程-概述

线程可以被定义为一系列指令,可以由调度程序运行,调度程序是操作系统的一部分,决定哪个工作块将获得必要的资源来执行。通常,一个线程存在于一个进程内。进程可以被定义为正在执行的计算机程序的一个实例。

在之前的章节中,我们用类似$ python my_script.py的命令运行我们自己的模块和脚本。当运行这样的命令时,会创建一个 Python 进程。在其中,会生成一个主执行线程。脚本中的指令将在该线程内运行。

这只是一种工作方式,Python 实际上可以在同一个进程中使用多个线程,甚至可以生成多个进程。毫不奇怪,这些计算机科学的分支被称为多线程和多进程。

为了理解区别,让我们花点时间稍微深入地探讨线程和进程。

线程的快速解剖

一般来说,有两种不同类型的线程:

  • 用户级线程:我们可以创建和管理以执行任务的线程

  • 内核级线程:在内核模式下运行并代表操作系统执行操作的低级线程

鉴于 Python 在用户级别上运行,我们暂时不会深入研究内核线程。相反,我们将在本章的示例中探索几个用户级线程的示例。

线程可以处于以下任何状态:

  • 新线程:尚未启动,也没有分配任何资源的线程。

  • 可运行:线程正在等待运行。它具有运行所需的所有资源,一旦调度程序给予它绿灯,它将运行。

  • 运行:正在执行指令流的线程。从这种状态,它可以返回到非运行状态,或者死亡。

  • 非运行:已暂停的线程。这可能是由于另一个线程优先于它,或者仅仅是因为线程正在等待长时间运行的 IO 操作完成。

  • 死亡:线程已经死亡,因为它已经到达了其执行流的自然结束,或者它已经被终止。

状态之间的转换是由我们的操作或调度程序引起的。不过,有一件事要记住;最好不要干涉线程的死亡。

终止线程

终止线程并不被认为是良好的做法。Python 不提供通过调用方法或函数来终止线程的能力,这应该是终止线程不是你想要做的事情的暗示。

一个原因是线程可能有子线程——从线程本身内部生成的线程——当其父线程死亡时会成为孤儿。另一个原因可能是,如果您要终止的线程持有需要正确关闭的资源,您可能会阻止这种情况发生,这可能会导致问题。

稍后,我们将看到如何解决这些问题的示例。

上下文切换

我们已经说过调度程序可以决定何时运行线程,或者暂停线程等。任何时候运行的线程需要被暂停以便另一个线程可以运行时,调度程序会以一种方式保存运行线程的状态,以便在以后的某个时间恢复执行,恢复到暂停的地方。

这个行为被称为上下文切换。人们也经常这样做。我们正在做一些文书工作,然后听到手机上的叮铃声!。我们停下文书工作,查看手机。当我们处理完可能是第 n 张有趣猫的照片后,我们回到文书工作。不过,我们并不是从头开始文书工作;我们只是继续之前离开的地方。

上下文切换是现代计算机的奇妙能力,但如果生成了太多线程,它可能会变得麻烦。调度程序将尝试给每个线程一点时间来运行,并且将花费大量时间保存和恢复分别暂停和重新启动的线程的状态。

为了避免这个问题,限制可以在任何给定时间点运行的线程数量(同样的考虑也适用于进程)是相当常见的。这是通过使用一个称为池的结构来实现的,其大小可以由程序员决定。简而言之,我们创建一个池,然后将任务分配给它的线程。当池中的所有线程都忙碌时,程序将无法生成新的线程,直到其中一个终止(并返回到池中)。池对于节省资源也非常有用,因为它为线程生态系统提供了回收功能。

当你编写多线程代码时,了解软件将在哪台机器上运行的信息是很有用的。这些信息,再加上一些分析(我们将在第十一章 调试和故障排除中学习),应该能够让我们正确地校准我们的池的大小。

全局解释器锁

2015 年 7 月,我参加了在毕尔巴鄂举行的 EuroPython 大会,我在那里做了一个关于测试驱动开发的演讲。摄像机操作员不幸地丢失了其中的前半部分,但我后来又有机会再做了几次那个演讲,所以你可以在网上找到完整版本。在会议上,我有幸见到了 Guido van Rossum 并与他交谈,我还参加了他的主题演讲。

他谈到的一个话题是臭名昭著的全局解释器锁GIL)。GIL 是一个互斥锁,用于保护对 Python 对象的访问,防止多个线程同时执行 Python 字节码。这意味着即使你可以在 Python 中编写多线程代码,但在任何时间点只有一个线程在运行(每个进程,当然)。

在计算机编程中,互斥对象(mutex)是一个允许多个程序线程共享相同资源(如文件访问)但不是同时的程序对象。

这通常被视为语言的不良限制,许多开发人员以诅咒这个伟大的反派为傲。然而,事实并非如此,正如 Raymond Hettinger 在 2017 年 PyBay 大会上的并发性主题演讲中所美妙地解释的那样(bit.ly/2KcijOB)。大约 10 分钟后,Raymond 解释说,实际上很容易从 Python 中删除 GIL。这需要大约一天的工作。然而,你为此付出的代价是在代码中需要在需要的地方自行应用锁。这会导致更昂贵的印记,因为大量的个别锁需要更长的时间来获取和释放,最重要的是,它引入了错误的风险,因为编写健壮的多线程代码并不容易,你可能最终不得不编写几十甚至几百个锁。

为了理解锁是什么,以及为什么你可能想要使用它,我们首先需要谈谈多线程编程的危险之一:竞争条件。

竞争条件和死锁

当涉及编写多线程代码时,你需要意识到当你的代码不再被线性执行时会出现的危险。我的意思是,多线程代码有可能在任何时间点被调度程序暂停,因为它决定给另一个指令流一些 CPU 时间。

这种行为使你面临不同类型的风险,其中最著名的两种是竞争条件和死锁。让我们简要谈谈它们。

竞争条件

竞争条件是系统行为的一种,其中过程的输出取决于其他无法控制的事件的顺序或时间。当这些事件不按程序员预期的顺序展开时,竞争条件就会成为一个错误。

通过一个例子来解释这一点会更容易理解。

想象一下你有两个运行的线程。两者都在执行相同的任务,即从一个位置读取一个值,对该值执行一个操作,将该值增加1单位,然后保存回去。假设该操作是将该值发布到 API。

情景 A - 竞争条件不会发生

线程A读取值(1),将1发送到 API,然后将其增加到2,并保存回去。就在这之后,调度程序暂停了线程A,并运行了线程B。线程B读取值(现在是2),将2发送到 API,将其增加到3,然后保存回去。

在这一点上,即使操作发生了两次,存储的值也是正确的:1 + 2 = 3。此外,API 已经正确地被调用了两次,分别是12

情景 B - 竞争条件发生

线程A读取值(1),将其发送到 API,将其增加到2,但在它保存回去之前,调度程序决定暂停线程A,转而执行线程B

线程B读取值(仍然是1!),将其发送到 API,将其增加到2,然后保存回去。然后调度程序再次切换到线程A。线程A通过简单保存增加后的值(2)来恢复其工作流。

在这种情况下,即使操作像情景 A 中发生了两次,保存的值是2,API 也被调用了两次,每次都是1

在现实生活中,有多个线程和真实代码执行多个操作的情况下,程序的整体行为会爆炸成无数可能性。我们稍后会看到一个例子,并使用锁来解决它。

竞争条件的主要问题在于它使我们的代码变得不确定,这是不好的。在计算机科学中有一些领域使用了非确定性来实现某些目标,这是可以接受的,但通常情况下,你希望能够预测代码的行为,而竞争条件使这变得不可能。

锁来拯救

在处理竞争条件时,锁会拯救我们。例如,为了修复前面的例子,你只需要在该过程周围加上一个锁。锁就像一个守护者,只允许一个线程拿住它(我们说获取锁),并且直到该线程释放锁,其他线程都无法获取它。它们必须坐下等待,直到锁再次可用。

情景 C - 使用锁

线程A获取锁,读取值(1),发送到 API,增加到2,然后调度程序将其挂起。线程B获得了一些 CPU 时间,所以它尝试获取锁。但是锁还没有被线程A释放,所以线程B等待。调度程序可能会注意到这一点,并迅速决定切换回线程A

线程A保存 2,并释放锁,使其对所有其他线程可用。

在这一点上,无论是线程A再次获取锁,还是线程B获取锁(因为调度程序可能已经决定再次切换),都不重要。该过程将始终被正确执行,因为锁确保当一个线程读取一个值时,它必须在任何其他线程也能读取该值之前完成该过程(ping API,增加和保存)。

标准库中有许多不同的锁可用。我绝对鼓励你阅读它们,以了解在编写多线程代码时可能遇到的所有危险,以及如何解决它们。

现在让我们谈谈死锁。

死锁

死锁是一种状态,在这种状态下,组中的每个成员都在等待其他成员采取行动,例如发送消息,更常见的是释放锁或资源。

一个简单的例子将帮助你理解。想象两个小孩在一起玩。找一个由两部分组成的玩具,给他们每人一部分。自然地,他们中没有一个会想把自己的那部分给另一个,他们会想让另一个释放他们手中的那部分。因此,他们中没有一个能够玩这个玩具,因为他们每个人都握着一半,会无限期地等待另一个孩子释放另一半。

别担心,在制作这个例子的过程中没有伤害到任何孩子。这一切都发生在我的脑海中。

另一个例子可能是让两个线程再次执行相同的过程。该过程需要获取两个资源,AB,分别由单独的锁保护。线程1获取A,线程2获取B,然后它们将无限期地等待,直到另一个释放它所拥有的资源。但这不会发生,因为它们都被指示等待并获取第二个资源以完成该过程。线程可能比孩子更倔强。

你可以用几种方法解决这个问题。最简单的方法可能就是对资源获取应用顺序,这意味着获得A的线程也会获得其余的BC等等。

另一种方法是在整个资源获取过程周围加锁,这样即使可能发生顺序错误,它仍然会在锁的上下文中进行,这意味着一次只有一个线程可以实际获取所有资源。

现在让我们暂停一下关于线程的讨论,来探讨进程。

进程的简单解剖

进程通常比线程更复杂。一般来说,它们包含一个主线程,但如果你选择的话也可以是多线程的。它们能够生成多个子线程,每个子线程都包含自己的寄存器和堆栈。每个进程都提供计算机执行程序所需的所有资源。

与使用多个线程类似,我们可以设计我们的代码以利用多进程设计。多个进程可能在多个核心上运行,因此使用多进程可以真正并行计算。然而,它们的内存占用略高于线程的内存占用,使用多个进程的另一个缺点是进程间通信(IPC)往往比线程间通信更昂贵。

进程的属性

UNIX 进程是由操作系统创建的。它通常包含以下内容:

  • 进程 ID、进程组 ID、用户 ID 或组 ID

  • 一个环境和工作目录

  • 程序指令

  • 寄存器、堆栈和堆

  • 文件描述符

  • 信号动作

  • 共享库

  • 进程间通信工具(管道、消息队列、信号量或共享内存)

如果你对进程感兴趣,打开一个 shell 并输入$ top。这个命令会显示并更新有关系统中正在运行的进程的排序信息。当我在我的机器上运行它时,第一行告诉我以下信息:

$ top
Processes: 477 total, 4 running, 473 sleeping, 2234 threads
...

这让你对我们的计算机在我们并不真正意识到的情况下做了多少工作有了一个概念。

多线程还是多进程?

考虑到所有这些信息,决定哪种方法是最好的意味着要了解需要执行的工作类型,并且要了解将要专门用于执行该工作的系统。

这两种方法都有优势,所以让我们试着澄清一下主要的区别。

以下是使用多线程的一些优势:

  • 线程都是在同一个进程中诞生的。它们共享资源,并且可以非常容易地相互通信。进程之间的通信需要更复杂的结构和技术。

  • 生成线程的开销比生成进程的开销小。此外,它们的内存占用也更小。

  • 线程在阻塞 IO 密集型应用程序方面非常有效。例如,当一个线程被阻塞等待网络连接返回一些数据时,工作可以轻松有效地切换到另一个线程。

  • 因为进程之间没有共享资源,所以我们需要使用 IPC 技术,而且它们需要比线程之间通信更多的内存。

以下是使用多进程的一些优势:

  • 我们可以通过使用进程来避免 GIL 的限制。

  • 失败的子进程不会终止主应用程序。

  • 线程存在诸如竞争条件和死锁等问题;而使用进程时,需要处理这些问题的可能性大大降低。

  • 当线程数量超过一定阈值时,线程的上下文切换可能变得非常昂贵。

  • 进程可以更好地利用多核处理器。

  • 进程比多线程更擅长处理 CPU 密集型任务。

在本章中,我将为您展示多个示例的两种方法,希望您能对各种不同的技术有一个很好的理解。那么让我们开始编码吧!

Python 中的并发执行

让我们从一些简单的例子开始,探索 Python 多线程和多进程的基础知识。

请记住,以下示例中的几个将产生取决于特定运行的输出。处理线程时,事情可能变得不确定,就像我之前提到的那样。因此,如果您遇到不同的结果,那是完全正常的。您可能会注意到,您的一些结果也会从一次运行到另一次运行有所不同。

开始一个线程

首先,让我们开始一个线程:

# start.py
import threading

def sum_and_product(a, b):
    s, p = a + b, a * b
    print(f'{a}+{b}={s}, {a}*{b}={p}')

t = threading.Thread(
    target=sum_and_product, name='SumProd', args=(3, 7)
)
t.start()

在导入threading之后,我们定义一个函数:sum_and_product。这个函数计算两个数字的和和积,并打印结果。有趣的部分在函数之后。我们从threading.Thread实例化了t。这是我们的线程。我们传递了将作为线程主体运行的函数的名称,给它一个名称,并传递了参数37,它们将分别作为ab传递到函数中。

创建了线程之后,我们使用同名方法启动它。

此时,Python 将在一个新线程中开始执行函数,当该操作完成时,整个程序也将完成,并退出。让我们运行它:

$ python start.py
3+7=10, 3*7=21 

因此,开始一个线程非常简单。让我们看一个更有趣的例子,其中我们显示更多信息:

# start_with_info.py
import threading
from time import sleep

def sum_and_product(a, b):
    sleep(.2)
    print_current()
    s, p = a + b, a * b
    print(f'{a}+{b}={s}, {a}*{b}={p}')

def status(t):
    if t.is_alive():
        print(f'Thread {t.name} is alive.')
    else:
        print(f'Thread {t.name} has terminated.')

def print_current():
    print('The current thread is {}.'.format(
        threading.current_thread()
    ))
    print('Threads: {}'.format(list(threading.enumerate())))

print_current()
t = threading.Thread(
    target=sum_and_product, name='SumPro', args=(3, 7)
)
t.start()
status(t)
t.join()
status(t)

在这个例子中,线程逻辑与之前的完全相同,所以你不需要为此而劳累,可以专注于我添加的(疯狂的!)大量日志信息。我们使用两个函数来显示信息:statusprint_current。第一个函数接受一个线程作为输入,并通过调用其is_alive方法显示其名称以及它是否存活。第二个函数打印当前线程,然后枚举进程中的所有线程。这些信息来自threading.current_threadthreading.enumerate

我在函数内部放置了.2秒的睡眠时间是有原因的。当线程启动时,它的第一条指令是休眠一会儿。调皮的调度程序会捕捉到这一点,并将执行切换回主线程。您可以通过输出中看到,在线程内部的status(t)的结果之前,您将看到print_current的结果。这意味着这个调用发生在线程休眠时。

最后,请注意我在最后调用了t.join()。这指示 Python 阻塞,直到线程完成。这是因为我希望最后一次对status(t)的调用告诉我们线程已经结束。让我们来看一下输出(为了可读性稍作调整):

$ python start_with_info.py
The current thread is
 <_MainThread(MainThread, started 140735733822336)>.
Threads: [<_MainThread(MainThread, started 140735733822336)>]
Thread SumProd is alive.
The current thread is <Thread(SumProd, started 123145375604736)>.
Threads: [
 <_MainThread(MainThread, started 140735733822336)>,
 <Thread(SumProd, started 123145375604736)>
]
3+7=10, 3*7=21
Thread SumProd has terminated.

正如你所看到的,一开始当前线程是主线程。枚举只显示一个线程。然后我们创建并启动SumProd。我们打印它的状态,我们得知它还活着。然后,这一次是从SumProd内部,我们再次显示当前线程的信息。当然,现在当前线程是SumProd,我们可以看到枚举所有线程返回了两个。打印结果后,我们通过最后一次对status的调用验证线程是否已经终止,正如预期的那样。如果你得到不同的结果(当然除了线程的 ID 之外),尝试增加睡眠时间,看看是否有任何变化。

启动一个进程

现在让我们看一个等价的例子,但是不使用线程,而是使用进程:

# start_proc.py
import multiprocessing

...

p = multiprocessing.Process(
    target=sum_and_product, name='SumProdProc', args=(7, 9)
)
p.start()

代码与第一个示例完全相同,但我们实例化multiprocessing.Process而不是使用Threadsum_and_product函数与以前相同。输出也是相同的,只是数字不同。

停止线程和进程

如前所述,一般来说,停止线程是一个坏主意,进程也是一样。确保你已经注意到处理和关闭所有打开的东西可能会非常困难。然而,有些情况下你可能希望能够停止一个线程,所以让我告诉你如何做:

# stop.py
import threading
from time import sleep

class Fibo(threading.Thread):
    def __init__(self, *a, **kwa):
        super().__init__(*a, **kwa)
        self._running = True

    def stop(self):
        self._running = False

    def run(self):
        a, b = 0, 1
        while self._running:
            print(a, end=' ')
            a, b = b, a + b
            sleep(0.07)
        print()

fibo = Fibo()
fibo.start()
sleep(1)
fibo.stop()
fibo.join()
print('All done.')

对于这个例子,我们使用一个斐波那契生成器。我们之前见过它,所以我不会解释它。要关注的重要部分是_running属性。首先要注意的是类继承自Thread。通过重写__init__方法,我们可以将_running标志设置为True。当你以这种方式编写线程时,而不是给它一个目标函数,你只需在类中重写run方法。我们的run方法计算一个新的斐波那契数,然后睡眠约0.07秒。

在最后一段代码中,我们创建并启动了一个类的实例。然后我们睡眠一秒钟,这应该给线程时间产生大约 14 个斐波那契数。当我们调用fibo.stop()时,我们实际上并没有停止线程。我们只是将我们的标志设置为False,这允许run中的代码达到自然的结束。这意味着线程将自然死亡。我们调用join来确保线程在我们在控制台上打印All done.之前实际完成。让我们检查输出:

$ python stop.py
0 1 1 2 3 5 8 13 21 34 55 89 144 233
All done.

检查打印了多少个数字:14,正如预期的那样。

这基本上是一种解决技术,允许你停止一个线程。如果你根据多线程范例正确设计你的代码,你就不应该总是不得不杀死线程,所以让这种需要成为你设计更好的警钟。

停止一个进程

当涉及到停止一个进程时,情况就不同了,而且没有麻烦。你可以使用terminatekill方法,但请确保你知道自己在做什么,因为之前关于悬挂的开放资源的所有考虑仍然是正确的。

生成多个线程

只是为了好玩,现在让我们玩两个线程:

# starwars.py
import threading
from time import sleep
from random import random

def run(n):
    t = threading.current_thread()
    for count in range(n):
        print(f'Hello from {t.name}! ({count})')
        sleep(0.2 * random())

obi = threading.Thread(target=run, name='Obi-Wan', args=(4, ))
ani = threading.Thread(target=run, name='Anakin', args=(3, ))
obi.start()
ani.start()
obi.join()
ani.join()

run函数简单地打印当前线程,然后进入n个周期的循环,在循环中打印一个问候消息,并睡眠一个随机的时间,介于00.2秒之间(random()返回一个介于01之间的浮点数)。

这个例子的目的是向你展示调度程序可能在线程之间跳转,所以让它们睡一会儿会有所帮助。让我们看看输出:

$ python starwars.py
Hello from Obi-Wan! (0)
Hello from Anakin! (0)
Hello from Obi-Wan! (1)
Hello from Obi-Wan! (2)
Hello from Anakin! (1)
Hello from Obi-Wan! (3)
Hello from Anakin! (2)

正如你所看到的,输出在两者之间随机交替。每当发生这种情况时,你就知道调度程序已经执行了上下文切换。

处理竞争条件

现在我们有了启动线程和运行它们的工具,让我们模拟一个竞争条件,比如我们之前讨论过的条件:

# race.py
import threading
from time import sleep
from random import random

counter = 0
randsleep = lambda: sleep(0.1 * random())

def incr(n):
    global counter
    for count in range(n):
        current = counter
        randsleep()
        counter = current + 1
        randsleep()

n = 5
t1 = threading.Thread(target=incr, args=(n, ))
t2 = threading.Thread(target=incr, args=(n, ))
t1.start()
t2.start()
t1.join()
t2.join()
print(f'Counter: {counter}')

在这个例子中,我们定义了incr函数,它接收一个数字n作为输入,并循环n次。在每个循环中,它读取计数器的值,通过调用我编写的一个小的 Lambda 函数randsleep来随机休眠一段时间(在00.1秒之间),然后将counter的值增加1

我选择使用global来读/写counter,但实际上可以是任何东西,所以请随意尝试。

整个脚本基本上启动了两个线程,每个线程运行相同的函数,并获得n = 5。请注意,我们需要在最后加入两个线程的连接,以确保当我们打印计数器的最终值(最后一行)时,两个线程都完成了它们的工作。

当我们打印最终值时,我们期望计数器是 10,对吧?两个线程,每个循环五次,这样就是 10。然而,如果我们运行这个脚本,我们几乎永远不会得到 10。我自己运行了很多次,似乎总是在 5 和 7 之间。发生这种情况的原因是这段代码中存在竞争条件,我添加的随机休眠是为了加剧这种情况。如果你删除它们,仍然会存在竞争条件,因为计数器的增加是非原子的(这意味着一个可以被分解成多个步骤的操作,因此在其中间可以暂停)。然而,竞争条件发生的可能性非常低,所以添加随机休眠有所帮助。

让我们分析一下代码。t1获取计数器的当前值,比如3。然后,t1暂停一会儿。如果调度程序在那一刻切换上下文,暂停t1并启动t2t2将读取相同的值3。无论之后发生什么,我们知道两个线程都将更新计数器为4,这是不正确的,因为在两次读取后,它应该已经增加到5。在更新后添加第二个随机休眠调用有助于调度程序更频繁地切换,并且更容易显示竞争条件。尝试注释掉其中一个,看看结果如何改变(它会发生戏剧性的变化)。

现在我们已经确定了问题,让我们通过使用锁来解决它。代码基本上是一样的,所以我只会向您展示发生了什么变化:

# race_with_lock.py
incr_lock = threading.Lock()

def incr(n):
    global counter
    for count in range(n):
        with incr_lock:
            current = counter
            randsleep()
            counter = current + 1
            randsleep()

这一次我们创建了一个锁,来自threading.Lock类。我们可以手动调用它的acquirerelease方法,或者我们可以使用上下文管理器在其中使用它,这看起来更好,而且可以为我们完成整个获取/释放的工作。请注意,我在代码中保留了随机休眠。然而,每次运行它,它现在会返回10

区别在于:当第一个线程获取该锁时,即使它在睡眠时,调度程序稍后切换上下文也无所谓。第二个线程将尝试获取锁,Python 会坚决拒绝。因此,第二个线程将一直等待,直到锁被释放。一旦调度程序切换回第一个线程并释放锁,那么另一个线程将有机会(如果它首先到达那里,这并不一定保证)获取锁并更新计数器。尝试在该逻辑中添加一些打印,看看线程是否完美交替。我猜想它们不会,至少不是每次都会。记住threading.current_thread函数,以便能够看到哪个线程实际上打印了信息。

Python 在threading模块中提供了几种数据结构:Lock、RLock、Condition、Semaphore、Event、Timer 和 Barrier。我无法向您展示所有这些,因为不幸的是,我没有足够的空间来解释所有的用例,但阅读threading模块的文档(docs.python.org/3.7/library/threading.html)将是开始理解它们的好地方。

现在让我们看一个关于线程本地数据的例子。

线程的本地数据

threading模块提供了一种为线程实现本地数据的方法。本地数据是一个保存特定于线程的数据的对象。让我给你展示一个例子,并且让我偷偷加入一个Barrier,这样我就可以告诉你它是如何工作的:

# local.py
import threading
from random import randint

local = threading.local()

def run(local, barrier):
    local.my_value = randint(0, 10**2)
    t = threading.current_thread()
    print(f'Thread {t.name} has value {local.my_value}')
    barrier.wait()
    print(f'Thread {t.name} still has value {local.my_value}')

count = 3
barrier = threading.Barrier(count)
threads = [
    threading.Thread(
        target=run, name=f'T{name}', args=(local, barrier)
    ) for name in range(count)
]
for t in threads:
    t.start()

我们首先定义local。这是保存特定于线程的数据的特殊对象。我们运行三个线程。它们中的每一个都将一个随机值赋给local.my_value,并将其打印出来。然后线程到达一个Barrier对象,它被编程为总共容纳三个线程。当第三个线程碰到屏障时,它们都可以通过。这基本上是一种确保N个线程达到某一点并且它们都等待,直到每一个都到达的好方法。

现在,如果local是一个普通的虚拟对象,第二个线程将覆盖local.my_value的值,第三个线程也会这样做。这意味着我们会看到它们在第一组打印中打印不同的值,但在第二组打印中它们将显示相同的值(最后一个)。但由于local的存在,这种情况不会发生。输出显示如下:

$ python local.py
Thread T0 has value 61
Thread T1 has value 52
Thread T2 has value 38
Thread T2 still has value 38
Thread T0 still has value 61
Thread T1 still has value 52

注意错误的顺序,由于调度程序切换上下文,但所有值都是正确的。

线程和进程通信

到目前为止,我们已经看到了很多例子。所以,让我们探讨如何通过使用队列使线程和进程相互通信。让我们从线程开始。

线程通信

在这个例子中,我们将使用queue模块中的普通Queue

# comm_queue.py
import threading
from queue import Queue

SENTINEL = object()

def producer(q, n):
    a, b = 0, 1
    while a <= n:
        q.put(a)
        a, b = b, a + b
    q.put(SENTINEL)

def consumer(q):
    while True:
        num = q.get()
        q.task_done()
        if num is SENTINEL:
            break
        print(f'Got number {num}')

q = Queue()
cns = threading.Thread(target=consumer, args=(q, ))
prd = threading.Thread(target=producer, args=(q, 35))
cns.start()
prd.start()
q.join()

逻辑非常基本。我们有一个producer函数,它生成斐波那契数并将它们放入队列中。当下一个数字大于给定的n时,生产者退出while循环,并在队列中放入最后一件事:一个SENTINELSENTINEL是用来发出信号的任何对象,在我们的例子中,它向消费者发出信号,表示生产者已经完成。

有趣的逻辑部分在consumer函数中。它无限循环,从队列中读取值并将其打印出来。这里有几件事情需要注意。首先,看看我们如何调用q.task_done()?这是为了确认队列中的元素已被处理。这样做的目的是允许代码中的最后一条指令q.join()在所有元素都被确认时解除阻塞,以便执行可以结束。

其次,注意我们如何使用is运算符来与项目进行比较,以找到哨兵。我们很快会看到,当使用multiprocessing.Queue时,这将不再可能。在我们到达那里之前,你能猜到为什么吗?

运行这个例子会产生一系列行,比如Got number 0Got number 1,依此类推,直到34,因为我们设置的限制是35,下一个斐波那契数将是55

发送事件

另一种使线程通信的方法是触发事件。让我快速给你展示一个例子:

# evt.py
import threading

def fire():
    print('Firing event...')
    event.set()

def listen():
    event.wait()
    print('Event has been fired')

event = threading.Event()
t1 = threading.Thread(target=fire)
t2 = threading.Thread(target=listen)
t2.start()
t1.start()

这里有两个线程分别运行firelisten,分别触发和监听事件。要触发事件,调用set方法。首先启动的t2线程已经在监听事件,直到事件被触发。前面例子的输出如下:

$ python evt.py
Firing event...
Event has been fired

在某些情况下,事件非常有用。想象一下,有一些线程正在等待连接对象准备就绪,然后才能开始使用它。它们可以等待事件,一个线程可以检查该连接,并在准备就绪时触发事件。事件很有趣,所以确保你进行实验,并考虑它们的用例。

使用队列进行进程间通信

让我们现在看看如何使用队列在进程之间进行通信。这个例子非常类似于线程的例子:

# comm_queue_proc.py
import multiprocessing

SENTINEL = 'STOP'

def producer(q, n):
    a, b = 0, 1
    while a <= n:
        q.put(a)
        a, b = b, a + b
    q.put(SENTINEL)

def consumer(q):
    while True:
        num = q.get()
        if num == SENTINEL:
            break
        print(f'Got number {num}')

q = multiprocessing.Queue()
cns = multiprocessing.Process(target=consumer, args=(q, ))
prd = multiprocessing.Process(target=producer, args=(q, 35))
cns.start()
prd.start()

如您所见,在这种情况下,我们必须使用multiprocessing.Queue的实例作为队列,它不公开task_done方法。但是,由于这个队列的设计方式,它会自动加入主线程,因此我们只需要启动两个进程,一切都会正常工作。这个示例的输出与之前的示例相同。

在 IPC 方面,要小心。对象在进入队列时被 pickled,因此 ID 丢失,还有一些其他微妙的事情要注意。这就是为什么在这个示例中,我不能再使用对象作为 sentinel,并使用is进行比较,就像我在多线程版本中所做的那样。这个 sentinel 对象将在队列中被 pickled(因为这次Queue来自multiprocessing而不是之前的queue),并且在 unpickling 后会假定一个新的 ID,无法正确比较。在这种情况下,字符串"STOP"就派上了用场,你需要找到一个适合的 sentinel 值,它需要是永远不会与队列中的任何项目发生冲突的值。我把这留给你去参考文档,并尽可能多地了解这个主题。

队列不是进程之间通信的唯一方式。您还可以使用管道(multiprocessing.Pipe),它提供了从一个进程到另一个进程的连接(显然是管道),反之亦然。您可以在文档中找到大量示例;它们与我们在这里看到的并没有太大的不同。

线程和进程池

如前所述,池是设计用来保存N个对象(线程、进程等)的结构。当使用达到容量时,不会将工作分配给线程(或进程),直到其中一个当前正在工作的线程再次可用。因此,池是限制同时可以活动的线程(或进程)数量的绝佳方式,防止系统因资源耗尽而饥饿,或者计算时间受到过多的上下文切换的影响。

在接下来的示例中,我将利用concurrent.futures模块来使用ThreadPoolExecutorProcessPoolExecutor执行器。这两个类使用线程池(和进程池),以异步方式执行调用。它们都接受一个参数max_workers,它设置了执行器同时可以使用多少个线程(或进程)的上限。

让我们从多线程示例开始:

# pool.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from random import randint
import threading

def run(name):
    value = randint(0, 10**2)
    tname = threading.current_thread().name
    print(f'Hi, I am {name} ({tname}) and my value is {value}')
    return (name, value)

with ThreadPoolExecutor(max_workers=3) as executor:
    futures = [
        executor.submit(run, f'T{name}') for name in range(5)
    ]
    for future in as_completed(futures):
        name, value = future.result()
        print(f'Thread {name} returned {value}')

在导入必要的部分之后,我们定义了run函数。它获取一个随机值,打印它,并返回它,以及它被调用时的name参数。有趣的部分就在函数之后。

如您所见,我们使用上下文管理器调用ThreadPoolExecutor,我们传递max_workers=3,这意味着池大小为3。这意味着任何时候只有三个线程是活动的。

我们通过使用列表推导式定义了一个未来对象列表,在其中我们在执行器对象上调用submit。我们指示执行器运行run函数,名称将从T0T4future是一个封装可调用异步执行的对象。

然后我们循环遍历future对象,因为它们已经完成。为此,我们使用as_completed来获取future实例的迭代器,它们在完成(完成或被取消)时立即返回。我们通过调用同名方法来获取每个future的结果,并简单地打印它。鉴于run返回一个元组namevalue,我们期望结果是包含namevalue的两元组。如果我们打印run的输出(请记住每个run可能略有不同),我们会得到:

$ python pool.py
Hi, I am T0 (ThreadPoolExecutor-0_0) and my value is 5
Hi, I am T1 (ThreadPoolExecutor-0_0) and my value is 23
Hi, I am T2 (ThreadPoolExecutor-0_1) and my value is 58
Thread T1 returned 23
Thread T0 returned 5
Hi, I am T3 (ThreadPoolExecutor-0_0) and my value is 93
Hi, I am T4 (ThreadPoolExecutor-0_1) and my value is 62
Thread T2 returned 58
Thread T3 returned 93
Thread T4 returned 62

在继续阅读之前,你能告诉我为什么输出看起来像这样吗?你能解释发生了什么吗?花点时间思考一下。

所以,发生的是三个线程开始运行,所以我们得到三个“嗨,我是…”消息被打印出来。一旦它们都在运行,池就满了,所以我们需要等待至少一个线程完成,然后才能发生其他事情。在示例运行中,T0 和 T2 完成了(这是通过打印它们返回的内容来表示),所以它们返回到池中可以再次使用。它们被命名为 T3 和 T4,并最终所有三个 T1、T3 和 T4 都完成了。您可以从输出中看到线程是如何被实际重用的,以及前两个在完成后如何被重新分配给 T3 和 T4。

现在让我们看看相同的例子,但使用多进程设计:

# pool_proc.py
from concurrent.futures import ProcessPoolExecutor, as_completed
from random import randint
from time import sleep

def run(name):
    sleep(.05)
    value = randint(0, 10**2)
    print(f'Hi, I am {name} and my value is {value}')
    return (name, value)

with ProcessPoolExecutor(max_workers=3) as executor:
    futures = [
        executor.submit(run, f'P{name}') for name in range(5)
    ]
    for future in as_completed(futures):
        name, value = future.result()
        print(f'Process {name} returned {value}')

差异真的是微乎其微。这次我们使用 ProcessPoolExecutor,并且 run 函数完全相同,只是增加了一个小细节:在每次运行开始时我们休眠 50 毫秒。这是为了加剧行为并清楚地显示池的大小,仍然是三。如果我们运行示例,我们得到:

$ python pool_proc.py
Hi, I am P0 and my value is 19
Hi, I am P1 and my value is 97
Hi, I am P2 and my value is 74
Process P0 returned 19
Process P1 returned 97
Process P2 returned 74
Hi, I am P3 and my value is 80
Hi, I am P4 and my value is 68
Process P3 returned 80
Process P4 returned 68

这个输出清楚地显示了池的大小为三。有趣的是,如果我们去掉对 sleep 的调用,大多数情况下输出将有五次打印“嗨,我是…”,然后是五次打印“进程 Px 返回…”。我们如何解释这个呢?很简单。当前三个进程完成并由 as_completed 返回时,所有三个都被要求返回它们的结果,无论返回什么,都会被打印出来。在这个过程中,执行器已经可以开始回收两个进程来运行最后两个任务,它们恰好在允许 for 循环中的打印发生之前打印它们的“嗨,我是…”消息。

这基本上意味着 ProcessPoolExecutor 非常快速和积极(在获取调度程序的注意方面),值得注意的是,这种行为在线程对应的情况下不会发生,如果您还记得,我们不需要使用任何人为的睡眠。

然而,要记住的重要事情是,即使是这样简单的例子,也可能稍微难以理解或解释。让这成为你的一课,这样你在为多线程或多进程设计编码时就能提高你的注意力到 110%。

现在让我们转到一个更有趣的例子。

使用一个过程为函数添加超时

大多数,如果不是所有,公开函数以进行 HTTP 请求的库,在执行请求时提供指定超时的能力。这意味着如果在X秒后(X是超时时间),请求还没有完成,整个操作将被中止,并且执行将从下一条指令继续。不过,并非所有函数都提供这个功能,所以当一个函数没有提供中断的能力时,我们可以使用一个过程来模拟这种行为。在这个例子中,我们将尝试将主机名翻译成 IPv4 地址。然而,socket 模块的 gethostbyname 函数不允许我们在操作上设置超时,所以我们使用一个过程来人为地实现。接下来的代码可能不那么直接,所以我鼓励您在阅读解释之前花一些时间去理解它:

# hostres/util.py
import socket
from multiprocessing import Process, Queue

def resolve(hostname, timeout=5):
    exitcode, ip = resolve_host(hostname, timeout)
    if exitcode == 0:
        return ip
    else:
        return hostname

def resolve_host(hostname, timeout):
    queue = Queue()
    proc = Process(target=gethostbyname, args=(hostname, queue))
    proc.start()
    proc.join(timeout=timeout)

    if queue.empty():
        proc.terminate()
        ip = None
    else:
        ip = queue.get()
    return proc.exitcode, ip

def gethostbyname(hostname, queue):
    ip = socket.gethostbyname(hostname)
    queue.put(ip)

让我们从 resolve 开始。它只是接受一个主机名和一个超时时间,并用它们调用 resolve_host。如果退出代码是 0(这意味着进程正确终止),它返回对应于该主机的 IPv4。否则,它将主机名本身作为后备机制返回。

接下来,让我们谈谈 gethostbyname。它接受一个主机名和一个队列,并调用 socket.gethostbyname 来解析主机名。当结果可用时,它被放入队列。现在问题就出在这里。如果对 socket.gethostbyname 的调用时间超过我们想要分配的超时时间,我们需要终止它。

resolve_host函数正是这样做的。它接收hostnametimeout,起初只是创建一个queue。然后它生成一个以gethostbynametarget的新进程,并传递适当的参数。然后启动进程并加入,但带有一个timeout

现在,成功的情况是这样的:对socket.gethostbyname的调用很快成功,IP 在队列中,进程在超时时间之前成功终止,当我们到达if部分时,队列不会为空。我们从中获取 IP,并返回它,以及进程退出代码。

在失败的情况下,对socket.gethostbyname的调用时间太长,进程在超时后被终止。因为调用失败,没有 IP 被插入到队列中,因此队列将为空。在if逻辑中,我们将 IP 设置为None,并像以前一样返回。resolve函数会发现退出代码不是0(因为进程不是幸福地终止,而是被杀死),并且将正确地返回主机名而不是 IP,我们无论如何都无法获取 IP。

在本章的源代码中,在本章的hostres文件夹中,我添加了一些测试,以确保这种行为是正确的。你可以在文件夹中的README.md文件中找到如何运行它们的说明。确保你也检查一下测试代码,它应该会很有趣。

案例示例

在本章的最后部分,我将向你展示三个案例,我们将看到如何通过采用不同的方法(单线程、多线程和多进程)来做同样的事情。最后,我将专门介绍asyncio,这是一个在 Python 中引入另一种异步编程方式的模块。

例一 - 并发归并排序

第一个例子将围绕归并排序算法展开。这种排序算法基于“分而治之”设计范式。它的工作方式非常简单。你有一个要排序的数字列表。第一步是将列表分成两部分,对它们进行排序,然后将结果合并成一个排序好的列表。让我用六个数字举个简单的例子。假设我们有一个列表,v=[8, 5, 3, 9, 0, 2]。第一步是将列表v分成两个包含三个数字的子列表:v1=[8, 5, 3]v2=[9, 0, 2]。然后我们通过递归调用归并排序对v1v2进行排序。结果将是v1=[3, 5, 8]v2=[0, 2, 9]。为了将v1v2合并成一个排序好的v,我们只需考虑两个列表中的第一个项目,并选择其中的最小值。第一次迭代会比较30。我们选择0,留下v2=[2, 9]。然后我们重复这个过程:比较32,我们选择2,现在v2=[9]。然后我们比较39。这次我们选择3,留下v1=[5, 8],依此类推。接下来我们会选择559比较),然后选择889比较),最后选择9。这将给我们一个新的、排序好的vv=[0, 2, 3, 5, 8, 9]

我选择这个算法作为例子的原因有两个。首先,它很容易并行化。你将列表分成两部分,让两个进程对它们进行处理,然后收集结果。其次,可以修改算法,使其将初始列表分成任意N ≥ 2,并将这些部分分配给N个进程。重新组合就像处理两个部分一样简单。这个特性使它成为并发实现的一个很好的候选。

单线程归并排序

让我们看看所有这些是如何转化为代码的,首先学习如何编写我们自己的自制mergesort

# ms/algo/mergesort.py
def sort(v):
    if len(v) <= 1:
        return v
    mid = len(v) // 2
    v1, v2 = sort(v[:mid]), sort(v[mid:])
    return merge(v1, v2)

def merge(v1, v2):
    v = []
    h = k = 0
    len_v1, len_v2 = len(v1), len(v2)
    while h < len_v1 or k < len_v2:
        if k == len_v2 or (h < len_v1 and v1[h] < v2[k]):
            v.append(v1[h])
            h += 1
        else:
            v.append(v2[k])
            k += 1
    return v

让我们从sort函数开始。首先,我们遇到递归的基础,它说如果列表有01个元素,我们不需要对其进行排序,我们可以直接返回它。如果不是这种情况,我们计算中点(mid),并在v[:mid]v[mid:]上递归调用 sort。我希望你现在对切片语法非常熟悉,但以防万一你需要复习一下,第一个是v中到mid索引(不包括)的所有元素,第二个是从mid到末尾的所有元素。排序它们的结果分别分配给v1v2。最后,我们调用merge,传递v1v2

merge的逻辑使用两个指针hk来跟踪我们已经比较了v1v2中的哪些元素。如果我们发现最小值在v1中,我们将其附加到v,并增加h。另一方面,如果最小值在v2中,我们将其附加到v,但这次增加k。该过程在一个while循环中运行,其条件与内部的if结合在一起,确保我们不会因为索引超出范围而出现错误。这是一个非常标准的算法,在网上可以找到许多不同的变体。

为了确保这段代码是可靠的,我编写了一个测试套件,位于ch10/ms文件夹中。我鼓励你去看一下。

现在我们有了构建模块,让我们看看如何修改它,使其能够处理任意数量的部分。

单线程多部分归并排序

算法的多部分版本的代码非常简单。我们可以重用merge函数,但我们需要重新编写sort函数:

# ms/algo/multi_mergesort.py
from functools import reduce
from .mergesort import merge

def sort(v, parts=2):
    assert parts > 1, 'Parts need to be at least 2.'
    if len(v) <= 1:
        return v

    chunk_len = max(1, len(v) // parts)
    chunks = (
        sort(v[k: k + chunk_len], parts=parts)
        for k in range(0, len(v), chunk_len)
    )
    return multi_merge(*chunks)

def multi_merge(*v):
    return reduce(merge, v)

我们在第四章中看到了reduce函数,代码的构建模块,当我们编写我们自己的阶乘函数时。它在multi_merge中的工作方式是合并v中的前两个列表。然后将结果与第三个合并,之后将结果与第四个合并,依此类推。

看一下sort的新版本。它接受v列表和我们想要将其分割成的部分数。我们首先检查我们传递了一个正确的parts数,它至少需要是两个。然后,就像以前一样,我们有递归的基础。最后,我们进入函数的主要逻辑,这只是前一个例子中看到的逻辑的多部分版本。我们使用max函数计算每个chunk的长度,以防列表中的元素少于部分数。然后,我们编写一个生成器表达式,对每个chunk递归调用sort。最后,我们通过调用multi_merge合并所有的结果。

我意识到在解释这段代码时,我没有像我通常那样详尽,我担心这是有意的。在归并排序之后的例子将会更加复杂,所以我想鼓励你尽可能彻底地理解前两个片段。

现在,让我们将这个例子推进到下一步:多线程。

多线程归并排序

在这个例子中,我们再次修改sort函数,这样,在初始分成块之后,它会为每个部分生成一个线程。每个线程使用单线程版本的算法来对其部分进行排序,然后最后我们使用多重归并技术来计算最终结果。翻译成 Python:

# ms/algo/mergesort_thread.py
from functools import reduce
from math import ceil
from concurrent.futures import ThreadPoolExecutor, as_completed
from .mergesort import sort as _sort, merge

def sort(v, workers=2):
    if len(v) == 0:
        return v
    dim = ceil(len(v) / workers)
    chunks = (v[k: k + dim] for k in range(0, len(v), dim))
    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = [
            executor.submit(_sort, chunk) for chunk in chunks
        ]
        return reduce(
            merge,
            (future.result() for future in as_completed(futures))
        )

我们导入所有必需的工具,包括执行器、ceiling函数,以及从单线程版本的算法中导入的sortmerge。请注意,我在导入时将单线程的sort的名称更改为_sort

在这个版本的sort中,我们首先检查v是否为空,如果不是,我们继续。我们使用ceil函数计算每个chunk的维度。它基本上做的是我们在前面片段中使用max的事情,但我想向你展示另一种解决问题的方法。

当我们有了维度,我们计算chunks并准备一个漂亮的生成器表达式来将它们提供给执行器。其余部分很简单:我们定义了一个未来对象列表,每个未来对象都是在执行器上调用submit的结果。每个未来对象在分配给它的chunk上运行单线程的_sort算法。

最后,当它们被as_completed函数返回时,结果将使用我们在之前的多部分示例中看到的相同技术进行合并。

多进程归并排序

为了执行最后一步,我们只需要修改前面代码中的两行。如果你在介绍性的例子中注意到了,你会知道我指的是哪两行。为了节省一些空间,我只会给你代码的差异:

# ms/algo/mergesort_proc.py
...
from concurrent.futures import ProcessPoolExecutor, as_completed
...

def sort(v, workers=2):
    ...
    with ProcessPoolExecutor(max_workers=workers) as executor:
    ...

就是这样!你所要做的就是使用ProcessPoolExecutor而不是ThreadPoolExecutor,而不是生成线程,你正在生成进程。

你还记得我说过进程实际上可以在不同的核心上运行,而线程在同一个进程中运行,因此它们实际上并不是并行运行吗?这是一个很好的例子,向你展示选择其中一种方法的后果。因为代码是 CPU 密集型的,没有进行 IO 操作,分割列表并让线程处理块并没有任何优势。另一方面,使用进程有优势。我进行了一些性能测试(自己运行ch10/ms/performance.py模块,你会看到你的机器的性能如何),结果证明了我的期望:

$ python performance.py

Testing Sort
Size: 100000
Elapsed time: 0.492s
Size: 500000
Elapsed time: 2.739s

Testing Sort Thread
Size: 100000
Elapsed time: 0.482s
Size: 500000
Elapsed time: 2.818s

Testing Sort Proc
Size: 100000
Elapsed time: 0.313s
Size: 500000
Elapsed time: 1.586s

这两个测试分别在两个包含 10 万和 50 万个项目的列表上运行。我为多线程和多进程版本使用了四个工作进程。在寻找模式时,使用不同的大小非常有用。正如你所看到的,前两个版本(单线程和多线程)的时间消耗基本相同,但在多进程版本中减少了约 50%。这略高于 50%,因为生成进程并处理它们是有代价的。但是,你肯定会欣赏到我在我的机器上有一个有两个内核的处理器。

这也告诉你,即使我在多进程版本中使用了四个工作进程,我仍然只能按比例并行化我的处理器核心数量。因此,两个或更多的工作进程几乎没有什么区别。

现在你已经热身了,让我们继续下一个例子。

第二个例子 - 批量数独求解器

在这个例子中,我们将探索一个数独求解器。我们不会详细讨论它,因为重点不是理解如何解决数独,而是向你展示如何使用多进程来解决一批数独谜题。

在这个例子中有趣的是,我们不再比较单线程和多线程版本,而是跳过这一点,将单线程版本与两个不同的多进程版本进行比较。一个将分配一个谜题给每个工作进程,所以如果我们解决了 1,000 个谜题,我们将使用 1,000 个工作进程(好吧,我们将使用一个* N *工作进程池,每个工作进程都在不断回收)。另一个版本将把初始批次的谜题按照池的大小进行划分,并在一个进程内批量解决每个块。这意味着,假设池的大小为四,将这 1,000 个谜题分成每个 250 个谜题的块,并将每个块分配给一个工作进程,总共有四个工作进程。

我将向您展示数独求解器的代码(不包括多进程部分),这是由 Peter Norvig 设计的解决方案,根据 MIT 许可证进行分发。他的解决方案非常高效,以至于在尝试重新实现自己的解决方案几天后,得到了相同的结果,我简单地放弃了并决定采用他的设计。不过,我进行了大量的重构,因为我对他选择的函数和变量名不满意,所以我将它们更改为更符合书本风格的名称。您可以在ch10/sudoku/norvig文件夹中找到原始代码、获取原始页面的链接以及原始的 MIT 许可证。如果您跟随链接,您将找到 Norvig 本人对数独求解器的非常详尽的解释。

什么是数独?

首先来看看。什么是数独谜题?数独是一种基于逻辑的数字填充谜题,起源于日本。目标是用数字填充9x9网格,使得每行、每列和每个3x3子网格(组成网格的子网格)都包含从19的所有数字。您从一个部分填充的网格开始,然后根据逻辑考虑逐渐添加数字。

从计算机科学的角度来看,数独可以被解释为一个适合exact cover类别的问题。唐纳德·克努斯,计算机编程艺术的作者(以及许多其他精彩的书籍),设计了一个算法,称为Algorithm X,用于解决这一类问题。一种名为Dancing Links的美丽而高效的 Algorithm X 实现,利用了循环双向链表的强大功能,可以用来解决数独。这种方法的美妙之处在于,它只需要数独的结构与 Dancing Links 算法之间的映射,而无需进行通常需要解决难题的逻辑推断,就能以光速到达解决方案。

许多年前,当我的空闲时间大于零时,我用 C#编写了一个 Dancing Links 数独求解器,我仍然在某个地方存档着,设计和编码过程非常有趣。我绝对鼓励您查阅相关文献并编写自己的求解器,如果您有时间的话,这是一个很好的练习。

在本例的解决方案中,我们将使用与人工智能中的约束传播相结合的搜索算法。这两种方法通常一起使用,使问题更容易解决。我们将看到在我们的例子中,它们足以让我们在几毫秒内解决一个困难的数独。

在 Python 中实现数独求解器

现在让我们来探索我重构后的求解器实现。我将分步向您展示代码,因为它非常复杂(而且在每个片段的顶部我不会重复源名称,直到我转移到另一个模块):

# sudoku/algo/solver.py
import os
from itertools import zip_longest, chain
from time import time

def cross_product(v1, v2):
    return [w1 + w2 for w1 in v1 for w2 in v2]

def chunk(iterable, n, fillvalue=None):
    args = [iter(iterable)] * n
    return zip_longest(*args, fillvalue=fillvalue)

我们从一些导入开始,然后定义了一些有用的函数:cross_productchunk。它们确实做了名称所暗示的事情。第一个函数返回两个可迭代对象之间的叉积,而第二个函数返回iterable的一系列块,每个块都有n个元素,最后一个块可能会用给定的fillvalue填充,如果iterable的长度不是n的倍数。然后我们继续定义一些结构,这些结构将被求解器使用:

digits = '123456789'
rows = 'ABCDEFGHI'
cols = digits
squares = cross_product(rows, cols)
all_units = (
    [cross_product(rows, c) for c in cols]
    + [cross_product(r, cols) for r in rows]
    + [cross_product(rs, cs)
        for rs in chunk(rows, 3) for cs in chunk(cols, 3)]
)
units = dict(
    (square, [unit for unit in all_units if square in unit])
    for square in squares
)
peers = dict(
    (square, set(chain(*units[square])) - set([square]))
    for square in squares
)

不详细展开,让我们简单介绍一下这些对象。squares是网格中所有方块的列表。方块由诸如A3C7之类的字符串表示。行用字母编号,列用数字编号,因此A3表示第一行第三列的方块。

all_units是所有可能的行、列和块的列表。每个元素都表示为属于行/列/块的方格的列表。units是一个更复杂的结构。它是一个有 81 个键的字典。每个键代表一个方格,相应的值是一个包含三个元素的列表:行、列和块。当然,这些是方格所属的行、列和块。

最后,peers是一个与units非常相似的字典,但每个键的值(仍然表示一个方格)是一个包含该方格的所有对等方格的集合。对等方格被定义为属于键中的方格所属的行、列和块的所有方格。这些结构将在解决谜题时用于计算解决方案。

在我们看一下解析输入行的函数之前,让我给你一个输入谜题的例子:

1..3.......75...3..3.4.8.2...47....9.........689....4..5..178.4.....2.75.......1.

前九个字符代表第一行,然后另外九个代表第二行,依此类推。空方格用点表示:

def parse_puzzle(puzzle):
    assert set(puzzle) <= set('.0123456789')
    assert len(puzzle) == 81

    grid = dict((square, digits) for square in squares)
    for square, digit in zip(squares, puzzle):
        if digit in digits and not place(grid, square, digit):
            return False  # Incongruent puzzle
    return grid

def solve(puzzle):
    grid = parse_puzzle(puzzle)
    return search(grid)

这个简单的parse_puzzle函数用于解析输入的谜题。我们在开始时进行了一些合理性检查,断言输入的谜题必须缩小为所有数字加一个点的子集。然后我们确保有 81 个输入字符,最后我们定义了grid,最初它只是一个有 81 个键的字典,每个键都是一个方格,都具有相同的值,即所有可能数字的字符串。这是因为在完全空的网格中,一个方格有潜力成为 1 到 9 之间的任何数字。

for循环绝对是最有趣的部分。我们解析输入谜题中的每个 81 个字符,将它们与网格中相应的方格相结合,并尝试“放置”它们。我用双引号括起来,因为正如我们将在一会儿看到的,place函数做的远不止简单地在给定的方格中设置一个给定的数字。如果我们发现无法在输入谜题中放置一个数字,这意味着输入无效,我们返回False。否则,我们可以继续并返回grid

parse_puzzle函数用于solve函数中,它简单地解析输入的谜题,并在其上释放search。因此,接下来的内容是算法的核心:

def search(grid):
    if not grid:
        return False
    if all(len(grid[square]) == 1 for square in squares):
        return grid  # Solved
    values, square = min(
        (len(grid[square]), square) for square in squares
        if len(grid[square]) > 1
    )
    for digit in grid[square]:
        result = search(place(grid.copy(), square, digit))
        if result:
            return result

这个简单的函数首先检查网格是否真的非空。然后它尝试查看网格是否已解决。已解决的网格将每个方格都有一个值。如果不是这种情况,它会循环遍历每个方格,并找到具有最少候选项的方格。如果一个方格的字符串值只有一个数字,这意味着一个数字已经放在了那个方格中。但如果值超过一个数字,那么这些就是可能的候选项,所以我们需要找到具有最少候选项的方格,并尝试它们。尝试一个有 23 个候选项的方格要比尝试一个有 23589 个候选项的方格好得多。在第一种情况下,我们有 50%的机会得到正确的值,而在第二种情况下,我们只有 20%。选择具有最少候选项的方格因此最大化了我们在网格中放置好数字的机会。

一旦找到候选项,我们按顺序尝试它们,如果其中任何一个成功,我们就解决了网格并返回。您可能已经注意到在搜索中使用了place函数。因此,让我们来探索它的代码:

def place(grid, square, digit):
    """Eliminate all the other values (except digit) from
    grid[square] and propagate.
    Return grid, or False if a contradiction is detected.
    """
    other_vals = grid[square].replace(digit, '')
    if all(eliminate(grid, square, val) for val in other_vals):
        return grid
    return False

这个函数接受一个正在进行中的网格,并尝试在给定的方格中放置一个给定的数字。正如我之前提到的,“放置”并不那么简单。事实上,当我们放置一个数字时,我们必须在整个网格中传播该行为的后果。我们通过调用eliminate函数来做到这一点,该函数应用数独游戏的两种策略:

  • 如果一个方格只有一个可能的值,就从该方格的对等方格中消除该值

  • 如果一个单元只有一个值的位置,就把值放在那里

让我简要地举个例子。对于第一个点,如果你在一个方块中放入数字 7,那么你可以从属于该行、列和块的所有方块的候选数字列表中删除 7。

对于第二点,假设你正在检查第四行,而属于它的所有方块中,只有一个方块的候选数字中有数字 7。这意味着数字 7 只能放在那个确切的方块中,所以你应该继续把它放在那里。

接下来的函数eliminate应用了这两条规则。它的代码相当复杂,所以我没有逐行解释,而是添加了一些注释,留给你去理解:

def eliminate(grid, square, digit):
    """Eliminate digit from grid[square]. Propagate when candidates
    are <= 2.
    Return grid, or False if a contradiction is detected.
    """
    if digit not in grid[square]:
        return grid  # already eliminated
    grid[square] = grid[square].replace(digit, '')

    ## (1) If a square is reduced to one value, eliminate value
    ## from peers.
    if len(grid[square]) == 0:
        return False  # nothing left to place here, wrong solution
    elif len(grid[square]) == 1:
        value = grid[square]
        if not all(
            eliminate(grid, peer, value) for peer in peers[square]
        ):
            return False

    ## (2) If a unit is reduced to only one place for a value,
    ## then put it there.
    for unit in units[square]:
        places = [sqr for sqr in unit if digit in grid[sqr]]
        if len(places) == 0:
            return False  # No place for this value
        elif len(places) == 1:
            # digit can only be in one place in unit,
            # assign it there
            if not place(grid, places[0], digit):
                return False
    return grid

模块中的其他函数对于本例来说并不重要,所以我会跳过它们。你可以单独运行这个模块;它首先对其数据结构进行一系列检查,然后解决我放在sudoku/puzzles文件夹中的所有数独难题。但这不是我们感兴趣的,对吧?我们想要看看如何使用多进程技术解决数独,所以让我们开始吧。

使用多进程解决数独

在这个模块中,我们将实现三个函数。第一个函数简单地解决一批数独难题,没有涉及多进程。我们将使用结果进行基准测试。第二个和第三个函数将使用多进程,一个是批量解决,一个是非批量解决,这样我们可以欣赏到它们之间的差异。让我们开始吧:

# sudoku/process_solver.py
import os
from functools import reduce
from operator import concat
from math import ceil
from time import time
from contextlib import contextmanager
from concurrent.futures import ProcessPoolExecutor, as_completed
from unittest import TestCase
from algo.solver import solve

@contextmanager
def timer():
    t = time()
    yield
    tot = time() - t
    print(f'Elapsed time: {tot:.3f}s')

经过一长串的导入后,我们定义了一个上下文管理器,我们将用它作为计时器。它获取当前时间的引用(t),然后进行 yield。在 yield 之后,才执行上下文管理器的主体。最后,在退出上下文管理器时,我们计算总共经过的时间tot,并打印出来。这是一个简单而优雅的上下文管理器,使用了装饰技术编写,非常有趣。现在让我们看看前面提到的三个函数:

def batch_solve(puzzles):
    # Single thread batch solve.
    return [solve(puzzle) for puzzle in puzzles]

这是一个单线程的简单批量求解器,它将给我们一个用于比较的时间。它只是返回所有已解决的网格的列表。无聊。现在,看看下面的代码:

def parallel_single_solver(puzzles, workers=4):
    # Parallel solve - 1 process per each puzzle
    with ProcessPoolExecutor(max_workers=workers) as executor:
        futures = (
            executor.submit(solve, puzzle) for puzzle in puzzles
        )
        return [
            future.result() for future in as_completed(futures)
        ]

这个函数好多了。它使用ProcessPoolExecutor来使用一个workers池,每个池用于解决大约四分之一的难题。这是因为我们为每个难题生成一个future对象。逻辑与本章中已经看到的任何多进程示例非常相似。现在让我们看看第三个函数:

def parallel_batch_solver(puzzles, workers=4):
    # Parallel batch solve - Puzzles are chunked into `workers`
    # chunks. A process for each chunk.
    assert len(puzzles) >= workers
    dim = ceil(len(puzzles) / workers)
    chunks = (
        puzzles[k: k + dim] for k in range(0, len(puzzles), dim)
    )
    with ProcessPoolExecutor(max_workers=workers) as executor:
        futures = (
            executor.submit(batch_solve, chunk) for chunk in chunks
        )
        results = (
            future.result() for future in as_completed(futures)
        )
        return reduce(concat, results)

最后一个函数略有不同。它不是为每个难题生成一个future对象,而是将所有难题的列表分成workers块,然后为每一块创建一个future对象。这意味着如果workers为八,我们将生成八个future对象。请注意,我们不是将solve传递给executor.submit,而是传递batch_solve,这就是诀窍所在。我之所以编写最后两个函数如此不同,是因为我很好奇我们从池中重复使用进程时所产生的开销的严重程度。

现在我们已经定义了这些函数,让我们使用它们:

puzzles_file = os.path.join('puzzles', 'sudoku-topn234.txt')
with open(puzzles_file) as stream:
    puzzles = [puzzle.strip() for puzzle in stream]

# single thread solve
with timer():
    res_batch = batch_solve(puzzles)

# parallel solve, 1 process per puzzle
with timer():
    res_parallel_single = parallel_single_solver(puzzles)

# parallel batch solve, 1 batch per process
with timer():
    res_parallel_batch = parallel_batch_solver(puzzles)

# Quick way to verify that the results are the same, but
# possibly in a different order, as they depend on how the
# processes have been scheduled.
assert_items_equal = TestCase().assertCountEqual
assert_items_equal(res_batch, res_parallel_single)
assert_items_equal(res_batch, res_parallel_batch)
print('Done.')

我们使用了一组 234 个非常难的数独难题进行基准测试。正如你所看到的,我们只是在一个计时上下文中运行了三个函数,batch_solveparallel_single_solverparallel_batch_solver。我们收集结果,并且为了确保,我们验证所有运行是否产生了相同的结果。

当然,在第二次和第三次运行中,我们使用了多进程,所以我们不能保证结果的顺序与单线程batch_solve的顺序相同。这个小问题通过assertCountEqual得到了很好的解决,这是 Python 标准库中命名最糟糕的方法之一。我们可以在TestCase类中找到它,我们可以实例化它来引用我们需要的方法。我们实际上并没有运行单元测试,但这是一个很酷的技巧,我想向你展示一下。让我们看看运行这个模块的输出:

$ python process_solver.py
Elapsed time: 5.368s
Elapsed time: 2.856s
Elapsed time: 2.818s
Done. 

哇。这非常有趣。首先,你可以再次看到我的机器有一个双核处理器,因为多进程运行的时间大约是单线程求解器所花时间的一半。然而,更有趣的是,两个多进程函数所花费的时间基本上没有区别。多次运行有时候会偏向一种方法,有时候会偏向另一种方法。要理解原因需要对参与游戏的所有组件有深入的了解,而不仅仅是进程,因此这不是我们可以在这里讨论的事情。不过,可以相当肯定的是,这两种方法在性能方面是可比较的。

在这本书的源代码中,你可以在sudoku文件夹中找到测试,并附有运行说明。花点时间去查看一下吧!

现在,让我们来看最后一个例子。

第三个例子 - 下载随机图片

这个例子编写起来很有趣。我们将从网站上下载随机图片。我会向你展示三个版本:一个串行版本,一个多进程版本,最后一个使用asyncio编写的解决方案。在这些例子中,我们将使用一个名为lorempixel.com的网站,它提供了一个 API,你可以调用它来获取随机图片。如果你发现该网站宕机或运行缓慢,你可以使用一个很好的替代网站:lorempizza.com/

这可能是一个意大利人写的书的陈词滥调,但图片确实很漂亮。如果你想玩得开心,可以在网上寻找其他选择。无论你选择哪个网站,请理智一点,尽量不要通过发出一百万个请求来使其崩溃。这段代码的多进程和asyncio版本可能会相当激进!

让我们先来探索单线程版本的代码:

# aio/randompix_serial.py
import os
from secrets import token_hex
import requests

PICS_FOLDER = 'pics'
URL = 'http://lorempixel.com/640/480/'

def download(url):
    resp = requests.get(URL)
    return save_image(resp.content)

def save_image(content):
    filename = '{}.jpg'.format(token_hex(4))
    path = os.path.join(PICS_FOLDER, filename)
    with open(path, 'wb') as stream:
        stream.write(content)
    return filename

def batch_download(url, n):
    return [download(url) for _ in range(n)]

if __name__ == '__main__':
    saved = batch_download(URL, 10)
    print(saved)

现在这段代码对你来说应该很简单了。我们定义了一个download函数,它向给定的URL发出请求,通过调用save_image保存结果,并将来自网站响应的主体传递给它。保存图片非常简单:我们使用token_hex创建一个随机文件名,只是因为这样很有趣,然后计算文件的完整路径,以二进制模式创建文件,并将响应的内容写入其中。我们返回filename以便在屏幕上打印它。最后,batch_download只是运行我们想要运行的n个请求,并将文件名作为结果返回。

你现在可以跳过if __name__ ...这一行,它将在第十二章中解释,GUIs and Scripts,这里并不重要。我们所做的就是调用batch_download并告诉它下载10张图片。如果你有编辑器,打开pics文件夹,你会看到它在几秒钟内被填充(还要注意:脚本假设pics文件夹存在)。

让我们加点料。让我们引入多进程(代码基本相似,所以我就不重复了):

# aio/randompix_proc.py
...
from concurrent.futures import ProcessPoolExecutor, as_completed
...

def batch_download(url, n, workers=4):
    with ProcessPoolExecutor(max_workers=workers) as executor:
        futures = (executor.submit(download, url) for _ in range(n))
        return [future.result() for future in as_completed(futures)]

...

这种技术现在对你来说应该很熟悉。我们只是将作业提交给执行器,并在结果可用时收集它们。因为这是 IO 绑定的代码,所以进程工作得相当快,而在进程等待 API 响应时,有大量的上下文切换。如果你查看pics文件夹,你会注意到它不再是线性地填充,而是分批次地填充。

现在让我们看看这个例子的asyncio版本。

使用 asyncio 下载随机图片

这段代码可能是整个章节中最具挑战性的,所以如果此刻对你来说太多了,不要感到难过。我添加了这个例子,只是作为一种引人入胜的手段,鼓励你深入了解 Python 异步编程的核心。另一个值得知道的是,可能有几种其他编写相同逻辑的方式,所以请记住,这只是可能的例子之一。

asyncio模块提供了基础设施,用于使用协程编写单线程并发代码,多路复用 IO 访问套接字和其他资源,运行网络客户端和服务器,以及其他相关原语。它在 Python 3.4 版本中添加,有人声称它将成为未来编写 Python 代码的事实标准。我不知道这是否属实,但我知道它绝对值得看一个例子:

# aio/randompix_corout.py
import os
from secrets import token_hex
import asyncio
import aiohttp

首先,我们不能再使用requests,因为它不适用于asyncio。我们必须使用aiohttp,所以请确保你已经安装了它(它在这本书的要求中):

PICS_FOLDER = 'pics'
URL = 'http://lorempixel.com/640/480/'

async def download_image(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.read()

之前的代码看起来不太友好,但一旦你了解了背后的概念,就不会那么糟糕。我们定义了异步协程download_image,它以 URL 作为参数。

如果你不知道,协程是一种计算机程序组件,它通过允许在特定位置挂起和恢复执行来概括非抢占式多任务处理的子例程。子例程是作为一个单元打包的执行特定任务的程序指令序列。

download_image中,我们使用ClientSession上下文管理器创建一个会话对象,然后通过使用另一个上下文管理器session.get获取响应。这些管理器被定义为异步的事实意味着它们能够在它们的enterexit方法中暂停执行。我们使用await关键字返回响应的内容,这允许暂停。请注意,为每个请求创建一个会话并不是最佳的,但是为了这个例子的目的,我觉得保持代码尽可能简单,所以将其优化留给你作为一个练习。

让我们继续下一个片段:

async def download(url, semaphore):
    async with semaphore:
        content = await download_image(url)
    filename = save_image(content)
    return filename

def save_image(content):
    filename = '{}.jpg'.format(token_hex(4))
    path = os.path.join(PICS_FOLDER, filename)
    with open(path, 'wb') as stream:
        stream.write(content)
    return filename

另一个协程download获取一个URL和一个信号量。它所做的就是获取图像的内容,通过调用download_image保存它,并返回文件名。这里有趣的地方是使用了信号量。我们将其用作异步上下文管理器,以便我们也可以暂停这个协程,并允许切换到其他东西,但更重要的不是如何,而是理解为什么我们要使用信号量。原因很简单,这个信号量有点类似于线程池。我们使用它来允许最多N个协程同时活动。我们在下一个函数中实例化它,并将 10 作为初始值传递。每当一个协程获取信号量时,它的内部计数器就会减少1,因此当有 10 个协程获取它时,下一个协程将会等待,直到信号量被一个已经完成的协程释放。这是一个不错的方式,试图限制我们从网站 API 中获取图像的侵略性。

save_image函数不是一个协程,它的逻辑已经在之前的例子中讨论过。现在让我们来到执行代码的部分:

def batch_download(images, url):
    loop = asyncio.get_event_loop()
    semaphore = asyncio.Semaphore(10)
    cors = [download(url, semaphore) for _ in range(images)]
    res, _ = loop.run_until_complete(asyncio.wait(cors))
    loop.close()
    return [r.result() for r in res]

if __name__ == '__main__':
    saved = batch_download(20, URL)
    print(saved)

我们定义了batch_download函数,它接受一个数字images和要获取它们的 URL。它做的第一件事是创建一个事件循环,这是运行任何异步代码所必需的。事件循环是asyncio提供的中央执行设备。它提供了多种设施,包括:

  • 注册、执行和取消延迟调用(超时)

  • 为各种通信创建客户端和服务器传输

  • 启动子进程和与外部程序通信的相关传输

  • 将昂贵的函数调用委托给线程池

事件循环创建后,我们实例化信号量,然后继续创建一个期货列表cors。通过调用loop.run_until_complete,我们确保事件循环将一直运行,直到整个任务完成。我们将其喂给asyncio.wait的调用结果,它等待期货完成。

完成后,我们关闭事件循环,并返回每个期货对象产生的结果列表(保存图像的文件名)。请注意我们如何捕获对loop.run_until_complete的调用结果。我们并不真正关心错误,所以我们将第二个元组项赋值为_。这是一个常见的 Python 习惯用法,用于表明我们对该对象不感兴趣。

在模块的最后,我们调用batch_download,并保存了 20 张图片。它们分批次到达,整个过程受到只有 10 个可用位置的信号量的限制。

就是这样!要了解更多关于asyncio的信息,请参阅标准库中asyncio模块的文档页面(docs.python.org/3.7/library/asyncio.html)。这个例子编码起来很有趣,希望它能激励你努力学习并理解 Python 这一美妙的一面的复杂性。

总结

在本章中,我们学习了并发和并行。我们看到了线程和进程如何帮助实现其中的一个和另一个。我们探讨了线程的性质以及它们暴露给我们的问题:竞争条件和死锁。

我们学会了如何通过使用锁和谨慎的资源管理来解决这些问题。我们还学会了如何使线程通信和共享数据,并讨论了调度程序,即操作系统决定任何给定时间运行哪个线程的部分。然后我们转向进程,并探讨了它们的一些属性和特征。

在最初的理论部分之后,我们学会了如何在 Python 中实现线程和进程。我们处理了多个线程和进程,解决了竞争条件,并学会了防止线程错误地留下任何资源的解决方法。我们还探讨了 IPC,并使用队列在进程和线程之间交换消息。我们还使用了事件和屏障,这些是标准库提供的一些工具,用于在非确定性环境中控制执行流程。

在所有这些介绍性示例之后,我们深入研究了三个案例示例,展示了如何使用不同的方法解决相同的问题:单线程、多线程、多进程和asyncio

我们学习了归并排序以及通常分而治之算法易于并行化。

我们学习了关于数独,并探讨了一种使用少量人工智能来运行高效算法的好方法,然后我们以不同的串行和并行模式运行了它。

最后,我们看到了如何使用串行、多进程和asyncio代码从网站上下载随机图片。后者无疑是整本书中最难的代码,它在本章中的存在是作为一种提醒,或者一种里程碑,鼓励读者深入学习 Python。

现在我们将转向更简单的、大多数是项目导向的章节,我们将在不同的背景下尝试不同的真实世界应用。

第十一章:调试和故障排除

“如果调试是消除软件错误的过程,那么编程一定是引入错误的过程。”- Edsger W. Dijkstra

在专业程序员的生活中,调试和故障排除占据了相当大的时间。即使你在人类编写的最美丽的代码库上工作,仍然会有错误;这是肯定的。

在我的观点中,一个优秀的软件开发人员是一个即使在阅读没有报告错误或错误的代码时也能保持高度关注的人。

能够高效快速地调试代码是每个程序员都需要不断提高的技能。有些人认为因为他们已经阅读了手册,所以没问题,但现实是,游戏中的变量数量如此之大,以至于没有手册。有一些指导方针可以遵循,但没有一本魔法书会教你所有你需要知道的东西,以便成为这方面的专家。

在这个特定的主题上,我觉得我从同事那里学到了最多。观察一个非常熟练的人攻击问题让我感到惊讶。我喜欢看到他们采取的步骤,验证排除可能的原因,以及他们考虑嫌疑人的方式,最终导致他们找到解决方案。

我们与之合作的每个同事都可以教给我们一些东西,或者用一个最终证明是正确的奇妙猜测让我们感到惊讶。当这种情况发生时,不要只停留在惊讶中(或者更糟糕的是嫉妒),而是抓住这一刻,问问他们是如何猜到的,以及为什么。答案将让你看到是否有一些东西你可以后来深入研究,也许下一次,你就是那个发现问题的人。

有些错误很容易发现。它们是由粗心的错误造成的,一旦你看到这些错误的影响,很容易找到解决问题的方法。

但还有其他一些错误要微妙得多,更加难以捉摸,需要真正的专业知识,以及大量的创造力和超越常规的思维来处理。

对我来说,最糟糕的是那些不确定的错误。有时会发生,有时不会。有些只在环境 A 中发生,但在环境 B 中却没有,尽管 A 和 B 应该是完全相同的。这些错误是真正邪恶的,它们会让你发疯。

当然,错误不仅仅发生在沙盒中,对吧?当你的老板告诉你,“别担心!花点时间解决这个问题。先吃午饭!”的时候,不。它们发生在星期五下午五点半,当你的大脑已经烧坏,你只想回家的时候。就在那些每个人都在瞬间变得沮丧的时刻,当你的老板在你身边喘着气的时候,你必须能够保持冷静。我是认真的。如果你让自己的大脑感到紧张,那么创造性思维、逻辑推理以及你在那一刻所需要的一切都会消失。所以深呼吸,端正坐姿,集中注意力。

在这一章中,我将尝试演示一些有用的技术,根据错误的严重程度,以及一些建议,希望能够增强你对错误和问题的解决能力。

具体来说,我们将看一下以下内容:

  • 调试技术

  • 性能分析

  • 断言

  • 故障排除指南

调试技术

在这部分,我将向你介绍最常见的技术,我经常使用的技术;但是,请不要认为这个列表是详尽无遗的。

使用打印进行调试

这可能是所有技术中最简单的技术。它并不是非常有效,不能在所有地方使用,需要同时访问源代码和一个能运行它的终端(因此显示print函数调用结果)。

然而,在许多情况下,这仍然是一种快速和有用的调试方式。例如,如果你正在开发一个 Django 网站,页面上发生的情况与你的预期不符,你可以在视图中填充打印,并在重新加载页面时留意控制台。当你在代码中散布调用print时,通常会出现这样的情况,你会重复大量的调试代码,要么是因为你正在打印时间戳(就像我们在测量列表推导和生成器的速度时所做的那样),要么是因为你不得不以某种方式构建一个你想要显示的字符串。

另一个问题是,在你的代码中很容易忘记调用print

因此,出于这些原因,我有时候更喜欢编写自定义函数,而不是直接调用print。让我们看看如何做。

使用自定义函数进行调试

在一个片段中有一个自定义函数,你可以快速抓取并粘贴到代码中,然后用于调试,这是非常有用的。如果你很快,你总是可以即兴编写一个。重要的是以一种不会在最终删除调用和定义时留下东西的方式编写它。因此以一种完全自包含的方式编写它是很重要的。这个要求的另一个很好的理由是它将避免与代码的其余部分潜在的名称冲突。

让我们看一个这样的函数的例子:

# custom.py
def debug(*msg, print_separator=True):
    print(*msg)
    if print_separator:
        print('-' * 40)

debug('Data is ...')
debug('Different', 'Strings', 'Are not a problem')
debug('After while loop', print_separator=False)

在这种情况下,我使用了一个仅限关键字的参数,以便能够打印一个分隔符,这是一个由40个破折号组成的行。

这个函数非常简单。我只是将msg中的任何内容重定向到对print的调用,如果print_separatorTrue,我会打印一条分隔线。运行代码将显示以下内容:

$ python custom.py
Data is ...
----------------------------------------
Different Strings Are not a problem
----------------------------------------
After while loop

正如你所看到的,最后一行后面没有分隔符。

这只是一种简单的方法,以某种方式增强对print函数的简单调用。让我们看看如何利用 Python 的一个棘手特性来计算调用之间的时间差:

# custom_timestamp.py
from time import sleep

def debug(*msg, timestamp=[None]):
    print(*msg)
    from time import time  # local import
    if timestamp[0] is None:
        timestamp[0] = time()  #1
    else:
        now = time()
        print(
            ' Time elapsed: {:.3f}s'.format(now - timestamp[0])
        )
        timestamp[0] = now  #2

debug('Entering nasty piece of code...')
sleep(.3)
debug('First step done.')
sleep(.5)
debug('Second step done.')

这有点棘手,但仍然相当简单。首先,注意我们从debug函数内部的time模块中导入time函数。这使我们避免了在函数外部添加该导入,也许会忘记在那里添加。

看一下我是如何定义timestamp的。当然,它是一个列表,但这里重要的是它是一个可变对象。这意味着当 Python 解析函数时,它将被设置,并且在不同的调用中保留其值。因此,如果我们在每次调用后都放一个时间戳,我们就可以跟踪时间,而不必使用外部全局变量。我从我的闭包研究中借鉴了这个技巧,我鼓励你去了解一下,因为它非常有趣。

好了,所以,在打印出我们必须打印的任何消息和一些导入时间之后,我们检查timestamp中的唯一项的内容。如果它是None,我们没有先前的引用,因此我们将值设置为当前时间(#1)。

另一方面,如果我们有一个先前的引用,我们可以计算一个差值(我们很好地格式化为三个小数位),然后我们最终再次将当前时间放入timestamp#2)。这是一个很好的技巧,不是吗?

运行这段代码会显示以下结果:

$ python custom_timestamp.py
Entering nasty piece of code...
First step done.
 Time elapsed: 0.304s
Second step done.
 Time elapsed: 0.505s

无论你的情况如何,拥有一个像这样的自包含函数可能非常有用。

检查回溯

我们在第八章中简要讨论了回溯,测试、分析和处理异常,当我们看到了几种不同类型的异常。回溯提供了关于应用程序出了什么问题的信息。阅读它是有帮助的,所以让我们看一个小例子:

# traceback_simple.py
d = {'some': 'key'}
key = 'some-other'
print(d[key])

我们有一个字典,我们尝试访问其中不存在的键。你应该记住这将引发一个KeyError异常。让我们运行代码:

$ python traceback_simple.py
Traceback (most recent call last):
 File "traceback_simple.py", line 3, in <module>
 print(d[key])
KeyError: 'some-other'

您可以看到我们获得了所有需要的信息:模块名称,导致错误的行(数字和指令),以及错误本身。有了这些信息,您可以返回到源代码并尝试理解发生了什么。

现在让我们创建一个更有趣的例子,基于此构建,并练习 Python 3 中才有的一个特性。假设我们正在验证一个字典,处理必填字段,因此我们希望它们存在。如果没有,我们需要引发一个自定义的ValidationError,我们将在运行验证器的过程中进一步捕获它(这里没有显示,所以它可能是任何东西)。应该是这样的:

# traceback_validator.py
class ValidatorError(Exception):
    """Raised when accessing a dict results in KeyError. """

d = {'some': 'key'}
mandatory_key = 'some-other'
try:
    print(d[mandatory_key])
except KeyError as err:
    raise ValidatorError(
        f'`{mandatory_key}` not found in d.'
    ) from err

我们定义了一个自定义异常,当必需的键不存在时会引发该异常。请注意,它的主体由其文档字符串组成,因此我们不需要添加任何其他语句。

非常简单,我们定义了一个虚拟字典,并尝试使用mandatory_key访问它。当发生KeyError时,我们捕获并引发ValidatorError。我们通过使用 Python 3 中由 PEP 3134(www.python.org/dev/peps/pep-3134/)引入的raise ... from ...语法来实现这一点,以链接异常。这样做的目的是,我们可能还想在其他情况下引发ValidatorError,不一定是由于缺少必需的键而引起的。这种技术允许我们在一个简单的try/except中运行验证,只关心ValidatorError

如果不能链接异常,我们将丢失关于KeyError的信息。代码产生了这个结果:

$ python traceback_validator.py
Traceback (most recent call last):
 File "traceback_validator.py", line 7, in <module>
 print(d[mandatory_key])
KeyError: 'some-other'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
 File "traceback_validator.py", line 10, in <module>
 '`{}` not found in d.'.format(mandatory_key)) from err
__main__.ValidatorError: `some-other` not found in d.

这很棒,因为我们可以看到导致我们引发ValidationError的异常的回溯,以及ValidationError本身的回溯。

我和我的一位审阅者就pip安装程序产生的回溯进行了很好的讨论。他在设置一切以便审查第十三章 数据科学的代码时遇到了麻烦。他的新的 Ubuntu 安装缺少一些pip软件包所需的库,以便正确运行。

他被阻止的原因是,他试图修复回溯中显示的错误,从顶部开始。我建议他从底部开始,然后修复。原因是,如果安装程序已经到达最后一行,我猜在那之前,无论发生了什么错误,仍然有可能从中恢复。只有在最后一行之后,pip决定无法继续下去,因此我开始修复那个错误。一旦安装了修复该错误所需的库,其他一切都顺利进行。

阅读回溯可能会很棘手,我的朋友缺乏解决这个问题所需的经验。因此,如果您也遇到了同样的情况。不要灰心,试着摇动一下,不要想当然。

Python 有一个庞大而美妙的社区,很少有可能当您遇到问题时,您是第一个遇到它的人,所以打开浏览器并搜索。通过这样做,您的搜索技能也会得到提高,因为您将不得不将错误减少到最小但必要的详细信息集,以使您的搜索有效。

如果您想更好地玩耍和理解回溯,标准库中有一个模块可以使用,惊喜惊喜,名为traceback。它提供了一个标准接口,用于提取、格式化和打印 Python 程序的堆栈跟踪,模仿 Python 解释器在打印堆栈跟踪时的行为。

使用 Python 调试器

调试 Python 的另一个非常有效的方法是使用 Python 调试器:pdb。不过,您应该绝对检查pdbpp库,而不是直接使用它。pdbpp通过提供一些方便的工具来增强标准的pdb接口,其中我最喜欢的是粘性模式,它允许您在逐步执行其指令时查看整个函数。

有几种不同的使用调试器的方法(无论哪个版本,都不重要),但最常见的一种方法是简单地设置一个断点并运行代码。当 Python 达到断点时,执行将被暂停,并且您可以访问该点的控制台,以便您可以检查所有名称等。您还可以即时更改数据以改变程序的流程。

作为一个玩具示例,假设我们有一个解析器,因为字典中缺少一个键而引发KeyError。字典来自我们无法控制的 JSON 有效负载,我们只是想暂时欺骗并通过控制,因为我们对之后发生的事情感兴趣。让我们看看我们如何能拦截这一刻,检查数据,修复它,并深入了解,使用pdbpp

# pdebugger.py
# d comes from a JSON payload we don't control
d = {'first': 'v1', 'second': 'v2', 'fourth': 'v4'}
# keys also comes from a JSON payload we don't control
keys = ('first', 'second', 'third', 'fourth')

def do_something_with_value(value):
    print(value)

for key in keys:
    do_something_with_value(d[key])

print('Validation done.')

正如您所看到的,当key获得'third'值时,代码将中断,这个值在字典中缺失。请记住,我们假装dkeys都是动态来自我们无法控制的 JSON 有效负载,因此我们需要检查它们以修复d并通过for循环。如果我们按原样运行代码,我们会得到以下结果:

$ python pdebugger.py
v1
v2
Traceback (most recent call last):
 File "pdebugger.py", line 10, in <module>
 do_something_with_value(d[key])
KeyError: 'third'

所以我们看到字典中缺少key,但由于每次运行此代码时我们可能会得到不同的字典或keys元组,这些信息并不能真正帮助我们。让我们在for循环之前注入一个pdb调用。您有两个选择:

import pdb
pdb.set_trace()

这是最常见的方法。您导入pdb并调用其set_trace方法。许多开发人员在其编辑器中有宏,可以通过键盘快捷键添加此行。不过,从 Python 3.7 开始,我们甚至可以进一步简化事情,变成这样:

breakpoint()

新的breakpoint内置函数在底层调用sys.breakpointhook(),默认情况下编程为调用pdb.set_trace()。但是,您可以重新编程sys.breakpointhook()来调用任何您想要的东西,因此breakpoint也将指向那个东西,这非常方便。

此示例的代码位于pdebugger_pdb.py模块中。如果我们现在运行此代码,事情变得有趣起来(请注意,您的输出可能会有所不同,本输出中的所有注释都是我添加的):

$ python pdebugger_pdb.py
(Pdb++) l
 16
 17 -> for key in keys:  # breakpoint comes in
 18 do_something_with_value(d[key])
 19

(Pdb++) keys  # inspecting the keys tuple
('first', 'second', 'third', 'fourth')
(Pdb++) d.keys()  # inspecting keys of `d`
dict_keys(['first', 'second', 'fourth'])
(Pdb++) d['third'] = 'placeholder'  # add tmp placeholder
(Pdb++) c  # continue
v1
v2
placeholder
v4
Validation done.

首先,请注意,当您达到断点时,会收到一个控制台,告诉您您所在的位置(Python 模块)以及下一行要执行的行。在这一点上,您可以执行一系列的探索性操作,比如检查下一行之前和之后的代码,打印堆栈跟踪,并与对象交互。请参考官方 Python 文档(docs.python.org/3.7/library/pdb.html)上的pdb,了解更多信息。在我们的例子中,我们首先检查keys元组。之后,我们检查d的键。我们发现'third'缺失了,所以我们自己放进去(这可能危险—想一想)。最后,现在所有的键都在了,我们输入c,表示(c)继续。

pdb还可以让您逐行执行代码,使用(n)下一步,深入分析函数,或使用(b)断点处理。有关命令的完整列表,请参考文档或在控制台中输入(h)帮助。

您可以看到,从前面的运行输出中,我们最终可以到达验证的结尾。

pdb(或pdbpp)是我每天都使用的宝贵工具。所以,去玩耍吧,设置一个断点,尝试检查它,按照官方文档尝试在您的代码中使用命令,看看它们的效果并好好学习。

请注意,在此示例中,我假设您已安装了pdbpp。如果不是这样,那么您可能会发现一些命令在pdb中不起作用。一个例子是字母d,在pdb中会被解释为down命令。为了解决这个问题,您需要在d前面加上!,告诉pdb它应该被字面解释,而不是作为命令。

检查日志文件

调试一个行为异常的应用程序的另一种方法是检查其日志文件。日志文件是特殊的文件,应用程序会在其中记录各种事情,通常与其内部发生的事情有关。如果重要的过程开始了,我通常期望在日志中有相应的记录。当它结束时也是一样,可能还有它内部发生的事情。

错误需要被记录下来,这样当出现问题时,我们可以通过查看日志文件中的信息来检查出错的原因。

在 Python 中有许多不同的设置记录器的方法。日志记录非常灵活,可以进行配置。简而言之,通常有四个角色:记录器、处理程序、过滤器和格式化程序:

  • 记录器:公开应用程序代码直接使用的接口

  • 处理程序:将日志记录(由记录器创建)发送到适当的目的地

  • 过滤器:提供了一个更精细的设施,用于确定要输出哪些日志记录

  • 格式化程序:指定最终输出中日志记录的布局

记录是通过调用Logger类的实例的方法来执行的。您记录的每一行都有一个级别。通常使用的级别有:DEBUGINFOWARNINGERRORCRITICAL。您可以从logging模块中导入它们。它们按严重程度排序,正确使用它们非常重要,因为它们将帮助您根据您要搜索的内容过滤日志文件的内容。日志文件通常变得非常庞大,因此将其中的信息正确地写入非常重要,这样在需要时您可以快速找到它。

您可以记录到文件,也可以记录到网络位置,队列,控制台等。一般来说,如果您的架构部署在一台机器上,记录到文件是可以接受的,但当您的架构跨越多台机器(比如面向服务或微服务架构的情况下),实现一个集中的日志记录解决方案非常有用,这样每个服务产生的所有日志消息都可以存储和调查在一个地方。否则,尝试从几个不同来源的巨大文件中找出问题发生了什么可能会变得非常具有挑战性。

面向服务的架构(SOA)是软件设计中的一种架构模式,其中应用程序组件通过通信协议向其他组件提供服务,通常通过网络。这个系统的美妙之处在于,当编写正确时,每个服务都可以用最合适的语言来实现其目的。唯一重要的是与其他服务的通信,这需要通过一个共同的格式进行,以便进行数据交换。

微服务架构是 SOA 的演变,但遵循一组不同的架构模式。

在这里,我将向您介绍一个非常简单的日志记录示例。我们将向文件记录一些消息:

# log.py
import logging

logging.basicConfig(
    filename='ch11.log',
    level=logging.DEBUG,  # minimum level capture in the file
    format='[%(asctime)s] %(levelname)s: %(message)s',
    datefmt='%m/%d/%Y %I:%M:%S %p')

mylist = [1, 2, 3]
logging.info('Starting to process `mylist`...')

for position in range(4):
    try:
        logging.debug(
            'Value at position %s is %s', position, mylist[position]
        )
    except IndexError:
        logging.exception('Faulty position: %s', position)

logging.info('Done parsing `mylist`.')

让我们逐行进行。首先,我们导入logging模块,然后设置基本配置。一般来说,生产日志配置比这复杂得多,但我想尽可能简单。我们指定一个文件名,我们想要在文件中捕获的最低日志级别,以及消息格式。我们将记录日期和时间信息、级别和消息。

我将从记录一个告诉我我们即将处理列表的info消息开始。然后,我将记录(这次使用DEBUG级别,使用debug函数)某个位置的值。我在这里使用debug,因为我希望能够在将来过滤这些日志(通过将最低级别设置为logging.INFO或更高),因为我可能必须处理非常大的列表,而我不想记录所有的值。

如果我们得到IndexError(我们确实得到了,因为我正在循环遍历range(4)),我们调用logging.exception(),它与logging.error()相同,但还会打印出回溯。

在代码的结尾,我记录了另一个info消息,说我们已经完成了。结果是这样的:

# ch11.log
[05/06/2018 11:13:48 AM] INFO:Starting to process `mylist`...
[05/06/2018 11:13:48 AM] DEBUG:Value at position 0 is 1
[05/06/2018 11:13:48 AM] DEBUG:Value at position 1 is 2
[05/06/2018 11:13:48 AM] DEBUG:Value at position 2 is 3
[05/06/2018 11:13:48 AM] ERROR:Faulty position: 3
Traceback (most recent call last):
  File "log.py", line 15, in <module>
    position, mylist[position]))
IndexError: list index out of range
[05/06/2018 11:13:48 AM] INFO:Done parsing `mylist`.

这正是我们需要的,可以调试在服务器上运行而不是在我们的控制台上运行的应用程序。我们可以看到发生了什么,引发的任何异常的回溯等等。

这里介绍的示例只是日志记录的皮毛。要获得更深入的解释,您可以在官方 Python 文档的Python HOWTOs部分找到信息:日志记录 HOWTO日志记录 Cookbook

日志记录是一门艺术。您需要在记录所有内容和不记录任何内容之间找到一个良好的平衡。理想情况下,您应该记录任何需要确保应用程序正常工作的内容,以及可能的所有错误或异常。

其他技术

在这最后一节中,我想简要演示一些您可能会发现有用的技术。

分析

我们在第八章中讨论了分析,测试、分析和处理异常,我在这里提到它只是因为分析有时可以解释由于组件过慢而导致的奇怪错误。特别是涉及网络时,了解应用程序需要经历的时间和延迟非常重要,以便在出现问题时了解可能发生了什么,因此我建议您熟悉分析技术,也从故障排除的角度来看。

断言

断言是确保代码验证您的假设的一种好方法。如果是,一切都会正常进行,但如果不是,您会得到一个很好的异常,可以处理。有时,与其检查,不如在代码中放置一些断言来排除可能性更快。让我们看一个例子:

# assertions.py
mylist = [1, 2, 3]  # this ideally comes from some place
assert 4 == len(mylist)  # this will break
for position in range(4):
    print(mylist[position])

这段代码模拟了一个情况,即mylist并非由我们定义,但我们假设它有四个元素。因此我们在那里放置了一个断言,结果是这样的:

$ python assertions.py
Traceback (most recent call last):
 File "assertions.py", line 3, in <module>
 assert 4 == len(mylist)  # this will break
AssertionError

这告诉我们问题出在哪里。

查找信息的位置

在 Python 官方文档中,有一个专门介绍调试和分析的部分,您可以在那里了解bdb调试器框架,以及诸如faulthandlertimeittracetracemallock和当然pdb等模块。只需转到文档中的标准库部分,您就可以非常容易地找到所有这些信息。

故障排除指南

在这个简短的部分中,我想给您一些建议,这些建议来自我的故障排除经验。

使用控制台编辑器

首先,要熟练使用Vimnano作为编辑器,并学习控制台的基础知识。当事情出错时,您就没有您的编辑器带来的所有便利了。您必须连接到服务器并从那里工作。因此,熟练使用控制台命令浏览生产环境,并能够使用基于控制台的编辑器编辑文件,比如 vi、Vim 或 nano,是一个非常好的主意。不要让您通常的开发环境宠坏了您。

检查的位置

我的第二个建议涉及在哪里放置调试断点。无论您使用print、自定义函数还是pdb,您仍然必须选择在哪里放置提供信息的调用,对吧?

有些地方比其他地方更好,有些处理调试进展的方法比其他方法更好。

我通常不会在if子句中设置断点,因为如果该子句没有执行,我就失去了获取所需信息的机会。有时很难或很快到达断点,所以在设置断点之前请仔细考虑。

另一件重要的事情是从哪里开始。想象一下,您有 100 行代码来处理您的数据。数据从第 1 行进入,但在第 100 行出现错误。您不知道错误在哪里,那么该怎么办呢?您可以在第 1 行设置断点,耐心地检查所有行,检查您的数据。在最坏的情况下,99 行(和许多杯咖啡)后,您找到了错误。因此,请考虑使用不同的方法。

您从第 50 行开始,然后进行检查。如果数据正常,这意味着错误发生在后面,这种情况下,您将在第 75 行设置下一个断点。如果第 50 行的数据已经出错,您将在第 25 行设置断点。然后,您重复这个过程。每次,您要么向后移动,要么向前移动,跳过上次的一半。

在最坏的情况下,您的调试将从 1、2、3、...、99 以线性方式进行,变成一系列跳跃,如 50、75、87、93、96、...、99,速度要快得多。事实上,这是对数的。这种搜索技术称为二分搜索,它基于分而治之的方法,非常有效,因此请尽量掌握它。

使用测试进行调试

您还记得第八章吗,测试、性能分析和处理异常,关于测试?如果我们有一个错误,而所有测试都通过了,这意味着我们的测试代码库中有问题或遗漏。因此,一种方法是修改测试,以便它们适应已经发现的新边缘情况,然后逐步检查代码。这种方法非常有益,因为它确保在修复错误时,您的错误将被测试覆盖。

监控

监控也非常重要。软件应用程序可能会在遇到边缘情况时变得完全疯狂,并且在网络中断、队列已满或外部组件无响应等情况下出现非确定性的故障。在这些情况下,重要的是要了解问题发生时的整体情况,并能够以微妙、甚至神秘的方式将其与相关的内容联系起来。

您可以监视 API 端点、进程、网页可用性和加载时间,基本上几乎可以监视您可以编码的所有内容。一般来说,从头开始设计应用程序时,考虑如何监视它可能非常有用。

总结

在这个简短的章节中,我们探讨了不同的调试和故障排除技术和建议。调试是软件开发人员工作中始终存在的活动,因此擅长调试非常重要。

如果以正确的态度对待,调试可以是有趣和有益的。

我们探讨了检查我们的代码库的技术,包括函数、日志记录、调试器、回溯信息、性能分析和断言。我们看到了它们大部分的简单示例,我们还谈到了一套指导方针,将在面对困难时提供帮助。

只要记住始终保持冷静和专注,调试就会变得更容易。这也是一种需要学习的技能,也是最重要的。激动和紧张的心态无法正常、逻辑和创造性地工作,因此,如果您不加强它,很难将所有知识充分利用。

在下一章中,我们将探讨 GUI 和脚本,从更常见的 Web 应用程序场景中进行有趣的偏离。

第十二章:GUI 和脚本

“用户界面就像一个笑话。如果你不得不解释它,那就不是那么好。”– Martin LeBlanc

在本章中,我们将一起开展一个项目。我们将编写一个简单的抓取器,用于查找和保存网页中的图像。我们将专注于三个部分:

  • Python 中的简单 HTTP 网络服务器

  • 一个用于抓取给定 URL 的脚本

  • 一个 GUI 应用程序,用于抓取给定 URL

图形用户界面GUI)是一种允许用户通过图形图标、按钮和小部件与电子设备进行交互的界面类型,与需要在键盘上键入命令或文本的基于文本或命令行的界面相对。简而言之,任何浏览器,任何办公套件(如 LibreOffice)以及一般情况下,任何在单击图标时弹出的东西都是 GUI 应用程序。

因此,如果您还没有这样做,现在是在名为ch12的文件夹中的项目根目录中启动控制台并定位的绝佳时机。在该文件夹中,我们将创建两个 Python 模块(scrape.pyguiscrape.py)和一个文件夹(simple_server)。在simple_server中,我们将编写我们的 HTML 页面:index.html。图像将存储在simple_server/img中。

ch12中的结构应该是这样的:

$ tree -A
.
├── guiscrape.py
├── scrape.py
└── simple_server
 ├── img
 │ ├── owl-alcohol.png
 │ ├── owl-book.png
 │ ├── owl-books.png
 │ ├── owl-ebook.jpg
 │ └── owl-rose.jpeg
 ├── index.html
 └── serve.sh

如果您使用的是 Linux 或 macOS,您可以像我一样将启动 HTTP 服务器的代码放在一个名为serve.sh的文件中。在 Windows 上,您可能想使用批处理文件。

我们要抓取的 HTML 页面具有以下结构:

# simple_server/index.html
<!DOCTYPE html>
<html lang="en">
  <head><title>Cool Owls!</title></head>
  <body>
    <h1>Welcome to my owl gallery</h1>
    <div>
      <img src="img/owl-alcohol.png" height="128" />
      <img src="img/owl-book.png" height="128" />
      <img src="img/owl-books.png" height="128" />
      <img src="img/owl-ebook.jpg" height="128" />
      <img src="img/owl-rose.jpeg" height="128" />
    </div>
    <p>Do you like my owls?</p>
  </body>
</html>

这是一个非常简单的页面,所以我们只需要注意一下,我们有五张图片,其中三张是 PNG 格式,两张是 JPG 格式(请注意,尽管它们都是 JPG 格式,但一张以.jpg结尾,另一张以.jpeg结尾,这两种都是此格式的有效扩展名)。

因此,Python 为您提供了一个非常简单的免费 HTTP 服务器,您可以使用以下命令启动它(在simple_server文件夹中):

$ python -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [06/May/2018 16:54:30] "GET / HTTP/1.1" 200 -
...

最后一行是当您访问http://localhost:8000时得到的日志,我们美丽的页面将在那里提供。或者,您可以将该命令放在一个名为serve.sh的文件中,并使用以下命令运行它(确保它是可执行的):

$ ./serve.sh

它将产生相同的效果。如果您有本书的代码,您的页面应该看起来像这样:

随意使用任何其他图像集,只要您至少使用一个 PNG 和一个 JPG,并且在src标签中使用相对路径而不是绝对路径。我从openclipart.org/获取了这些可爱的猫头鹰。

第一种方法 - 脚本

现在,让我们开始编写脚本。我将分三步讲解源代码:导入、解析参数和业务逻辑。

导入

脚本的开始部分如下:

# scrape.py
import argparse
import base64
import json
import os
from bs4 import BeautifulSoup
import requests

从顶部开始浏览它们,您会发现我们需要解析参数,然后将其提供给脚本本身(argparse)。我们将需要base64库来将图像保存在 JSON 文件中(json),并且我们需要打开文件进行写入(os)。最后,我们需要BeautifulSoup来轻松抓取网页,以及requests来获取其内容。我假设您熟悉requests,因为我们在之前的章节中使用过它。

我们将在《第十四章》Web Development中探讨 HTTP 协议和requests机制,所以现在,让我们简单地说,我们执行一个 HTTP 请求来获取网页的内容。我们可以使用库(如requests)以编程方式执行此操作,这更或多是相当于在浏览器中输入 URL 并按下Enter(然后浏览器获取网页内容并将其显示给您)。

所有这些导入中,只有最后两个不属于 Python 标准库,所以请确保您已经安装了它们:

$ pip freeze | egrep -i "soup|requests"
beautifulsoup4==4.6.0
requests==2.18.4

当然,版本号可能对您来说是不同的。如果它们没有安装,请使用此命令进行安装:

$ pip install beautifulsoup4==4.6.0 requests==2.18.4

在这一点上,我认为可能会让您困惑的唯一事情是base64/json对,所以请允许我花几句话来解释。

正如我们在上一章中看到的,JSON 是应用程序之间数据交换的最流行格式之一。它也被广泛用于其他目的,例如在文件中保存数据。在我们的脚本中,我们将为用户提供将图像保存为图像文件或 JSON 单个文件的功能。在 JSON 中,我们将放置一个字典,其中键是图像名称,值是它们的内容。唯一的问题是以二进制格式保存图像很棘手,这就是base64库发挥作用的地方。

base64库实际上非常有用。例如,每次您发送带有附加图像的电子邮件时,图像在发送电子邮件之前都会使用base64进行编码。在接收方端,图像会自动解码为其原始二进制格式,以便电子邮件客户端可以显示它们。

解析参数

既然技术问题已经解决,让我们看看我们脚本的第二部分(应该在scrape.py模块的末尾):

if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description='Scrape a webpage.')
    parser.add_argument(
        '-t',
        '--type',
        choices=['all', 'png', 'jpg'],
        default='all',
        help='The image type we want to scrape.')
    parser.add_argument(
        '-f',
        '--format',
        choices=['img', 'json'],
        default='img',
        help='The format images are _saved to.')
    parser.add_argument(
        'url',
        help='The URL we want to scrape for images.')
    args = parser.parse_args()
    scrape(args.url, args.format, args.type)

看看第一行;这是脚本编写时非常常见的习语。根据官方 Python 文档,'__main__'字符串是顶层代码执行的范围名称。当从标准输入、脚本或交互式提示中读取时,模块的__name__被设置为'__main__'

因此,如果您将执行逻辑放在if下面,它将仅在直接运行脚本时运行,因为其__name__将为'__main__'。另一方面,如果您从此模块导入,则其名称将设置为其他内容,因此if下的逻辑将不会运行。

我们要做的第一件事是定义我们的解析器。我建议使用标准库模块argparse,它足够简单且功能强大。还有其他选择,但在这种情况下,argparse将为我们提供所需的一切。

我们希望向我们的脚本提供三种不同的数据:我们要保存的图像类型,我们要保存它们的格式以及要抓取的页面的 URL。

类型可以是 PNG、JPG 或两者(默认),而格式可以是图像或 JSON,图像是默认值。URL 是唯一的强制参数。

因此,我们添加了-t选项,还允许长版本--type。选择是'all''png''jpg'。我们将默认设置为'all'并添加一个help消息。

我们对format参数执行类似的过程,允许使用短语法和长语法(-f--format),最后我们添加url参数,这是唯一一个以不同方式指定的参数,因此它不会被视为选项,而是作为位置参数。

为了解析所有参数,我们只需要parser.parse_args()。非常简单,不是吗?

最后一行是我们触发实际逻辑的地方,通过调用scrape函数,传递我们刚刚解析的所有参数。我们很快将看到它的定义。argparse的好处是,如果通过传递-h调用脚本,它将自动为您打印一个漂亮的使用文本。让我们试一试:

$ python scrape.py -h
usage: scrape.py [-h] [-t {all,png,jpg}] [-f {img,json}] url

Scrape a webpage.

positional arguments:
 url The URL we want to scrape for images.

optional arguments:
 -h, --help show this help message and exit
 -t {all,png,jpg}, --type {all,png,jpg}
 The image type we want to scrape.
 -f {img,json}, --format {img,json}
 The format images are _saved to.

如果您仔细考虑一下,这样做的真正优势在于我们只需要指定参数,而不必担心使用文本,这意味着我们不必在每次更改内容时保持与参数定义同步。这是非常宝贵的。

以下是调用我们的scrape.py脚本的几种不同方式,演示了typeformat是可选的,以及如何使用短语法和长语法来使用它们:

$ python scrape.py http://localhost:8000
$ python scrape.py -t png http://localhost:8000
$ python scrape.py --type=jpg -f json http://localhost:8000

第一个是使用typeformat的默认值。第二个将仅保存 PNG 图像,第三个将仅保存 JPG 图像,但以 JSON 格式保存。

业务逻辑

现在我们已经看到了脚手架,让我们深入到实际的逻辑中(如果看起来令人生畏,不要担心;我们会一起学习)。在脚本中,这个逻辑位于导入之后和解析之前(在if __name__子句之前)。

def scrape(url, format_, type_):
    try:
        page = requests.get(url)
    except requests.RequestException as err:
        print(str(err))
    else:
        soup = BeautifulSoup(page.content, 'html.parser')
        images = _fetch_images(soup, url)
        images = _filter_images(images, type_)
        _save(images, format_)

让我们从scrape函数开始。它所做的第一件事就是获取给定url参数的页面。无论在此过程中可能发生的任何错误,我们都会将其捕获在RequestExceptionerr)中并打印出来。RequestExceptionrequests库中所有异常的基本异常类。

然而,如果一切顺利,我们从GET请求中得到了一个页面,那么我们可以继续(else分支),并将其内容提供给BeautifulSoup解析器。BeautifulSoup库允许我们在很短的时间内解析网页,而不必编写查找页面上所有图像所需的所有逻辑,这是我们真的不想做的。这并不像看起来那么容易,重新发明轮子从来都不是好事。为了获取图像,我们使用_fetch_images函数,并用_filter_images对它们进行过滤。最后,我们调用_save来保存结果。

将代码分割成不同的函数并赋予有意义的名称,使我们更容易阅读它。即使你没有看到_fetch_images_filter_images_save函数的逻辑,也不难预测它们的功能,对吧?看看下面的内容:

def _fetch_images(soup, base_url):
    images = []
    for img in soup.findAll('img'):
        src = img.get('src')
        img_url = f'{base_url}/{src}'
        name = img_url.split('/')[-1]
        images.append(dict(name=name, url=img_url))
    return images

_fetch_images接受一个BeautifulSoup对象和一个基本 URL。它所做的就是循环遍历页面上找到的所有图像,并在一个字典中填写关于它们的nameurl信息(每个图像一个字典)。所有字典都添加到images列表中,并在最后返回。

当我们获取图像的名称时,有一些技巧。我们使用'/'作为分隔符来分割img_urlhttp://localhost:8000/img/my_image_name.png)字符串,并将最后一项作为图像名称。有一种更健壮的方法来做到这一点,但对于这个例子来说,这将是杀鸡用牛刀。如果你想看到每个步骤的细节,请尝试将这个逻辑分解为更小的步骤,并打印每个步骤的结果来帮助你理解。在本书的末尾,我会向你展示另一种更有效的调试技术。

无论如何,只需在_fetch_images函数的末尾添加print(images),我们就得到了这个:

[{'url': 'http://localhost:8000/img/owl-alcohol.png', 'name': 'owl-alcohol.png'}, {'url': 'http://localhost:8000/img/owl-book.png', 'name': 'owl-book.png'}, ...]  

我为了简洁起见截断了结果。你可以看到每个字典都有一个urlname键/值对,我们可以用它们来获取、识别和保存我们喜欢的图像。此时,我听到你在问,如果页面上的图像是用绝对路径而不是相对路径指定的,会发生什么,对吧?好问题!

答案是脚本将无法下载它们,因为这个逻辑期望相对路径。当我想要添加一点逻辑来解决这个问题时,我想在这个阶段,这将是一个很好的练习,所以我会留给你来解决它。

提示:检查src变量的开头。如果以'http'开头,那么它可能是一个绝对路径。你可能还想查看urllib.parse来做到这一点。

我希望_filter_images函数的主体对你有趣。我想向你展示如何使用映射技术来检查多个扩展名:

def _filter_images(images, type_):
    if type_ == 'all':
        return images
    ext_map = {
        'png': ['.png'],
        'jpg': ['.jpg', '.jpeg'],
    }
    return [
        img for img in images
        if _matches_extension(img['name'], ext_map[type_])
    ]

def _matches_extension(filename, extension_list):
    name, extension = os.path.splitext(filename.lower())
    return extension in extension_list

在这个函数中,如果type_all,那么不需要进行过滤,所以我们只返回所有的图像。另一方面,当type_不是all时,我们从ext_map字典中获取允许的扩展名,并用它来过滤函数体结束的列表推导式中的图像。你可以看到,通过使用另一个辅助函数_matches_extension,我使列表推导式更简单、更易读。

_matches_extension函数所做的就是分割获取图像扩展名的名称,并检查它是否在允许的列表中。你能找到一个微小的改进(速度方面)可以应用到这个函数吗?

我相信你一定想知道为什么我要将所有图像收集到列表中,然后再删除它们,而不是在将它们添加到列表之前检查是否要保存它们。第一个原因是我现在需要在 GUI 应用程序中使用_fetch_images。第二个原因是合并、获取和过滤会产生一个更长更复杂的函数,而我正在尽量降低复杂性。第三个原因是这可能是一个很好的练习给你做:

def _save(images, format_):
    if images:
        if format_ == 'img':
            _save_images(images)
        else:
            _save_json(images)
        print('Done')
    else:
        print('No images to save.')

def _save_images(images):
    for img in images:
        img_data = requests.get(img['url']).content
        with open(img['name'], 'wb') as f:
            f.write(img_data)

def _save_json(images):
    data = {}
    for img in images:
        img_data = requests.get(img['url']).content
        b64_img_data = base64.b64encode(img_data)
        str_img_data = b64_img_data.decode('utf-8')
        data[img['name']] = str_img_data
    with open('images.json', 'w') as ijson:
        ijson.write(json.dumps(data))

让我们继续阅读代码并检查_save函数。你可以看到,当images不为空时,这基本上充当一个调度程序。我们要么调用_save_images,要么调用_save_json,这取决于format_变量中存储的信息。

我们快要完成了。让我们跳到_save_images。我们循环遍历images列表,对于我们在那里找到的每个字典,我们对图像 URL 执行一个GET请求,并将其内容保存在一个文件中,我们将其命名为图像本身。

最后,现在让我们进入_save_json函数。它与之前的函数非常相似。我们基本上填充了data字典。图像名称是,其二进制内容的 Base64 表示是。当我们完成填充字典时,我们使用json库将其转储到images.json文件中。我会给你一个小预览:

# images.json (truncated)
{
  "owl-alcohol.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEICA...
  "owl-book.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEbCAYAA...
  "owl-books.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAElCAYA...
  "owl-ebook.jpg": "/9j/4AAQSkZJRgABAQEAMQAxAAD/2wBDAAEB...
  "owl-rose.jpeg": "/9j/4AAQSkZJRgABAQEANAA0AAD/2wBDAAEB...
}

就是这样!现在,在继续下一部分之前,请确保你玩过这个脚本并了解它是如何工作的。尝试修改一些东西,打印出中间结果,添加一个新的参数或功能,或者打乱逻辑。我们现在将把它迁移到一个 GUI 应用程序中,这将增加一层复杂性,因为我们将不得不构建 GUI 界面,所以熟悉业务逻辑非常重要——这将使你能够集中精力处理代码的其余部分。

第二种方法-一个 GUI 应用程序

有几个库可以用 Python 编写 GUI 应用程序。最著名的是TkinterwxPythonPyGTKPyQt。它们都提供了各种工具和小部件,可以用来组成 GUI 应用程序。

我将在本章的其余部分中使用 Tkinter。Tkinter代表Tk 界面,它是 Python 与 Tk GUI 工具包的标准接口。Tk 和 Tkinter 都可以在大多数 Unix 平台、macOS X 以及 Windows 系统上使用。

让我们通过运行这个命令来确保tkinter在你的系统上安装正确:

$ python -m tkinter

它应该打开一个对话框窗口,展示一个简单的Tk界面。如果你能看到它,那就没问题。但是,如果它不起作用,请在 Python 官方文档中搜索tkinterdocs.python.org/3.7/library/tkinter.html)。你会找到一些资源的链接,这些资源将帮助你快速上手。

我们将制作一个非常简单的 GUI 应用程序,基本上模仿本章第一部分中所见的脚本的行为。我们不会添加单独保存 JPG 或 PNG 的功能,但在你完成本章后,你应该能够玩转代码,并自己加入该功能。

所以,这就是我们的目标:

华丽,不是吗?正如你所看到的,这是一个非常简单的界面(这是在 Mac 上的样子)。有一个框架(即容器)用于 URL 字段和获取信息按钮,另一个框架用于Listbox(内容)来保存图像名称和控制保存方式的单选按钮,最后底部有一个抓取按钮。我们还有一个状态栏,它会向我们显示一些信息。

为了获得这种布局,我们可以将所有小部件放在根窗口上,但那样会使布局逻辑变得非常混乱和不必要地复杂。因此,我们将使用框架来划分空间,并将小部件放在这些框架中。这样我们将获得一个更好的结果。所以,这是布局的草案:

我们有一个根窗口,它是应用程序的主窗口。我们将它分成两行,第一行放置主框架,第二行放置状态框架(用于保存状态栏文本)。主框架随后被分成三行。在第一行,我们放置URL 框架,其中包含URL小部件。在第二行,我们放置Img 框架,它将包含ListboxRadio 框架,后者将承载一个标签和单选按钮小部件。最后我们有第三行,它将只包含Scrape按钮。

为了布局框架和小部件,我们将使用一个布局管理器,称为grid,它简单地将空间分成行和列,就像矩阵一样。

现在,我要写的所有代码都来自guiscrape.py模块,所以我不会为每个片段重复它的名称,以节省空间。该模块在逻辑上分为三个部分,与脚本版本类似:导入、布局逻辑和业务逻辑。我们将逐行分析它们,分为三个部分。

导入

导入与脚本版本类似,只是我们失去了argparse,它不再需要,并且添加了两行:

# guiscrape.py
from tkinter import * 
from tkinter import ttk, filedialog, messagebox 
...

第一行在处理tkinter时是相当常见的做法,尽管通常使用*语法进行导入是不好的做法.你可能会遇到名称冲突,而且如果模块太大,导入所有内容将会很昂贵。

之后,我们明确导入了ttkfiledialogmessagebox,遵循了这个库的常规方法。ttk是一组新的样式化小部件。它们基本上的行为与旧的小部件相同,但能够根据操作系统的样式正确地绘制自己,这很好。

其余的导入(已省略)是我们现在所熟知的任务所需的。请注意,在这第二部分中,我们不需要使用pip安装任何东西;我们已经拥有了我们需要的一切。

布局逻辑

我将逐块粘贴它,这样我可以更容易地向你解释。你会看到我们在布局草案中讨论的所有那些部分是如何排列和粘合在一起的。我将要粘贴的内容,就像我们之前在脚本中所做的那样,是guiscrape.py模块的最后部分。我们将最后留下中间部分,也就是业务逻辑:

if __name__ == "__main__":
    _root = Tk()
    _root.title('Scrape app')

正如你现在所知,我们只想在模块直接运行时执行逻辑,所以第一行不应该让你感到惊讶。

在最后两行,我们设置了主窗口,它是Tk类的一个实例。我们实例化它并给它一个标题。请注意,我使用了tkinter对象的所有名称的前置下划线技术,以避免与业务逻辑中的名称潜在冲突。我觉得这样更清晰,但你可以不同意:

    _mainframe = ttk.Frame(_root, padding='5 5 5 5')
    _mainframe.grid(row=0, column=0, sticky=(E, W, N, S))

在这里,我们设置了主框架。它是一个ttk.Frame实例。我们将_root设置为它的父级,并给它一些paddingpadding是以像素为单位的度量,用于在内部内容和边框之间插入多少空间,以便让我们的布局有一点空间,否则我们会有一个沙丁鱼效应,小部件被过紧地打包在一起。

第二行更有趣。我们将这个_mainframe放在父对象_root的第一行(0)和第一列(0)。我们还说这个框架需要在每个方向上扩展自己,使用sticky参数和所有四个基本方向。如果你想知道它们是从哪里来的,那就是from tkinter import *魔法给我们带来的:

    _url_frame = ttk.LabelFrame(
        _mainframe, text='URL', padding='5 5 5 5')
    _url_frame.grid(row=0, column=0, sticky=(E, W))
    _url_frame.columnconfigure(0, weight=1)
    _url_frame.rowconfigure(0, weight=1)

接下来,我们首先放置URL Frame。这次,父对象是_mainframe,正如您从我们的草图中记得的那样。这不仅仅是一个简单的Frame,它实际上是一个LabelFrame,这意味着我们可以设置文本参数,并期望在其周围绘制一个矩形,并在其左上部分写入文本参数的内容(如果有必要,请查看上一张图片)。我们将此框架定位在(00),并说它应该向左和向右扩展。我们不需要其他两个方向。

最后,我们使用rowconfigurecolumnconfigure来确保它在需要调整大小时能够正确运行。这只是我们当前布局中的一种形式:

    _url = StringVar()
    _url.set('http://localhost:8000')
    _url_entry = ttk.Entry(
        _url_frame, width=40, textvariable=_url)
    _url_entry.grid(row=0, column=0, sticky=(E, W, S, N), padx=5)
    _fetch_btn = ttk.Button(
        _url_frame, text='Fetch info', command=fetch_url)
    _fetch_btn.grid(row=0, column=1, sticky=W, padx=5)

在这里,我们有布局 URL 文本框和_fetch按钮的代码。在这种环境中,文本框称为Entry。我们像往常一样实例化它,将_url_frame设置为其父级并为其设置宽度。而且,这是最有趣的部分,我们将textvariable参数设置为_url_urlStringVar,它是一个现在连接到Entry并将用于操作其内容的对象。因此,我们不直接修改_url_entry实例中的文本,而是通过访问_url。在这种情况下,我们调用其set方法将初始值设置为我们本地网页的 URL。

我们将_url_entry定位在(00),为其设置了四个基本方向,使其粘附,并且还使用padx在左右边缘设置了一些额外的填充,该参数在x轴(水平)上添加填充。另一方面,pady负责垂直方向。

到目前为止,您应该知道每次在对象上调用.grid方法时,我们基本上都在告诉网格布局管理器根据我们在grid()调用中指定的规则将该对象放置在某个地方。

类似地,我们设置并放置了_fetch按钮。唯一有趣的参数是command=fetch_url。这意味着当我们单击此按钮时,我们调用fetch_url函数。这种技术称为回调

    _img_frame = ttk.LabelFrame(
        _mainframe, text='Content', padding='9 0 0 0')
    _img_frame.grid(row=1, column=0, sticky=(N, S, E, W))

这就是我们在布局草图中称为Img Frame的东西。它放置在其父级_mainframe的第二行。它将容纳ListboxRadio Frame

    _images = StringVar()
    _img_listbox = Listbox(
        _img_frame, listvariable=_images, height=6, width=25)
    _img_listbox.grid(row=0, column=0, sticky=(E, W), pady=5)
    _scrollbar = ttk.Scrollbar(
        _img_frame, orient=VERTICAL, command=_img_listbox.yview)
    _scrollbar.grid(row=0, column=1, sticky=(S, N), pady=6)
    _img_listbox.configure(yscrollcommand=_scrollbar.set)

这可能是整个布局逻辑中最有趣的部分。与_url_entry一样,我们需要通过将其绑定到_images变量来驱动Listbox的内容。我们设置Listbox,使_img_frame成为其父级,并且_images是其绑定的变量。我们还传递了一些尺寸。

有趣的部分来自_scrollbar实例。请注意,当我们实例化它时,我们将其命令设置为_img_listbox.yview。这是ListboxScrollbar之间的合同的第一部分。另一半由_img_listbox.configure方法提供,该方法设置yscrollcommand=_scrollbar.set

通过提供这种相互关系,当我们在Listbox上滚动时,Scrollbar将相应移动,反之亦然,当我们操作Scrollbar时,Listbox将相应滚动:

    _radio_frame = ttk.Frame(_img_frame)
    _radio_frame.grid(row=0, column=2, sticky=(N, S, W, E))

我们放置Radio Frame,准备填充。请注意,Listbox占据了_img_frame的(00),Scrollbar占据了(01),因此_radio_frame将放在(02):

    _choice_lbl = ttk.Label(
        _radio_frame, text="Choose how to save images")
    _choice_lbl.grid(row=0, column=0, padx=5, pady=5)
    _save_method = StringVar()
    _save_method.set('img')
    _img_only_radio = ttk.Radiobutton(
        _radio_frame, text='As Images', variable=_save_method,
        value='img')
    _img_only_radio.grid(
        row=1, column=0, padx=5, pady=2, sticky=W)
    _img_only_radio.configure(state='normal')
    _json_radio = ttk.Radiobutton(
        _radio_frame, text='As JSON', variable=_save_method,
        value='json')
    _json_radio.grid(row=2, column=0, padx=5, pady=2, sticky=W)

首先,我们放置标签,并为其添加一些填充。请注意,标签和单选按钮都是_radio_frame的子级。

至于EntryListbox对象,Radiobutton也受到与外部变量的绑定的影响,我称之为_save_method。每个Radiobutton实例都设置了一个值参数,通过检查_save_method上的值,我们知道

选择哪个按钮:

    _scrape_btn = ttk.Button(
        _mainframe, text='Scrape!', command=save)
    _scrape_btn.grid(row=2, column=0, sticky=E, pady=5)

_mainframe的第三行放置Scrape按钮。其commandsave,在成功解析网页后,将图像保存到Listbox中:

    _status_frame = ttk.Frame(
        _root, relief='sunken', padding='2 2 2 2')
    _status_frame.grid(row=1, column=0, sticky=(E, W, S))
    _status_msg = StringVar()
    _status_msg.set('Type a URL to start scraping...')
    _status = ttk.Label(
        _status_frame, textvariable=_status_msg, anchor=W)
    _status.grid(row=0, column=0, sticky=(E, W))

我们通过放置状态框架来结束布局部分,这是一个简单的ttk.Frame。为了给它一个小小的状态栏效果,我们将其relief属性设置为'sunken',并给它统一的两像素填充。它需要粘附在_root窗口的左侧、右侧和底部,因此我们将其sticky属性设置为(E, W, S)

然后我们在其中放置一个标签,并且这次我们将其绑定到StringVar对象,因为我们每次想要更新状态栏文本时都必须修改它。您现在应该熟悉这种技术了。

最后,在最后一行,我们通过在Tk实例上调用mainloop方法来运行应用程序:

    _root.mainloop()

请记住,所有这些指令都放在原始脚本中的if __name__ == "__main__":子句下。

如您所见,设计我们的 GUI 应用程序的代码并不难。当然,在开始时,您必须稍微尝试一下。并不是每件事情都会在第一次尝试时完美无缺,但我向您保证,这非常容易,您可以在网上找到大量的教程。现在让我们来到有趣的部分,业务逻辑。

业务逻辑

我们将分析 GUI 应用程序的业务逻辑分为三个部分。有获取逻辑、保存逻辑和警报逻辑。

获取网页

让我们从获取页面和图片的代码开始:

config = {}

def fetch_url():
    url = _url.get()
    config['images'] = []
    _images.set(())  # initialised as an empty tuple
    try:
        page = requests.get(url)
    except requests.RequestException as err:
        _sb(str(err))
    else:
        soup = BeautifulSoup(page.content, 'html.parser')
        images = fetch_images(soup, url)
        if images:
            _images.set(tuple(img['name'] for img in images))
            _sb('Images found: {}'.format(len(images)))
        else:
            _sb('No images found')
        config['images'] = images

def fetch_images(soup, base_url):
    images = []
    for img in soup.findAll('img'):
        src = img.get('src')
        img_url = f'{base_url}/{src}'
        name = img_url.split('/')[-1]
        images.append(dict(name=name, url=img_url))
    return images

首先,让我解释一下config字典。我们需要一种在 GUI 应用程序和业务逻辑之间传递数据的方式。现在,我个人偏好的做法是,不是用许多不同的变量污染全局命名空间,而是使用一个单一的字典,其中包含我们需要来回传递的所有对象,这样全局命名空间就不会被所有这些名称弄得混乱,我们有一个单一、清晰、简单的方式来知道我们应用程序所需的所有对象在哪里。

在这个简单的例子中,我们将config字典填充了我们从页面获取的图片,但我想向您展示这种技术,这样您至少有一个例子。这种技术来自于我的 JavaScript 经验。当您编写网页时,通常会导入几种不同的库。如果每个库都用各种变量弄乱了全局命名空间,可能会出现问题,因为名称冲突和变量覆盖的问题。

因此,最好尽量保持全局命名空间的清洁。在这种情况下,我发现使用一个config变量是完全可以接受的。

fetch_url函数与我们在脚本中所做的非常相似。首先,我们通过调用_url.get()来获取url的值。请记住,_url对象是一个绑定到_url_entry对象的StringVar实例,后者是一个Entry。您在 GUI 上看到的文本字段是Entry,但在幕后的文本是StringVar对象的值。

通过在_url上调用get(),我们可以获得文本的值,该值显示在_url_entry中。

下一步是准备config['images']为空列表,并清空与_img_listbox绑定的_images变量。当然,这会清理_img_listbox中的所有项目。

准备工作完成后,我们可以尝试获取页面,使用与本章开头的脚本中采用的相同的try/except逻辑。唯一的区别是如果出现问题,我们会调用_sb(str(err))_sb是一个帮助函数,我们很快就会看到它的代码。基本上,它为我们设置状态栏中的文本。不是一个好名字,对吧?我不得不向您解释它的行为-值得思考。

如果我们可以获取页面,那么我们就创建soup实例,并从中获取图片。fetch_images的逻辑与之前解释的逻辑完全相同,因此我就不在这里重复了。

如果我们有图像,我们使用一个快速的元组推导式(实际上是一个生成器表达式馈送到一个元组构造函数),将_images作为StringVar,这会使我们的_img_listbox填充所有图像名称。最后,我们更新状态栏。

如果没有图像,我们仍然更新状态栏,并且在函数结束时,无论找到了多少图像,我们都会更新config['images']以保存images列表。这样,我们就能够通过检查config['images']而无需传递该列表来从其他函数中访问图像。

保存图像

保存图像的逻辑非常简单。如下所示:

def save():
    if not config.get('images'):
        _alert('No images to save')
        return

    if _save_method.get() == 'img':
        dirname = filedialog.askdirectory(mustexist=True)
        _save_images(dirname)
    else:
        filename = filedialog.asksaveasfilename(
            initialfile='images.json',
            filetypes=[('JSON', '.json')])
        _save_json(filename)

def _save_images(dirname):
    if dirname and config.get('images'):
        for img in config['images']:
            img_data = requests.get(img['url']).content
            filename = os.path.join(dirname, img['name'])
            with open(filename, 'wb') as f:
                f.write(img_data)
        _alert('Done')

def _save_json(filename):
    if filename and config.get('images'):
        data = {}
        for img in config['images']:
            img_data = requests.get(img['url']).content
            b64_img_data = base64.b64encode(img_data)
            str_img_data = b64_img_data.decode('utf-8')
            data[img['name']] = str_img_data

        with open(filename, 'w') as ijson:
            ijson.write(json.dumps(data))
        _alert('Done')

当用户点击抓取按钮时,使用回调机制调用save函数。

这个函数的第一件事就是检查是否有要保存的图像。如果没有,它会使用另一个辅助函数_alert来提醒用户,我们很快就会看到它的代码。如果没有图像,就不会执行进一步的操作。

另一方面,如果config['images']列表不为空,save充当一个调度程序,并根据_same_method持有的值调用_save_images_save_json。请记住,这个变量与单选按钮相关联,因此我们期望它的值要么是'img',要么是'json'

这个调度程序与脚本中的不同。根据我们选择的方法,必须采取不同的操作。

如果我们想要将图像保存为图像,我们需要要求用户选择一个目录。我们通过调用filedialog.askdirectory并将调用的结果分配给dirname变量来实现这一点。这将打开一个漂亮的对话框窗口,询问我们选择一个目录。我们选择的目录必须存在,如我们调用该方法的方式所指定的。这样做是为了在保存文件时不必编写处理可能缺少的目录的代码。

这个对话框在 mac 上应该是这样的:

如果我们取消操作,dirname将被设置为None

在完成对save中的逻辑分析之前,让我们快速浏览一下_save_images

它与脚本中的版本非常相似,因此请注意,在开始时,为了确保我们确实有事情要做,我们检查dirnameconfig['images']中至少有一张图像的存在。

如果是这样,这意味着我们至少有一个要保存的图像和它的路径,所以我们可以继续。保存图像的逻辑已经解释过了。这一次我们做的不同的一件事是,通过os.path.join将目录(即完整路径)与图像名称连接起来。

_save_images结束时,如果我们至少保存了一张图像,我们会提醒用户我们已经完成了。

现在让我们回到save中的另一个分支。当用户在按下抓取按钮之前选择了作为 JSON 的单选按钮时,将执行此分支。在这种情况下,我们想要保存一个文件;因此,我们不能只要求一个目录。我们还希望让用户有能力选择一个文件名。因此,我们启动了一个不同的对话框:filedialog.asksaveasfilename

我们传递一个初始文件名,该文件名建议给用户-如果他们不喜欢它,他们有能力更改它。此外,因为我们正在保存一个 JSON 文件,我们通过传递filetypes参数来强制用户使用正确的扩展名。这是一个列表,其中包含任意数量的两元组(描述,扩展名),用于运行对话框的逻辑。

这个对话框在 macOS 上应该是这样的:

一旦我们选择了一个位置和一个文件名,我们就可以继续进行保存逻辑,这与之前的脚本中的逻辑相同。我们从一个 Python 字典(data)创建一个 JSON 对象,该字典由images名称和 Base64 编码内容组成的键值对。

_save_json中,我们还有一个小检查,确保我们没有文件名和至少一个要保存的图像时不会继续。这确保了如果用户按下取消按钮,不会发生任何不好的事情。

警告用户

最后,让我们看看警报逻辑。这非常简单:

def _sb(msg):
    _status_msg.set(msg)

def _alert(msg):
    messagebox.showinfo(message=msg)

就改变状态栏消息而言,我们所需要做的就是访问_status_msg StringVar,因为它与_status标签相关联。

另一方面,如果我们想向用户显示更明显的消息,我们可以弹出一个消息框。在 Mac 上应该是这样的:

messagebox对象还可以用于警告用户(messagebox.showwarning)或者表示错误(messagebox.showerror)。但它也可以用于提供询问我们是否确定要继续或者是否真的要删除那个文件等对话框。

如果你通过简单地打印dir(messagebox)的返回值来检查messagebox,你会发现诸如askokcancelaskquestionaskretrycancelaskyesnoaskyesnocancel等方法,以及一组常量来验证用户的响应,如CANCELNOOKOKCANCELYESYESNOCANCEL。你可以将这些与用户的选择进行比较,以便知道对话框关闭时执行的下一个操作。

我们如何改进应用程序?

现在你已经习惯了设计 GUI 应用程序的基础知识,我想给你一些建议,如何使我们的应用程序更好。

我们可以从代码质量开始。你认为这段代码足够好,还是你会改进它?如果是的话,你会怎么做?我会测试它,并确保它是健壮的,并且考虑到用户可能通过点击应用程序而创建的各种情况。我还会确保当我们正在抓取的网站因任何原因而关闭时,行为是我所期望的。

我们可以改进的另一件事是命名。我谨慎地用下划线作为前缀命名了所有组件,既突出了它们的私有性质,又避免了与它们链接的底层对象发生名称冲突。但回想起来,许多这些组件可能需要更好的名称,因此真的取决于你重构,直到找到最适合你的形式。你可以从给_sb函数一个更好的名称开始!

就用户界面而言,你可以尝试调整主应用程序的大小。看看会发生什么?整个内容保持不变。如果你扩展,会添加空白空间,如果你缩小,整个小部件集会逐渐消失。这种行为并不是很好,因此一个快速的解决方案可能是使根窗口固定(即无法调整大小)。

你可以做的另一件事是改进应用程序,使其具有与脚本中相同的功能,只保存 PNG 或 JPG。为了做到这一点,你可以在某个地方放置一个组合框,有三个值:全部、PNG、JPG,或类似的东西。用户在保存文件之前应该能够选择其中一个选项。

更好的是,你可以更改Listbox的声明,以便可以同时选择多个图像,并且只保存所选的图像。如果你成功做到这一点(相信我,这并不像看起来那么难),那么你应该考虑更好地呈现Listbox,也许为行提供交替的背景颜色。

你可以添加的另一件好事是添加一个按钮,打开一个对话框来选择一个文件。文件必须是应用程序可以生成的 JSON 文件之一。一旦选择,你可以运行一些逻辑来从它们的 Base64 编码版本重建图像。这样做的逻辑非常简单,所以这里有一个例子:

with open('images.json', 'r') as f:
    data = json.loads(f.read())

for (name, b64val) in data.items():
    with open(name, 'wb') as f:
        f.write(base64.b64decode(b64val))

如你所见,我们需要以读模式打开images.json,并获取data字典。一旦我们有了它,我们就可以循环遍历它的项目,并保存每个图像的 Base64 解码内容。我会把这个逻辑留给你,让你把它与应用程序中的一个按钮联系起来。

你可以添加的另一个很酷的功能是能够打开一个预览窗格,显示从Listbox中选择的任何图像,这样用户就可以在决定保存它们之前先看一眼这些图像。

最后,对于这个应用的最后一个建议是添加一个菜单。甚至可以添加一个简单的菜单,包括文件和?来提供通常的帮助或关于。只是为了好玩。添加菜单并不复杂;你可以添加文本、键盘快捷键、图像等等。

我们从这里去哪里?

如果你对深入了解 GUI 的世界感兴趣,那么我想给你提几个建议。

乌龟模块

turtle模块是 Python 标准发行版中自 Python 2.5 版本以来的同名模块的扩展重新实现。这是向孩子介绍编程的一种非常受欢迎的方式。

它基于一个想象中的乌龟从笛卡尔平面的(0, 0)开始的想法。你可以通过编程命令乌龟向前和向后移动,旋转等等;通过组合所有可能的移动,可以绘制各种复杂的形状和图像。

它绝对值得一看,即使只是为了看到一些不同的东西。

wxPython,PyQt 和 PyGTK

在你探索了 tkinter 的广阔领域之后,我建议你探索其他 GUI 库:wxPython(https://www.wxpython.org/),PyQt(https://riverbankcomputing.com/software/pyqt/intro),和 PyGTK(https://pygobject.readthedocs.io/en/latest/)。你可能会发现其中一个更适合你,或者它会让你更容易编写你需要的应用程序。

我相信只有当编码人员意识到他们可以使用的工具时,他们才能实现他们的想法。如果你的工具集太狭窄,你的想法可能看起来是不可能的,或者非常难以实现,它们可能会保持原样,只是想法。

当然,今天的技术范围是巨大的,所以不可能了解一切;因此,当你要学习新技术或新主题时,我的建议是通过广度优先探索来增加你的知识。

调查几件事情,然后深入研究看起来最有希望的一个或几个。这样你就能至少用一种工具高效地工作,当这个工具不再满足你的需求时,你会知道在哪里深入挖掘,感谢你之前的探索。

最少惊讶法则

在设计界面时,有许多不同的事情需要牢记。其中一个对我来说最重要的是最少惊讶法则。它基本上是说,如果在你的设计中一个必要的功能具有很高的惊讶因素,可能需要重新设计你的应用程序。举个例子,当你习惯于在 Windows 上工作时,最小化、最大化和关闭窗口的按钮在右上角,但在 Linux 上工作时,它们在左上角,这是相当困难的。你会发现自己不断地去右上角,只发现按钮在另一边。

如果某个按钮在应用程序中变得如此重要,以至于设计师现在将其放在一个精确的位置,请不要创新。只需遵循惯例。用户只会在不得不花时间寻找不在预期位置的按钮时感到沮丧。

对这个规则的忽视是我无法使用 Jira 等产品的原因。做简单的事情花费了我几分钟的时间,本应该只需要几秒钟。

线程考虑

这个主题超出了本书的范围,但我还是想提一下。

如果你正在编写一个 GUI 应用程序,需要在点击按钮时执行一个长时间运行的操作,你会发现你的应用程序可能会在操作完成之前冻结。为了避免这种情况,并保持应用程序的响应性,你可能需要在不同的线程(甚至是不同的进程)中运行那个耗时的操作,这样操作系统就能够不时地为 GUI 分配一点时间,以保持其响应性。

首先要对基本原理有很好的掌握,然后再去享受探索的乐趣!

总结

在本章中,我们一起完成了一个项目。我们编写了一个脚本,可以抓取一个非常简单的网页,并接受可选命令来改变其行为。我们还编写了一个 GUI 应用程序,通过点击按钮而不是在控制台上输入来完成相同的操作。我希望你阅读和跟随的过程和我写作的过程一样愉快。

我们看到了许多不同的概念,比如处理文件和执行 HTTP 请求,并讨论了可用性和设计的指导方针。

我只能触及皮毛,但希望你有一个很好的起点,可以从中扩展你的探索。

在整个章节中,我指出了几种不同的改进应用程序的方法,并向你提出了一些练习和问题。我希望你花时间去尝试这些想法。你可以通过玩弄像我们一起编写的这个应用程序一样有趣的应用程序来学到很多东西。

在下一章中,我们将讨论数据科学,或者至少讨论一下当涉及这个主题时,Python 程序员所拥有的工具。

第十三章:数据科学

“如果我们有数据,让我们看看数据。如果我们只有意见,那就听我的。”- Jim Barksdale,前网景公司 CEO

数据科学是一个非常广泛的术语,根据上下文、理解、工具等可以有几种不同的含义。关于这个主题有无数的书籍,这对心脏脆弱的人来说并不适合。

为了做好数据科学,你至少需要了解数学和统计学。然后,你可能想深入研究其他学科,比如模式识别和机器学习,当然,你可以选择各种语言和工具。

我无法在这里讨论所有内容。因此,为了使本章有意义,我们将一起做一个很酷的项目。

大约在 2012/2013 年,我在伦敦一家顶级社交媒体公司工作。我在那里呆了两年,很荣幸能和一些非常聪明的人一起工作,他们的才华令我只能开始描述。我们是世界上第一个可以访问 Twitter 广告 API 的公司,我们也是 Facebook 的合作伙伴。这意味着有大量的数据。

我们的分析师们处理了大量的活动,并且他们为了完成工作而苦苦挣扎,所以我所在的开发团队尝试通过介绍 Python 和 Python 提供的处理数据的工具来帮助他们。这是一段非常有趣的旅程,让我在公司里指导了几个人,最终带我去了马尼拉,在那里我为分析师们进行了为期两周的 Python 和数据科学密集培训。

我们在本章中要做的项目是我在马尼拉向学生展示的最终示例的轻量级版本。我已经重新编写了它,使其适合本章的篇幅,并对一些地方进行了一些调整,但所有主要概念都在其中,所以对你来说应该是有趣和有教育意义的。

具体来说,我们将探讨以下内容:

  • Jupyter Notebook

  • Pandas 和 NumPy:Python 中的数据科学主要库

  • Pandas 的DataFrame类的一些概念

  • 创建和操作数据集

让我们先谈谈罗马神话中的神祗。

IPython 和 Jupyter Notebook

在 2001 年,Fernando Perez 是科罗拉多大学博尔德分校的物理学研究生,他试图改进 Python shell,以便在使用类似 Mathematica 和 Maple 等工具时能够获得他习惯的便利。这一努力的结果被命名为IPython

简而言之,那个小脚本最初是 Python shell 的增强版本,通过其他编码人员的努力,最终得到了来自不同公司的适当资金支持,成为了今天的出色和成功的项目。它诞生 10 年后,一个 Notebook 环境被创建,由 WebSockets、Tornado web 服务器、jQuery、CodeMirror 和 MathJax 等技术提供支持。ZeroMQ 库也被用来处理 Notebook 界面和其背后的 Python 核心之间的消息。

IPython Notebook 变得如此受欢迎和广泛使用,随着时间的推移,各种好东西都被添加进去。它可以处理小部件、并行计算、各种媒体格式等等。而且,在某个时候,甚至可以在 Notebook 内部使用 Python 以外的语言进行编码。

这导致了一个庞大的项目,曾经被分成两部分:IPython 被精简以更专注于内核部分和 shell,而 Notebook 已经成为一个名为Jupyter的全新项目。Jupyter 允许以 40 多种语言进行交互式科学计算。

本章的项目将全部在 Jupyter Notebook 中编写和运行,所以让我简单解释一下 Notebook 是什么。

笔记本环境是一个网页,它公开了一个简单的菜单和可以运行 Python 代码的单元格。尽管单元格是可以单独运行的独立实体,但它们都共享相同的 Python 内核。这意味着您在一个单元格中定义的所有名称(变量、函数等)将在任何其他单元格中都可用。

简而言之,Python 内核是 Python 正在运行的进程。因此,笔记本网页是向用户公开的用于驱动此内核的接口。网页使用非常快速的消息传递系统与内核进行通信。

除了所有图形优势之外,拥有这样的环境之美在于能够以块的方式运行 Python 脚本,这可能是一个巨大的优势。拿一个连接到数据库以获取数据然后操作该数据的脚本来说。如果您以常规方式进行,使用 Python 脚本,您必须每次想要对其进行实验时都获取数据。在笔记本环境中,您可以在一个单元格中获取数据,然后在其他单元格中操作和实验,因此不必每次都获取数据。

笔记本环境对于数据科学也非常有帮助,因为它允许逐步的内省。您完成一部分工作,然后进行验证。然后再做另一部分并再次验证,依此类推。

这对于原型设计也是非常宝贵的,因为结果就在你眼前,立即可用。

如果您想了解更多关于这些工具的信息,请访问ipython.orgjupyter.org

我创建了一个非常简单的示例笔记本,其中包含一个fibonacci函数,该函数为您提供了小于给定N的所有斐波那契数的列表。在我的浏览器中,它看起来像这样:

每个单元格都有一个 In []标签。如果方括号之间没有任何内容,这意味着单元格从未被执行过。如果有一个数字,这意味着单元格已被执行,并且该数字表示单元格被执行的顺序。最后,*表示该单元格当前正在执行。

您可以看到图片中,在第一个单元格中我定义了fibonacci函数,并执行了它。这样做的效果是将fibonacci名称放在与笔记本关联的全局框架中,因此fibonacci函数现在也可以在其他单元格中使用。实际上,在第二个单元格中,我可以运行fibonacci(100)并在 Out [2]中看到结果。在第三个单元格中,我向您展示了笔记本中的几个魔术函数之一。%timeit 多次运行代码并为您提供一个很好的基准。我在第五章中进行的所有列表理解和生成器的测量都是使用这个很好的功能进行的,节省时间和内存

您可以执行单元格任意次数,并更改运行它们的顺序。单元格非常灵活,您还可以放入 Markdown 文本或将其呈现为标题。

Markdown是一种轻量级标记语言,具有纯文本格式化语法,设计成可以转换为 HTML 和许多其他格式。

此外,无论您将什么放在单元格的最后一行,都将自动为您打印出来。这非常方便,因为您不必明确地编写print(...)

随时探索笔记本环境;一旦您熟悉它,我保证这将是一段持久的关系。

安装所需的库

为了运行笔记本,您必须安装一些库,每个库都与其他库合作以使整个系统工作。或者,您可以只安装 Jupyter,它会为您处理一切。对于本章,我们需要安装一些其他依赖项。您可以在项目的根文件夹中的requirements/requirements.data.science.in中找到它们的列表。要安装它们,请查看README.rst,您将在其中找到专门针对本章的说明。

使用 Anaconda

有时安装数据科学库可能非常痛苦。如果您在虚拟环境中为本章安装库而苦苦挣扎,您的另一个选择是安装 Anaconda。Anaconda 是 Python 和 R 编程语言的免费开源发行版,用于数据科学和机器学习相关应用,旨在简化软件包管理和部署。您可以从anaconda.org网站下载它。安装在系统中后,查看本章的各种要求,并通过 Anaconda 安装它们。

开始笔记本

一旦您安装了所有必需的库,您可以使用以下命令启动笔记本,或者使用 Anaconda 界面:

 $ jupyter notebook 

您将在浏览器中打开此地址(端口可能不同):http://localhost:8888/。转到该页面并使用菜单创建一个新的笔记本。当您感到舒适时,您已经准备好了。我强烈建议您在继续阅读之前尝试并运行 Jupyter 环境。有时不得不处理困难的依赖关系是一个很好的练习。

我们的项目将在笔记本中进行,因此我将使用单元格编号标记每个代码片段,以便您可以轻松地复制代码并跟随操作。

如果您熟悉键盘快捷键(查看笔记本的帮助部分),您将能够在单元格之间移动并处理它们的内容,而无需使用鼠标。这将使您在笔记本中工作时更加熟练和更快。

现在让我们继续讨论本章最有趣的部分:数据。

处理数据

通常,当您处理数据时,您会经历以下路径:获取数据,清理和操作数据,然后检查数据,并将结果呈现为值,电子表格,图形等。我希望您能够独立完成这个过程的所有三个步骤,而不依赖于外部数据提供者,因此我们将进行以下操作:

  1. 我们将创建数据,模拟数据以一种不完美或不准备好被处理的格式

  2. 我们将对其进行清理并将其提供给项目中将使用的主要工具,如pandas库中的DataFrame

  3. 我们将在DataFrame中操作数据

  4. 我们将以不同格式将DataFrame保存到文件中

  5. 我们将检查数据并从中获取一些结果

设置笔记本

首先,让我们生成数据。我们从ch13-dataprep笔记本开始:

#1
import json
import random
from datetime import date, timedelta
import faker

单元格#1负责导入。我们已经遇到过它们,除了faker。您可以使用此模块准备虚假数据。在测试中非常有用,当您准备您的固定装置时,可以获得各种各样的东西,如姓名,电子邮件地址,电话号码和信用卡详细信息。当然,这都是假的。

准备数据

我们希望实现以下数据结构:我们将拥有一个用户对象列表。每个用户对象将与多个活动对象相关联。在 Python 中,一切都是对象,所以我以一种通用的方式使用这个术语。用户对象可以是字符串,字典或其他东西。

在社交媒体世界中,广告系列是媒体机构代表客户在社交媒体网络上运行的促销活动。请记住,我们将准备这些数据,使其不是完美的(但也不会太糟糕...):

#2
fake = faker.Faker() 

首先,我们实例化Faker,我们将用它来创建数据:

#3
usernames = set()
usernames_no = 1000

# populate the set with 1000 unique usernames
while len(usernames) < usernames_no:
    usernames.add(fake.user_name())

然后我们需要用户名。我想要 1,000 个唯一的用户名,所以我循环遍历用户名集合的长度,直到它有 1,000 个元素。set方法不允许重复元素,因此确保了唯一性:

#4
def get_random_name_and_gender():
    skew = .6  # 60% of users will be female
    male = random.random() > skew
    if male:
        return fake.name_male(), 'M'
    else:
        return fake.name_female(), 'F'

def get_users(usernames):
    users = []
    for username in usernames:
        name, gender = get_random_name_and_gender()
        user = {
            'username': username,
            'name': name,
            'gender': gender,
            'email': fake.email(),
            'age': fake.random_int(min=18, max=90),
            'address': fake.address(),
        }
        users.append(json.dumps(user))
    return users

users = get_users(usernames)
users[:3]

在这里,我们创建了一个用户列表。每个用户名现在已经增加到一个完整的用户字典中,其中包括姓名性别电子邮件等其他细节。然后将每个用户字典转储为 JSON 并添加到列表中。当然,这种数据结构并不是最佳的,但我们正在模拟用户以这种方式来到我们这里的情况。

注意到了random.random()的偏斜使用,使 60%的用户为女性。其余的逻辑应该对你来说非常容易理解。

还要注意最后一行。每个单元格都会自动打印最后一行的内容;因此,#4的输出是一个包含前三个用户的列表:

['{"username": "samuel62", "name": "Tonya Lucas", "gender": "F", "email": "anthonyrobinson@robbins.biz", "age": 27, "address": "PSC 8934, Box 4049\\nAPO AA 43073"}',
 '{"username": "eallen", "name": "Charles Harmon", "gender": "M", "email": "courtneycollins@hotmail.com", "age": 28, "address": "38661 Clark Mews Apt. 528\\nAnthonychester, ID 25919"}',
 '{"username": "amartinez", "name": "Laura Dunn", "gender": "F", "email": "jeffrey35@yahoo.com", "age": 88, "address": "0536 Daniel Court Apt. 541\\nPort Christopher, HI 49399-3415"}']

我希望你正在用自己的笔记本跟着做。如果是的话,请注意所有数据都是使用随机函数和值生成的;因此,你会看到不同的结果。每次执行笔记本时都会发生变化。

在下面的代码中,#5是生成广告系列名称的逻辑:

#5
# campaign name format:
# InternalType_StartDate_EndDate_TargetAge_TargetGender_Currency
def get_type():
    # just some gibberish internal codes
    types = ['AKX', 'BYU', 'GRZ', 'KTR']
    return random.choice(types)

def get_start_end_dates():
    duration = random.randint(1, 2 * 365)
    offset = random.randint(-365, 365)
    start = date.today() - timedelta(days=offset)
    end = start + timedelta(days=duration)

    def _format_date(date_):
        return date_.strftime("%Y%m%d")
    return _format_date(start), _format_date(end)

def get_age():
    age = random.randint(20, 45)
    age -= age % 5
    diff = random.randint(5, 25)
    diff -= diff % 5
    return '{}-{}'.format(age, age + diff)

def get_gender():
    return random.choice(('M', 'F', 'B'))

def get_currency():
    return random.choice(('GBP', 'EUR', 'USD'))

def get_campaign_name():
    separator = '_'
    type_ = get_type()
    start, end = get_start_end_dates()
    age = get_age()
    gender = get_gender()
    currency = get_currency()
    return separator.join(
        (type_, start, end, age, gender, currency))

分析师们经常使用电子表格,并想出各种编码技术,以尽可能多地压缩信息到广告系列名称中。我选择的格式是这种技术的一个简单示例——有一个代码告诉我们广告系列类型,然后是开始和结束日期,然后是目标年龄性别,最后是货币。所有值都用下划线分隔。

get_type函数中,我使用random.choice()从集合中随机获取一个值。也许更有趣的是get_start_end_dates。首先,我得到了广告系列的持续时间,从一天到两年(随机),然后我得到了一个随机的时间偏移,我从今天的日期中减去它以获得开始日期。鉴于偏移是-365 到 365 之间的随机数,如果我将它添加到今天的日期而不是减去它,会有什么不同吗?

当我有开始和结束日期时,我会返回它们的字符串版本,用下划线连接起来。

然后,我们对年龄计算进行了一些模块化的技巧。我希望你还记得第二章中的取模运算符()。

这里发生的是,我想要一个具有五的倍数作为极端的日期范围。因此,有很多方法可以做到这一点,但我做的是从2045之间获取一个随机数,然后去除除以5的余数。因此,例如,如果我得到28,我将从中去除28%5 = 3,得到25。我本来可以使用random.randrange(),但很难抵制模块化除法。

其余的函数只是random.choice()的一些其他应用,最后一个get_campaign_name只是一个收集所有这些拼图块的收集器,返回最终的广告系列名称:

#6
# campaign data:
# name, budget, spent, clicks, impressions
def get_campaign_data():
    name = get_campaign_name()
    budget = random.randint(10**3, 10**6)
    spent = random.randint(10**2, budget) 
    clicks = int(random.triangular(10**2, 10**5, 0.2 * 10**5)) 
    impressions = int(random.gauss(0.5 * 10**6, 2))
    return {
        'cmp_name': name,
        'cmp_bgt': budget,
        'cmp_spent': spent,
        'cmp_clicks': clicks,
        'cmp_impr': impressions
    }

#6中,我们编写了一个创建完整广告系列对象的函数。我使用了random模块中的一些不同函数。random.randint()给出了两个极端之间的整数。它的问题在于它遵循均匀概率分布,这意味着区间内的任何数字出现的概率都是相同的。

因此,当处理大量数据时,如果你使用均匀分布来分发你的固定值,你得到的结果将会看起来很相似。因此,我选择使用triangulargauss,对于clicksimpressions。它们使用不同的概率分布,这样我们最终会有一些更有趣的东西。

为了确保我们对术语的理解是一致的:clicks代表对活动广告的点击次数,budget是分配给活动的总金额,spent是已经花费的金额,impressions是活动从其来源获取的次数,无论点击了多少次活动。通常,impressions的数量大于clicks的数量。

现在我们有了数据,是时候把它们整合在一起了:

#7
def get_data(users):
    data = []
    for user in users:
        campaigns = [get_campaign_data()
                     for _ in range(random.randint(2, 8))]
        data.append({'user': user, 'campaigns': campaigns})
    return data

正如你所看到的,data中的每个项目都是一个带有user和与该user相关的一系列活动的字典。

清理数据

让我们开始清理数据:

#8
rough_data = get_data(users)
rough_data[:2]  # let's take a peek

我们模拟从源获取数据然后检查它。笔记本是检查你的步骤的完美工具。你可以根据需要调整粒度。rough_data中的第一项看起来像这样:

{'user': '{"username": "samuel62", "name": "Tonya Lucas", "gender": "F", "email": "anthonyrobinson@robbins.biz", "age": 27, "address": "PSC 8934, Box 4049\\nAPO AA 43073"}',
 'campaigns': [{'cmp_name': 'GRZ_20171018_20171116_35-55_B_EUR',
 'cmp_bgt': 999613,
 'cmp_spent': 43168,
 'cmp_clicks': 35603,
 'cmp_impr': 500001},
 ...
 {'cmp_name': 'BYU_20171122_20181016_30-45_B_USD',
 'cmp_bgt': 561058,
 'cmp_spent': 472283,
 'cmp_clicks': 44823,
 'cmp_impr': 499999}]} 

所以,我们现在开始处理它:

#9
data = []
for datum in rough_data:
    for campaign in datum['campaigns']:
        campaign.update({'user': datum['user']})
        data.append(campaign)
data[:2]  # let's take another peek

为了能够用这个data来填充DataFrame,我们需要做的第一件事是对其进行去规范化。这意味着将data转换为一个列表,其项是活动字典,附加上它们的相关user字典。用户将在他们所属的每个活动中被复制。data中的第一项看起来像这样:

{'cmp_name': 'GRZ_20171018_20171116_35-55_B_EUR',
 'cmp_bgt': 999613,
 'cmp_spent': 43168,
 'cmp_clicks': 35603,
 'cmp_impr': 500001,
 'user': '{"username": "samuel62", "name": "Tonya Lucas", "gender": "F", "email": "anthonyrobinson@robbins.biz", "age": 27, "address": "PSC 8934, Box 4049\\nAPO AA 43073"}'}

你可以看到user对象已经被带入了活动字典中,这对于每个活动都是重复的。

现在,我想帮助你并提供本章的确定性第二部分,所以我将保存我在这里生成的数据,这样我(以及你)就可以从下一个笔记本中加载它,然后我们应该有相同的结果:

#10
with open('data.json', 'w') as stream:
    stream.write(json.dumps(data))

你应该在书的源代码中找到data.json文件。现在我们已经完成了ch13-dataprep,所以我们可以关闭它,然后打开ch13

创建 DataFrame

首先,我们有另一轮导入:

#1
import json
import calendar
import numpy as np
from pandas import DataFrame
import arrow
import pandas as pd

jsoncalendar库来自标准库。numpy是 NumPy 库,用于科学计算的基本包。NumPy 代表 Numeric Python,它是数据科学环境中最广泛使用的库之一。我稍后会在本章中谈到它。pandas是整个项目的核心。Pandas代表Python 数据分析库。除了许多其他功能外,它提供了DataFrame,这是一种类似矩阵的数据结构,具有高级处理能力。习惯上,单独导入DataFrame,然后import pandas as pd

arrow是一个很好的第三方库,可以极大地加快处理日期的速度。从技术上讲,我们可以使用标准库来做到这一点,但我认为没有理由不扩展示例的范围并向你展示一些不同的东西。

在导入之后,我们将data加载如下:

#2
with open('data.json') as stream:
    data = json.loads(stream.read())

最后,是时候创建DataFrame了:

#3
df = DataFrame(data)
df.head()

我们可以使用DataFramehead方法来检查前五行。你应该会看到类似这样的东西:

Jupyter 会自动将df.head()调用的输出呈现为 HTML。为了获得基于文本的输出,只需将df.head()包装在print调用中。

DataFrame结构非常强大。它允许我们操纵许多内容。您可以按行、列进行过滤,对数据进行聚合以及许多其他操作。您可以在不受纯 Python 数据的时间惩罚的情况下操作行或列。这是因为在幕后,pandas利用了 NumPy 库的强大功能,而 NumPy 库本身又从其核心的低级实现中获得了令人难以置信的速度。

使用DataFrame允许我们将 NumPy 的强大功能与类似电子表格的功能相结合,这样我们就能够以类似分析师的方式处理我们的数据。只是,我们用代码来做。

但让我们回到我们的项目。让我们看看两种快速了解数据的方法:

#4
df.count()

count返回每列中所有非空单元格的计数。这有助于您了解数据有多稀疏。在我们的情况下,我们没有缺失值,因此输出是:

cmp_bgt       5037
cmp_clicks    5037
cmp_impr      5037
cmp_name      5037
cmp_spent     5037
user          5037
dtype: int64

太好了!我们有 5,037 行数据,数据类型是整数(dtype: int64表示长整数,因为每个整数占用 64 位)。考虑到我们有 1,000 个用户,每个用户的活动数量是 2 到 8 之间的随机数,我们正好符合我的预期:

#5
df.describe() 

describe方法是一个不错的、快速的深入了解的方法:

 cmp_bgt   cmp_clicks      cmp_impr     cmp_spent
count  5037.000000  5037.000000   5037.000000   5037.000000
mean 496930.317054 40920.962676 499999.498312 246963.542783
std  287126.683484 21758.505210      2.033342 217822.037701
min    1057.000000   341.000000 499993.000000    114.000000
25%  247663.000000 23340.000000 499998.000000  64853.000000
50%  491650.000000 37919.000000 500000.000000 183716.000000
75%  745093.000000 56253.000000 500001.000000 379478.000000
max  999577.000000 99654.000000 500008.000000 975799.000000

正如您所看到的,它为我们提供了几个度量,如countmeanstd(标准偏差)、minmax,并显示数据在各个象限中的分布情况。由于这种方法,我们已经对我们的数据结构有了一个大致的了解。

让我们看看哪三个活动的预算最高和最低:

#6
df.sort_index(by=['cmp_bgt'], ascending=False).head(3) 

这给出了以下输出:

 cmp_bgt  cmp_clicks  cmp_impr                           cmp_name
3321   999577        8232    499997  GRZ_20180810_20190107_40-55_M_EUR   
2361   999534       53223    499999  GRZ_20180516_20191030_25-30_B_EUR   
2220   999096       13347    499999  KTR_20180620_20190809_40-50_F_USD

调用tail会显示出预算最低的活动:

#7
df.sort_values(by=['cmp_bgt'], ascending=False).tail(3)

解开活动名称

现在是时候增加复杂性了。首先,我们想摆脱那个可怕的活动名称(cmp_name)。我们需要将其分解为部分,并将每个部分放入一个专用列中。为了做到这一点,我们将使用Series对象的apply方法。

pandas.core.series.Series类基本上是一个数组的强大包装器(将其视为具有增强功能的列表)。我们可以通过与字典中的键相同的方式从DataFrame中提取Series对象,并且我们可以在该Series对象上调用apply,这将运行一个函数将Series中的每个项目传递给它。我们将结果组合成一个新的DataFrame,然后将该DataFramedf连接:

#8
def unpack_campaign_name(name):
    # very optimistic method, assumes data in campaign name
    # is always in good state
    type_, start, end, age, gender, currency = name.split('_')
    start = arrow.get(start, 'YYYYMMDD').date()
    end = arrow.get(end, 'YYYYMMDD').date()
    return type_, start, end, age, gender, currency

campaign_data = df['cmp_name'].apply(unpack_campaign_name)
campaign_cols = [
    'Type', 'Start', 'End', 'Age', 'Gender', 'Currency']
campaign_df = DataFrame(
    campaign_data.tolist(), columns=campaign_cols, index=df.index)
campaign_df.head(3)

unpack_campaign_name中,我们将活动name分成几部分。我们使用arrow.get()从这些字符串中获取一个合适的date对象(arrow使这变得非常容易,不是吗?),然后我们返回这些对象。快速查看最后一行显示:

 Type       Start         End    Age Gender Currency
0  KTR  2019-03-24  2020-11-06  20-35      F      EUR
1  GRZ  2017-05-21  2018-07-24  30-45      B      GBP
2  KTR  2017-12-18  2018-02-08  30-40      F      GBP

太好了!一个重要的事情:即使日期显示为字符串,它们只是托管在DataFrame中的真实date对象的表示。

另一件非常重要的事情:当连接两个DataFrame实例时,它们必须具有相同的index,否则pandas将无法知道哪些行与哪些行配对。因此,当我们创建campaign_df时,我们将其index设置为dfindex。这使我们能够将它们连接起来。在创建此DataFrame时,我们还传递了列的名称:

#9
df = df.join(campaign_df)

join之后,我们做了一个快速查看,希望看到匹配的数据:

#10
df[['cmp_name'] + campaign_cols].head(3)

上述代码片段的截断输出如下:

 cmp_name Type      Start        End
0 KTR_20190324_20201106_20-35_F_EUR  KTR 2019-03-24 2020-11-06
1 GRZ_20170521_20180724_30-45_B_GBP  GRZ 2017-05-21 2018-07-24
2 KTR_20171218_20180208_30-40_F_GBP  KTR 2017-12-18 2018-02-08

正如您所看到的,join是成功的;活动名称和单独的列显示了相同的数据。您看到我们在那里做了什么吗?我们使用方括号语法访问DataFrame,并传递一个列名的列表。这将产生一个全新的DataFrame,其中包含这些列(顺序相同),然后我们调用head()方法。

解开用户数据

现在我们对每个user JSON 数据的每一部分做完全相同的事情。我们在user系列上调用apply,运行unpack_user_json函数,该函数接受一个 JSON user对象并将其转换为其字段的列表,然后我们可以将其注入到全新的DataFrame user_df中。之后,我们将user_dfdf重新连接,就像我们对campaign_df所做的那样:

#11
def unpack_user_json(user):
    # very optimistic as well, expects user objects
    # to have all attributes
    user = json.loads(user.strip())
    return [
        user['username'],
        user['email'],
        user['name'],
        user['gender'],
        user['age'],
        user['address'],
    ]

user_data = df['user'].apply(unpack_user_json)
user_cols = [
    'username', 'email', 'name', 'gender', 'age', 'address']
user_df = DataFrame(
    user_data.tolist(), columns=user_cols, index=df.index)

这与之前的操作非常相似,不是吗?我们还需要注意,在创建user_df时,我们需要指示DataFrame关于列名和index。让我们加入并快速查看一下:

#12
df = df.join(user_df)

#13
df[['user'] + user_cols].head(2)

输出向我们展示了一切都进行得很顺利。我们很好,但我们还没有完成。如果你在一个单元格中调用df.columns,你会看到我们的列名仍然很丑陋。让我们来改变一下:

#14
better_columns = [
    'Budget', 'Clicks', 'Impressions',
    'cmp_name', 'Spent', 'user',
    'Type', 'Start', 'End',
    'Target Age', 'Target Gender', 'Currency',
    'Username', 'Email', 'Name',
    'Gender', 'Age', 'Address',
]
df.columns = better_columns

好了!现在,除了'cmp_name''user'之外,我们只有漂亮的名称。

完成datasetNext步骤将是添加一些额外的列。对于每个活动,我们有点击次数和展示次数,还有花费金额。这使我们能够引入三个测量比率:CTRCPCCPI。它们分别代表点击通过率每次点击成本每次展示成本

最后两个很简单,但 CTR 不是。简而言之,它是点击次数和展示次数之间的比率。它为您提供了一个指标,即有多少次点击是在广告活动上每次展示中进行的-这个数字越高,广告吸引用户点击的成功性就越高:

#15
def calculate_extra_columns(df):
    # Click Through Rate
    df['CTR'] = df['Clicks'] / df['Impressions']
    # Cost Per Click
    df['CPC'] = df['Spent'] / df['Clicks']
    # Cost Per Impression
    df['CPI'] = df['Spent'] / df['Impressions']
calculate_extra_columns(df)

我将其写成一个函数,但我也可以直接在单元格中编写代码。这不重要。我想让你注意到的是,我们只需每行代码添加这三列,但DataFrame会自动应用操作(在这种情况下是除法)到适当列的每对单元格。因此,即使它们被掩盖为三个除法,这实际上是5037 * 3个除法,因为它们是针对每一行执行的。Pandas 为我们做了很多工作,并且很好地隐藏了其复杂性。

函数calculate_extra_columns接受DataFrame,并直接在其上运行。这种操作模式称为原地。你还记得list.sort()是如何对列表进行排序的吗?它是一样的。你也可以说这个函数不是纯的,这意味着它具有副作用,因为它修改了作为参数传递的可变对象。

我们可以通过过滤相关列并调用head来查看结果:

#16
df[['Spent', 'Clicks', 'Impressions',
    'CTR', 'CPC', 'CPI']].head(3)

这向我们展示了每一行上的计算都是正确执行的:

 Spent  Clicks  Impressions       CTR       CPC       CPI
0   39383   62554       499997  0.125109  0.629584  0.078766
1  210452   36176       500001  0.072352  5.817448  0.420903
2  342507   62299       500001  0.124598  5.497793  0.685013

现在,我想手动验证第一行的结果的准确性:

#17
clicks = df['Clicks'][0]
impressions = df['Impressions'][0]
spent = df['Spent'][0]
CTR = df['CTR'][0]
CPC = df['CPC'][0]
CPI = df['CPI'][0]
print('CTR:', CTR, clicks / impressions)
print('CPC:', CPC, spent / clicks)
print('CPI:', CPI, spent / impressions)

这产生了以下输出:

CTR: 0.1251087506525039 0.1251087506525039
CPC: 0.6295840393899671 0.6295840393899671
CPI: 0.0787664725988356 0.0787664725988356

这正是我们在先前的输出中看到的。当然,我通常不需要这样做,但我想向你展示如何以这种方式执行计算。你可以通过将其名称传递给DataFrame的方括号来访问Series(一列),然后通过其位置访问每一行,就像你使用常规列表或元组一样。

我们的DataFrame几乎完成了。我们现在缺少的只是一个列,告诉我们活动的持续时间,以及一个列,告诉我们每个活动的开始日期对应的是一周中的哪一天。这使我能够扩展如何使用date对象进行操作:

#18
def get_day_of_the_week(day):
    number_to_day = dict(enumerate(calendar.day_name, 1))
    return number_to_day[day.isoweekday()]

def get_duration(row):
    return (row['End'] - row['Start']).days

df['Day of Week'] = df['Start'].apply(get_day_of_the_week)
df['Duration'] = df.apply(get_duration, axis=1)

我们在这里使用了两种不同的技术,但首先是代码。

get_day_of_the_week接受一个date对象。如果你不明白它的作用,请花点时间自己尝试理解一下,然后再阅读解释。使用我们之前做过几次的从内到外的技术。

所以,我相信你现在已经知道了,如果你将calendar.day_name放在list调用中,你会得到['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']。这意味着,如果我们从1开始枚举calendar.day_name,我们会得到诸如(1, 'Monday')(2, 'Tuesday')等等的对。如果我们将这些对传递给一个字典,我们就得到了一种将星期几与它们的名称相对应的映射关系。当映射创建完成后,为了得到一天的名称,我们只需要知道它的数字。为了得到它,我们调用date.isoweekday(),这告诉我们那一天是一周的第几天(作为一个数字)。你将这个数字传递给映射,嘭!你就得到了这一天的名称。

get_duration 也很有趣。首先,注意它接受整行数据,而不仅仅是单个值。在函数体内部发生的是我们计算活动结束日期和开始日期之间的差值。当你对date对象进行减法运算时,结果是一个timedelta对象,它代表了一定的时间量。我们取它的.days属性的值。就是这么简单。

现在,我们可以介绍有趣的部分,应用这两个函数。

第一个应用是在Series对象上执行的,就像我们之前对'user''cmp_name'做的那样;这里没有什么新的。

第二个应用于整个DataFrame,为了指示pandas在行上执行该操作,我们传递axis=1

我们可以很容易地验证结果,如下所示:

#19
df[['Start', 'End', 'Duration', 'Day of Week']].head(3)

前面的代码产生了以下输出:

 Start         End  Duration Day of Week
0  2019-03-24  2020-11-06       593      Sunday
1  2017-05-21  2018-07-24       429      Sunday
2  2017-12-18  2018-02-08        52      Monday

所以,我们现在知道在 2019 年 3 月 24 日和 2020 年 11 月 6 日之间有 593 天,2019 年 3 月 24 日是星期日。

如果你想知道这样做的目的是什么,我会举个例子。想象一下,你有一个与通常在星期日举行的体育赛事相关联的活动。你可能想根据日期检查你的数据,以便将它们与你拥有的各种测量结果相关联。我们在这个项目中不打算这样做,但是看到这种方式在DataFrame上调用apply()是很有用的。

清理一切

现在我们已经得到了我们想要的一切,是时候进行最后的清理了;记住我们仍然有'cmp_name''user'列。现在它们没有用了,所以它们必须离开。另外,我想重新排列DataFrame中的列,使其更相关于它现在包含的数据。为了做到这一点,我们只需要根据我们想要的列列表对df进行过滤。我们将得到一个全新的DataFrame,我们可以重新分配给df本身:

#20
final_columns = [
    'Type', 'Start', 'End', 'Duration', 'Day of Week', 'Budget',
    'Currency', 'Clicks', 'Impressions', 'Spent', 'CTR', 'CPC',
    'CPI', 'Target Age', 'Target Gender', 'Username', 'Email',
    'Name', 'Gender', 'Age'
]
df = df[final_columns]

我将活动信息分组放在前面,然后是测量数据,最后是用户数据。现在我们的DataFrame已经干净,可以供我们检查。

在我们开始用图表疯狂之前,怎么样先对DataFrame进行快照,这样我们就可以很容易地从文件中重新构建它,而不必重新做到这里的所有步骤。一些分析师可能希望以电子表格形式保存它,以进行与我们想要进行的不同类型的分析,所以让我们看看如何将DataFrame保存到文件。这比说起来更容易。

将 DataFrame 保存到文件

我们可以以许多不同的方式保存DataFrame。你可以输入df.to_,然后按下Tab键,使自动补全弹出,以查看所有可能的选项。

我们将以三种不同的格式保存DataFrame,只是为了好玩。首先是 CSV:

#21
df.to_csv('df.csv')

然后是 JSON:

#22
df.to_json('df.json')

最后,在 Excel 电子表格中:

#23
df.to_excel('df.xls')

CSV 文件如下(输出截断):

,Type,Start,End,Duration,Day of Week,Budget,Currency,Clicks,Im
0,KTR,2019-03-24,2020-11-06,593,Sunday,847110,EUR,62554,499997
1,GRZ,2017-05-21,2018-07-24,429,Sunday,510835,GBP,36176,500001
2,KTR,2017-12-18,2018-02-08,52,Monday,720897,GBP,62299,500001,

JSON 的输出如下(同样,输出截断):

{
 "Age": {
 "0": 29,
 "1": 29,
 "10": 80,

所以,将DataFrame以许多不同的格式保存是非常容易的,好消息是反之亦然:将电子表格加载到DataFrame中也非常容易。pandas背后的程序员们为了简化我们的任务走了很长的路,这是值得感激的。

可视化结果

最后,精彩的部分。在本节中,我们将可视化一些结果。从数据科学的角度来看,我对深入分析并不感兴趣,特别是因为数据是完全随机的,但是,这段代码将帮助您开始使用图形和其他功能。

我在生活中学到的一件事,也许这会让您感到惊讶,那就是—外表也很重要,因此当您呈现您的结果时,您应该尽力使它们漂亮

首先,我们告诉pandas在单元格输出框中呈现图形,这很方便。我们用以下方法做到这一点:

#24
%matplotlib inline

然后,我们进行一些样式处理:

#25
import matplotlib.pyplot as plt
plt.style.use(['classic', 'ggplot'])
import pylab
pylab.rcParams.update({'font.family' : 'serif'})

它的目的是让我们在本节中查看的图形看起来更漂亮一些。您也可以在从控制台启动笔记本时传递参数来指示笔记本执行此操作,但我也想向您展示这种方式,因为如果您想绘制某些东西就必须重新启动笔记本可能会很烦人。通过这种方式,您可以即时执行,然后继续工作。

我们还使用pylab来将font.family设置为serif。这在您的系统上可能并不是必要的。尝试将其注释掉并执行笔记本,看看是否有任何变化。

现在DataFrame完成了,让我们再次运行df.describe()#26)。结果应该看起来像这样:

这种快速结果非常适合满足那些只有 20 秒时间来关注你并且只想要粗略数字的经理们。

再次,请记住我们的广告系列有不同的货币,因此这些数字实际上是没有意义的。这里的重点是演示DataFrame的功能,而不是进行正确或详细的真实数据分析。

另外,图表通常比带有数字的表格要好得多,因为它更容易阅读,并且可以立即给出反馈。因此,让我们绘制出每个广告系列的四个信息—'Budget''Spent''Clicks''Impressions'

#27
df[['Budget', 'Spent', 'Clicks', 'Impressions']].hist(
    bins=16, figsize=(16, 6));

我们推断这四列(这将给我们另一个只由这些列组成的DataFrame)并在其上调用直方图hist()方法。我们对箱子和图形大小进行了一些测量,但基本上一切都是自动完成的。

一个重要的事情:由于这个指令是这个单元格中唯一的指令(这也意味着,它是最后一个),笔记本会在绘制图形之前打印其结果。要抑制这种行为,只绘制图形而不打印任何内容,只需在末尾加上一个分号(你以为我在怀念 Java,不是吗?)。这里是图形:

它们很漂亮,不是吗?您有没有注意到衬线字体?这些数字的含义如何?如果您回过头看一下我们生成数据的方式,您会发现所有这些图形都是完全合理的:

  • 预算只是一个在间隔内的随机整数,因此我们预期是均匀分布,而我们确实有;它几乎是一条恒定的线。

  • 花费也是均匀分布,但其间隔的高端是预算,而预算是在变化的。这意味着我们应该期望类似于向右减少的二次双曲线。而它也在那里。

  • 点击是用三角形分布生成的,平均值大约是间隔大小的 20%,您可以看到峰值就在那里,大约向左 20%。

  • 印象是一个高斯分布,这是假设著名的钟形曲线的分布。平均值恰好在中间,标准偏差为 2。您可以看到图形符合这些参数。

好了!让我们绘制出我们计算的测量值:

#28
df[['CTR', 'CPC', 'CPI']].hist(
    bins=20, figsize=(16, 6))

这是图形表示:

我们可以看到 CPC 向左高度倾斜,这意味着大多数 CPC 值非常低。CPI 形状类似,但不那么极端。

现在,这一切都很好,但如果你只想分析数据的特定部分,你该怎么做呢?我们可以对DataFrame应用一个掩码,这样我们就可以得到另一个只包含满足掩码条件的行的DataFrame。这就像应用全局的、逐行的if子句一样:

#29
mask = (df.Spent > 0.75 * df.Budget)
df[mask][['Budget', 'Spent', 'Clicks', 'Impressions']].hist(
    bins=15, figsize=(16, 6), color='g');

在这种情况下,我准备了mask,以过滤掉花费金额少于或等于预算的所有行。换句话说,我们只包括那些花费至少达到预算四分之三的广告系列。请注意,在mask中,我向你展示了一种请求DataFrame列的替代方式,即使用直接属性访问(object.property_name),而不是类似字典的访问(object['property_name'])。如果property_name是一个有效的 Python 名称,你可以交替使用这两种方式(JavaScript 也是这样工作的)。

mask的应用方式类似于我们访问带有键的字典。当你将mask应用到DataFrame上时,你会得到另一个DataFrame,然后我们只选择相关的列,并再次调用hist()。这一次,只是为了好玩,我们希望结果是绿色的:

请注意,图形的形状除了'花费'图形之外,基本没有改变,'花费'图形非常不同。原因是我们只要求包括花费金额至少达到预算的行。这意味着我们只包括了花费接近预算的行。预算数字来自均匀分布。因此,很明显,'花费'图形现在呈现出这种形状。如果你把边界设得更紧,要求达到 85%或更多,你会看到'花费'图形越来越像预算图形。

现在让我们来看看不同的东西。如何按星期几分组测量'花费'、'点击'和'展示'的指标:

#30
df_weekday = df.groupby(['Day of Week']).sum()
df_weekday[['Impressions', 'Spent', 'Clicks']].plot(
    figsize=(16, 6), subplots=True);

第一行通过在df上按照'星期几'分组来创建一个新的DataFramedf_weekday。用于聚合数据的函数是加法。

第二行使用列名列表获取df_weekday的一个切片,这是我们现在习惯的做法。然后我们调用plot(),这和hist()有点不同。subplots=True选项使plot绘制三个独立的图形:

有趣的是,我们可以看到大部分活动发生在星期日和星期三。如果这是有意义的数据,这可能是向客户提供重要信息的原因,这就是为什么我向你展示这个例子。

请注意,日期按字母顺序排序,这有点混乱。你能想到一个快速的解决方案来解决这个问题吗?我把这个问题留给你作为一个练习来解决。

让我们用几个简单的聚合来结束这个演示部分。我们想在'Target Gender'和'Target Age'上进行聚合,并显示'Impressions'和'Spent'。对于这两个指标,我们想看到'平均值'和标准差('std'):

#31
agg_config = {
    'Impressions': ['mean', 'std'],
    'Spent': ['mean', 'std'],
}
df.groupby(['Target Gender', 'Target Age']).agg(agg_config)

这很容易做。我们将准备一个字典作为配置。然后,我们对'Target Gender'和'Target Age'列进行分组,并将我们的配置字典传递给agg()方法。结果被截断和重新排列了一点,以使其适应,并在这里显示:

 Impressions                    Spent
                                   mean       std           mean
Target Gender Target Age                                        
B             20-25       499999.741573  1.904111  218917.000000
              20-30       499999.618421  2.039393  237180.644737
              20-35       499999.358025  2.039048  256378.641975
...                                 ...       ...            ...
M             20-25       499999.355263  2.108421  277232.276316
              20-30       499999.635294  2.075062  252140.117647
              20-35       499999.835821  1.871614  308598.149254 

当然,这是文本表示,但你也可以有 HTML 表示。

在我们结束本章之前,让我们做一件事。我想向你展示一个叫做数据透视表的东西。在数据环境中,这是一个流行词,所以这样一个简单的例子是必不可少的:

#32
pivot = df.pivot_table(
    values=['Impressions', 'Clicks', 'Spent'],
    index=['Target Age'],
    columns=['Target Gender'],
    aggfunc=np.sum
)
pivot

我们创建了一个数据透视表,显示了“目标年龄”和“展示次数”、“点击次数”和“花费”之间的相关性。最后三个将根据“目标性别”进行细分。用于计算结果的聚合函数(aggfunc)是 numpy.sum 函数(如果我没有指定任何内容,numpy.mean 将是默认值)。

创建了数据透视表之后,我们只需用单元格中的最后一行打印它,这里是结果的一部分:

当数据有意义时,它非常清晰并提供非常有用的信息。

就是这样!我会让你自己去探索 IPython、Jupyter 和数据科学的美妙世界。我强烈建议你熟悉 Notebook 环境。它比控制台好得多,非常实用和有趣,你甚至可以用它创建幻灯片和文档。

接下来我们去哪里?

数据科学确实是一个迷人的课题。正如我在介绍中所说的,那些想要深入研究它的人需要在数学和统计学方面接受良好的训练。与插值不正确的数据一起工作会使得任何关于它的结果变得毫无意义。同样,对于不正确外推或以错误频率采样的数据也是如此。举个例子,想象一群排队的人,如果由于某种原因,这群人的性别在男女之间交替,那么排队就会是这样:F-M-F-M-F-M-F-M-F...

如果你只取偶数元素进行采样,你会得出结论说这个群体只由男性组成,而采样奇数元素会告诉你完全相反的结论。

当然,这只是一个愚蠢的例子,我知道,但在这个领域很容易犯错,特别是在处理大数据时,采样是强制性的,因此,你所做的内省的质量首先取决于采样本身的质量。

在数据科学和 Python 方面,这些是你想要了解的主要工具:

除了这些单一的库之外,你还可以找到生态系统,比如SciPy (scipy.org/) 和前面提到的Anaconda (anaconda.org/),它们捆绑了几个不同的软件包,以便为您提供一个“开箱即用”的解决方案。

在一些系统上安装所有这些工具及其多个依赖项是很困难的,所以我建议你也尝试一下生态系统,看看你是否对它们感到舒适。这可能是值得的。

总结

在这一章中,我们谈到了数据科学。我们并没有试图解释这个极其广泛的主题,而是深入了一个项目。我们熟悉了 Jupyter Notebook,以及不同的库,比如 Pandas、Matplotlib 和 NumPy。

当然,不得不把所有这些信息压缩到一个章节中意味着我只能简要地涉及我提出的主题。我希望我们一起经历的项目足够全面,让你对在这个领域工作时可能遵循的工作流程有所了解。

下一章专门讨论网页开发。所以,请确保你已经准备好浏览器,让我们开始吧!

第十四章:Web 开发

“不要相信你在网上看到的一切。”– 孔子

在本章中,我们将一起开发一个网站。通过参与一个小项目,我的目标是为你打开一扇窗,让你窥探一下 Web 开发是什么,以及你应该了解的主要概念和工具,如果你想在这方面取得成功的话。

特别是,我们将探索以下内容:

  • Web 编程的基本概念

  • Django Web 框架

  • 正则表达式

  • Flask 和 Falcon Web 框架的简要概述

让我们从基础知识开始。

什么是网络?

万维网WWW),或者简称为web,是通过使用称为互联网的媒介来访问信息的一种方式。互联网是一个庞大的网络,一个网络基础设施。它的目的是连接全球各地的数十亿设备,以便它们可以相互通信。信息通过互联网以丰富多样的语言(称为协议)传输,这些语言允许不同的设备说同一种语言以共享内容。

网络是一种建立在互联网之上的信息共享模型,它以超文本传输协议HTTP)作为数据通信的基础。因此,网络只是互联网上可以交换信息的几种不同方式之一;电子邮件、即时通讯、新闻组等都依赖于不同的协议。

网络是如何工作的?

简而言之,HTTP 是一种不对称的请求-响应 客户端-服务器协议。HTTP 客户端向 HTTP 服务器发送请求消息。服务器反过来返回一个响应消息。换句话说,HTTP 是一个拉取协议,客户端从服务器拉取信息(与推送协议相对,服务器向客户端推送信息)。看一下下面的图表:

HTTP 基于TCP/IP(或者传输控制协议/互联网协议),它提供了可靠的通信交换工具。

HTTP 协议的一个重要特点是它是无状态的。这意味着当前请求对之前的请求发生的事情一无所知。这是一个限制,但你可以以被登录的幻觉浏览网站。然而,在幕后,发生的是,在登录时,用户信息的令牌被保存(通常在客户端,称为cookies的特殊文件中),以便用户发出的每个请求都携带服务器识别用户并提供自定义界面的手段,比如显示他们的名字,保持他们的购物篮中的物品等等。

尽管它非常有趣,我们不打算深入研究 HTTP 的丰富细节以及它的工作原理。然而,我们将要编写一个小型网站,这意味着我们将不得不编写处理 HTTP 请求并返回 HTTP 响应的代码。从现在开始,我不会再在术语请求和响应前面加上 HTTP,因为我相信不会有任何混淆。

Django Web 框架

对于我们的项目,我们将使用 Python 生态系统中最流行的 Web 框架之一:Django。

Web 框架是一组工具(库、函数、类等),我们可以用它们来编写网站。我们需要决定允许针对我们的 Web 服务器发出什么样的请求以及我们如何对它们做出响应。Web 框架是做到这一点的完美工具,因为它为我们处理了许多事情,这样我们就可以专注于重要的部分,而不必重新发明轮子。

有不同类型的框架。并非所有框架都是为编写网络代码而设计的。一般来说,框架是一种提供功能以便于开发软件应用程序、产品和解决方案的工具。

Django 设计哲学

Django 根据以下原则设计:

  • 不要重复自己DRY):不要重复编写代码,并以尽可能从尽可能少的代码中推断出尽可能多的框架。

  • 松耦合:框架的各个层次不应该彼此了解(除非出于绝对必要的原因)。松耦合最适合与高内聚并行。将因同一原因而改变的事物放在一起,将因不同原因而改变的事物分开。

  • 少量代码:应用程序应尽可能使用最少的代码,并以尽可能有利于重用的方式编写。

  • 一致性:使用 Django 框架时,无论您正在编写哪个层,您的体验都将与选择布置项目的设计模式和范例非常一致。

框架本身是围绕模型-模板-视图MTV)模式设计的,这是模型-视图-控制器MVC)的一个变体,其他框架广泛采用。这些模式的目的是分离关注点,促进代码重用和质量。

模型层

在这三个层次中,这一层定义了应用程序处理的数据结构,并处理数据源。模型是表示数据结构的类。通过一些 Django 魔术,模型被映射到数据库表,以便您可以将数据存储在关系数据库中。

关系数据库以表格形式存储数据,其中每一列都是数据的属性,每一行代表该表格所代表的集合中的单个项目或条目。通过每个表格的主键,即允许其唯一标识每个项目的数据部分,可以建立不同表格中项目之间的关系,即将它们放入关系中。

这个系统的美妙之处在于,您不必编写特定于数据库的代码来处理数据。您只需正确配置模型并使用它们。Django 的对象关系映射ORM)为您处理数据库的工作,它负责将在 Python 对象上执行的操作转换为关系数据库可以理解的语言:SQL(或结构化查询语言)。我们在第七章中看到了 ORM 的一个例子,即文件和数据持久性,在那里我们探讨了 SQLAlchemy。

这种方法的一个好处是,您可以在不重写代码的情况下更改数据库,因为 Django 会根据连接的数据库动态生成所有特定于数据库的代码。关系数据库使用 SQL,但每个数据库都有其自己独特的风格;因此,在我们的应用程序中不需要硬编码任何 SQL 是一个巨大的优势。

Django 允许您随时修改模型。当您这样做时,您可以运行一个命令来创建一个迁移,这是将数据库移植到表示模型当前定义的状态的一组指令。

总之,这一层处理定义您的网站中需要处理的数据结构,并为您提供通过简单访问模型来保存和加载数据到数据库的手段,这些模型是 Python 对象。

视图层

视图的功能是处理请求,执行需要执行的任何操作,并最终返回响应。例如,如果您打开浏览器并请求电子商务商店中产品类别对应的页面,视图可能会与数据库交互,请求所有作为所选类别子类的类别(例如,在导航侧栏中显示它们)以及属于所选类别的所有产品,以便在页面上显示它们。

因此,视图是我们可以满足请求的机制。其结果,响应对象,可以呈现为多种不同形式:JSON 有效载荷、文本、HTML 页面等。当您编写网站时,您的响应通常由 HTML 或 JSON 组成。

超文本标记语言,或HTML,是用于创建网页的标准标记语言。Web 浏览器运行引擎,能够解释 HTML 代码并将其呈现为我们在打开网站页面时看到的内容。

模板层

这是提供后端和前端开发之间桥梁的层。当视图必须返回 HTML 时,通常会通过准备一个上下文对象(字典)与一些数据,然后将此上下文提供给模板,模板会被呈现(即转换为 HTML),并以响应的形式返回给调用者(更准确地说,是响应的主体)。这种机制允许最大程度地重用代码。如果回到类别的例子,很容易看出,如果您浏览销售产品的网站,您点击哪个类别或执行何种类型的搜索并不重要,产品页面的布局不会改变。改变的是用于填充该页面的数据。

因此,页面的布局是由模板定义的,该模板是用 HTML 和 Django 模板语言编写的混合体。提供页面的视图会在上下文字典中收集所有要显示的产品,并将其提供给模板,然后由 Django 模板引擎将其呈现为 HTML 页面。

Django URL 分发器

Django 将统一资源定位符URL)与视图关联的方式是通过将请求的 URL 与注册在特殊文件中的模式进行匹配。URL 代表网站中的一个页面,因此http://mysite.com/categories?id=123可能指向我的网站上 ID 为123的类别页面,而https://mysite.com/login可能是用户登录页面。

HTTP 和 HTTPS 之间的区别在于后者为协议添加了加密,以确保您与网站交换的数据是安全的。当您在网站上输入信用卡详细信息,或者在任何地方登录,或者处理敏感数据时,您希望确保您正在使用 HTTPS。

正则表达式

Django 将 URL 与模式匹配的方式是通过正则表达式。正则表达式是一系列字符,用于定义搜索模式,我们可以执行操作,如模式和字符串匹配,查找/替换等。

正则表达式具有特殊的语法,用于指示诸如数字、字母和空格等内容,以及我们期望字符出现的次数等内容。本书不涵盖此主题的完整解释。然而,这是一个非常重要的主题,因此我们将一起开展的项目将围绕它展开,希望您能有时间自行探索一下。

举个快速的例子,假设您想指定一个匹配日期的模式,比如 "26-12-1947"。这个字符串由两个数字、一个破折号、两个数字、一个破折号,最后是四个数字。因此,我们可以这样写:r'[0-9]{2}-[0-9]{2}-[0-9]{4}'。我们使用方括号创建了一个类,并在其中定义了一个数字范围,从 09,因此包括了所有可能的数字。然后,在花括号中,我们说我们期望其中有两个。然后是一个破折号,然后我们重复这个模式一次,然后再一次,通过改变我们期望的数字数量,而最后没有破折号。拥有一个类,比如 [0-9] 是一个如此常见的模式,以至于已经创建了一个特殊的符号作为快捷方式:'\d'。因此,我们可以这样重写这个模式:r'\d{2}-\d{2}-\d{4}',它将完全相同地工作。字符串前面的那个 r 代表 原始,它的目的是防止 Python 尝试解释反斜杠转义序列,以便它们可以原样传递给正则表达式引擎。

一个正则表达式网站

所以,我们要编写一个网站,用于存储正则表达式,以便我们可以稍微玩一下。

在我们继续创建项目之前,我想谈谈 层叠样式表CSS)。CSS 是我们在其中指定 HTML 页面上各种元素的外观的文件。您可以设置各种属性,比如形状、大小、颜色、边距、边框和字体。在这个项目中,我已经尽力在页面上取得了一个体面的结果,但我既不是前端开发人员也不是设计师,所以请不要太在意事物的外观。请尽量关注它们的工作原理。

设置 Django

在 Django 网站 (www.djangoproject.com/) 上,您可以按照教程,这会让您对 Django 的功能有一个很好的了解。如果您愿意,您可以先按照该教程操作,然后再回到这个例子。所以,首先要做的事情是;让我们在您的虚拟环境中安装 Django(您会发现它已经安装好了,因为它是要求文件的一部分):

$ pip install django  

当这个命令完成后,您可以在控制台中测试它(尝试使用 bpython,它会给您一个类似于 IPython 但具有良好内省能力的 shell):

>>> import django
>>> django.VERSION
(2, 0, 5, 'final', 0) 

现在 Django 已经安装好了,我们可以开始了。我们需要做一些搭建工作,所以我会快速指导您完成。

启动项目

选择书中环境中的一个文件夹并切换到其中。我会使用 ch14。从那里,我们可以使用以下命令启动一个 Django 项目:

$ django-admin startproject regex  

这将为一个名为 regex 的 Django 项目准备好骨架。切换到 regex 文件夹并运行以下命令:

$ python manage.py runserver  

您应该能够在浏览器中输入 http://127.0.0.1:8000/ 并看到 It worked! 默认的 Django 页面。这意味着项目已经正确设置。当您看到页面后,使用 Ctrl + C 杀死服务器(或者控制台上显示的其他指令)。我现在将粘贴项目的最终结构,以便您可以用作参考:

$ tree -A regex  # from the ch14 folder
regex
├── entries
│ ├── __init__.py
│ ├── admin.py
│ ├── forms.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ └── __init__.py
│ ├── models.py
│ ├── static
│ │ └── entries
│ │ └── css
│ │ └── main.css
│ ├── templates
│ │ └── entries
│ │ ├── base.html
│ │ ├── footer.html
│ │ ├── home.html
│ │ ├── insert.html
│ │ └── list.html
│ └── views.py
├── manage.py
└── regex
 ├── __init__.py
 ├── settings.py
 ├── urls.py
 └── wsgi.py

如果您缺少文件,不要担心,我们会解决的。一个 Django 项目通常是由几个不同的应用程序集合组成的。每个应用程序都旨在以一种独立的、可重用的方式提供功能。我们将创建一个名为 entries 的应用程序:

$ python manage.py startapp entries  

在创建的 entries 文件夹中,您可以删除 tests.py 模块。

现在,让我们修复 regex/settings.py 文件在 regex 文件夹中。我们需要将我们的应用程序添加到 INSTALLED_APPS 列表中,以便我们可以使用它(将其添加到列表的底部):

INSTALLED_APPS = [
    'django.contrib.admin',
    ...
    'entries',
]

然后,您可能想根据个人喜好设置语言和时区。我住在伦敦,所以我设置它们如下:

LANGUAGE_CODE = 'en-gb'
TIME_ZONE = 'Europe/London'

在这个文件中没有其他事情要做,所以您可以保存并关闭它。

现在是时候将迁移应用到数据库了。Django 需要数据库支持来处理用户、会话等内容,所以我们需要创建一个数据库并用必要的数据填充它。幸运的是,这可以很容易地通过以下命令完成:

$ python manage.py migrate  

对于这个项目,我们使用 SQLite 数据库,它基本上只是一个文件。在一个真正的项目中,你会使用不同的数据库引擎,比如 MySQL 或 PostgreSQL。

创建用户

现在我们有了一个数据库,我们可以使用控制台创建一个超级用户:

$ python manage.py createsuperuser  

输入用户名和其他细节后,我们有了一个具有管理员特权的用户。这足以访问 Django 管理部分,所以尝试启动服务器:

$ python manage.py runserver  

这将启动 Django 开发服务器,这是一个非常有用的内置 Web 服务器,你可以在使用 Django 时使用它。现在服务器正在运行,我们可以在http://localhost:8000/admin/访问管理页面。稍后我会向你展示这部分的截图。如果你用刚刚创建的用户的凭据登录并转到身份验证和授权部分,你会找到用户。打开它,你就能看到用户列表。你可以作为管理员编辑任何用户的细节。在我们的情况下,请确保你创建一个不同的用户,这样系统中至少有两个用户(我们以后会需要)。我会称第一个用户为 Fabrizio(用户名:fab),第二个用户为 Adriano(用户名:adri),以纪念我的父亲。

顺便说一句,你应该看到 Django 管理面板是自动免费提供的。你定义你的模型,连接它们,就这样。这是一个令人难以置信的工具,显示了 Django 的内省能力有多么先进。而且,它是完全可定制和可扩展的。这真是一项出色的工作。

添加 Entry 模型

既然样板代码已经完成,我们有了一些用户,我们准备好编码了。我们首先要将Entry模型添加到我们的应用程序中,以便我们可以将对象存储在数据库中。以下是你需要添加的代码(记得使用项目树作为参考):

# entries/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone

class Entry(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    pattern = models.CharField(max_length=255)
    test_string = models.CharField(max_length=255)
    date_added = models.DateTimeField(default=timezone.now)

    class Meta:
        verbose_name_plural = 'entries'

这是我们将用来在系统中存储正则表达式的模型。我们将存储一个模式、一个测试字符串、对创建条目的用户的引用以及创建的时刻。你可以看到创建一个模型实际上是相当容易的,但尽管如此,让我们逐行过一遍。

首先,我们需要从django.db中导入models模块。这将为我们提供Entry模型的基类。Django 模型是特殊的类,当我们从models.Model继承时,很多工作都是在幕后为我们完成的。

我们需要一个对创建条目的用户的引用,所以我们需要从 Django 的授权应用程序中导入User模型,我们还需要导入timezone模型以访问timezone.now()函数,该函数为我们提供了datetime.now()timezone-aware 版本。这样做的美妙之处在于它与我之前向你展示的TIME_ZONE设置相连。

至于这个类的主键,如果我们不明确设置一个,Django 会为我们添加一个。主键是一个允许我们在数据库中唯一标识Entry对象的键(在这种情况下,Django 将添加一个自增的整数 ID)。

因此,我们定义了我们的类,并设置了四个类属性。我们有一个ForeignKey属性,它是我们对User模型的引用。我们还有两个CharField属性,用于保存我们的正则表达式的模式和测试字符串。我们还有一个DateTimeField,其默认值设置为timezone.now。请注意,我们不在那里调用timezone.now,它是now,而不是now()。因此,我们不是传递一个DateTime实例(在解析该行时设置的时间点),而是传递一个可调用的函数,在保存数据库中的条目时调用该函数。这类似于我们在第十二章中使用的回调机制,GUIs and Scripts,当我们将命令分配给按钮点击时。

最后两行非常有趣。我们在Entry类本身内部定义了一个Meta类。Meta类被 Django 用来为模型提供各种额外信息。Django 在幕后有很多逻辑,根据我们放入Meta类的信息来调整其行为。在这种情况下,在管理面板中,Entry的复数形式将是Entrys,这是错误的,因此我们需要手动设置它。我们将复数形式指定为全部小写,因为 Django 在需要时会为我们大写它。

现在我们有了一个新模型,我们需要更新数据库以反映代码的新状态。为了做到这一点,我们需要告诉 Django 它需要创建更新数据库的代码。这段代码被称为迁移。让我们创建并执行它:

$ python manage.py makemigrations entries
$ python manage.py migrate  

在这两个指令之后,数据库将准备好存储Entry对象。

有两种不同类型的迁移:数据迁移和模式迁移。数据迁移将数据从一种状态转移到另一种状态,而不改变其结构。例如,数据迁移可以通过将标志切换为False0来将某个类别的所有产品设置为缺货。模式迁移是一组指令,用于改变数据库模式的结构。例如,可以向Person表添加一个age列,或者增加字段的最大长度以适应非常长的地址。在使用 Django 进行开发时,通常需要在开发过程中执行这两种类型的迁移。数据不断发展,特别是在敏捷环境中编码时。

自定义管理面板

下一步是将Entry模型与管理面板连接起来。您可以用一行代码做到这一点,但在这种情况下,我想添加一些选项来自定义管理面板显示条目的方式,无论是在数据库中的所有条目的列表视图中,还是在允许我们创建和修改它们的表单视图中。

我们只需要添加以下代码:

# entries/admin.py
from django.contrib import admin
from .models import Entry

@admin.register(Entry)
class EntryAdmin(admin.ModelAdmin):
    fieldsets = [
        ('Regular Expression',
         {'fields': ['pattern', 'test_string']}),
        ('Other Information',
         {'fields': ['user', 'date_added']}),
    ]
    list_display = ('pattern', 'test_string', 'user')
    list_filter = ['user']
    search_fields = ['test_string']

这真是太美了。我猜你可能已经理解了大部分内容,即使你是 Django 的新手。

因此,我们首先导入admin模块和Entry模型。因为我们想要促进代码重用,我们使用相对导入导入Entry模型(在models之前有一个点)。这将使我们能够在不太麻烦的情况下移动或重命名应用程序。然后,我们定义EntryAdmin类,它继承自admin.ModelAdmin。类上的装饰告诉 Django 在管理面板中显示Entry模型,我们在EntryAdmin类中放入的内容告诉 Django 如何自定义处理这个模型。

首先,我们为创建/编辑页面指定fieldsets。这将把页面分成两个部分,以便我们更好地可视化内容(模式和测试字符串)和其他细节(用户和时间戳)分开。

然后,我们自定义列表页面显示结果的方式。我们想要看到所有字段,但不包括日期。我们还希望能够按用户进行筛选,以便我们可以列出一个用户的所有条目,并且我们希望能够在test_string上进行搜索。

我将继续添加三个条目,一个是我自己的,另外两个是我父亲的。结果显示在接下来的两个屏幕截图中。插入它们后,列表页面如下所示:

我已经突出显示了我们在EntryAdmin类中自定义的此视图的三个部分。我们可以按用户筛选,可以搜索,并且所有字段都显示出来。如果单击模式,则会打开编辑视图。

在我们的自定义之后,它看起来像这样:

请注意我们有两个部分:正则表达式和其他信息,这要归功于我们自定义的EntryAdmin类。试一试,为几个不同的用户添加一些条目,熟悉界面。免费获得所有这些东西是不是很好?

创建表单

每当您在网页上填写详细信息时,您都在表单字段中插入数据。表单是 HTML 文档对象模型DOM)树的一部分。在 HTML 中,您可以使用form标签创建一个表单。当您单击提交按钮时,您的浏览器通常会将form数据打包在一起,并将其放入POST请求的正文中。与用于向 Web 服务器请求资源的GET请求相反,POST请求通常会将数据发送到 Web 服务器,目的是创建或更新资源。因此,处理POST请求通常需要比处理GET请求更多的注意。

当服务器接收到来自POST请求的数据时,需要对这些数据进行验证。此外,服务器需要采用安全机制来防范各种类型的攻击。一种非常危险的攻击是跨站点请求伪造CSRF)攻击,当数据从未经用户认证的域发送时就会发生。Django 允许您以非常优雅的方式处理这个问题。

因此,与其懒惰地使用 Django 管理员来创建条目,我将向您展示如何使用 Django 表单来完成。通过使用框架提供的工具,您已经完成了非常好的验证工作(实际上,我们不需要添加任何自定义验证)。

在 Django 中有两种形式类:FormModelForm。您可以使用前者创建一个表单,其形状和行为取决于您如何编写类,添加哪些字段等。另一方面,后者是一种类型的表单,尽管仍然可定制,但是从模型中推断字段和行为。由于我们需要一个Entry模型的表单,我们将使用这个:

# entries/forms.py
from django.forms import ModelForm
from .models import Entry

class EntryForm(ModelForm):
    class Meta:
        model = Entry
        fields = ['pattern', 'test_string']

令人惊讶的是,这就是我们需要做的一切,就可以在页面上放置一个表单。这里唯一值得注意的是,我们将字段限制为patterntest_string。只有已登录的用户才能访问插入页面,因此我们不需要询问用户是谁,我们已经知道了。至于日期,当我们保存一个Entry时,date_added字段将根据其默认设置,因此我们也不需要指定。我们将在视图中看到如何在保存之前向表单提供用户信息。因此,现在背景工作已经完成,我们所需要的只是视图和模板。让我们从视图开始。

编写视图

我们需要编写三个视图。我们需要一个用于主页的视图,一个用于显示用户的所有条目的列表,以及一个用于创建新条目的视图。我们还需要登录和注销视图。但幸运的是,由于 Django,我们不需要编写它们。我将逐步粘贴代码:

# entries/views.py
import re
from django.contrib.auth.decorators import login_required
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView
from .forms import EntryForm
from .models import Entry

让我们从导入开始。我们需要re模块来处理正则表达式,然后我们需要从 Django 中导入一些类和函数,最后,我们需要Entry模型和EntryForm表单。

主页视图

第一个视图是HomeView

# entries/views.py
class HomeView(TemplateView):
    template_name = 'entries/home.html'

    @method_decorator(
        login_required(login_url=reverse_lazy('login')))
    def get(self, request, *args, **kwargs):
        return super(HomeView, self).get(request, *args, **kwargs)

它继承自TemplateView,这意味着响应将通过渲染视图中创建的上下文来创建。我们所要做的就是指定template_name类属性,指向正确的模板。Django 在代码重用方面的推广程度达到了一个程度,以至于如果我们不需要使此视图仅对已登录用户可访问,前两行就足够了。

然而,我们希望此视图仅对已登录用户可访问;因此,我们需要用login_required进行装饰。现在,历史上 Django 中的视图是函数;因此,这个装饰器是设计为接受一个函数的,而不是我们在这个类中拥有的方法。在这个项目中,我们使用 Django 基于类的视图,因此为了使事情正常工作,我们需要转换login_required,以便它接受一个方法(不同之处在于第一个参数:self)。我们通过将login_required传递给method_decorator来实现这一点。

我们还需要向login_required装饰器提供login_url信息,这里又出现了 Django 的另一个精彩特性。正如在我们完成视图后所看到的那样,在 Django 中,您通过模式将视图绑定到 URL,该模式由一个字符串组成,该字符串可能是正则表达式,也可能是其他信息。您可以在urls.py文件中为每个条目指定一个名称,以便在引用 URL 时,您不必将其值硬编码到代码中。您只需让 Django 从我们在urls.py中为条目指定的名称中反向工程出该 URL,定义 URL 和绑定到它的视图。这种机制稍后将变得更加清晰。现在,只需将reverse('...')视为从标识符获取 URL 的一种方式。通过这种方式,您只需在urls.py文件中写入实际的 URL 一次,这是非常出色的。在views.py代码中,我们需要使用reverse_lazy,它的工作原理与reverse完全相同,只有一个主要区别:它只在我们实际需要时才找到 URL(以懒惰的方式)。reverse_lazy之所以如此有用的原因是,有时可能需要从标识符反转 URL,但在调用reverse时,urls.py模块尚未加载,这会导致失败。reverse_lazy的懒惰行为解决了这个问题,因为即使在urls.py模块加载之前进行调用,从标识符反转到相关 URL 的实际过程也是以懒惰的方式进行的,稍后,当urls.py肯定已经加载时。

我们刚刚装饰的get方法只是简单地调用了父类的get方法。当然,get方法是 Django 在针对与此视图绑定的 URL 执行GET请求时调用的方法。

条目列表视图

这个视图比之前的要有趣得多:

# entries/views.py
class EntryListView(TemplateView):
    template_name = 'entries/list.html'

    @method_decorator(
        login_required(login_url=reverse_lazy('login')))
    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        entries = Entry.objects.filter(
            user=request.user).order_by('-date_added')
        matches = (self._parse_entry(entry) for entry in entries)
        context['entries'] = list(zip(entries, matches))
        return self.render_to_response(context)

    def _parse_entry(self, entry):
        match = re.search(entry.pattern, entry.test_string)
        if match is not None:
            return (
                match.group(),
                match.groups() or None,
                match.groupdict() or None
            )
        return None

首先,我们像之前一样装饰get方法。在其中,我们需要准备一个Entry对象列表并将其提供给模板,然后将其显示给用户。为了做到这一点,我们首先获取context字典,调用TemplateView类的get_context_data方法。然后,我们使用 ORM 获取条目列表。我们通过访问对象管理器并对其进行过滤来执行此操作。我们根据登录的用户对条目进行过滤,并要求它们按降序排序(名称前面的'-'指定了降序排序)。objects管理器是每个 Django 模型在创建时都会增强的默认管理器:它允许我们通过其方法与数据库进行交互。

我们解析每个条目以获得匹配项列表(实际上,我编码使matches成为一个生成器表达式)。最后,我们向上下文添加了一个'entries'键,其值是entriesmatches的耦合,以便每个Entry实例与其模式和测试字符串的结果匹配配对。

在最后一行,我们简单地要求 Django 使用我们创建的上下文来呈现模板。

看一下_parse_entry方法。它所做的就是对entry.test_stringentry.pattern进行搜索。如果结果的match对象不是None,那么意味着我们找到了一些东西。如果是这样,我们返回一个包含三个元素的元组:总体组、子组和组字典。

注意,match.groups()match.groupdict()可能分别返回一个空元组和一个空字典。为了将空结果规范化为更简单的None,我使用了 Python 中的一个常见模式,即利用or运算符。实际上,A or B将返回A,如果A评估为真值,否则返回B。你能想到这与and运算符的行为有何不同吗?

如果你对这些术语不熟悉,不用担心,你很快就会看到一个例子的截图。如果没有匹配,我们返回None(从技术上讲,这并不需要,因为 Python 会自动执行,但我在这里包含它是为了明确起见)。

表单视图

最后,让我们来看一下EntryFormView

# entries/views.py
class EntryFormView(SuccessMessageMixin, FormView):
    template_name = 'entries/insert.html'
    form_class = EntryForm
    success_url = reverse_lazy('insert')
    success_message = "Entry was created successfully"

    @method_decorator(
        login_required(login_url=reverse_lazy('login')))
    def get(self, request, *args, **kwargs):
        return super(EntryFormView, self).get(
            request, *args, **kwargs)

    @method_decorator(
        login_required(login_url=reverse_lazy('login')))
    def post(self, request, *args, **kwargs):
        return super(EntryFormView, self).post(
            request, *args, **kwargs)

    def form_valid(self, form):
        self._save_with_user(form)
        return super(EntryFormView, self).form_valid(form)

    def _save_with_user(self, form):
        self.object = form.save(commit=False)
        self.object.user = self.request.user
        self.object.save()

这对几个原因来说特别有趣。首先,它向我们展示了 Python 的多重继承的一个很好的例子。我们想在插入Entry后在页面上显示一条消息,所以我们继承自SuccessMessageMixin。但是我们也想处理一个表单,所以我们还继承自FormView

请注意,当处理混合和继承时,您可能需要考虑在类声明中指定基类的顺序,因为这将影响在继承链上传递调用时方法的查找方式。

为了正确设置这个视图,我们需要在开始时指定一些属性:要呈现的模板、用于处理来自POST请求的数据的表单类、在成功时需要将用户重定向到的 URL 以及成功消息。

另一个有趣的特点是,这个视图需要处理GETPOST请求。当我们第一次进入表单页面时,表单是空的,这是GET请求。另一方面,当我们填写表单并想要提交Entry时,我们会发出POST请求。你可以看到get的主体在概念上与HomeView是相同的。Django 为我们做了一切。

post方法就像get一样。我们需要编写这两种方法的唯一原因是为了能够装饰它们以要求登录。

在 Django 的表单处理过程中(在FormView类中),有一些方法可以重写以自定义整体行为。我们需要使用form_valid方法。当表单验证成功时,将调用此方法。它的目的是保存表单,以便从中创建一个Entry对象,然后将其存储在数据库中。

唯一的问题是我们的表单缺少用户。我们需要在调用链的某个时刻拦截并自己放入用户信息。这是通过调用_save_with_user方法来完成的,非常简单。

首先,我们要求 Django 使用commit参数设置为False保存表单。这将创建一个Entry实例,而不会尝试将其保存到数据库中。立即保存它会失败,因为user信息不在那里。

下一行更新了Entry实例(self.object),添加了user信息,并且在最后一行,我们可以安全地保存它。我之所以这样调用object并将其设置在实例上,是为了遵循原始的FormView类的做法。

我们在这里玩弄 Django 的机制,因此如果我们希望整个事情能够正常工作,我们需要注意何时以及如何修改它的行为,并确保我们不会错误地改变它。因此,非常重要的是要记住在我们自定义的版本的最后调用基类的form_valid方法(我们使用super来实现),以确保该方法通常执行的所有其他操作都能正确执行。

请注意,请求与每个视图实例(self.request)相关联,因此当我们将我们的逻辑重构为方法时,我们不需要通过它传递。还要注意,用户信息已经被 Django 自动添加到请求中。最后,将整个过程分成非常小的方法的原因是,这样我们只需要覆盖我们需要自定义的方法。所有这些都消除了编写大量代码的需要。

现在我们已经涵盖了视图,让我们看看如何将它们与 URL 配对。

绑定 URL 和视图

urls.py模块中,我们将每个视图与一个 URL 绑定。有许多方法可以做到这一点。我选择了最简单的方法,这对于这个练习的范围来说完全有效,但如果你打算使用 Django,你可能需要更深入地探索这个主题。这是整个网站逻辑的核心,因此你应该努力正确地理解它。请注意,urls.py模块属于项目文件夹:

# regex/urls.py
from django.contrib import admin
from django.urls import path
from django.contrib.auth import views as auth_views
from django.urls import reverse_lazy
from entries.views import HomeView, EntryListView, EntryFormView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('entries/', EntryListView.as_view(), name='entries'),
    path('entries/insert',
        EntryFormView.as_view(),
        name='insert'),

    path('login/',
        auth_views.login,
        kwargs={'template_name': 'admin/login.html'},
        name='login'),
    path('logout/',
        auth_views.logout,
        kwargs={'next_page': reverse_lazy('home')},
        name='logout'),

    path('', HomeView.as_view(), name='home'),
] 

如果你熟悉 Django 的 1.x 版本,你会注意到一些区别,因为这个项目是在 2.x 版本中编写的。正如你所看到的,这种魔法来自path函数,它最近取代了url函数。首先,我们传递一个路径字符串(也称为路由),然后是视图,最后是一个名称,这是我们将在reversereverse_lazy函数中使用的,以恢复 URL。

请注意,当使用基于类的视图时,我们必须将它们转换为函数,这是path所期望的。为此,我们在它们上调用as_view()方法。

还要注意,对于管理员的第一个path条目是特殊的。它不是指定一个 URL 和一个视图,而是指定一个 URL 前缀和另一个urls.py模块(来自admin.site包)。这样,Django 将通过在所有在admin.site.urls中指定的 URL 前面添加'admin/'来完成管理员部分的所有 URL。我们本可以对我们的 entries 应用程序做同样的事情(而且我们应该这样做),但我觉得对于这个简单的项目来说有点过度了。

在这个模块中定义的 URL 路径是如此简单,以至于不需要定义任何正则表达式。如果你需要使用正则表达式,你可以查看re_path函数,它就是为此目的而设计的。

我们还通过使用直接来自django.contrib.auth包的视图,包括登录和注销功能。我们用必要的信息(例如注销视图的下一页)丰富了声明,而且我们不需要编写任何处理身份验证的代码。这很棒,节省了我们很多时间。

每个path声明必须在urlpatterns列表中完成,在这个问题上,重要的是要考虑到,当 Django 试图为已请求的 URL 找到一个视图时,模式是按顺序从上到下进行的。第一个匹配的模式将为其提供视图,因此一般来说,你必须在通用模式之前放置特定模式,否则它们永远不会有机会被捕获。举个例子,使用正则表达式在路由声明中,'^shop/categories/$'需要放在'^shop'之前(注意'$'表示模式的结尾,在后者中没有指定),否则它永远不会被调用。

因此,模型、表单、管理员、视图和 URL 都已完成。剩下的就是处理模板。我必须在这部分非常简要,因为 HTML 可能非常冗长。

编写模板

所有模板都继承自一个基本模板,它为所有其他模板提供 HTML 结构,以一种非常面向对象的编程(OOP)方式。它还指定了一些块,这些块是可以被子模板覆盖的区域,以便它们可以为这些区域提供自定义内容。让我们从基本模板开始:

# entries/templates/entries/base.html
{% load static from staticfiles %}
<!DOCTYPE html>
<html lang="en">
  <head>
    {% block meta %}
      <meta charset="utf-8">
      <meta name="viewport"
       content="width=device-width, initial-scale=1.0">
    {% endblock meta %}

    {% block styles %}
      <link href="{% static "entries/css/main.css" %}"
       rel="stylesheet">
    {% endblock styles %}

    <title> {% block title %}Title{% endblock title %} </title>
  </head>

  <body>
    <div id="page-content">
      {% block page-content %}
      {% endblock page-content %}
    </div>
    <div id="footer">
      {% block footer %}
      {% endblock footer %}
    </div>
  </body>
</html>

templates中的entries文件夹重复entries文件夹有一个很好的理由。

当您部署 Django 网站时,您需要将所有模板文件收集到一个文件夹下。如果您不像我一样指定路径,您可能会在 entries 应用程序中得到一个base.html模板,在另一个应用程序中得到一个base.html模板。最后收集的文件将覆盖同名的任何其他文件。因此,通过将它们放在templates/entries文件夹中,并对每个您编写的 Django 应用程序使用这种技术,您可以避免名称冲突的风险(对于任何其他静态文件也是如此)。

关于这个模板,实际上没有什么可说的,除了它加载了static标签,以便我们可以轻松访问static路径,而不需要在模板中硬编码使用{% static ... %}。特殊的{% ... %}部分中的代码定义了逻辑。特殊的{{ ... }}中的代码表示将在页面上呈现的变量。

我们定义了五个块:stylesmetatitlepage-contentfooter,它们的目的是保存元数据,样式信息,标题,页面内容和页脚。块可以选择性地被子模板覆盖,以便在其中提供不同的内容。

这是页脚:

# entries/templates/entries/footer.html
<div class="footer">
  Go back <a href="{% url "home" %}">home</a>.
</div>

它为我们提供了一个指向主页的链接,这个链接来自以下模板:

# entries/templates/entries/home.html
{% extends "entries/base.html" %}
{% block title%}Welcome to the Entry website.{% endblock title %}

{% block page-content %}
  <h1>Welcome {{ user.first_name }}!</h1>

  <div class="home-option">To see the list of your entries
    please click <a href="{% url "entries" %}">here.</a>
  </div>
  <div class="home-option">To insert a new entry please click
    <a href="{% url "insert" %}">here.</a>
  </div>
  <div class="home-option">To login as another user please click
    <a href="{% url "logout" %}">here.</a>
  </div>
    <div class="home-option">To go to the admin panel
    please click <a href="{% url "admin:index" %}">here.</a>
  </div>
{% endblock page-content %}

它扩展了base.html模板,并覆盖了titlepage-content。您可以看到它基本上只是为用户提供了四个链接。这些是条目列表,插入页面,注销页面和管理页面。所有这些都是在不硬编码任何 URL 的情况下完成的,通过使用{% url ... %}标签,这是reverse函数的模板等效。

插入Entry的模板如下:

# entries/templates/entries/insert.html
{% extends "entries/base.html" %}
{% block title%}Insert a new Entry{% endblock title %}

{% block page-content %}
  {% if messages %}
    {% for message in messages %}
      <p class="{{ message.tags }}">{{ message }}</p>
    {% endfor %}
  {% endif %}

  <h1>Insert a new Entry</h1>
  <form action="{% url "insert" %}" method="post">
    {% csrf_token %}{{ form.as_p }}
    <input type="submit" value="Insert">
  </form><br>
{% endblock page-content %}

{% block footer %}
  <div><a href="{% url "entries" %}">See your entries.</a></div>
  {% include "entries/footer.html" %}
{% endblock footer %}

在开始时有一些条件逻辑来显示消息(如果有的话),然后我们定义表单。Django 让我们能够通过简单调用{{ form.as_p }}(或者form.as_ulform.as_table)来呈现一个表单。这为我们创建了所有必要的字段和标签。这三个命令之间的区别在于表单的布局方式:作为段落,作为无序列表或作为表格。我们只需要将其包装在表单标签中并添加一个提交按钮。这种行为是为了我们的方便而设计的:我们需要自由地塑造<form>标签,所以 Django 在这方面并不具有侵入性。另外,请注意{% csrf_token %}

它将被 Django 渲染为一个令牌,并成为提交时发送到服务器的数据的一部分。这样,Django 将能够验证请求是否来自允许的来源,从而避免前面提到的 CSRF 问题。当我们为Entry插入编写视图时,您是否看到了我们如何处理令牌?没错。我们没有为此编写一行代码。Django 会自动处理它,这要归功于一个中间件类(CsrfViewMiddleware)。请参阅官方 Django 文档(docs.djangoproject.com/en/2.0/)以进一步探讨这个主题。

对于这个页面,我们还使用页脚块来显示指向主页的链接。最后,我们有列表模板,这是最有趣的一个:

# entries/templates/entries/list.html
{% extends "entries/base.html" %}
{% block title%} Entries list {% endblock title %}

{% block page-content %}
 {% if entries %}
  <h1>Your entries ({{ entries|length }} found)</h1>
  <div><a href="{% url "insert" %}">Insert new entry.</a></div>

  <table class="entries-table">
   <thead>
     <tr><th>Entry</th><th>Matches</th></tr>
   </thead>
   <tbody>
    {% for entry, match in entries %}
     <tr class="entries-list {% cycle 'light-gray' 'white' %}">
      <td>
        Pattern: <code class="code">
         "{{ entry.pattern }}"</code><br>
        Test String: <code class="code">
         "{{ entry.test_string }}"</code><br>
        Added: {{ entry.date_added }}
      </td>
      <td>
        {% if match %}
         Group: {{ match.0 }}<br>
         Subgroups:
          {{ match.1|default_if_none:"none" }}<br>
         Group Dict: {{ match.2|default_if_none:"none" }}
        {% else %}
         No matches found.
        {% endif %}
      </td>
     </tr>
    {% endfor %}
   </tbody>
  </table>
 {% else %}
  <h1>You have no entries</h1>
  <div><a href="{% url "insert" %}">Insert new entry.</a></div>
 {% endif %}
{% endblock page-content %}

{% block footer %}
 {% include "entries/footer.html" %}
{% endblock footer %}

您可能需要一段时间来适应模板语言,但实际上,它只是使用for循环创建表格。我们首先检查是否有任何条目,如果有,我们创建一个表格。有两列,一列是Entry,另一列是匹配。

Entry列中,我们显示Entry对象(除了用户),在Matches列中,我们显示在EntryListView中创建的三元组。请注意,要访问对象的属性,我们使用与 Python 中相同的点语法,例如{{ entry.pattern }}{{ entry.test_string }}等。

在处理列表和元组时,我们不能使用方括号语法访问项目,因此我们也使用点语法({{ match.0 }}相当于match[0],依此类推)。我们还使用过滤器,通过管道(|)运算符来显示一个自定义值,如果匹配为None

Django 模板语言(它并不是真正的 Python)之所以保持简单,是有一个明确的原因的。如果你发现自己受到语言的限制,那意味着你可能正在尝试在模板中做一些实际上应该在视图中完成的事情,那里的逻辑更相关。

让我向你展示一些列表插入模板的屏幕截图。这是我父亲的条目列表的样子:

请注意,使用循环标签交替将行的背景颜色从白色变为浅灰色。这些类在main.css文件中定义。

Entry插入页面足够智能,可以提供几种不同的场景。当你第一次登陆时,它会向你展示一个空表单。如果你正确填写了它,它会为你显示一个漂亮的消息(见下图)。然而,如果你未能填写两个字段,它将在它们之前显示一个错误消息,提醒你这些字段是必填的。

还要注意自定义页脚,其中包括指向条目列表和主页的链接:

就是这样!如果你愿意,你可以玩弄 CSS 样式。下载这本书的代码,尽情探索和扩展这个项目。向模型添加其他内容,创建并应用迁移,玩弄模板,有很多事情要做!

Django 是一个非常强大的框架,提供的功能远远超出了我在本章中能够向你展示的内容,所以你绝对应该去了解一下。它的美妙之处在于 Django 是 Python,因此阅读其源代码是非常有用的练习。

Web 开发的未来

与人类共存数个世纪的其他科学分支相比,计算机科学是一个非常年轻的学科。它的主要特点之一是发展极快。它以如此之快的速度前进,以至于在短短几年内,你可以看到与需要一个世纪才会发生的现实世界变化相媲美的变化。因此,作为一个编码者,你必须时刻关注这个世界发生的事情。

目前,由于强大的计算机相当便宜,几乎每个人都可以接触到,趋势是尽量避免在后端投入过多的工作量,让前端处理部分工作。因此,在过去几年中,JavaScript 框架和库,如 jQuery,Backbone,以及最近变得非常流行的 React,已经变得非常流行。Web 开发已经从后端负责处理数据、准备数据并提供给前端显示的范式转变为后端有时仅用作 API,纯粹的数据提供者。前端通过 API 调用从后端获取数据,然后处理其余部分。这种转变促进了单页应用程序SPA)等范式的存在,理想情况下,整个页面只加载一次,然后根据通常来自后端的内容进行演变。使用类似技术的电子商务网站在不刷新周围结构的页面中加载搜索结果。浏览器可以执行异步调用,如异步 JavaScript 和 XMLAJAX),可以返回数据,该数据可以通过 JavaScript 代码读取、操作并注入回页面。

因此,如果您计划从事 Web 开发,我强烈建议您熟悉 JavaScript(如果您还没有),以及 API。在本章的最后几页,我将为您演示如何使用两种不同的 Python 微框架(Flask 和 Falcon)制作一个简单的 API 的示例。

编写一个 Flask 视图

Flask(flask.pocoo.org/)是一个 Python 微框架。它提供的功能远不及 Django,但如果您的项目非常小,那么它可能是一个更好的选择。不过,根据我的经验,当开发人员在项目开始时选择 Flask 时,他们最终会不断添加插件,直到拥有我所说的 Django Frankenstein 项目。灵活意味着不时地花时间减少随时间积累的技术债务。但是,从 Flask 切换到 Django 可能是一个令人生畏的操作,因此在启动新项目时,请确保考虑其发展。我对这个问题的俏皮看法非常简单:我总是选择 Django,因为我个人更喜欢它而不是 Flask,但您可能不同意我的看法,因此我想为您提供一个例子。

在您的ch14文件夹中,创建一个flask文件夹,其结构如下:

$ tree -A flask  # from the ch14 folder
flask
├── main.py
└── templates
 └── main.html

基本上,我们将编写两个简单的文件:一个 Flask 应用程序和一个 HTML 模板。Flask 使用 Jinja2 作为模板引擎。它非常受欢迎且非常快速,以至于即使 Django 也开始为其提供原生支持:

# flask/templates/main.html
<!doctype html>
<title>Hello from Flask</title>
<h1>
  {% if name %}
    Hello {{ name }}!
  {% else %}
    Hello shy person!
  {% endif %}
</h1>

模板几乎是令人反感地简单。它只是根据name变量的存在改变问候语。更有趣的是渲染它的 Flask 应用程序:

# flask/main.py
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
@app.route('/<name>')
def hello(name=None):
    return render_template('main.html', name=name)

我们创建一个app对象,它是一个 Flask 应用程序。我们只需提供模块的完全限定名称,该名称存储在__name__中。

然后,我们编写一个简单的hello视图,它接受一个可选的name参数。在视图的主体中,我们只是渲染main.html模板,并将name参数传递给它,而不管其值如何。

有趣的是路由。与 Django 将视图和 URL 绑定在一起的方式不同(urls.py模块),在Flask中,您可以使用一个或多个@app.route装饰器来装饰您的视图。在这种情况下,我们装饰两次:第一行将视图绑定到根 URL(/),而第二行将视图绑定到具有名称信息的根 URL(/<name>)。

切换到flask文件夹并输入(确保您已经使用$ pip install flask安装了 Flask,或者通过安装书籍源代码中的要求):

$ FLASK_APP=main.py flask run

您可以打开浏览器并转到http://127.0.0.1:5000/。此 URL 没有名称信息;因此,您将看到“Hello shy person!”它写得很好很大。尝试在该 URL 中添加一些内容,例如http://127.0.0.1:5000/Milena。按Enter,页面将更改为 Hello Milena!(这样您就向我的妹妹打招呼了)。

当然,Flask 为您提供的远不止这些,但我们没有足够的空间来介绍更复杂的示例。不过,它绝对值得探索。许多项目都成功使用它,使用它创建网站或 API 非常有趣和愉快。Flask 的作者 Armin Ronacher 是一位成功且非常多产的程序员。他还创建或参与了其他几个有趣的项目,如 Werkzeug、Jinja2、Click 和 Sphinx。他还为 Python AST 模块贡献了功能。

在 Falcon 中构建 JSON 引用服务器

Falcon(falconframework.org/)是另一个用 Python 编写的微框架,旨在轻巧、快速和灵活。我看到这个相对年轻的项目发展成为真正受欢迎的东西,因为它的速度令人印象深刻,所以我很高兴向您展示一个使用它的小例子。我们将构建一个 API,从佛陀那里返回一个随机引用。

在您的ch14文件夹中,创建一个名为falcon的新文件夹。我们将有两个文件:quotes.pymain.py。要运行此示例,请安装 Falcon 和 Gunicorn($ pip install falcon gunicorn或书中的完整要求)。Falcon 是框架,GunicornGreen Unicorn)是用于 Unix 的 Python WSGI HTTP 服务器(通俗地说,这是用于运行服务器的技术)。

Web 服务器网关接口(WSGI)是一个简单的调用约定,用于将请求转发给用 Python 编写的 Web 应用程序或框架。如果您想了解更多,请查看PEP333,该页面定义了接口。

当您准备好后,首先创建quotes.py文件:

# falcon/quotes.py
quotes = [
    "Thousands of candles can be lighted from a single candle, "
    "and the life of the candle will not be shortened. "
    "Happiness never decreases by being shared.",
    ...
    "Peace comes from within. Do not seek it without.",
    ...
]

您将在本书的源代码中找到完整的引用列表。如果您没有,您可以填写您喜欢的引用。请注意,并非每一行都在末尾有逗号。在 Python 中,可以这样连接字符串,只要它们在括号(或大括号)中。这被称为隐式连接

主应用程序的代码并不长,但很有趣:

# falcon/main.py
import json
import random
import falcon
from quotes import quotes

class QuoteResource:
    def on_get(self, req, resp):
        quote = {
            'quote': random.choice(quotes),
            'author': 'The Buddha'
        }
        resp.body = json.dumps(quote)

api = falcon.API()
api.add_route('/quote', QuoteResource())

让我们从类开始。在 Django 中,我们有一个get方法,在 Flask 中我们定义了一个函数,在这里我们编写了一个on_get方法,这种命名风格让我想起了 Java/C#事件处理程序。它接受一个请求和一个响应参数,两者都由框架自动提供。在其主体中,我们定义了一个包含随机选择的引用和作者信息的字典。然后我们将该字典转储为 JSON 字符串,并将响应主体设置为其值。我们不需要返回任何东西,Falcon 会为我们处理。

在文件末尾,我们创建了 Falcon 应用程序,并在其上调用add_route,将我们刚刚编写的处理程序绑定到我们想要的 URL。

当您准备好后,切换到falcon文件夹并输入:

$ gunicorn main:api  

然后,发出请求(或者简单地用浏览器打开页面)到http://127.0.0.1:8000/quote。当我这样做时,我得到了这个 JSON 作为响应:

{
  quote: "Peace comes from within. Do not seek it without.",
  author: "The Buddha"
}

falcon文件夹中,我留下了一个名为stress.py的模块,用于测试我们的 Falcon 代码的速度。看看您是否可以自己使其工作,对您来说应该很容易。

无论您最终选择哪个框架进行 Web 开发,都要尽量了解其他选择。有时候您可能会处于需要选择不同框架的情况,了解不同工具的工作原理将给您带来优势。

总结

在本章中,我们看了一下 Web 开发。我们谈到了重要的概念,比如 DRY 哲学和框架的概念,框架作为一个工具,为我们提供了许多编写代码以响应请求所需的东西。我们还谈到了 MTV 模式,以及这三个层如何很好地协同工作来实现请求-响应路径。

然后,我们简要介绍了正则表达式,这是一个非常重要的主题,它是提供 URL 路由工具的层。

有许多不同的框架,Django 绝对是最好和最广泛使用的之一,因此值得探索,特别是其源代码,写得很好。

还有其他非常有趣和重要的框架,比如 Flask。它们提供的功能较少,但可能更快,无论是执行时间还是设置时间。一个非常快速的框架是 Falcon 项目,其基准测试非常出色。

重要的是要对请求-响应机制以及 Web 的工作原理有扎实的理解,这样最终您将不会太在意必须使用哪个框架。您将能够快速掌握它,因为这只是熟悉已经了解很多的某种做事方式的问题。

至少探索三种框架,并尝试提出不同的用例来决定它们中哪一个可能是理想的选择。当你能够做出选择时,你就会知道你对它们有足够的了解。

告别

希望你仍然渴望,并且这本书只是你迈向 Python 的第一步。这是一门非常美妙的语言,值得深入学习。

希望你喜欢和我一起的这段旅程,我尽力让它对你有趣。对我来说肯定是有趣的,我在写这些页面时度过了美好的时光。

Python 是开源的,所以请继续分享它,并考虑支持周围的美妙社区。

下次再见,朋友,再见!

posted @ 2024-05-04 21:27  绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报