Python-学习指南-全-

Python 学习指南(全)

原文:zh.annas-archive.org/md5/35b3fb9eb54ab38e1bf3e98dc8673900

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在我开始写作不久后,一个朋友问我是否真的需要另一本学习 Python的书。

一个非常好的问题,我们也可以用另一种形式来表达:这本书能提供什么?这本书与普通的 Python 入门书有什么不同?

我认为有两个主要差异以及许多很好的理由,让你想要阅读它。

首先,我们从介绍一些重要的编程概念开始。通过涵盖这门美妙语言的各个方面,我们建立了一个坚实的基础。

速度逐渐加快,同时所呈现主题的难度也在增加。到第七章结束,即测试、性能分析和处理异常,我们将涵盖所有基础知识。

从第八章开始,本书转向一个陡峭的转折点,这带来了第二个差异。

为了巩固所获得的知识,没有什么比参与一个小项目更好了。因此,在书的第二部分,每一章都提供了一个不同主题的项目。我们探索了脚本、图形界面、数据科学和网页编程。

每个项目都足够小,可以放在一章中,同时又足够大,具有相关性。每一章都很有趣,传达了信息,并教授了一些有价值的知识。

在简短的调试部分之后,本书以一个完整的示例结束,总结了全书内容。我试图设计它,以便你可以以几种方式扩展它。

因此,这绝对不是一本普通的学习 Python的书。它的方法要“动手”得多,更实用。

我想要赋予你力量,帮助你成为一个真正的 Python 忍者。但我也尽力让你在阅读过程中得到娱乐,并培养你的逻辑思维和创造力。

现在,我回答了这个问题吗?

本书涵盖的内容

第一章,介绍和第一步 - 深呼吸,介绍了基本的编程概念。它指导你如何在计算机上安装并运行 Python,并介绍了一些其结构。

第二章,内置数据类型,介绍了 Python 的内置数据类型。Python 有一套非常丰富的原生数据类型,本章将为每个数据类型提供描述和简短示例。

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

第四章,函数,代码的构建块,教你如何编写函数。函数是重用代码、减少调试时间以及一般而言,编写更好代码的关键。

第五章, 节省时间和内存,介绍了 Python 编程的功能性方面。本章教你如何编写列表推导式和生成器,这些是强大的工具,你可以使用它们来加速你的代码并节省内存。

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

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

第八章, 边缘 – GUI 和脚本,从两个不同的角度引导你通过一个示例。它们位于一个光谱的两端:一个是脚本实现,另一个是合适的图形用户界面应用程序。

第九章, 数据科学,介绍了一些关键概念以及一个非常特别的工具,Jupyter Notebook。

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

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

第十二章, 总结 – 一个完整的示例,展示了一个 Django 网站,它作为底层使用 Falcon Web 框架编写的瘦 API 的接口。这一章将书中涵盖的所有概念提升到新的水平,并建议如何深入挖掘并采取下一步行动。

你需要这本书的内容

鼓励您遵循本书中的示例。为了做到这一点,您需要一个计算机、互联网连接和浏览器。本书是用 Python 3.4 编写的,但它也应该与任何 Python 3.*版本兼容。我已经编写了如何在当今使用的三个主要操作系统(Windows、Mac 和 Linux)上安装 Python 的说明。我还解释了如何安装各种示例中使用的所有额外库,并在读者在安装任何这些库时遇到问题时提供了建议。不需要特定的编辑器来输入代码;然而,我建议那些有兴趣跟随示例的人考虑采用合适的编码环境。我在第一章中对此提出了建议。

这本书适合谁阅读

Python 是美国顶尖计算机科学大学中最受欢迎的入门教学语言,因此如果您是软件开发的新手或者经验不多,希望从正确的起点开始,那么这门语言和这本书就是您所需要的。它惊人的设计和便携性将帮助您在任何工作环境中都能变得高效。

如果您已经使用过 Python 或其他任何语言,这本书仍然可以作为 Python 基础知识的参考,并提供二十年来收集的广泛考虑和建议。

术语

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将按照以下方式显示:“打开 Python 控制台,并输入import this。”

代码块按照以下格式设置:

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

m = 5
print(m)

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

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

m = 5
print(m)

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

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

新术语重要词汇将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“在 Windows 上打开控制台,请转到开始菜单,选择运行,然后输入cmd。”

注意

警告或重要注意事项将以如下方式显示。

小贴士

小技巧和技巧看起来像这样。

读者反馈

我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们来说非常重要,因为它帮助我们开发出您真正能从中受益的书籍。

要向我们发送一般性反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果你在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请参阅我们的作者指南,网址为 www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲所有者,我们有一些事情可以帮助你从你的购买中获得最大收益。

下载示例代码

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

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果你发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入你的勘误详情来报告它们。一旦你的勘误得到验证,你的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

在互联网上,版权材料盗版是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果你在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢你在保护我们的作者和我们提供有价值内容的能力方面的帮助。

询问

如果你对本书的任何方面有问题,你可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. 引言和第一步 – 深呼吸

"给一个人一条鱼,你只能养他一天。教一个人钓鱼,你就能养他一辈子。"
--中国谚语

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

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

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

电脑是非常强大的工具,但遗憾的是,它们不能自己思考。所以它们需要被告知一切。它们需要被告知如何执行任务,如何评估条件以决定要遵循的路径,如何处理来自网络或磁盘等设备的来自设备的数据,以及当发生不可预见的事情时如何反应,比如说,某物损坏或丢失。

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

最后,一切都取决于你想要走多远。编码不仅仅是组合一些能工作的指令。它要复杂得多!

优秀的代码是简短、快速、优雅的,易于阅读和理解,简单,易于修改和扩展,易于扩展和重构,以及易于测试。要同时具备所有这些品质的代码需要时间,但好消息是,你通过阅读这本书已经迈出了第一步。我毫不怀疑你能够做到。实际上,任何人都可以,因为我们都在不断地编程,只是我们没有意识到这一点。

你想要一个例子吗?

假设你想冲速溶咖啡。你需要拿一个杯子,速溶咖啡罐,一把勺子,水,还有水壶。即使你没有意识到,你也在评估大量的数据。你确保水壶里有水,水壶已经插上电源,杯子是干净的,罐子里有足够的咖啡。然后,你烧开水,也许同时你在杯子里放了一些咖啡。当水烧开了,你倒入杯子,然后搅拌。

那么,编程是什么呢?

好吧,我们收集了资源(水壶、咖啡、水、茶匙和杯子)并验证了它们的一些条件(水壶已插电,杯子干净,有足够的咖啡)。然后我们开始执行两个动作(烧开水和把咖啡倒入杯子),当这两个动作都完成后,我们通过倒水进杯子并搅拌来最终结束程序。

你看得到吗?我刚刚描述了一个咖啡程序的高级功能。这并不难,因为大脑整天都在做这件事:评估条件、决定采取行动、执行任务、重复一些任务,并在某个点上停止。清洁对象,放回原处,等等。

现在你需要学习如何将你在现实生活中自动执行的所有动作分解,以便计算机能够真正理解它们。你还需要学习一种语言,来指导它。

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

适当的介绍

我喜欢在教授编码时引用现实世界;我相信这有助于人们更好地保留概念。然而,现在是时候更加严谨一点,从更技术性的角度来了解编码了。

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

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

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

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

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

因此,现在你已经知道了对象是什么,以及它们暴露了你可以运行的方法和你可以检查的属性,你就可以开始编码了。实际上,编码就是关于管理那些存在于我们软件中复制的世界子集中的对象。你可以随意创建、使用、重用和删除对象。

根据官方 Python 文档中的“数据模型”章节:

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

我们将在第六章“高级概念 – 面向对象编程、装饰器和迭代器”中更详细地研究 Python 对象。现在,我们只需要知道,Python 中的每个对象都有一个 ID(或标识),一个类型和一个值。

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

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

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

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

我们如何使用一个对象?当然,给它一个名字!当你给一个对象一个名字时,然后你可以使用这个名字来检索对象并使用它。

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

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

而为了做所有这些,基本上我们需要一种语言。这就是 Python 的作用。Python 是我们将在这本书中一起使用的语言,用来指导计算机为我们做某事。

现在,关于这些理论性的东西就足够了,让我们开始吧。

进入 Python

Python 是荷兰计算机科学家和数学家 Guido Van Rossum 的杰作,他决定在 1989 年圣诞节期间赠送世界一个他一直在玩的项目。这种语言大约在 1991 年向公众亮相,从那时起,它已经发展成为今天全球使用的领先编程语言之一。

我 7 岁时开始编程,在 Commodore VIC 20 上,后来被它的更大兄弟 Commodore 64 所取代。使用的语言是 BASIC。后来,我转向了 Pascal、汇编、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 包索引PyPI)上免费访问。当你编写 Python 代码并意识到你需要某个功能时,在大多数情况下,至少有一个库已经为你实现了这个功能。

软件质量

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

软件集成

另一个重要方面是 Python 可以扩展并与许多其他语言集成,这意味着即使一家公司使用不同的语言作为主流工具,Python 也可以介入并作为需要以某种方式相互通信的复杂应用程序之间的粘合剂。这是一个高级话题,但在现实世界中,这个特性非常重要。

满足与享受

最后但同样重要的是,乐趣所在!使用 Python 是一件有趣的事情。我可以连续编码 8 个小时,然后快乐地离开办公室,而其他使用不提供同样数量精心设计的数据结构和结构的语言的程序员则必须忍受痛苦。毫无疑问,Python 让编码变得有趣。而乐趣可以促进动力和生产力。

这些是我推荐 Python 给每个人的主要原因。当然,还有很多其他技术和高级特性我可以讨论,但它们并不真正属于像这样一个介绍性章节。它们将在本书的每一章中自然出现。

有什么缺点?

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

然而,Python 的速度在当今很少成为问题,因此尽管这个次优特性存在,它仍然得到了广泛的应用。实际上,硬件成本不再是问题,通常通过并行化任务来提高速度是足够的。然而,当涉及到数值计算时,人们可以切换到更快的 Python 实现,如 PyPy,它通过实现高级编译技术提供了平均 7 倍的速度提升(参考 pypy.org/ 获取更多信息)。

在进行数据科学时,你很可能会发现你与 Python 一起使用的库,如 Pandas 和 Numpy,由于它们的实现方式,能够达到原生速度。

如果这还不够有说服力,你总是可以考虑 Python 正在推动 Spotify 和 Instagram 等服务的后端,这些服务对性能有要求。尽管如此,Python 完美地完成了它的任务。

谁在现在使用 Python?

还没有说服你?让我们简要地看看今天正在使用 Python 的公司:Google、YouTube、Dropbox、Yahoo、Zope 公司、工业光魔、华特迪士尼动画、皮克斯、NASA、NSA、Red Hat、诺基亚、IBM、Netflix、Yelp、英特尔、思科、惠普、高通和摩根大通,仅举几个例子。

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

Python 被用于许多不同的环境,例如系统编程、网络编程、GUI 应用程序、游戏和机器人技术、快速原型设计、系统集成、数据科学、数据库应用程序等等。

设置环境

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

Python 2 与 Python 3 – 伟大的辩论

Python 有两个主要版本——Python 2,这是过去——和 Python 3,这是现在。这两个版本虽然非常相似,但在某些方面是不兼容的。

在现实世界中,Python 2 实际上离成为过去还相当遥远。简而言之,尽管 Python 3 自 2008 年以来就已经推出,但过渡阶段还远未结束。这主要是因为 Python 2 在行业中得到了广泛的应用,当然,公司并不那么热衷于仅仅为了更新而更新他们的系统,遵循“如果它没坏,就别修”的哲学。你可以在网上阅读关于这两个版本之间过渡的所有内容。

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

在写作的时候,大多数最广泛使用的库都已经移植到了 Python 3,对于大多数情况来说,开始一个 Python 3 项目是非常安全的。许多库已经被重写,以便它们与两个版本兼容,主要利用了 six(2 x 3)库的力量,这个库有助于根据使用的版本进行内省和调整行为。

在我的 Linux 机器(Ubuntu 14.04)上,我使用的 Python 版本如下:

>>> import sys
>>> print(sys.version)
3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2]

所以你可以看到我的 Python 版本是 3.4.0。前面的文本是我输入到我的控制台中的一段 Python 代码。我们稍后会讨论它。

本书中的所有示例都将使用这个 Python 版本运行。其中大部分也可以在 Python 2 中运行(我已安装了 Python 2.7.6),而那些不能运行的则只需要进行一些小的调整来适应两个版本之间的小不兼容性。选择这个版本的原因还有,我认为学习 Python 3 更好,然后,如果你需要的话,再学习它与 Python 2 的差异,而不是反过来。

尽管如此,不必担心版本问题:在实践中这并不是一个大问题。

安装 Python

我从未真正理解为什么一本书中要有“设置”这一章节,无论你需要设置什么。大多数情况下,从作者编写说明到你自己尝试它们,可能已经过去了数月。也就是说,如果你很幸运的话。一旦版本更新,事情可能就不会像书中描述的那样工作。幸运的是,我们现在有了网络,为了帮助你快速上手,我只会给你提供一些指导和目标。

小贴士

如果我在本书中提到的任何 URL 或资源在你阅读本书时已经不存在,请记住:谷歌是你的朋友。

设置 Python 解释器

首先,让我们谈谈你的操作系统。Python 在几乎所有 Linux 发行版中都是完全集成的,并且很可能已经安装好了(然而,可能只有 Python 2.7),而如果你使用的是 Mac,Python 也可能已经安装好了(然而,可能只有 Python 2.7),而如果你使用的是 Windows,你可能需要安装它。

安装 Python 和所需的库并使其运行需要一点手动操作。Linux 对于 Python 程序员来说是最友好的操作系统,而 Windows 则需要最大的努力,Mac 则介于两者之间。因此,如果你可以选择,我建议你使用 Linux。如果你不能,并且你有 Mac,那么无论如何都去尝试。如果你使用 Windows,你将能够运行本书中的示例,但总的来说,使用 Python 需要你进行一些调整。

我的操作系统是 Ubuntu 14.04,本书中我会一直使用这个版本,以及 Python 3.4.0。

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

小贴士

另一个关于 Python 及其生态系统的优秀、资源丰富的网站是docs.python-guide.org

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

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

注意

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

要在 Windows 中打开控制台,请转到开始菜单,选择运行,然后输入cmd。如果在本书的示例中遇到任何看起来像权限问题的东西,请确保您以管理员权限运行控制台。

在 Mac OS X 上,您可以通过转到应用程序 | 实用工具 | 终端来启动一个终端。

如果您在 Linux 上,您已经知道所有关于控制台的知识。

注意

我将交替使用术语控制台来表示 Linux 控制台、Windows 命令提示符和 Mac 终端。我还会使用 Linux 默认格式来指示命令行提示符,如下所示:

$ sudo apt-get update

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

这是在 Windows 7 上的样子:

设置 Python 解释器

这是在 Linux 上的样子:

设置 Python 解释器

现在 Python 已设置好并且可以运行,是时候确保您拥有其他将不可或缺的工具,以便跟随书中的示例:virtualenv。

关于 virtualenv

如您所猜测的,virtualenv全名是关于虚拟环境。让我解释一下它们是什么,为什么我们需要它们,并通过一个简单的例子来说明。

您在系统中安装 Python 并开始为客户 X 制作一个网站。您创建了一个项目文件夹并开始编码。在这个过程中,您还安装了一些库,例如 Django 框架,我们将在第十章 Web Development Done Right 中深入探讨,正确进行 Web 开发。假设您为项目 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

可能,最简单的方法是使用pip,以下是一个命令:

$ sudo pip install virtualenv # sudo may by optional

pip是一个用于安装和管理用 Python 编写的软件包的包管理系统。

Python 3 内置了对虚拟环境的支持,但在实践中,外部库仍然是生产系统上的默认选项。如果你在设置 virtualenv 时遇到麻烦,请参阅 virtualenv 官方网站:virtualenv.pypa.io

您的第一个虚拟环境

创建虚拟环境非常简单,但根据您的系统配置和您希望虚拟环境运行的 Python 版本,您需要正确运行命令。另外,当您想使用 virtualenv 时,您还需要执行激活操作。激活 virtualenv 实际上会在后台进行一些路径调整,这样当您调用 Python 解释器时,您实际上是在调用活动的虚拟环境,而不是普通的系统环境。

我将向您展示在 Linux 和 Windows 上的完整示例。我们将:

  1. 在您的项目根目录下创建一个名为 learning.python 的文件夹(在我的情况下,这是一个名为 srv 的文件夹,位于我的家目录中)。请根据您在机器上的设置调整路径。

  2. learning.python 文件夹内,我们将创建一个名为 .lpvenv 的虚拟环境。

    注意

    一些开发者更喜欢使用相同的名称(例如,.venv)来调用所有虚拟环境。这样,他们只需知道他们所在项目的名称,就可以运行任何虚拟环境中的脚本。这是一种非常常见的技巧,我也是这样使用的。.venv 中的点是因为在 Linux/Mac 中,在名称前加上点会使该文件或文件夹不可见。

  3. 创建虚拟环境后,我们将激活它(在 Linux、Mac 和 Windows 之间略有不同)。

  4. 然后,我们将确保我们正在运行所需的 Python 版本(3.4.*),通过运行 Python 交互式外壳来做到这一点。

  5. 最后,我们将使用 deactivate 命令来关闭虚拟环境。

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

这里是一个在 Linux 上如何执行这些步骤的示例(以 # 开头的命令是注释):

您的第一个虚拟环境

注意,我必须明确告诉 virtualenv 使用 Python 3.4 解释器,因为在我的机器上 Python 2.7 是默认的。如果我没有这样做,我将有一个 Python 2.7 而不是 Python 3.4 的虚拟环境。

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

$ virtualenv -p $( which python3.4 ) .lpvenv

在这种情况下,我更喜欢明确地详细说明,以帮助您理解每个步骤的细节。

另一件需要注意的事情是,为了激活一个虚拟环境,我们需要运行 /bin/activate 脚本,该脚本需要被引用(当一个脚本被“引用”时,意味着它在运行完成后其效果仍然存在)。这一点非常重要。同时注意,在激活虚拟环境后,提示符发生了变化,显示其名称在左侧(以及当我们关闭时它如何消失)。在 Mac OS 中,步骤是相同的,所以这里不再重复。

现在,让我们看看如何在 Windows 上实现相同的结果。你可能需要做一些尝试,特别是如果你使用的 Windows 或 Python 版本与我这里的不同。但这都是很好的经验,所以试着在开始时保持积极的态度,因为每个程序员都必须经历这种为了启动项目而必须克服的初步挑战。

下面是在 Windows 上的样子(以::开头的命令是注释):

你的第一个虚拟环境

注意,与 Linux 版本相比,有一些细微的差别。除了创建和导航文件夹的命令外,一个重要的区别是激活你的 virtualenv 的方式。另外,在 Windows 中,没有which命令,所以我们使用了where命令。

到目前为止,你应该能够创建和激活一个虚拟环境。请尝试在没有我的指导下创建另一个,熟悉这个流程,因为它是你将一直要做的事情:我们从不全局使用 Python,记住?这非常重要。

因此,在搭建好框架之后,我们准备更深入地讨论 Python 以及如何使用它。但在我们这样做之前,请允许我花几分钟谈谈控制台。

你的朋友,控制台

在这个图形用户界面和触摸屏设备的时代,似乎有些荒谬,不得不求助于像控制台这样的工具,因为一切似乎都只需要一键即可完成。

但事实是,每当你从键盘(或者如果你是左撇子,是左手)上移开右手(或左手)去抓鼠标并将光标移动到你想要点击的位置时,你都在浪费时间。用控制台完成任务,尽管这可能看起来有些反直觉,但结果却是更高的生产力和速度。我知道,你必须相信我。

速度和生产力很重要,就我个人而言,我对鼠标没有意见,但还有一个非常好的理由让你熟悉控制台:当你开发的代码最终部署到某个服务器上时,控制台可能是唯一可用的工具。如果你能与之成为朋友,我保证,在你最需要的时候,你永远不会迷失方向(通常,当网站宕机时,你必须迅速调查发生了什么)。

所以这完全取决于你。如果你有疑虑,请给我一个机会,尝试一下。这比你想象的要简单,你永远不会后悔。没有什么比一个优秀的开发者因为习惯了自己定制的工具集,而迷失在服务器的 SSH 连接中更可怜的了。

现在,让我们回到 Python。

如何运行 Python 程序

你可以通过几种不同的方式运行 Python 程序。

运行 Python 脚本

Python 可以用作脚本语言。实际上,它总是证明自己非常有用。脚本通常是小型文件,你通常执行它们来完成某些任务,比如一个任务。许多开发者最终会拥有自己的工具库,当他们需要执行任务时就会使用这些工具。例如,你可以有脚本来解析一种格式的数据并将其渲染成另一种不同的格式。或者你可以使用脚本来处理文件和文件夹。你可以创建或修改配置文件,等等。从技术上讲,在脚本中几乎可以做任何事情。

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

注意

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

我有一些 Python 脚本可以完成那些需要我手动操作几分钟甚至更长时间的低级任务,并且在某一点上,我决定自动化。例如,我有一台没有Fn键来切换触摸板开关的笔记本电脑。我觉得这非常烦人,而且我不希望每次需要这样做时都要在几个菜单中点击,所以我编写了一个小脚本,足够智能地告诉我的系统切换触摸板的活动状态,现在我可以通过启动器的一个简单点击来完成它。无价之宝。

我们将把第八章的一半内容,即边缘 – 图形用户界面和脚本,用于 Python 脚本编写。

运行 Python 交互式 shell

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

因此打开控制台,激活你的虚拟环境(到现在这应该已经变得很自然了,对吧?),然后输入python。你会看到几行,如果是在 Linux 系统上,它们应该看起来像这样:

Python 3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.

那些带有>>>的提示符是 shell 的提示符。它们告诉你 Python 正在等待你输入某些内容。如果你输入一个简单的指令,一行之内就能完成的指令,那么你只会看到这个指令。然而,如果你输入需要多行代码的指令,shell 会将提示符更改为...,给你一个视觉提示,表明你正在输入一个多行语句(或任何需要多行代码的内容)。

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

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

最后的操作是展示一些令人难以置信的事情。我们将 2 的 1024 次幂,Python 完全没有问题地处理这个任务。尝试在 Java、C++ 或 C# 中做这件事,除非你使用特殊库来处理这样的大数,否则它不会工作。

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

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

另一个解决方案,它提供了一个更漂亮的图形布局,是使用 IDLE集成开发环境)。它相当简单,主要面向初学者。它比控制台中的裸交互式外壳具有更广泛的功能集,因此你可能想探索它。它随 Windows Python 安装程序免费提供,你可以在任何其他系统上轻松安装它。你可以在 Python 网站上找到有关它的信息。

吉多·范罗苏姆将 Python 命名为英国喜剧团体 Monty Python 的名字,因此据说 IDLE 的名字是为了纪念 Monty Python 的创始人之一埃里克·艾德勒。

将 Python 作为服务运行

除了作为脚本运行和在壳内运行之外,Python 还可以编码并作为真正的软件运行。本书中我们将看到许多关于这种模式的示例。我们将在稍后讨论 Python 代码的组织和运行方式时了解更多关于它。

将 Python 作为 GUI 应用程序运行

Python 也可以作为 GUI图形用户界面)运行。有几种框架可供选择,其中一些是跨平台的,而其他一些则是特定平台的。在 第八章 边缘 – GUI 和脚本 中,我们将看到一个使用 Tkinter 创建的 GUI 应用程序的示例,Tkinter 是一个位于 Tk(Tkinter 意味着 Tk 接口)之上的面向对象的层。

注意

Tk 是一个图形用户界面工具包,它将桌面应用程序开发提升到了比传统方法更高的水平。它是 Tcl工具命令语言)的标准 GUI,也是许多其他动态语言的 GUI,可以在 Windows、Linux、Mac OS X 等操作系统上无缝运行丰富本机应用程序。

Tkinter 与 Python 一起打包,因此它为程序员提供了轻松访问 GUI 世界的方式,出于这些原因,我选择了它作为本书中将要展示的 GUI 示例的框架。

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

  • PyQt

  • wxPython

  • PyGtk

详细描述它们超出了本书的范围,但你可以在 Python 网站上的GUI 编程部分找到你需要的所有信息。如果你在寻找 GUI,记得根据一些原则选择你想要的。确保它们:

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

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

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

  • 包装那些你可以轻松安装/访问的图形驱动程序/工具

Python 代码是如何组织的

让我们简单谈谈 Python 代码是如何组织的。在这段话中,我们将深入探讨一些更多的技术名称和概念。

从基础知识开始,Python 代码是如何组织的?当然,你将你的代码写入文件。当你保存一个扩展名为.py的文件时,这个文件就被认为是 Python 模块。

小贴士

如果你在 Windows 或 Mac 上,这些系统通常默认隐藏文件扩展名以供用户查看,请确保你更改配置,以便可以看到文件的完整名称。这并不是一个严格的要求,而是一个诚恳的建议。

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

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

总是如此,一个例子会让这一切变得更加清晰。我在我的书项目中创建了一个示例结构,当我在我 Linux 控制台中输入:

$ 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 文件仅仅是为了告诉 Python util 是一个包,而不仅仅是一个文件夹。

如果这个软件仅仅在模块中组织,那么推断其结构将会更加困难。我把一个仅模块的例子放在了 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"  # S. Holmes
>>> employee = {
...     'age': 45,
...     'role': 'CTO',
...     'SSN': 'AB1234567',
... }
>>> # let's print them
>>> n
3
>>> address
'221b Baker Street, NW1 6XE, London'
>>> employee
{'role': 'CTO', 'SSN': 'AB1234567', 'age': 45}
>>> # what if I try to print a name I didn't define?
>>> 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 数据结构之王。

小贴士

你有没有注意到,当我输入员工定义时,提示符从>>>变成了...?这是因为定义跨越了多行。

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

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

命名空间的美妙之处在于,它们允许你清晰地定义和组织你的名称,而不重叠或干扰。例如,与我们在图书馆中寻找的那本书相关的命名空间可以用来导入这本书本身,如下所示:

from library.second_floor.section_x.row_three import book

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

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

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

作用域

根据 Python 的文档,作用域是 Python 程序中的一个文本区域,其中命名空间可以直接访问。直接访问意味着当你寻找一个无限定名称的引用时,Python 会尝试在命名空间中查找它。

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

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

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

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

  • 内置作用域包含内置名称。Python 提供了一组你可以直接使用的函数,例如 printallabs 等。它们位于内置作用域中。

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

因此,在查找名称时,命名空间被扫描的顺序是:局部封装全局内置LEGB)。

这一切都是非常理论性的,所以让我们看看一个例子。为了向您展示局部和封装命名空间,我必须定义几个函数。如果你目前不熟悉它们的语法,请不要担心,我们将在第四章函数,代码的构建块中学习函数。只需记住,在下面的代码中,当你看到 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 将会打印以下内容:

(.lpvenv)fab@xps:ch1$ 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 将会在控制台上打印:

(.lpvenv)fab@xps:ch1$ python scopes3.py
5 printing from the global scope
13 printing from the local scope

如您所见,函数 local 中的 print 指令与之前一样引用 mm 仍然在函数内部未定义,因此 Python 开始按照 LEGB 顺序遍历作用域。这次 m 在封装作用域中被找到。

如果现在这还不完全清楚,请不要担心。随着我们在书中通过示例进行学习,这会逐渐变得清晰。Python 教程(官方文档)的 Classes 部分有一个关于作用域和命名空间的有趣段落。如果您希望对主题有更深入的理解,请确保在某个时候阅读它。

在我们完成本章之前,我想再谈谈对象。毕竟,基本上 Python 中的所有东西都是对象,所以我认为它们值得更多的关注。

小贴士

下载示例代码

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

对象和类

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

因此,在不深入细节的情况下(我们将在第六章中这样做,高级概念 – 面向对象编程、装饰器和迭代器),我想给您一个简而言之的解释关于类和对象。

我们已经看到,对象是 Python 对数据的抽象。实际上,Python 中的所有东西都是一个对象。数字、字符串(包含文本的数据结构)、容器、集合,甚至函数。您可以将它们想象成具有至少三个特征的盒子:一个 ID(唯一的)、一个类型和一个值。

但它们是如何变得有生命的呢?我们如何创建它们?我们如何编写自己的自定义对象?答案在于一个简单的词:类。

实际上,对象是类的实例。Python 的美丽之处在于类本身也是对象,但让我们不要走这条路。这会引导我们进入这门语言最先进的概念之一:元类。我们将在第六章中非常简要地讨论它们,高级概念 – 面向对象编程、装饰器和迭代器。现在,你了解类和对象之间区别的最好方式是通过一个例子。

假设一个朋友告诉你“我买了一辆新自行车!”你立刻就明白她在说什么。你见过那辆自行车吗?没有。你知道它的颜色吗?不知道。品牌?不知道。你知道关于它的任何信息吗?不知道。但与此同时,你知道你需要知道的一切,以便理解当你的朋友告诉你她买了一辆新自行车时她的意思。你知道自行车有两个轮子连接到车架,有座椅、踏板、把手、刹车等等。换句话说,即使你没有见过那辆自行车本身,你也知道自行车的概念。一组抽象的特征和特性,共同构成了所谓的自行车。

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

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

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

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属性,我们得到了不同的值。没有重叠,没有混淆。

点(.)操作符当然是我们用来进入命名空间的方式,在对象的情况下也是如此。

关于如何编写良好代码的指南

编写良好的代码并不像看起来那么简单。正如我之前所说,良好的代码暴露出一系列相当难以组合的品质。在某种程度上,编写良好的代码是一种艺术。无论你会在哪个阶段感到满意,总有一些你可以接受的东西,这将使你的代码立即变得更好:PEP8

根据维基百科:

"Python 的开发主要通过各种 Python 增强提案(PEP)流程进行。PEP 流程是提出重大新功能、收集社区对问题的反馈以及记录 Python 设计决策的主要机制。"

在所有 PEP 中,可能最著名的是 PEP8。它概述了一套简单但有效的指南,以定义 Python 美学,以便我们编写漂亮的 Python 代码。如果你从这个章节中取出一项建议,请让它成为这个:使用它。接受它。你以后会感谢我的。

今天的编码已经不再是简单的签到/签退业务。相反,它更像是一项社会性的努力。几个开发者通过像 git 和 mercurial 这样的工具共同协作编写代码,结果是许多不同人手的产物。

注意

Git 和 Mercurial 可能是今天最常用的分布式版本控制系统。它们是专为帮助开发者团队在同一软件上协作而设计的必要工具。

这些天,比以往任何时候都更需要有一种一致的代码编写方式,以便最大限度地提高可读性。当一家公司的所有开发人员都遵守 PEP8 时,任何一个人遇到一段代码时都会认为他们自己写了它。这实际上经常发生在我身上(我总是忘记我写的代码)。

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

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

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。从有趣层次开始,然后深入挖掘。总是深入挖掘。

关于 IDEs 的注意事项

简单地说几句关于集成开发环境IDEs)的。为了跟随这本书中的示例,你不需要一个 IDE,任何文本编辑器都行。如果你想拥有更高级的功能,如语法高亮和自动完成,你必须自己获取一个 IDE。你可以在 Python 网站上找到一个开源 IDE 的完整列表(只需 Google“python ides”)。我个人使用 Sublime Text 编辑器。它免费试用,只需几美元。我一生中尝试过许多 IDE,但这是让我最有效率的一个。

两条极其重要的建议:

  • 无论你将选择什么 IDE,都要努力学好它,以便你能利用它的优势,但不要依赖它。偶尔练习使用 VIM(或任何其他文本编辑器),学会在任何平台上使用任何一组工具做一些工作。

  • 无论你将使用什么文本编辑器/IDE,当涉及到编写 Python 时,缩进应该是四个空格。不要使用制表符,也不要将它们与空格混合。使用四个空格,不要用两个、三个或五个。只需使用四个。全世界都是这样做的,你也不想因为喜欢三个空格布局而成为局外人。

摘要

在本章中,我们开始探索编程和 Python 的世界。我们只是刚刚触及表面,只是触及一下将在书中更详细讨论的概念。

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

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

在实际层面上,我们学习了如何在我们的系统上安装 Python,如何确保我们有需要的工具,pip 和 virtualenv,我们还创建并激活了我们的第一个虚拟环境。这将使我们能够在不危及 Python 系统安装的情况下在一个自包含的环境中工作。

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

尽量跟随示例,我会让它们简单而简短。如果你能将它们放在指尖下,你将比仅仅阅读它们更好地保留它们。

在下一章中,我们将探讨 Python 丰富的内置数据类型。有很多内容要介绍,也有很多东西要学习!

第二章. 内置数据类型

"数据!数据!数据!"他焦急地喊道。“没有粘土,我无法制作砖块。"
--夏洛克·福尔摩斯 - 铜 Beeches 冒险

你用计算机做的每一件事都是管理数据。数据有多种不同的形状和风味。它是你听的音乐,你流媒体播放的电影,你打开的 PDF 文件。甚至你此刻正在阅读的章节也只是一个文件,也就是数据。

数据可以是简单的,比如一个整数来表示年龄,或者复杂的,比如在网站上下的订单。它可以关于单个对象,也可以关于它们的集合。

数据甚至可以关于数据,即元数据。描述其他数据结构设计的数据或描述应用程序数据及其上下文的数据。

在 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 是一个指向值为 42 的 int 对象的名称。当我们输入 age = 43 时,实际上发生的是创建了一个新的对象,类型为 int,值为 43(同时,id 也会不同),并且名称 age 被设置为指向它。所以,我们并没有将 42 改为 43。我们实际上只是将 age 指向了另一个位置:值为 43 的新 int 对象。让我们看看同样的代码也打印了 ID:

>>> age = 42
>>> id(age)
10456352
>>> age = 43
>>> id(age)
10456384

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

现在,让我们用可变对象来查看相同的例子。为此,我们可以使用一个具有 age 属性的 Person 对象:

>>> fab = Person(age=39)
>>> fab.age
39
>>> id(fab)
139632387887456
>>> fab.age = 29  # I wish!
>>> id(fab)
139632387887456  # still the same id

在这个例子中,我创建了一个名为 fab 的对象,其 typePerson(一个自定义类)。在创建时,该对象被赋予了 39 岁的 age。紧接着,我将打印它以及对象的 id。请注意,即使我将 age 改为 29,fab 的 ID 仍然保持不变(当然,age 的 ID 已经改变)。Python 中的自定义对象是可变的(除非你编写代码使其不可变)。请记住这个概念,它非常重要。我将在本章的其余部分提醒你。

数字

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

数字是不可变对象。

整数

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

>>> a = 12
>>> b = 3
>>> a + b  # addition
15
>>> b - a  # subtraction
-9
>>> a // b  # integer division
4
>>> a / b  # true division
4.0
>>> a * b  # multiplication
36
>>> b ** a  # power operator
531441
>>> 2 ** 1024  # a very big number, Python handles it gracefully
17976931348623159077293051907890247336179769789423065727343008115
77326758055009631327084773224075360211201138798713933576587897688
14416622492847430639474124377767893424865485276302219601246094119
45308295208500576883815068234246288147391311054082723716335051068
4586298239947245938479716304835356329624224137216

上述代码应该很容易理解。只需注意一点重要的事情:Python 有两个除法运算符,一个执行所谓的真除法/),它返回操作数的商,另一个执行所谓的整数除法//),它返回操作数的向下取整商。看看这是如何对正数和负数不同的:

>>> 7 / 4  # true division
1.75
>>> 7 // 4  # integer division, flooring 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 中,TrueFalse是两个用于表示真理值的保留字。布尔值是整数的一个子类,分别对应于 1 和 0。布尔值的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,它们属于从整数类派生出的一个类,当需要时会被转换回整数。这个话题涉及到继承,将在第六章中详细解释,高级概念 – 面向对象编程、装饰器和迭代器

实数

实数或浮点数在 Python 中按照 IEEE 754 双精度二进制浮点格式表示,该格式存储在 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.61725123519331

注意

在计算面积的计算中,我将radius ** 2用括号括起来。尽管这并不是必要的,因为幂运算符的优先级高于乘法运算符,但我认为这样公式读起来更清晰。

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 值,你就会意识到无法表示它们全部。空间不足,所以它们被近似到最接近的可表示的数字。你可能认为只有极大或极小的数字会受到影响。但再想想:

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

这告诉你什么?它告诉你,即使是像 0.1 或 0.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 reduced to lowest terms
>>> 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 来获取和设置精度。

让我们用一个 Decimal 数字来快速看一下示例:

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

注意到当我们从一个 float 构造一个 Decimal 数字时,它会继承 float 可能带来的所有近似问题。另一方面,当 Decimal 没有近似问题时,例如,当我们向构造函数提供 intstring 表示时,计算就没有奇怪的行为。当涉及到金钱时,使用小数。

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

不可变序列

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

字符串和字节

Python 中的文本数据通过 str 对象处理,更常见的是字符串。它们是不可变的 unicode 代码点 序列。Unicode 代码点可以表示一个字符,但也可以有其他含义,例如格式化数据等。Python 与其他语言不同,没有 char 类型,所以一个字符简单地通过长度为 1 的字符串来表示。Unicode 是处理数据的一种优秀方式,应该用于任何应用程序的内部。当涉及到存储文本数据或通过网络发送数据时,你可能想要使用适当的编码对其进行编码。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,我们正在创建一个 bytes 对象。

>>> 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 的副本(相同的值,但不同的对象)。你能找到一种使用切片获取字符串反转副本的方法吗?(不要查找,自己找到它)

元组

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

>>> t = ()  # empty tuple
>>> type(t)
<class 'tuple'>
>>> one_element_tuple = (42, )  # you need the comma!
>>> three_elements_tuple = (1, 3, 5)
>>> 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 = b, a  # this is the Pythonic way to do it
>>> a, b
(1, 2)

看看显示 Python 交换两个值方式的行:你还记得我在第一章,“简介和第一步 – 深呼吸”中写了什么吗?Python 程序通常比等价的 Java 或 C++代码小五分之一到三分之一,而像一行交换这样的特性也对此有所贡献。Python 是优雅的,这里的优雅也意味着经济。

由于它们是不可变的,元组可以用作字典的键(我们很快就会看到这一点)。dict对象需要键是不可变的,因为如果它们可以改变,那么它们引用的值就找不到(因为到达它的路径依赖于键)。如果你对数据结构感兴趣,你知道这个特性是多么好。对我来说,元组是 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]

上述代码的最后两行非常有趣,因为它们向我们介绍了一个称为操作符 重载的概念。简而言之,这意味着像+-*%等操作符可以根据它们使用的上下文表示不同的操作。将两个列表相加没有意义,对吧?因此,+符号用于连接它们。因此,*符号用于根据右操作数将列表连接到自身。现在,让我们进一步深入这个兔子洞,看看一些更有趣的东西。我想向您展示排序方法有多么强大,以及 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,它是 归并插入 排序的结合,并且比大多数用于主流编程语言的算法具有更好的时间性能。Timsort 是一个稳定的排序算法,这意味着当多个记录具有相同的键时,它们的原始顺序被保留。我们在 sorted(a, key=itemgetter(0)) 的结果中已经看到了这一点,它产生了 [(1, 3), (1, 2), ...],其中这两个元组的顺序被保留,因为它们在位置 0 的值是相同的。

字节数组

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

注意

当涉及到区间时,我将使用标准的开/闭范围表示法。一端上的方括号表示值被包含,而圆括号表示被排除。粒度通常由边缘元素的类型推断,例如,区间 [3, 7] 表示介于 3 和 7 之间的所有整数,包括 3 和 7。另一方面,(3, 7) 表示介于 3 和 7 之间的所有整数,不包括 4、5 和 6。bytearray 类型的项是介于 0 和 256 之间的整数,包括 0,不包括 256。区间通常以这种方式表达的一个原因是便于编码。如果我们把范围 a, b) 分成 N 个连续的范围,我们可以很容易地将其表示为这样的连接:

![字节数组

在代码中处理区间时,排除了中间点 (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 中,我从字符串 b'Lina' 创建了 name bytearray 来向您展示 bytearray 对象如何公开序列和字符串的方法,这非常方便。如果你这么想,它们可以被认为是可变字符串。

集合类型

Python 还提供了两种集合类型,setfrozensetset 类型是可变的,而 frozenset 是不可变的。它们是无序的不变对象集合。

可哈希性 是一个特性,它允许一个对象既可以作为集合的成员,也可以作为字典的键,正如我们很快就会看到的。

备注

一个对象如果是可哈希的,那么它在其生命周期内具有一个永远不会改变的哈希值。

比较相等的对象必须具有相同的哈希值。集合通常用于测试成员资格,所以让我们在以下示例中介绍 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  # indeed!

你注意到那些双等号吗?赋值使用一个等号,而要检查一个对象是否与另一个对象相同(或者在这种情况下一次性检查 5 个),我们使用双等号。还有另一种比较对象的方法,它涉及到is运算符,并检查两个对象是否相同(如果它们有相同的 ID,而不仅仅是值),但除非你有很好的理由使用它,否则你应该使用双等号。在前面的代码中,我还使用了一个很棒的功能:zip。这个名字来源于现实生活中的 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)]

在前面的例子中,我以两种不同的方式创建了相同的列表,一种更为明确,另一种则更符合 Python 风格。暂时忘记我必须将list构造函数包裹在zip调用中(原因是因为zip返回的是一个迭代器,而不是一个list),让我们专注于结果。看看zip是如何将其两个参数的第一个元素配对在一起,然后是第二个元素,然后是第三个元素,以此类推?看看你的裤子(如果你是女士,看看你的钱包)你就会在你的实际 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
{'e': 1, 'h': 0, 'o': 4, 'l': 3}
>>> d.keys()
dict_keys(['e', 'h', 'o', 'l'])
>>> d.values()
dict_values([1, 0, 4, 3])
>>> d.items()
dict_items([('e', 1), ('h', 0), ('o', 4), ('l', 3)])
>>> 3 in d.values()
True
>>> ('o', 4) in d.items()
True

在前面的代码中,有几个需要注意的地方。首先,注意我们是如何通过迭代字符串'hello'和列表[0, 1, 2, 3, 4]的压缩版本来创建字典的。字符串'hello'中有两个'l'字符,它们通过zip函数与值 2 和 3 配对。注意在字典中,'l'键的第二次出现(值为 3)会覆盖第一次出现(值为 2)。另一件要注意的事情是,当请求任何视图时,原始顺序会丢失,但在视图内部是一致的,正如预期的那样。还要注意,当你在自己的机器上尝试这段代码时,可能会得到不同的结果。Python 不保证这一点,它只保证视图呈现顺序的一致性。

当我们谈到遍历集合时,我们会看到这些视图是如何成为基本工具的。现在让我们看看 Python 字典暴露的其它一些方法,它们很多并且非常有用:

>>> d
{'e': 1, 'h': 0, 'o': 4, 'l': 3}
>>> d.popitem()  # removes a random item
('e', 1)
>>> d
{'h': 0, 'o': 4, '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
{'a': 13, 'another': 'value', 'h': 0, 'o': 4}
>>> 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}  # didn't work, as expected

因此,我们现在已经结束了这次游览。测试你对字典的了解,尝试预测在这行代码之后d看起来会是什么样子。

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

这并不复杂,但如果你一开始不理解也不要担心。我只是想激励你尝试使用字典。

这就结束了我们对内置数据类型的游览。在我对这一章中我们所看到的内容进行一些思考之前,我想简要地看一下collections模块。

collections模块

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

数据类型 描述
namedtuple() 创建具有命名字段的元组子类的工厂函数
deque 一个类似于list的容器,两端快速追加和弹出
ChainMap 创建多个映射的单个视图的dict类似类
Counter 一个用于计数可哈希对象的dict子类
OrderedDict 一个记住添加条目顺序的dict子类
defaultdict 一个调用工厂函数以提供缺失值的dict子类
UserDict 一个围绕字典对象的包装器,以便更容易地子类化字典
UserList 一个围绕列表对象的包装器,以便更容易地子类化列表
UserString 一个围绕字符串对象的包装器,以便更容易地子类化字符串

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

命名元组

namedtuple是一个类似tuple的对象,它可以通过属性查找访问字段,同时又是可索引和可迭代的(实际上它是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 perfect
9.5
>>> vision.right  # still perfect (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  # d is there, we get 40
>>> d
{'age': 40}

现在让我们看看它如何与 defaultdict 数据类型一起工作。第二行实际上是 4 行长的 if 子句的简写版本,如果我们没有 get 方法,我们就必须编写这个 if 子句。我们将在 第三章 中看到所有关于 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
>>> dd['age'] = 39
>>> dd['age'] += 1
>>> dd
defaultdict(<class 'int'>, {'age': 40})  # 40, 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
[{'host': 'packtpub.com', 'port': 5678},
 {'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 是如何使你的生活变得如此轻松的。我确实如此,每次我不得不在其他语言中编码时,我都会感到压抑。

最后的考虑

就这些了。现在你已经看到了你将在 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,
}

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

另一个需要考虑的因素是,元组和列表是有序集合,而如果你使用字典或集合,你会失去排序,因此你需要知道排序在你的应用中是否重要。

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

注意

如果你想知道 O(n)O(1) 是什么意思,请谷歌搜索“大 O 符号”并从任何地方了解其概要。在这个上下文中,我们只需说,如果对一个数据结构执行操作 Op 需要 O(f(n)) 时间,这意味着 Op 完成所需的时间最多为 如何选择数据结构,其中 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" 上索引如何工作的好图解:

关于索引和切片

尝试访问大于 9 或小于 -10 的索引将引发一个 IndexError,正如预期的那样。

关于命名

你可能已经注意到,为了尽可能缩短示例,我使用了简单的字母来调用许多对象,如 abcd 等。当你需要在控制台调试或展示 a + b == 7 时,这是完全可以接受的,但在专业编码(或任何类型的编码,无论如何)中,这是一种不好的做法。我希望你能够容忍我有时这样做,原因是为了以更紧凑的方式展示代码。

在实际环境中,当你为你的数据选择名称时,你应该仔细选择,并且它们应该反映数据的内容。所以,如果你有一组 Customer 对象,customers 是它的一个非常好的名称。customers_listcustomers_tuplecustomers_collection 也能行得通吗?思考一下。将集合的名称与数据类型绑定是否好?我不这么认为,至少在大多数情况下不是。所以我会说,如果你有很好的理由这么做,那就去做吧,否则不要。原因是,一旦 customers_tuple 开始在你的代码的不同地方使用,你意识到你实际上想使用列表而不是元组,你将面临一些有趣的重构(也称为 浪费时间)。数据名称应该是名词,函数名称应该是动词。名称应该尽可能表达清晰。Python 在名称方面实际上是一个非常好的例子。大多数时候,如果你知道一个函数的作用,你就可以猜出它的名称。疯狂,对吧?

第二章,《代码整洁之道》罗伯特·C·马丁普伦蒂斯·霍尔,完全致力于命名。这是一本令人惊叹的书,它帮助我在许多不同的方面改进了我的编码风格,如果你想要将你的编码提升到下一个层次,这是一本必读的书。

摘要

在本章中,我们探讨了 Python 的内置数据类型。我们看到了它们的数量,以及仅通过不同组合使用它们所能实现的事情。

我们已经看到了数字类型、序列、集合、映射、集合,我们了解到一切皆对象,我们学习了可变和不可变之间的区别,我们还学习了切片和索引(以及,自豪地说,负索引)。

我们已经展示了简单的例子,但关于这个主题,你还可以学到更多,所以请深入官方文档去探索。

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

如果你的基础不是坚如磐石,你的代码能有多好?数据是一切的基础。数据塑造了围绕它的东西。

随着你对这本书的进步,你很可能会发现我的代码(或你的)中存在一些差异,或者这里那里可能有一些小错误。你会收到一个错误信息,某些东西会出错。那太好了!当你编码时,事情总是出错,你总是调试和修复,所以请把错误视为学习关于你所使用语言的新知识的有用练习,而不是失败或问题。错误会一直出现,直到你代码的最后一行,这是肯定的,所以你现在就开始与它们和解吧。

下一章是关于迭代和做决策的。我们将看到如何实际使用这些集合,并基于我们呈现的数据做出决策。现在你的知识正在积累,我们将开始稍微快一点,所以确保你在转到下一章之前对这一章的内容感到舒适。再次提醒,要开心,探索,破坏事物。这是一种非常好的学习方法。

第三章。迭代和决策

"Insanity: doing the same thing over and over again and expecting different results."
--阿尔伯特·爱因斯坦

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

根据维基百科:

在计算机科学中,控制流(或称为控制流程)指的是指定 imperative 程序中各个语句、指令或函数调用的执行或评估的顺序

为了控制程序的流程,我们有两个主要的工具:条件编程(也称为分支)和循环。我们可以用许多不同的组合和变化来使用它们,但在这个章节中,我更愿意先给你讲解基础知识,然后我会和你一起编写几个小脚本。在第一个脚本中,我们将看到如何创建一个基本的素数生成器,而在第二个脚本中,我们将看到如何根据优惠券为客户应用折扣。这样你应该能更好地理解条件编程和循环是如何被使用的。

条件编程

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

主要工具是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!

由于lateTrue,所以执行了print语句。让我们扩展这个例子:

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。尝试将False/True值分配给late名称,并观察此代码的输出如何相应地改变。

先前的例子还介绍了else子句,当我们在if子句中想要提供一个当表达式评估为False时的替代指令集时,它变得非常有用。else子句是可选的,正如通过比较前两个例子所显示的那样。

特殊的else: elif

有时候,只要满足一个条件,你只需要做某件事情(简单的if子句)。其他时候,你可能需要提供一个替代方案,以防条件为Falseif/else子句),但有时你可能需要从多个路径中选择,所以,既然联系经理(或不联系他们)是一种二进制类型的例子(要么联系要么不联系),那么让我们改变例子类型并继续扩展。这次我们决定税率。如果我的收入低于 10k,我不会缴纳任何税费。如果它在 10k 到 30k 之间,我将缴纳 20%的税费。如果它在 30k 到 100k 之间,我将缴纳 35%的税费,超过 100k,我将(乐意地)缴纳 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

让我们逐行分析这个例子:我们首先设置收入值。在这个例子中,我的收入是 15k。我们进入if子句。注意,这次我们还引入了elif子句,它是else-if的缩写,它与裸else子句不同,因为它也有自己的条件。所以,if表达式income < 10000评估为False,因此块#1没有被执行。控制权传递到下一个条件评估器:elif income < 30000。这个评估为True,因此块#2被执行,因此 Python 随后在执行完整个if/elif/elif/else子句(从现在起我们可以简单地称之为if子句)之后继续执行。if子句之后只有一个指令,即print调用,它告诉我们今年我将缴纳 3k 的税费(15k * 20%)。注意,顺序是强制性的:if首先,然后(可选地)需要多少个elif,最后(可选地)一个else子句。

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

尝试修改income的值,直到你可以随意执行所有代码块(每次执行一个,当然)。然后尝试边界值。这非常重要,无论你将条件表达为等式不等式==!=<><=>=),这些数字都代表边界。彻底测试边界是至关重要的。我应该允许你在 18 岁或 17 岁时开车吗?我是用age < 18还是age <= 18来检查你的年龄?你无法想象我不得不多少次修复由使用错误的运算符引起的微妙错误,所以请继续实验前面的代码。将一些<改为<=,并将收入设置为边界值之一(10k、30k、100k)以及任何介于这些值之间的值。看看结果如何变化,在继续之前,对它有一个良好的理解。

在我们转到下一个主题之前,让我们看看另一个例子,这个例子展示了如何嵌套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部分,而内层有。注意缩进是如何使我们能够在另一个语句内部嵌套一个语句的。

如果alert_system == 'console',则执行body #1,其他什么也不发生。另一方面,如果alert_system == 'email',那么我们将进入另一个if语句,我们称之为内层。在内层if语句中,根据error_severity,我们将向管理员、一级支持或二级支持发送电子邮件(blocks #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循环。让我们从一个类似于 C++风格的简单示例开始,然后逐步了解如何在 Python 中实现相同的结果(你会喜欢 Python 的语法的)。

simple.for.py

for number in [0, 1, 2, 3, 4]:
    print(number)

这段简单的代码片段在执行时,会打印出从 0 到 4 的所有数字。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]

目前,忽略我们需要在list中包装range(...)的事实。range对象有点特殊,但在这个例子中,我们只是想了解它将返回给我们哪些值。你看到的情况与切片相同: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),这基本上是一个序列(0, 1, 2)。这意味着for循环将运行三次迭代。在第一次迭代中,position将取值0,在第二次迭代中,它将取值1,最后在第三次和最后一次迭代中取值2(0, 1, 2)如果不是surnames列表的可能索引位置,那会是什么?在位置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在每次迭代中返回一个 2 元组(position, surname),但即便如此,它仍然比range(len(...))示例更易读(并且更高效)。你可以用start参数调用enumerate,例如enumerate(iterable, start),它将从start开始,而不是从0开始。这只是另一个小细节,展示了在 Python 的设计中投入了多少思考,以便让生活变得更简单。

使用for循环可以迭代列表、元组,以及 Python 中称为可迭代对象的一切。这是一个非常重要的概念,所以让我们多谈谈它。

迭代器和可迭代对象

根据 Python 文档,一个可迭代对象是:

“一个能够一次返回其成员的对象。可迭代的例子包括所有序列类型(如 liststrtuple)以及一些非序列类型,如 dictfile 对象,以及任何具有 __iter__()__getitem__() 方法的类定义的对象。可迭代的可以在 for 循环和许多其他需要序列的地方使用(zip()map() 等)。当一个可迭代对象作为参数传递给内置函数 iter() 时,它返回该对象的一个迭代器。这个迭代器适用于对值集进行一次遍历。在使用可迭代对象时,通常不需要调用 iter() 或自己处理迭代器对象。for 语句会为你自动完成这些操作,创建一个临时未命名的变量来在循环期间持有迭代器。”

简单来说,当你编写 for k in sequence: ... body ... 时发生的情况是,for 循环会向 sequence 请求下一个元素,它得到一些东西,并将其称为 k,然后执行其主体。然后,再次,for 循环再次向 sequence 请求下一个元素,再次将其称为 k,并再次执行主体,以此类推,直到序列耗尽。空序列将导致主体执行零次。

一些数据结构在迭代时按顺序产生它们的元素,如列表、元组和字符串,而另一些则不会,如集合和字典。

Python 给我们提供了使用称为 迭代器 的对象来迭代可迭代对象的能力。根据官方文档,迭代器是:

“一个表示数据流的对象。对迭代器的 __next__() 方法(或将其传递给内置函数 next())的重复调用将返回数据流中的连续项。当没有更多数据可用时,将引发 StopIteration 异常。此时,迭代器对象已耗尽,对其 __next__() 方法的任何进一步调用都将再次引发 StopIteration。迭代器必须有一个 __iter__() 方法,该方法返回迭代器对象本身,因此每个迭代器也是可迭代的,可以在接受其他可迭代对象的大多数地方使用。一个值得注意的例外是尝试多次迭代遍历的代码。容器对象(如 list)每次你将其传递给 iter() 函数或用于 for 循环时,都会产生一个新的迭代器。尝试使用迭代器这样做只会返回在先前迭代遍历中使用的同一个耗尽的迭代器对象,使其看起来像一个空容器。”

如果你对前面的法律术语不完全理解,不要担心,你会在适当的时候理解。我把它们放在这里,作为未来方便的参考。

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

遍历多个序列

让我们看看另一个例子,说明如何迭代两个长度相同的序列,以便成对地处理它们的各自元素。比如说,我们有一个包含人的列表和一个表示第一个列表中人的年龄的数字列表。我们想要打印出所有人的姓名/年龄对。让我们从一个例子开始,然后逐步改进它。

multiple.sequences.py

people = ['Jonas', 'Julio', 'Mike', 'Mez']
ages = [25, 30, 31, 39]
for position in range(len(people)):
    person = people[position]
    age = ages[position]
    print(person, age)

到现在为止,你应该已经能够理解这段代码了。我们需要遍历位置列表(0,1,2,3),因为我们想要从两个不同的列表中检索元素。执行它我们得到以下结果:

$ python multiple.sequences.py
Jonas 25
Julio 30
Mike 31
Mez 39

这段代码既低效又不符合 Python 风格。低效是因为给定位置检索元素可能是一个昂贵的操作,而且我们每次迭代都是从零开始做的。邮递员每次送信时不会回到路的起点,对吧?他是从一栋房子走到另一栋。让我们尝试使用 enumerate 来让它变得更好:

multiple.sequences.enumerate.py

people = ['Jonas', 'Julio', 'Mike', 'Mez']
ages = [25, 30, 31, 39]
for position, person in enumerate(people):
    age = ages[position]
    print(person, age)

更好,但仍然不完美。而且仍然有点丑。我们正确地迭代了people,但我们仍然使用位置索引来获取age,这是我们想要丢弃的。嗯,不用担心,Python 给你提供了zip函数,记得吗?让我们来用它!

multiple.sequences.zip.py

people = ['Jonas', 'Julio', 'Mike', 'Mez']
ages = [25, 30, 31, 39]
for person, age in zip(people, ages):
    print(person, age)

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

multiple.sequences.explicit.py

people = ['Jonas', 'Julio', 'Mike', 'Mez']
ages = [25, 30, 31, 39]
nationalities = ['Belgium', 'Spain', 'England', 'Bangladesh']
for person, age, nationality in zip(people, ages, nationalities):
    print(person, age, nationality)

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

$ python multiple.sequences.explicit.py
Jonas 25 Belgium
Julio 30 Spain
Mike 31 England
Mez 39 Bangladesh

有时候,可能由于前面的简单示例中不明显的原因,你可能想在for循环体内部展开元组。如果你有这个愿望,这样做是完全可能的。

multiple.sequences.implicit.py

people = ['Jonas', 'Julio', 'Mike', 'Mez']
ages = [25, 30, 31, 39]
nationalities = ['Belgium', 'Spain', 'England', 'Bangladesh']
for data in zip(people, ages, nationalities):
    person, age, nationality = data
    print(person, age, nationality)

它基本上是在做for循环自动为你做的事情,但在某些情况下,你可能想自己来做。在这里,来自zip(...)的 3 元组datafor循环体内部展开成三个变量:personagenationality

while循环

在前面的页面中,我们看到了for循环的实际应用。当你需要遍历一个序列或集合时,它非常有用。当你需要能够区分使用哪种循环结构时,需要记住的关键点是,当你需要遍历有限数量的元素时,for循环非常出色。它可以是非常大的数量,但仍然,在某一点上会结束。

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

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

如同往常,让我们看一个例子,这个例子将为我们澄清一切。我们想要打印一个正数的二进制表示。为了做到这一点,我们反复将数字除以 2,收集余数,然后生成余数列表的逆序。让我给你一个使用数字 6 的小例子,它在二进制中表示为 110。

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.append(remainder)  # we keep track of remainders
    n //= 2  # we divide n by 2

# reassign the list to its reversed copy and print it
remainders = remainders[::-1]
print(remainders)

在前面的代码中,我强调了两个要点:n > 0,这是保持循环的条件,以及remainders[::-1],这是一个获取列表反转的好方法(缺少startend参数,step = -1会产生相同的列表,从endstart,以相反的顺序)。我们可以通过使用divmod函数使代码更短(并且更 Pythonic),该函数用一个数和一个除数调用,并返回一个包含整数除法结果及其余数的元组。例如,divmod(13, 5)将返回(2, 3),确实5 * 2 + 3 = 13

binary.2.py

n = 39
remainders = []
while n > 0:
    n, remainder = divmod(n, 2)
    remainders.append(remainder)

# reassign the list to its reversed copy and print it
remainders = remainders[::-1]
print(remainders)

在前面的代码中,我们将n重新赋值为除以 2 的结果,以及余数,都在一行中完成。

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

注意

如果条件永远不会评估为False,循环就变成了所谓的无限循环。无限循环在例如从网络设备轮询时使用:你询问套接字是否有数据,如果有,你处理它,然后你睡一小会儿,然后你再次询问套接字,一次又一次,永不停止。

能够根据条件循环或无限循环,是为什么仅仅for循环不足以满足需求,因此 Python 提供了while循环。

小贴士

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

只为了好玩,让我们使用 while 逻辑修改一个例子(multiple.sequences.py)。

multiple.sequences.while.py

people = ['Jonas', 'Julio', 'Mike', 'Mez']
ages = [25, 30, 31, 39]
position = 0
while position < len(people):
    person = people[position]
    age = ages[position]
    print(person, age)
    position += 1

在前面的代码中,我已经突出显示了变量position初始化条件更新,这使得通过手动处理迭代变量来模拟等效的for循环代码成为可能。你可以用for循环完成的事情,也可以用while循环完成,尽管你可以看到为了达到相同的结果,你必须经历一些样板代码。相反的情况也是真实的,但是使用for循环模拟一个永不结束的while循环需要一些真正的技巧,所以你为什么要这样做呢?使用适合的工具,99.9%的情况下你会没事的。

因此,总结一下,当你需要遍历一个(或多个)可迭代对象时,使用for循环;当你需要根据条件是否满足来循环时,使用while循环。如果你记住这两个目的之间的区别,你就永远不会选择错误的循环结构。

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

breakcontinue语句

根据当前任务,有时你可能需要改变循环的正常流程。你可以跳过单个迭代(多次你想跳过的次数),或者你可以完全跳出循环。跳过迭代的常见用例,例如当你遍历一个项目列表,并且只有当某些条件得到验证时,你才需要处理每个项目。另一方面,如果你正在遍历一个项目集合,并且你已经找到了满足你需求的一个项目,你可能会决定不继续整个循环,因此跳出它。可能存在无数种可能的场景,所以最好看看几个例子。

假设你想要对购物车列表中今天过期的所有产品应用 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 对象,然后设置我们的产品。那些 sku 为 13 的产品有 today 的过期日期,这意味着我们想要对它们应用 20%的折扣。我们遍历每个 product 并检查过期日期。如果它不是(不等号操作符,!=today,我们不想执行其余的代码块,所以使用 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模式。我们将driver设置为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对象,以防我们找到它,因为代码的其余部分将使用这些信息。注意代码变得更短、更优雅,因为逻辑现在被正确地组合在一起。

将这些放在一起

现在你已经看到了关于条件和循环的所有内容,是时候让事情变得有趣一些,看看我在本章开头提到的两个例子。我们将混合使用,这样你可以看到如何将这些概念结合起来。让我们先编写一些代码来生成一个到某个限制的质数列表。请记住,我将编写一个非常低效和原始的算法来检测质数。对你来说,重要的是要专注于代码中属于本章主题的部分。

示例 1 – 一个素数生成器

根据维基百科:

"素数(或素数)是一个大于 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 循环。在外层循环中,我们遍历候选素数,即从 2 到 upto 的所有自然数。在每个外层循环的迭代中,我们设置一个标志(每个迭代都设置为 True),然后开始将当前的 n 除以从 2 到 n – 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]

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

你弄懂了吗?如果没有,别难过,这是完全正常的。我让你做这个小练习是因为这就是程序员一直做的事情。通过仅仅看代码就能理解代码的功能的技能是你在时间中逐渐培养出来的。这非常重要,所以尽量在可能的情况下练习它。我现在告诉你答案:表现与其他迭代不同的迭代是第一个。原因是第一个迭代中 n 是 2。因此最内层的 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 列表中。看看代码看起来更整洁,读起来更好吗?

示例 2 – 应用折扣

在这个例子中,我想向你展示我非常喜欢的一种技术。在许多编程语言中,除了 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 操作。这意味着我们得到它的值,然后我们决定它适合哪个情况(如果有的话)。从 1 到 5 是一个级联,这意味着无论数字是什么,[1, 5] 都会下降到设置 day"Weekday" 的逻辑部分。然后我们有针对 0 和 6 的单个情况,以及一个 default 情况来防止错误,这会通知系统 day_number 不是一个有效的天数,也就是说,不在 [0, 6] 范围内。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 不在 [0, 6] 范围内。这是翻译 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。简单但有效。

这种技术很重要,因为它还用于其他上下文中,比如函数,在那里它实际上比我们在前面的代码片段中看到的功能要强大得多。如果您对它的工作原理还不完全清楚,我建议您花点时间实验一下。改变值并添加打印语句,以查看程序运行时的状态。

快速浏览itertools模块

在关于可迭代对象、迭代器、条件逻辑和循环的章节中,如果不花点时间谈谈itertools模块,那就不是一个完整的章节。如果您喜欢迭代,这将是一个天堂。

根据 Python 官方文档,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的例子。这个迭代器根据选择器中相应项的TrueFalse返回数据:

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

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]

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

组合生成器

最后但同样重要的是,组合生成器。如果你对这类东西感兴趣,这真的很有趣。让我们看看排列的一个简单例子。

根据 Wolfram Mathworld:

"排列,也称为“排列数”或“顺序”,是将有序列表 S 的元素重新排列,使其与 S 本身形成一一对应关系。"

例如,ABC 的排列有 6 个: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关键字定义的,之后跟随着函数名,由一对括号(可能包含或不包含输入参数)终止,最后,一个冒号(:)标志着函数定义行的结束。紧接着,缩进四个空格,我们找到函数的主体,这是函数被调用时将执行的一组指令。

注意

注意,使用四个空格缩进不是强制性的,但它是由PEP8建议的空格数量,在实践中,这是最广泛使用的缩进度量。

函数可能返回也可能不返回输出。如果函数想要返回输出,它将通过使用return关键字,后跟所需的输出来实现。如果你有敏锐的眼睛,你可能已经注意到了前一张图片输出部分可选后面的一个小星号。这是因为 Python 中的函数总是返回某些东西,即使你没有明确使用return子句。如果一个函数在其主体中没有return语句,它的返回值是None。这个设计选择背后的原因超出了入门章节的范围,所以你只需要知道这种行为会像往常一样使你的生活更轻松,感谢 Python。

为什么使用函数?

函数是任何语言中最重要和最基本的概念和结构之一,所以让我给你几个为什么我们需要它们的原因:

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

  • 他们帮助将复杂任务或程序拆分成更小的块,每个块都成为一个函数。

  • 他们将实现细节隐藏给用户。

  • 它们提高了可追溯性。

  • 它们提高了可读性。

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

减少代码重复

想象一下,你正在编写一段科学软件,你需要计算到某个限制的质数,就像我们在上一章所做的那样。你编写了几个算法和质数,作为许多不同类型计算的基础,不断地出现在你的代码中。好吧,你有一个计算它们的良好算法,所以你把它复制粘贴到你需要的地方。然而,有一天,你的朋友聪明先生给你提供了一个更好的计算质数的算法,这将为你节省很多时间。在这个时候,你需要检查整个代码库,用新代码替换旧代码。

实际上,这是一种非常糟糕的做法。它容易出错,你永远不知道在复制粘贴代码时,你会不小心删除或留下哪些行,你还有可能错过进行质数计算的地方,导致你的软件出现不同版本。你能想象如果你发现旧方法有错误吗?你会在代码中有一个未检测到的错误,而这种错误很难被发现,尤其是在大型代码库中。

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

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

分解复杂任务

函数也非常有用,可以将一个长或复杂的任务分解成更小的部分。结果是,代码在多个方面都从中受益,例如可读性、可测试性和可重用性。为了给你一个简单的例子,想象一下你正在准备一份报告。你的代码需要从数据源获取数据,解析它,过滤它,润色它,然后需要对它运行一系列算法,以便生成将喂养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 等等)。

作用域和名称解析

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

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 = 1my_function 中覆盖了赋值 test = 0。在全局上下文中,test 仍然是 0,正如你可以从程序的输出中看到的那样,但我们再次在函数体中定义了 test 这个名字,并将其设置为指向值为 1 的整数。因此,这两个 test 名字都存在,一个在全局作用域中,指向值为 0 的 int 对象,另一个在 my_function 作用域中,指向值为 1 的 int 对象。让我们注释掉 test = 1 这一行。Python 会去下一个封装的作用域中寻找 test 这个名字(回想一下 LEGB 规则:LocalEnclosingGlobalBuilt-in,在第一章第一章。介绍和第一步 – 深呼吸中描述),在这种情况下,我们会看到值 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 允许你在另一个函数中定义一个函数。内部函数的名称是在外部函数的命名空间中定义的,这与任何其他名称的情况完全相同。

全局和非局部语句

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

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

"nonlocal语句使列出的标识符引用最近封闭作用域中先前绑定的变量,但不包括全局变量。"

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

scoping.level.2.nonlocal.py

def outer():
    test = 1  # outer scope

    def inner():
        nonlocal test
        test = 2  # nearest enclosing scope
        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中将test声明为nonlocal,我们实际上将test这个名字绑定到了函数outer中声明的那个。如果我们从函数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 赋值影响的名称现在是全局的。这个技巧在 outer 函数中也会起作用,因为在这种情况下,我们是在引用全局作用域。自己试一试,看看会发生什么变化,熟悉作用域和名称解析,这非常重要。

输入参数

在本章的开头,我们看到了一个函数可以接受输入参数。在我们深入所有可能的参数类型之前,让我们确保你对将参数传递给函数的含义有一个清晰的理解。有三个关键点需要记住:

  • 参数传递不过是将一个对象赋值给一个局部变量名

  • 在函数内部将对象赋值给参数名称不会影响调用者

  • 在函数中更改可变对象参数会影响调用者

让我们看看每个这些点的例子。

参数传递

看看下面的代码。我们在全局作用域中声明了一个名为 x 的变量,然后我们声明了一个函数 func(y) 并调用它,传递了 x。我在代码中突出了这个调用。

key.points.argument.passing.py

x = 3
def func(y):
    print(y)
func(x)  # prints: 3

当用 x 调用 func 时,在它的局部作用域内,创建了一个名为 y 的变量,并且它指向了与 x 相同的对象。以下图片可以更好地说明这一点:

参数传递

前一张图的右侧展示了程序执行到达末尾时的状态,在 func 返回 (None) 之后。看看 Frames 这一列,注意我们在全局命名空间(Global frame)中有两个名称,xfunc,分别指向一个 int(值为三)和一个函数对象。在其下方,在标题为 func 的矩形中,我们可以看到函数的局部命名空间,其中只定义了一个名称:y。因为我们用 x(图片左侧的第五行)调用了 func,所以 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'。根据第 2 点,这不会改变调用者,实际上,输出与前面的代码片段相同。

仔细研究这个概念,并通过打印和调用id函数进行实验,直到你心中的一切都清楚。这是 Python 的一个关键方面,它必须非常清晰,否则你可能会在代码中引入微妙的错误。

现在我们已经很好地理解了输入参数及其行为,让我们看看我们如何指定它们。

如何指定输入参数

有五种不同的方式来指定输入参数。让我们逐一来看。

位置参数

位置参数从左到右读取,它们是最常见的参数类型。

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

关键字参数在调用函数时起作用,而不是尊重从左到右的位置赋值,k。关键字参数通过名称匹配,即使它们不尊重定义的原始位置(我们将在后面看到,这种行为的限制)。

在定义方面,关键字参数的对立面是默认值。语法相同,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

有两点需要注意,这两点非常重要。首先,你不能在位置参数的左侧指定默认参数。其次,注意在示例中,当一个参数没有使用argument_name=value语法传递时,它必须是列表中的第一个参数,并且它总是被分配给a。尝试打乱这些参数,看看会发生什么。Python 的错误信息非常擅长告诉你出了什么问题。所以,例如,如果你尝试了以下操作:

func(b=1, c=2, 42)  # positional argument after keyword one

你会得到以下错误:

SyntaxError: non-keyword arg after keyword arg

这告诉你你调用函数的方式不正确。

可变位置参数

有时你可能希望向函数传递一个可变数量的位置参数,Python 为你提供了这样做的能力。让我们看看一个非常常见的用例,即minimum函数。这是一个计算输入值最小值的函数。

arguments.variable.positional.py

def minimum(*n):
    # print(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(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', 'pwd': '', 'user': '', 'port': 5432}
{'host': '127.0.0.42', 'pwd': '', 'user': '', 'port': 5433}
{'host': '127.0.0.1', 'pwd': 'gandalf', 'user': 'fab', 'port': 5431}

注意函数调用与输出的对应关系。注意默认值要么存在,要么被传递给函数的值覆盖。

仅关键字参数

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: {'f': 'F', 'e': 'E'}

另有一件事需要注意是我给可变位置参数和关键字参数取的名字。你可以自由选择不同的名字,但请注意,argskwargs是这些参数的传统名称,至少在通用意义上。现在,既然你已经知道了如何以所有可能的方式定义函数,让我给你展示一个有点棘手的东西:可变默认值。

避免陷阱!可变默认值

在 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语句。

另一方面,如果在函数体中我们不返回任何内容,函数将返回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),我们没有收集它。正如我之前说的,收集函数调用的返回值不是强制的。

现在,这很好,但不是很吸引人,那么我们写一个有趣的函数怎么样?记住,在第一章中,简介和第一步 – 深呼吸,我们讨论了函数的阶乘。让我们在这里写一个自己的(为了简单起见,我将假设函数总是以适当的值正确调用,所以我不需要在输入参数上进行合理性检查):

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。我们能把这个函数写得更有 Python 风格吗?是的,但我会让你自己作为练习来找出这一点。

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]
    
    

遵循这些指南,你将能写出更好的函数,这对你的工作大有裨益。

注意

《Clean Code》第三章节,函数*,由罗伯特·C·马丁撰写,由 Prentice Hall 出版,专门讨论函数,这可能是我在这个主题上读过的最好的指南集。

递归函数

当一个函数调用自身以产生结果时,它被称为递归。有时递归函数非常有用,因为它们使编写代码变得更容易。有些算法使用递归范式编写起来非常容易,而有些则不然。没有哪个递归函数不能重写为迭代形式,所以通常取决于程序员根据具体情况选择最佳方法。

递归函数通常有一组基本案例,其返回值不依赖于对函数本身的后续调用,以及一组递归案例,其返回值是通过一个或多个对函数本身的调用来计算的。

例如,我们可以考虑(希望现在已经熟悉)的factorial函数 N!。基本案例是当 N 为 0 或 1 时。函数不需要进一步计算就返回 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)))
print(get_multiples_of_five(50))

我突出了get_multiples_of_five的主要逻辑。注意过滤器如何使用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)))
print(get_multiples_of_five(50))

逻辑完全相同,但过滤函数现在是一个 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 0x7ff529e79300, file "ch4/func.attributes.py", line 1>
__globals__ -> {... omitted ...}
__dict__ -> {}
__closure__ -> None
__annotations__ -> {}
__kwdefaults__ -> None

我省略了__globals__属性的值,因为它太大。关于此属性含义的解释可以在Python 数据模型文档页面的类型部分找到。

内置函数

Python 自带了许多内置函数。它们在任何地方都可以使用,你可以通过检查builtin模块的dir(__builtin__)或访问官方 Python 文档来获取它们的列表。不幸的是,我没有足够的空间在这里全部介绍它们。我们已经看到了一些,例如anybinbooldivmodfilterfloatgetattridintlenlistminprintsettupletypezip,但还有很多,你应该至少阅读一次。

熟悉它们,进行实验,为每个编写一小段代码,确保你能够随时使用它们。

一个最后的例子

在我们结束这一章之前,不妨举一个最后的例子?我想我们可以编写一个函数来生成一个小于等于某个限制的质数列表。我们已经看到了这段代码,让我们将其变成一个函数,并且为了保持趣味性,让我们对其进行一些优化。

实际上,你不需要将一个数除以从 2 到 N-1 的所有数来决定一个数 N 是否为质数。你可以在 一个最后的例子 处停止。此外,你不需要对从 2 到 一个最后的例子 的所有数进行除法测试,你只需使用该范围内的质数即可。如果你感兴趣,我会让你自己找出为什么这会起作用。让我们看看代码如何变化:

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 = int(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

代码与上一章相同。我们改变了除法算法,以便我们只使用之前计算出的质数来测试可除性,并且一旦测试除数大于候选数的平方根,我们就停止测试。我们使用一个花哨的公式来计算根值,即候选数根的整数上界。虽然简单的 int(k ** 0.5) + 1 也能达到我们的目的,但我选择的公式更简洁,需要我使用几个导入,我想向你展示。查看 math 模块中的函数,它们非常有趣!

记录你的代码

我非常喜欢不需要文档的代码。当你正确编程,选择合适的名称并注意细节时,你的代码应该是自解释的,不需要文档。尽管有时注释和一些文档非常有用。你可以通过 PEP257 – Docstring conventions 找到记录 Python 的指南,但我会在这里展示基础知识。

Python 使用字符串进行文档记录,这些字符串被称为 docstrings。任何对象都可以进行文档记录,你可以使用单行或多行 docstrings。单行非常简单。它们不应该为函数提供另一个签名,但应清楚地说明其目的。

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 函数。

注意

Sphinx 可能是创建 Python 文档最广泛使用的工具。实际上,官方的 Python 文档就是用它编写的。花些时间了解它绝对是值得的。

docstrings.py

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

导入对象

现在你对函数了解得很多了,让我们看看如何使用它们。编写函数的整个目的就是为了能够以后重用它们,在 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

为了让你对导入有一个直观的了解,这里有一个例子,来自我几年前编写的一个数论库的测试模块(它在 Bitbucket 上可用):

karma/test_nt.py

import unittest  # imports the unittest module
from math import sqrt  # imports one function from math
from random import randint, sample  # two imports at once

from mock import patch
from nose.tools import (  # multiline import
    assert_equal,
    assert_list_equal,
    assert_not_in,
)

from karma import nt, utils

我对其中的一些进行了注释,希望这很容易理解。当你有一个从项目根目录开始的文件结构时,你可以使用点符号来获取你想要导入到当前命名空间的对象,无论是包、模块、类、函数还是其他任何东西。from module import 语法还允许一个通配符子句 from module import *,有时用于一次性将模块中的所有名称导入到当前命名空间中,但出于几个原因,这并不被推荐:性能、静默覆盖其他名称的风险等等。你可以在官方 Python 文档中找到有关导入的所有信息,但在我们离开这个话题之前,让我给你一个更好的例子。

假设你在一个模块中定义了一些函数:square(n)cube(n),该模块名为 funcdef.py,位于 lib 文件夹中。你想要在位于 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

对于相对导入的完整解释,请参考PEP328 (www.python.org/dev/peps/pep-0328)。

在后面的章节中,我们将使用不同的库来创建项目,并且我们会使用几种不同类型的导入,包括相对导入,所以请确保你花一些时间在官方 Python 文档中阅读相关内容。

摘要

在本章中,我们终于探索了函数的世界。它们极其重要,从现在开始,我们将基本上在所有地方使用它们。我们讨论了使用函数的主要原因,其中最重要的是代码重用和实现隐藏。

我们看到函数对象就像一个盒子,它接受可选输入并产生输出。我们可以通过多种方式向函数提供输入值,使用位置参数和关键字参数,以及使用变量语法来处理这两种类型。

现在你应该知道如何编写一个函数,如何对其进行文档化,将其导入到你的代码中,并调用它。

下一章将迫使我进一步加大油门,所以我建议你抓住任何机会,通过深入研究 Python 官方文档来巩固和丰富你至今所积累的知识。

准备好迎接酷炫的内容了吗?让我们开始吧!

第五章:节省时间和内存

*"不是每天的增量,而是每天的减量。砍掉不必要的东西。"
--李小龙

我非常喜欢李小龙的这句话,他是一位非常明智的人!特别是,第二部分“砍掉不必要的东西”,对我来说正是使计算机程序优雅的原因。毕竟,如果我们有更好的做事方式,这样我们就不浪费时间和内存,为什么不呢?

有时候,有一些合理的理由不把我们的代码推到最大极限:例如,有时候为了实现微小的改进,我们必须牺牲可读性或可维护性。当我们可以用可读的、干净的代码在 1.05 秒内提供服务时,为什么还要用 1 秒内无法阅读的、复杂的代码来提供服务呢?没有,这没有意义。

另一方面,有时尝试从函数中节省一个毫秒是完全合法的,尤其是当这个函数预期会被调用数千次时。你节省的每一个毫秒都意味着在每次调用中节省一秒钟,这对于你的应用程序来说可能是有意义的。

考虑到这些因素,本章的重点不是给你工具来让你的代码无论什么情况下都能达到性能和优化的绝对极限,而是给你工具来编写高效、优雅的代码,这些代码易于阅读,运行速度快,并且不会以明显的方式浪费资源。

在本章中,我将进行几次测量和比较,并谨慎地得出一些结论。请务必记住,在不同的机器、不同的设置或不同的操作系统上,结果可能会有所不同。看看这段代码:

squares.py

def square1(n):
    return n ** 2  # squaring through the power operator

def square2(n):
    return n * n  # squaring through multiplication

这两个函数都返回n的平方,但哪个更快?从我对他们进行的一个简单基准测试来看,第二个似乎稍微快一点。如果你这么想,这是有道理的:计算一个数的幂涉及乘法,因此,无论你使用什么算法来执行幂运算,它都不太可能比square2中的简单乘法更快。

我们是否关心这个结果?在大多数情况下不关心。如果你正在编写一个电子商务网站,你很可能永远不需要将一个数字提高到平方,即使你需要这样做,你可能也只是在每一页上做几次。你不需要担心在一个只调用几次的函数上节省几微秒。

那么,何时优化变得重要呢?一个非常常见的例子是当你必须处理大量数据时。如果你要对一百万个customer对象应用相同的函数,那么你希望你的函数调优到最佳状态。在一个被调用一百万次的函数上节省 1/10 秒可以为你节省 100,000 秒,这大约是 27.7 小时。这难道不是一样的吗?所以,让我们专注于集合,看看 Python 提供了哪些工具来高效且优雅地处理它们。

注意

本章中我们将看到的大部分概念都基于 迭代器可迭代对象 的概念。简单来说,就是对象在被询问时返回其下一个元素的能力,以及在耗尽时引发 StopIteration 异常的能力。我们将在下一章中看到如何编写自定义的迭代器和可迭代对象。

map, zip, 和 filter

我们将首先回顾 mapfilterzip,这些是在处理集合时可以使用的内置函数的主要函数,然后我们将学习如何使用两个非常重要的结构来实现相同的结果:列表推导式生成器。系好安全带!

map

根据官方 Python 文档:

map(function, iterable, ...) 返回一个迭代器,它将函数应用到可迭代对象的每个元素上,并产生结果。如果传递了额外的可迭代参数,函数必须接受那么多参数,并且将并行应用于所有可迭代对象的元素。如果有多个可迭代对象,迭代器会在最短的迭代对象耗尽时停止。

我们将在本章后面解释 yielding 的概念。现在,让我们将其转换为代码:我们将使用一个接受可变数量位置参数的 lambda 函数,并仅将它们作为元组返回。此外,由于 map 返回一个迭代器,我们需要在 list 构造函数中包装对它的每个调用,以便通过将所有元素放入列表来耗尽可迭代对象(你将在代码中看到一个例子):

map.example.py

>>> map(lambda *a: a, range(3))  # without wrapping in list...
<map object at 0x7f563513b518>  # we get the iterator object
>>> list(map(lambda *a: a, range(3)))  # wrapping in list...
[(0,), (1,), (2,)]  # we get a list with its elements
>>> list(map(lambda *a: a, range(3), 'abc'))  # 2 iterables
[(0, 'a'), (1, 'b'), (2, 'c')]
>>> list(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
>>> list(map(lambda *a: a, (), 'abc'))  # empty tuple is shortest
[]
>>> list(map(lambda *a: a, (1, 2), 'abc'))  # (1, 2) shortest
[(1, 'a'), (2, 'b')]
>>> list(map(lambda *a: a, (1, 2, 3, 4), 'abc'))  # 'abc' shortest
[(1, 'a'), (2, 'b'), (3, 'c')]

在前面的代码中,你可以看到为什么,为了向你展示结果,我必须将 map 的调用包装在 list 构造函数中,否则我会得到一个 map 对象的字符串表示,这在当前上下文中并不是很有用,对吧?

你还可以注意到每个可迭代对象的元素是如何应用到函数上的:首先,每个可迭代对象的第一元素,然后是每个可迭代对象的第二个元素,依此类推。还要注意,当使用 map 的可迭代对象中最短的一个耗尽时,map 会停止。这实际上是一个非常棒的行为:它不会强迫我们将所有可迭代对象都调整到相同的长度,而且如果它们的长度不相同,它也不会出错。

当你需要将相同的函数应用到一组或多个对象集合上时,map 非常有用。作为一个更有趣的例子,让我们看看 decorate-sort-undecorate 习语(也称为 Schwartzian 转换)。这是一种在 Python 排序不提供 key-functions 时非常流行的技术,因此今天使用较少,但它是一个偶尔仍然有用的巧妙技巧。

让我们在下一个例子中看看它的一个变体:我们想要根据学生累积的学分总和进行降序排序,以便将最佳学生放在位置 0。我们编写一个函数来生成装饰对象,然后排序,然后取消装饰。每个学生有三个(可能不同的)科目学分。装饰一个对象意味着对其进行转换,要么向其添加额外数据,要么将其放入另一个对象中,以便我们能够以我们想要的方式对原始对象进行排序。排序后,我们将装饰对象恢复以获取原始对象。这被称为取消装饰。

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 = list(map(undecorate, students))

在前面的代码中,我突出了棘手和重要的部分。让我们首先了解每个学生对象是什么。事实上,让我们打印第一个:

{'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0}

你可以看到这是一个包含两个键的字典:idcreditcredit 的值也是一个字典,其中包含三个科目/成绩的键/值对。我相信你从我们在数据结构世界中的访问中还记得,调用 dict.values() 返回一个类似于 iterable 的对象,只包含值。因此,对于第一个学生,sum(student['credits'].values()) 等同于 sum(9, 6, 7)(或者这些数字的任何排列,因为字典不保留顺序,但幸运的是,加法是交换的)。

解决了这个问题之后,很容易看出调用装饰函数后会发生什么。让我们打印 decorate(students[0]) 的结果:

(22, {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0})

很好!如果我们这样装饰所有学生,我们就可以根据他们的总学分对他们进行排序,但只需对元组列表进行排序。为了将装饰应用于学生中的每个项目,我们调用 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}]

你可以看到,按照学生对象的顺序,它们确实是根据它们的学分总和进行了排序。

注意

关于 decorate-sort-undecorate 习语的更多信息,可以在官方 Python 文档的排序如何做部分中找到一个非常好的介绍(docs.python.org/3.4/howto/sorting.html#the-old-way-using-decorate-sort-undecorate)。

关于排序部分有一点需要注意:如果有两个或更多学生的总分相同,排序算法将按比较 student 对象进行排序。这没有任何意义,在更复杂的情况下可能会导致不可预测的结果,甚至错误。如果你想要确保避免这个问题,一个简单的解决方案是创建一个 3-元组而不是 2-元组,第一个位置是学分的总和,第二个位置是 students 列表中 student 对象的位置,第三个位置是 student 对象本身。这样,如果学分的总和相同,元组将按位置排序,这总是不同的,因此足以解决任何一对元组之间的排序。关于这个主题的更多考虑,请查看官方 Python 文档中的排序如何操作部分。

zip

我们已经在前面的章节中介绍了 zip,所以让我们正确地定义它,然后我想向你展示如何将它与 map 结合使用。

根据 Python 文档:

zip(*iterables) 返回一个元组的迭代器,其中第 i 个元组包含每个参数序列或可迭代对象的第 i 个元素。当最短的输入可迭代对象耗尽时,迭代器停止。如果只有一个可迭代对象参数,它返回一个 1-元组迭代器。如果没有参数,它返回一个空迭代器。

让我们看看一个例子:

zip.grades.py

>>> grades = [18, 23, 30, 27, 15, 9, 22]
>>> avgs = [22, 21, 29, 24, 18, 18, 24]
>>> list(zip(avgs, grades))
[(22, 18), (21, 23), (29, 30), (24, 27), (18, 15), (18, 9), (24, 22)]
>>> list(map(lambda *a: a, avgs, grades))  # equivalent to zip
[(22, 18), (21, 23), (29, 30), (24, 27), (18, 15), (18, 9), (24, 22)]

在前面的代码中,我们将平均分和最后考试的分数按每个学生进行组合。注意代码在两个列表调用中产生的结果完全相同,展示了如何轻松地通过 map 重新生成 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))
>>> list(maxs)
[6, 9, 2, 9, 7]

注意计算三个序列的最大值是多么容易。当然,zip 不是必需的,我们也可以只使用 map,但这将需要我们编写一个更复杂的函数来提供给 map。有时我们可能处于无法更改提供给 map 的函数的情况。在这种情况下,能够处理数据(就像我们在本例中使用 zip 所做的那样)非常有帮助。

filter

根据 Python 文档:

filter(function, iterable) 从那些函数返回 True 的可迭代对象元素中构建一个迭代器。可迭代对象可以是序列,支持迭代的容器,或迭代器。如果函数是 None,则假定身份函数,即移除可迭代对象中所有为假的元素。

让我们看看一个非常快速的例子:

filter.py

>>> test = [2, 5, 8, 0, 0, 1, 0]
>>> list(filter(None, test))
[2, 5, 8, 1]
>>> list(filter(lambda x: x, test))  # equivalent to previous one
[2, 5, 8, 1]
>>> list(filter(lambda x: x > 4, test))  # keep only items > 4
[5, 8]

在前面的代码中,注意第二个filter调用与第一个是等价的。如果我们传递一个只接受一个参数并返回该参数本身的函数,只有那些返回True的参数才会使函数返回True,因此这种行为与传递None完全相同。模仿一些内置的 Python 行为通常是一个非常有益的练习。当你成功时,你可以说你完全理解了 Python 在特定情况下的行为。

有了mapzipfilter(以及 Python 标准库中的几个其他函数),我们可以非常有效地处理序列。但这些都是做这件事的方法之一。所以让我们看看 Python 最漂亮的功能之一:推导式。

推导式

Python 为你提供了不同类型的推导式:listdictset

我们现在先关注第一个,然后解释其他两个就会容易得多。

列表推导式是快速创建列表的一种方法。通常列表是某些操作的结果,这些操作可能涉及应用函数、过滤或构建不同的数据结构。

让我们从一个非常简单的例子开始,我想计算前 10 个自然数的平方列表。你会怎么做?有几个等效的方法:

squares.map.py

# If you code like this you are not a Python guy! ;)
>>> squares = []
>>> for n in range(10):
...     squares.append(n ** 2)
...
>>> list(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))
>>> list(squares)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

上述示例对你来说应该不是什么新鲜事。让我们看看如何使用列表推导式达到相同的结果:

squares.comprehension.py

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

就这么简单。难道这不优雅吗?基本上我们是在方括号内放置了一个for循环。现在让我们过滤掉奇数平方。我会用mapfilter来展示如何做,然后再用列表推导式来做。

even.squares.py

# using map and filter
sq1 = list(
    filter(lambda n: not n % 2, map(lambda n: 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

我想现在可读性的差异应该是显而易见的。列表推导式读起来要好得多。它几乎就像是英语:给我所有 0 到 9 之间偶数的平方(n ** 2)。

根据 Python 文档:

列表推导式由包含一个表达式和随后的for子句的括号组成,然后是零个或多个forif子句。结果将是一个新列表,该列表是在forif子句的上下文中评估表达式后得到的。

嵌套推导式

让我们看看嵌套循环的一个例子。在处理算法时,通常需要使用两个占位符来迭代一个序列。第一个占位符从左到右遍历整个序列。第二个占位符也是如此,但它从第一个占位符开始,而不是从 0 开始。这个概念是测试所有成对而不重复。让我们看看经典的for循环等效。

pairs.for.loop.py

items = 'ABCDE'
pairs = []
for a in range(len(items)):
    for b in range(a, len(items)):
        pairs.append((items[a], items[b]))

如果你最后打印出成对的数据,你会得到:

[('A', 'A'), ('A', 'B'), ('A', 'C'), ('A', 'D'), ('A', 'E'), ('B', 'B'), ('B', 'C'), ('B', 'D'), ('B', 'E'), ('C', 'C'), ('C', 'D'), ('C', 'E'), ('D', 'D'), ('D', 'E'), ('E', 'E')]

所有具有相同字母的元组都是那些b在相同位置于a的元组。现在,让我们看看我们如何将这个翻译成列表推导式:

pairs.list.comprehension.py

items = 'ABCDE'
pairs = [(items[a], items[b])
    for a in range(len(items)) for b in range(a, len(items))]

这个版本只有两行长,但达到了相同的效果。注意,在这个特定的情况下,因为for循环对b的遍历依赖于a,所以在推导式中它必须跟在for循环对a的遍历之后。如果你交换它们的位置,你会得到一个命名错误。

过滤推导式

我们可以将过滤应用于推导式。让我们首先使用filter来做。让我们找到所有短边小于 10 的勾股数。显然,我们不希望测试两次相同的组合,因此我们将使用我们在上一个例子中看到的那种技巧。

注意

勾股数是一个整数三元组(a, b, c),满足方程过滤推导式

pythagorean.triple.py

from math import sqrt
# this will generate all possible pairs
mx = 10
legs = [(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
legs = list(
    filter(lambda triple: triple[2].is_integer(), legs))
print(legs)  # prints: [(3, 4, 5.0), (6, 8, 10.0)]

在前面的代码中,我们生成了一组三元组,即边长。每个元组包含两个整数(边长)和勾股三角形的斜边,其边长是元组中的前两个数字。例如,当 a = 3 且 b = 4 时,元组将是(3, 4, 5.0),当 a = 5 且 b = 7 时,元组将是(5, 7, 8.602325267042627)。

在生成所有三元组之后,我们需要过滤掉那些斜边不是整数的所有三元组。为了做到这一点,我们基于float_number.is_integer()True进行过滤。这意味着在我之前给你展示的两个示例元组中,斜边为 5.0 的那个将被保留,而斜边为 8.602325267042627 的那个将被丢弃。

这很好,但我不喜欢三元组中有两个整数和一个浮点数。它们应该都是整数,所以让我们使用map来修复这个问题:

pythagorean.triple.int.py

from math import sqrt
mx = 10
legs = [(a, b, sqrt(a**2 + b**2))
    for a in range(1, mx) for b in range(a, mx)]
legs = filter(lambda triple: triple[2].is_integer(), legs)
# this will make the third number in the tuples integer
legs = list(
    map(lambda triple: triple[:2] + (int(triple[2]), ), legs))
print(legs)  # prints: [(3, 4, 5), (6, 8, 10)]

注意我们添加的步骤。我们取legs中的每个元素,并从中切片,只取其中的前两个元素。然后,我们将这个切片与一个 1 元组连接起来,在这个 1 元组中,我们放那个我们不喜欢的浮点数的整数版本。

看起来要做很多工作,对吧?确实如此。让我们看看如何使用列表推导式来完成所有这些:

pythagorean.triple.comprehension.py

from math import sqrt
# this step is the same as before
mx = 10
legs = [(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
legs = [(a, b, int(c)) for a, b, c in legs if c.is_integer()]
print(legs)  # prints: [(3, 4, 5), (6, 8, 10)]

我知道。它好多了,不是吗?它干净、易读、更短。换句话说,优雅。

小贴士

我在这里走得很快,正如上一章总结中预期的那样。你在玩这个代码吗?如果不是,我建议你试试。重要的是你要玩一玩,破坏一些东西,改变一些东西,看看会发生什么。确保你对正在发生的事情有清晰的理解。你想要成为一个忍者,对吧?

字典推导式

字典和集合推导式的工作方式与列表推导式完全一样,只是在语法上有一点不同。以下示例将足以解释你需要知道的一切:

dictionary.comprehensions.py

from string import ascii_lowercase
lettermap = dict((c, k) for k, c in enumerate(ascii_lowercase, 1))

如果你打印lettermap,你会看到以下内容(我省略了中间结果,你得到的是要点):

{'a': 1,
 'b': 2,
 'c': 3,
 ... omitted results ...
 'x': 24,
 '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: {'o': 'O', 'l': 'L', 'e': 'E', 'H': 'h'}

我们创建了一个字典,键是字符串 'Hello' 中的字母,值是相同字母的大小写互换。注意只有一个 'l': 'L' 对。构造函数不会抱怨,只是简单地重新分配重复项到最新的值。让我们用另一个例子来使这更清晰;让我们将每个键分配到其在字符串中的位置:

dictionary.comprehensions.positions.py

word = 'Hello'
positions = {c: k for k, c in enumerate(word)}
print(positions)  # prints: {'l': 3, 'o': 4, 'e': 1, 'H': 0}

注意与字母 'l' 关联的值是 3。对 'l': 2 并不存在,它已被 'l': 3 覆盖。

集合推导式

集合推导式与列表和字典推导式非常相似。Python 允许使用 set() 构造函数或显式的 {} 语法。让我们看一个快速示例:

set.comprehensions.py

word = 'Hello'
letters1 = set(c for c in word)
letters2 = {c for c in word}
print(letters1)  # prints: {'l', 'o', 'H', 'e'}
print(letters1 == letters2)  # prints: True

注意对于集合推导式,就像对于字典一样,不允许重复,因此生成的集合只有四个字母。此外,注意分配给 letters1letters2 的表达式产生了等效的集合。

创建 letters2 所使用的语法与我们用来创建字典推导式的语法非常相似。你只能通过以下事实来区分它们:字典需要通过列分隔的键和值,而集合不需要。

生成器

生成器是 Python 赋予我们的一种非常强大的工具。它们基于我们之前提到的 迭代 概念,并允许结合优雅和效率的编码模式。

生成器有两种类型:

  • 生成器函数:这些与常规函数非常相似,但它们不是通过返回语句返回结果,而是使用 yield,这使得它们可以在每次调用之间暂停和恢复其状态

  • 生成器表达式:这些与我们在本章中看到的列表推导式非常相似,但它们返回的是一个对象,该对象一次产生一个结果

生成器函数

生成器函数在所有方面都类似于常规函数,只有一个区别:它们不是一次性收集结果并返回,而是可以开始计算,产生一个值,暂停其状态,保存所有需要能够恢复的信息,如果再次被调用,则恢复并执行另一个步骤。Python 会自动将生成器函数转换为它们自己的迭代器,因此你可以对它们调用next

这一切都是非常理论性的,所以,让我们明确一下这种机制为什么如此强大,然后让我们看看一个例子。

假设我让你大声从 1 数到一百万。你开始数,在某个时候我让你停下来。过了一段时间,我让你继续。在这个时候,你至少需要记住哪个数字才能正确地继续?好吧,你需要记住你最后叫的数字。如果我让你在 31415 之后停下来,你将直接从 31416 开始,以此类推。

重点是,你不需要记住你之前说的所有数字,也不需要将它们写下来。好吧,你可能不知道,但你已经在像生成器一样行事了!

仔细看看下面的代码:

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

打印的结果将会相同:[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]。但是这两个函数之间有一个巨大的区别。get_squares是一个经典的函数,它将 0, n)区间内所有数的平方收集到一个列表中,并返回它。另一方面,get_squares_gen是一个生成器,其行为非常不同。每次解释器到达yield行时,其执行就会暂停。那些打印返回相同结果的原因仅仅是因为我们将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 0x7f158...>
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 个元素,排列数是 3628800。但是一个有 20 个元素的序列将有 2432902008176640000 个排列。它们以阶乘的方式增长。

现在想象一下,你有一个经典函数,它试图计算所有排列,将它们放入一个列表中,然后返回给你。对于 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)

之前的代码生成了几何级数的所有项 aaq,![生成器函数,生成器函数,... 当级数产生一个大于 100,000 的项时,生成器停止(使用return语句)。运行代码会产生以下结果:

$ python gen.yield.return.py
2
10
50
250
1250
6250
31250

下一个项将是 156250,这太大了。

超越下一个

在本章的开头,我告诉过你,生成器对象基于迭代协议。我们将在下一章看到一个如何编写自定义迭代器/可迭代对象的完整示例。现在,我只是想让你理解next()是如何工作的。

当你调用 next(generator) 时,实际上是在调用 generator.__next__() 方法。记住,方法只是属于一个对象的功能,Python 中的对象可以有特殊方法。我们的朋友 __next__() 就是其中之一,它的目的是返回迭代中的下一个元素,或者当迭代结束时没有更多元素返回时抛出 StopIteration 异常。

注意

在 Python 中,一个对象的特殊方法也被称为 魔法方法,或 双下划线 方法

当我们编写一个生成器函数时,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
print(squares.__next__())

结果与上一个示例完全相同,只是这次我们没有使用代理调用 next(squares),而是直接调用 squares.__next__()

生成器对象还有三个其他方法可以用来控制其行为: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 改为 True 时,while 循环将退出,下一次调用将抛出 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),并将 n(0)返回给调用者。0 在控制台上打印。

然后我们调用 send (#D),执行恢复,result 被设置为 'Wow!'(仍然 #A),然后其类型和值在控制台上打印(#B)。result 不是 'Q',因此 n 增加 1,执行回到 while 条件,由于 True,它评估为 True(这不难猜到,对吧?)。另一个循环周期开始,执行再次停止(#A),并将 n(1)返回给调用者。1 在控制台上打印。

在这个点上,我们调用 next (#E),执行再次恢复(#A),因为我们没有明确地向生成器发送任何内容,Python 的行为与不使用 return 语句的函数完全一样:yield n 表达式(#A)返回 None。因此,result 被设置为 None,其类型和值再次在控制台上打印(#B)。执行继续,result 不是 'Q',所以 n 增加 1,然后再次开始另一个循环。执行再次停止(#A),并将 n(2)返回给调用者。2 在控制台上打印。

现在是压轴大戏:我们再次调用 send (#F),但这次我们传递了 'Q',因此当执行恢复时,result 被设置为 'Q' (#A)。其类型和值在控制台上打印(#B),然后最终 if 子句评估为 Truewhile 循环通过 break 语句停止。生成器自然终止,这意味着会引发 StopIteration 异常。你可以在控制台打印的最后几行看到其跟踪信息。

这一点一开始并不容易理解,所以如果你觉得不清楚,不要气馁。你可以继续阅读,然后过一段时间再回到这个例子。

使用 send 允许出现有趣的模式,值得注意的是,send 只能用于恢复执行,而不能用于启动执行。只有 next 才能启动生成器的执行。

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 表达式返回子迭代器产生的每个值。它更短,读起来更好。

生成器表达式

现在我们来谈谈其他一次生成一个值的技术。

语法与列表推导式完全相同,只是,你用圆括号而不是方括号来包装推导式。这被称为 生成器表达式

通常,生成器表达式的行为类似于等效的列表推导式,但有一件非常重要的事情需要记住:生成器只允许进行一次迭代,然后它们就会耗尽。让我们看一个例子:

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 0x7ff26b5db990>
>>> type(cubes_gen)
<class 'generator'>
>>> list(cubes_gen)  # this will exhaust the generator
[0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
>>> list(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), zip(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。它们完全相同,当 n 是 3 或 5 的倍数时,返回 2-元组 (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内部,我们找到一个列表推导式。这意味着为了计算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)

尝试运行前面的示例。如果我运行第一行,这就是我得到的结果:

$ python sum.example.2.py
Killed

另一方面,如果我注释掉第一行,取消注释第二行,这就是结果:

$ python sum.example.2.py
333333328333333350000000

美味的生成器表达式。这两行之间的区别在于,在第一行中,必须先创建一个包含前一百个百万数的平方的列表,然后才能将它们相加。这个列表非常大,我们耗尽了内存(至少,我的机器是这样,如果你的没有,尝试一个更大的数字),因此 Python 为我们杀死了进程。悲伤的脸。

但是当我们移除方括号时,我们不再创建列表。求和函数接收 0、1、4、9 等等,直到最后一个数,并将它们相加。没问题,开心的脸。

一些性能考虑

因此,我们已经看到我们有很多不同的方法可以达到相同的结果。我们可以使用mapzipfilter的任何组合,或者选择使用列表推导式,或者也许选择使用生成器,无论是函数还是表达式。我们甚至可能决定使用for循环:当要应用于每个运行参数的逻辑不简单时,它们可能是最佳选择。

除了可读性考虑之外,让我们谈谈性能。当谈到性能时,通常有两个因素起着主要作用:空间时间

空间意味着数据结构将要占用的内存大小。最好的选择是问问自己你是否真的需要一个列表(或元组),或者一个简单的生成器函数是否也能工作。如果答案是肯定的,就使用生成器,它将节省大量的空间。同样也适用于函数:如果你实际上不需要它们返回列表或元组,你也可以将它们转换为生成器函数。

有时候,你必须使用列表(或元组),例如,有一些算法使用多个指针扫描序列,或者可能多次遍历序列。生成器函数(或表达式)只能迭代一次,然后就会耗尽,所以在这种情况下,它不是最佳选择。

时间比空间更难确定,因为它依赖于更多的变量,因此不可能在所有情况下绝对肯定地说“X 比 Y 快”。然而,根据今天在 Python 上运行的测试,我们可以说map调用可以比等效的for循环快两倍,而列表推导(通常来说)甚至可以比等效的map调用更快。

为了完全理解这些陈述背后的原因,我们需要了解 Python 是如何工作的,但这超出了本书的范围,因为它的技术细节太复杂。我们只需说,在解释器中,map和列表推导以 C 语言的速度运行,而 Python 的for循环在 Python 虚拟机中以 Python 字节码的形式运行,这通常要慢得多。

注意

Python 有几种不同的实现。最初的一个,也是最常见的一个,是用 C 语言编写的。C 语言是今天仍在使用的最强大和最受欢迎的编程语言之一。

我提出的这些观点来自可以在网上找到的书籍和文章,但我们可以做一个小练习,自己尝试找出答案?我将编写一小段代码,收集一组整数对(a, b)divmod(a, b)的结果。我将使用time模块中的time函数来计算我将执行的操作的耗时。让我们开始吧!

performances.py

from time import time
mx = 5500  # this is the max I could reach with my computer...

t = time()  # start time for the for loop
dmloop = []
for a in range(1, mx):
    for b in range(a, mx):
        dmloop.append(divmod(a, b))
print('for loop: {:.4f} s'.format(time() - t))  # elapsed time

t = time()  # start time for the list comprehension
dmlist = [
    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
dmgen = list(
    divmod(a, b) for a in range(1, mx) for b in range(a, mx))
print('generator expression: {:.4f} s'.format(time() - t))

# verify correctness of results and number of items in each list
print(dmloop == dmlist == dmgen, len(dmloop))

如您所见,我们创建了三个列表:dmloopdmlistdmgendivmod-for循环、divmod-list推导、divmod-生成器表达式)。我们从最慢的选项开始,即for循环。然后是列表推导,最后是生成器表达式。让我们看看输出:

$ python performances.py
for loop: 4.3433 s
list comprehension: 2.7238 s
generator expression: 3.1380 s
True 15122250

列表推导运行的时间是for循环的 63%。这很令人印象深刻。生成器表达式的速度也非常接近,达到了 72%。生成器表达式之所以较慢,是因为我们需要将其传递给list()构造函数,这比纯粹的列表推导有更多的开销。

尽管如此,我永远不会在类似的情况下使用生成器表达式,如果我们最终想要一个列表,那就没有意义。我会直接使用列表推导,前一个例子中的结果证明我是对的。另一方面,如果我只是需要做那些divmod计算而不保留结果,那么生成器表达式将是最佳选择,因为在那种情况下,列表推导会不必要地消耗大量的空间。

所以,总结一下:生成器非常快,并且可以节省空间。列表推导通常更快,但不会节省空间。纯 Python 的for循环是最慢的选择。让我们看看一个类似的例子,比较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))

print(absloop == abslist == absmap)

这段代码在概念上与上一个例子非常相似。唯一不同的是,我们使用的是abs函数而不是divmod函数,我们只有一个循环而不是两个嵌套循环。执行结果如下:

$ python performances.map.py
for loop: 3.1283 s
list comprehension: 1.3966 s
map: 1.2319 s
True

map函数赢得了比赛!正如我之前告诉你的,给出一个关于什么比什么快的声明是非常棘手的。在这种情况下,map调用比列表推导式更快。

尽管在逐个案例中存在一些细微的差异,但很明显,for循环选项是最慢的,所以让我们看看我们为什么还想使用它。

不要过度使用推导式和生成器

我们已经看到了列表推导式和生成器表达式有多么强大。它们确实如此,不要误会我,但当我处理它们时,我有一种感觉,它们的复杂性呈指数增长。你在一个单一的推导式或生成器表达式中尝试做的事情越多,阅读、理解和维护或更改它们就越困难。

打开 Python 控制台并输入import this,让我们再次阅读 Python 之禅,特别是有几行我认为非常重要需要记住:

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

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

我在这里的主要关注点右侧添加了注释符号。列表推导式和生成器表达式变得难以阅读,比显式更隐晦,更复杂,并且它们可能难以解释。有时你必须使用从内到外的技术将它们拆分,以理解它们为什么产生这样的结果。

为了给你一个例子,让我们再谈谈毕达哥拉斯三元组。只是为了提醒你,毕达哥拉斯三元组是一组正整数(a, b, c)的元组,满足 不要过度使用推导式和生成器

我们在本章前面已经看到了如何计算它们,但我们以非常低效的方式做了这件事,因为我们扫描了低于某个阈值的所有数字对,计算斜边,并过滤掉那些没有产生三元组的数字对。

获取毕达哥拉斯三元组列表的更好方法是通过直接生成它们。为此有许多不同的公式,我们将使用其中之一:欧几里得公式

这个公式表明,任何满足以下条件的三元组(a, b, c),其中 不要过度使用推导式和生成器b = 2mn不要过度使用推导式和生成器mn是满足m > n的正整数,都是一个毕达哥拉斯三元组。例如,当m = 2 且n = 1 时,我们找到最小的三元组:(3, 4, 5)。

但是有一个问题:考虑三元组 (6, 8, 10),它就像 (3, 4, 5) 乘以 2 得到的那样。这个三元组无疑是毕达哥拉斯三元组,因为 不要过度使用列表推导和生成器,但我们可以通过简单地将其每个元素乘以 2 从 (3, 4, 5) 推导出它。同样适用于 (9, 12, 15),(12, 16, 20),以及一般地,对于所有我们可以写成 (3k, 4k, 5k) 的三元组,其中 k 是大于 1 的正整数。

不能通过将另一个三元组的元素乘以某个因子 k 得到的一个三元组被称为 原始的。另一种表述方式是:如果一个三元组的三个元素是 互质的,那么这个三元组是原始的。两个数互质是指它们在它们的除数中没有共享任何质因数,也就是说,它们的 最大公约数GCD)是 1。例如,3 和 5 是互质的,而 3 和 6 不是,因为它们都能被 3 整除。

因此,欧几里得公式告诉我们,如果 mn 是互质的,并且 m – n 是奇数,那么它们生成的三元组是 原始的。在下面的例子中,我们将编写一个生成器表达式来计算所有斜边 (c) 小于或等于某个整数 N 的原始毕达哥拉斯三元组。这意味着我们想要所有满足 不要过度使用列表推导和生成器 的三元组。当 n1 时,公式看起来像这样:不要过度使用列表推导和生成器,这意味着我们可以用一个上限 不要过度使用列表推导和生成器 来近似计算。

因此,总结一下:m 必须大于 n,它们也必须是互质的,并且它们的差 m - n 必须是奇数。此外,为了避免无用的计算,我们将 m 的上限设置为 floor(sqrt(N)) + 1

对于实数 xfloor 函数给出小于 x 的最大整数 n,例如,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
)

print(triples)

看这里。它不容易阅读,所以让我们逐行分析。在#3处,我们开始一个生成器表达式,用于创建三元组。从#4#5可以看出,我们在[1, M]上对m进行循环,其中MN的整数部分加上 1。另一方面,n1, 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。这是必要的,因为![不要过度使用列表推导和生成器是我们能应用的最低上限,但它不能保证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
print(triples)

我已经感觉好多了。让我们逐行分析这段代码。你会发现它更容易理解。

我们从#1#2开始循环,与上一个例子中的循环方式完全相同。在#3行,我们有原型的三元组过滤条件。在#4行,我们稍微偏离了我们之前所做的方法:我们计算c,在#5行,我们过滤c小于或等于N。只有当c满足这个条件时,我们才计算ab,并产生结果元组。尽可能推迟所有计算总是好的,这样我们就不浪费时间去丢弃那些结果。

在最后一行,在打印结果之前,我们使用与在生成器表达式示例中使用的相同键进行排序。

我希望你会同意,这个例子更容易理解。我保证,如果你有一天必须修改代码,你会发现修改这个例子很容易,而修改其他版本将需要更长的时间(并且更容易出错)。

这两个示例运行时都会打印以下内容:

$ python pythagorean.triple.generation.py
[(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,然后我们练习了四种推导:列表、生成器表达式、字典和集合。它们都没有改变全局名称 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
{'__spec__': None, '__name__': '__main__', 's': 10, 'A': 4, '__doc__': None, '__cached__': None, '__package__': None, '__file__': 'scopes.for.py', '__loader__': <_frozen_importlib.SourceFileLoader object at 0x7f05a5a183c8>, '__builtins__': <module 'builtins' (built-in)>}

与许多其他样板代码一起,我们可以找到 '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)。这个序列非常适合测试关于递归、记忆化技术和其他技术细节的知识,但在这个情况下,这是一个很好的机会来检查候选人是否了解生成器(在我面试他们的时候,太多的所谓 Python 程序员并不知道这一点)。

让我们从函数的一个基本版本开始,然后对其进行改进:

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 语句旁边的注释中看到这些语句的结果。

在这个阶段,我会问候选人以下问题:“如果我只是想遍历这些数字呢?”一个优秀的候选人会像下面这样修改代码(一个更优秀的候选人可能会从一开始就这样做!):

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)如何帮助使代码更短、更易读。这是我喜欢 Python 的许多特性之一。

摘要

在本章中,我们更深入地探讨了迭代和生成的概念。我们相当详细地看到了mapzipfilter函数,以及如何将它们用作常规for循环方法的替代方案。

然后,我们看到了列表、字典和集合的推导式概念。我们看到了它们的语法以及如何将它们用作经典for循环方法的替代方案,以及mapzipfilter函数的使用。

最后,我们讨论了生成概念,有两种形式:生成器函数和表达式。我们学习了如何通过使用生成技术节省时间和空间,并看到了它们如何使基于列表的传统方法不可能实现的事情成为可能。

我们讨论了性能,并看到for循环在速度上排在最后,但它们提供了最佳的可读性和灵活性来改变。另一方面,mapfilter这样的函数可能要快得多,而推导式可能更好。

使用这些技术编写的代码的复杂性呈指数增长,因此,为了优先考虑可读性和易于维护性,我们有时仍然需要使用经典的for循环方法。另一个区别在于名称本地化,其中for循环的行为与其他所有推导式类型不同。

下一章将全部关于对象和类。在结构上与这一章相似,因为我们不会探索很多不同的主题,而是只探索其中的一些,但我们会尝试更深入地研究。

在跳到下一章之前,请确保你很好地理解了本章的概念。我们是一块砖一块砖地建造墙壁,如果基础不牢固,我们就走不远。

第六章. 高级概念 – 面向对象编程、装饰器和迭代器

"La classe non è acqua. (类将超越)"
--意大利谚语

我可能可以写一本关于面向对象编程(以下简称OOP)和类的书。在本章中,我面临着在广度和深度之间找到平衡的艰难挑战。有太多的事情要讲,而且有很多事情如果单独深入描述,将会超过整个章节。因此,我将尝试给你一个我认为是好的全景视图的基础知识,以及一些可能在下一章中派上用场的知识点。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.3003859519958496

t = time()
g()
print('g took:', time() - t)  # g took: 0.5005719661712646

在前面的代码中,我定义了两个函数fg,它们什么也不做,只是睡眠(分别睡眠 0.3 秒和 0.5 秒)。我使用了sleep函数来暂停执行所需的时间。我还突出显示了如何通过将t设置为当前时间,然后在任务完成后减去它来计算经过的时间。你可以看到这个测量非常准确。

现在我们要如何避免重复代码和计算呢?一个首先的潜在方法可能是以下这样:

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.30041074752807617
measure(g)  # g took: 0.5006198883056641

啊,现在好多了。整个计时机制已经被封装成一个函数,这样我们就不会重复代码。我们动态地打印函数名,编码起来也很容易。如果我们需要向测量的函数传递参数怎么办?这段代码会稍微复杂一点,让我们看看一个例子。

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.3004162311553955
measure(f, 0.2)  # f took: 0.20028162002563477

现在,f函数期望接收sleep_time参数(默认值为0.1)。我还不得不修改measure函数,使其现在可以接受一个函数、任意可变位置参数和任意可变关键字参数。这样,无论我们用什么调用measure,我们都会将这些参数重定向到我们在内部对f的调用。

这很好,但我们还可以更进一步。假设我们想在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.2002875804901123
f(sleep_time=0.3)  # f took: 0.3003721237182617
print(f.__name__)  # wrapper  <- ouch!

前面的代码可能不是那么直观。我承认,即使今天,有时我还需要集中精力才能理解一些装饰器,它们可能相当复杂。让我们看看这里发生了什么。魔法在于 装饰点。我们基本上在调用它时,将 f 重新分配为 measure 返回的任何内容。在 measure 中,我们定义另一个函数 wrapper,然后返回它。所以,最终效果是在装饰点之后,当我们调用 f 时,我们实际上是在调用 wrapper。由于包装器内部调用的是 func,也就是 f,所以我们实际上是这样闭合循环的。如果你不相信我,看看最后一行。

wrapper 实际上...就是一个包装器。它接受变量和位置参数,并用它们调用 f。它还在调用周围执行时间测量技巧。

这种技术被称为 装饰器,而 measure 在所有效果上,就是一个 装饰器。这种范式变得如此流行和广泛使用,以至于在某个时刻,Python 为它添加了特殊的语法(检查 PEP 318)。让我们探索三个案例:一个装饰器,两个装饰器,以及一个接受参数的装饰器。

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

在应用多个装饰器时,请注意顺序,这很重要。在前面的例子中,func 首先被 deco2 装饰,然后结果被 deco1 装饰。一个很好的经验法则是:装饰器离函数越近,应用得越早

一些装饰器可以接受参数。这种技术通常用于生成其他装饰器。让我们看看语法,然后我们会看到一个例子。

decorators/syntax.py

def func(arg1, arg2, ...):
    pass
func = decoarg(argA, argB)(func)

# is equivalent to the following:

@decoarg(argA, argB)
def func(arg1, arg2, ...):
    pass

如你所见,这个案例有点不同。首先 decoarg 使用给定的参数被调用,然后它的返回值(实际的装饰器)使用 func 被调用。在我给你另一个例子之前,让我们解决一个让我烦恼的问题。我不想在装饰它时丢失原始函数名称和文档字符串(以及其他属性,请参阅文档以获取详细信息)。但是,因为在我们装饰器内部我们返回 wrapper,所以 func 最终会丢失原始属性并分配 wrapper 的属性。从 functools 中有一个简单的解决方案,这是一个来自 Python 标准库的奇妙模块。我将修复最后一个例子,并将它的语法重写为使用 @ 操作符。

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.30039525032043457
print(f.__name__, ':', f.__doc__)
# f : I'm a cat. I love to sleep!

现在我们来谈谈重点!正如你所见,我们只需要告诉 Python,wrapper 实际上包装了 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: 7.62939453125e-06  #
8  #
Result is too big (125). Max allowed is 100.
cube took: 1.1205673217773438e-05
125

为了方便起见,我在第一次调用的结果右侧加了一个#print(cube(2))。结果是 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)时,我们进入其主体。在其中定义了一个装饰器函数,它只接受一个函数作为其唯一参数。在这个函数内部,执行了通常的装饰器技巧。我们定义了一个包装器,在其中我们检查原始函数调用的结果。这种方法的优点是从最内层开始,我们仍然可以引用functhreshold,这允许我们动态地设置阈值。

wrapper 返回 resultdecorator 返回 wrapper,而 max_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 中非常流行。它们被频繁使用,并且极大地简化(甚至美化,我敢这么说)了代码。

面向对象编程

这已经是一段相当漫长且希望愉快的旅程了,到现在,我们应该准备好探索面向对象编程了。我将使用来自 Kindler, E.; Krivy, I. (2011). Object-Oriented Simulation of systems with sophisticated control. International Journal of General Systems 的定义,并将其应用于 Python:

面向对象编程(OOP)是一种基于“对象”概念的编程范式,对象是包含数据的数据结构,以属性的形式存在,以及以方法的形式存在的代码。对象的一个显著特征是,对象的方法可以访问并经常修改与之关联的对象的数据属性(对象有一个“自我”的概念)。在面向对象编程中,计算机程序是通过构建相互交互的对象来设计的。

Python 完全支持这种范式。实际上,正如我们之前所说的,Python 中的所有东西都是一个对象,所以这表明面向对象编程不仅仅是 Python 支持的,而且是其核心的一部分。

面向对象编程(OOP)中的两个主要角色是对象。类用于创建对象(对象是它们被创建的类的实例),因此我们可以将它们视为实例工厂。当对象由一个类创建时,它们继承类的属性和方法。它们代表程序域中的具体项。

最简单的 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 oop/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,这也是另一个类属性。你可以看到,从类中访问该属性没有限制。你可以看到man,它是Person的一个实例,继承了这两个属性,并且当它们改变时立即反映出来。

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的命名空间中打印出xyp.xp.y)。当我们这样做时,Python 没有在实例上找到任何xy属性,因此它搜索类,并在那里找到了它们。

然后,我们通过分配p.x = 12p自己的x属性。这种行为一开始可能看起来有点奇怪,但如果你仔细想想,它与在全局x = 10外部声明x = 12的函数中发生的情况完全相同。我们知道x = 12不会影响全局的x,对于类和实例来说,情况也是一样的。

在分配p.x = 12之后,当我们打印它时,搜索不需要读取类属性,因为x已经在实例上找到了,因此我们打印出12

我们还打印了Point.x,它指的是类命名空间中的x

然后,我们从p的命名空间中删除x,这意味着在下一行再次打印时,Python 将再次在类中搜索它,因为实例中已经找不到它了。

最后三行显示,将属性分配给实例并不意味着它们会在类中找到。实例会得到类中的任何东西,但反之则不然。

你认为将xy坐标作为类属性怎么样?你认为这是一个好主意吗?

我,我自己,以及自我——使用 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

上述代码显示,我们可以在声明方法时使用参数。我们可以使用与函数相同的语法,但我们需要记住,第一个参数始终是实例。

初始化实例

你有没有注意到,在调用p1.final_price(...)之前,我们必须将net_price分配给p1?有更好的方法来做这件事。在其他语言中,这会被称为构造函数,但在 Python 中不是这样。它实际上是一个初始化器,因为它作用于已经创建的实例,因此被称为__init__。它是一个魔法方法,在对象创建后立即运行。Python 对象还有一个__new__方法,它是实际的构造函数。在实践中,通常不需要重写它,这是一个主要用于编写元类的实践,这是一个相当高级的话题,我们不会在本书中探讨。

oop/class.init.py

class Rectangle():
    def __init__(self, sideA, sideB):
 self.sideA = sideA
 self.sideB = sideB

    def area(self):
        return self.sideA * self.sideB

r1 = Rectangle(10, 4)
print(r1.sideA, r1.sideB)  # 10 4
print(r1.area())  # 40

r2 = Rectangle(7, 3)
print(r2.area())  # 21

事情终于开始成形。当一个对象被创建时,__init__方法会自动为我们运行。在这种情况下,我编写了代码,当我们创建一个对象(通过像函数一样调用类名)时,我们向创建调用传递参数,就像在常规函数调用中做的那样。我们传递参数的方式遵循__init__方法的签名,因此,在两个创建语句中,107将分别是r1r2sideA,而43将是sideB。你可以看到,r1r2area()调用的调用反映了它们有不同的实例参数。

以这种方式设置对象要更优雅、更方便。

面向对象编程(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
    engine_cls = V8Engine

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

这意味着ElectricEngineV8Engine都从Engine类继承属性和方法,这个类被称为它们的基类

对于汽车来说,情况也是如此。CarRaceCarCityCar的基类。RaceCar也是F1Car的基类。另一种说法是,F1CarRaceCar继承,而RaceCarCar继承。因此,F1CarRaceCarRaceCarCar。由于传递性质,我们可以说F1CarCarCityCar也是Car

当我们定义class A(B): pass时,我们说AB子类,而BA父类父类基类是同义词,同样子类派生类也是。我们还说一个类继承自另一个类,或者扩展了它。

这就是继承机制。

另一方面,让我们回到代码。每个类都有一个类属性,engine_cls,它是对我们想要分配给每种类型汽车的引擎类的引用。Car有一个通用的Engine,而两辆赛车都有强大的 V8 引擎,而城市车有一个电动引擎。

当在初始化方法__init__中创建汽车时,我们创建一个引擎类的实例,并将其设置为engine实例属性。

在所有类实例之间共享engine_cls是有意义的,因为同一辆车的实例很可能具有相同类型的引擎。另一方面,将单个引擎(任何Engine类的实例)作为类属性是不好的,因为这会导致我们在所有实例之间共享一个引擎,这是不正确的。

汽车与其引擎之间的关系类型是具有类型。汽车具有引擎。这被称为组合,反映了对象可以由许多其他对象组成的事实。汽车具有引擎、变速箱、车轮、底盘、车门、座椅等等。

在设计面向对象(OOP)代码时,以这种方式描述对象至关重要,这样我们才能正确地使用继承和组合来以最佳方式构建我们的代码。

在我们离开这个段落之前,让我们用另一个例子来验证我是否告诉了您真相:

oop/class.issubclass.isinstance.py

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)的一个实例。一个香蕉香蕉的一个实例。但,它也是一个水果。也是食物,对吧?这是同一个概念。

要检查一个对象是否是某个类的实例,请使用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类作为我们定义的类的基类。最终,所有类都从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_

看看前面的代码。我突出显示了从其基类Book中复制的Ebook初始化部分。这相当糟糕的做法,因为我们现在有两套做同样事情的指令。此外,任何对Book.__init__签名的变化都不会反映在Ebook中。我们知道EbookBook的子类,因此我们可能希望这些变化在子类中得到反映。

让我们看看一种修复这个问题的方法:

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('Learning Python', 'Packt Publishing', 360, 'PDF')
print(ebook.title)  # Learning Python
print(ebook.publisher)  # Packt Publishing
print(ebook.pages)  # 360
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('Learning Python', 'Packt Publishing', 360, 'PDF')
print(ebook.title)  # Learning Python
print(ebook.publisher)  # Packt Publishing
print(ebook.pages)  # 360
print(ebook.format_)  # PDF

super是一个返回代理对象的函数,该对象将方法调用委托给父类或兄弟类。在这种情况下,它将调用委托给Book类的__init__,这个方法的优点是现在我们可以自由地将Book改为Liber,而无需触及Ebook__init__方法中的逻辑。

现在我们知道了如何从子类访问基类,让我们来探索 Python 的多重继承。

多重继承

除了使用多个基类来组合一个类之外,这里有趣的是属性搜索是如何进行的。看看下面的图示:

多重继承

正如你所见,ShapePlotter作为所有其他类的基类。Polygon直接从它们继承,RegularPolygonPolygon继承,而RegularHexagonSquare都从RegularPolygon继承。注意,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继承。

几何多边形有很多种类型,其中一种是正多边形,它既等角(所有角度相等)又等边(所有边相等),因此我们创建了继承自PolygonRegularPolygon类。因为对于正多边形,所有边都相等,我们可以在RegularPolygon上实现一个简单的__init__方法,它接受边的长度。最后,我们创建了RegularHexagonSquare类,它们都继承自RegularPolygon

这个结构相当长,但希望这能给你一个在设计代码时如何专门化对象分类的思路。

现在,请看看最后八行。注意,当我调用hexagonsquare上的area方法时,我得到了两者的正确面积。这是因为它们都提供了正确的实现逻辑。此外,我可以在它们两个上调用get_geometric_type,即使这个方法没有定义在它们的类中,Python 必须一直追溯到Shape来找到它的实现。注意,尽管实现是在Shape类中提供的,但用于返回值的self.geometric_type是正确地从调用实例中获取的。

plot方法的调用也很有趣,展示了你可以如何通过这种技术丰富你的对象,使其拥有它们原本不具备的能力。这种技术在像 Django 这样的 Web 框架中非常流行(我们将在后面的两章中探讨),它提供了特殊的类,称为混入(mixins),你可以直接使用这些类的功能。你所要做的就是将所需的混入定义为你自己的基类之一,然后就可以了。

多重继承功能强大,但也可能变得非常混乱,因此我们需要确保我们理解使用它时会发生什么。

方法解析顺序

到目前为止,我们知道当你请求someobject.attribute,并且attribute没有在对象上找到时,Python 会从someobject创建的类someobject开始搜索。如果那里也没有,Python 会沿着继承链向上搜索,直到找到attribute或者到达object类。如果继承链只包含单继承步骤,这意味着类只有一个父类,那么这很容易理解。然而,当涉及多重继承时,有些情况下如果找不到属性,预测下一个将被搜索的类可能并不直接。

Python 提供了一种方法,可以始终知道在属性查找时类被搜索的顺序:方法解析顺序。

注意

方法解析顺序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 都继承自 A,而 D 则继承自 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 并不常见,但当你第一次与框架中的某个 mixin 作斗争时,我保证你会很高兴我花了一段文字来解释它。

静态和类方法

到目前为止,我们编写的类已经包含了以数据和实例方法形式存在的属性,但我们可以将两种其他类型的方法放入类中:静态方法类方法

静态方法

如你所回忆的那样,当你创建一个类对象时,Python 会给它分配一个名称。这个名称充当一个命名空间,有时将功能分组在下面是有意义的。静态方法非常适合这种用例,因为与实例方法不同,它们没有传递任何特殊参数。让我们看看一个假设的 String 类的例子。

oop/static.methods.py

class String:

    @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(String.is_palindrome(
    'Radar', case_insensitive=False))  # False: Case Sensitive
print(String.is_palindrome('A nut for a jar of tuna'))  # True
print(String.is_palindrome('Never Odd, Or Even!'))  # True
print(String.is_palindrome(
    'In Girum Imus Nocte Et Consumimur Igni')  # Latin! Show-off!
)  # True

print(String.get_unique_words(
    'I love palindromes. I really really love them!'))
# {'them!', 'really', 'palindromes.', 'I', 'love'}

上述代码非常有趣。首先,我们了解到静态方法是通过简单地应用 staticmethod 装饰器来创建的。你可以看到它们没有传递任何特殊参数,所以除了装饰之外,它们实际上看起来就像函数一样。

我们有一个名为 String 的类,它充当函数的容器。另一种方法是将函数放在一个单独的模块中。大多数情况下,这完全取决于个人喜好。

is_palindrome 函数内部的逻辑你现在应该已经很容易理解了,但以防万一,我们还是来过一遍。首先,我们从 s 中移除所有既不是字母也不是数字的字符。为了做到这一点,我们使用字符串对象的 join 方法(在这个例子中是一个空字符串对象)。通过在空字符串上调用 join,结果是所有传递给 join 的可迭代对象中的元素将被连接在一起。我们给 join 传递一个生成器表达式,它表示“从 s 中取出任何既是字母数字又是数字的字符”。我希望你已经自己找到了这个,也许是用我在前几章中展示的内外部技术。

如果case_insensitiveTrue,我们就会将s转换为小写,然后继续检查它是否是一个回文。为了做到这一点,我们比较第一个和最后一个字符,然后是第二个和倒数第二个,以此类推。如果在任何一点我们发现差异,这意味着字符串不是回文,因此我们可以返回False。另一方面,如果我们正常退出for循环,这意味着没有发现差异,因此我们可以断定字符串是回文。

注意,这段代码无论字符串的长度如何都能正确工作,也就是说,无论长度是奇数还是偶数。len(s) // 2达到s的一半,如果s的长度是奇数个字符,中间的那个字符不会被检查(比如在RaDaR中,D没有被检查),但我们并不关心;它会与自己比较,所以总是通过那个检查。

get_unique_words要简单得多,它只是返回一个集合,我们将一个包含句子中单词的列表喂给这个集合。set类会为我们移除任何重复项,因此我们不需要做任何事情。

String类为我们提供了一个很好的容器命名空间,用于那些旨在处理字符串的方法。我可以用一个Math类编写一个类似的例子,并添加一些用于处理数字的静态方法,但我想要展示一些不同的东西。

类方法

类方法与实例方法略有不同,因为它们也接受一个特殊的第一个参数,但在这个情况下,它是类对象本身。编写类方法的两个非常常见的用例是为类提供工厂功能,以及允许拆分静态方法(你必须使用类名来调用它们),而无需在逻辑中硬编码类名。让我们看看这两个用例的例子。

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 程序员都不会改变的事情,因为这是一个非常强的约定,解析器、linters 以及任何自动处理你代码的工具都会期望,所以坚持这个约定会更好。

让我们来看一个其他用例的例子:拆分一个静态方法。

oop/class.methods.split.py

class String:

    @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(String.is_palindrome('A nut for a jar of tuna'))  # True
print(String.is_palindrome('A nut for a jar of beans'))  # False

将此代码与上一个版本进行比较。首先请注意,尽管is_palindrome现在是一个类方法,但我们调用它的方式与它作为静态方法时相同。我们将其更改为类方法的原因是,在提取出几块逻辑(_strip_string_is_palindrome)之后,我们需要获取它们的引用,如果没有cls在我们的方法中,唯一的选择就是像这样调用它们:String._strip_string(...)String._is_palindrome(...),这不是一个好的做法,因为我们会在is_palindrome方法中硬编码类名,从而使得每次我们更改类名时都必须修改它。使用cls将充当类名,这意味着我们的代码不需要任何修改。

还要注意,通过将提取出的方法命名为前导下划线,我在暗示这些方法不应该从类外部调用,但这将是下一段的主题。

私有方法和名称改写

如果你熟悉像 Java、C#、C++或类似的语言,那么你知道它们允许程序员为属性(数据和方法)分配隐私状态。每种语言都有自己略微不同的风味,但大意是公共属性可以从代码的任何位置访问,而私有属性只能在定义它们的范围内访问。

在 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()(也称为 getterssetters),并将逻辑放在那里。get_age() 很可能只是返回 age,而 set_age() 将也会进行范围检查。问题是,你可能已经有很多代码直接访问 age 属性,这意味着你现在需要进行一些很好的(并且可能危险且繁琐的)重构。像 Java 这样的语言通过默认使用访问器模式来克服这个问题。许多 Java 集成开发环境IDEs)会自动完成属性声明,并为你即时编写 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):
        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 变量中,我们使用该装饰器定义属性获取器和设置器,这样我们就可以像以前一样继续使用 person 实例。获取器是在我们访问属性进行读取时被调用的方法。另一方面,设置器是在我们访问属性以写入它时被调用的方法。在其他语言中,例如 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(),无论它是哪种类型的引擎。只要它公开了启动方法,我们就可以调用它。这就是多态的作用。

在其他语言中,如 Java,为了使一个函数能够接受不同类型并在它们上调用方法,这些类型需要以这种方式编码,即它们共享一个接口。这样,编译器就知道,无论函数接收的对象类型如何(只要它扩展了适当的接口,当然),该方法都将可用。

在 Python 中,情况不同。多态是隐式的,没有阻止你在对象上调用方法,因此,技术上,不需要实现接口或其他模式。

有一种特殊的多态称为临时多态,这是我们上一段中看到的:运算符重载。运算符根据其接收的数据类型改变形状的能力。

我不能在多态上花费太多时间,但我鼓励你自己去探索它,这将扩展你对面向对象编程的理解。祝你好运!

编写自定义迭代器

现在我们有了所有工具来欣赏我们如何编写自己的自定义迭代器。让我们首先定义什么是可迭代对象和迭代器:

  • 可迭代对象:如果一个对象能够一次返回其成员之一,那么它就被称作可迭代对象。列表、元组、字符串、字典等都是可迭代对象。定义了__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]、...,直到我们返回数据中的每个项目。为了做到这一点,我们准备了一个列表,索引,如[0, 2, 4, 6, ..., 1, 3, 5, ...],并且当列表中至少有一个元素时,我们弹出第一个元素,并从数据中返回该位置的元素,从而实现我们的目标。当indexes为空时,我们根据迭代器协议的要求抛出StopIteration异常。

有其他方法可以达到相同的结果,所以请尝试自己编写不同的代码。确保最终结果适用于所有边缘情况,空序列,长度为 1、2 等的序列。

摘要

在本章中,我们看到了装饰器,发现了它们存在的原因,以及同时使用一个或多个装饰器的几个示例。我们还看到了接受参数的装饰器,这些参数通常用作装饰器工厂。

我们在 Python 中简要介绍了面向对象编程。我们以你应该现在能够轻松理解未来章节中的代码的方式涵盖了所有基础知识。我们讨论了一个人可以在类中编写的所有各种方法和属性,我们探讨了继承与组合、方法重写、属性、运算符重载和多态。

最后,我们非常简短地提到了迭代器,所以现在你拥有了深入了解生成器的所有知识。

在下一章中,我们将迎来一个陡峭的转折。这将开始本书的第二部分,这部分更加以项目为导向,因此从现在开始,理论将减少,代码将增多,我希望你会喜欢跟随示例,并且能够亲自动手,深入实践。

他们说,平静的海面永远不会造就一个熟练的水手,所以继续探索,打破常规,阅读错误消息以及文档,看看我们能否看到那只白兔。

第七章. 测试、性能分析和处理异常

*"没有测试的代码是有意设计成有缺陷的。"
--雅各布·卡普兰-莫斯

雅各布·卡普兰-莫斯是 Django Web 框架的核心开发者之一。我们将在下一章中探讨它。我非常同意他的这句话。我相信没有测试的代码不应该部署到生产环境中。

为什么测试如此重要?好吧,首先,它们为你提供了可预测性。或者至少,它们帮助你实现高可预测性。不幸的是,总有一些漏洞会悄悄地进入我们的代码。但我们肯定希望我们的代码尽可能可预测。我们不希望的是惊喜,我们的代码以一种不可预测的方式运行。你会高兴地知道,检查你度假时乘坐的飞机传感器的软件有时会发疯吗?不,可能不会。

因此我们需要测试我们的代码,我们需要检查其行为是否正确,当它处理边缘情况时是否按预期工作,当与之通信的组件出现问题时它不会挂起,性能是否在可接受的范围内,等等。

本章全部关于这个主题,确保你的代码准备好面对可怕的外部世界,即足够快,并且能够处理意外或异常情况。

我们将探讨测试,包括对测试驱动开发TDD)的简要介绍,这是我最喜欢的作业方法之一。然后,我们将探索异常的世界,最后我们将简要谈谈性能和性能分析。深呼吸,我们开始吧...

测试你的应用程序

有许多不同种类的测试,实际上如此之多,以至于公司通常有一个专门的部门,称为质量保证QA),由那些整天测试公司开发人员生产的软件的个人组成。

为了开始进行初步分类,我们可以将测试分为两大类:白盒测试和黑盒测试。

白盒测试是那些锻炼代码内部结构的测试,它们检查到非常细粒度的程度。另一方面,黑盒测试是那些将正在测试的软件视为一个盒子内的,忽略其内部结构的测试。甚至盒子内使用的科技或语言对黑盒测试来说也不重要。它们所做的是将输入连接到盒子的一端,并验证另一端的输出,这就是全部。

注意

还有一个介于两者之间的类别,称为灰盒测试,它以与黑盒方法相同的方式测试系统,但对我们使用的算法和数据结构有一些了解,并且只有部分访问源代码的权限。

在这些类别中有很多不同种类的测试,每种测试都服务于不同的目的。仅为了给你一个概念,这里有一些例子:

  • 前端测试确保应用程序的客户端暴露了应该暴露的信息,所有链接、按钮、广告等需要展示给客户的内容。它还可能验证是否可以通过用户界面走一条特定的路径。

  • 场景测试利用故事(或场景)帮助测试人员解决复杂问题或测试系统的一部分。

  • 集成测试验证应用程序的各个组件在通过接口发送消息时一起工作的行为。

  • 冒烟测试在您部署应用程序的新更新时特别有用。它们检查应用程序最基本、最重要的部分是否仍然按预期工作,并且它们没有着火。这个术语来自工程师通过确保没有冒烟来测试电路的时候。

  • 验收测试,或用户验收测试UAT),是开发者与产品负责人(例如,在敏捷开发环境中)一起进行的工作,以确定委托的工作是否正确完成。

  • 功能测试验证软件的功能或功能。

  • 破坏性测试通过模拟系统故障来摧毁系统的一部分,以确定系统剩余部分的表现如何。这类测试通常由需要提供极其可靠服务的公司广泛进行,例如亚马逊等。

  • 性能测试旨在验证系统在特定数据负载或流量下的表现如何,以便工程师可以更好地了解系统中的瓶颈,这些瓶颈可能在重负载情况下使系统崩溃,或者那些阻止可扩展性的瓶颈。

  • 可用性测试和与之密切相关的用户体验UX)测试旨在检查用户界面是否简单、易于理解和使用。它们旨在为设计师提供反馈,以改善用户体验。

  • 安全性和渗透测试旨在验证系统抵御攻击和入侵的能力。

  • 单元测试帮助开发者以稳健和一致的方式编写代码,提供对编码错误、重构错误等的初步反馈和防御。

  • 回归测试在更新后向开发者提供有关系统中被损害的功能的有用信息。系统被认为有回归的原因包括旧错误重新出现、现有功能受损或引入了新问题。

关于测试已经写了许多书籍和文章,如果你对了解所有不同类型的测试感兴趣,我必须向你指出这些资源。在本章中,我们将专注于单元测试,因为它们是软件构建的基石,构成了开发者编写的绝大多数测试。

测试是一种艺术,恐怕你无法从书中学习到这种艺术。你可以学习所有的定义(你应该这样做),并尽可能多地收集有关测试的知识,但我向你保证,只有在你在这个领域工作足够长的时间后,你才能正确地测试你的软件。

当你在重构一小段代码时遇到困难,因为每次你触摸到的东西都会使测试失败,你会学会编写不那么僵化和限制性的测试,这些测试仍然可以验证你的代码的正确性,同时,也让你有自由和乐趣去玩弄它,按照你的意愿塑造它。

当你频繁被要求修复代码中的意外错误时,你会学会如何更彻底地编写测试,如何提出一个更全面的边缘情况列表,以及应对这些情况的策略,以防它们变成错误。

当你花费太多时间阅读测试并尝试重构它们以更改代码中的一个小功能时,你会学会编写更简单、更短、更专注的测试。

我可以继续说“当你……你学习……”,但我想你明白我的意思。你需要亲自动手,积累经验。我的建议?尽可能多地学习理论,然后尝试使用不同的方法进行实验。此外,尝试向经验丰富的程序员学习;这非常有效。

测试的结构

在我们专注于单元测试之前,让我们看看什么是测试,以及它的目的是什么。

测试是一段代码,其目的是验证我们系统中的某个东西。这可能意味着我们正在调用一个函数,传递两个整数,或者一个对象有一个名为donald_duck的属性,或者当你在一个 API 上下单后,一分钟内你可以在数据库中看到它被分解成基本元素。

一个测试通常由三个部分组成:

  • 准备:这是你设置场景的地方。你准备所有需要的数据、对象、服务,确保它们在需要的地方准备好使用。

  • 执行:这是你执行检查逻辑的地方。你使用在准备阶段设置的数据和接口执行操作。

  • 验证:这是你验证结果并确保它们符合你的预期的地方。你检查函数的返回值,或者某些数据是否在数据库中,某些数据不在,某些数据已更改,已发起请求,发生了某些事情,调用了某个方法,等等。

测试指南

就像软件一样,测试可以是好的或坏的,中间有各种不同的程度。为了编写好的测试,以下是一些指导原则:

  • 尽可能保持简单:违反一些良好的编码规则,如硬编码值或重复代码,是可以接受的。测试首先需要尽可能的可读和易于理解。当测试难以阅读或理解时,你永远无法确定它们是否确实确保了你的代码正在正确执行。

  • 测试应验证一件事,且仅一件事:保持测试简短并集中非常重要。编写多个测试来测试单个对象或函数是完全可行的。只需确保每个测试只有一个且仅有一个目的。

  • 测试在验证数据时不应做任何不必要的假设:一开始这可能难以理解,但假设你在测试一个函数的返回值,它是一个无序的数字列表(如 [2, 3, 1])。如果列表中的顺序是随机的,在测试中你可能想对其进行排序并与 [1, 2, 3] 进行比较。如果你这样做,你将在函数调用结果的顺序上引入额外的假设,这是不好的做法。你应该总是找到一种方法来验证事物,而不会引入任何假设或任何不属于你用测试描述的使用案例中的特性。

  • 测试应测试“是什么”,而不是“如何”:测试应专注于检查函数应该做什么,而不是它是如何做的。例如,关注它正在计算一个数字的平方根(“是什么”),而不是关注它是如何调用 math.sqrt 来实现的(“如何”)。除非你正在编写性能测试或你有特定的需求来验证某些动作是如何执行的,否则尽量避免这种类型的测试,专注于“是什么”。测试“如何”会导致测试过于严格,并使得重构变得困难。此外,当你专注于“如何”时,你必须编写的测试类型更有可能在频繁修改软件时降低你的测试代码库的质量(关于这一点稍后还会讨论)。

  • 测试在准备阶段应假设尽可能少的内容:假设你有 10 个测试,它们正在检查一个函数如何操作数据结构。假设这个数据结构是一个包含五个键/值对的字典。如果你在每个测试中都放入完整的字典,那么当你需要更改该字典中的任何内容时,你也必须修改所有十个测试。另一方面,如果你尽可能减少测试数据,你会发现,大多数情况下,大多数测试只检查数据的部分版本,而只有少数测试使用完整的版本。这意味着当你需要更改数据时,你只需要修改那些实际在测试它的测试。

  • 测试应该尽可能快地运行:一个良好的测试代码库可能会比被测试的代码本身更长。这取决于具体情况和开发者,但无论长度如何,你最终会有数百甚至数千个测试要运行,这意味着它们运行得越快,你就能越快回到编写代码。例如,在使用 TDD 时,你会非常频繁地运行测试,所以速度是至关重要的。

  • 测试应该使用尽可能少的资源:这样做的原因是,任何检查你代码的开发者都应该能够运行你的测试,无论他们的机器有多强大。这可能是一个瘦虚拟机,或者是一个被忽视的 Jenkins 服务器,但你的测试应该在不消耗太多资源的情况下运行。

    注意

    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 工具,它可以作为一个函数或类装饰器,甚至作为一个上下文管理器(更多内容请参阅第八章,边缘 – GUI 和脚本),你可以用它来模拟事物。一旦你用合适的模拟替换了你不需要运行的所有东西,你就可以将它们传递到测试的第二阶段,并运行你正在执行的代码。执行后,你将能够检查这些模拟以验证你的代码是否正确工作。

断言

验证阶段是通过使用断言来完成的。断言是一个函数(或方法),你可以用它来验证对象之间的相等性,以及其他条件。当条件不满足时,断言将引发一个异常,这将使你的测试失败。你可以在 unittest 模块文档中找到一个断言列表,以及它们在 nose 第三方库中的对应 Python 版本,该库提供了一些相对于纯 unittest 模块的优势,从改进的测试发现策略开始(这是测试运行器检测和发现你应用程序中的测试的方式)。

一个经典的单元测试示例

模拟、修补和断言是我们编写测试时将使用的基本工具。所以,最后,让我们看看一个例子。我将编写一个函数,该函数接受一个整数列表,并过滤掉所有非正数。

filter_funcs.py

def filter_ints(v):
    return [num for num in v if is_positive(num)]

def is_positive(n):
    return n > 0

在前面的例子中,我们定义了 filter_ints 函数,它基本上使用列表推导来保留 v 中所有正数,丢弃零和负数。我希望,到现在为止,任何进一步的代码解释都是侮辱性的。

然而,有趣的是开始思考我们如何测试它。好吧,我们为什么不调用 filter_ints 并传递一个数字列表,并确保对每个数字调用 is_positive 呢?当然,我们还需要测试 is_positive,但我稍后会向你展示如何做。现在,让我们为 filter_ints 编写一个简单的测试。

注意

为了确保我们处于同一页面上,我将本章的代码放入一个名为 ch7 的文件夹中,该文件夹位于我们项目的根目录下。在 ch7 的同一级别,我创建了一个名为 tests 的文件夹,其中我放置了一个名为 test_ch7 的文件夹。在这个文件夹中,我有一个测试文件,名为 test_filter_func.py

基本上,在 tests 文件夹中,我将重新创建我正在测试的代码的树结构,将所有内容前缀为 test_。这样,查找测试就非常容易,同时也能保持它们的整洁。

tests/test_ch7/test_filter_funcs.py

from unittest import TestCase  # 1
from unittest.mock import patch, call  # 2
from nose.tools import assert_equal  # 3
from ch7.filter_funcs import filter_ints  # 4

class FilterIntsTestCase(TestCase):  # 5

    @patch('ch7.filter_funcs.is_positive')  # 6
    def test_filter_ints(self, is_positive_mock):  # 7
        # preparation
        v = [3, -4, 0, 5, 8]

        # execution
        filter_ints(v)  # 8

        # verification
        assert_equal(
            [call(3), call(-4), call(0), call(5), call(8)],
            is_positive_mock.call_args_list
        )  # 9

哇,这么少的代码,却说了这么多。首先:#1TestCase类是我们用来运行测试的封装实体的基类。它不仅仅是一个裸容器;它为您提供了编写测试的更简单的方法。

#2中,我们从unittest.mock模块中导入patchcallpatch负责用Mock实例替换对象,从而在执行阶段完成后,让我们能够检查它。call为我们提供了一个很好的方式来表达一个(例如,函数)调用。

#3中,您可以看到我更倾向于使用nose提供的断言,而不是unittest模块自带的断言。举个例子,如果我没有使用noseassert_equal(...)将变为self.assertEqual(...)。我不喜欢为任何断言输入self.,如果有可能避免,我也不特别喜欢驼峰命名法,因此我总是更喜欢使用nose来进行断言。

assert_equal是一个接受两个参数(以及一个可选的第三个参数,用作消息)并验证它们是否相同的函数。如果它们相等,则不会发生任何事情,但如果它们不同,则会引发AssertionError异常,告诉我们有问题。当我编写测试时,我总是将预期值作为第一个参数,将实际值作为第二个参数。这个约定在阅读测试时节省了我很多时间。

#4中,我们导入我们想要测试的函数,然后(#5)我们继续创建一个类,我们的测试将在这个类中运行。这个类的每个以test_开头的方法都将被解释为一个测试。如您所见,我们需要用patch装饰test_filter_ints#6)。理解这部分内容至关重要,我们需要在对象实际使用的地方进行补丁。在这种情况下,路径非常简单:ch7.filter_func.is_positive

小贴士

补丁操作可能非常复杂,所以我强烈建议您阅读模拟文档中的“在哪里打补丁”部分:docs.python.org/3/library/unittest.mock.html#where-to-patch

当我们使用patch装饰一个函数时,就像在我们的例子中那样,测试签名中会多出一个参数(#7),我喜欢将其称为补丁函数名称,加上一个_mock后缀,以便清楚地表明该对象已被补丁(或模拟)。)

最后,我们到达测试的主体,我们有一个非常简单的准备阶段,在这个阶段中,我们设置一个列表,其中至少包含所有整数类别(负数、零和正数)的代表。

然后,在#8中,我们执行执行阶段,运行filter_ints函数,但不收集其结果。如果一切如预期进行,伪造的is_positive函数必须已经对v中的每个整数进行了调用。

我们可以通过比较调用对象列表与模拟的call_args_list属性(#9)来验证这一点。这个属性是自对象创建以来在对象上执行的所有调用的列表。

让我们运行这个测试。首先,确保你已经安装了nose$ pip freeze会告诉你你是否已经安装了它):

$ pip install nose

然后,切换到项目的根目录(我的项目名为learning.python),并按如下方式运行测试:

$ nosetests tests/test_ch7/
.
------------------------------------------------------------
Ran 1 test in 0.006s
OK

输出显示了单个点(每个点代表一个测试),一条分隔线,以及运行整个测试套件所需的时间。最后还显示OK,这意味着我们的测试都是成功的。

使测试失败

好的,为了好玩,让我们让其中一个失败。在测试文件中,将最后的调用从call(8)改为call(9),然后再次运行测试:

$ nosetests tests/test_ch7/
F
============================================================
FAIL: test_filter_ints (test_filter_funcs.FilterIntsTestCase)
------------------------------------------------------------
Traceback (most recent call last):
 File "/usr/lib/python3.4/unittest/mock.py", line 1125, in patched
 return func(*args, **keywargs)
 File "/home/fab/srv/learning.python/tests/test_ch7/test_filter_funcs.py", line 21, in test_filter_ints
 is_positive_mock.call_args_list
AssertionError: [call(3), call(-4), call(0), call(5), call(9)] != [call(3), call(-4), call(0), call(5), call(8)]
------------------------------------------------------------
Ran 1 test in 0.008s
FAILED (failures=1)

哇,我们激怒了这个怪物!尽管如此,信息量仍然很大。这告诉你,测试test_filter_ints(包括它的路径)已经运行并且失败了(顶部的大F,之前是点所在的位置)。它提供了一个Traceback,告诉你,在test_filter_funcs.py模块的第 21 行,当我们断言is_positive_mock.call_args_list时,存在差异。测试期望调用列表以call(9)实例结束,但实际列表以call(8)结束。这简直太棒了。

如果你有一个这样的测试,你能想象如果你不小心重构并引入了错误会发生什么吗?好吧,你的测试会崩溃!它们会告诉你你搞砸了某些东西,这里是详细信息。所以,你去检查你破坏了什么。

接口测试

让我们添加另一个测试来检查返回值。这是类中的另一个方法,所以我不需要再次复制整个代码:

tests/test_ch7/test_filter_funcs.py

def test_filter_ints_return_value(self):
    v = [3, -4, 0, -2, 5, 0, 8, -1]

    result = filter_ints(v)

    assert_list_equal([3, 5, 8], result)

这个测试与之前的测试略有不同。首先,我们不能模拟is_positive函数,否则我们就无法检查结果。其次,我们不是检查调用,而是检查输入数据时函数的结果。

我更喜欢这个测试,而不是之前的那个。这种类型的测试被称为接口测试,因为它检查我们正在测试的函数的接口(输入和输出的集合)。它不使用任何模拟,这就是为什么我比之前更常用这种技术。让我们运行新的测试套件,然后看看为什么我比那些带模拟的测试更喜欢接口测试。

$ nosetests tests/test_ch7/
..
------------------------------------------------------------
Ran 2 tests in 0.006s
OK

两个测试都运行良好(我在第一个测试中将那个9改回了8,当然)。

比较带和不带模拟的测试

现在,让我们看看为什么我真的不喜欢模拟,只有在没有选择的情况下才使用它们。让我们以这种方式重构代码:

filter_funcs_refactored.py

def filter_ints(v):
    v = [num for num in v if num != 0]  # 1
    return [num for num in v if is_positive(num)]

is_positive的代码与之前相同。但filter_ints中的逻辑现在已改变,这样is_positive就不会用0调用,因为它们都在#1中被过滤掉了。这导致了一个有趣的结果,所以让我们再次运行测试:

$ nosetests tests/test_ch7/test_filter_funcs_refactored.py 
F.
============================================================
FAIL: test_filter_ints (test_filter_funcs_refactored.FilterIntsTestCase)
------------------------------------------------------------
... omit ...
AssertionError: [call(3), call(-4), call(0), call(5), call(8)] != [call(3), call(-4), call(5), call(8)]
------------------------------------------------------------
Ran 2 tests in 0.002s
FAILED (failures=1)

一个测试通过了,但另一个测试,即使用模拟的is_positive函数的测试失败了。AssertionError消息显示我们现在需要修改预期调用列表,移除call(0),因为它不再执行了。

这并不好。我们既没有改变函数的接口,也没有改变其行为。函数仍然遵守其原始契约。通过用模拟对象测试它,我们所做的是限制了自己。实际上,我们现在必须修改测试以使用新的逻辑。

这只是一个简单的例子,但它展示了整个模拟机制中的一个重要缺陷。你必须确保你的模拟与被替换的代码保持最新和同步,否则你可能会遇到前面提到的问题,甚至更糟。你的测试可能不会失败,因为它们使用的是模拟对象,它们的表现良好,但真正的问题在于,现在不再同步的它们实际上失败了。

所以只有在必要时才使用模拟,只有在没有其他测试方法时才使用。当你跨越测试中的应用边界时,尝试使用替代品,比如测试数据库或模拟 API,只有在不可能的情况下才求助于模拟。它们非常强大,但如果不正确处理,也非常危险。

因此,让我们移除第一个测试,只保留第二个,这样我就可以向你展示你在编写测试时可能会遇到的其他问题。整个测试模块现在看起来是这样的:

tests/test_ch7/test_filter_funcs_final.py

from unittest import TestCase
from nose.tools import assert_list_equal
from ch7.filter_funcs import filter_ints

class FilterIntsTestCase(TestCase):
    def test_filter_ints_return_value(self):
        v = [3, -4, 0, -2, 5, 0, 8, -1]
        result = filter_ints(v)
        assert_list_equal([3, 5, 8], result)

如果我们运行它,它将通过。

简单聊聊三角剖分。现在让我问你:如果我将我的filter_ints函数改为这样,会发生什么?

filter_funcs_triangulation.py

def filter_ints(v):
    return [3, 5, 8]

如果你运行测试套件,我们现有的测试仍然会通过!你可能觉得我疯了,但我展示这个是因为我想讨论一个叫做三角剖分的概念,这在进行 TDD 接口测试时非常重要。

整个想法是通过从不同的角度(比如从一个三角形的三个顶点之一到其他两个顶点)定位,以使我们的代码无法作弊,并暴露出错误。我们可以简单地修改测试如下:

tests/test_ch7/test_filter_funcs_final_triangulation.py

def test_filter_ints_return_value(self):
    v1 = [3, -4, 0, -2, 5, 0, 8, -1]
    v2 = [7, -3, 0, 0, 9, 1]

    assert_list_equal([3, 5, 8], filter_ints(v1))
    assert_list_equal([7, 9, 1], filter_ints(v2))

我已经将执行部分直接移动到断言中,你可以看到我们现在从两个不同的角度定位我们的函数,从而要求真实代码必须包含在其中。我们的函数再也不能作弊了。

三角剖分是一种非常强大的技术,它教会我们始终尝试从许多不同的角度来测试我们的代码,以覆盖所有可能的边缘情况,从而暴露任何问题。

边界和粒度

现在让我们为is_positive函数添加一个测试。我知道它只有一行,但它为我们提供了讨论两个非常重要的概念的机会:边界粒度

函数主体中的那个0是一个边界,不等式中的>是我们对这个边界的行为。通常,当你设置一个边界时,你会将空间分成三个区域:边界之前的部分,边界之后的部分,以及边界本身。在例子中,边界之前我们找到负数,边界是元素0,边界之后我们找到正数。我们需要测试这些区域中的每一个,以确保我们正确地测试了函数。那么,让我们看看一个可能的解决方案(我将把这个测试添加到类中,但不会展示重复的代码):

tests/test_ch7/test_filter_funcs_is_positive_loose.py

def test_is_positive(self):
    assert_equal(False, is_positive(-2))  # before boundary
    assert_equal(False, is_positive(0))  # on the boundary
    assert_equal(True, is_positive(2))  # after the boundary

你可以看到,我们正在为边界周围的每个不同区域练习一个数字。你认为这个测试好吗?在你继续阅读之前,先思考一下。

答案是否定的,这个测试并不好。至少不够好。如果我改变is_positive函数的主体,使其读取return n > 1,我预期我的测试会失败,但它不会。-2仍然是False0也是,而2仍然是True。为什么会这样呢?这是因为我们没有正确考虑粒度。我们处理的是整数,那么从一个整数移动到下一个整数时的最小粒度是多少?是 1。因此,当我们考虑边界时,只考虑这三个区域是不够的。我们需要以可能的最小粒度来做这件事。让我们改变一下测试:

tests/test_ch7/test_filter_funcs_is_positive_correct.py

def test_is_positive(self):
    assert_equal(False, is_positive(-1))
    assert_equal(False, is_positive(0))
    assert_equal(True, is_positive(1))

啊,现在好多了。现在如果我们改变is_positive的主体,使其读取return n > 1,第三个断言将会失败,这正是我们想要的。你能想到一个更好的测试吗?

tests/test_ch7/test_filter_funcs_is_positive_better.py

def test_is_positive(self):
    assert_equal(False, is_positive(0))
    for n in range(1, 10 ** 4):
        assert_equal(False, is_positive(-n))
        assert_equal(True, is_positive(n))

这个测试甚至更好。我们测试了前十个整数(正数和负数)以及0。这基本上为我们提供了比仅仅跨越边界更好的覆盖率。所以,请记住这一点。在边界周围以最小的粒度进行近距离观察,但也要尝试扩展,在最佳覆盖率和执行速度之间找到一个良好的折衷方案。我们很想检查前十亿个整数,但我们不能等待几天来运行我们的测试。

一个更有趣的例子

好的,这是我能够给你的最温和的介绍了,所以让我们继续到更有趣的部分。让我们编写并测试一个函数,它可以扁平化嵌套字典结构。在过去的几年里,我非常紧密地与 Twitter 和 Facebook API 合作。处理这样庞大的数据结构并不容易,尤其是它们通常非常嵌套。结果发现,以一种你可以工作而不丢失原始结构信息的方式扁平化它们要容易得多,然后可以从扁平结构重新创建嵌套结构。为了给你一个例子,我们想要的是这样的:

data_flatten.py

nested = {
    'fullname': 'Alessandra',
    'age': 41,
    'phone-numbers': ['+447421234567', '+447423456789'],
    'residence': {
        'address': {
            'first-line': 'Alexandra Rd',
            'second-line': '',
        },
        'zip': 'N8 0PP',
        'city': 'London',
        'country': 'UK',
    },
}

flat = {
    'fullname': 'Alessandra',
    'age': 41,
    'phone-numbers': ['+447421234567', '+447423456789'],
    'residence.address.first-line': 'Alexandra Rd',
    'residence.address.second-line': '',
    'residence.zip': 'N8 0PP',
    'residence.city': 'London',
    'residence.country': 'UK',
}

类似于 flat 的结构更容易操作。在编写扁平化函数之前,让我们做一些假设:键是字符串,除非是字典,否则我们保留每个数据结构不变,如果是字典,则进行扁平化处理,我们使用点作为分隔符,但希望能够将不同的分隔符传递给我们的函数。以下是代码:

data_flatten.py

def flatten(data, prefix='', separator='.'):
    """Flattens a nested dict structure. """
    if not isinstance(data, dict):
        return {prefix: data} if prefix else data

    result = {}
    for (key, value) in data.items():
        result.update(
            flatten(
                value,
                _get_new_prefix(prefix, key, separator),
                separator=separator))
    return result

def _get_new_prefix(prefix, key, separator):
    return (separator.join((prefix, str(key)))
            if prefix else str(key))

上述示例并不困难,但也不是显而易见的,所以让我们来分析一下。首先,我们检查 data 是否是字典。如果不是字典,那么它就是不需要扁平化的数据;因此,我们简单地返回 data,或者如果 prefix 不是空字符串,则返回一个包含一个键/值对的字典:prefix/data

如果 data 是一个字典,我们准备一个空的 result 字典来返回,然后解析 data 的项目列表,我相信你会记得,它们是 2-元组 (键,值)。对于每一对 (键,值),我们递归地调用 flatten,并用那个调用返回的内容更新 result 字典。递归在遍历嵌套结构时非常出色。

乍一看,你能理解 _get_new_prefix 函数的作用吗?让我们再次使用内外部技术。我看到一个三元运算符,当 prefix 是空字符串时返回 key 的字符串表示。另一方面,当 prefix 是非空字符串时,我们使用 separatorprefixkey 的字符串表示形式 join。请注意,在 join 调用内部的括号不是多余的,我们需要它们。你能想出为什么吗?

让我们为这个函数写几个测试用例:

tests/test_ch7/test_data_flatten.py

# ... imports omitted ...
class FlattenTestCase(TestCase):

    def test_flatten(self):
        test_cases = [
            ({'A': {'B': 'C', 'D': [1, 2, 3], 'E': {'F': 'G'}},
 'H': 3.14,
 'J': ['K', 'L'],
 'M': 'N'},
             {'A.B': 'C',
              'A.D': [1, 2, 3],
              'A.E.F': 'G',
              'H': 3.14,
              'J': ['K', 'L'],
              'M': 'N'}),
            (0, 0),
            ('Hello', 'Hello'),
            ({'A': None}, {'A': None}),
        ]
        for (nested, flat) in test_cases:
            assert_equal(flat, flatten(nested))

    def test_flatten_custom_separator(self):
        nested = {'A': {'B': {'C': 'D'}}}
        assert_equal(
            {'A#B#C': 'D'}, flatten(nested, separator='#'))

让我们从 test_flatten 开始。我定义了一个包含 2-元组 (nested, flat) 的列表,每个都代表一个测试用例(我突出显示了 nested 以便于阅读)。我有一个包含三个嵌套级别的字典,然后是一些较小的数据结构,当传递给 flatten 函数时它们不会改变。这些测试用例可能不足以覆盖所有边缘情况,但它们应该能给你一个关于如何构建此类测试的好主意。通过简单的 for 循环,我遍历每个测试用例,并断言 flatten(nested) 的结果等于 flat

小贴士

关于这个示例,有一点要说的是,当你运行它时,它会显示已经运行了两个测试。这实际上是不正确的,因为即使技术上只有一个测试在运行,其中一个测试中我们有多个测试用例。最好以它们被识别为单独的方式运行。这可以通过使用像 nose-parameterized 这样的库来实现,我鼓励你查看。它位于 pypi.python.org/pypi/nose-parameterized

我还提供了一个额外的测试用例,以确保自定义分隔符功能正常工作。正如你所看到的,我只使用了一个数据结构,它要小得多。我们不需要再次做大,也不需要测试其他边缘情况。记住,测试应该确保一件事,而且只有一件事,test_flatten_custom_separator只是负责验证我们是否可以向flatten函数提供不同的separator

如果我有足够的空间,我可以继续谈论测试,大概还能写一本书,但不幸的是,我们得在这里停下来。我没有告诉你关于doctests(使用 Python 交互式 shell 风格编写的文档中的测试)以及关于这个主题可能说的另外五十万件事情。你必须自己发现这些。

查看一下unittest模块、nosenose-parameterized库以及pytestpytest.org/)的文档,这样你就能顺利了。根据我的经验,对于新手来说,模拟和补丁似乎很难掌握,所以请给自己一点时间来消化这些技术。尝试并逐渐学习它们。

测试驱动开发

让我们简要地谈谈测试驱动开发TDD。这是一种由 Kent Beck 重新发现的方法论,他写了《通过示例进行测试驱动开发》,Addison Wesley – 2002,如果你想了解这个主题的基础知识,我强烈建议你阅读这本书,我对这个主题非常着迷。

TDD 是一种基于持续重复非常短的开发周期的软件开发方法

首先,开发者编写一个测试用例,并运行它。测试用例旨在检查代码中尚未包含的功能。可能是要添加的新功能,或者是要删除或修改的内容。运行测试用例会使它失败,因此这个阶段被称为红色

当测试失败时,开发者编写最少的代码来使其通过。当运行测试成功时,我们就进入了所谓的绿色阶段。在这个阶段,可以编写一些欺骗性的代码,只是为了使测试通过(这就是为什么你会使用三角测量法)。这种技术被称为,假装直到你做到

这个循环的最后一部分是开发者负责代码和测试(分别在不同的时间)并进行重构,直到它们达到期望的状态。这个最后阶段被称为重构

因此,TDD 的咒语是,红色-绿色-重构

起初,在编写代码之前编写测试用例感觉真的很奇怪,我必须承认我花了一些时间才习惯这种方式。但是,如果你坚持下去,并强迫自己学习这种稍微有些反直觉的工作方式,在某个时刻,几乎神奇的事情就会发生,你将看到你的代码质量以一种在其他情况下不可能的方式提高。

当你在编写测试之前编写代码时,你必须同时注意代码需要做什么以及如何做。另一方面,当你先编写测试再编写代码时,你可以专注于“做什么”的部分,在编写测试时集中精力。当你编写代码之后,你主要需要关注的是如何实现测试所要求的内容。这种关注点的转变允许你的大脑在单独的时刻集中精力在“做什么”和“如何做”的部分,从而产生一个让你惊讶的大脑能力提升。

从采用这种技术中还可以获得其他一些好处:

  • 你将更有信心地进行重构:因为当你接触代码时,你知道如果你搞砸了,至少会破坏一个测试。此外,你将在重构阶段处理架构设计,此时有测试作为守护者将允许你享受对代码进行按摩,直到它达到让你满意的状态。

  • 代码将更加易于阅读:在我们这个时代,编码已经成为一种社会活动,每个专业开发者花费在阅读代码上的时间远多于编写代码。

  • 代码将更加松散耦合,更容易测试和维护:这仅仅是因为先编写测试迫使你更深入地思考其结构。

  • 先编写测试需要你对业务需求有更好的理解:这是交付实际所需内容的基本要求。如果你的需求理解缺乏信息,你会发现编写测试极具挑战性,这种情况就像一个哨兵一样提醒你。

  • 所有内容都经过单元测试意味着代码将更容易调试:此外,小测试是提供替代文档的完美选择。英语可能会误导,但五行简单的 Python 代码在一个简单的测试中很难被误解。

  • 更高的速度:编写测试和代码比先编写代码然后花费时间调试要快。如果你不编写测试,你可能会更快地交付代码,但随后你必须追踪并解决错误(而且,请放心,肯定会有错误)。编写代码然后调试所花费的总时间通常比使用 TDD(测试驱动开发)开发代码的时间要长,在编写代码之前就有测试运行,确保其中的错误数量将远低于其他情况。

另一方面,这种技术的缺点主要是:

  • 整个公司都需要相信它:否则你将不得不不断地与你的老板争论,而你的老板可能不会理解为什么你交付的时间如此之长。事实是,短期内你交付的时间可能会更长,但长期来看,TDD 会给你带来很多好处。然而,由于它不像短期那样触手可及,因此很难看到长远的影响。在我的职业生涯中,我与固执的老板进行了斗争,以便能够使用 TDD 进行编码。有时这很痛苦,但总是值得的,我从未后悔过,因为最终,结果的质量总是受到赞赏。

  • 如果你没有理解业务需求,这将在你编写的测试中反映出来,因此也会反映在代码中:这种问题直到你进行用户验收测试(UAT)之前很难被发现,但你可以做的一件事是与另一位开发者结对编程。结对编程不可避免地需要讨论业务需求,这有助于在编写测试之前更好地了解它们。

  • 编写糟糕的测试很难维护:这是一个事实。带有太多模拟或额外假设或结构不良数据的测试很快就会成为一种负担。不要让这让你气馁;只需继续实验,改变你编写测试的方式,直到你找到一种不需要你在每次触摸代码时做大量工作的方法。

我对 TDD(测试驱动开发)的热情如此之高,以至于当我去面试工作时,我总是询问我即将加入的公司是否采用 TDD。如果答案是“不”,对我来说这可能是一个决定性的因素。我鼓励你尝试并使用它。一直用到你在脑海中有所领悟。我保证你不会后悔。

异常

尽管我还没有正式向你介绍它们,但到现在为止,我期望你至少对什么是异常有一个模糊的概念。在前几章中,我们看到了当迭代器耗尽时,对其调用next会引发StopIteration异常。当我们尝试访问一个不在有效范围内的列表位置时,我们遇到了IndexError。当我们尝试访问一个没有该属性的对象的属性时,我们遇到了AttributeError,当我们用键和字典做同样的事情时,我们遇到了KeyError。我们还在运行测试时遇到了AssertionError

现在,是我们讨论异常的时候了。

有时候,即使一个操作或一段代码是正确的,也可能存在某些可能导致错误的情况。例如,如果我们正在将用户输入从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_var)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'undefined_var' 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 的外壳相当宽容。我们可以看到 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('In the try block: {}/{}'
              .format(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 exceptions/try.syntax.py 
In the try block: 12/4
The result is: 3.0
Exiting
3.0
In the try block: 11/0
division by zero
Exiting
None

当您执行一个 try 块时,您可能想要捕获多个异常。例如,当尝试解码一个 JSON 对象时,您可能会遇到 ValueError(不规则的 JSON),或者如果您提供给 json.loads() 的数据类型不是字符串,则可能发生 TypeError。在这种情况下,您可以像这样结构化您的代码:

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 = '{{',您将看到不同的输出。

注意

JSON 代表 JavaScript Object Notation,它是一种开放标准格式,使用人类可读的文本来传输由键/值对组成的数据对象。它是当在应用程序之间移动数据时广泛使用的交换格式,尤其是在数据需要以语言或平台无关的方式处理时。

如果您想以不同的方式处理多个异常,只需添加更多的 except 子句,如下所示:

exceptions/multiple.except.py

try:
    # some code
except Exception1:
    # react to Exception1
except (Exception2, Exception3):
    # react to Exception2 and Exception3
except Exception3:
    # react to Exception3
...

请记住,异常是在定义该异常类或其基类的第一个块中处理的。因此,当您堆叠多个 except 子句时,请确保将特定的异常放在顶部,将通用的异常放在底部。在面向对象术语中,子类在顶部,基类在底部。此外,请记住,当抛出异常时,只有一个 except 处理器被执行。

您还可以编写 自定义异常。为了做到这一点,您只需从任何其他异常类继承即可。Python 内置的异常太多,无法在此列出,因此我必须将您指向官方文档。需要知道的一个重要事项是,每个 Python 异常都源自 BaseException,但您的自定义异常不应直接从该类继承。这样做的原因是,处理此类异常也会捕获来自 BaseException系统退出异常,如 SystemExitKeyboardInterrupt,这可能导致严重问题。在灾难发生时,您希望能够通过 Ctrl + C 从应用程序中退出。

您可以通过从 Exception 继承来轻松解决问题,Exception 继承自 BaseException,但它的子类不包括任何退出系统的异常,因为它们在内置异常层次结构中是兄弟关系(参见 docs.python.org/3/library/exceptions.html#exception-hierarchy)。

使用异常进行编程可能会非常棘手。你可能会无意中静音错误,或者捕获那些本不应该处理的异常。为了安全起见,请记住以下几点指南:只将可能引发你想要处理的异常(s)的代码放入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。在示例中,条件是一个简单的线性方程,但想象一下比这更酷的东西。让我烦恼的是,我不得不在每个循环的开始检查是否找到了解决方案,以便在找到时尽可能快地跳出循环。跳出逻辑干扰了其他代码,我不喜欢它,所以我为这个问题想出了一个不同的解决方案。看看它,看看你是否可以将其适应到其他情况。

e xceptions/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子句。这是值得深思的。这个例子间接地展示了如何抛出自己的异常。查阅官方文档,深入了解这个主题的美丽细节。

分析 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 迭代区间 [1, mx](通过设置 b >= a 来避免重复的配对)并检查它们是否属于直角三角形。我们使用 calc_hypotenuse 来获取 ab 的斜边,然后,使用 is_int 检查它是否为整数,这意味着 (a, b, c) 是一个毕达哥拉斯三元组。当我们对这个脚本进行性能分析时,我们得到表格形式的信息。列包括 ncallstottimepercallcumtimepercallfilename:lineno(function)。它们代表我们对函数的调用次数、在其中花费的时间等等。我会裁剪一些列以节省空间,所以如果你自己运行性能分析,不要担心如果你得到不同的结果。

$ python -m cProfile profiling/triples.py
1502538 function calls in 0.750 seconds
Ordered by: standard name
ncalls  tottime  percall filename:lineno(function)
500500    0.469    0.000 triples.py:14(calc_hypotenuse)
500500    0.087    0.000 triples.py:18(is_int)
 1    0.000    0.000 triples.py:4(<module>)
 1    0.163    0.163 triples.py:4(calc_triples)
 1    0.000    0.000 {built-in method exec}
 1034    0.000    0.000 {method 'append' of 'list' objects}
 1    0.000    0.000 {method 'disable' of '_lsprof.Profil...
500500    0.032    0.000 {method 'is_integer' of 'float' objects}

即使只有这么有限的数据,我们仍然可以从中推断出一些关于此代码的有用信息。首先,我们可以看到我们选择的算法的时间复杂度随着输入大小的平方增长。我们进入内循环体的次数正好是 mx (mx + 1) / 2。我们用 mx = 1000 运行脚本,这意味着我们在内层 for 循环中进入了 500500 次。在这个循环中发生三件事,我们调用 calc_hypotenuse,调用 is_int,如果条件满足,我们将元素添加到 triples 列表中。

查看性能分析报告,我们发现算法在 calc_hypotenuse 函数中花费了 0.469 秒,这比在 is_int 函数中花费的 0.087 秒多得多,尽管它们被调用的次数相同,所以让我们看看是否可以稍微提升 calc_hypotenuse 的性能。

实际上,我们可以。正如我之前在书中提到的,幂运算符**相当昂贵,在calc_hypotenuse中我们使用了它三次。幸运的是,我们可以轻松地将其中两个转换为简单的乘法,如下所示:

profiling/triples.py

def calc_hypotenuse(a, b):
    return (a*a + b*b) ** .5

这个简单的更改应该会改善情况。如果我们再次运行性能分析,我们会看到现在0.469已经下降到0.177。不错!这意味着现在我们花费在calc_hypotenuse函数内部的时间大约只有之前的 37%。

让我们看看我们是否可以通过这样改变来改进is_int

profiling/triples.py

def is_int(n):
    return n == int(n)

这个实现是不同的,其优势在于它也适用于n是整数的情况。然而,当我们对它进行性能分析时,我们发现is_int函数内部的时间已经上升到0.141秒。这意味着它大约翻了一番,与之前相比。在这种情况下,我们需要恢复到之前的实现。

当然,这个例子很简单,但足以展示如何对应用程序进行性能分析。了解针对函数执行的调用次数可以帮助我们更好地理解算法的时间复杂度。例如,你不会相信有多少程序员没有意识到那两个for循环是与输入大小的平方成比例运行的。

有一点需要提及:根据你使用的系统,结果可能会有所不同。因此,能够在一个尽可能接近软件部署系统的系统上对软件进行性能分析,如果不是实际上在那个系统上,这非常重要。

何时进行性能分析?

性能分析非常酷,但我们需要知道何时进行性能分析,以及我们需要以何种程度来处理从性能分析中获得的结果。

Donald Knuth 曾经说过,“过早优化是万恶之源”,虽然我不会如此极端地表达,但我同意他的观点。毕竟,我怎能不同意这位给我们带来了《计算机程序设计艺术》、《TeX》以及我在大学时研究过的最酷算法的人呢?

所以,首先也是最重要的:正确性。你希望你的代码能够正确地交付结果,因此编写测试,寻找边缘情况,并以你认为合理的方式对你的代码进行压力测试。不要过于保护,不要把事情放在脑后,因为你认为它们不太可能发生。要彻底。

其次,注意编码的最佳实践。记住可读性、可扩展性、松散耦合、模块化和设计。应用面向对象编程(OOP)原则:封装、抽象、单一职责、开闭原则等。深入了解这些概念。它们将为你打开新的视野,并扩展你对代码的思考方式。

第三,像野兽一样重构代码!童子军规则说:“总是要使你离开的地方比找到的时候更干净。”将这个规则应用到你的代码上。

最后,当所有上述事项都得到妥善处理之后,然后且仅在此之后,你才开始关注性能分析。

运行你的性能分析器并识别瓶颈。当你对需要解决的瓶颈有了概念后,先从最严重的一个开始。有时,修复一个瓶颈会引起连锁反应,从而改变代码其余部分的工作方式。这取决于你的代码是如何设计和实现的,有时这种影响很小,有时则更多。因此,先从最大的问题开始。

Python 之所以如此受欢迎,其中一个原因是它可以以许多不同的方式实现。所以,如果你发现自己在使用纯 Python 时遇到困难,没有什么能阻止你卷起袖子,买上几百升咖啡,然后用 C 语言重写那些慢速的代码。保证会很有趣!

摘要

在本章中,我们探讨了测试、异常和性能分析的世界。

我试图给你一个相当全面的测试概述,特别是单元测试,这是开发者主要进行的测试类型。我希望我已经成功地传达了测试不是完美定义的东西,你可以从书中学习。在感到舒适之前,你需要大量地实验。在程序员必须做出的学习和实验努力中,我认为测试是最值得的之一。

我们简要地看到了如何防止我们的程序因为运行时发生的错误,称为异常,而死亡。而且,为了避开常规内容,我给你提供了一个使用异常跳出嵌套for循环的不太常规的例子。这并非唯一的情况,我相信随着你作为程序员成长,你还会发现其他情况。

最后,我们非常简要地提到了性能分析,通过一个简单的例子和一些指导方针。我想为了完整性而讨论性能分析,这样你至少可以尝试一下。

我们现在即将进入第八章,边缘 – GUI 和脚本,我们将在这里动手处理脚本和 GUI,并希望有所发现。

注意

我知道我在本章中给了你很多提示,但没有提供链接或方向。我恐怕这是出于选择。作为一个程序员,在工作中不会有哪一天不需要在文档页面、手册、网站等地方查找东西。我认为对于程序员来说,能够有效地搜索所需信息是至关重要的,所以我希望你能原谅我这种额外的训练。毕竟,这一切都是为了你的利益。

第八章。边缘 – GUI 和脚本

*"用户界面就像一个笑话。如果你不得不解释它,那就不是很好。"
--马丁·勒布兰

在本章中,我们将一起完成一个项目。我们将准备一个非常简单的 HTML 页面,其中包含一些图片,然后我们将爬取它,以便保存这些图片。

我们将编写一个脚本来完成这项工作,这将使我们能够讨论一些我想向您介绍的概念。我们还将添加一些选项,根据图片的格式保存图片,并选择保存的方式。完成脚本后,我们将编写一个 GUI 应用程序,它基本上做同样的事情,一石二鸟。只有一个项目来解释将使我能够在本章中展示更广泛的主题。

注意

图形用户界面GUI)是一种允许用户通过图形图标、按钮和小部件与电子设备交互的界面,与基于文本或命令行界面相反,后者需要在键盘上输入命令或文本。简而言之,任何浏览器、任何办公套件如 LibreOffice,以及您点击图标时弹出的任何东西,都是一个 GUI 应用程序。

因此,如果您还没有这样做,现在开始一个控制台并将自己定位在项目根目录下的一个名为ch8的文件夹中,这将是一个完美的时机。在那个文件夹中,我们将创建两个 Python 模块(scrape.pyguiscrape.py)和一个标准文件夹(simple_server)。在simple_server中,我们将编写我们的 HTML 页面(index.html),图片将存储在ch8/simple_server/imgch8中的结构应该如下所示:

$ 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 或 Mac,您可以像我所做的那样,将启动 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/strong>" height="128" />
      <img src="img/strong>" height="128" />
      <img src="img/strong>" height="128" />
      <img src="img/strong>" height="128" />
      <img src="img/strong>" 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 ...
127.0.0.1 - - [31/Aug/2015 16:11:10] "GET / HTTP/1.1" 200 -

最后一行是您访问http://localhost:8000时得到的日志,我们的美丽页面将在这里提供服务。或者,您可以将该命令放入一个名为serve.sh的文件中,然后使用以下命令运行它(确保它是可执行的):

$ ./serve.sh

它将产生相同的效果。如果您有这本书的代码,您的页面应该看起来像这样:

The Edges – GUIs and Scripts

您可以自由地使用任何其他图像集,只要您至少使用一个 PNG 和一个 JPG,并且在src标签中使用相对路径,而不是绝对路径。我得到了那些可爱的猫头鹰来自openclipart.org/

第一种方法 – 脚本

现在,让我们开始编写脚本。我将分三步进行源代码的审查:首先是导入,然后是参数解析逻辑,最后是业务逻辑。

导入

scrape.py (导入)

import argparse
import base64
import json
import os
from bs4 import BeautifulSoup
import requests

从顶部开始查看,我们可以看到我们需要解析参数,这些参数将被提供给脚本本身(argparse)。我们需要base64库来在 JSON 文件中保存图像(base64json),我们还需要打开文件进行写入(os)。最后,我们需要BeautifulSoup来轻松抓取网页,以及requests来获取其内容。requests是一个执行 HTTP 请求的极其流行的库,它基于快速的第三方库urllib3

注意

我们将在第十章 Web Development Done Right 中探索 HTTP 协议和requests机制,因此,现在我们只需(简单地)说,我们执行一个 HTTP 请求来获取网页内容。我们可以使用像requests这样的库来程序化地完成它,这大致相当于在浏览器中输入一个 URL 并按Enter键(浏览器随后获取网页内容并显示给您)。

在所有这些导入中,只有最后两个不属于 Python 标准库,但它们在全球范围内被广泛使用,我不敢在这本书中排除它们。请确保您已安装它们:

$ pip freeze | egrep -i "soup|requests"
beautifulsoup4==4.4.0
requests==2.7.0

当然,版本号可能对您来说有所不同。如果它们尚未安装,请使用以下命令进行安装:

$ pip install beautifulsoup4 requests

到目前为止,我认为可能让您感到困惑的唯一事情是base64/json这对,因此请允许我花几句话来解释这一点。

正如我们在上一章中看到的,JSON 是应用程序之间数据交换最流行的格式之一。它也被广泛用于其他目的,例如,在文件中保存数据。在我们的脚本中,我们将为用户提供保存图像为图像文件或 JSON 单个文件的能力。在 JSON 中,我们将放置一个字典,键为图像名称,值为内容。唯一的问题是,以二进制格式保存图像有点棘手,这就是base64库发挥作用的地方。Base64是一种非常流行的二进制到文本编码方案,通过将其转换为基数 64 表示来以 ASCII 字符串格式表示二进制数据。

注意

radix-64表示法使用字母A-Za-z和数字0-9,以及两个符号+/,总共 64 个符号。因此,不出所料,Base64 字母表由这 64 个符号组成。

如果你认为你从未使用过它,那么再想想。每次你发送带有图片的电子邮件时,图片在发送邮件之前都会被 Base64 编码。在接收方,图片会自动解码成原始的二进制格式,以便电子邮件客户端可以显示它们。

解析参数

现在技术细节已经解决,让我们看看脚本的第二部分(它应该在scrape.py模块的末尾)。

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__'。另一方面,当你直接运行脚本,就像我们即将要做的那样,__name__将是'__main__',所以执行逻辑将会运行。

我们首先做的事情是定义我们的解析器。我建议使用标准库模块argparse,它足够简单且功能强大。还有其他选择,但在这个情况下,argparse将为我们提供所有需要的东西。

我们希望向我们的脚本提供三种不同的数据:我们想要保存的图像类型、我们想要保存它们的格式以及要抓取的页面的 URL。

类型可以是 PNG、JPG 或两者(默认),而格式可以是图像或 JSON,图像是默认格式。URL 是唯一的必填参数。

因此,我们添加了-t选项,也允许长版本--type。选项有'all''png''jpg'。我们将默认值设置为'all'并添加了帮助信息。

我们对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__子句之前):

scrape.py (业务逻辑)

def scrape(url, format_, type_):
    try:
        page = requests.get(url)
    except requests.RequestException as rex:
        print(str(rex))
    else:
        soup = BeautifulSoup(page.content, 'html.parser')
        images = _fetch_images(soup, url)
        images = _filter_images(images, type_)
        _save(images, format_)

def _fetch_images(soup, base_url):
    images = []
    for img in soup.findAll('img'):
        src = img.get('src')
        img_url = (
            '{base_url}/{src}'.format(
                base_url=base_url, src=src))
        name = img_url.split('/')[-1]
        images.append(dict(name=name, url=img_url))
    return 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

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

让我们从scrape函数开始。它首先做的事情是获取给定的url参数指定的页面。在这个过程中可能发生的任何错误,我们都会在RequestException rex中捕获它并打印出来。RequestExceptionrequests库中所有异常的基类。

然而,如果一切顺利,并且我们从GET请求中得到了一个页面,那么我们可以继续(else分支)并将其内容传递给BeautifulSoup解析器。BeautifulSoup库允许我们快速解析网页,而无需编写所有用于在页面中找到所有图像的逻辑,我们真的不想这样做。这并不像看起来那么简单,重新发明轮子从来都不是一个好的选择。为了获取图像,我们使用_fetch_images函数,并通过_filter_images过滤它们。最后,我们使用结果调用_save

将代码拆分成具有意义名称的不同函数,使我们更容易阅读。即使你没有看到_fetch_images_filter_images_save函数的逻辑,也不难预测它们的作用,对吧?

_fetch_images函数接受一个BeautifulSoup对象和一个基础 URL。它所做的一切就是遍历页面上找到的所有图像,并在字典中填充它们的'name''url'信息(每个图像一个字典)。所有字典都添加到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'}, ...]

我为了简洁起见截断了结果。你可以看到每个字典都有一个 'url''name' 键/值对,我们可以使用它来获取、识别并按我们的喜好保存图像。在这个时候,我听到你在问,如果页面上的图像使用绝对路径而不是相对路径指定会发生什么,对吧?好问题!

答案是,脚本将无法下载它们,因为这种逻辑期望相对路径。我正准备添加一些逻辑来解决这个问题时,我想这个阶段,这对你来说将是一个很好的练习,所以我会把它留给你来修复。

小贴士

提示:检查那个 src 变量的开头。如果它以 'http' 开头,那么它可能是一个绝对路径。

我希望 _filter_images 函数的主体对你来说很有趣。我想向你展示如何通过使用映射技术来检查多个扩展名。

在这个函数中,如果 type_'all',则不需要过滤,所以我们只需返回所有图像。另一方面,当 type_ 不是 'all' 时,我们从 ext_map 字典中获取允许的扩展名,并使用它来过滤函数体末尾的列表推导式中的图像。你可以看到,通过使用另一个辅助函数 _matches_extension,我已经使列表推导式更简单、更易读。

_matches_extension 函数所做的只是将图像名称与其扩展名分开,并检查它是否在允许的列表中。你能找到对这个函数进行(从速度方面)微优化的方法吗?

我确信你一定在想,为什么我把所有图像收集到列表中然后再删除,而不是在将它们添加到列表之前检查我是否想要保存它们。第一个原因是,我需要现在的 GUI 应用程序中的 _fetch_images。第二个原因是,组合、获取和过滤会产生一个更长且稍微复杂一些的函数,而我正在尝试保持复杂性水平。第三个原因是,这可能是一个很好的练习给你做。感觉我们像是在结对编程...

让我们继续查看代码,并检查 _save 函数。你可以看到,当 images 不为空时,它基本上充当一个分发器。我们根据 format_ 变量中存储的信息,要么调用 _save_images,要么调用 _save_json

我们几乎完成了。让我们跳转到 _save_images。我们遍历 images 列表,并对每个找到的字典执行对图像 URL 的 GET 请求,并将内容保存到文件中,我们将其命名为图像本身。这里需要注意的一个重要问题是文件是如何保存的。

我们使用上下文管理器,通过关键字with来执行这个操作。Python 的with语句支持由上下文管理器定义的运行时上下文的概念。这是通过一对方法(contextmanager.__enter__()contextmanager.__exit__(exc_type, exc_val, exc_tb))实现的,允许用户定义的类定义一个在语句体执行之前进入并在语句结束时退出的运行时上下文。

在我们的情况下,使用上下文管理器,结合 open 函数,确保如果在写入文件时发生任何错误,涉及该过程的资源将被清理并正确释放,无论错误如何。你有没有尝试在 Windows 上删除一个文件,结果弹出一个警告告诉你,你不能删除该文件,因为另一个进程正在占用它?我们正在避免这种非常讨厌的事情。

当我们打开一个文件时,我们会得到一个处理它的句柄,无论发生什么情况,我们都想确保在完成文件操作后释放它。上下文管理器是我们确保这一点的工具。

最后,让我们现在进入_save_json函数。它与之前的函数非常相似。我们基本上填写data字典。图像名称是,其二进制内容的 Base64 表示是。当我们完成字典的填充后,我们使用json库将其写入images.json文件。我将给你一个小的预览:

images.json (截断)

{
 "owl-ebook.jpg": "/9j/4AAQSkZJRgABAQEAMQAxAAD/2wBDAAEBAQ...
 "owl-book.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEbCAYAAAB...
 "owl-books.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAElCAYAAA...
 "owl-alcohol.png": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEICAYA...
 "owl-rose.jpeg": "/9j/4AAQSkZJRgABAQEANAA0AAD/2wBDAAEBAQ...
}

就这样!现在,在进入下一节之前,请确保你玩转这个脚本,并很好地理解它是如何工作的。尝试修改一些内容,打印出中间结果,添加新的参数或功能,或者打乱逻辑。我们现在将把它迁移到一个 GUI 应用程序中,这将增加一层复杂性,仅仅因为我们需要构建 GUI 界面,所以你熟悉业务逻辑是很重要的:这将允许你专注于代码的其他部分。

第二种方法——GUI 应用程序

在 Python 中编写 GUI 应用程序有几个库。最著名的是tkinterwxPythonPyGTKPyQt。它们都提供了一系列的工具和控件,你可以使用它们来构建 GUI 应用程序。

我将在本章剩余部分使用的工具是 tkinter。tkinter代表Tk 界面,它是 Tk GUI 工具包的标准 Python 接口。Tk 和 tkinter 在大多数 Unix 平台、Mac OS X 以及 Windows 系统上都是可用的。

让我们通过运行以下命令来确保tkinter在你的系统上安装正确:

$ python -m tkinter

它应该打开一个对话框,展示一个简单的Tk界面。如果你能看到它,那么我们就没问题了。然而,如果它不起作用,请在 Python 官方文档中搜索tkinter。你将找到几个链接到资源,这些资源将帮助你开始使用它。

我们将制作一个非常简单的 GUI 应用程序,它基本上模仿了我们在本章第一部分看到的脚本的行为。我们不会添加保存 JPG 或 PNG 图片的功能,但当你完成本章后,你应该能够自己玩弄代码并添加这个功能。

因此,这是我们追求的目标:

第二种方法 – 一个 GUI 应用程序

美丽,不是吗?正如你所见,它是一个非常简单的界面(这就是它在 Ubuntu 上应该看起来样子)。有一个用于URL字段和获取信息按钮的框架(即容器),另一个用于列表框以存放图片名称和单选按钮以控制我们保存图片的方式的框架,最后在底部有一个抓取!按钮。我们还有一个状态栏,它显示一些信息。

为了得到这个布局,我们本可以将所有小部件都放在根窗口上,但这样会使布局逻辑变得相当混乱且不必要地复杂。因此,我们将使用框架来划分空间,并将小部件放置在这些框架中。这样我们就能得到一个更好的结果。所以,这是布局的草案:

第二种方法 – 一个 GUI 应用程序

我们有一个根窗口,它是应用程序的主窗口。我们将其分为两行,第一行放置主框架,第二行放置状态框架(它将包含状态栏)。主框架随后本身也分为三行。在第一行中,我们放置URL 框架,它包含URL小部件。在第二行中,我们放置图片框架,它将包含列表框单选框架,后者将托管标签和单选按钮小部件。最后是第三行,它将仅包含抓取按钮。

为了布局框架和小部件,我们将使用一个名为grid的布局管理器,它简单地像矩阵一样将空间划分为行和列。

现在,我将要写的所有代码都来自guiscrape.py模块,所以为了节省空间,我不会在每个代码片段中重复其名称。该模块逻辑上分为三个部分,与脚本版本类似:导入、布局逻辑和业务逻辑。我们将逐行分析它们,分为三个部分。

导入

from tkinter import *
from tkinter import ttk, filedialog, messagebox
import base64
import json
import os
from bs4 import BeautifulSoup
import requests

我们已经熟悉了其中大部分。这里有趣的部分是前两行。第一行是一种相当常见的做法,尽管在 Python 中使用星号语法导入是坏做法。你可能会遇到名称冲突,如果模块太大,导入所有内容将会很昂贵。

之后,我们明确地导入 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 设置为其父对象,并给它一些填充。填充 是指在内部内容和边框之间插入多少像素的空间,以便让我们的布局呼吸一下,否则我们会得到 沙丁鱼效应,其中小部件被紧密地打包在一起。

第二行更有趣。我们将这个 _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 框架。这次,父对象是 _mainframe,正如你从我们的草案中回忆的那样。这不仅仅是一个简单的 Frame,而实际上是一个 LabelFrame,这意味着我们可以设置文本参数,并期望围绕它绘制一个矩形,文本参数的内容将写在它的左上角(如果需要,可以查看之前的图片)。我们将这个框架放置在 (0, 0) 位置,并说它应该向左和向右扩展。我们不需要其他两个方向。

最后,我们使用 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_url是一个StringVar对象,它现在连接到Entry,并将用于操作其内容。因此,我们不是直接修改_url_entry实例中的文本,而是通过访问_url来修改。在这种情况下,我们调用它的set方法来设置初始值为我们本地网页的 URL。

我们将_url_entry定位在(0, 0),使其四个正方向都粘附,并且我们还通过使用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上的(0, 0),Scrollbar在(0, 1),因此_radio_frame将放在(0, 2)。

    _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来结束布局部分,这个ttk.Frame就是状态框架。为了给它一点状态栏的效果,我们将它的relief属性设置为'sunken',并给它 2 像素的均匀填充。它需要粘附在_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(())   # initialized as an empty tuple
    try:
        page = requests.get(url)
    except requests.RequestException as rex:
        _sb(str(rex))
    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 = (
            '{base_url}/{src}'.format(base_url=base_url, src=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对象(一个Entry)相关联的StringVar实例。你看到的 GUI 中的文本字段是Entry,但幕后是StringVar对象的值。

通过在_url上调用get(),我们获取显示在_url_entry中的文本值。

下一步是将config['images']准备为一个空列表,并清空_images变量,该变量与_img_listbox相关联。当然,这会清除_img_listbox中的所有项目。

在这个准备步骤之后,我们可以尝试使用我们在本章开头脚本中采用的相同try/except逻辑来获取页面。

唯一的不同之处在于,如果出现问题,我们调用_sb(str(rex))_sb是一个辅助函数,我们很快就会看到它的代码。基本上,它为我们设置状态栏的文本。名字不太好,对吧?我不得不向你解释它的行为:供你思考。

如果我们可以获取页面,那么我们创建soup实例,并从中获取图片。fetch_images的逻辑与之前解释的完全相同,所以这里不再重复。

如果我们有图片,使用一个快速的元组推导(实际上是一个传递给元组构造函数的生成器表达式)我们向_images StringVar提供数据,这会将所有图片名称填充到我们的_img_listbox中。最后,我们更新状态栏。

如果没有图片,我们仍然更新状态栏,并且在函数的末尾,无论找到多少图片,我们都更新config['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来实现这一点。这打开了一个漂亮的对话框,要求我们选择一个目录。我们选择的目录必须存在,因为我们调用方法的方式指定了这一点。这样做是为了我们不必编写处理保存文件时可能缺失目录的代码。

下面是在 Ubuntu 上这个对话框应该看起来是怎样的:

保存图片

如果我们取消操作,dirname将被设置为None

在完成save中的逻辑分析之前,让我们快速浏览一下_save_images

它与脚本中的版本非常相似,只需注意,在开始时,为了确保我们确实有事情要做,我们检查了dirnameconfig['images']中至少有一个图片的存在。

如果是这样,这意味着我们至少有一个图片需要保存及其路径,因此我们可以继续。保存图片的逻辑已经解释过了。这次我们唯一不同的地方是使用os.path.join将目录(即完整路径)与图片名称连接起来。在os.path模块中有很多有用的方法可以用来处理路径和文件名。

_save_images的末尾,如果我们至少保存了一个图片,我们将通知用户我们已经完成了。

现在让我们回到save函数中的另一个分支。当用户在按下Scrape按钮之前选择As JSON单选按钮时,这个分支会被执行。在这种情况下,我们想要保存一个文件;因此,我们不仅需要请求一个目录。我们希望用户能够选择一个文件名。因此,我们启动了一个不同的对话框:filedialog.asksaveasfilename

我们传递一个初始文件名,这个文件名被建议给用户,如果他们不喜欢,可以更改它。此外,因为我们正在保存 JSON 文件,所以我们通过传递filetypes参数强制用户使用正确的扩展名。它是一个包含任意数量 2 元组的列表(描述,扩展名),该列表运行对话框的逻辑。

下面是在 Ubuntu 上这个对话框应该看起来是怎样的:

保存图片

一旦我们选择了位置和文件名,我们就可以继续保存逻辑,这与之前的脚本中的逻辑相同。我们从一个 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 标签相关联。

另一方面,如果我们想向用户显示一个更明显的消息,我们可以弹出一个消息框。以下是在 Ubuntu 上的样子:

提醒用户

messagebox 对象还可以用来警告用户(messagebox.showwarning)或表示错误(messagebox.showerror)。但它也可以用来提供对话框,询问我们是否确实想要继续,或者是否真的想要删除那个文件,等等。

如果你通过简单地打印出 dir(messagebox) 返回的内容来检查 messagebox,你会找到像 askokcancelaskquestionaskretrycancelaskyesnoaskyesnocancel 这样的方法,以及一组用于验证用户响应的常量,如 CANCELNOOKOKCANCELYESYESNOCANCEL 等。你可以将这些与用户的选项进行比较,以便你知道在对话框关闭时执行的下一步操作。

如何改进应用程序?

现在你已经习惯了设计 GUI 应用程序的基础知识,我想给你一些建议,让你 ours 更好。

我们可以从代码质量开始。你认为这段代码足够好吗,或者你会改进它?如果是的话,你会怎么做?我会测试它,并确保它是健壮的,能够应对用户可能通过在应用程序中点击创建的所有各种场景。我还会确保当我们要抓取的网站因任何原因关闭时,行为是我预期的。

我们还可以改进的一点是命名。我谨慎地用前导下划线命名了所有组件,既是为了突出它们的某种“私有”性质,也是为了避免与它们所链接的底层对象发生名称冲突。但回顾起来,许多这些组件可能需要一个更好的名称,所以这完全取决于你,直到你找到最适合你的形式。你可以从给 _sb 函数起一个更好的名字开始!

关于用户界面,你可以尝试调整主应用程序的大小。看看会发生什么?整个内容都保持在原地。如果你扩展,会添加空空间;如果你缩小,整个小部件集会逐渐消失。这种行为并不完美,因此一个快速的解决方案可能是使根窗口固定(即无法调整大小)。

你还可以通过添加我们在脚本中已有的相同功能来改进应用程序,即只保存 PNG 或 JPG 文件。为了做到这一点,你可以在某个地方放置一个组合框,有三个值:所有、PNGs、JPGs 或类似的内容。在保存文件之前,用户应该能够选择这些选项之一。

更好的是,你可以更改 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 世界,那么我想给你以下建议。

tkinter.tix 模块

探索 tkinter 及其主题小部件集 tkinter.ttk 将需要一些时间。有很多东西可以学习和玩耍。当你熟悉这项技术时,另一个有趣的模块是 tkinter.tix

tkinter.tixTk 接口扩展)模块提供了一组非常丰富的额外小部件。它们的需求源于标准 Tk 库中的小部件远非完整。

tkinter.tix 库允许我们通过提供 HList、ComboBox、Control(或 SpinBox)以及各种可滚动小部件等小部件来解决此问题。总共有超过 40 个小部件。它们允许你将不同的交互技术和范例引入你的应用程序中,从而提高其质量和可用性。

海龟模块

turtle 模块是对 Python 标准分布中同名模块的扩展实现,直到 Python 2.5 版本。它是一种非常流行的向儿童介绍编程的方式。

它基于一个想象中的海龟在笛卡尔平面上从 (0, 0) 点开始的概念。你可以通过编程命令海龟前进和后退,旋转等。通过组合所有可能的移动,可以绘制出各种复杂的形状和图像。

它绝对值得一看,只是为了看看一些不同的东西。

wxPython、PyQt 和 PyGTK

在你已经探索了tkinter领域的广阔天地之后,我建议你探索其他的 GUI 库:wxPythonPyQtPyGTK。你可能会发现其中之一更适合你,或者它会使你编写所需的应用程序变得更加容易。

我认为,只有当程序员意识到他们有哪些可用的工具时,他们才能实现他们的想法。如果你的工具集太窄,你的想法可能看起来不可能实现,或者极其难以实现,它们的风险就是保持原样,仅仅是想法。

当然,今天的科技领域非常庞大,因此不可能知道一切;因此,当你即将学习一项新技术或新主题时,我的建议是通过广泛探索来增长你的知识。

对几件事情进行不太深入的调查研究,然后对看起来最有希望的一个或几个进行深入研究。这样你将能够至少使用一个工具来提高生产力,当这个工具不再满足你的需求时,你将知道在哪里进行更深入的挖掘,这要归功于你之前的探索。

最小惊讶原则

在设计界面时,有许多不同的事情需要考虑。其中之一,对我来说是最重要的,就是“最小惊讶原则”。它基本上表明,如果你的设计中一个必要的功能具有很高的惊讶因素,那么可能需要重新设计你的应用程序。举一个例子,当你习惯于在 Windows 上工作,其中最小化、最大化窗口和关闭窗口的按钮位于右上角时,在 Linux 上工作会相当困难,因为它们位于左上角。你会发现你不断地走向右上角,只是为了再次发现按钮在另一边。

如果某个按钮在应用程序中变得如此重要,以至于设计师将其放置在精确的位置,请不要创新。只需遵循惯例。当用户不得不浪费时间寻找本应存在的按钮时,他们只会感到沮丧。

忽视这条规则是为什么我无法使用像 Jira 这样的产品的原因。做一些简单的事情需要我花费几分钟,而这些事情本应只需要几秒钟。

线程考虑事项

这个主题超出了这样一本入门书的范围,但我确实想提一下。简单来说,执行线程是能够被调度器独立管理的最小程序指令序列。我们之所以有现代计算机可以同时做很多事情的感知,不仅仅是因为它们有多个处理器。它们还将工作细分到不同的线程中,然后按顺序处理这些线程。如果它们的生命周期足够短,线程可以一次性处理,但通常情况下,操作系统会先在一个线程上工作一段时间,然后切换到另一个线程,然后又切换到另一个线程,然后回到第一个线程,如此循环。它们被处理的顺序取决于不同的因素。最终结果是,因为计算机在切换上的速度非常快,所以我们感知到许多事情同时发生。

如果你正在编写一个当按钮被点击时需要执行长时间运行操作的 GUI 应用程序,你会发现你的应用程序可能会在操作执行完毕之前冻结。为了避免这种情况,并保持应用程序的响应性,你可能需要在不同的线程中运行这个耗时操作,这样操作系统就可以时不时地为 GUI 分配一点时间,以保持其响应性。

线程是一个高级话题,尤其是在 Python 中。首先掌握基础知识,然后尽情探索它们吧!

摘要

在本章中,我们一起完成了一个项目。我们编写了一个脚本,它可以抓取一个非常简单的网页,并接受可选的命令来改变其行为。我们还编写了一个 GUI 应用程序,通过点击按钮而不是在控制台输入来执行相同的功能。我希望你阅读它并跟随我的思路,就像我写作时一样享受。

我们看到了许多不同的概念,如上下文管理器、文件操作、执行 HTTP 请求,我们还讨论了可用性和设计的指导原则。

我只能触及表面,但希望你能从中获得一个良好的起点,以便进一步拓展你的探索。

在本章中,我以几种不同的方式指导你如何改进应用程序,并挑战你完成一些练习和问题。我希望你已经花时间尝试了那些想法。仅仅通过玩一些有趣的应用程序,比如我们一起编写的应用程序,就能学到很多东西。

在下一章中,我们将讨论数据科学,或者至少是当 Python 程序员面对这个主题时所拥有的工具。

第九章:数据科学

*"如果我们有数据,让我们看看数据。如果我们只有意见,那就听我的。"
--吉姆·巴克斯代尔,前网景 CEO

数据科学是一个非常广泛的概念,根据上下文、理解、工具等因素,它可以有多种不同的含义。关于这个主题有无数本书籍,这对胆小的人来说并不适合。

为了进行适当的数据科学,你至少需要了解数学和统计学。然后,你可能想深入研究其他主题,如模式识别和机器学习,当然,你有很多语言和工具可以选择。

除非我在接下来的几分钟内变成神奇的法布里齐奥,否则我无法谈论所有的事情;我甚至无法接近它。因此,为了使这一章节有意义,我们将一起进行一个有趣的项目。

大约 3 年前,我在伦敦的一家顶级社交媒体公司工作。我在那里待了 2 年,我有幸与几位我只能说初窥其才华的人一起工作。我们是世界上第一个能够访问 Twitter Ads API 的人,我们也是 Facebook 的合作伙伴。这意味着有很多数据。

我们的分析师正在处理大量的活动,他们正在努力应对他们必须完成的工作量,所以我所在的开发团队试图通过向他们介绍 Python 和 Python 提供的数据处理工具来帮助他们。这是一段非常有趣的旅程,它引导我成为公司中几个人的导师,并最终到了马尼拉,在那里,我花了 2 个星期的时间对那里的分析师进行了密集的 Python 和数据科学培训。

在这一章中,我们将一起做的项目是我在马尼拉学生面前展示的最终示例的轻量级版本。我将其重写到一个适合这一章的大小,并对教学目的进行了一些调整,但所有主要概念都在那里,所以你应该会享受编码并从中学习。

在我们的旅途中,我们将遇到一些你可以在 Python 生态系统中发现的数据处理工具,所以让我们先从罗马神祇开始谈。

IPython 和 Jupyter 笔记本

2001 年,费尔南多·佩雷斯是科罗拉多大学博尔德分校的物理学研究生,他试图改进 Python shell,以便他能够拥有一些他习惯于在像 Mathematica 和 Maple 这样的工具工作时的一些便利性。这项努力的成果被命名为IPython

简而言之,那个小脚本最初是 Python shell 的一个增强版本,通过其他编码者的努力,以及几家不同公司的适当资助,它最终成为了今天这个美妙且成功的项目。在其诞生后的大约 10 年后,一个笔记本环境被创建,它由 WebSocket、Tornado 网络服务器、jQuery、CodeMirror 和 MathJax 等技术驱动。ZeroMQ 库也被用来处理笔记本界面和背后的 Python 核心之间的消息。

IPython 笔记本变得如此受欢迎和广泛使用,以至于最终,各种功能都被添加到了它上面。它可以处理小部件、并行计算、各种媒体格式等等。此外,在某个时刻,从笔记本内部使用除 Python 以外的语言进行编码成为可能。

这导致了这样一个巨大的项目,最近才被拆分为两个部分:IPython 被精简以更多地关注内核和 shell 部分,而笔记本则成为了一个全新的项目,名为Jupyter。Jupyter 允许在 40 多种语言中进行交互式科学计算。

本章的项目都将使用 Jupyter 笔记本进行编码和运行,所以让我用几句话来解释一下什么是笔记本。

笔记本环境是一个网页,它暴露了一个简单的菜单和你可以运行 Python 代码的单元格。尽管单元格是独立的实体,你可以单独运行它们,但它们都共享同一个 Python 内核。这意味着你在单元格中定义的所有名称(变量、函数等)都将可在任何其他单元格中使用。

注意

简而言之,Python 内核就是一个 Python 正在运行的进程。因此,笔记本网页就是提供给用户的一个界面,用于驱动这个内核。网页通过一个非常快速的消息系统与之通信。

除了所有图形优势之外,拥有这样一个环境的美妙之处在于能够分块运行 Python 脚本,这可以是一个巨大的优势。以一个连接数据库以获取数据并处理这些数据的脚本为例。如果你用传统的 Python 脚本方式来做,每次你想对其进行实验时都必须重新获取数据。在笔记本环境中,你可以在一个单元格中获取数据,然后在其他单元格中对其进行处理和实验,因此不需要每次都获取数据。

笔记本环境对于数据科学也非常有帮助,因为它允许逐步进行内省。你完成一块工作后验证它。然后你做另一块工作并再次验证,依此类推。

它对于原型设计也非常有价值,因为结果就在你眼前,立即可用。

如果你想了解更多关于这些工具的信息,请查看ipython.org/jupyter.org/

我创建了一个非常简单的示例笔记本,其中包含一个fibonacci函数,该函数可以给出小于给定N的所有斐波那契数的列表。在我的浏览器中,它看起来像这样:

IPython 和 Jupyter 笔记本

每个单元格都有一个In []标签。如果花括号内没有内容,这意味着该单元格从未被执行过。如果有数字,这意味着该单元格已被执行,数字代表单元格被执行的顺序。最后,一个*****表示该单元格目前正在被执行。

你可以从图片中看到,在第一个单元格中,我定义了fibonacci函数,并且已经执行了它。这会将fibonacci名称放入与笔记本关联的全局框架中,因此fibonacci函数现在对其他单元格也是可用的。实际上,在第二个单元格中,我可以运行fibonacci(100)并在Out [2]中看到结果。在第三个单元格中,我向你展示了笔记本中可以找到的几个魔法函数之一。%timeit会多次运行代码,并为你提供一个很好的基准。我在第五章“节省时间和内存”中进行的所有列表推导和生成器的测量都是使用这个很好的功能完成的。

你可以多次执行一个单元格,并改变它们的执行顺序。单元格非常灵活,你还可以在其中放入 Markdown 文本或将其渲染为标题。

注意

Markdown是一种轻量级标记语言,具有纯文本格式化语法,旨在能够将其转换为 HTML 和其他格式。

此外,无论你在单元格的最后一行放置什么内容,它都会自动为你打印出来。这非常方便,因为你不必明确地写print(...)

随意探索笔记本环境;一旦你成为它的朋友,这将是一段持久的友谊,我保证。

为了运行笔记本,你必须安装一些库,每个库都与其他库协作以使整个系统工作。或者,你只需安装 Jupyter,它将为你处理一切。对于本章,我们还需要安装一些其他依赖项,所以请运行以下命令:

$ pip install jupyter pandas matplotlib fake-factory delorean xlwt

别担心,我会逐渐向你介绍这些内容。现在,当你安装完这些库(可能需要几分钟)后,你就可以开始使用笔记本了:

$ jupyter notebook

这将在你的浏览器中打开一个页面,地址为:http://localhost:8888/

前往该页面并使用菜单创建一个新的笔记本。当你熟悉它时,我们就准备好了。

小贴士

如果你设置笔记本环境时遇到任何问题,请不要气馁。如果你遇到错误,通常只是在网上搜索一下,你最终会找到其他人遇到相同问题并解释如何修复它的页面。在你继续本章之前,尽力让笔记本环境运行起来。

由于我们的项目将在笔记本中进行,因此我将为每个代码片段标记所属的单元格编号,这样你就可以轻松地重现代码并跟随。

小贴士

如果你熟悉键盘快捷键(在笔记本的帮助部分查看),你将能够在单元格之间移动并处理它们的内容,而无需伸手去拿鼠标。这将使你在笔记本中工作时更加熟练,速度也会更快。

处理数据

通常,当你处理数据时,这是你经过的路径:你获取它,清理和操作它,然后检查它,并以值、电子表格、图表等形式展示结果。我希望你负责整个过程的三个步骤,而不依赖于任何外部数据提供者,所以我们将做以下事情:

  1. 我们将创建数据,模拟它以不完美或未准备好工作的格式出现。

  2. 我们将清理它,并将其提供给我们在项目中使用的核心工具:pandasDataFrame

  3. 我们将操作 DataFrame 中的数据。

  4. 我们将把 DataFrame 保存到不同格式的文件中。

  5. 最后,我们将检查数据并从中获取一些结果。

设置笔记本

首先,我们需要设置笔记本。这意味着导入和一些配置。

#1

import json
import calendar
import random
from datetime import date, timedelta

import faker
import numpy as np
from pandas import DataFrame
from delorean import parse
import pandas as pd

# make the graphs nicer
pd.set_option('display.mpl_style', 'default')

单元格 #1 负责导入。这里有很多新事物:calendarrandomdatetime 模块是标准库的一部分。它们的名字具有自解释性,所以让我们看看 fakerfake-factory 库提供了这个模块,你可以使用它来准备假数据。在测试中,当你准备你的固定数据时,它非常有用,可以获取各种东西,如姓名、电子邮件地址、电话号码、信用卡详情等等。当然,这些都是假的。

numpy 是 NumPy 库,是 Python 科学计算的基础包。我将在本章后面简要介绍它。

pandas 是整个项目的基础核心。它代表 Python 数据分析库。在许多其他功能中,它提供了 DataFrame,这是一种具有高级处理能力的类似矩阵的数据结构。通常,我们会单独导入 DataFrame,然后执行 import pandas as pd

delorean 是一个很好的第三方库,可以显著加快处理日期的速度。技术上,我们可以使用标准库来完成,但我看不出为什么要限制示例的范围,所以我会展示一些不同的事情。

最后,我们在最后一行有一个指令,这将使我们的最终图表看起来更美观,这不会有什么坏处。

准备数据

我们希望达到以下数据结构:我们将有一个用户对象的列表。每个用户对象都将链接到一定数量的活动对象。

在 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 个唯一的用户名,所以我遍历 usernames 集合的长度,直到它有 1,000 个元素。集合不允许重复元素,因此保证了唯一性。

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

在这里,我们创建一个用户列表。每个 username 现在已经被扩展为一个完整的 user 字典,包括其他细节,如姓名、性别、电子邮件等。然后,每个 user 字典被转换为 JSON 并添加到列表中。这种数据结构当然不是最优的,但我们正在模拟用户以这种方式来到我们这里的场景。

注意 random.random() 的倾斜使用,以使 60% 的用户为女性。其余的逻辑应该很容易理解。

注意最后一行。每个单元格会自动打印最后一行上的内容;因此,这个输出是一个包含前三个用户的列表:

Out #4

['{"gender": "F", "age": 48, "email": "jovani.dickinson@gmail.com", "address": "2006 Sawayn Trail Apt. 207\\nHyattview, MO 27278", "username": "darcy00", "name": "Virgia Hilpert"}',
 '{"gender": "F", "age": 58, "email": "veum.javen@hotmail.com", "address": "5176 Andres Plains Apt. 040\\nLakinside, GA 92446", "username": "renner.virgie", "name": "Miss Clarabelle Kertzmann MD"}',
 '{"gender": "M", "age": 33, "email": "turner.felton@rippin.com", "address": "1218 Jacobson Fort\\nNorth Doctor, OK 04469", "username": "hettinger.alphonsus", "name": "Ludwig Prosacco"}']

注意

希望你在自己的笔记本上也能跟着做。如果你做了,请注意,所有数据都是使用随机函数和值生成的;因此,你会看到不同的结果。每次执行笔记本时,它们都会改变。

#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 = separator.join(get_start_end_dates())
    age = get_age()
    gender = get_gender()
    currency = get_currency()
    return separator.join(
        (type_, start_end, age, gender, currency))

#5 中,我们定义了生成活动名称的逻辑。分析师经常使用电子表格,他们会想出各种编码技术,尽可能将尽可能多的信息压缩到活动名称中。我选择的是这种技术的简单示例:有一个代码表示活动类型,然后是开始和结束日期,然后是目标年龄和性别,最后是货币。所有值都由下划线分隔。

get_type 函数中,我使用 random.choice() 从一个集合中随机获取一个值。可能更有趣的是 get_start_end_dates。首先,我获取活动的持续时间,从 1 天到 2 年(随机),然后我获取一个随机的时间偏移量,我从今天的日期中减去它以获取开始日期。考虑到偏移量是一个介于 -365 和 365 之间的随机数,如果我将它加到今天的日期上而不是减去,会有什么不同吗?

当我有了开始和结束日期,我会返回它们的字符串化版本,并用下划线连接。

然后,我们在年龄计算中做了一些模运算技巧。我希望您还记得第二章内置数据类型中提到的模运算符(%)。

这里发生的事情是我想要一个具有 5 的倍数为端点的日期范围。所以有很多方法可以做到这一点,但我所做的是获取一个介于 20 和 45 之间的随机数作为左端点,并去除除以 5 的余数。例如,如果得到 28,我会从 28 中去除28 % 5 = 3,得到 25。我本可以使用random.randrange(),但很难抗拒模除运算。

其余的函数只是random.choice()的一些其他应用,最后一个函数get_campaign_name不过是一个收集所有这些拼图碎片并返回最终活动名称的收集器。

#6

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是活动从其源获取作为资源的次数,无论在活动中执行了多少点击。通常,展示次数要多于点击次数。

现在我们有了数据,是时候将它们全部组合在一起了:

#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中的每个项目都是一个包含用户和与该用户关联的活动列表的字典。

清理数据

让我们开始清理数据:

#8

rough_data = get_data(users)
rough_data[:2]  # let's take a peek

我们模拟从源数据中获取数据并对其进行检查。笔记本是检查您步骤的完美工具。您可以根据需要调整粒度。rough_data中的第一个项目看起来是这样的:

[{'campaigns': [{'cmp_bgt': 130532,
 'cmp_clicks': 25576,
 'cmp_impr': 500001,
 'cmp_name': 'AKX_20150826_20170305_35-50_B_EUR',
 'cmp_spent': 57574},
 ... omit ...
 {'cmp_bgt': 884396,
 'cmp_clicks': 10955,
 'cmp_impr': 499999,
 'cmp_name': 'KTR_20151227_20151231_45-55_B_GBP',
 'cmp_spent': 318887}],
 'user': '{"age": 44, "username": "jacob43",
 "name": "Holland Strosin",
 "email": "humberto.leuschke@brakus.com",
 "address": "1038 Runolfsdottir Parks\\nElmapo...",
 "gender": "M"}'}]

因此,我们现在开始处理它。

#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

为了能够将此数据输入到 DataFrame 中,我们首先需要对其进行反规范化。这意味着将数据转换成一个列表,其项目是包含其相对用户字典的活动字典。用户将在他们所属的每个活动中重复。data中的第一个项目看起来是这样的:

[{'cmp_bgt': 130532,
 'cmp_clicks': 25576,
 'cmp_impr': 500001,
 'cmp_name': 'AKX_20150826_20170305_35-50_B_EUR',
 'cmp_spent': 57574,
 'user': '{"age": 44, "username": "jacob43",
 "name": "Holland Strosin",
 "email": "humberto.leuschke@brakus.com",
 "address": "1038 Runolfsdottir Parks\\nElmaport...",
 "gender": "M"}'}]

您可以看到用户对象已经被引入到活动字典中,并且为每个活动重复。

创建 DataFrame

现在是时候创建DataFrame了:

#10

df = DataFrame(data)
df.head()

最后,我们将创建DataFrame并使用head方法检查前五行。你应该会看到类似以下的内容:

创建 DataFrame

Jupyter 会自动将df.head()调用的输出渲染为 HTML。为了得到基于文本的输出,只需将df.head()包裹在一个print调用中即可。

DataFrame结构非常强大。它允许我们对内容进行大量操作。你可以按行或列进行过滤,对数据进行聚合,以及许多其他操作。你可以操作行或列,而无需支付如果你使用纯 Python 处理数据时必须支付的时间惩罚。这是因为,在幕后,pandas正在利用 NumPy 库的力量,而 NumPy 本身则从其核心的低级实现中获得了惊人的速度。NumPy 代表Numeric Python,它是数据科学环境中使用最广泛的库之一。

使用DataFrame允许我们将 NumPy 的力量与类似电子表格的功能相结合,这样我们就可以以类似于分析师可以做到的方式处理我们的数据。只是,我们用代码来做。

但让我们回到我们的项目。让我们看看两种快速获取数据概览的方法:

#11

df.count()

count返回每列中所有非空单元格的计数。这有助于你了解你的数据可以有多稀疏。在我们的例子中,我们没有缺失值,所以输出如下:

cmp_bgt       4974
cmp_clicks    4974
cmp_impr      4974
cmp_name      4974
cmp_spent     4974
user          4974
dtype: int64

好棒!我们有 4,974 行数据,数据类型是整数(dtype: int64表示长整数,因为它们每个占用 64 位)。考虑到我们有 1,000 个用户,每个用户的营销活动数量是一个介于 2 到 8 之间的随机数,我们正好符合我预期的结果。

#12

df.describe()

describe是一个快速且方便的方法来进一步检查:

 cmp_bgt    cmp_clicks       cmp_impr      cmp_spent
count    4974.000000   4974.000000    4974.000000    4974.000000
mean   503272.706876  40225.764978  499999.495979  251150.604343
std    289393.747465  21910.631950       2.035355  220347.594377
min      1250.000000    609.000000  499992.000000     142.000000
25%    253647.500000  22720.750000  499998.000000   67526.750000
50%    508341.000000  36561.500000  500000.000000  187833.000000
75%    757078.250000  55962.750000  500001.000000  385803.750000
max    999631.000000  98767.000000  500006.000000  982716.000000

如你所见,它给出了几个度量,如count(计数)、mean(平均值)、std(标准差)、min(最小值)、max(最大值),并显示了数据在各个象限中的分布情况。多亏了这个方法,我们已经有了一个关于数据结构的大致了解。

让我们看看预算最高和最低的三个营销活动:

#13

df.sort_index(by=['cmp_bgt'], ascending=False).head(3)

这给出了以下输出(已截断):

 cmp_bgt  cmp_clicks  cmp_impr                  cmp_name
4655   999631       15343    499997  AKX_20160814_20180226_40
3708   999606       45367    499997  KTR_20150523_20150527_35
1995   999445       12580    499998  AKX_20141102_20151009_30

以及(#14)调用.tail(3),显示了预算最低的几个。

解包营销活动名称

现在是时候稍微增加一点复杂性了。首先,我们想要摆脱那个糟糕的营销活动名称(cmp_name)。我们需要将其分解成部分,并将每个部分放入一个专门的列中。为了做到这一点,我们将使用Series对象的apply方法。

pandas.core.series.Series类基本上是一个围绕数组的强大包装器(将其视为具有增强功能的列表)。我们可以通过以字典中键的方式访问它来从DataFrame中提取一个Series对象,并且可以在该Series对象上调用apply,这将运行一个函数,将Series中的每个项目作为参数传递给它。我们将结果组合成一个新的DataFrame,然后将其与我们的df合并。

#15

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 = parse(start).date
    end = parse(end).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分割成几个部分。我们使用delorean.parse()从这些字符串中获取适当的日期对象(delorean使这个过程变得非常简单,不是吗?),然后返回这些对象。快速查看最后一行可以发现:

 Type       Start         End    Age Gender Currency
0  KTR  2016-06-16  2017-01-24  20-30      M      EUR
1  BYU  2014-10-25  2015-07-31  35-50      B      USD
2  BYU  2015-10-26  2016-03-17  35-50      M      EUR

很好!有一点很重要:即使日期以字符串的形式出现,它们也只是DataFrame中托管的真实date对象的表示。

另一个非常重要的事情是:在连接两个DataFrame实例时,它们必须具有相同的索引,否则pandas将无法知道哪些行与哪些行对应。因此,当我们创建campaign_df时,我们将它的索引设置为df中的索引。这使得我们可以将它们连接起来。在创建此 DataFrame 时,我们还传递了列名。

#16

df = df.join(campaign_df)

在连接之后,我们进行快速查看,希望看到匹配的数据(输出被截断):

#17

df[['cmp_name'] + campaign_cols].head(3)

得到:

 cmp_name Type       Start         End
0  KTR_20160616_20170124_20-30_M_EUR  KTR  2016-06-16  2017-01-24
1  BYU_20141025_20150731_35-50_B_USD  BYU  2014-10-25  2015-07-31
2  BYU_20151026_20160317_35-50_M_EUR  BYU  2015-10-26  2016-03-17

如您所见,连接操作成功;活动名称和单独的列显示了相同的数据。您是否看到了我们在这里做了什么?我们正在使用方括号语法访问DataFrame,并传递一个列名列表。这将生成一个新的DataFrame,包含那些列(按相同顺序),然后我们调用head()

解包用户数据

现在,我们对每个user JSON 数据片段执行完全相同的事情。我们对user Series调用apply,运行unpack_user_json函数,该函数接受一个 JSON user对象并将其转换为字段列表,然后我们可以将其注入到一个全新的 DataFrame user_df中。之后,我们将user_dfdf合并,就像我们之前对campaign_df所做的那样。

#18

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关于列名和,非常重要,索引。让我们进行连接(#19)并快速查看(#20):

df = df.join(user_df)
df[['user'] + user_cols].head(2)

输出显示一切顺利。我们做得很好,但还没有完成。

如果在单元格中调用df.columns,您会看到我们列的名称仍然很丑陋。让我们来改变一下:

#21

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 不是。简单来说,它是点击和展示的比率。它衡量了每次展示中点击活动广告的次数:这个数字越高,广告在吸引用户点击方面就越成功。

#22

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会自动(在这种情况下是除法)对适当列的每一对单元格执行操作。所以,即使它们被标记为三个除法,实际上也是4974 * 3次除法,因为它们是针对每一行执行的。Pandas 为我们做了很多工作,同时也很好地隐藏了它的复杂性。

函数calculate_extra_columns接受一个DataFrame,并直接在其上工作。这种操作模式被称为原地。你还记得list.sort()是如何排序列表的吗?情况是一样的。

我们可以通过筛选相关列并调用head来查看结果。

#23

df[['Spent', 'Clicks', 'Impressions',
    'CTR', 'CPC', 'CPI']].head(3)

这表明每个行的计算都是正确的:

 Spent  Clicks  Impressions       CTR       CPC       CPI
0   57574   25576       500001  0.051152  2.251095  0.115148
1  226319   61247       499999  0.122494  3.695185  0.452639
2    4354   15582       500004  0.031164  0.279425  0.008708

现在,我想手动验证第一行的结果准确性:

#24

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.0511518976962 0.0511518976962
CPC: 2.25109477635 2.25109477635
CPI: 0.115147769704 0.115147769704

这正是我们在之前的输出中看到的内容。当然,我通常不需要这样做,但我想展示你可以这样进行计算。你可以通过将名称传递给DataFrame(在方括号中),然后通过其位置访问每个行,就像你使用常规列表或元组一样,来访问一个序列(一个列)。

我们几乎完成了我们的DataFrame。我们现在缺少的是告诉我们活动持续时间的列和告诉我们每个活动开始日期对应星期几的列。这让我可以进一步讲解如何操作日期对象。

#25

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接受一个日期对象。如果你不能理解它是做什么的,请在阅读解释之前花几分钟时间尝试自己理解。使用我们之前几次使用过的内外技术。

所以,正如你现在可能已经知道的,如果你在list调用中放入calendar.day_name,你会得到['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']。这意味着,如果我们从 1 开始枚举calendar.day_name,我们会得到如(1, 'Monday')(2, 'Tuesday')等对。如果我们将这些对输入到一个字典中,我们就会得到一周中天数(1、2、3、...)和它们名称之间的映射。当映射创建后,为了得到一个天的名称,我们只需要知道它的数字。为了得到它,我们调用date.isoweekday(),它告诉我们这个日期是星期几(作为一个数字)。你将这个数字输入到映射中,然后,砰!你就得到了这个天的名称。

get_duration同样有趣。首先,注意它接受整个行,而不仅仅是单个值。在其主体中发生的事情是我们执行活动结束日期和开始日期之间的减法。当你从日期对象中减去时,结果是timedelta对象,它表示给定的时间量。我们取其.days属性的值。就这么简单。

现在,我们可以介绍有趣的部分,即这两个函数的应用。

第一个应用是在一个Series对象上执行的,就像我们之前对'user''cmp_name'所做的那样,这里没有什么新的。

第二个操作应用于整个 DataFrame,为了指示 Pandas 在行上执行该操作,我们传递axis=1

我们可以很容易地验证结果,如下所示:

#26

df[['Start', 'End', 'Duration', 'Day of Week']].head(3)

Yields:

 Start         End  Duration Day of Week
0  2015-08-26  2017-03-05       557   Wednesday
1  2014-10-15  2014-12-19        65   Wednesday
2  2015-02-22  2016-01-14       326      Sunday

因此,我们现在知道,从 2015 年 8 月 26 日到 2017 年 3 月 5 日之间有 557 天,而且 2015 年 8 月 26 日是一个星期三。

如果你想知道这个的目的,我会提供一个例子。想象一下,你有一个与通常在星期日举行的一项体育赛事相关的活动。你可能想根据天数检查你的数据,以便你可以将它们与你的各种测量数据相关联。我们不会在这个项目中这样做,但看到不同的方式调用 DataFrame 上的apply()是有用的。

清理一切

现在我们已经拥有了我们想要的一切,是时候进行最后的清理了:记住我们仍然有'cmp_name''user'列。这些现在都是无用的,所以它们必须被移除。此外,我想重新排列 DataFrame 中的列,使其与现在包含的数据更加相关。为了做到这一点,我们只需要根据我们想要的列列表过滤df。我们将得到一个新的 DataFrame,我们可以将其重新分配给df本身。

#27

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)、JSON 和 Excel 电子表格。

#28

df.to_csv('df.csv')

#29

df.to_json('df.json')

#30

df.to_excel('df.xls')

CSV 文件看起来像这样(输出已截断):

Type,Start,End,Duration,Day of Week,Budget,Currency,Clicks,Impres
0,GRZ,2015-03-15,2015-11-10,240,Sunday,622551,GBP,35018,500002,787
1,AKX,2016-06-19,2016-09-19,92,Sunday,148219,EUR,45185,499997,6588
2,BYU,2014-09-25,2016-07-03,647,Thursday,537760,GBP,55771,500001,3

而像这样的 JSON 格式(再次,输出已截断):

{
 "Type": {
 "0": "GRZ",
 "1": "AKX",
 "2": "BYU",

因此,将 DataFrame 保存为多种格式非常简单,好消息是相反的情况也是真的:将电子表格加载到 DataFrame 中也非常容易。Pandas 背后的程序员走得很远,简化了我们的任务,这是我们应该感激的。

可视化结果

最后,是精华部分。在本节中,我们将可视化一些结果。从数据科学的角度来看,我对深入分析并不感兴趣,特别是因为数据是完全随机的,但无论如何,这段代码将帮助你开始使用图表和其他功能。

我在生活中学到的一件事——这可能会让你感到惊讶——是外表也很重要,所以当你展示你的结果时,你尽最大努力使它们看起来很漂亮。

我不会试图向你证明最后一个陈述的真实性,但我确实相信它。如果你还记得单元格 #1 的最后一行:

# make the graphs nicer
pd.set_option('display.mpl_style', 'default')

它的目的是让我们在本节中查看的图表看起来更漂亮一些。

好吧,首先,我们必须指示笔记本我们想要使用 matplotlib inline。这意味着当我们要求 Pandas 绘制某些内容时,结果将在单元格输出框架中渲染。为了做到这一点,我们只需要一个简单的指令:

#31

%matplotlib inline

你也可以通过传递一个参数来指示笔记本在从控制台启动时执行此操作,但我还想展示这种方法,因为仅仅因为你想绘制一些东西而不得不重新启动笔记本可能会很烦人。这样,你就可以即时操作,然后继续工作。

接下来,我们将对 pylab 设置一些参数。这是为了绘图目的,它将消除一个字体未找到的警告。我建议你不要执行这一行,继续前进。如果你收到一个字体缺失的警告,请回到这个单元格并运行它。

#32

import pylab
pylab.rcParams.update({'font.family' : 'serif'})

这基本上是告诉 Pylab 使用第一个可用的衬线字体。它简单但有效,你还可以尝试其他字体。

现在 DataFrame 已经完整,让我们再次运行 df.describe() (#33)。结果应该看起来像这样:

可视化结果

这种快速的结果非常适合那些只有 20 秒时间给你并且只想得到粗略数字的经理。

注意

再次提醒,请注意,我们的活动有不同的货币,所以这些数字实际上是没有意义的。这里的目的是展示 DataFrame 的能力,而不是对真实数据进行正确或详细的分析。

另外,图表通常比数字表格要好得多,因为它更容易阅读,并且能立即给出反馈。所以,让我们绘制出我们每个活动拥有的四条信息:预算、花费、点击和印象。

#34

df[['Budget', 'Spent', 'Clicks', 'Impressions']].hist(
    bins=16, figsize=(16, 6));

我们外推这四列(这将给我们另一个只包含这些列的 DataFrame)并对其调用hist()方法。我们对桶和图形大小给出一些测量,但基本上一切都是自动完成的。

一件重要的事情:由于这个指令是这个单元格中唯一的(这也意味着,它是最后一个),笔记本会在绘制图表之前打印出其结果。为了抑制这种行为,只绘制图表而不打印,只需在末尾添加一个分号(你以为我在回忆 Java,不是吗?)。以下是图表:

可视化结果

它们很漂亮,不是吗?你注意到衬线字体了吗?那些数字的意义是什么?如果你回到#6并看看我们生成数据的方式,你会发现所有这些图表都很有意义。

预算是一个区间内的随机整数,因此我们预期的是一个均匀分布,看那里;它几乎是一条恒定的线。

花费也是一个均匀分布,但它的区间高端是预算,这是在变化的,这意味着我们应该预期一个向右递减的二次抛物线。看那里,它也是这样的。

点击是通过一个平均大约为区间大小 20%的三角分布生成的,你可以看到峰值就在那里,大约在左侧 20%的位置。

最后,印象是一个高斯分布,这是假设著名的钟形曲线的那个分布。平均值正好在中间,我们的标准差是 2。你可以看到图表与这些参数相匹配。

好的!让我们绘制出我们计算出的度量值:

#35

df[['CTR', 'CPC', 'CPI']].hist(
    bins=20, figsize=(16, 6));

可视化结果

我们可以看到每点击成本高度偏左,这意味着大多数的 CPC 值都非常低。每印象成本也有类似的形状,但不太极端。

现在,所有这些都很好,但如果你只想分析数据的一个特定部分,你会怎么做?我们可以对一个 DataFrame 应用一个掩码,这样我们就会得到另一个只包含满足掩码条件的行的 DataFrame。这就像应用一个全局的行if子句。

#36

mask = (df.Spent > 0.75 * df.Budget)
df[mask][['Budget', 'Spent', 'Clicks', 'Impressions']].hist(
    bins=15, figsize=(16, 6), color='g');

在这种情况下,我准备了一个面具来过滤掉所有花费小于或等于预算 75%的行。换句话说,我们只包括那些至少花费了预算四分之三的活动。注意,在我向你展示的面具中,我展示了请求 DataFrame 列的另一种方式,即使用直接属性访问(object.property_name),而不是类似字典的访问(object['property_name'])。如果property_name是一个有效的 Python 名称,你可以互换使用这两种方式(JavaScript 也是这样工作的)。

面具的运用方式与我们使用键访问字典的方式相同。当你对一个 DataFrame 应用一个面具时,你会得到另一个 DataFrame,我们只选择这个 DataFrame 上的相关列,并再次调用hist()。这次,为了好玩,我们希望结果被涂成绿色:

可视化结果

注意到图形的形状变化不大,除了花费,它相当不同。这是因为我们只请求了花费至少为预算 75%的行。这意味着我们只包括花费接近预算的行。预算数字来自均匀分布。因此,现在花费呈现出这种形状是很明显的。如果你将边界设置得更紧,要求 85%或更多,你会看到花费越来越接近预算。

现在我们来请求一些不同的事情。关于按星期分组的花费、点击和展示量怎么样?

#37

df_weekday = df.groupby(['Day of Week']).sum()
df_weekday[['Impressions', 'Spent', 'Clicks']].plot(
    figsize=(16, 6), subplots=True);

第一行通过在df上按'Day of Week'进行分组来创建一个新的DataFrame,名为df_weekday。用于聚合数据的功能是加法。

第二行使用列名列表从df_weekday中获取一个切片,这是我们现在已经习惯的做法。在结果上我们调用plot(),这与hist()有点不同。选项subplots=True使得plot绘制三个独立的图表:

可视化结果

令人惊讶的是,我们可以看到大部分活动都发生在星期四。如果这是有意义的资料,这可能会成为提供给客户的重要信息,这也是我向你展示这个例子的原因。

注意到天是按字母顺序排序的,这有点打乱了它们的顺序。你能想到一个快速解决问题的方案吗?我会把它留给你作为一个练习,来想出一种方法。

让我们用几件事情来结束这个演示部分。首先是一个简单的聚合。我们想要对'Target Gender''Target Age'进行聚合,并显示'Impressions''Spent'。对于两者,我们想要看到平均值和标准差。

#38

agg_config = {
    'Impressions': {
        'Mean Impr': 'mean',
        'Std Impr': 'std',
    },
    'Spent': ['mean', 'std'],
}

df.groupby(['Target Gender', 'Target Age']).agg(agg_config)

做这件事非常简单。我们将准备一个字典,我们将用它作为配置。我向你展示了两种做这件事的方法。对于'Impressions',我们使用更漂亮的格式,其中我们传递一个嵌套字典,以描述/函数作为键/值对。另一方面,对于'Spent',我们只使用一个简单的列表,只包含函数名。

然后,我们在'Target Gender''Target Age'列上执行分组,并将我们的配置字典传递给agg()方法。结果被截断并稍微重新排列,以适应显示,如下所示:

 Impressions              Spent
 Mean Impr  Std Impr    mean            std
Target Target 
Gender Age 
B      20-25           500000  2.189102  239882  209442.168488
 20-30           500000  2.245317  271285  236854.155720
 20-35           500000  1.886396  243725  174268.898935
 20-40           499999  2.100786  247740  211540.133771
 20-45           500000  1.772811  148712  118603.932051
...                    ...       ...     ...            ...
M      20-25           500000  2.022023  212520  215857.323228
 20-30           500000  2.111882  292577  231663.713956
 20-35           499999  1.965177  255651  222790.960907
 20-40           499999  1.932473  282515  250023.393334
 20-45           499999  1.905746  271077  219901.462405

这当然是一种文本表示,但你也可以有 HTML 版本。你可以看到Spentmeanstd列,其标签仅仅是函数名,而Impressions则展示了我们添加到配置字典中的美好标题。

在结束这一章之前,让我们再做一些事情。我想向你展示一个叫做交叉表的东西。在数据环境中,这有点像是一个热门词汇,所以像这样一个虽然非常简单但却是必须的例子。

#39

pivot = df.pivot_table(
    values=['Impressions', 'Clicks', 'Spent'],
    index=['Target Age'],
    columns=['Target Gender'],
    aggfunc=np.sum
)
pivot

我们创建了一个交叉表,展示了目标年龄与展示次数、点击次数和花费之间的相关性。后三者将根据目标性别进行细分。用于计算结果的聚合函数是numpy.sum函数(如果没有指定,默认将是numpy.mean)。

在创建交叉表后,我们只需在单元格的最后一行打印它,这里是结果的截图:

可视化结果

当数据有意义时,它非常清晰,并提供了非常有用的信息。

就这样!我将让你去发现更多关于 IPython、Jupyter 和数据科学的奇妙世界。我强烈建议你熟悉笔记本环境。它比控制台好得多,它极其实用且使用起来很有趣,你甚至可以用它来做幻灯片和文档。

我们接下来该去哪里?

数据科学确实是一个迷人的主题。正如我在引言中所说,那些想要深入其迷宫的人需要在数学和统计学方面接受良好的训练。处理被错误插值的数据会使任何关于它的结果都变得无用。同样,对于被错误外推或以错误频率采样的数据也是如此。为了给你一个例子,想象一个个体排列在队列中的人群。如果出于某种原因,这个群体的性别在男性和女性之间交替,队列将类似于这样:F-M-F-M-F-M-F-M-F...

如果你只采样偶数元素,你会得出结论说这个群体只由男性组成,而采样奇数元素则会告诉你正好相反。

当然,这只是一个愚蠢的例子,我知道,但请相信我,在这个领域很容易出错,尤其是在处理大数据时,采样是强制性的,因此,您进行的内省的质量首先取决于采样的质量。

当谈到数据科学和 Python 时,这些是您想要查看的主要工具:

  • NumPy (www.numpy.org/): 这是 Python 科学计算的基础包。它包含一个强大的 N 维数组对象,复杂的(广播)函数,用于集成 C/C++和 Fortran 代码的工具,有用的线性代数、傅里叶变换、随机数能力以及更多。

  • Scikit-Learn (scikit-learn.org/stable/): 这可能是 Python 中最受欢迎的机器学习库。它提供了简单高效的数据挖掘和分析工具,对每个人都是可访问的,并且可以在各种环境中重复使用。它是基于 NumPy、SciPy 和 Matplotlib 构建的。

  • Pandas (pandas.pydata.org/): 这是一个开源的、BSD 许可的库,提供高性能、易于使用的数据结构和数据分析工具。我们在这整个章节中都用到了它。

  • IPython (ipython.org/) / Jupyter (jupyter.org/): 这些提供了丰富的交互式计算架构。

  • Matplotlib (matplotlib.org/): 这是一个 Python 2D 绘图库,可以在各种硬拷贝格式和跨平台的交互式环境中生成高质量的出版物。Matplotlib 可用于 Python 脚本、Python 和 IPython shell 和 notebook、Web 应用程序服务器以及六个图形用户界面工具包。

  • Numba (numba.pydata.org/): 这为您提供了使用直接在 Python 中编写的性能函数来加速应用程序的能力。通过一些注解,面向数组和数学密集型的 Python 代码可以被即时编译成本地机器指令,其性能与 C、C++和 Fortran 相似,而无需切换语言或 Python 解释器。

  • Bokeh (bokeh.pydata.org/en/latest/): 这是一个针对现代 Web 浏览器的 Python 交互式可视化库,用于展示。其目标是提供类似于 D3.js 风格的优雅、简洁的图形构建,同时也能在非常大的或流式数据集上提供高性能的交互性。

除了这些单个库之外,您还可以找到如SciPy (scipy.org/)和Anaconda (www.continuum.io/)这样的生态系统,它们捆绑了多个不同的包,以便为您提供“开箱即用”的解决方案。

在某些系统上安装所有这些工具及其依赖项很困难,所以我建议你也尝试一下生态系统,看看你是否对他们感到舒适。这可能值得尝试。

摘要

在本章中,我们讨论了数据科学。而不是试图解释这个极其广泛的主题的任何内容,我们深入了一个项目。我们熟悉了 Jupyter 笔记本,以及 Pandas、Matplotlib、NumPy 等不同的库。

当然,必须将这些信息压缩到一章中意味着我只能简要地触及我提出的主题。我希望我们共同完成的项目足够全面,能够给你一个关于你在这个领域工作时可能遵循的工作流程的好主意。

下一章将专注于 Web 开发。所以,请确保你已经准备好了浏览器,我们开始吧!

第十章. 正确进行网络开发

*"不要相信你在网上看到的一切。"
--孔子

在本章中,我们将一起制作一个网站。通过一个小项目,我的目标是为你打开一扇窗户,让你窥视一下网络开发是什么,以及如果你想要成功地进行网络开发,你应该了解的主要概念和工具。

什么是网络?

万维网,或简称Web,是通过使用称为互联网的媒介来访问信息的一种方式。互联网是一个巨大的网络网络,是一个网络基础设施。它的目的是连接全球数十亿设备,以便它们可以相互通信。信息通过称为协议的丰富语言在互联网中传输,这些协议允许不同的设备使用相同的语言来共享内容。

网络是一种基于互联网的信息共享模型,它使用超文本传输协议HTTP)作为数据通信的基础。因此,网络只是互联网上信息交换的几种不同方式之一:电子邮件、即时消息、新闻组等等,它们都依赖于不同的协议。

网络是如何工作的?

简而言之,HTTP 是一种请求-响应客户端-服务器协议。HTTP 客户端向 HTTP 服务器发送请求消息。服务器反过来返回响应消息。换句话说,HTTP 是一种拉协议,客户端从服务器拉取信息(与推协议相对,服务器将信息推送到客户端)。看看下面的图片:

网络是如何工作的?

HTTP 基于TCP/IP传输控制协议/互联网协议),它提供了可靠通信交换的工具。

HTTP 协议的一个重要特性是它是无状态的。这意味着当前请求不知道之前请求发生了什么。这是一个限制,但你可以在登录的错觉下浏览网站。然而,在幕后发生的是,在登录时,用户信息的令牌被保存(通常在客户端,在称为cookies的特殊文件中),这样用户发出的每个请求都携带了服务器识别用户并提供定制界面的手段,比如显示他们的名字,保持购物车内容,等等。

尽管这非常有趣,我们不会深入探讨 HTTP 的丰富细节以及它是如何工作的。然而,我们将编写一个小的网站,这意味着我们需要编写处理 HTTP 请求和返回 HTTP 响应的代码。从现在起,我不会再在请求响应这些术语前加上 HTTP,因为我相信不会产生任何混淆。

Django 网络框架

对于我们的项目,我们将使用 Python 生态系统中最受欢迎的 Web 框架之一:Django。

Web 框架是一组工具(库、函数、类等),我们可以使用它们来编写网站。我们需要决定我们希望允许对 Web 服务器发出哪些类型的请求,以及我们如何响应它们。Web 框架是完成这项任务的完美工具,因为它为我们处理了许多事情,使我们能够专注于重要部分,而无需重新发明轮子。

注意

存在着不同类型的框架。并非所有框架都是为编写 Web 代码而设计的。一般来说,框架是一种提供功能以简化软件开发、产品和服务解决方案开发的工具。

Django 设计哲学

Django 的设计遵循以下原则:

  • DRY:即“不要重复自己”。不要重复代码,并以一种让框架尽可能从尽可能少的代码中推断出尽可能多的内容的方式进行编码。

  • 松耦合:框架的各个层不应该相互了解(除非在绝对必要时)。松耦合与高内聚并行时效果最佳。用罗伯特·马丁的话来说:将因同一原因而改变的事物放在一起,将因不同原因而改变的事物分开。

  • 少代码:应用程序应使用尽可能少的代码,并以尽可能多的方式促进重用。

  • 一致性:在使用 Django 框架时,无论你针对哪一层进行编码,你的体验都将与用于构建项目的选定的设计模式和范式非常一致。

框架本身是围绕模型-模板-视图MTV)模式设计的,这是模型-视图-控制器MVC)的变体,其他框架广泛采用这种模式。这种模式的目的在于分离关注点,促进代码重用和质量提升。

模型层

在这三个层次中,这是定义应用程序处理的数据结构的层次,并处理数据源。模型是一个表示数据结构的类。通过一些 Django 魔法,模型被映射到数据库表,这样你就可以在关系数据库中存储你的数据。

注意

关系数据库将数据存储在表中,其中每一列是数据的属性,每一行代表该表所表示的集合中的单个项目或条目。通过每个表的主键,即允许唯一标识每个项目的数据部分,可以建立属于不同表的项目之间的关系,即将它们放入关系中。

这个系统的美在于,你不需要编写特定于数据库的代码来处理你的数据。你只需正确配置你的模型并简单地使用它们。数据库的工作将由 Django 的 对象关系映射ORM)为你完成,它负责将针对 Python 对象执行的操作转换为关系数据库可以理解的语言:SQL结构化查询语言)。

这种方法的一个好处是,你将能够更改数据库而无需重写代码,因为所有特定于数据库的代码都是由 Django 在运行时根据连接的数据库自动生成的。关系数据库使用 SQL,但每个数据库都有其独特的风味;因此,在我们的应用程序中不需要硬编码任何 SQL 是一个巨大的优势。

Django 允许你在任何时候修改你的模型。当你这样做时,你可以运行一个创建迁移的命令,迁移是一组指令,用于将数据库转换为表示你模型当前定义的状态。

总结来说,这一层负责定义你在网站中需要处理的数据结构,并为你提供通过访问模型(Python 对象)来简单地从数据库中保存和加载它们的手段。

视图层

视图的功能是处理请求,执行需要执行的操作,并最终返回响应。例如,如果你打开浏览器并请求一个电子商务商店中某个产品类别的页面,视图可能会与数据库通信,请求所有属于所选类别的子类别(例如,在导航侧边栏中显示它们)以及所有属于所选类别的产品,以便在页面上显示它们。

因此,视图是我们满足请求的机制。其结果,响应对象,可以采取多种不同的形式:JSON 有效负载、文本、HTML 页面等。当你编写网站时,你的响应通常由 HTML 或 JSON 组成。

注意

超文本标记语言HTML)是创建网页的标准标记语言。网络浏览器运行能够解释 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}'。我们通过使用方括号创建了一个类,并在其中定义了从 0 到 9 的数字范围,因此包含了所有可能的数字。然后,在大括号中,我们说我们期望有两个这样的数字。然后是一个破折号,然后我们重复这个模式一次,然后再次,通过改变我们期望的数字数量,并且不包含最后的破折号。像[0-9]这样的类是一个非常常见的模式,因此已经创建了一个特殊的表示法作为快捷方式:'\d'。因此,我们可以将模式重写为r'\d{2}-\d{2}-\d{4}',它将工作得完全一样。字符串前面的r代表原始的,它的目的是改变正则表达式引擎解释每个反斜杠\'的方式。

一个正则表达式网站

所以,我们现在在这里。我们将编写一个网站,用来存储正则表达式,这样我们就可以稍微玩玩它们。

注意

在我们开始创建项目之前,我想说几句关于 CSS 的话。CSS层叠样式表)是包含我们指定 HTML 页面上各种元素外观的文件。你可以设置各种属性,如形状、大小、颜色、边距、边框、字体等。在这个项目中,我已经尽力让页面看起来不错,但 neither a frontend developer nor a designer,所以请不要过于关注外观。试着关注它们是如何工作的。

设置 Django

在 Django 网站(www.djangoproject.com/)上,你可以跟随教程,它给你一个相当不错的 Django 功能介绍。如果你想的话,可以先跟随那个教程,然后再回到这个例子。所以,首先的事情是;让我们在你的虚拟环境中安装 Django:

$ pip install django

当这个命令完成后,你可以在控制台中测试它(尝试使用 bpython,它给你一个类似于 IPython 的 shell,但具有很好的内省能力):

>>> import django
>>> django.VERSION
(1, 8, 4, 'final', 0)

现在 Django 已经安装好了,我们可以开始了。我们得做一些脚手架,所以我会快速带你过一遍。

开始项目

在书籍的环境中选一个文件夹并切换到那里。我会使用ch10。从那里,我们使用以下命令开始一个 Django 项目:

$ django-admin startproject regex

这将为名为regex的 Django 项目准备骨架。切换到regex文件夹并运行以下命令:

$ python manage.py runserver

你应该能够用浏览器访问http://127.0.0.1:8000/并看到默认的 Django 页面It worked!。这意味着项目已经正确设置。当你看到这个页面后,用Ctrl + C(或者在控制台里显示的任何内容)关闭服务器。我现在会粘贴项目的最终结构,这样你可以作为参考使用:

$ tree -A regex  # from the ch10 folder
regex
├── db.sqlite3
├── entries
│   ├── admin.py
│   ├── forms.py
│   ├── __init__.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文件夹中的regex/settings.py文件。我们需要将我们的应用程序添加到INSTALLED_APPS元组中,以便我们可以使用它(将其添加到元组的底部):

INSTALLED_APPS = (
    ... django apps ...
    '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)
    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.now()函数的访问权限,该函数为我们提供了一个对datetime.now()的时区感知版本。它的美妙之处在于它与之前向您展示的TIME_ZONE设置相连。

对于这个类的主键,如果我们没有明确设置一个,Django 会为我们添加一个。主键是一个允许我们在数据库中唯一标识一个Entry对象(在这种情况下,Django 将添加一个自动递增的整数 ID)的键。

因此,我们定义我们的类,并设置四个类属性。我们有一个ForeignKey属性,它是我们指向*User*模型的引用。我们还有两个CharField属性,用于存储我们的正则表达式的模式和测试字符串。我们还有一个DateTimeField,其默认值设置为timezone.now。请注意,我们并没有在那里调用timezone.now,而是now,不是now()。因此,我们不是传递一个DateTime实例(在解析该行时设置的时间点),而是传递一个可调用对象,一个在我们在数据库中保存条目时被调用的函数。这与我们在第八章中使用的回调机制类似,即当我们为按钮点击分配命令时。

最后两行非常有趣。我们在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模型。因为我们希望促进代码重用,所以我们使用相对导入(在models之前有一个点)。这将允许我们在不太多麻烦的情况下移动或重命名应用程序。然后,我们定义EntryAdmin类,它继承自admin.ModelAdmin。类上的装饰告诉 Django 在行政面板中显示Entry模型,而我们在EntryAdmin类中放入的内容告诉 Django 如何定制处理此模型的方式。

首先,我们指定创建/编辑页面的fieldsets。这将把页面分成两个部分,以便我们更好地分别可视化内容(模式测试字符串)和其他细节(用户时间戳)。

然后,我们定制列表页面显示结果的方式。我们想看到所有字段,但不显示日期。我们还希望能够按用户过滤,以便我们能够只通过一个用户查看所有条目,并且我们希望能够在test_string上搜索。

我将继续添加三个条目,一个代表我自己,另外两个代表我的父亲。结果将在接下来的两张图片中展示。在插入它们之后,列表页面看起来是这样的:

定制管理面板

我已经突出显示了在EntryAdmin类中我们定制的这个视图的三个部分。我们可以按用户过滤,可以搜索,并且显示所有字段。如果你点击一个模式,编辑视图就会打开。

在我们定制之后,它看起来是这样的:

定制管理面板

注意我们有两个部分:正则表达式其他信息,这要归功于我们的自定义 EntryAdmin 类。试一试,为几个不同的用户添加一些条目,熟悉界面。所有这些都不需要付费,不是很好吗?

创建表单

每次你在网页上填写详细信息时,你都是在表单字段中插入数据。表单HTML 文档对象模型DOM的一部分。在 HTML 中,你可以通过使用 form 标签来创建表单。当你点击提交按钮时,你的浏览器通常会打包表单数据并将其放入 POST 请求的主体中。与用于请求资源的 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.core.urlresolvers 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

class HomeView(TemplateView):
    template_name = 'entries/home.html'

    @method_decorator(
        login_required(login_url=reverse_lazy('login')))
    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        return self.render_to_response(context)

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

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

让我们从导入开始。我们需要re模块来处理正则表达式,然后我们需要从 Django 中获取几个类和函数,最后,我们需要Entry模型和EntryForm表单。

主页视图

第一个视图是HomeView。它继承自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函数被使用时,如果urls.py文件尚未加载,就需要这个功能。

我们刚刚装饰的get方法只是简单地调用父类的get方法。当然,get方法是在对与这个视图关联的 URL 执行GET请求时 Django 调用的方法。

条目列表视图

这个视图比之前的视图更有趣。首先,我们像之前一样装饰了get方法。在它里面,我们需要准备一个Entry对象的列表并将其传递给模板,这样用户就能看到它。为了做到这一点,我们首先像预期的那样获取context字典,通过调用TemplateView类的get_context_data方法。然后,我们使用 ORM 获取条目的列表。我们通过访问对象管理器并对其调用过滤器来完成此操作。我们根据哪个用户登录来过滤条目,并要求它们按降序排序(名称前的'-'指定了降序)。objects管理器是 Django 模型创建时默认增加的管理器,它允许我们通过其方法与数据库交互。

我们解析每个条目以获取匹配项的列表(实际上,我编写了代码,使得matches是一个生成器表达式)。最后,我们在上下文中添加一个'entries'键,其值是entriesmatches的耦合,这样每个Entry实例都与它的模式测试字符串的匹配结果配对。

在最后一行,我们只是要求 Django 使用我们创建的上下文渲染模板。

看一下_parse_entry方法。它所做的只是使用entry.patternentry.test_string上执行搜索。如果结果match对象不是None,这意味着我们找到了一些东西。如果是这样,我们返回一个包含三个元素的元组:整体组、子组和组字典。如果你不熟悉这些术语,不要担心,你很快就会看到一个带有示例的截图。如果没有匹配项,我们返回None

表单视图

最后,让我们来检查EntryFormView。这有几个特别有趣的原因。首先,它展示了 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.conf.urls import include, url
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.core.urlresolvers import reverse_lazy
from entries.views import HomeView, EntryListView, EntryFormView

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^entries/$', EntryListView.as_view(), name='entries'),
 url(r'^entries/insert$',
 EntryFormView.as_view(),
 name='insert'),

    url(r'^login/$',
        auth_views.login,
        kwargs={'template_name': 'admin/login.html'},
        name='login'),
    url(r'^logout/$',
        auth_views.logout,
        kwargs={'next_page': reverse_lazy('home')},
        name='logout'),

    url(r'^$', HomeView.as_view(), name='home'),
]

如您所见,魔法来自于url函数。首先,我们传递给它一个正则表达式;然后是视图;最后是一个名称,这是我们将在reversereverse_lazy函数中使用来恢复 URL 的名称。

注意,当使用基于类的视图时,我们必须将它们转换为函数,这正是url所期望的。为此,我们调用它们的as_view()方法。

注意,第一个url条目,针对管理员,是特殊的。它不是指定一个 URL 和一个视图,而是指定一个 URL 前缀和另一个urls.py模块(来自admin.site包)。这样,Django 将通过在admin.site.urls中指定的所有 URL 前添加'admin/'来完成管理员部分的全部 URL。我们本来也可以为我们的条目应用做同样的事情(我们应该这么做),但我感觉这对这个简单的项目来说可能有点过于复杂了。

在正则表达式语言中,'^''$'符号代表字符串的开始结束。注意,如果你使用包含技术,就像管理员一样,'$'是缺失的。当然,这是因为'admin/'只是一个前缀,需要由包含的urls模块中的所有定义来完成。

另一个值得注意的事情是,我们还可以包含视图路径的字符串化版本,我们就是这样为loginlogout视图做的。我们还通过kwargs参数添加了有关要使用哪些模板的信息。顺便说一下,这些视图直接来自django.contrib.auth包,因此我们不需要写一行代码来处理认证。这是非常棒的,节省了我们很多时间。

每个url声明都必须在urlpatterns列表内完成,关于这一点,重要的是要考虑,当 Django 试图为请求的 URL 查找视图时,模式是按顺序从上到下执行的。第一个匹配的模式将提供该 URL 的视图,所以通常情况下,你必须将具体的模式放在通用模式之前,否则它们将永远不会有机会被捕获。例如,'^shop/categories/$'需要放在'^shop'之前(注意后者中没有'$'),否则它将永远不会被调用。我们的条目示例工作得很好,因为我详细地使用了'$'来指定 URL。

因此,模型、表单、管理员、视图和 URL 都已经完成。剩下要做的就是处理模板。关于这部分,我必须非常简略,因为 HTML 可能非常冗长。

编写模板

所有模板都继承自一个基础模板,它为所有其他模板提供了 HTML 结构,以一种非常面向对象的方式。它还指定了一些块,这些块是可以被子模板覆盖的区域,以便它们可以为这些区域提供自定义内容。让我们从基础模板开始:

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文件夹。当你部署 Django 网站时,你会在一个文件夹下收集所有的模板文件。如果你没有指定路径,就像我这样做,你可能会在 entries 应用中找到一个base.html模板,在另一个应用中也有一个base.html模板。最后收集到的将覆盖任何具有相同名称的其他文件。因此,通过将它们放在templates/entries文件夹中,并使用这种技术为每个 Django 应用编写,你可以避免名称冲突的风险(这同样适用于任何其他静态文件)。

关于这个模板,实际上没有太多可说的,除了它加载了static标签,这样我们就可以通过使用{% static ... %}来轻松访问static路径,而无需在模板中硬编码它。特殊{% ... %}部分中的代码定义了逻辑。特殊{{ ... }}中的代码代表将在页面上渲染的变量。

我们定义了三个块:titlepage-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 ... %}标签来完成的,这是模板中的reverse函数的等价物,而且没有硬编码任何 URL。

插入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 就能验证请求是否来自允许的来源,从而避免上述跨站请求伪造问题。你看到我们如何在编写Entry插入视图时处理令牌了吗?正是这样。我们没有为它写一行代码。Django 通过一个中间件类(CsrfViewMiddleware)自动处理它。请参考官方 Django 文档以进一步探索这个主题。

对于这个页面,我们也使用页脚块来显示指向主页的链接。最后,我们有列表模板,这是最有趣的一个。

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中创建的那个 3 元组。请注意,为了访问对象的属性,我们使用与 Python 中相同的点语法,例如{{ entry.pattern }}{{ entry.test_string }},等等。

当处理列表和元组时,我们不能使用方括号语法来访问项,所以我们也要使用点语法({{ match.0 }}等同于match[0],等等)。我们还使用一个通过管道(|)操作符的过滤器来显示一个自定义值,如果匹配是None

Django 模板语言(它并不是真正的 Python)被保持得非常简单,这是出于一个精确的原因。如果你发现自己受到语言的限制,这意味着你可能在模板中尝试做一些实际上应该在视图中完成的事情,那里的逻辑更为相关。

让我向您展示几个listinsert模板的截图。这是我父亲的条目列表看起来像这样:

编写模板

注意使用cycle标签如何交替行的背景颜色,从白色变为浅灰色。这些类在main.css文件中定义。

Entry 插入页面足够智能,可以提供几种不同的场景。当你第一次访问它时,它会给你一个空表单。如果你正确填写,它会显示一个漂亮的提示信息(见下图)。然而,如果你未能填写两个字段,它会在它们之前显示一个错误信息,提醒你这些字段是必需的。

还要注意自定义页脚,它包括对条目列表的链接和对主页的链接:

编写模板

就这样!如果你愿意,可以随意玩转 CSS 样式。下载这本书的代码,尽情探索和扩展这个项目。给模型添加其他内容,创建并应用迁移,玩转模板,有很多事情可以做!

Django 是一个非常强大的框架,它提供的功能远超我在本章中向您展示的内容,所以您绝对想看看它。它的美在于它是 Python,因此阅读它的源代码是一个非常有益的练习。

网络开发的未来

计算机科学与其他伴随人类数百年甚至更长时间的学科相比,是一个非常年轻的学科。它的一大特点是发展速度极快。它以如此快的速度前进,以至于在短短几年内,你就能看到与实际世界变化相当的变化,而这些变化原本需要一百年才能发生。因此,作为一名程序员,你必须时刻关注这个世界正在发生的事情。

现在正在发生的事情是,由于强大的计算机现在非常便宜,几乎每个人都能访问它们,因此趋势是尽量避免在服务器端放置过多的工作负载,让前端处理一部分。因此,在过去的几年里,像 jQuery 和 Backbone 这样的 JavaScript 框架和库变得非常流行,网络开发已经从后端负责处理数据、准备数据并为其提供显示给前端的范式,转变为后端有时仅用作 API、纯粹的数据提供者的范式。前端通过 API 调用从后端获取数据,然后处理其余部分。这种转变促进了单页应用SPA)等范式的存在,理想情况下,整个页面只加载一次,然后根据通常来自后端的内容进行演变。使用类似技术的电子商务网站可以在不刷新周围结构的情况下在页面中加载搜索结果。浏览器可以执行异步调用(AJAX),这些调用可以返回数据,这些数据可以用 JavaScript 代码读取、操作并注入回页面。

因此,如果你打算从事 Web 开发,我强烈建议你熟悉 JavaScript(如果你还没有的话),以及 API。在本章的最后几页,我将给你一个如何使用两个不同的 Python 微框架(Flask 和 Falcon)创建简单 API 的例子。

编写 Flask 视图

Flask (flask.pocoo.org/) 是一个 Python 微框架。它提供的功能比 Django 少,但据说速度更快,启动也更快。说实话,现在启动 Django 也非常快,但 Flask 如此受欢迎,看到它的例子也很不错。

在你的ch10文件夹中,创建一个名为flask的文件夹,其结构如下:

$ tree -A flask  # from the ch10 folder
flask
├── main.py
└── templates
 └── main.html

基本上,我们将编写两个简单的文件:一个 Flask 应用和一个 HTML 模板。Flask 使用 Jinja2 作为模板引擎。它非常流行且非常快,而且最近 Django 甚至开始提供对它的原生支持,这是 Python 程序员长期渴望的事情。

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)

if __name__ == '__main__':
    app.run()

我们创建一个app对象,这是一个 Flask 应用。我们只提供存储在__name__中的模块的完全限定名称。

然后,我们编写一个简单的hello视图,它接受一个可选的name参数。在视图的主体中,我们简单地渲染main.html模板,并将name参数传递给它,无论其值如何。

有趣的是路由。与 Django 将视图和 URL(urls.py模块)绑定起来的方式不同,在 Flask 中,你使用一个或多个@app.route装饰器来装饰你的视图。在这种情况下,我们接受没有其他信息的根 URL,或者带有名称信息。

切换到flask文件夹,并输入(确保你已经通过$ pip install flask安装了 Flask):

$ python main.py

你可以打开浏览器并访问http://127.0.0.1:5000/。这个 URL 没有名称信息;因此,你会看到Hello shy person!它写得很大很漂亮。试着在 URL 中添加一些内容,比如http://127.0.0.1:5000/Adriano。按Enter键,页面将变为Hello Adriano!

当然,Flask 为你提供了更多功能,但我们没有足够的空间来展示更复杂的例子。尽管如此,它绝对值得探索。几个项目已经成功地使用了它,而且用它创建网站或 API 很有趣。Flask 的作者 Armin Ronacher 是一位成功且多产的程序员。他还创建了或参与了几个其他有趣的项目,如 Werkzeug、Jinja2、Click 和 Sphinx。

在 Falcon 中构建 JSON 报价服务器

Falcon (falconframework.org/) 是另一个用 Python 编写的微框架,它被设计成轻量级、快速和灵活。我认为这个相对较新的项目会因其速度而发展成为一个真正受欢迎的项目,这种速度令人印象深刻,所以我很高兴能给你展示一个使用它的微小示例。

我们将构建一个视图,它返回一个随机选择的 佛陀 引语。

在你的 ch10 文件夹中,创建一个新的名为 falcon 的文件夹。我们将有两个文件:quotes.pymain.py。要运行此示例,请安装 Falcon 和 Gunicorn ($ pip install falcon gunicorn)。Falcon 是框架,Gunicorn绿色独角兽)是一个用于 Unix 的 Python WSGI HTTP 服务器(用通俗易懂的话来说,就是运行服务器所用的技术)。当你一切准备就绪时,首先创建 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 方法,这种命名风格让我想起了 C#的事件处理器。它接受一个请求和一个响应参数,这两个参数都由框架自动提供。在其主体中,我们定义一个包含随机选择引语和作者信息的字典,然后将该字典转换为 JSON 字符串,并将响应体设置为它的值。我们不需要返回任何内容,Falcon 会为我们处理。

在文件末尾,我们创建 Falcon 应用程序,并在其上调用 add_route 来将我们刚刚编写的处理器绑定到我们想要的 URL。

当你一切准备就绪时,切换到 falcon 文件夹,并输入:

$ gunicorn main:api

然后,向 http://127.0.0.1:8000/quote 发送一个请求(或者简单地用你的浏览器打开这个页面)。当我这样做的时候,我得到了这个 JSON 响应:

{
    quote: "The mind is everything. What you think you become.",
    author: "The Buddha"
}

无论你最终选择哪个框架进行 Web 开发,都要努力了解其他选择。有时你可能处于需要不同框架的情况,而了解不同工具的工作原理将给你带来优势。

摘要

在这一章中,我们简要地了解了 Web 开发。我们讨论了像 DRY 哲学和框架作为一个工具的概念,它为我们提供了编写代码以处理请求所需的所有东西。我们还讨论了 MTV 模式,以及这三个层次如何很好地协同工作以实现请求-响应路径。

之后,我们简要介绍了正则表达式,这是一个极其重要的主题,它是提供 URL 路由工具的那一层。

现在有许多不同的框架,Django 无疑是其中最好的和最广泛使用的之一,因此它绝对值得探索,尤其是它的源代码,写得非常好。

还有其他非常有趣且重要的框架,例如 Flask。它们提供的功能较少,但总体来说,它们在执行时间和设置上都要快得多。其中有一个非常快的框架是相对较新的 Falcon 项目,其基准测试非常出色。

理解请求-响应机制是如何工作的,以及互联网总体上是如何工作的,这一点非常重要,这样最终你就不必太在意你不得不使用哪个框架。你将能够快速掌握它,因为这只涉及到熟悉一种你已经了解很多的方法。

至少探索三个框架,并尝试想出不同的用例来决定哪一个可能是理想的选择。当你能够做出那个选择时,你就知道你对它们的理解已经足够好了。

下一章将介绍调试和故障排除。我们将学习如何处理错误和问题,这样当你编码时遇到麻烦(别担心,通常这种情况不会经常发生),你将能够快速找到解决问题的方法并继续前进。

第十一章。调试和故障排除

"如果调试是移除软件错误的过程,那么编程就必须是引入它们的过程。"
--埃德加·W·迪杰斯特拉

在专业程序员的生涯中,调试和故障排除占据了相当多的时间。即使你从事的是人类写过的最漂亮的代码库,其中仍然会有错误,这是肯定的。

我们花了很多时间阅读别人的代码,在我看来,一个优秀的软件开发者即使在阅读的不是报告为错误或存在错误的代码时,也能保持高度的关注。

能够高效快速地调试代码是任何程序员都需要不断改进的技能。有些人认为,因为他们已经阅读了手册,所以他们没问题,但现实是,游戏中的变量数量如此之大,以至于没有手册。有一些可以遵循的指南,但没有一本魔法书能教你成为调试高手所需知道的一切。

我觉得,在这个特定的问题上,我从我的同事那里学到了最多的东西。看到一个非常熟练的人攻击问题让我感到惊讶。我喜欢看到他们采取的步骤,他们验证的事情,以排除可能的原因,以及他们考虑的嫌疑人,最终引导他们找到问题的解决方案。

我们与每一个同事合作都能学到一些东西,或者用他们那惊人的猜测来让我们感到惊讶,而这些猜测最终被证明是正确的。当这种情况发生时,不要只是感到惊奇(或者更糟,感到嫉妒),而要抓住这个机会,问他们是如何得出这个猜测的,为什么。这个答案将让你看到,你能否在以后深入研究,以便下次,你将成为那个抓住错误的人。

有些错误很容易发现。它们来自粗心大意的错误,一旦你看到这些错误的影响,就很容易找到解决问题的解决方案。

但还有其他一些错误更加微妙,更加难以捉摸,需要真正的专业知识,以及大量的创造性和跳出思维,才能解决。

对于我来说,最糟糕的是那些非确定性的问题。这些有时会发生,有时不会。有些问题只发生在环境 A 中,而不发生在环境 B 中,尽管 A 和 B 应该完全相同。这些错误是真正的邪恶,它们可以让你发疯。

当然,错误不仅仅发生在沙盒中,对吧?当你的老板告诉你“别担心!慢慢来修复这个问题,先吃午饭吧!”。不。它们发生在周五下午五点半,那时你的大脑已经疲惫,你只想回家。就在那一刻,当每个人都瞬间变得焦躁不安,当你的老板在你耳边喘息时,你必须能够保持冷静。我确实是这么说的。如果你让大脑紧张,那么就告别创造性思维、逻辑推理以及你当时需要的所有东西。所以深呼吸,坐好,集中注意力。

在这一章中,我将尝试展示一些有用的技巧,你可以根据错误的严重程度来使用它们,以及一些希望有助于增强你对错误和问题的武器的建议。

调试技巧

在这部分,我将向你展示最常用的技术,我最常用的技术,然而,请不要认为这个列表是详尽的。

使用print进行调试

这可能是所有技巧中最简单的一个。它并不非常有效,不能在所有地方使用,并且需要访问源代码以及运行它的终端(因此可以显示print函数调用的结果)。

然而,在许多情况下,这仍然是一种快速且有用的调试方法。例如,如果你正在开发一个 Django 网站,页面上的情况并不是你所期望的,你可以在视图中添加print语句,并在重新加载页面时留意控制台。我可能已经做过无数次了。

当你在代码中分散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.300s
Second step done.
 Time elapsed: 0.501s

无论你的情况如何,拥有这样一个自包含的函数非常有用。

检查 traceback

我们在第七章中简要介绍了 traceback,测试、分析和处理异常,当我们看到几种不同类型的异常时。traceback 提供了关于你的应用程序中发生错误的信息。阅读它将给你很大的帮助。让我们看一个非常小的例子:

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:
    raise ValidatorError(
        '`{}` not found in d.'.format(mandatory_key))

我们定义一个自定义异常,当必填键不存在时引发。请注意,其主体由其文档字符串组成,因此我们不需要添加任何其他语句。

非常简单,我们定义一个虚拟字典并尝试使用 mandatory_key 访问它。当发生这种情况时,我们捕获 KeyError 并引发 ValidatorError。这样做的原因是,我们可能还希望在其他情况下引发 ValidatorError,而不仅仅是由于缺少必填键。这种技术允许我们在简单的 try/except 中运行验证,它只关心 ValidatorError

问题是,在 Python 2 中,这段代码只会显示最后一个异常(ValidatorError),这意味着我们会失去关于其前面的 KeyError 的信息。在 Python 3 中,这种行为已经改变,异常现在是链式的,因此当发生问题时,你会得到更好的信息报告。代码产生以下结果:

$ python traceback_validator.py 
Traceback (most recent call last):
 File "traceback_validator.py", line 7, in <module>
 print(d[mandatory_key])
KeyError: 'some-other'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
 File "traceback_validator.py", line 10, in <module>
 '`{}` not found in d.'.format(mandatory_key))
__main__.ValidatorError: `some-other` not found in d.

这很棒,因为我们可以看到导致我们引发 ValidationError 的异常的堆栈跟踪,以及 ValidationError 本身的堆栈跟踪。

我和我的一个审稿人关于从 pip 安装程序获得的堆栈跟踪进行了愉快的讨论。他在设置一切以便审查 第九章 的代码时遇到了麻烦,数据科学。他的新 Ubuntu 安装缺少一些库,这些库是 pip 包正常运行所必需的。

他受阻的原因是他试图从堆栈跟踪的顶部开始修复显示的错误。我建议他从底部开始修复。原因是,如果安装程序已经到达最后一行,我猜在那之前,无论发生什么错误,都有可能恢复。只有当 pip 决定无法继续进行时,才会到达最后一行,因此我开始修复那一行。一旦安装了修复该错误所需的库,其他一切都会顺利。

阅读堆栈跟踪可能很棘手,我的朋友缺乏处理这个问题的必要经验,因此,如果你陷入同样的情况,不要气馁,试着稍微改变一下,不要想当然。

Python 拥有一个庞大而精彩的社区,当你遇到问题时,你不太可能是第一个看到它的人,所以打开浏览器并搜索。通过这样做,你的搜索技巧也会提高,因为你必须将错误缩减到最小但最关键的细节集,这将使你的搜索变得有效。

如果你想要更好地玩耍和理解回溯,在标准库中有一个名为 traceback 的模块,你可以使用它。它提供了一个标准的接口来提取、格式化和打印 Python 程序的堆栈跟踪,这与 Python 解释器打印堆栈跟踪时的行为完全一致。

使用 Python 调试器

另一种非常有效的调试 Python 的方法是使用 Python 调试器:pdb。如果你像我一样沉迷于 IPython 控制台,那么你绝对应该检查一下 ipdb 库。ipdb 增强了标准的 pdb 接口,就像 IPython 对 Python 控制台所做的那样。

使用这个调试器的几种不同方法(无论哪个版本,这并不重要),但最常见的一种方法就是简单地设置一个断点并运行代码。当 Python 达到断点时,执行会暂停,你将获得对该点的控制台访问权限,以便你可以检查所有名称等。你还可以动态地更改数据以改变程序的流程。

作为一个小例子,让我们假装我们有一个解析器,它因为字典中缺少键而引发 KeyError。这个字典来自我们无法控制的 JSON 有效载荷,而我们目前只想暂时欺骗并传递这个控制权,因为我们对之后发生的事情感兴趣。让我们看看我们如何能够拦截这个时刻,检查数据,修复它,并使用 ipdb 到达底部。

ipdebugger.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 ipdebugger.py 
v1
v2
Traceback (most recent call last):
 File "ipdebugger.py", line 10, in <module>
 do_something_with_value(d[key])
KeyError: 'third'

因此我们看到 key 在字典中缺失,但由于每次我们运行这段代码时我们可能得到不同的字典或 keys 元组,这个信息实际上并没有真正帮助我们。让我们注入一个对 ipdb 的调用。

ipdebugger_ipdb.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)

import ipdb
ipdb.set_trace()  # we place a breakpoint here

for key in keys:
    do_something_with_value(d[key])

print('Validation done.')

如果我们现在运行这段代码,事情会变得有趣(请注意,你的输出可能略有不同,并且这个输出中的所有注释都是我添加的):

$ python ipdebugger_ipdb.py
> /home/fab/srv/l.p/ch11/ipdebugger_ipdb.py(12)<module>()
 11 
---> 12 for key in keys:  # this is where the breakpoint comes
 13     do_something_with_value(d[key])

ipdb> keys  # let's inspect the keys tuple
('first', 'second', 'third', 'fourth')
ipdb> !d.keys()  # now the keys of d
dict_keys(['first', 'fourth', 'second'])  # we miss 'third'
ipdb> !d['third'] = 'something dark side...'  # let's put it in
ipdb> c  # ... and continue
v1
v2
something dark side...
v4
Validation done.

这非常有趣。首先,请注意,当你达到断点时,你会得到一个控制台,告诉你你在哪里(Python 模块)以及下一行要执行的哪一行。此时,你可以执行一系列探索性操作,例如检查下一行之前和之后的代码,打印堆栈跟踪,与对象交互等。请查阅官方 Python 文档中的 pdb 了解更多。在我们的例子中,我们首先检查 keys 元组。之后,我们检查 d 的键。

你注意到我在 d 前面加上的感叹号了吗?这是必需的,因为 dpdb 接口中的一个命令,用于将帧 (d)own 移动。

注意

我在 ipdb 壳中使用这种符号表示命令:每个命令通过一个字母激活,通常是命令名称的第一个字母。所以,d 对应 downn 对应 nexts 对应 step,更简洁地表示为 (d)own,(n)ext 和 (s)tep。

我想这足够成为有一个更好的名字的理由,对吧?确实如此,但我需要向你展示这一点,所以我选择了使用 d。为了告诉 pdb 我们不是要执行 (d)own 命令,我们在 d 前面加上 "!",这样我们就没问题了。

在查看 d 的键之后,我们发现 'third' 缺失,所以我们自己添加了它(这可能会很危险?想想看)。最后,现在所有的键都已经添加完毕,我们输入 c,这意味着 (c)ontinue。

pdb 还能让你使用 (n)ext 逐行执行代码,(s)tep 进入函数进行深入分析,或使用 (b)reak 处理中断。有关命令的完整列表,请参阅文档或在控制台中输入 (h)elp。

从输出中你可以看到,我们最终到达了验证的末尾。

pdb(或 ipdb)是我每天都会使用的无价工具,没有它们我无法生活。所以,去享受吧,在某个地方设置一个断点,尝试检查,遵循官方文档,并在你的代码中尝试命令以查看它们的效果并熟练掌握它们。

检查日志文件

另一种调试表现不佳的应用程序的方法是检查其日志文件。日志文件是特殊文件,其中应用程序记录了各种信息,通常与它内部发生的事情有关。如果启动了重要的程序,我通常会期望在日志中有一条记录。当它完成时,以及可能在其内部发生的事情,情况也是如此。

需要将错误记录下来,这样当出现问题的时候,我们可以通过查看日志文件中的信息来检查发生了什么。

在 Python 中设置记录器有许多不同的方法。日志记录非常灵活,你可以配置它。简而言之,游戏中通常有四个参与者:记录器、处理器、过滤器、和格式化器:

  • 记录器暴露了应用程序代码直接使用的接口

  • 处理器将日志记录(由记录器创建)发送到适当的目的地

  • 过滤器提供了更细粒度的功能,用于确定要输出的日志记录

  • 格式化器指定最终输出中日志记录的布局

日志是通过调用Logger类实例的方法来执行的。您记录的每一行都有一个级别。通常使用的级别有:DEBUGINFOWARNINGERRORCRITICAL。您可以从logging模块导入它们。它们按照严重性顺序排列,并且正确使用它们非常重要,因为它们将帮助您根据您正在搜索的内容过滤日志文件的内容。日志文件通常变得非常大,因此确保其中的信息被正确写入非常重要,这样在需要时您可以快速找到它们。

您可以将日志记录到文件中,但您也可以将日志记录到网络位置、队列、控制台等。一般来说,如果您有一个部署在单个机器上的架构,将日志记录到文件是可以接受的,但当您的架构跨越多个机器(例如在面向服务的架构的情况下),实现一个集中式的日志解决方案非常有用,这样所有来自每个服务的日志消息都可以存储和调查在一个地方。这非常有帮助,否则您真的会疯狂地尝试关联来自几个不同来源的巨大文件,以找出出了什么问题。

注意

面向服务的架构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 {} is {}'.format(
            position, mylist[position]))
    except IndexError:
        logging.exception('Faulty position: {}'.format(position))

logging.info('Done parsing `mylist`.')

我们一行一行地过一遍。首先,我们导入logging模块,然后设置基本配置。一般来说,生产环境的日志配置比这要复杂得多,但我希望尽可能保持简单。我们指定一个文件名,我们希望在文件中捕获的最小日志级别,以及消息格式。我们将记录日期和时间信息,日志级别和消息内容。

我将首先记录一个info消息,告诉我我们即将处理我们的列表。然后,我将记录(这次使用DEBUG级别,通过使用debug函数)某个位置上的值。我在这里使用debug是因为我希望将来能够过滤掉这些日志(通过将最小级别设置为logging.INFO或更高),因为我可能需要处理非常大的列表,并且我不想记录所有值。

如果我们得到一个IndexError(我们确实得到了,因为我正在遍历range(4)),我们调用logging.exception(),它与logging.error()相同,但它也会打印跟踪信息。

在代码的末尾,我记录了一条info消息,说明我们已经完成。结果是这个:

[10/08/2015 04:17:06 PM] INFO:Starting to process `mylist`...
[10/08/2015 04:17:06 PM] DEBUG:Value at position 0 is 1
[10/08/2015 04:17:06 PM] DEBUG:Value at position 1 is 2
[10/08/2015 04:17:06 PM] DEBUG:Value at position 2 is 3
[10/08/2015 04:17:06 PM] 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
[10/08/2015 04:17:06 PM] INFO:Done parsing `mylist`.

这正是我们能够调试在机器上运行的应用程序而不是在控制台上的应用程序所需要的。我们可以看到发生了什么,任何抛出的异常的跟踪信息,等等。

注意

这里提供的示例只是对日志记录的表面了解。对于更深入的解释,你可以在官方 Python 文档的如何(docs.python.org/3.4/howto/logging.html)部分找到一个非常好的介绍。

日志记录是一种艺术,你需要在记录一切和记录什么都不记录之间找到一个好的平衡。理想情况下,你应该记录任何你需要确保应用程序正确运行的信息,以及可能的所有错误或异常。

其他技术

在本节的最后,我想简要演示一些你可能觉得有用的技术。

性能分析

我们在第七章中讨论了性能分析,测试、性能分析和处理异常,我在这里只是提到它,因为性能分析有时可以解释由于组件运行太慢而导致的奇怪错误。特别是当涉及到网络时,了解你的应用程序必须经历的时间和时间延迟对于理解问题出现时可能发生的情况非常重要,因此我建议你也从故障排除的角度熟悉性能分析技术。

断言

断言是确保你的假设得到验证的好方法。如果它们是,一切都会按常规进行,但如果它们不是,你会得到一个很好的异常,你可以处理它。有时,与其检查,不如在代码中添加几个断言来排除可能性。让我们看看一个例子:

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

这告诉我们问题确实在哪里。

去哪里寻找信息

在 Python 官方文档中,有一个专门介绍调试和性能分析的章节,你可以阅读有关bdb调试框架以及faulthandlertimeittracetracemalloc和当然还有pdb等模块的内容。只需前往文档中的标准库部分,你就可以很容易地找到所有这些信息。

故障排除指南

在这个简短的章节中,我想分享一些来自我的故障排除经验的技巧。

使用控制台编辑器

首先,熟悉使用vimnano作为编辑器,并学习控制台的基本知识。当事情变得糟糕时,你没有使用带有所有铃声和哨声的编辑器的奢侈。你必须连接到一个盒子并在那里工作。所以,熟悉使用控制台命令浏览你的生产环境,并能够使用基于控制台的编辑器(如 vi、vim 或 nano)编辑文件是非常好的主意。不要让你的常规开发环境宠坏你,因为如果你这样做,你将不得不付出代价。

检查的位置

我的第二个建议是关于在哪里放置调试断点。无论你是在使用print、自定义函数还是ipdb,你仍然需要选择放置提供信息的调用位置,对吧?

好吧,有些地方比其他地方更好,而且有一些处理调试进度的方法比其他方法更好。

我通常避免在if子句中放置断点,因为如果这个子句没有被执行,我就失去了获取所需信息的机会。有时候到达断点并不容易或快速,所以在放置它们之前要仔细思考。

另一个重要的事情是确定从哪里开始。想象一下,你有 100 行代码来处理你的数据。数据从第 1 行开始,不知为何在第 100 行出现了错误。你不知道错误在哪里,所以你该怎么办?你可以在第 1 行设置一个断点,并耐心地逐行检查你的数据。在最坏的情况下,99 行之后(以及许多咖啡杯)你才发现了错误。所以,考虑使用不同的方法。

你从第 50 行开始检查。如果数据是好的,这意味着错误发生在后面,在这种情况下,你将在第 75 行放置下一个断点。如果第 50 行的数据已经不好,你将继续通过在第 25 行放置断点。然后,你重复这个过程。每次,你要么向后移动,要么向前移动,移动的距离是上一次跳跃距离的一半。

在我们最坏的情况下,你的调试将从 1, 2, 3, ..., 99 变为 50, 75, 87, 93, 96, ..., 99,这要快得多。事实上,这是对数级的。这种搜索技术被称为二分搜索,它基于分而治之的方法,并且非常有效,所以尽量掌握它。

使用测试进行调试

你还记得第七章测试、性能分析和处理异常,关于测试的内容吗?嗯,如果我们有一个错误,并且所有测试都通过了,这意味着我们的测试代码库中存在问题或遗漏。所以,一种方法是对测试进行修改,使其能够应对新发现的边缘情况,然后逐步检查代码。这种方法非常有用,因为它确保了当你修复错误时,你的错误将被测试覆盖。

监控

监控也非常重要。当软件应用遇到边缘情况,如网络中断、队列满、外部组件无响应等情况时,它们可能会完全失控并出现非确定性的故障。在这些情况下,了解问题发生时的大致情况,并能以微妙、甚至神秘的方式将其与相关事物联系起来,这一点非常重要。

你可以监控 API 端点、进程、网页可用性和加载时间,以及基本上你可以编码的几乎所有东西。一般来说,当你从头开始构建一个应用程序时,考虑你想要如何监控它是非常有用的。

摘要

在这一简短的章节中,我们看到了调试和排查代码问题的不同技术和建议。调试是软件开发者工作中始终不可或缺的一部分,因此掌握它非常重要。

如果以正确的心态去对待,这可以是一件有趣且有益的事情。

我们看到了如何通过函数、日志、调试器、跟踪信息、性能分析和断言来检查我们的代码库。我们看到了其中大多数技术的简单示例,并且还讨论了一套有助于应对挑战的指导原则。

只需记住始终保持冷静和专注,调试就会变得更容易。这也是一种需要学习并认为最重要的技能。一个焦虑和紧张的大脑无法正常、逻辑和创造性地工作,因此,如果你不加强它,将很难将你的所有知识有效利用。

在下一章中,我们将以另一个小型项目结束本书,其目标是让你在跟随我的旅程开始时更加渴望。

准备好了吗?

第十二章. 总结 – 一个完整的示例

*"不要沉溺于过去,不要梦想未来,专注于当下。"
--释迦牟尼佛

在本章中,我将向你展示最后一个项目。如果你在本书的其他部分工作得很好,这个例子应该很容易。我尽力让它既不会对只读过书的人太难,也不会对那些也花时间做例子、也许还阅读了我建议的链接和主题的人太简单。

挑战

我们现在都面临的一个问题是记住密码。我们为每件事都有密码:网站、手机、卡片、银行账户等等。我们必须记住的信息量实在太多,所以很多人最终反复使用相同的密码。当然,这非常不好,因此,在某个时候,发明了工具来减轻这个问题。其中之一叫做KeepassX,基本上它的工作方式是这样的:你通过设置一个特殊的密码,即主密码来启动软件。一旦进入,你将为你需要记住的每个密码存储一个记录,例如,你的电子邮件账户、银行网站、信用卡信息等等。当你关闭软件时,它将加密用于存储所有这些信息的数据库,这样数据就只能由主密码的所有者访问。因此,有点像《指环王》的风格,只需拥有一个密码,就能统治一切。

我们的实现

我们在本章的目标是创建一个类似但基于 Web 的东西,我想要实现它的方式是编写两个应用程序。

其中一个将是一个用 Falcon 编写的 API。它的目的是双重的,它既能生成也能验证密码。它将为调用者提供有关有效性和评分的信息,评分应表明密码有多强大。

第二个应用是一个 Django 网站,它将提供处理记录的界面。每个记录将保留诸如用户名、电子邮件、密码、URL 等信息。它将显示所有记录的列表,并允许用户创建、更新和删除它们。密码在存储到数据库之前将被加密。

整个项目的目的因此是模仿 KeepassX 的工作方式,尽管它要简单得多。如果你喜欢这个想法,你可以进一步开发它,以添加其他功能并使其更安全。我保证在最后给你一些如何扩展它的建议。

因此,本章在代码方面将会相当密集。这是我为了在有限的空间内给你提供一个有趣的例子而必须付出的代价。

在我们开始之前,请确保您熟悉第十章中介绍的项目,即《正确的 Web 开发》,以便您熟悉 Web 开发的基础知识。请确保您已安装此项目所需的全部pip包:djangofalconcryptographynose-parameterized。如果您下载了本书的源代码,您将在requirements文件夹中找到所有需要安装的内容,而本章的代码将在ch12中。

实现 Django 接口

我希望您熟悉第十章中介绍的概念,该章节主要关于 Django。如果您还没有阅读,这可能是在这里继续阅读之前的好时机。

设置

在您的根目录(对我来说是ch12),其中将包含接口的根目录和 API 的根目录,首先运行以下命令:

$ django-admin startproject pwdweb

这将创建一个 Django 项目的结构,我们现在非常熟悉。我将在这里向您展示接口项目的最终结构:

$ tree -A pwdweb
pwdweb
├── db.sqlite3
├── manage.py
├── pwdweb
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── records
 ├── admin.py
 ├── forms.py
 ├── __init__.py
 ├── migrations
 │   ├── 0001_initial.py
 │   └── __init__.py
 ├── models.py
 ├── static
 │   └── records
 │       ├── css
 │       │   └── main.css
 │       └── js
 │           ├── api.js
 │           └── jquery-2.1.4.min.js
 ├── templates
 │   └── records
 │       ├── base.html
 │       ├── footer.html
 │       ├── home.html
 │       ├── list.html
 │       ├── messages.html
 │       ├── record_add_edit.html
 │       └── record_confirm_delete.html
 ├── templatetags
 │   └── record_extras.py
 ├── urls.py
 └── views.py

如往常一样,如果您没有所有文件,请不要担心,我们会逐步添加。切换到pwdweb文件夹,并确保 Django 已正确设置:$ python manage.py runserver(忽略关于未应用迁移的警告)。

关闭服务器并创建一个应用:$ python manage.py startapp records。太棒了,现在我们可以开始编码了。首先,让我们打开pwdweb/settings.py,并在INSTALLED_APP元组的末尾添加'records'(注意代码中包含逗号)。然后,继续根据您的偏好修复LANGUAGE_CODETIME_ZONE设置,最后在底部添加以下行:

ENCRYPTION_KEY = b'qMhPGx-ROWUDr4veh0ybPRL6viIUNe0vcPDmy67x6CQ='

这是一个自定义加密密钥,它与 Django 设置无关,但稍后我们将需要它,这是放置它的最佳位置。现在不用担心,我们很快就会回到它。

模型层

我们只需要为记录应用添加一个模型:Record。该模型将代表我们想要存储在数据库中的每个记录:

records/models.py

from cryptography.fernet import Fernet
from django.conf import settings
from django.db import models

class Record(models.Model):
    DEFAULT_ENCODING = 'utf-8'

    title = models.CharField(max_length=64, unique=True)
    username = models.CharField(max_length=64)
    email = models.EmailField(null=True, blank=True)
    url = models.URLField(max_length=255, null=True, blank=True)
    password = models.CharField(max_length=2048)
    notes = models.TextField(null=True, blank=True)
    created = models.DateTimeField(auto_now_add=True)
    last_modified = models.DateTimeField(auto_now=True)

    def encrypt_password(self):
        self.password = self.encrypt(self.password)

    def decrypt_password(self):
        self.password = self.decrypt(self.password)

    def encrypt(self, plaintext):
        return self.cypher('encrypt', plaintext)

    def decrypt(self, cyphertext):
        return self.cypher('decrypt', cyphertext)

    def cypher(self, cypher_func, text):
        fernet = Fernet(settings.ENCRYPTION_KEY)
        result = getattr(fernet, cypher_func)(
            self._to_bytes(text))
        return self._to_str(result)

    def _to_str(self, bytes_str):
        return bytes_str.decode(self.DEFAULT_ENCODING)

    def _to_bytes(self, s):
        return s.encode(self.DEFAULT_ENCODING)

首先,我们将DEFAULT_ENCODING类属性设置为'utf-8',这是网络(以及不仅仅是网络)中最流行的编码类型。我们将此属性设置在类上,以避免在多个地方硬编码字符串。

然后,我们继续设置所有模型的字段。正如您所看到的,Django 允许您指定非常具体的字段,例如EmailFieldURLField。之所以建议使用这些特定字段而不是简单的CharField,是因为当我们为该模型创建表单时,我们将免费获得电子邮件和 URL 验证,这是非常棒的。

所有选项都非常标准,我们在第十章,正确进行 Web 开发中看到了它们,但我还是想指出几点。首先,title需要是唯一的,这样每个Record对象都有一个唯一的标题,我们不希望有重复。每个数据库根据其设置、运行的引擎等因素,对字符串的处理方式都略有不同,因此我没有将title字段作为此模型的主键,这本来是自然的事情。我更喜欢避免处理奇怪字符串错误的痛苦,并且我很高兴让 Django 自动为模型添加主键。

另一个你应该理解的选择是null=True, blank=True这对。前者允许字段为NULL,这使得它不是强制的,而第二个允许它为空白(也就是说,一个空字符串)。在 Django 中,它们的使用相当特殊,所以我建议你查看官方文档以了解如何正确使用它们。

最后,关于日期:created 需要设置 auto_add_now=True,这样在对象创建时就会设置当前时间。另一方面,last_modified 需要在每次保存模型时更新,因此我们将其设置为 auto_now=True

在字段定义之后,有一些用于加密和解密密码的方法。将密码以原样保存在数据库中总是一个非常糟糕的主意,因此你总是在保存之前加密它们。

通常,在保存密码时,你会使用单向加密算法(也称为单向哈希函数)来加密它。这意味着一旦你创建了哈希值,就没有办法将其还原为原始密码。

这种加密通常用于身份验证:用户将他们的用户名和密码输入到一个表单中,在提交时,代码会从数据库中的用户记录中获取哈希值,并将其与用户刚刚在表单中输入的密码的哈希值进行比较。如果两个哈希值匹配,这意味着它们是由相同的密码产生的,因此认证被授权。

然而,在这种情况下,我们需要能够恢复密码,否则整个应用程序将不会非常有用。因此,我们将使用所谓的对称加密算法来加密它们。这种方式非常简单:密码(称为明文)被传递给一个加密函数,以及一个密钥。算法将产生一个加密字符串(称为密文),这就是你在数据库中存储的内容。当你想要恢复密码时,你需要密文和密钥。你将它们输入到一个解密函数中,就能得到原始密码。这正是我们所需要的。

为了执行对称加密,我们需要cryptography包,这就是为什么我指示你安装它的原因。

Record类中的所有方法都非常简单。encrypt_passworddecrypt_passwordencryptdecryptpassword字段的快捷方式,并将结果重新分配给自己。

encryptdecrypt方法是对cypher方法的分发器,而_to_str_to_bytes只是几个辅助函数。cryptography库与字节对象一起工作,因此我们需要这些辅助函数在字节和字符串之间来回转换,使用一个常见的编码。

唯一有趣的逻辑在cypher方法中。我可以在encryptdecrypt中直接编写代码,但那样会导致一些冗余,而且我没有机会向你展示访问对象属性的不同方式,所以让我们分析cypher方法的主体。

我们首先创建Fernet类的一个实例,它为我们提供了所需的对称加密功能。我们通过在设置(ENCRYPTION_KEY)中传递密钥来设置实例。创建fernet后,我们需要使用它。我们可以根据cypher_func参数的值来加密或解密。我们使用getattr根据对象本身和属性名称从对象中获取属性。这种技术允许我们动态地获取对象的任何属性。

getattr(fernet, cypher_func)的结果,例如cyper_func'encrypt'时,等同于fernet.encryptgetattr函数返回一个方法,然后我们使用文本参数的字节表示调用该方法。然后我们以字符串格式返回结果。

当通过加密分发器调用此函数时,这个函数相当于以下内容:

def cypher_encrypt(self, text):
        fernet = Fernet(settings.ENCRYPTION_KEY)
        result = fernet.encrypt(
            self._to_bytes(text))
        return self._to_str(result)

当你花时间正确理解它时,你会发现它并没有听起来那么困难。

因此,我们有了我们的模型,所以现在是时候迁移了(我希望你记得这将为您应用程序创建数据库中的表):

$ python manage.py makemigrations
$ python manage.py migrate

现在你应该有一个很好的数据库,其中包含运行界面应用程序所需的所有表。继续创建超级用户($ python manage.py createsuperuser)。

顺便说一句,如果你想生成自己的加密密钥,就像这样一样简单:

>>> from cryptography.fernet import Fernet
>>> Fernet.generate_key()

一个简单的表单

我们需要一个Record模型的表单,所以我们将使用我们在第十章中看到的ModelForm技术,即《正确进行 Web 开发》。

records/forms.py

from django.forms import ModelForm, Textarea
from .models import Record

class RecordForm(ModelForm):
    class Meta:
        model = Record
        fields = ['title', 'username', 'email', 'url',
                  'password', 'notes']
        widgets = {'notes': Textarea(
            attrs={'cols': 40, 'rows': 4})}

我们创建一个继承自ModelFormRecordForm类,这样表单就会自动创建,这是由于 Django 的反射能力。我们只指定要使用哪个模型,显示哪些字段(我们排除了日期,这些由系统自动处理),并为笔记字段的尺寸提供最小样式,该字段将使用Textarea(在 HTML 中是一个多行文本字段)显示。

视图层

界面应用总共有五个页面:主页、记录列表、记录创建、记录更新和记录删除确认。因此,我们必须编写五个视图。正如你马上就会看到的,Django 通过给我们提供可以最小化定制的可重用视图,在很大程度上帮助我们。接下来的所有代码都属于records/views.py文件。

导入和主页视图

为了打破僵局,这里有一些导入和主页视图的代码:

from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.core.urlresolvers import reverse_lazy
from django.views.generic import TemplateView
from django.views.generic.edit import (
    CreateView, UpdateView, DeleteView)
from .forms import RecordForm
from .models import Record

class HomeView(TemplateView):
    template_name = 'records/home.html'

我们从 Django 导入了一些工具。有几个与消息相关的对象,一个 URL 懒加载反转器,以及四种不同类型的视图。我们还导入了我们的Record模型和RecordForm。正如你所见,HomeView类只由两行组成,因为我们只需要指定我们想要使用的模板,其余的只是重用了TemplateView的代码,就像这样。这真是太简单了,几乎感觉像是在作弊。

列出所有记录

在主页视图之后,我们可以编写一个视图来列出数据库中所有的Record实例。

class RecordListView(TemplateView):
    template_name = 'records/list.html'

    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        records = Record.objects.all().order_by('title')  #1
        for record in records:
            record.plaintext = record.decrypt(record.password) #2
        context['records'] = records
        return self.render_to_response(context)

我们只需要再次子类化TemplateView,并重写get方法。我们需要做几件事情:我们从数据库中检索所有记录,并按title排序(#1),然后按顺序解析所有记录,以便为每个记录添加属性plaintext#2),以在页面上显示原始密码。另一种做法是在Record模型中添加一个只读属性,以动态进行解密。我将把它留给你,作为一个有趣的练习,来修改代码以实现这一点。

在恢复和增强记录后,我们将它们放入context字典中,并通过调用render_to_response按常规完成。

创建记录

下面是创建视图的代码:

class EncryptionMixin:
    def form_valid(self, form):
        self.encrypt_password(form)
        return super(EncryptionMixin, self).form_valid(form)

    def encrypt_password(self, form):
        self.object = form.save(commit=False)
        self.object.encrypt_password()
        self.object.save()

class RecordCreateView(
        EncryptionMixin, SuccessMessageMixin, CreateView):
    template_name = 'records/record_add_edit.html'
    form_class = RecordForm
    success_url = reverse_lazy('records:add')
    success_message = 'Record was created successfully'

其逻辑的一部分已被提取出来,以便稍后在更新视图中重用。让我们从EncryptionMixin开始。它所做的只是重写form_valid方法,这样在将新的Record实例保存到数据库之前,我们确保在保存表单的结果对象上调用encrypt_password。换句话说,当用户提交表单以创建新的Record时,如果表单验证成功,则调用form_valid方法。在这个方法中,通常会发生的是,从ModelForm实例创建一个对象,如下所示:

self.object = form.save()

我们需要干预这种行为,因为按照原样运行此代码将保存带有原始密码的记录,而这并没有加密。所以我们将其更改为调用form上的save并传递commit=False,这将从form创建Record实例,但不会尝试将其保存到数据库中。紧接着,我们在该实例上加密密码,然后我们最终可以调用save,实际上将其提交到数据库。

由于我们既需要在创建记录时也需要在更新记录时实现这种行为,所以我将其提取为一个 mixin。

注意

也许,对于这个密码加密逻辑的更好解决方案是创建一个自定义的Field(从CharField继承是最简单的方式)并添加必要的逻辑,这样当我们处理从数据库到Record实例的转换时,加密和解密逻辑会自动为我们执行。虽然这种方法更优雅,但需要我更多地偏离主题来解释 Django 的内部机制,这对于本例的范围来说太多了。像往常一样,如果你觉得这是一个挑战,你可以尝试自己来做。

在创建EncryptionMixin类之后,我们可以在RecordCreateView类中使用它。我们还继承了另外两个类:SuccessMessageMixinCreateView。消息混合类为我们提供了在创建成功时快速设置消息的逻辑,而CreateView则为我们提供了从表单创建对象的必要逻辑。

你可以看到,我们唯一需要编写的是一些定制化:模板名称、表单类、成功消息和 URL。其他所有事情都由 Django 优雅地为我们处理。

更新记录

更新Record实例的代码仅稍微复杂一些。我们只需要在将记录数据填充到表单之前,添加一些解密密码的逻辑。

class RecordUpdateView(
        EncryptionMixin, SuccessMessageMixin, UpdateView):
    template_name = 'records/record_add_edit.html'
    form_class = RecordForm
    model = Record
    success_message = 'Record was updated successfully'

    def get_context_data(self, **kwargs):
        kwargs['update'] = True
        return super(
            RecordUpdateView, self).get_context_data(**kwargs)

    def form_valid(self, form):
        self.success_url = reverse_lazy(
            'records:edit',
            kwargs={'pk': self.object.pk}
        )
        return super(RecordUpdateView, self).form_valid(form)

    def get_form_kwargs(self):
        kwargs = super(RecordUpdateView, self).get_form_kwargs()
        kwargs['instance'].decrypt_password()
        return kwargs

在这个视图中,我们仍然继承了EncryptionMixinSuccessMessageMixin,但我们使用的视图类是UpdateView

前四行与之前一样是定制化,我们设置了模板名称、表单类、Record模型和成功消息。我们不能将success_url设置为类属性,因为我们想要将成功的编辑重定向到该记录的相同编辑页面,为了做到这一点,我们需要编辑实例的 ID。不用担心,我们会用另一种方式来做。

首先,我们重写get_context_data以在kwargs参数中将'update'设置为True,这意味着一个键'update'最终会出现在传递给模板以渲染页面的context字典中。我们这样做是因为我们想要使用相同的模板来创建和更新记录,因此我们将在上下文中使用这个变量来理解我们处于哪种情况。还有其他方法可以做到这一点,但这个方法既快又简单,我喜欢它是因为它很明确。

在重写get_context_data之后,我们需要处理 URL 重定向。我们在form_valid方法中这样做,因为我们知道,如果我们到达那里,这意味着Record实例已经成功更新。我们反转了'records:edit'视图,这正是我们正在工作的视图,传递了所涉及对象的键。我们从这个self.object.pk中获取这个信息。

有一个原因使得在视图实例上保存对象很有帮助,那就是我们可以在需要时使用它,而无需修改视图中的许多方法的签名以便传递对象。这种设计非常有帮助,并允许我们用很少的代码实现很多功能。

我们需要做的最后一件事是在填充用户表单之前解密实例上的密码。在get_form_kwargs方法中做这件事很简单,在那里你可以访问kwargs字典中的Record实例,并对其调用decrypt_password

这就是我们更新记录所需做的全部工作。如果你想想,我们不得不写的代码量真的很少,多亏了 Django 基于类的视图。

小贴士

了解哪个方法是最好的重写方法的一个好方法是查看 Django 官方文档,或者在这个情况下,查看源代码并查看基于类的视图部分。你将能够欣赏到 Django 开发者在这里做了多少工作,这样你只需要触摸最少的代码来定制你的视图。

删除记录

在这三个动作中,删除记录无疑是其中最简单的一个。我们只需要以下代码:

class RecordDeleteView(SuccessMessageMixin, DeleteView):
    model = Record
    success_url = reverse_lazy('records:list')

    def delete(self, request, *args, **kwargs):
        messages.success(
            request, 'Record was deleted successfully')
        return super(RecordDeleteView, self).delete(
            request, *args, **kwargs)

我们只需要继承SuccessMessageMixinDeleteView,这给了我们所有需要的东西。我们设置模型和成功 URL 作为类属性,然后只重写delete方法,以便添加一个将在列表视图中显示的漂亮消息(这是删除后我们重定向到的位置)。

我们不需要指定模板名称,因为我们将使用 Django 默认推断的名称:record_confirm_delete.html

使用这个最终视图,我们就可以拥有一个很好的界面,我们可以用它来处理Record实例。

设置 URL

在我们转向模板层之前,让我们设置 URL。这次,我想向你展示我在第十章中提到的包含技术,Web Development Done Right

pwdweb/urls.py

from django.conf.urls import include, url
from django.contrib import admin
from records import urls as records_url
from records.views import HomeView

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^records/', include(records_url, namespace='records')),
    url(r'^$', HomeView.as_view(), name='home'),
]

这些是主要项目的 URL。我们通常有管理员、主页,然后在记录部分,我们包括另一个urls.py文件,我们在records应用中定义这个文件。这种技术使得应用可以重用且自包含。请注意,在包含另一个urls.py文件时,你可以传递命名空间信息,然后你可以在reverse函数或url模板标签中使用这些信息。例如,我们已经看到RecordUpdateView的路径是'records:edit'。这个字符串的第一部分是命名空间,第二部分是我们为视图给出的名称,正如你在以下代码中看到的那样:

records/urls.py

from django.conf.urls import include, url
from django.contrib import admin
from .views import (RecordCreateView, RecordUpdateView,
                    RecordDeleteView, RecordListView)

urlpatterns = [
    url(r'^add/$', RecordCreateView.as_view(), name='add'),
    url(r'^edit/(?P<pk>[0-9]+)/$', RecordUpdateView.as_view(),
        name='edit'),
    url(r'^delete/(?P<pk>[0-9]+)/$', RecordDeleteView.as_view(),
        name='delete'),
    url(r'^$', RecordListView.as_view(), name='list'),
]

我们定义了四个不同的 url 实例。有一个用于添加记录,由于对象尚未存在,因此不需要主键信息。然后我们有两个 url 实例用于更新和删除记录,对于这些,我们还需要指定要传递给视图的主键信息。由于 Record 实例具有整数 ID,我们可以安全地将它们传递到 URL 上,遵循良好的 URL 设计实践。最后,我们定义了一个 url 实例用于记录列表。

所有 url 实例都有 name 信息,这些信息在视图和模板中使用。

模板层

让我们从我们将用作其余部分的模板开始:

records/templates/records/base.html

{% load static from staticfiles %}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0">
    <link href="{% static "records/css/main.css" %}"
          rel="stylesheet">
    <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>
    {% block scripts %}
 <script
 src="img/{% static "records/js/jquery-2.1.4.min.js" %}">
 </script>
 {% endblock scripts %}
  </body>
</html>

它与我用于 第十章,正确进行 Web 开发 的模板非常相似,尽管它稍微更紧凑,并且有一个主要的不同之处。我们将在每个页面上导入 jQuery。

注意

jQuery 是最受欢迎的 JavaScript 库。它允许你编写在所有主要浏览器上都能工作的代码,并为你提供了许多额外的工具,例如从浏览器本身执行异步调用(AJAX)的能力。我们将使用这个库来执行对 API 的调用,无论是生成还是验证我们的密码。你可以在 jquery.com/ 下载它,并将其放入 pwdweb/records/static/records/js/ 文件夹(你可能需要修改模板中的导入)。

我为您突出显示了模板中唯一有趣的部分。请注意,我们是在模板的末尾加载 JavaScript 库。这是一个常见的做法,因为 JavaScript 用于操作页面,所以将库放在末尾有助于避免由于页面上的元素尚未渲染而导致 JavaScript 代码失败的情况。

主页和页脚模板

主模板非常简单:

records/templates/records/home.html

{% extends "records/base.html" %}
{% block title %}Welcome to the Records website.{% endblock %}

{% block page-content %}
  <h1>Welcome {{ user.first_name }}!</h1>
  <div class="home-option">To create a record click
    <a href="{% url "records:add" %}">here.</a>
  </div>
  <div class="home-option">To see all records click
    <a href="{% url "records:list" %}">here.</a>
  </div>
{% endblock page-content %}

与我们在 第十章,正确进行 Web 开发 中看到的 home.html 模板相比,这里没有什么新的内容。页脚模板实际上是完全相同的:

records/templates/records/footer.html

<div class="footer">
  Go back <a href="{% url "home" %}">home</a>.
</div>

列出所有记录

这个用于列出所有记录的模板相当简单:

records/templates/records/list.html

{% extends "records/base.html" %}
{% load record_extras %}
{% block title %}Records{% endblock title %}

{% block page-content %}
  <h1>Records</h1><span name="top"></span>
  {% include "records/messages.html" %}

  {% for record in records %}
  <div class="record {% cycle 'row-light-blue' 'row-white' %}"
       id="record-{{ record.pk }}">
    <div class="record-left">
      <div class="record-list">
        <span class="record-span">Title</span>{{ record.title }}
      </div>
      <div class="record-list">
        <span class="record-span">Username</span>
        {{ record.username }}
      </div>
      <div class="record-list">
        <span class="record-span">Email</span>{{ record.email }}
      </div>
      <div class="record-list">
        <span class="record-span">URL</span>
          <a href="{{ record.url }}" target="_blank">
            {{ record.url }}</a>
      </div>
      <div class="record-list">
        <span class="record-span">Password</span>
        {% hide_password record.plaintext %}
      </div>
    </div>
    <div class="record-right">
      <div class="record-list">
        <span class="record-span">Notes</span>
        <textarea rows="3" cols="40" class="record-notes"
                  readonly>{{ record.notes }}</textarea>
      </div>
      <div class="record-list">
        <span class="record-span">Last modified</span>
        {{ record.last_modified }}
      </div>
      <div class="record-list">
        <span class="record-span">Created</span>
        {{ record.created }}
      </div>

    </div>
    <div class="record-list-actions">
      <a href="{% url "records:edit" pk=record.pk %}">» edit</a>
      <a href="{% url "records:delete" pk=record.pk %}">» delete
      </a>
    </div>
  </div>
  {% endfor %}
{% endblock page-content %}

{% block footer %}
  <p><a href="#top">Go back to top</a></p>
  {% include "records/footer.html" %}
{% endblock footer %}

对于这个模板,我也已经突出显示了我希望您关注的部分。首先,我加载了一个自定义标签模块,record_extras,我们稍后会用到。我还添加了一个锚点在顶部,这样我们就可以在页面底部放置一个链接到它,以避免需要滚动到顶部。

然后,我包含了一个模板,为我提供显示 Django 消息的 HTML 代码。这是一个非常简单的模板,我很快就会向您展示。

然后,我们定义了一个 div 元素的列表。每个 Record 实例都有一个容器 div,其中包含另外两个主要的 div 元素:record-leftrecord-right。为了使它们并排显示,我在 main.css 文件中设置了此类:

.record-left { float: left; width: 300px;}

最外层的div容器(具有record类的那个),有一个id属性,我已将其用作锚点。这允许我们在记录删除页面上点击取消,这样如果我们改变主意,不想删除记录,我们可以回到列表页面,并定位到正确的位置。

然后记录的每个属性都在具有record-list类的div元素中显示。这些类中的大多数只是为了让我能够为 HTML 元素设置一些填充和尺寸。

下一个有趣的部分是hide_password标签,它接受明文,即未加密的密码。此自定义标签的目的是显示与原始密码一样多的'*'字符,这样如果有人在你浏览页面时经过,他们就不会看到你的密码。然而,将鼠标悬停在该序列的'*'字符上会显示原始密码的提示。以下是hide_password标签的代码:

records/templatetags/record_extras.py

from django import template
from django.utils.html import escape

register = template.Library()

@register.simple_tag
def hide_password(password):
    return '<span title="{0}">{1}</span>'.format(
        escape(password), '*' * len(password))

这里没有什么特别之处。我们只是将此函数注册为简单的标签,然后我们就可以在需要的地方使用它。它接受一个password并将其作为span元素的tooltip,其主要内容是一系列'*'字符。只需注意一点:我们需要转义密码,以确保它不会破坏我们的 HTML(想想如果密码包含一个双引号",会发生什么)。

list.html模板而言,下一个有趣的部分是我们将readonly属性设置到textarea元素上,这样就不会给用户留下他们可以即时修改笔记的印象。

然后,我们在容器div的底部为每个Record实例设置几个链接。有一个用于编辑页面,另一个用于删除页面。请注意,我们需要传递url标签不仅包含namespace:name字符串,还要传递主键信息,这是我们在urls.py模块中为这些视图设置的 URL 设置所要求的。

最后,我们导入页脚并设置链接到页面顶部的锚点。

现在,正如承诺的那样,这是消息的代码:

records/templates/records/messages.html

{% if messages %}
  {% for message in messages %}
    <p class="{{ message.tags }}">{{ message }}</p>
  {% endfor %}
{% endif %}

此代码确保只有当至少有一条消息需要显示时才会显示消息。我们给p标签赋予class信息,以便在绿色中显示成功消息,在红色中显示错误消息。

如果你从书籍的源代码中获取main.css文件,你现在将能够可视化列表页面(你的将是空的,你仍然需要将其插入数据),它应该看起来像这样:

列出所有记录

如您所见,目前数据库中有两个记录。我正悬停在第一个的密码上,这是我妹妹学校的平台账户密码,密码在工具提示中显示。将两个div元素分成左右两部分,有助于使行更小,从而使整体结果更悦目。重要信息在左侧,辅助信息在右侧。行颜色在非常浅的蓝色和白色之间交替。

每一行底部左侧都有一个编辑删除链接。在我们看到创建这些链接的模板代码之后,我们将展示这两个链接的页面。

维护这个界面所有信息的 CSS 代码如下:

records/static/records/css/main.css

html, body, * {
  font-family: 'Trebuchet MS', Helvetica, sans-serif; }
a { color: #333; }
.record {
  clear: both; padding: 1em; border-bottom: 1px solid #666;}
.record-left { float: left; width: 300px;}
.record-list { padding: 2px 0; }
.fieldWrapper { padding: 5px; }
.footer { margin-top: 1em; color: #333; }
.home-option { padding: .6em 0; }
.record-span { font-weight: bold; padding-right: 1em; }
.record-notes { vertical-align: top; }
.record-list-actions { padding: 4px 0; clear: both; }
.record-list-actions a { padding: 0 4px; }
#pwd-info { padding: 0 6px; font-size: 1.1em; font-weight: bold;}
#id_notes { vertical-align: top; }
/* Messages */
.success, .errorlist {font-size: 1.2em; font-weight: bold; }
.success {color: #25B725; }
.errorlist {color: #B12B2B; }
/* colors */
.row-light-blue { background-color: #E6F0FA; }
.row-white { background-color: #fff; }
.green { color: #060; }
.orange { color: #FF3300; }
.red { color: #900; }

请记住,我不是 CSS 大师,所以请把这个文件当作它本来的样子,一种相当天真地为我们的界面提供样式的方法。

创建和编辑记录

现在是有趣的部分。创建和更新记录。我们将使用相同的模板,所以我们期望有一些决策逻辑在那里,它会告诉我们我们处于两种情况中的哪一种。实际上,代码不会太多。然而,这个模板最激动人心的部分是其相关的 JavaScript 文件,我们将在之后立即检查它。

records/templates/records/record_add_edit.html

{% extends "records/base.html" %}
{% load static from staticfiles %}
{% block title %}
  {% if update %}Update{% else %}Create{% endif %} Record
{% endblock title %}

{% block page-content %}
  <h1>{% if update %}Update a{% else %}Create a new{% endif %}
    Record
  </h1>
  {% include "records/messages.html" %}

  <form action="." method="post">{% csrf_token %}
    {{ form.non_field_errors }}

    <div class="fieldWrapper">{{ form.title.errors }}
      {{ form.title.label_tag }} {{ form.title }}</div>

    <div class="fieldWrapper">{{ form.username.errors }}
      {{ form.username.label_tag }} {{ form.username }}</div>

    <div class="fieldWrapper">{{ form.email.errors }}
      {{ form.email.label_tag }} {{ form.email }}</div>

    <div class="fieldWrapper">{{ form.url.errors }}
      {{ form.url.label_tag }} {{ form.url }}</div>

    <div class="fieldWrapper">{{ form.password.errors }}
      {{ form.password.label_tag }} {{ form.password }}
      <span id="pwd-info"></span></div>

    <button type="button" id="validate-btn">
 Validate Password</button>
 <button type="button" id="generate-btn">
 Generate Password</button>

    <div class="fieldWrapper">{{ form.notes.errors }}
      {{ form.notes.label_tag }} {{ form.notes }}</div>

    <input type="submit"
      value="{% if update %}Update{% else %}Insert{% endif %}">
  </form>
{% endblock page-content %}

{% block footer %}
  <br>{% include "records/footer.html" %}<br>
  Go to <a href="{% url "records:list" %}">the records list</a>.
{% endblock footer %}

{% block scripts %}
  {{ block.super }}
  <script src="img/{% static "records/js/api.js" %}"></script>
{% endblock scripts %}

如同往常,我已经突出显示了重要部分,让我们一起来分析这段代码。

您可以看到在title块中的第一个决策逻辑。类似的决策逻辑也显示在页面的页眉(h1 HTML 标签)和表单末尾的submit按钮上。

除了这个逻辑之外,我还想让您关注的是表单及其内容。我们将 action 属性设置为点,这意味着这个页面,这样我们就不需要根据哪个视图在服务页面来定制它。此外,我们立即处理了跨站请求伪造令牌,如第十章中所述,正确进行 Web 开发

注意,这次我们不能将整个表单的渲染完全交给 Django,因为我们想添加一些额外的东西,所以我们需要降低一个粒度级别,并要求 Django 为我们渲染每个单独的字段,包括任何错误和其标签。这样我们仍然可以节省很多精力,同时我们也可以按我们的喜好自定义表单。在这种情况下,编写一个小模板来渲染字段以避免为每个字段重复那三行代码是很常见的。然而,由于这个表单很小,我决定避免进一步提高复杂性级别。

span元素pwd-info包含我们从 API 获取的密码信息。之后的两个按钮validate-btngenerate-btn与 API 的 AJAX 调用相关联。

在模板的末尾,在scripts块中,我们需要加载包含与 API 交互代码的api.jsJavaScript 文件。我们还需要使用block.super,这将加载父模板中同一块中的任何代码(例如,jQuery)。block.super基本上是模板中调用super(ClassName, self)在 Python 中的等效。在加载我们的库之前,加载 jQuery 非常重要,因为后者基于前者。

与 API 通信

现在我们来看看那个 JavaScript。我不期望你理解一切。首先,这是一本 Python 书,其次,你被认为是一个初学者(尽管到现在,忍者训练),所以不要害怕。然而,由于 JavaScript 现在已经变得在处理网络环境时至关重要,即使对于 Python 开发者来说,掌握它的工作知识也非常重要,所以尽量从我将要展示的内容中获取最大收益。我们首先看看密码生成:

records/static/records/js/api.js

var baseURL = 'http://127.0.0.1:5555/password';

var getRandomPassword = function() {
  var apiURL = '{url}/generate'.replace('{url}', baseURL);
  $.ajax({
    type: 'GET',
    url: apiURL,
    success: function(data, status, request) {
      $('#id_password').val(data[1]);
    },
    error: function() { alert('Unexpected error'); }
  });
}

$(function() {
  $('#generate-btn').click(getRandomPassword);
});

首先,我们为基本 API URL 设置一个变量:baseURL。然后,我们定义getRandomPassword函数,它非常简单。一开始,它定义了apiURL,通过替换技术扩展baseURL。即使语法与 Python 不同,你也不应该有任何问题理解这一行。

在定义了apiURL之后,有趣的部分出现了。我们调用$.ajax,这是执行 AJAX 调用的 jQuery 函数。那个$是 jQuery 的快捷方式。正如你在调用体中看到的,它是对apiURLGET请求。如果成功(success: ...),将运行一个匿名函数,将id_password文本字段的值设置为返回数据的第二个元素。当我们检查 API 代码时,我们将看到数据的结构,所以现在不用担心。如果发生错误,我们只需向用户提醒出现了意外的错误。

注意

HTML 中的密码字段为什么有id_password作为 ID,是因为 Django 渲染表单的方式。你可以使用自定义前缀来定制这种行为,例如。在这种情况下,我对 Django 的默认设置感到满意。

在函数定义之后,我们运行几行代码将generate-btn按钮的click事件绑定到getRandomPassword函数。这意味着在浏览器引擎运行此代码之后,每次我们点击generate-btn按钮,都会调用getRandomPassword函数。

这并不那么可怕,对吧?所以让我们看看验证部分需要什么。

现在密码字段中有一个值,我们想要验证它。我们需要调用 API 并检查其响应。由于密码可能包含奇怪的字符,我不想通过 URL 传递它们,因此我将使用POST请求,这允许我将密码放在其主体中。为此,我需要以下代码:

var validatePassword = function() {
  var apiURL = '{url}/validate'.replace('{url}', baseURL);
  $.ajax({
    type: 'POST',
    url: apiURL,
    data: JSON.stringify({'password': $('#id_password').val()}),
    contentType: "text/plain",  // Avoid CORS preflight
    success: function(data, status, request) {
      var valid = data['valid'], infoClass, grade;
      var msg = (valid?'Valid':'Invalid') + ' password.';
      if (valid) {
        var score = data['score']['total'];
        grade = (score<10?'Poor':(score<18?'Medium':'Strong'));
        infoClass = (score<10?'red':(score<18?'orange':'green'));
        msg += ' (Score: {score}, {grade})'
          .replace('{score}', score).replace('{grade}', grade);
      }
      $('#pwd-info').html(msg);
      $('#pwd-info').removeClass().addClass(infoClass);
    },
    error: function(data) { alert('Unexpected error'); }
  });
}

$(function() {
  $('#validate-btn').click(validatePassword);
});

这个概念与之前相同,只是这次是为validate-btn按钮。AJAX 调用的主体类似。我们使用POST请求而不是GET请求,并将数据定义为 JSON 对象,这在 Python 中相当于使用json.dumps({'password': 'some_pwd'})

contentType行是一个快速修复,以避免浏览器 CORS 预检行为的问题。跨源资源共享CORS)是一种机制,允许网页上的受限资源从请求源域之外的另一个域请求。简而言之,由于 API 位于127.0.0.1:5555,而接口运行在127.0.0.1:8000,没有这个修复,浏览器不会允许我们执行调用。在生产环境中,你可能想检查 JSONP 的文档,这是一个更好的(尽管更复杂)解决方案。

如果调用成功,匿名函数的主体显然只是稍微复杂一些。我们只需要理解密码是否有效(从data['valid']),然后根据其分数分配一个等级和 CSS 类。有效性和分数信息来自 API 响应。

这段代码中唯一棘手的部分是 JavaScript 三元运算符,所以让我们看看一个比较示例:

# Python
error = 'critical' if error_level > 50 else 'medium'
// JavaScript equivalent
error = (error_level > 50 ? 'critical' : 'medium');

通过这个示例,你应该没有问题阅读函数中其余的逻辑。我知道,我本可以使用普通的if (...),但 JavaScript 程序员经常使用三元运算符,所以你应该习惯它。稍微更努力地思考以理解代码是一种很好的训练。

最后,我想让你看看那个函数的末尾。我们将pwd-infospan 元素的html设置为组装的消息(msg),然后对其进行样式化。在一行中,我们从这个元素中移除所有 CSS 类(没有参数的removeClass()会这样做),并添加infoClassinfoClass'red''orange''green'之一。如果你回到main.css文件,你会在底部看到它们。

现在我们已经看到了模板代码和用于调用的 JavaScript,让我们看看页面的截图。我们将编辑第一条记录,关于我妹妹学校的记录。

与 API 对话

在图片中,你可以看到我通过点击生成 密码按钮更新了密码。然后,我保存了记录(所以你能够看到顶部的消息),最后,我点击了验证 密码按钮。

结果在密码字段的右侧以绿色显示。它很强(实际上我们能够得到的最高分数是 23),所以消息以漂亮的绿色显示。

删除记录

要删除记录,转到列表并点击删除链接。你将被重定向到一个请求你确认的页面;然后你可以选择继续并删除那个可怜的记录,或者取消请求并返回到列表页面。模板代码如下:

records/templates/records/record_confirm_delete.html

{% extends "records/base.html" %}
{% block title %}Delete record{% endblock title %}

{% block page-content %}
  <h1>Confirm Record Deletion</h1>
  <form action="." method="post">{% csrf_token %}
    <p>Are you sure you want to delete "{{ object }}"?</p>
    <input type="submit" value="Confirm" />&nbsp;
    <a href="{% url "records:list" %}#record-{{ object.pk }}">
       » cancel</a>
  </form>
{% endblock page-content %}

由于这是一个标准的 Django 视图模板,我们需要使用 Django 采用的命名约定。因此,相关的记录在模板中被称为object{{ object }}标签显示对象的字符串表示形式,目前并不十分美观,因为整行将读作:你确定要删除"Record object"吗

这是因为我们还没有在我们的Model类中添加__str__方法,这意味着当请求实例的字符串表示形式时,Python 并不知道要显示什么。让我们通过完成我们的模型,在类体底部添加__str__方法来解决这个问题:

records/models.py

class Record(models.Model):
    ...

    def __str__(self):
 return '{}'.format(self.title)

重新启动服务器,现在页面将显示:你确定要删除"某些银行"吗,其中某些银行是我点击的删除链接的记录的title

我们本可以使用{{ object.title }},但我更喜欢从根本上解决问题,而不仅仅是解决效果。添加__str__方法实际上是你应该为所有模型做的事情。

在这个最后的模板中,有趣的部分实际上是取消操作的链接。我们使用url标签回到列表视图(records:list),但我们向其中添加了锚信息,使其最终看起来像这样(这是针对pk=2):

http://127.0.0.1:8000/records/#record-2

这将返回到列表页面,并滚动到具有 ID 记录 2 的容器div,这很好。

这就结束了接口。尽管这一节与我们在第十章中看到的内容相似,即《正确进行 Web 开发》,但我们在这章中更多地关注了代码。我们看到了 Django 基于类的视图是多么有用,甚至触及了一些酷的 JavaScript。运行$ python manage.py runserver,你的界面应该会在http://127.0.0.1:8000上启动并运行。

注意

如果你有所疑问,127.0.0.1意味着localhost——你的电脑——而8000是服务器绑定的端口,用于监听传入的请求。

现在是时候用这个项目的第二部分来增加一些趣味性了。

实现 Falcon API

我们即将编写的 Falcon 项目的结构远不如接口那么复杂。我们将总共编写五个文件。在你的ch12文件夹中,创建一个新的名为pwdapi的文件。这是它的最终结构:

$ tree -A pwdapi/
pwdapi/
├── core
│   ├── handlers.py
│   └── passwords.py
├── main.py
└── tests
 └── test_core
 ├── test_handlers.py
 └── test_passwords.py

该 API 全部使用 TDD 进行编码,因此我们也将探索测试。然而,我认为如果你先看到代码,你将更容易理解测试,所以我们将从代码开始。

主要应用程序

这是 Falcon 应用程序的代码:

main.py

import falcon
from core.handlers import (
    PasswordValidatorHandler,
    PasswordGeneratorHandler,
)

validation_handler = PasswordValidatorHandler()
generator_handler = PasswordGeneratorHandler()

app = falcon.API()
app.add_route('/password/validate/', validation_handler)
app.add_route('/password/generate/', generator_handler)

就像在第十章的例子中一样,正确进行 Web 开发,我们首先为每个需要的处理程序创建一个实例,然后创建一个falcon.API对象,通过调用它的add_route方法,我们将设置路由到我们的 API 的 URL。我们稍后会看到处理程序的定义。首先,我们需要一些辅助函数。

编写辅助函数

在本节中,我们将查看我们将用于处理程序的一两个类。按照单一职责原则进行逻辑分离总是好的。

注意

在面向对象编程(OOP)中,单一职责原则(SRP)指出,每个类都应该对其提供的软件功能的一部分负责,并且这种责任应该完全封装在类中。它的所有服务都应该与这种责任紧密一致。

单一职责原则是S.O.L.I.D.中的S,这是由罗伯特·马丁(Robert Martin)提出的面向对象和软件设计的前五个原则的首字母缩写。

我强烈建议你打开浏览器并阅读这个主题,它非常重要。

辅助函数部分的所有代码都属于core/passwords.py模块。以下是它的开始部分:

from math import ceil
from random import sample
from string import ascii_lowercase, ascii_uppercase, digits

punctuation = '!#$%&()*+-?@_|'
allchars = ''.join(
    (ascii_lowercase, ascii_uppercase, digits, punctuation))

我们需要处理一些随机计算,但这里最重要的部分是允许的字符。我们将允许字母、数字和一组标点符号。为了简化代码编写,我们将这些部分合并到allchars字符串中。

编写密码验证器

PasswordValidator类是整个 API 中我最喜欢的逻辑部分。它公开了一个is_validscore方法。后者运行所有定义的验证器(同一类中的“私有”方法),并将分数收集到一个单一的字典中,作为结果返回。我将按方法逐一编写这个类,以免它变得过于复杂:

class PasswordValidator:
    def __init__(self, password):
        self.password = password.strip()

它首先将password(没有前导或尾随空格)设置为实例属性。这样我们就不必在方法之间传递它。所有后续的方法都属于这个类。

    def is_valid(self):
        return (len(self.password) > 0 and
                all(char in allchars for char in self.password))

当密码的长度大于 0 且所有字符都属于allchars字符串时,密码是有效的。当你阅读is_valid方法时,它几乎就是英语(这就是 Python 的神奇之处)。all是一个内置函数,它告诉你你提供给它的可迭代元素是否都是True

    def score(self):
        result = {
            'length': self._score_length(),
            'case': self._score_case(),
            'numbers': self._score_numbers(),
            'special': self._score_special(),
            'ratio': self._score_ratio(),
        }
        result['total'] = sum(result.values())
        return result

这是另一个主要方法。它非常简单,只是准备一个包含所有验证器结果的字典。唯一独立的逻辑部分发生在最后,当我们从每个验证器中汇总分数并将其分配给字典中的'total'键时,只是为了方便。

如您所见,我们通过长度、字母大小写、数字和特殊字符的存在以及最后字母和数字之间的比例来评分密码。字母允许字符在 26 * 2 = 52 种可能的选择之间,而数字只允许 10 种。因此,字母与数字比例更高的密码更难破解。

让我们看看长度验证器:

    def _score_length(self):
        scores_list = ([0]*4) + ([1]*4) + ([3]*4) + ([5]*4)
        scores = dict(enumerate(scores_list))
        return scores.get(len(self.password), 7)

我们将 0 分分配给长度小于 4 个字符的密码,1 分分配给长度小于 8 个字符的密码,3 分分配给长度小于 12 个字符的密码,5 分分配给长度小于 16 个字符的密码,以及 16 个或更多长度的密码得 7 分。

为了避免一系列的 if/elif 子句,我在这里采用了函数式风格。我准备了一个 score_list,它基本上是 [0, 0, 0, 0, 1, 1, 1, 1, 3, ...]。然后,通过枚举它,我为每个长度小于 16 的长度得到了一个 (length, score) 对。我把这些对放入一个字典中,这给了我字典形式的等效,所以它应该看起来像这样:{0:0, 1:0, 2:0, 3:0, 4:1, 5:1, ...}。然后,我使用密码的长度对这个字典进行 get 操作,将默认值设置为 7(这将返回长度为 16 或更多的值,这些值不在字典中)。

当然,我并不反对 if/elif 子句,但我想要抓住这个机会在这个最后一章中展示不同的编码风格,帮助你习惯阅读偏离你通常预期的代码。这只会带来好处。

    def _score_case(self):
        lower = bool(set(ascii_lowercase) & set(self.password))
        upper = bool(set(ascii_uppercase) & set(self.password))
        return int(lower or upper) + 2 * (lower and upper)

我们验证案例的方式再次使用了一个巧妙的技巧。当密码与所有小写字母的交集非空时,lowerTrue,否则为 Falseupper 的行为方式相同,只是针对大写字母。

为了理解最后一行的评估,让我们再次使用内部-外部技术:当至少有一个为 True 时,lower or upperTrue。当它是 True 时,它将被 int 类转换为 1。这相当于说,如果至少有一个字符,无论大小写,得分增加 1 分,否则保持在 0 分。

现在来看第二部分:当两个都为 True 时,lower and upperTrue,这意味着我们至少有一个小写和一个大写字母。这意味着,为了破解密码,暴力算法必须遍历 52 个字母而不是仅仅 26 个。因此,当它是 True 时,我们得到额外的 2 分。

因此,这个验证器根据密码产生一个在(0,1,3)范围内的结果。

    def _score_numbers(self):
        return 2 if (set(self.password) & set(digits)) else 0

对数字的评分更简单。如果我们至少有一个数字,我们得到 2 分,否则得到 0 分。在这种情况下,我使用三元运算符来返回结果。

    def _score_special(self):
        return 4 if (
            set(self.password) & set(punctuation)) else 0

特殊字符验证器与之前的逻辑相同,但由于特殊字符在破解密码时增加了相当多的复杂性,我们得到了 4 分而不是 2 分。

最后一个是验证字母和数字之间的比例。

    def _score_ratio(self):
        alpha_count = sum(
            1 if c.lower() in ascii_lowercase else 0
            for c in self.password)
        digits_count = sum(
            1 if c in digits else 0 for c in self.password)
        if digits_count == 0:
            return 0
        return min(ceil(alpha_count / digits_count), 7)

我在 sum 调用的表达式中突出了条件逻辑。在第一种情况下,对于每个小写形式在 ascii_lowercase 中的字符,我们得到一个 1。这意味着将所有这些 1 相加,我们就能得到所有字母的确切数量。然后,我们对数字做同样的处理,只是我们使用数字字符串作为参考,并且我们不需要将字符转换为小写。当 digits_count 为 0 时,alpha_count / digits_count 会引发 ZeroDivisionError,因此我们检查 digits_count,当它为 0 时返回 0。如果我们有数字,我们计算 letters:digits 比率的上限,并返回它,上限为 7。

当然,有许多不同的方法可以计算密码的分数。我的目标不是给你提供最优秀的算法来做这件事,而是展示你如何着手实现它。

编写密码生成器

密码生成器是一个比验证器更简单的类。然而,我这样编写代码是为了我们不需要创建一个实例来使用它,只是为了再次展示不同的编码风格。

class PasswordGenerator:

    @classmethod
    def generate(cls, length, bestof=10):
        candidates = sorted([
            cls._generate_candidate(length)
            for k in range(max(1, bestof))
        ])
        return candidates[-1]

    @classmethod
    def _generate_candidate(cls, length):
        password = cls._generate_password(length)
        score = PasswordValidator(password).score()
        return (score['total'], password)

    @classmethod
    def _generate_password(cls, length):
        chars = allchars * (ceil(length / len(allchars)))
        return ''.join(sample(chars, length))

在这三种方法中,只有第一种是打算使用的。让我们从最后一种方法开始分析:_generate_password

这个方法简单地接受一个长度,这是我们想要的密码的期望长度,然后调用 sample 函数从 chars 字符串中获取长度为元素的人口。sample 函数的返回值是一个长度为元素的列表,我们需要使用 join 将其转换为字符串。

在我们调用 sample 之前,先考虑一下,如果所需的长度超过了 allchars 的长度会怎样?这次调用将导致 ValueError: Sample larger than the population

因此,我们以这种方式创建 chars 字符串,使其通过将 allchars 字符串连接足够多次来覆盖所需的长度。为了给你一个例子,假设我们需要一个 27 个字符的密码,并且假设 allchars 是 10 个字符长。length / len(allchars) 得到 2.7,当传递给 ceil 函数时,变为 3。这意味着我们将 chars 分配给 allchars 字符串的三重连接,因此 chars 将是 10 * 3 = 30 个字符长,这足以满足我们的要求。

注意,为了能够在不创建这个类的实例的情况下调用这些方法,我们需要用 classmethod 装饰器来装饰它们。然后,约定使用第一个参数 cls 而不是 self,因为 Python 在幕后会将类对象传递给调用。

_generate_candidate 的代码也非常简单。我们只是生成一个密码,给定长度,我们计算其分数,并返回一个元组 (score, password)。

我们这样做是为了在 generate 方法中,每次调用该方法时都能生成 10 个(默认)密码,并返回得分最高的那个。由于我们的生成逻辑基于随机函数,因此采用这种技术来避免最坏情况总是好的。

这就完成了辅助代码。

编写处理程序

如你所注意到的,辅助代码的代码与 Falcon 完全无关。它只是我们可以重用的纯 Python 代码。另一方面,处理程序的代码当然基于 Falcon。下面的代码属于 core/handlers.py 模块,所以,就像我们之前做的那样,让我们从前面几行开始:

import json
import falcon
from .passwords import PasswordValidator, PasswordGenerator

class HeaderMixin:
    def set_access_control_allow_origin(self, resp):
        resp.set_header('Access-Control-Allow-Origin', '*')

这非常简单。我们导入 jsonfalcon 和我们的辅助工具,然后设置一个混合类,我们将在两个处理程序中使用它。这个混合类的需求是允许 API 服务来自其他地方的请求。这是我们在接口的 JavaScript 代码中看到的 CORS 问题的另一面。在这种情况下,我们大胆地走向了没有安全专家敢去的地方,允许来自任何域的请求('*')。我们这样做是因为这是一个练习,在这个上下文中,这是可以的,但请不要在生产环境中这样做,好吗?

编写密码验证处理程序

这个处理程序必须响应一个 POST 请求,因此我编写了一个 on_post 方法,这是在 Falcon 中响应 POST 请求的方式。

class PasswordValidatorHandler(HeaderMixin):

    def on_post(self, req, resp):
        self.process_request(req, resp)
        password = req.context.get('_body', {}).get('password')
        if password is None:
            resp.status = falcon.HTTP_BAD_REQUEST
            return None

        result = self.parse_password(password)
        resp.body = json.dumps(result)

    def parse_password(self, password):
        validator = PasswordValidator(password)
        return {
            'password': password,
            'valid': validator.is_valid(),
            'score': validator.score(),
        }

    def process_request(self, req, resp):
        self.set_access_control_allow_origin(resp)

        body = req.stream.read()
        if not body:
            raise falcon.HTTPBadRequest('Empty request body',
                'A valid JSON document is required.')
        try:
            req.context['_body'] = json.loads(
 body.decode('utf-8'))
        except (ValueError, UnicodeDecodeError):
            raise falcon.HTTPError(
                falcon.HTTP_753, 'Malformed JSON',
                'JSON incorrect or not utf-8 encoded.')

让我们从 on_post 方法开始。首先,我们调用 process_request 方法,它对请求正文进行合理性检查。我不会深入细节,因为它是从 Falcon 文档中取出的,并且是处理请求的标准方式。让我们简单地说,如果一切顺利(高亮部分),我们会在 req.context['_body'] 中得到请求的正文(已经从 JSON 解码)。如果由于任何原因出了问题,我们会返回适当的错误响应。

让我们回到 on_post。我们从请求上下文中获取密码。在这个阶段,process_request 已经成功,但我们仍然不知道正文是否格式正确。我们期望的是:{'password': 'my_password'}

因此,我们小心翼翼地进行。我们获取 '_body' 键的值,如果该值不存在,我们返回一个空字典。然后我们从那里获取 'password' 的值。我们使用 get 而不是直接访问,以避免 KeyError 问题。

如果密码是 None,我们简单地返回一个 400 错误(错误请求)。否则,我们验证它并计算其得分,然后将结果设置为响应的正文。

你可以通过 parse_password 方法看到验证和计算密码得分的简单性,通过使用我们的辅助工具。

我们返回一个包含三条信息的字典:passwordvalidscore。密码信息在技术上可能是多余的,因为发起请求的人会知道密码,但在这个情况下,我认为这是一种提供足够信息的好方法,比如用于日志记录,所以我添加了它。

如果 JSON 解码后的主体不是一个字典会发生什么?我将把它留给你来修复代码,添加一些逻辑来处理这种情况。

编写密码生成器处理程序

生成器处理程序必须处理一个带有单个查询参数的GET请求:所需的密码长度。

class PasswordGeneratorHandler(HeaderMixin):

    def on_get(self, req, resp):
        self.process_request(req, resp)
        length = req.context.get('_length', 16)
        resp.body = json.dumps(
            PasswordGenerator.generate(length))

    def process_request(self, req, resp):
        self.set_access_control_allow_origin(resp)
        length = req.get_param('length')
        if length is None:
            return
        try:
            length = int(length)
            assert length > 0
            req.context['_length'] = length
        except (ValueError, TypeError, AssertionError):
            raise falcon.HTTPBadRequest('Wrong query parameter',
                '`length` must be a positive integer.')

我们有一个类似的process_request方法。它对请求进行合理性检查,尽管与前面的处理程序略有不同。这次,我们需要确保如果长度在查询字符串中提供(例如,http://our-api-url/?length=23),它应该处于正确的格式。这意味着length需要是一个正整数。

因此,为了验证这一点,我们进行int转换(req.get_param('length')返回一个字符串),然后断言length大于零,最后将其放在context下的'_length'键下。

当一个字符串不是整数的合适表示时,进行int转换会引发ValueError,而从不是字符串的类型转换会引发TypeError,因此我们在except子句中捕获这两个错误。

我们还捕获由assert length > 0行引发的AssertionError,当length不是一个正整数时。然后我们可以安全地保证长度符合要求,使用一个单独的try/except块。

小贴士

注意,在编写try/except块时,你应该尽量具体,区分在出现问题时可能引发不同异常的指令。这将使你能够更好地控制问题,并更容易进行调试。然而,由于这是一个简单的 API,代码只对length格式不正确的请求做出反应是可以的。

on_get方法的代码相当简单。它首先处理请求,然后获取长度,如果没有传递,则回退到默认值 16,然后生成一个密码并将其写入 JSON,最后将其设置为响应的主体。

运行 API

为了运行这个应用程序,你需要记住我们在界面中设置了基本 URL 为http://127.0.0.1:5555。因此,我们需要以下命令来启动 API:

$ gunicorn -b 127.0.0.1:5555 main:app

运行它将启动主模块中定义的应用程序,将服务器实例绑定到localhost上的5555端口。有关 Gunicorn 的更多信息,请参阅第十章 Web Development Done Right,Web Development Done Right或直接访问项目的首页(gunicorn.org/)。

API 的代码现在已经完成,所以如果您同时运行了接口和 API,您可以一起尝试它们。看看是否一切如预期工作。

测试 API

在本节中,让我们看看我为辅助工具和处理器编写的测试。辅助工具的测试在很大程度上基于nose_parameterized库,因为我的最喜欢的测试风格是接口测试,尽可能少地进行修补。使用nose_parameterized允许我编写更容易阅读的测试,因为测试用例非常明显。

另一方面,对于处理器的测试必须遵循 Falcon 库的测试约定,所以它们会有所不同。这当然是理想的,因为它允许我向您展示更多内容。

由于我剩余的页面有限,我将只展示部分测试,所以请确保您在源代码中查看完整内容。

测试辅助工具

让我们看看PasswordGenerator类的测试:

tests/test_core/test_passwords.py

class PasswordGeneratorTestCase(TestCase):

    def test__generate_password_length(self):
        for length in range(300):
            assert_equal(
                length,
                len(PasswordGenerator._generate_password(length))
            )

    def test__generate_password_validity(self):
        for length in range(1, 300):
            password = PasswordGenerator._generate_password(
                length)
            assert_true(PasswordValidator(password).is_valid())

    def test__generate_candidate(self):
        score, password = (
            PasswordGenerator._generate_candidate(42))
        expected_score = PasswordValidator(password).score()
        assert_equal(expected_score['total'], score)

    @patch.object(PasswordGenerator, '_generate_candidate')
    def test__generate(self, _generate_candidate_mock):
        # checks `generate` returns the highest score candidate
        _generate_candidate_mock.side_effect = [
            (16, '&a69Ly+0H4jZ'),
            (17, 'UXaF4stRfdlh'),
            (21, 'aB4Ge_KdTgwR'),  # the winner
            (12, 'IRLT*XEfcglm'),
            (16, '$P92-WZ5+DnG'),
            (18, 'Xi#36jcKA_qQ'),
            (19, '?p9avQzRMIK0'),
            (17, '4@sY&bQ9*H!+'),
            (12, 'Cx-QAYXG_Ejq'),
            (18, 'C)RAV(HP7j9n'),
        ]
        assert_equal(
            (21, 'aB4Ge_KdTgwR'), PasswordGenerator.generate(12))

test__generate_password_length中,我们确保_generate_password方法正确处理长度参数。我们为[0, 300)范围内的每个长度生成一个密码,并验证其长度是否正确。

test__generate_password_validity测试中,我们做类似的事情,但这次我们确保无论我们要求什么长度,生成的密码都是有效的。我们使用PasswordValidator类来检查有效性。

最后,我们需要测试generate方法。密码生成是随机的,因此,为了测试这个函数,我们需要模拟_generate_candidate,从而控制其输出。我们将其模拟的side_effect参数设置为包含 10 个候选者的列表,我们期望generate方法选择得分最高的一个。将模拟的side_effect设置为列表会导致该模拟每次被调用时逐个返回列表中的元素。为了避免歧义,最高分数是 21,只有一个候选者得分这么高。我们调用该方法并确保返回的是那个特定的候选者。

注意

如果您想知道为什么我在测试名称中使用了双下划线,那很简单:第一个是一个分隔符,第二个是测试方法名称的一部分。

测试PasswordValidator类需要更多的代码行,所以我只会展示其中的一部分测试:

pwdapi/tests/test_core/test_passwords.py

from unittest import TestCase
from unittest.mock import patch
from nose_parameterized import parameterized, param
from nose.tools import (
    assert_equal, assert_dict_equal, assert_true)
from core.passwords import PasswordValidator, PasswordGenerator

class PasswordValidatorTestCase(TestCase):

    @parameterized.expand([
        (False, ''),
        (False, '  '),
        (True, 'abcdefghijklmnopqrstuvwxyz'),
        (True, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
        (True, '0123456789'),
        (True, '!#$%&()*+-?@_|'),
    ])
    def test_is_valid(self, valid, password):
        validator = PasswordValidator(password)
        assert_equal(valid, validator.is_valid())

我们首先测试is_valid方法。我们测试当传入一个空字符串,以及仅由空格组成的字符串时,它是否返回False,这确保我们在分配密码时调用.strip()

然后,我们使用所有我们希望被接受的字符来确保函数能够接受它们。

我理解parameterize.expand装饰器背后的语法一开始可能有点挑战,但事实上,每个元组都包含一个独立的测试用例,这意味着test_is_valid测试会为每个元组单独运行,并且两个元组元素作为参数传递给方法:validpassword

然后我们测试无效字符。我们预期它们都会失败,所以我们使用param.explicit,它为那个奇怪字符串中的每个字符运行测试。

    @parameterized.expand(
        param.explicit(char) for char in '>]{<`\\;,[^/"\'~:}=.'
    )
    def test_is_valid_invalid_chars(self, password):
        validator = PasswordValidator(password)
        assert_equal(False, validator.is_valid())

它们都评估为False,所以我们没问题。

    @parameterized.expand([
        (0, ''),  # 0-3: score 0
        (0, 'a'),  # 0-3: score 0
        (0, 'aa'),  # 0-3: score 0
        (0, 'aaa'),  # 0-3: score 0
        (1, 'aaab'),  # 4-7: score 1
        ...
        (5, 'aaabbbbccccddd'),  # 12-15: score 5
        (5, 'aaabbbbccccdddd'),  # 12-15: score 5
    ])
    def test__score_length(self, score, password):
        validator = PasswordValidator(password)
        assert_equal(score, validator._score_length())

为了测试_score_length方法,我创建了从 0 到 15 长度的 16 个测试用例。测试的主体只是确保分数被适当地分配。

    def test__score_length_sixteen_plus(self):
        # all password whose length is 16+ score 7 points
        password = 'x' * 255
        for length in range(16, len(password)):
            validator = PasswordValidator(password[:length])
            assert_equal(7, validator._score_length())

前面的测试是为了长度从 16 到 254 的情况。我们只需要确保任何超过 15 的长度都能得到 7 分。

我将跳过其他内部方法的测试,直接跳到对得分方法的测试。为了测试它,我想精确控制每个_score_*方法返回的内容,所以我模拟了它们,并在测试中为每个方法设置一个返回值。请注意,为了模拟类的成员方法,我们使用patch.object的变体。当你设置模拟的返回值时,重复是不好的,因为你可能不确定哪个方法返回了什么,如果发生交换,测试可能不会失败。所以,总是返回不同的值。在我的情况下,我使用前几个素数来确保没有混淆的可能性。

    @patch.object(PasswordValidator, '_score_length')
    @patch.object(PasswordValidator, '_score_case')
    @patch.object(PasswordValidator, '_score_numbers')
    @patch.object(PasswordValidator, '_score_special')
    @patch.object(PasswordValidator, '_score_ratio')
    def test_score(
            self,
            _score_ratio_mock,
            _score_special_mock,
            _score_numbers_mock,
            _score_case_mock,
            _score_length_mock):

        _score_ratio_mock.return_value = 2
        _score_special_mock.return_value = 3
        _score_numbers_mock.return_value = 5
        _score_case_mock.return_value = 7
        _score_length_mock.return_value = 11

        expected_result = {
            'length': 11,
            'case': 7,
            'numbers': 5,
            'special': 3,
            'ratio': 2,
            'total': 28,
        }

        validator = PasswordValidator('')
        assert_dict_equal(expected_result, validator.score())

我要明确指出,_score_*方法都是模拟的,所以我通过将空字符串传递给类构造函数来设置我的validator实例。这使得读者更加明显地意识到类的内部已经被模拟。然后,我只是检查结果是否与我预期的相同。

最后这个测试是这个类别中唯一一个我使用了模拟的测试。其他所有针对_score_*方法的测试都是接口风格,这种风格读起来更好,通常也能产生更好的结果。

测试处理器

让我们简要看看一个处理器测试的例子:

pwdapi/tests/test_core/test_handlers.py

import json
from unittest.mock import patch
from nose.tools import assert_dict_equal, assert_equal
import falcon
import falcon.testing as testing
from core.handlers import (
    PasswordValidatorHandler, PasswordGeneratorHandler)

class PGHTest(PasswordGeneratorHandler):
    def process_request(self, req, resp):
        self.req, self.resp = req, resp
        return super(PGHTest, self).process_request(req, resp)

class PVHTest(PasswordValidatorHandler):
    def process_request(self, req, resp):
        self.req, self.resp = req, resp
        return super(PVHTest, self).process_request(req, resp)

由于 Falcon 为你提供了测试处理器的工具,我为我想测试的每个类创建了一个子类。我唯一改变的是(通过覆盖一个方法),在process_request方法(由两个类调用)在处理请求之前,我确保我设置了实例上的reqresp参数。因此,process_request方法的正常行为在以其他方式没有被改变。通过这样做,无论测试过程中发生什么,我都能检查这些对象。

在测试时使用这样的技巧是很常见的。我们从不更改代码以适应测试,这会是一种不良的做法。我们找到一种方法来调整我们的测试以适应我们的需求。

class TestPasswordValidatorHandler(testing.TestBase):

    def before(self):
        self.resource = PVHTest()
        self.api.add_route('/password/validate/', self.resource)

before方法是由 Falcon 的TestBase逻辑调用的,它允许我们设置我们想要测试的资源(处理器)及其路由(这不一定是我们生产中使用的那个)。

    def test_post(self):
        self.simulate_request(
            '/password/validate/',
            body=json.dumps({'password': 'abcABC0123#&'}),
            method='POST')
        resp = self.resource.resp

        assert_equal('200 OK', resp.status)
        assert_dict_equal(
            {'password': 'abcABC0123#&',
             'score': {'case': 3, 'length': 5, 'numbers': 2,
                'special': 4, 'ratio': 2, 'total': 16},
             'valid': True},
             json.loads(resp.body))

这是测试成功的路径。它所做的只是模拟一个带有 JSON 负载体的POST请求。然后,我们检查响应对象。特别是,我们检查其状态和其体。我们确保处理器已正确调用验证器并返回了其结果。

我们还测试了生成器处理器:

class TestPasswordGeneratorHandler(testing.TestBase):

    def before(self):
        self.resource = PGHTest()
        self.api.add_route('/password/generate/', self.resource)

    @patch('core.handlers.PasswordGenerator')
    def test_get(self, PasswordGenerator):
        PasswordGenerator.generate.return_value = (7, 'abc123')
        self.simulate_request(
            '/password/generate/',
            query_string='length=7',
            method='GET')
        resp = self.resource.resp

        assert_equal('200 OK', resp.status)
        assert_equal([7, 'abc123'], json.loads(resp.body))

对于这个,我也只会展示测试成功的路径。我们模拟了PasswordGenerator类,因为我们需要控制它将生成哪种密码,除非我们进行模拟,否则我们无法做到,因为它是一个随机过程。

一旦我们正确设置了其返回值,我们就可以再次模拟请求。在这种情况下,它是一个带有 7 个期望长度的GET请求。我们使用与另一个处理器类似的技术,并检查响应状态和体。

这些不是你针对 API 可以编写的唯一测试,其风格也可能不同。有些人经常模拟,我倾向于只有在真正需要时才模拟。试着看看你是否能从中找到一些有意义的见解。我知道它们并不容易,但这对你的训练会有好处。测试非常重要,所以请尽力而为。

你接下来要做什么?

如果你喜欢这个项目并且想要扩展它,这里有一些建议:

  • 在自定义 Django 字段机制中实现加密。

  • 修改记录列表的模板,以便你可以搜索特定的记录。

  • 修改 JavaScript 以使用带有回调的 JSONP 来克服 CORS 问题。

  • 修改 JavaScript,以便在密码字段更改时触发验证调用。

  • 编写一个 Django 命令,允许你加密和解密数据库文件。当你从命令行执行时,将这种行为集成到网站中,可能是在主页上,这样除非你经过身份验证,否则你无法访问记录。这绝对是一个挑战,因为它需要另一个数据库,其中存储有正确的单向散列的认证密码,或者对用于存储记录模型数据的结构进行重大修改。即使你现在没有做这件事的手段,也试着想想你会如何解决这个问题。

  • 在你的机器上设置 PostgreSQL,并切换到使用它而不是默认的 SQLite 文件。

  • 添加将文件附加到记录的能力。

  • 玩转这个应用程序,尝试找出你想要添加或更改的功能,然后去做。

摘要

在本章中,我们完成了一个涉及界面和 API 的最终项目。我们使用了两个不同的框架来实现我们的目标:Django 和 Falcon。它们非常不同,使我们能够探索不同的概念和技术来构建我们的软件,使这个有趣的应用程序变得生动。

我们已经看到了一个对称加密的例子,并探索了以更功能性的风格编写的代码,而不是更经典的以控制流为导向的方法。我们重用了 Django 基于类的视图,并将需要编写的代码量减少到最小。

在编写 API 时,我们将处理请求与密码管理解耦。这样,更容易看到代码的哪一部分依赖于 Falcon 框架,哪一部分与之独立。

最后,我们看到了一些针对 API 的辅助程序和处理程序的测试。我们简要地介绍了一种技术,我使用它来扩展测试中的类,以便能够测试那些通常不可用的代码部分。

我在本章中的目标是提供一个有趣且可以以不同方式扩展和改进的例子。我还想给你一些不同编码风格和技术的例子,这就是为什么我选择将它们分开,并使用不同的框架。

一句告别的话

希望你仍然渴望学习,这本书只是你迈向 Python 的许多步骤中的第一步。它是一门真正美好的语言,值得深入学习。

希望你喜欢与我一起的这段旅程,我尽力让它对你来说有趣。对我来说,写作这些页面确实是一次愉快的经历。

Python 是开源的,所以请继续分享它,并考虑支持围绕它的美好社区。

到下次见面时,我的朋友,再见!

posted @ 2025-09-22 13:20  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报