Python-编程学习指南第四版-全-

Python 编程学习指南第四版(全)

原文:zh.annas-archive.org/md5/9c28dd95f85acb0e4f19ad9c6a2a5fb1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我们非常高兴向您介绍我们书籍的第四版。第一版于 2015 年出版,自那时起,这本书在全球范围内一直是最畅销的。

十年前,成为一名程序员意味着使用某些工具,实现某些范式。今天,情况不同了。开发者比以前更加专业化,并且对 API 和分布式应用等事物给予了极大的关注。我们试图捕捉当前的趋势,并为您提供我们能够想到的最佳基础层,借鉴我们作为在快节奏行业中工作的开发者的经验。

每个新版本都带来了一些变化。过时的章节被删除,新的章节被添加,其他章节也被修订,以反映软件编写的现代方式。

本版新增了三个新章节。第一个章节讨论了类型提示的话题。类型提示对 Python 来说并不新鲜,但现在我们认为它已经变得如此根深蒂固,以至于没有它这本书就不完整。

第二个章节,讨论了命令行应用程序的话题,通过替换一个关于图形用户界面的旧章节而介入。如今,我们的大部分计算机操作都在浏览器中进行,许多桌面应用程序都是通过利用浏览器组件构建或重写的,因此我们认为一个专门讨论图形用户界面的章节可能有点过时。

最后,第三个主题探讨了竞赛编程。

剩余的章节已经更新,以反映语言最新的添加,并进行了改进,使展示更加简单流畅,同时仍然旨在为读者提供有趣示例。

书的灵魂,其精髓,仍然保持完整。它不应该感觉像另一本 Python 书。首先,它是一本关于编程的书。它试图传达尽可能多的信息,有时,当页数不允许时,它会指向你需要进一步了解知识的资源。

它被设计成持久耐用。它以应该经得起时间考验的方式解释概念和信息,尽可能长时间地保持其有效性。为此,我们投入了大量的工作、思考和会议。

它与第一版相比也发生了根本性的变化。它更加成熟,更加专业,更专注于语言,而稍微减少了对项目的关注。我们认为,在这两部分之间取得平衡的线条已经画在了正确的位置。

这将要求你集中精力并努力工作。所有代码都可供下载。如果你喜欢,你可以从 GitHub 克隆仓库。请查看它。这将帮助你巩固你在阅读这些页面时所学到的知识。代码不是静态的东西。它非常活跃。它变化,它演变。如果你花时间去探索它,改变它,并打破它,你会学到更多。我们在书中留下了几条指南,以帮助你做到这一点。

在结束之际,我想对我的合著者海因里希表达我的感激之情。

这本书现在和他的一样,也是我的。每一章都融入了他的才华、他的创造力和他知识的深度。他还拥有惊人的记忆力:他可以在相隔 200 页的章节中找到重复的信息。我做不到这一点。

就像我一样,他花费了许多漫长的夜晚和周末,确保一切都能以最佳的方式呈现。正是因为我和他一起走过这段旅程,我对这本书的质量充满信心。

我们对您的建议是仔细研究这些页面,并尝试源代码。一旦您对自己的 Python 技能有信心,请不要停止学习。尝试超越语言,超越它。高级开发者应该了解某些概念并掌握某些技能,这些技能不能被包含在一种语言中,这是不可能的。学习其他语言有助于了解如何区分那些属于特定语言的特性,以及那些更通用的特性,与编程相关。希望这本书能帮助您达到这一点。

享受这段旅程,无论您学到什么,请与他人分享。

法布里齐奥

这本书面向的人群

这本书是为那些有一定编程经验的人准备的,但不一定非要是 Python。了解一些基本编程概念会有所帮助,尽管这不是严格的要求。

即使您已经有一些 Python 的经验,这本书仍然对您有用,既可以作为 Python 基础知识的参考,也可以提供四十年经验积累的广泛考虑和建议。

这本书涵盖的内容

第一章Python 的温和介绍,向您介绍了 Python 语言的基本编程概念和结构。它还指导您如何在您的计算机上安装并运行 Python。

第二章内置数据类型,向您介绍了 Python 的内置数据类型。Python 拥有非常丰富的原生数据类型,本章将为您描述并举例说明每一种。

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

第四章函数,代码的构建块,教您如何编写函数。函数对于代码重用、减少调试时间以及总体上编写更高质量的代码至关重要。

第五章列表推导式和生成器,向您介绍了 Python 编程的功能性方面。本章教您如何编写列表推导式和生成器,这些是强大的工具,您可以用它们来编写更快、更简洁的代码,并节省内存。

第六章面向对象编程、装饰器和迭代器,教你使用 Python 进行面向对象编程的基础知识。它展示了这一范式的关键概念和所有潜力。它还展示了语言中最有用的功能之一:装饰器。

第七章异常和上下文管理器,介绍了异常的概念,它代表了应用程序中发生的错误,以及如何处理它们。它还涵盖了上下文管理器,这在处理资源时非常有用。

第八章文件和数据持久性,教你如何处理文件、流、数据交换格式和数据库。

第九章密码学和令牌,涉及安全、散列、加密和令牌的概念,这些对于编写安全软件至关重要。

第十章测试,教你测试的基础知识,并指导你通过一些示例了解如何测试你的代码,以便使其更健壮、快速和可靠。

第十一章调试和性能分析,展示了调试和性能分析代码的主要方法,以及一些应用示例。

第十二章类型提示简介,指导你了解类型提示的语法和主要概念。近年来,类型提示越来越受欢迎,因为它丰富了语言及其生态系统中的工具。

第十三章简明数据科学,通过一个综合示例,使用强大的 Jupyter Notebook,说明了几个关键概念。

第十四章API 开发简介,介绍了使用 FastAPI 框架进行 API 开发。

第十五章命令行应用程序,介绍了命令行界面应用程序。它们在控制台或终端中运行,是开发者编写日常工具的一种常见且自然的方式。

第十六章打包 Python 应用程序,指导你准备一个项目以供发布,并展示如何将结果上传到Python 包索引PyPI)。

第十七章编程挑战,介绍了竞技编程的概念,通过展示如何解决 Advent of Code 网站上的两个问题来展示。

为了充分利用这本书

鼓励您遵循本书中的示例。您需要一个计算机、互联网连接和浏览器。本书是为 Python 3.12 编写的,但大部分也应该适用于任何较新的 Python 3 版本。我们已提供有关如何在您的操作系统上安装Python的指南。这些步骤通常很快就会过时,因此我们建议您参考网络上最新的指南以获取精确的设置说明。我们还解释了如何安装各章节中使用的所有额外库。不需要特定的编辑器来输入代码;然而,我们建议有兴趣跟随示例的人考虑采用适当的编码环境。我们在第一章中对此提出了建议。

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835882948

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Python-Programming-Fourth-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“as_integer_ratio()方法也已添加到整数和布尔值中。”

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“它们是不可变的 Unicode 代码点序列

警告或重要提示看起来像这样。

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

联系我们

我们始终欢迎读者的反馈。

一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您向我们报告。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Python 编程学习》第四版,我们非常乐意听到您的想法!请点击此处直接访问此书的 Amazon 评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

请放心,现在购买每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接:

img

packt.link/free-ebook/9781835882948

  1. 提交您的购买证明。

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。

第一章:Python 入门指南

“给一个人一条鱼,你就能养活他一天。教一个人捕鱼,你就能养活他一辈子。”

——中国谚语

计算机编程,有时也称为编码,就是用计算机能理解的语言告诉计算机做某事。

计算机是非常强大的工具,但不幸的是,它们不能自己思考。它们需要被告知一切:如何执行任务;如何评估条件以决定走哪条路;如何处理来自设备的数据,比如网络或磁盘;以及当发生不可预见的事情时,比如某物损坏或丢失时,如何反应。

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

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

好的代码应该是简短、快速、优雅、易于阅读和理解、简单、易于修改和扩展、易于扩展和重构、易于测试。要同时具备所有这些品质的代码需要时间,但好消息是,你通过阅读这本书已经迈出了第一步。我们确信你可以做到。任何人都可以;事实上,我们每天都在编程,只是我们没有意识到这一点。

假设,比如说,你想冲泡速溶咖啡。你需要一个杯子、速溶咖啡罐、茶匙、水和水壶。即使你没有意识到,你也在评估大量的数据。你确保水壶里有水,水壶已插电,杯子干净,罐子里有足够的咖啡。然后你烧水,也许同时,你在杯子里放了一些咖啡。当水烧好后,你把它倒入杯子,并搅拌。

那么,这是怎样的编程呢?

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

你能看出其中的相似之处吗?我们刚刚描述了一个咖啡程序的顶层功能。这并不难,因为这就是大脑整天在做的事情:评估条件、决定采取行动、执行任务、重复一些任务,并在某个时候停止。

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

因此,这本书的目的就在于此。我们将向你展示一种成功编码的方法,我们将通过许多简单但专注的示例(我们最喜欢的那种)来实现这一点。

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

  • Python 的特点和生态系统

  • 关于如何使用 Python 和虚拟环境的指南

  • 如何运行 Python 程序

  • 如何组织 Python 代码及其执行模型

编程简介

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

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

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

要做到这一点,你需要在程序中创建和处理对象。人可以是一个对象。一辆车是一个对象。一条裤子是一个对象。幸运的是,Python 非常理解对象。

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

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

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

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

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

我们将在第六章面向对象编程(OOP)、装饰器和迭代器中更详细地研究 Python 对象。现在,我们只需要知道 Python 中的每个对象都有一个ID(或身份)、一个类型和一个

面向对象编程OOP)只是许多编程范式之一。在 Python 中,我们可以使用函数式或命令式风格编写代码,也可以使用面向对象。然而,正如我们之前所述,Python 中的所有东西都是对象,因此我们始终使用它们,无论选择的编码风格如何。

一旦创建,对象的 ID 永远不会改变。它是它的唯一标识符,Python 在幕后使用它来检索对象,当我们想要使用它时。类型也永远不会改变。类型说明了对象支持的操作以及可以分配给它的可能值。我们将在第二章内置数据类型中看到 Python 最重要的数据类型。某些对象的值可以改变。这样的对象被称为可变的。如果值不能改变,则该对象被称为不可变的

那么,我们如何使用一个对象呢?当然,我们给它一个名字!当你给一个对象一个名字时,你就可以使用这个名字来检索对象并使用它。在更通用的意义上,对象,如数字、字符串(文本)和集合,都与一个名字相关联。在其他语言中,这个名字通常被称为变量。你可以把变量看作是一个盒子,你可以用它来存储数据。

对象代表数据。它存储在数据库中或通过网络连接发送。当你打开任何网页或处理文档时,你所看到的就是数据。计算机程序通过操作这些数据来执行各种动作。它们调节其流动,评估条件,对事件做出反应,等等。

要完成所有这些,我们需要一种语言。这正是 Python 的作用所在。Python 是我们将在整本书中使用的语言,用来指导计算机为我们做某些事情。

进入 Python

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

我们(作者)都很小的时候就开始编程了。Fabrizio 从 7 岁开始,在 Commodore VIC-20 上,后来被更大的兄弟 Commodore 64 所取代。它使用的语言是 BASIC。Heinrich 在高中时开始学习 Pascal。在我们之间,我们使用过 Pascal、汇编、C、C++、Java、JavaScript、Visual Basic、PHP、ASP、ASP .NET、C# 以及我们甚至无法记住的许多其他语言;只有当我们到达 Python 时,我们才终于有了那种感觉,就像你在商店里找到正确的沙发时,你的整个身体都在喊:买这个!这个就是完美的!

我们大约花了一天时间来适应它。它的语法与我们习惯的不同,但一旦我们克服了那种不适感(就像穿新鞋一样),我们俩都深深地爱上了它。让我们看看原因。

关于 Python

在我们深入细节之前,让我们了解一下为什么有人想使用 Python。它体现了以下品质。

便携性

Python 在任何地方都能运行,将程序从 Linux 迁移到 Windows 或 Mac 通常只是修复路径和设置的问题。Python 是为便携性设计的,它处理了特定 操作系统OS)的怪癖,这些怪癖在接口后面保护你免受编写针对特定平台定制的代码的痛苦。

一致性

Python 非常逻辑和连贯。你可以看出它是由一位杰出的计算机科学家设计的。大多数时候,如果你不知道一个方法的名称,你只需猜测一下就可以了。

你可能现在还没有意识到这一点有多重要,尤其是如果你作为一个程序员不是那么有经验的话,但这是一个主要特点。这意味着你的头脑中杂乱无章的东西更少,以及当你编码时,你大脑中映射的需求也更少。

开发者生产力

根据马克·卢茨(《Python 学习,第 5 版,O’Reilly 媒体)的说法,Python 程序通常比等价的 Java 或 C++ 代码小五分之一到三分之一。这意味着工作可以更快完成。更快是好事。更快意味着能够更快地响应市场。更少的代码不仅意味着要编写的代码更少,而且意味着要阅读(专业程序员读的比写的多)、维护、调试和重构的代码也更少。

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

丰富的库

Python 拥有一个极其广泛的标准库(据说它包含了内置电池)。如果这还不够的话,Python 的国际社区还维护了一系列第三方库,这些库针对特定需求定制,你可以在Python 包索引PyPI)上免费访问它们。当你用 Python 编码并意识到需要某个特性时,在大多数情况下,至少有一个库已经实现了这个特性。

软件质量

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

软件集成

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

数据科学

Python 是目前数据科学、机器学习和人工智能领域使用最流行(如果不是最流行)的语言之一。因此,对于那些希望在这些领域有职业发展的人来说,掌握 Python 几乎是必需的。

满足与享受

最后,但同样重要的是,是它的乐趣!使用 Python 很有趣;我们可以连续编码八小时,然后快乐而满足地离开办公室,不受其他程序员因使用不提供同样数量精心设计的数据结构和构造的语言而必须忍受的挣扎的影响。毫无疑问,Python 让编码变得有趣,而乐趣可以促进动力和生产力。

这些是我们推荐 Python 给每个人的主要原因。当然,我们还可以提到许多其他的技术和高级特性,但它们并不真正适用于像这样一个入门章节。随着我们更深入地了解 Python,这些特性会自然而然地出现,一章接一章。

现在,让我们来看看 Python 可能存在的局限性。

有什么缺点?

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

尽管如此,Python 的速度在今天很少成为问题,因此无论这个缺点如何,它都得到了广泛的应用。实际情况是,硬件成本不再是问题,通常您可以通过并行化任务来提高速度。此外,许多程序花费大量时间等待 I/O 操作完成;因此,原始的执行速度往往是整体性能的次要因素。

值得注意的是,Python 的核心开发者在过去几年里投入了大量精力来加速对最常见数据结构的操作。这种努力在某些情况下非常成功,在一定程度上缓解了这个问题。

在速度真正至关重要的场合,人们可以切换到更快的 Python 实现,例如 PyPy,它通过实现高级编译技术,平均提供超过四倍的速度提升(参考pypy.org/获取更多信息)。您还可以将代码中性能关键的部分用更快的语言编写,例如 C 或 C++,并将其与 Python 代码集成。例如,pandasNumPy(在 Python 中进行数据科学时常用)就使用了这样的技术。

Python 语言有几种不同的实现方式。在这本书中,我们将使用被称为 CPython 的参考实现。您可以在www.python.org/download/alternatives/找到其他实现方式的列表。

如果这还不足以说服您,您可以考虑这样一个事实:Python 已被用于驱动 Spotify 和 Instagram 等服务的后端,在这些服务中性能是一个关注点。从这个角度来看,Python 已经完美地完成了它的任务。

今天谁在使用 Python?

Python 被用于许多不同的环境,例如系统编程、Web 和 API 编程、GUI 应用程序、游戏和机器人技术、快速原型设计、系统集成、数据科学、数据库应用程序、实时通信等等。一些著名的大学也将 Python 作为计算机科学课程的主要语言。

下面是一个已知使用 Python 的主要公司和组织的列表,它们在技术栈、产品开发、数据分析或自动化流程中使用 Python:

  • 科技行业

    • Google:使用 Python 进行许多任务,包括后端服务、数据分析以及人工智能(AI)

    • Facebook: 利用 Python 进行各种目的,包括基础设施管理和运营自动化

    • Instagram: 严重依赖 Python 进行其后端,使其成为最大的 Django(一个 Python 网络框架)用户之一

    • Spotify: 主要使用 Python 进行数据分析和服务后端

    • Netflix: 使用 Python 进行数据分析、运营自动化和安全

  • 金融行业

    • 摩根大通(JP Morgan Chase): 使用 Python 进行金融模型、数据分析和算法交易

    • 高盛(Goldman Sachs): 使用 Python 进行各种金融模型和应用

    • 彭博社(Bloomberg): 使用 Python 进行金融数据分析及其彭博终端界面

  • 科技和软件

    • 国际商业机器公司(IBM): 利用 Python 进行人工智能、机器学习和网络安全

    • 英特尔(Intel): 使用 Python 进行硬件测试和开发流程

    • Dropbox: 桌面客户端主要用 Python 编写

  • 空间和研究

    • 美国国家航空航天局(NASA): 使用 Python 进行多种目的,包括数据分析系统和系统集成

    • 欧洲核子研究中心(CERN): 使用 Python 进行物理实验中的数据处理和分析

  • 零售和电子商务

    • 亚马逊(Amazon): 使用 Python 进行数据分析、产品推荐和运营自动化

    • eBay: 利用 Python 进行各种后端服务和数据分析

  • 娱乐和媒体

    • 皮克斯(Pixar): 使用 Python 进行动画软件和动画过程中的脚本编写

    • 工业光魔(Industrial Light & Magic, ILM): 使用 Python 进行视觉效果和图像处理

  • 教育和学习平台

    • Coursera: 利用 Python 进行网络开发和后端服务

    • 可汗学院(Khan Academy): 使用 Python 进行教育内容交付和后端服务

  • 政府和非营利组织

    • 美国联邦政府:有多个部门和机构使用 Python 进行数据分析、网络安全和自动化

    • 树莓派基金会(The Raspberry Pi Foundation): 将 Python 作为教育目的和项目的首选编程语言

设置环境

在我们的机器(MacBook Pro)上,这是最新的 Python 版本:

>>> import sys
>>> print(sys.version)
3.12.2 (main, Feb 14 2024, 14:16:36) [Clang 15.0.0 (clang-1500.1.0.2.5)] 

因此,您可以看到版本是 3.12.2,该版本于 2023 年 10 月 2 日发布。前面的文本是一段输入到控制台中的 Python 代码。我们稍后会讨论这个问题。

本书中的所有示例都将使用 Python 3.12 运行。如果您希望跟随示例并下载本书的源代码,请确保您使用的是相同的版本。

安装 Python

在您的计算机上安装 Python 的过程取决于您所使用的操作系统。首先,Python 在几乎每个 Linux 发行版中都是完全集成的,并且很可能已经安装。如果您有较新的 macOS 版本,Python 3 可能也已经安装,而如果您使用的是 Windows,您可能需要安装它。

无论 Python 是否已安装在您的系统中,您都需要确保已安装版本 3.12。

要检查您是否已在系统上安装了 Python,请在命令提示符中尝试输入 python --versionpython3 --version(关于这一点稍后会有更多介绍)。

你想要开始的地方是官方的 Python 网站:www.python.org 。这个网站托管了官方的 Python 文档以及许多其他你会发现非常有用的资源。

有用的安装资源

Python 网站提供了有关在各个操作系统上安装 Python 的有用信息。请参考您操作系统的相关页面。

Windows 和 macOS:

对于 Linux,请参考以下链接:

在 Windows 上安装 Python

例如,这是在 Windows 上安装 Python 的步骤。前往 www.python.org/downloads/ 下载适合您计算机 CPU 的相应安装程序。

一旦下载,您可以通过双击它来启动安装。

img

图 1.1:在 Windows 上开始安装过程

我们建议选择默认安装,并且不要勾选 将 python.exe 添加到 PATH 选项,以防止与您机器上可能已安装的其他版本的 Python 发生冲突,可能是由其他用户安装的。

对于更全面的指南,请参考上一段中提到的链接。

一旦点击 现在安装,安装程序将开始。

img

图 1.2:安装进行中

安装完成后,您将进入最终屏幕。

img

图 1.3:安装完成

点击 关闭 以完成安装。

现在 Python 已安装到您的系统上,打开命令提示符,通过输入 py 来运行 Python 交互式外壳。此命令将选择您机器上安装的最新版本的 Python。在撰写本文时,3.12 是可用的最新版本。如果您安装了更早的版本,您可以使用命令 py -3.12 指定版本。

要在 Windows 中打开命令提示符,请前往 开始 菜单,在搜索框中输入 cmd 以启动终端。或者,您也可以使用 Powershell。

在 macOS 上安装 Python

在 macOS 上,安装过程与 Windows 类似。一旦下载了适合您机器的相应安装程序,完成安装步骤,然后通过前往 应用程序 > 工具 > 终端 来启动终端。或者,您也可以通过 Homebrew 安装它。

一旦进入终端窗口,你可以输入 python。如果启动了错误的版本,你可以尝试使用 python3python3.12 来指定版本。

在 Linux 上安装 Python

在 Linux 上安装 Python 的过程通常比在 Windows 或 macOS 上复杂一些。如果你使用的是 Linux 机器,最佳做法是在网上搜索适用于你发行版的最新步骤。这些步骤可能因发行版而异,因此很难给出一个对每个人都有相关性的例子。请参考“有用的安装资源”部分中的链接以获取指导。

Python 控制台

我们将使用 控制台 这个术语来交替表示 Linux 控制台、Windows 命令提示符或 PowerShell、macOS 终端。我们还将使用默认的 Linux 格式来指示命令行提示符,如下所示:

$ sudo apt-get update 

如果你对此不熟悉,请花些时间学习控制台的基本工作原理。简而言之,在 $ 符号之后,你将输入你的指令。注意大小写和空格,因为它们非常重要。

无论你打开哪个控制台,在提示符处输入 python(在 Windows 上是 py),确保出现 Python 交互式外壳。输入 exit() 退出。请记住,如果你的操作系统预装了其他 Python 版本,你可能需要指定 python3python3.12

我们通常将 Python 交互式外壳简单地称为 Python 控制台

这大致是你运行 Python 时应该看到的内容(一些细节会根据版本和操作系统而变化):

$ python
Python 3.12.2 (main, Feb 14 2024, 14:16:36)
[Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 

现在 Python 已经设置好了,你可以运行它了,是时候确保你拥有其他工具,这将对于跟随书中的示例至关重要:一个虚拟环境。

关于虚拟环境

在使用 Python 时,使用虚拟环境是非常常见的。让我们通过一个简单的例子来看看它们是什么以及为什么我们需要它们。

你在你的系统上安装了 Python,并开始为客户 X 制作网站。你创建了一个项目文件夹并开始编码。在这个过程中,你还安装了一些库,例如 Django 框架。假设你为项目 X 安装的 Django 版本是 4.2。

现在,你的网站如此出色,你得到了另一个客户 Y。她希望你再建一个网站,所以你开始项目 Y,在这个过程中,你需要再次安装 Django。唯一的问题是现在 Django 的版本是 5.0,你无法在你的系统上安装它,因为这会替换掉为项目 X 安装的版本。你不想冒引入不兼容性问题的风险,所以你有两个选择:要么坚持使用你机器上当前的版本,要么升级它并确保第一个项目仍然可以完全正确地与新版本一起工作。

让我们坦诚地说;这两个选项都不太吸引人,对吧?绝对不是。但是有一个解决方案:虚拟环境!

虚拟环境是隔离的 Python 环境,每个环境都是一个包含所有必要可执行文件的文件夹,这些文件用于使用 Python 项目所需的包(暂时将包视为库)。

因此,您为项目 X 创建一个虚拟环境,安装所有依赖项,然后为项目 Y 创建一个虚拟环境并安装其所有依赖项,而无需担心,因为您安装的每个库最终都会在适当的虚拟环境边界内。在我们的例子中,项目 X 将包含 Django 4.2,而项目 Y 将包含 Django 5.0。

非常重要的一点是,您永远不要在系统级别直接安装库。例如,Linux 依赖于 Python 执行许多不同的任务和操作,如果您篡改 Python 的系统安装,您可能会危及整个系统的完整性。所以,请记住这个规则:在开始新项目时,始终创建一个虚拟环境。

当涉及到在您的系统上创建虚拟环境时,有几种不同的方法可以实现。自 Python 3.5 以来,建议使用venv模块来创建虚拟环境。您可以在官方文档页面(docs.python.org/3/library/venv.html)上查找更多信息。

例如,如果你使用的是基于 Debian 的 Linux 发行版,在使用之前,你需要安装venv模块:

$ sudo apt-get install python3.12-venv 

创建虚拟环境的另一种常见方法是使用第三方 Python 包virtualenv。您可以在其官方网站上找到它:virtualenv.pypa.io

在这本书中,我们将使用推荐的技巧,该技巧利用了 Python 标准库中的venv模块。

您的第一个虚拟环境

创建虚拟环境非常简单,但根据您的系统配置以及您希望虚拟环境运行的 Python 版本,您需要正确运行命令。当您想要使用它时,您还需要执行的操作是激活它。激活虚拟环境在幕后进行一些路径调整,以便当您从该 shell 调用 Python 解释器时,它将来自虚拟环境,而不是系统。我们将为您展示在 macOS 和 Windows 上的完整示例(在 Linux 上,它将与 macOS 非常相似)。我们将:

  1. 打开一个终端,切换到我们用作项目根目录的文件夹(目录)(我们的文件夹是code)。我们将创建一个名为my-project的新文件夹,并切换到它。

  2. 创建一个名为lpp4ed的虚拟环境。

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

  4. 然后,我们将确保我们正在运行所需的 Python 版本(3.12.X),通过运行 Python 交互式 shell 来实现。

  5. 最后,我们将取消虚拟环境的激活。

一些开发者喜欢将所有虚拟环境命名为相同的名称(例如,.venv)。这样,他们可以通过知道其位置来配置工具和运行脚本,针对任何虚拟环境。.venv 中的点存在是因为在 Linux/macOS 中,在名称前加上点会使该文件或文件夹“不可见”。

这些步骤就是你开始一个项目所需的所有。

我们将以 macOS 上的一个示例开始(请注意,根据你的操作系统、Python 版本等,你可能会得到略有不同的结果)。在这个列表中,以井号 # 开头的行是注释,为了可读性添加了空格,箭头 表示由于空间不足而换行的行:

fab@m1:~/code$ mkdir my-project  # step 1
fab@m1:~/code$ cd my-project
fab@m1:~/code/my-project$ which python3.12  # check system python
/usr/bin/python3.12  # <-- system python3.12
fab@m1:~/code/my-project$ python3.12 -m venv lpp4ed  # step 2
fab@m1:~/code/my-project$ source ./lpp4ed/bin/activate  # step 3
# check python again: now using the virtual environment's one
(lpp4ed) fab@m1:~/code/my-project$ which python
/Users/fab/code/my-project/lpp4ed/bin/python
(lpp4ed) fab@m1:~/code/my-project$ python  # step 4
Python 3.12.2 (main, Feb 14 2024, 14:16:36)
→ [Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> exit()
(lpp4ed) fab@m1:~/code/my-project$ deactivate  # step 5
fab@m1:~/code/my-project$ 

每个步骤都带有注释,所以你应该能够很容易地跟随。

这里需要注意的一点是,为了激活虚拟环境,我们需要运行 lpp4ed/bin/activate 脚本,该脚本需要被引用。当一个脚本被 引用 时,意味着它在当前 shell 中执行,并且其效果在执行后仍然持续。这非常重要。同时注意,在激活虚拟环境后,提示符如何发生变化,显示其名称在左侧(以及当我们取消激活时它如何消失)。

在 Windows 11 PowerShell 中,步骤如下:

PS C:\Users\H\Code> mkdir my-project  # step 1
PS C:\Users\H\Code> cd .\my-project\
# check installed python versions
PS C:\Users\H\Code\my-project> py --list-paths
 -V:3.12 *
→ C:\Users\H\AppData\Local\Programs\Python\Python312\python.exe
PS C:\Users\H\Code\my-project> py -3.12 -m venv lpp4ed  # step 2
PS C:\Users\H\Code\my-project> .\lpp4ed\Scripts\activate  # step 3
# check python versions again: now using the virtual environment's
(lpp4ed) PS C:\Users\H\Code\my-project> py --list-paths
  *
→ C:\Users\H\Code\my-project\lpp4ed\Scripts\python.exe
 -V:3.12
→ C:\Users\H\AppData\Local\Programs\Python\Python312\python.exe
(lpp4ed) PS C:\Users\H\Code\my-project> python  # step 4
Python 3.12.2 (tags/v3.12.2:6abddd9, Feb  6 2024, 21:26:36)
→ [MSC v.1937 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more
→ information.
>>> exit()
(lpp4ed) PS C:\Users\H\Code\my-project> deactivate  # step 5
PS C:\Users\H\Code\my-project> 

注意,在 Windows 上,激活虚拟环境后,你可以使用 py 命令,或者更直接地使用 python

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

书籍的源代码为每个章节都包含一个专门的文件夹。当章节中显示的代码需要安装第三方库时,我们将包含一个 requirements.txt 文件(或一个包含多个文本文件的等效 requirements 文件夹),你可以使用它来安装运行该代码所需的库。我们建议在实验章节的代码时,为该章节创建一个专门的虚拟环境。这样,你将能够在创建虚拟环境和安装第三方库方面获得一些实践。

安装第三方库

为了安装第三方库,我们需要使用 Python 包安装器,也就是 pip。很可能它已经在你虚拟环境中可用,如果没有,你可以在其文档页面上了解所有相关信息:pip.pypa.io

以下示例展示了如何创建一个虚拟环境并安装从 requirements 文件中获取的几个第三方库:

fab@m1:~/code$ mkdir my-project
fab@m1:~/code$ cd my-project
fab@m1:~/code/my-project$ python3.12 -m venv lpp4ed
fab@m1:~/code/my-project$ source ./lpp4ed/bin/activate
(lpp4ed) fab@m1:~/code/my-project$ cat requirements.txt
django==5.0.3
requests==2.31.0
# the following instruction shows how to use pip to install
# requirements from a file
(lpp4ed) fab@m1:~/code/my-project$ pip install -r requirements.txt
Collecting django==5.0.3 (from -r requirements.txt (line 1))
  Using cached Django-5.0.3-py3-none-any.whl.metadata (4.2 kB)
Collecting requests==2.31.0 (from -r requirements.txt (line 2))
  Using cached requests-2.31.0-py3-none-any.whl.metadata (4.6 kB)
  ... more collecting omitted ...
Installing collected packages: ..., requests, django
Successfully installed ... django-5.0.3 requests-2.31.0
(lpp4ed) fab@m1:~/code/my-project$ 

正如你在列表底部可以看到的,pip安装了要求文件中的所有库,还有一些额外的库。这是因为djangorequests都有自己的第三方库列表,它们依赖于这些库,因此pip会自动为我们安装它们。

使用requirements.txt文件只是 Python 中安装第三方库的许多方法之一。你也可以直接指定要安装的包名,例如,pip install django

你可以在 pip 用户指南中找到更多信息:pip.pypa.io/en/stable/user_guide/

现在,随着框架的搭建完成,我们准备更多地讨论 Python 及其用法。不过,在我们这样做之前,让我们先说几句关于控制台的话。

控制台

在这个 GUI 和触摸屏设备的时代,当一切似乎都只需一键即可完成时,不得不求助于像控制台这样的工具,这似乎有些荒谬。

但事实是,每当你从键盘上抬起手去抓鼠标,并将光标移动到你想要点击的位置时,你都在浪费时间。尽管一开始可能感觉不太直观,但使用控制台完成任务可以提高生产力和速度。相信我们,我们知道——你将不得不在这方面信任我们。

速度和生产率很重要,尽管我们并不反对使用鼠标,但熟练掌握控制台还有一个很好的原因:当你开发的代码最终部署到某个服务器上时,控制台可能是唯一可用的工具来访问该服务器上的代码。如果你与它成为朋友,当你最需要它的时候,你永远不会迷失方向(通常,当网站宕机时,你必须快速调查发生了什么)。

如果你仍然没有说服自己,请给我们一个机会,试一试。这比你想象的要简单,你不会后悔的。没有什么比一个优秀的开发者因为习惯了自己定制的工具集,而迷失在服务器的 SSH 连接中更可怜的了。

现在,让我们回到 Python 上来。

如何运行 Python 程序

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

运行 Python 脚本

Python 可以用作脚本语言;事实上,它总是证明自己非常有用。脚本通常是执行某些任务(如任务)的文件(通常尺寸较小)。许多开发者最终会拥有一系列工具,他们在需要执行任务时使用这些工具。例如,你可以有脚本来解析一种格式的数据并将其渲染成另一种格式,或者你可以使用脚本来处理文件和文件夹;你可以创建或修改配置文件——从技术上讲,在脚本中几乎没有什么不能做的。

在服务器上脚本在精确时间运行是很常见的事情。例如,如果你的网站数据库需要每 24 小时清理一次(例如,定期清理过期的用户会话),你可以设置一个 Cron 作业,每天凌晨 1 点触发你的脚本。

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

我们有 Python 脚本来完成所有那些需要我们花费几分钟甚至更多时间手动完成的琐事,在某个时候,我们决定自动化。

运行 Python 交互式 Shell

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

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

(lpp4ed) fab@m1 ~/code/lpp4ed$ python
Python 3.12.2 (main, Feb 14 2024, 14:16:36)
[Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more
information.
>>> 

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

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

>>> 3 + 7
10
>>> 10 / 4
2.5
>>> 2 ** 1024
179769313486231590772930519078902473361797697894230657273430081157
732675805500963132708477322407536021120113879871393357658789768814
416622492847430639474124377767893424865485276302219601246094119453
082952085005768838150682342462881473913110540827237163350510684586
298239947245938479716304835356329624224137216 

最后一个操作是向你展示一些令人难以置信的事情。我们将21024次方,Python 处理这个任务毫无困难。尝试在 Java、C++或 C#中做这件事,除非你使用特殊库来处理这样的大数,否则它将无法工作。

我们每天都在使用交互式 Shell。它对于快速调试来说极其有用;例如,检查一个数据结构是否支持某个操作,或者检查或运行一段代码。你会发现,交互式 Shell 很快就会成为你在旅途中最亲密的朋友之一。

另一个解决方案,它提供了一个更漂亮的图形布局,是使用集成开发和学习环境IDLE)。它是一个非常简单的集成开发环境IDE),主要面向初学者。它比控制台中获得的裸交互式 Shell 的功能集稍大,所以你可能想探索一下。它随 Windows 和 macOS Python 安装程序免费提供,你可以在任何其他系统上轻松安装它。你可以在 Python 网站上找到更多关于它的信息。

吉多·范罗苏姆将 Python 命名为英国喜剧团体蒙提·派森的名字,所以据说选择IDLE这个名字是为了纪念蒙提·派森的创始人之一埃里克·艾德尔。

将 Python 作为服务运行

除了作为脚本运行和在壳内运行之外,Python 还可以编码并作为应用程序运行。在这本书的整个过程中,我们将看到这种模式的示例。我们将在讨论 Python 代码的组织和运行方式时更深入地探讨这一点。

以 GUI 应用程序的形式运行 Python

Python 也可以用来创建 图形用户界面 (GUI)。有多个框架可供选择,其中一些是跨平台的,而另一些则是特定平台的。一个流行的 GUI 应用程序库示例是 Tkinter,它是一个位于 Tk 之上的面向对象层(Tkinter 意味着 Tk 接口)。

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

Tkinter 是 Python 的内置库,因此它为程序员提供了轻松访问 GUI 世界的方法。

其他广泛使用的 GUI 框架包括:

  • PyQT/PySide

  • wxPython

  • Kivy

详细描述它们超出了本书的范围,但你可以在 Python 网站上找到所需的所有信息:docs.python.org/3/faq/gui.html

你可以在 Python 有哪些 GUI 工具包? 这一部分找到信息。如果你正在寻找 GUI,记得根据一些基本原则选择你想要的。确保它们:

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

  • 在你需要支持的平台上运行

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

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

Python 代码是如何组织的?

让我们简单谈谈 Python 代码是如何组织的。在本节中,我们将开始进入传说中的兔子洞,并介绍更多的技术名称和概念。

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

如果你使用的是 Windows 或 macOS,这些系统通常隐藏文件扩展名,我们建议你更改配置,以便可以看到文件的完整名称。这并不是严格的要求,而是一个建议,可能在区分文件时有所帮助。

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

一个完整的 Python 应用程序可能由成千上万行代码组成,所以你将不得不将它们分散到不同的模块中,这比没有组织要好,但还不够好。结果证明,即使这样,处理代码仍然是不切实际的。所以,Python 给了你另一个结构,称为 ,它允许你将模块组合在一起。

包不过是一个文件夹。在 Python 的早期版本中,需要一个特殊的文件 __init__.py 来标记一个目录为包。这个文件不需要包含任何代码,尽管它的存在不再是强制性的,但仍然有实际的理由说明为什么总是包含它是一个好主意。

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

$ tree -v example 

我们得到了 ch1/example 文件夹内容的树形表示,它包含本章示例的代码。一个简单应用程序的结构可能看起来是这样的:

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

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

如前所述,__init__.py 文件仅仅是为了告诉 Python util 是一个包,而不仅仅是一个简单的文件夹。

如果这个软件仅仅在模块中组织,那么推断其结构将会更困难。我们在 ch1/files_only 文件夹中放置了一个仅模块的示例;请亲自查看:

$ tree -v files_only 

这展示了完全不同的画面:

files_only
├── core.py
├── db.py
├── maths.py
├── network.py
└── run.py 

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

我们如何使用模块和包?

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

例如,在一个民意调查应用程序中,用户会被问及许多问题,其中很可能有几个需要数值答案。这些可能包括:

  • 你多大年纪?

  • 你有多少宠物?

  • 你有多少孩子?

  • 你结过几次婚?

在我们期望得到数值答案的每个地方复制/粘贴(或者说得更正式一些,复制)验证逻辑是不良的做法。这会违反不要重复自己(DRY)原则,该原则指出,你永远不应该在应用程序中重复相同的代码片段超过一次。尽管有 DRY 原则,我们在这里仍然需要强调这个原则的重要性:你永远不应该在应用程序中重复相同的代码片段超过一次

重复相同的逻辑可能有几个原因,其中最重要的原因是:

  • 逻辑中可能存在错误,因此你将不得不在每个副本中纠正它。

  • 你可能想要修改你执行验证的方式,并且,你将不得不在每个副本中更改它。

  • 你可能会忘记修复或修改一段逻辑,因为你错过了在搜索所有其出现位置时找到它。这会在你的应用程序中留下错误或不一致的行为。

  • 你的代码可能会因为没有任何原因而比所需的更长。

Python 是一种奇妙的语言,为你提供了应用编码最佳实践所需的所有工具。在这个例子中,我们需要能够重用一段代码。为了有效地做到这一点,我们需要有一个结构来为我们保存代码,这样我们就可以在需要重复其中逻辑时调用该结构。这个结构存在,它被称为函数

我们在这里不会深入探讨具体细节,所以请记住,函数是一个用于执行任务的有序、可重用代码块。函数可以采取许多形式和名称,根据它们所属的环境而定,但就目前而言,这并不重要。细节将在我们能够欣赏它们的时候,在本书的后面部分看到。函数是应用程序模块化的基石,它们几乎是不可或缺的。除非你正在编写一个非常简单的脚本,否则你将始终使用函数。函数将在第四章函数,代码的构建块中探讨。

如前几页所述,Python 附带了一个非常广泛的库。现在是一个定义的好时机:库是一组提供功能以丰富语言能力的函数和对象。例如,在 Python 的math库中,可以找到大量的函数,其中之一是factorial函数,它计算一个数的阶乘。

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

N! = 12345 = 120

0 的阶乘是 0! = 1,以尊重空乘积的惯例。

因此,如果你想在代码中使用这个函数,你所要做的就是导入它,并用正确的输入值调用它。如果现在输入值和调用的概念还不清楚,请不要过于担心;请只专注于导入部分。我们通过从库中导入特定组件来使用库,然后将其用于预期目的。在 Python 中,要计算 5!,我们只需要以下代码:

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

无论我们在 shell 中输入什么,如果它有一个可打印的表示,它都会被打印在控制台上供我们使用(在这种情况下,函数调用的结果:120)。

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

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

Python 的执行模型

在本节中,我们想向您介绍一些重要概念,例如作用域、名称和命名空间。您当然可以在官方语言参考(docs.python.org/3/reference/executionmodel.html)中阅读有关 Python 执行模型的全部内容,但我们认为它相当技术性和抽象,所以让我们先给您一个不那么正式的解释。

名称和命名空间

假设你在找一本书,所以你去图书馆并请求某人帮忙。他们会告诉你类似“二楼,区 X,行三”这样的信息。然后你上楼,寻找区 X,等等。如果进入一个所有书籍都随机堆放在一个大房间里的图书馆将会非常不同。没有楼层,没有区,没有行,没有顺序。找一本书将会非常困难。

当我们编写代码时,我们面临相同的问题:我们必须尝试组织它,以便对没有先验知识的人来说容易找到他们想要的东西。当软件结构正确时,它也促进了代码的重用。此外,无组织的软件更有可能包含散乱的重复逻辑片段。

作为第一个例子,让我们拿一本书。我们通过书名来引用它;在 Python 术语中,这将是名称。Python 名称是其他语言所称为变量的最接近的抽象。名称引用对象,并通过名称绑定操作引入。让我们看一个快速示例(再次注意,任何跟在#后面的内容都是注释):

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

记住,每个 Python 对象都有一个身份、一个类型和一个值。我们在前面的代码中定义了三个对象;现在让我们检查它们的类型和值:

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

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

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

不要担心,我们知道我们还没有涵盖字典是什么。我们将在第二章内置数据类型中看到,它是 Python 数据结构之王。

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

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

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

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

from library.second_floor.section_x.row_three import book 

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

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

另有一个概念,与命名空间密切相关,我们想简要提及:作用域

作用域

根据 Python 的文档:

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

直接可访问意味着,当查找未指定名称的引用时,Python 会尝试在命名空间中找到它。

作用域是静态确定的,但在运行时是动态使用的。这意味着通过检查源代码,你可以知道一个对象的作用域。Python 提供了四种不同的作用域可供访问(尽管不一定同时都存在):

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

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

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

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

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

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

这一切都是理论上的,所以让我们看一个例子。为了演示局部和封装命名空间,我们不得不定义几个函数。如果你目前不熟悉它们的语法,请不要担心——我们将在 第四章函数,代码的构建块 中介绍。只需记住,在下面的代码中,当你看到 def 时,这意味着我们正在定义一个函数:

# scopes1.py
# Local versus Global
# we define a function, called local
def local():
    age = 7
    print(age)
# we define age within the global scope
age = 5
# we call, or `execute` the function local
local()
print(age) 

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

$ python scopes1.py 

我们在控制台看到打印出两个数字:7 和 5。

发生的事情是,Python 解释器从上到下解析文件。首先,它找到几行注释,这些注释会被跳过,然后它解析 local 函数的定义。当被调用时,这个函数将做两件事:它将为代表数字 7 的对象设置一个名称,并将其打印出来。Python 解释器继续执行,并找到另一个名称绑定。这次,绑定发生在全局作用域,其值为 5。在下一行,有一个对 local 函数的调用。此时,Python 执行该函数,因此这次 age = 7 的绑定发生在局部作用域,并被打印出来。最后,有一个对 print 函数的调用,该调用被执行,现在将打印 5

需要注意的一个特别重要的事情是,属于 local 函数定义部分的代码在右侧缩进了四个空格。实际上,Python 通过缩进来定义作用域。你通过缩进来进入一个作用域,通过取消缩进来退出。一些程序员使用两个空格,一些使用三个空格,但建议使用的空格数是四个。这是一个提高可读性的好方法。我们将在稍后更详细地讨论你在编写 Python 代码时应遵循的所有约定。

在其他语言中,例如 Java、C# 和 C++,作用域是通过在成对的大括号 { … } 内编写代码来创建的。因此,在 Python 中,缩进代码对应于打开大括号,而缩进代码对应于关闭大括号。

如果我们删除那行 age = 7 会发生什么?记住 LEGB 规则。Python 将从局部作用域(函数 local)开始查找 age,如果没有找到,它将进入下一个封装作用域。在这种情况下,下一个是全局作用域。因此,我们会在控制台上看到数字 5 被打印两次。让我们看看在这种情况下代码会是什么样子:

# scopes2.py
# Local versus Global
def local():
    # age does not belong to the scope defined by the local
    # function so Python will keep looking into the next enclosing
    # scope. age is finally found in the global scope.
    print(age, 'printing from the local scope')
age = 5
print(age, 'printing from the global scope')
local() 

运行 scopes2.py 将打印以下内容:

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

如预期,Python 首次打印 age,然后当调用 local 函数时,age 在其作用域中未找到,因此 Python 沿着 LEGB 链查找,直到在全局作用域中找到 age。让我们通过一个额外的层,即封装作用域,来看一个例子:

# scopes3.py
# Local, Enclosing and Global
def enclosing_func():
    age = 13
    def local():
        # age does not belong to the scope defined by the local
        # function so Python will keep looking into the next
        # enclosing scope. This time age is found in the enclosing
        # scope
        print(age, 'printing from the local scope')
    # calling the function local
    local()
age = 5
print(age, 'printing from the global scope')
enclosing_func() 

运行 scopes3.py 将在控制台上打印以下内容:

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

如你所见,local 函数中的 print 指令仍然像之前一样引用 ageage 仍然在函数内部没有定义,所以 Python 按照 LEGB 顺序开始遍历作用域。这次,age封装 作用域中被找到。

如果现在这仍然不是很清楚,请不要担心。随着我们通过书中的例子进行学习,它将变得更加清晰。Python 教程的 部分(docs.python.org/3/tutorial/classes.html)有一个关于作用域和命名空间的有趣段落。确保你阅读它,以获得对该主题的更深入理解。

编写良好代码的指南

编写良好的代码并不像看起来那么简单。正如我们之前所说的,良好的代码展示了一系列难以结合的品质。编写良好的代码是一种艺术。无论你将在哪个阶段感到满意,你都可以接受一些东西,这将使你的代码立即变得更好:PEP 8

Python 增强提案PEP)是一份描述新提出的 Python 功能的文档。PEP 还用于记录 Python 语言开发周围的流程,并提供指南和信息。你可以在 www.python.org/dev/peps 找到所有 PEP 的索引。

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

今天的编码不再是一个检查-签出业务。相反,它更多的是一种社会努力。几个开发者通过 Git 和 Mercurial 等工具共同协作编写代码,结果是许多不同人手的产品。

Git 和 Mercurial 是目前使用最广泛的两个分布式版本控制系统。它们是旨在帮助开发团队在同一软件上协作的必要工具。

这些天,比以往任何时候都更需要有一种一致的代码编写方式,以便最大限度地提高可读性。当一家公司的所有开发人员都遵守 PEP 8 时,任何一个人遇到一段代码时都会觉得是自己写的(实际上,Fabrizio 总是这样,因为他很快就会忘记自己写的任何代码)。

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

Python 开发者可以利用几个不同的工具自动根据 PEP 8 指南格式化他们的代码。流行的工具有blackruff。还有其他一些工具,称为 linters,它们检查代码是否格式正确,并向开发者发出警告,说明如何修复错误。著名的工具有flake8PyLint。我们鼓励您使用这些工具,因为它们简化了编写格式良好软件的任务。

在本书的示例中,我们将尽可能尊重 PEP 8。不幸的是,我们没有 79 个字符的奢侈(这是 PEP 8 建议的最大行长度),我们不得不减少空白行和其他一些内容,但我们承诺我们会尽量使代码布局尽可能易于阅读。

Python 文化

Python 已经在软件行业得到了广泛的应用。许多不同的公司出于不同的目的使用它,同时它也是一个出色的教育工具(由于它的简单性,这使得它易于学习;它鼓励编写可读性强的代码的好习惯;它是平台无关的;并且支持现代面向对象编程范式)。

Python 今天之所以如此受欢迎,其中一个原因是因为围绕它的社区庞大、充满活力,并且充满了杰出的人才。世界各地组织了许多活动,大多数活动要么与 Python 有关,要么与其最广泛采用的 Web 框架之一(如 Django)有关。

Python 的源代码是开放的,而且那些接受它的人的思想也往往是开放的。查看 Python 网站上的社区页面以获取更多信息并参与其中!

Python 另一个方面围绕着“Pythonic”这一概念。这与 Python 允许你使用一些在其他地方找不到的惯用语有关,至少不是以相同的形式或易用性(当不得不在非 Python 语言中编码时,有时会感到压抑)。

总之,多年来,“Pythonic”这一概念已经出现,根据我们的理解,它类似于按照 Python 应该做到的方式进行操作

为了帮助你更好地了解 Python 的文化和“Pythonic”,我们将向你展示Python 的禅宗——一个非常受欢迎的*复活节彩蛋。打开 Python 控制台,输入 import this

以下就是那项指导的结果:

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

这里有两种阅读层次。一种是将它视为一组以随意方式组合在一起的指南。另一种是将其牢记在心,偶尔阅读,试图理解它如何引用更深层次的内容:一些你必须深入理解的 Python 特性,以便以正确的方式编写 Python。从有趣层面开始,然后深入挖掘。总是深入挖掘。

关于 IDE 的一些说明

关于 IDE 的一些简要说明。为了遵循这本书中的示例,你不需要一个 IDE;任何体面的文本编辑器都足够好。如果你想拥有更高级的功能,例如语法高亮和自动完成,你将需要为自己获取一个 IDE。你可以在 Python 网站上找到一个开源 IDE 的综合列表(只需在 Google 上搜索“Python IDEs”)。

Fabrizio 使用的是来自微软的 Visual Studio Code。它是免费使用的,并且提供了许多开箱即用的功能,甚至可以通过安装扩展来进一步扩展。

在与包括 Sublime Text 在内的几位编辑器合作多年后,这一个是让他觉得最有生产力的。

另一方面,Heinrich 是一个坚定的 Neovim 用户。尽管它可能有一个陡峭的学习曲线,但 Neovim 是一个非常强大的文本编辑器,也可以通过插件进行扩展。它还有与它的前辈 Vim 兼容的好处,Vim 几乎安装在每个软件开发者经常工作的系统上。

两条重要的建议:

  • 无论你决定使用什么 IDE,都要努力学好,以便充分利用其优势,但不要过度依赖它。偶尔练习使用 Vim(或任何其他文本编辑器);学会在任何平台上使用任何一组工具进行工作。

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

关于 AI 的一些话

在过去一年左右的时间里,世界见证了人工智能的诞生。现在市场上有很多选择,其中一些为程序员提供了工具。

存在能够编写代码的工具的事实并不否定学习编程语言的所有理由。AI 工具远远不能做到人类能做的事情。它们并不完美,在撰写本文时,它们主要用于帮助完成重复性任务,有时甚至是枯燥的任务。

几个集成开发环境(IDE)可以与 GitHub Copilot(及其类似技术)等技术集成。Visual Studio Code、Zed、IntelliJ IDEA 和 PyCharm 都提供了使用 AI 插件来增强其功能的方法。甚至还有一些专门围绕 AI 功能设计的全新 IDE,例如 Cursor。

虽然我们在工作中确实使用这样的工具,但我们认为强调你尝试自己理解本书中的代码示例的重要性是至关重要的。请尽量在没有 AI 助手帮助的情况下解决这些问题,因为这将是你的学习过程中的一个重要部分。

摘要

在本章中,我们开始探索编程世界和 Python 世界。我们只是触及了表面,只是简要地提到了书中稍后会更详细讨论的概念。

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

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

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

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

尝试跟随示例;我们将保持它们简单且简短。如果你能将它们应用到实践中,你将比仅仅阅读它们更好地记住它们。

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

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第二章:内置数据类型

“数据!数据!数据!”他焦急地喊道。“没有粘土,我无法制作砖块。”

——夏洛克·福尔摩斯,《铜 Beeches》冒险

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

数据可以是简单的,无论是代表年龄的整数,还是像网站上的订单这样的复杂结构。它可以关于单个对象,也可以关于它们的集合。数据甚至可以关于数据——即 元数据。这是描述其他数据结构设计的数据,或描述应用程序数据或其上下文的数据。在 Python 中,对象是数据的抽象,Python 有许多惊人的数据结构,你可以使用它们来表示数据或将它们组合起来创建自己的自定义数据。

在本章中,我们将介绍以下内容:

  • Python 对象的结构

  • 可变性和不可变性

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

  • collections 模块,简要介绍

  • 枚举

万物皆对象

在我们深入具体细节之前,我们希望你对 Python 中的对象有非常清晰的认识,所以让我们再谈谈它们。Python 中的万物都是对象,每个对象都有一个 identityID)、一个 type 和一个 value。但当你在一个 Python 模块中输入像 age = 42 这样的指令时,实际上会发生什么呢?

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

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

如果你搬家,你会把所有的刀叉和勺子放进一个盒子里,并在上面贴上 cutlery 标签。这正是同样的概念。下面是一个截图,展示了它可能的样子(你可能需要调整设置才能获得相同的视图):

img

图 2.1 – 一个指向对象的名称

因此,在本章的剩余部分,每当你读到像 name = some_value 这样的内容时,请想象一个放置在命名空间中的名称,它与编写指令的作用域相关联,并通过一个指向具有 idtypevalue 的对象的优美箭头。关于这个机制还有更多要说的,但用例子来说明它要容易得多,所以我们稍后再来讨论这个问题。

可变性

Python 对数据的第一项基本区分是对象的值是否可以改变。如果值可以改变,则该对象称为可变,否则该对象称为不可变

理解可变和不可变之间的区别非常重要,因为它会影响你编写的代码。让我们看看以下例子:

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

在前面的代码中,在行#A上,我们是否改变了age的值?好吧,没有。但现在它是43(我们听到你说...)。是的,它是43,但42是一个整数,类型为int,是不可变的。所以,实际上发生的事情是,在第一行,age是一个指向值为42int对象的名称。当我们输入age = 43时,发生的事情是创建了一个新的int对象,其值为43(同样,id也会不同),并且名称age被设置为指向它。所以,实际上我们没有将42改为43——我们只是将名称age指向了不同的位置,即新的值为43int对象。让我们看看对象的 ID:

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

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

如果你在你自己的计算机上重现这些例子,你会注意到你得到的 ID 将是不同的。这是当然的,因为它们是由 Python 随机生成的,并且每次都会不同。

现在,让我们用可变对象来查看相同的例子。为此,我们将使用内置的set类型:

>>> numbers = set()
>>> id(numbers)
4368427136
>>> numbers
set()
>>> numbers.add(3)
>>> numbers.add(7)
>>> id(numbers)
4368427136
>>> numbers
{3, 7} 

在这个例子中,我们创建了一个对象,numbers,它代表一个数学集合。我们可以看到它的id在创建后立即被打印出来,并且它是空的(set())。然后我们继续向其中添加两个数字:37。我们再次打印id(这表明它是指向同一个对象)和它的值,现在显示它包含这两个数字。所以对象的价值已经改变,但它的id仍然是相同的。这显示了可变对象的典型行为。我们将在本章的后面更详细地探讨集合。

可变性是一个非常重要的概念。我们将在本章的其余部分提醒你。

数字

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

数字是不可变对象。

整数

Python 整数具有无限的范围,仅受可用虚拟内存的限制。这意味着你想要存储的数字有多大并不重要——只要它能适应你的计算机内存,Python 就会处理它。

整数可以是正数、负数或 0(零)。它们的类型是int。它们支持所有基本数学运算,如下面的例子所示:

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

上述代码应该很容易理解。只需注意一点:Python 有两个除法运算符,一个执行所谓的真除法/),它返回操作数的商,另一个是所谓的整数除法//),它返回操作数的向下取整商。

作为历史信息,在 Python 2 中,除法运算符 / 的行为与 Python 3 不同。

让我们看看当我们引入负数时,除法是如何表现出不同的行为的:

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

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

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

注意,截断是向 0 方向进行的。

int() 函数还可以从字符串表示中返回给定基数的整数数字:

>>> int('10110', base=2)
22 

值得注意的是,幂运算符 ** 也有一个内置函数对应物,即下面的例子中所示的 pow() 函数:

>>> pow(10, 3)
1000
>>> 10 ** 3
1000
>>> pow(10, -3)
0.001
>>> 10 ** -3
0.001 

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

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

pow() 函数允许第三个参数执行模幂运算

带有三个参数的这种形式在基数与模数互质的情况下也接受负指数。结果是基数的模乘法逆元(或当指数为负时,该基数的适当幂,但不是 -1),模第三个参数。

这里有一个例子:

>>> pow(123, 4)
228886641
>>> pow(123, 4, 100)
41  # notice: 228886641 % 100 == 41
>>> pow(37, -1, 43)  # modular inverse of 37 mod 43
7
>>> (7 * 37) % 43  # proof the above is correct
1 

Python 3.6 引入的一个不错的新特性是能够在数字字面量中添加下划线(在数字或基数指定符之间,但不能是开头或结尾)。其目的是帮助使某些数字更易于阅读,例如 1_000_000_000

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

布尔值

布尔代数是代数的一个子集,其中变量的值是真理值,即。在 Python 中,TrueFalse 是两个用于表示真理值的保留字。布尔值是整数的子类,因此 TrueFalse 分别像 10 一样行为。布尔值的int类型等价于bool类型,它返回 TrueFalse。每个内置的 Python 对象在布尔上下文都有一个值,这意味着当将它们传递给 bool 函数时,它们评估为 TrueFalse

布尔值可以使用逻辑运算符 andornot 在布尔表达式中进行组合。让我们看一个简单的例子:

>>> int(True)  # True behaves like 1
1
>>> int(False)  # False behaves like 0
0
>>> bool(1)  # 1 evaluates to True in a Boolean context
True
>>> bool(-42)  # and so does every non-zero number
True
>>> bool(0)  # 0 evaluates to False
False
>>> # quick peek 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.617251235400005 

在计算面积时,我们将radius ** 2用括号括起来。即使这不是必要的,因为幂运算符的优先级高于乘法运算符,但我们认为这样公式读起来更清晰。此外,如果你得到的面积结果略有不同,请不要担心。这可能会取决于你的操作系统、Python 是如何编译的等等。只要前几位小数是正确的,你就知道这是正确的结果。

sys.float_info序列包含有关浮点数在你的系统上如何表现的信息。这是一个你可能看到的例子:

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

在这里让我们做一些考虑:我们有 64 位来表示浮点数。这意味着我们最多可以表示 2⁶⁴(即 18,446,744,073,709,551,616)个不同的数。看看浮点数的maxepsilon值,你就会意识到不可能表示它们全部。空间实在不够,所以它们被近似到最接近的可表示的数。你可能认为只有极大或极小的数会受到影响。如果是这样,接下来的例子会让你感到惊讶:

>>> 0.3 - 0.1 * 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 = complex(3.14, 2.73)  # same as above
>>> 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.406700000000001+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 has been simplified
>>> Fraction(1, 3) + Fraction(2, 3)  # 1/3 + 2/3 == 3/3 == 1/1
Fraction(1, 1)
>>> f = Fraction(10, 6)
>>> f.numerator
5
>>> f.denominator
3
>>> f.as_integer_ratio()
(5, 3) 

as_integer_ratio()方法也添加到了整数和布尔值中。这很有帮助,因为它允许你使用它而无需担心正在处理哪种类型的数字。

除了传递分子和分母之外,分数还可以通过传递字符串、小数、浮点数以及当然还有分数来初始化。让我们通过浮点数和字符串的例子来看一下:

>>> Fraction(0.125)  
Fraction(1, 8)
>>> Fraction("3 / 7")
Fraction(3, 7)
>>> Fraction("-.250")
Fraction(-1, 4) 

虽然Fraction对象有时非常有用,但在商业软件中并不常见。相反,更常见的是在所有那些精度至关重要的场合使用十进制数,例如在科学和金融计算中。

重要的是要记住,任意精度的十进制数在性能方面是有代价的,当然。每个数字要存储的数据量比分数或浮点数要多。它们被处理的方式也要求 Python 解释器在幕后更加努力。

让我们快速看一下十进制数字的例子:

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

注意,当我们从一个浮点数构造一个十进制数时,它继承了浮点数可能带来的所有近似问题。另一方面,当我们从一个整数或数字的字符串表示形式创建一个decimal时,那么这个decimal将没有近似问题,因此也没有奇怪的行为。当涉及到货币或精度至关重要的场合时,请使用十进制。

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

不可变序列

让我们探索不可变序列:字符串、元组和字节。

字符串和字节

在 Python 中,文本数据通过str对象处理,更常见的是称为字符串。它们是Unicode 代码点的不可变序列。

Unicode 码点是分配给 Unicode 标准中每个字符的数字,Unicode 是一种通用的字符编码方案,用于在计算机中表示文本。Unicode 标准为每个字符提供唯一的数字,无论平台、程序或语言如何,从而使得在不同系统之间对文本的一致表示和处理成为可能。Unicode 涵盖了广泛的字符,包括拉丁字母的字母、中文、日文和韩文书写系统的表意文字、符号、表情符号等等。

与其他语言不同,Python 没有char类型,所以单个字符由长度为 1 的字符串表示。

应该在任何应用程序的内部使用 Unicode。然而,当涉及到存储文本数据或通过网络发送它时,你通常需要使用适当的编码对其进行编码,使用你正在使用的介质的适当编码。编码的结果产生一个bytes对象,其语法和行为与字符串类似。在 Python 中,字符串字面量使用单引号、双引号或三引号(单引号或双引号)编写。如果使用三引号构建,字符串可以跨越多行。以下示例将阐明这一点:

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

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

字符串,像任何序列一样,都有长度。你可以通过调用len()函数来获取它:

>>> len(str1)
49 

Python 3.9 引入了两种处理字符串前缀和后缀的新方法。以下是一个示例,解释了它们的工作方式:

>>> s = "Hello There"
>>> s.removeprefix("Hell")
'o There'
>>> s.removesuffix("here")
'Hello T'
>>> s.removeprefix("Ooops")
'Hello There' 

他们的优点体现在最后的指令中:当我们尝试移除一个不存在的前缀或后缀时,该方法会简单地返回原始字符串的副本。在幕后,这些方法会检查字符串是否有与调用参数匹配的前缀或后缀,如果是的话,它们会移除它。

字符串的编码和解码

使用encode/decode方法,我们可以对 Unicode 字符串进行编码,对 bytes 对象进行解码。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 us revert to the original
'This is üŋíc0de'
>>> bytes_obj = b"A bytes object"  # a bytes object
>>> type(bytes_obj)
<class 'bytes'> 

字符串的索引和切片

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

虽然索引只有一种形式——对序列中任何位置的零基访问——但切片有不同形式。当你从序列中获取一个切片时,你可以指定 startstop 位置,以及 step 。它们用冒号(:)分隔,如下所示:my_sequence[start:stop:step]。所有参数都是可选的;start 是包含的,而 stop 是排除的。可能最好通过示例来理解,而不是用文字进一步解释:

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

最后一行相当有趣。如果你没有指定任何参数,Python 会为你填充默认值。在这种情况下,start 将是字符串的开始,stop 将是字符串的结束,而 step 将是默认值:1。这是一种简单快捷的方法来获取字符串 s 的副本(相同的值,但不同的对象)。你能想到一种使用切片来获取字符串反转副本的方法吗(不要查找它——自己找出来)?

字符串格式化

字符串的一个有用特性是它们可以用作模板。这意味着它们可以包含占位符,这些占位符可以通过格式化操作用任意值替换。有几种格式化字符串的方法。关于所有可能性的完整列表,我们鼓励您查阅文档。以下是一些常见的示例:

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

在前面的示例中,你可以看到四种不同的字符串格式化方法。第一种,依赖于 % 操作符,可能会导致意外的错误,应该小心使用。一种更现代的字符串格式化方法是使用 format() 字符串方法。你可以从不同的示例中看到,一对大括号在字符串中充当占位符。当我们调用 format() 时,我们向它提供数据来替换占位符。我们可以在大括号内指定索引(以及更多),甚至名称,这意味着我们必须使用关键字参数而不是位置参数来调用 format()

注意 greet_positional_idx 是如何通过向 format 调用提供不同的数据而以不同的方式呈现的。

我们想向您展示的一个特性是在 Python 3.6 版本中添加的,它被称为 格式化字符串字面量。这个特性非常酷(而且比使用 format() 方法更快):字符串以 f 为前缀,并包含由大括号包围的替换字段。

替换字段是在运行时评估的表达式,然后使用格式协议进行格式化:

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

f-strings(在 Python 3.8 中引入)的一个有趣补充是,可以在 f-string 子句中添加一个等号指定符;这会导致表达式扩展为表达式的文本,然后是一个等号,接着是评估表达式的表示。这对于自文档化和调试目的来说非常好。以下是一个显示行为差异的示例:

>>> user = "heinrich"
>>> password = "super-secret"
>>> f"Log in with: {user} and {password}"
'Log in with: heinrich and super-secret'
>>> f"Log in with: {user=} and {password=}"
"Log in with: user='heinrich' and password='super-secret'" 

在版本 3.12 中,f-string 的语法规范化通过几个特性得到了升级,这些特性在 PEP 701(peps.python.org/pep-0701/)中概述。这些特性之一是引号重用:

>>> languages = ["Python", "Javascript"]
>>> f"Two very popular languages: {", ".join(languages)}"
'Two very popular languages: Python, Javascript' 

注意我们如何在花括号内重用了双引号,这并没有破坏我们的代码。在这里,我们使用字符串","join()方法,用逗号和空格将languages列表中的字符串连接起来。在 Python 的早期版本中,我们不得不在花括号内使用单引号来界定字符串:', '

另一个特性是能够编写多行表达式和注释,以及使用反斜杠(\),这在之前是不允许的。

>>> f"Who knew f-strings could be so powerful? {"\N{shrug}"}"
'Who knew f-strings could be so powerful? 🤷' 

查看官方文档以了解有关字符串格式化和其强大功能的全部内容。

元组

在这里我们要讨论的最后一种不可变序列类型是元组。元组是任意 Python 对象的序列。在元组声明中,项目由逗号分隔。元组在 Python 中无处不在。它们允许其他语言难以复制的模式。有时元组不使用括号;例如,在一行中设置多个变量,或者允许函数返回多个对象(在许多语言中,函数通常只能返回一个对象),以及在 Python 控制台中,可以使用单个指令隐式地使用元组打印多个元素。我们将看到所有这些情况的示例:

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

我们使用in运算符来检查一个值是否是元组的成员。这个成员运算符也可以与列表、字符串和字典一起使用,以及一般地与集合和序列对象一起使用。

要创建一个只有一个元素的元组,我们需要在该元素后放置一个逗号。原因是,如果没有逗号,该元素将单独用大括号括起来,这可以被认为是一个冗余的表达式。注意,在赋值时,大括号是可选的,所以my_tuple = 1, 2, 3my_tuple = (1, 2, 3)相同。

元组赋值允许我们进行一行交换,无需第三个临时变量。让我们首先看看传统的做法:

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

现在我们来看看如何在 Python 中实现它:

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

看看展示 Python 交换两个值方式的行。你还记得我们在第一章,《Python 的温柔介绍》中写了什么吗?Python 程序通常比等价的 Java 或 C++代码小五分之一到三分之一,而像一行交换这样的特性也对此做出了贡献。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 不熟悉。这被称为列表推导: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
>>> from math import prod
>>> prod(a)  # product of all values in the list
105
>>> 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] 

注意我们如何轻松地计算列表中所有值的总和和乘积。prod()函数来自math模块,是 Python 3.8 中引入的许多新增功能之一。即使您不打算经常使用它,了解math模块并熟悉其函数也是一个好主意,因为它们可能非常有帮助。

上述代码的最后两行也非常有趣,因为它们向我们介绍了一个称为运算符重载的概念。简而言之,这意味着运算符,如+-*%等,根据它们使用的上下文可能代表不同的操作。将两个列表相加没有意义,对吧?因此,+符号用于连接它们。因此,*符号用于将列表与自身连接指定次数,这个次数由右操作数指定。

现在,让我们更进一步,看看一些更有趣的东西。我们想向您展示sorted方法有多强大,以及 Python 如何轻松实现在其他语言中可能需要大量努力才能实现的结果:

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

上述代码需要一点解释。注意,a 是一个元组列表。这意味着 a 中的每个元素都是一个元组(在这个例子中是一个 2 元组)。当我们调用 sorted(my_list) 时,我们得到 my_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 的元素进行排序的示例,然后再次,以相同的排序但顺序相反。如果你在其他语言中见过排序,你应该在这个时刻感到相当印象深刻。

Python 的排序算法非常强大,它是由 Tim Peters(Python 之禅 的作者)编写的。它恰当地命名为 Timsort ,它是 归并排序插入排序 的结合,并且比大多数用于主流编程语言的算法具有更好的时间性能。Timsort 是一个稳定的排序算法,这意味着当多个记录在比较中得分相同时,它们的原始顺序被保留。我们在 sorted(a, key=itemgetter(0)) 的结果中看到了这一点,它产生了 [(1, 3), (1, 2), ...] ,其中这两个元组的顺序被保留,因为它们在位置 0 的值相同。

字节数组

为了总结我们对可变序列类型的概述,让我们花一点时间来谈谈 bytearray 类型。字节数组是字节对象的可变版本。它们公开了大多数可变序列的常规方法以及字节类型的大多数方法。字节数组中的项是范围 [0, 256) 内的整数。

为了表示区间,我们将使用开放/封闭范围的常规表示法。一个方括号在一端意味着该值被包含,而一个圆括号意味着它被排除。粒度通常由边缘元素的类型推断,例如,区间 [3, 7] 表示介于 3 和 7 之间的所有整数,包括 3 和 7。另一方面,(3, 7) 表示介于 3 和 7 之间的所有整数,不包括(4,5 和 6)。字节数组类型的项是介于 0 和 256 之间的整数;0 被包含,256 不被包含。

间隔通常以这种方式表达的一个原因是便于编码。如果我们把范围[a, b)分成 N 个连续的范围,我们可以很容易地用如下方式表示原始范围:

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

一端排除中间点(k[i]),另一端包含中间点,使得连接和分割变得容易。

让我们用一个bytearray类型的例子来看看:

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

正如你所见,创建bytearray对象有几种方式。它们在许多情况下都可能很有用;例如,在通过套接字接收数据时,它们消除了在轮询时需要连接数据的需求,因此它们可能非常方便。在行#A中,我们从一个字节数组字面量b'Lina'创建了一个名为namebytearray,以向你展示bytearray对象如何公开序列和字符串的方法,这非常方便。如果你这么想,它们可以被看作是可变的字符串。

集合类型

Python 还提供了两种集合类型,setfrozensetset类型是可变的,而frozenset是不可变的。它们是无序的不可变对象集合。当打印时,它们通常以逗号分隔的值形式表示,在成对的括号内。

可哈希性是一种特性,使得一个对象可以作为集合成员以及作为字典的键使用,正如我们很快就会看到的。

从官方文档(docs.python.org/3.12/glossary.html#term-hashable):

“一个对象是可哈希的,如果它在整个生命周期中有一个永远不会改变的哈希值,并且可以与其他对象进行比较。 [...] 可哈希性使得对象可以作为字典键和集合成员使用,因为这些数据结构在内部使用哈希值。Python 的大多数不可变内置对象都是可哈希的;可变容器(如列表或字典)不是;不可变容器(如元组和 frozenset)只有在它们的元素是可哈希的时才是可哈希的。用户定义类的实例默认是可哈希的。它们都不相等(除了与自己相等),它们的哈希值是从它们的id()派生出来的。”

相等的对象必须有相同的哈希值。集合通常被用来测试成员资格;让我们在以下示例中介绍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)  # 1 is not a prime!
>>> small_primes
{1, 2, 3, 5}
>>> small_primes.remove(1)  # so let us 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 = dict(A=1, Z=-1)
>>> b = {"A": 1, "Z": -1}
>>> c = dict(zip(["A", "Z"], [1, -1]))
>>> d = dict([("A", 1), ("Z", -1)])
>>> e = dict({"Z": -1, "A": 1})
>>> a == b == c == d == e  # are they all the same?
True  # They are indeed 

所有这些字典都将键 A 映射到值 1,而 Z 映射到值 -1

你注意到那些双等号吗?赋值使用一个等号,而要检查一个对象是否与另一个对象(或在这种情况下一次五个)相同,我们使用双等号。还有另一种比较对象的方法,它涉及到 is 操作符,并检查两个对象是否相同(即它们具有相同的 ID,而不仅仅是相同的值),但除非你有很好的理由使用它,否则你应该使用双等号。

在前面的代码中,我们还使用了一个很棒的功能:zip()。它是以现实生活中的拉链命名的,它将两个部分粘合在一起,一次从每个部分取一个元素。让我们给你一个例子:

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

在前面的例子中,我们以两种不同的方式创建了相同的列表,一种更明确,另一种更符合 Python 风格。暂时忘记我们不得不在 zip() 调用周围包裹 list() 构造函数(原因是 zip() 返回一个迭代器,而不是 list,因此如果我们想看到结果,我们需要将迭代器耗尽到某种东西——在这个例子中是一个列表),而专注于结果。看看 zip() 是如何将其两个参数的第一个元素配对,然后是第二个,然后是第三个,以此类推?

看看手提箱、钱包或枕头套的拉链,你会发现它的工作方式与 Python 中的完全一样。但让我们回到字典,看看它们为我们提供了多少有用的方法来按我们的意愿操作它们。让我们从基本操作开始:

>>> d = {}
>>> d["a"] = 1  # let us 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 us remove `a`
>>> d
{'b': 2}
>>> d["c"] = 3  # let us 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 us clean everything from this dictionary
>>> d
{} 

注意访问字典的键时,无论我们执行的操作类型如何,都是使用方括号完成的。你还记得字符串、列表和元组吗?我们也是通过方括号访问某些位置的元素,这是 Python 一致性的另一个例子。

现在,让我们看看三个称为字典视图的特殊对象:keysvaluesitems。这些对象提供了对字典条目的动态视图。当字典发生变化时,它们也会发生变化。keys()返回字典中的所有键,values()返回字典中的所有值,items()返回字典中的所有(键,值)对,作为一个 2 元组的列表。

让我们通过一些代码来练习所有这些内容:

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

在这里有几个需要注意的地方。首先,注意我们是如何通过迭代字符串'hello'和数字0, 1, 2, 3, 4的打包版本来创建字典的。字符串'hello'中有两个'l'字符,它们通过zip()函数与值 2 和 3 配对。注意在字典中,第二个'l'键(值为 3)覆盖了第一个(值为 2)。这是因为字典中的每个键都必须是唯一的。另一件需要注意的事情是,当请求任何视图时,保留项目添加的原始顺序。

当我们讨论遍历集合时,我们将看到这些观点是如何成为基本工具的。现在,让我们看看 Python 字典暴露的一些其他有用的方法:

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

所有这些方法都很容易理解,但值得花点时间谈谈None。Python 中的每个函数都会返回None,除非显式地使用return语句返回其他内容。当我们探索第四章中的函数,即代码的构建块时,我们将深入了解这一点。None经常用来表示没有值,并且它通常用作函数声明中参数的默认值。经验不足的程序员有时会编写返回FalseNone的函数。在布尔上下文中,FalseNone都评估为False,所以它们之间似乎没有太大的区别。但实际上,我们会认为它们之间有一个重要的区别:False表示我们有信息,而我们拥有的信息是False

None表示没有信息;没有信息与False的信息非常不同。用简单的话说,如果你问你的修车工我的车准备好了吗?,那么回答没有,还没有False)和我不知道None)之间有很大的区别。

我们非常喜欢字典的最后一个方法setdefault()setdefault()方法的行为类似于get()方法。当被调用时,它也会将key/value对设置到字典中。让我们看一个例子:

>>> 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 us try to override the value
1
>>> d
{'a': 1}  # no override, as expected 

这就结束了我们对字典的这次游览。通过尝试预测这一行之后的d看起来像什么来测试你对它们的了解:

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

如果这些内容对你来说不是立即显而易见,请不要担心。我们只是想鼓励你尝试使用字典。

Python 3.9 添加了一个新的并集操作符,可用于 dict 对象,这是由 PEP 584 引入的。当涉及到将并集应用于 dict 对象时,我们需要记住,对于它们来说,并集不是交换的。当合并的两个 dict 对象有一个或多个共同键时,这一点变得明显。查看以下示例:

>>> d = {"a": "A", "b": "B"}
>>> e = {"b": 8, "c": "C"}
>>> d | e
{'a': 'A', 'b': 8, 'c': 'C'}
>>> e | d
{'b': 'B', 'c': 'C', 'a': 'A'}
>>> {**d, **e}
{'a': 'A', 'b': 8, 'c': 'C'}
>>> {**e, **d}
{'b': 'B', 'c': 'C', 'a': 'A'}
>>> d |= e
>>> d
{'a': 'A', 'b': 8, 'c': 'C'} 

在这里,dict 对象 de 共享 'b' 这个键。在 d 中,与 'b' 关联的值是 'B';而在 e 中,它是数字 8。这意味着当我们使用并集操作符 |e 放在右侧合并这两个对象时,e 中的值会覆盖 d 中的值。当然,当我们交换这些对象相对于并集操作符的位置时,情况正好相反。

在这个例子中,您还可以看到如何使用 ** 操作符执行并集操作以产生 字典解包。值得注意的是,并集也可以作为增强赋值操作(d |= e)执行,它是就地进行的。请参阅 PEP 584 获取有关此功能的更多信息。

这完成了我们对内置数据类型的巡礼。在我们结束这一章之前,我们想简要地看看标准库提供的其他数据类型。

数据类型

Python 提供了各种专用数据类型,例如日期和时间、容器类型和枚举。Python 标准库中有一个名为 数据类型 的整个章节,值得探索;它充满了满足每个程序员需求的有趣和有用的工具。您可以在以下位置找到它:docs.python.org/3/library/datatypes.html

在本节中,我们将简要介绍日期和时间、集合以及枚举。

日期和时间

Python 标准库提供了几种可以用来处理日期和时间的内置数据类型。一开始这可能看起来是一个简单的话题,但时区、夏令时、闰年以及其他怪癖很容易让一个粗心的程序员陷入困境。还有大量格式化和本地化日期和时间信息的方法。这反过来又使得解析日期和时间变得具有挑战性。这可能是为什么专业的 Python 程序员在处理日期和时间时也常常依赖各种第三方库来提供一些急需的额外功能。

标准库

我们将从标准库开始,并以第三方库的简要概述结束整个会话。

从标准库中,用于处理日期和时间的最主要模块是 datetimecalendarzoneinfotime。让我们从您需要为本节导入的导入开始:

>>> from datetime import date, datetime, timedelta, timezone, UTC
>>> import time
>>> import calendar as cal
>>> from zoneinfo import ZoneInfo 

第一个例子处理日期。让我们看看它们的形状:

>>> today = date.today()
>>> today
datetime.date(2024, 3, 19)
>>> today.ctime()
'Tue Mar 19 00:00:00 2024'
>>> today.isoformat()
'2024-03-19'
>>> today.weekday()
1
>>> cal.day_name[today.weekday()]
'Tuesday'
>>> today.day, today.month, today.year
(19, 3, 2024)
>>> today.timetuple()
time.struct_time(
    tm_year=2024, tm_mon=3, tm_mday=19,
    tm_hour=0, tm_min=0, tm_sec=0,
    tm_wday=1, tm_yday=79, tm_isdst=-1
) 

我们首先获取今天的日期。我们可以看到它是一个 datetime.date 类的实例。然后我们根据 CISO 8601 格式标准分别获取它的两种不同表示。之后,我们询问它是星期几,得到数字 1。天数编号为 0 到 6(代表星期一到星期日),所以我们抓取 calendar.day_name 中的第六个元素的值(注意在代码中我们将 calendar 别名为 cal 以便简短)。

最后两条指令展示了如何从日期对象中获取详细信息。我们可以检查其 daymonthyear 属性,或者调用 timetuple() 方法并获取大量信息。由于我们处理的是日期对象,请注意,所有关于时间的信息都已设置为 0

让我们现在玩玩时间:

>>> time.ctime()
'Tue Mar 19 21:15:23 2024'
>>> time.daylight
1
>>> time.gmtime()
time.struct_time(
    tm_year=2024, tm_mon=3, tm_mday=19,
    tm_hour=21, tm_min=15, tm_sec=53,
    tm_wday=1, tm_yday=79, tm_isdst=0
)
>>> time.gmtime(0)
time.struct_time(
    tm_year=1970, tm_mon=1, tm_mday=1,
    tm_hour=0, tm_min=0, tm_sec=0,
    tm_wday=3, tm_yday=1, tm_isdst=0
)
>>> time.localtime()
time.struct_time(
    tm_year=2024, tm_mon=3, tm_mday=19,
    tm_hour=21, tm_min=16, tm_sec=6,
    tm_wday=1, tm_yday=79, tm_isdst=0
)
>>> time.time()
1710882970.789991 

这个例子与之前的例子非常相似,只是这次我们处理的是时间。我们可以看到如何根据 C 格式标准获取时间的打印表示,然后检查夏令时是否生效。gmtime 函数将给定的秒数从纪元转换为 UTC 中的 struct_time 对象。如果我们不提供数字,它将使用当前时间。

纪元是从计算机系统测量系统时间的日期和时间。你可以看到,在运行此代码的机器上,纪元是 1970 年 1 月 1 日。这是 Unix 和 POSIX 使用的点时间。协调世界时或UTC是世界调节时钟和时间的主要时间标准。

我们通过获取当前本地时间的 struct_time 对象以及从纪元以来表示为浮点数的秒数(time.time())来完成这个例子。

让我们现在通过使用 datetime 对象的例子来看看,这些对象结合了日期和时间。

>>> now = datetime.now()
>>> utcnow = datetime.now(UTC)
>>> now
datetime.datetime(2024, 3, 19, 21, 16, 56, 931429)
>>> utcnow
datetime.datetime(
    2024, 3, 19, 21, 17, 53, 241072,
    tzinfo=datetime.timezone.utc
)
>>> now.date()
datetime.date(2024, 3, 19)
>>> now.day, now.month, now.year
(19, 3, 2024)
>>> now.date() == date.today()
True
>>> now.time()
datetime.time(21, 16, 56, 931429)
>>> now.hour, now.minute, now.second, now.microsecond
(21, 16, 56, 931429)
>>> now.ctime()
'Tue Mar 19 21:16:56 2024'
>>> now.isoformat()
'2024-03-19T21:16:56.931429'
>>> now.timetuple()
time.struct_time(
    tm_year=2024, tm_mon=3, tm_mday=19,
    tm_hour=21, tm_min=16, tm_sec=56,
    tm_wday=1, tm_yday=79, tm_isdst=-1
)
>>> now.tzinfo
>>> utcnow.tzinfo
datetime.timezone.utc
>>> now.weekday()
1 

上述例子相当直观。我们首先设置两个实例来表示当前时间。一个是与 UTC 相关的(utcnow),另一个是本地表示(now)。

你可以通过与我们已经看到的方式类似的方式从 datetime 对象中获取 datetime 和特定属性。还值得注意的是,nowutcnow 对于 tzinfo 属性有不同的值。now 是一个天真的对象,而 utcnow 则不是。

日期和时间对象可以根据它们是否包含时区信息分为有意识的天真的

让我们现在看看在这个上下文中持续时间是如何表示的:

>>> f_bday = datetime(
    1975, 12, 29, 12, 50, tzinfo=ZoneInfo('Europe/Rome')
)
>>> h_bday = datetime(
    1981, 10, 7, 15, 30, 50, tzinfo=timezone(timedelta(hours=2))
)
>>> diff = h_bday - f_bday
>>> type(diff)
<class 'datetime.timedelta'>
>>> diff.days
2109
>>> diff.total_seconds()
182223650.0
>>> today + timedelta(days=49)
datetime.date(2024, 5, 7)
>>> now + timedelta(weeks=7)
datetime.datetime(2024, 5, 7, 21, 16, 56, 931429) 

已经创建了两个对象来表示 Fabrizio 和 Heinrich 的生日。这次,为了展示一个替代方案,我们创建了有意识的对象。

在创建 datetime 对象时,有几种方法可以包含时区信息,在这个例子中,我们展示了其中两种。一种使用 Python 3.9 中引入的 zoneinfo 模块中的 ZoneInfo 对象。第二种使用简单的 timedelta,这是一个表示持续时间的对象。

我们然后创建 diff 对象,它被分配为它们的差值。这个操作的结果是一个 timedelta 实例。你可以看到我们如何查询 diff 对象来告诉我们 Fabrizio 和 Heinrich 的生日相差多少天,甚至代表整个持续时间的秒数。注意,我们需要使用 total_seconds(),它以秒为单位表达整个持续时间。seconds 属性代表分配给该持续时间的秒数。所以,timedelta(days=1) 将有 0 秒和 total_seconds() 等于 86,400(这是一天中的秒数)。

将一个 datetime 与一个持续时间结合会将该持续时间加到或从原始日期和时间信息中减去。在示例的最后几行中,我们可以看到将一个持续时间加到一个 date 对象上会产生一个 date 作为结果,而将其加到 datetime 上会产生一个 datetime,正如我们预期的那样。

使用日期和时间执行的一些更具挑战性的任务之一是解析。让我们看一个简短的例子:

>>> datetime.fromisoformat('1977-11-24T19:30:13+01:00')
datetime.datetime(
    1977, 11, 24, 19, 30, 13,
    tzinfo=datetime.timezone(datetime.timedelta(seconds=3600))
)
>>> datetime.fromtimestamp(time.time())
datetime.datetime(2024, 3, 19, 21, 26, 56, 785166) 

我们可以轻松地从 ISO 格式字符串以及从时间戳中创建 datetime 对象。然而,通常从未知格式解析日期可能是一项困难的任务。

第三方库

为了完成这个子节,我们想提一下一些第三方库,你很可能在处理代码中的日期和时间时遇到它们:

这些是最常见的,它们值得探索。

让我们来看最后一个例子,这次使用 Arrow 第三方库:

>>> import arrow
>>> arrow.utcnow()
<Arrow [2024-03-19T21:29:15.076737+00:00]>
>>> arrow.now()
<Arrow [2024-03-19T21:29:26.354786+00:00]>
>>> local = arrow.now("Europe/Rome")
>>> local
<Arrow [2024-03-19T22:29:40.282730+01:00]>
>>> local.to("utc")
<Arrow [2024-03-19T21:29:40.282730+00:00]>
>>> local.to("Europe/Moscow")
<Arrow [2024-03-20T00:29:40.282730+03:00]>
>>> local.to("Asia/Tokyo")
<Arrow [2024-03-20T06:29:40.282730+09:00]>
>>> local.datetime
datetime.datetime(
    2024, 3, 19, 22, 29, 40, 282730,
    tzinfo=tzfile('/usr/share/zoneinfo/Europe/Rome')
)
>>> local.isoformat()
'2024-03-19T22:29:40.282730+01:00' 

Arrow 在标准库的数据结构周围提供了一个包装器,以及一套简化处理日期和时间的任务的方法和辅助工具。你可以从这个例子中看到,获取意大利时区(Europe/Rome)的本地日期和时间,以及将其转换为 UTC,或者转换为俄罗斯或日本时区是多么容易。最后两条指令展示了如何从一个 Arrow 对象中获取底层的 datetime 对象,以及日期和时间的非常有用的 ISO 格式表示。

集合模块

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

数据类型 描述
namedtuple() 创建具有命名字段的元组子类的工厂函数
deque 具有在两端快速追加和弹出操作类似列表的容器
ChainMap 用于创建多个映射的单个视图的字典类似类
Counter 用于计数可哈希对象的字典子类
OrderedDict 具有允许重新排序条目的方法的字典子类
defaultdict 调用工厂函数以提供缺失值的字典子类
UserDict 用于简化字典子类化的字典对象包装器
UserList 用于简化列表子类化的列表对象包装器
UserString 用于简化字符串子类化的字符串对象包装器

表 2.1:collections 模块数据类型

这里没有足够的空间涵盖所有内容,但您可以在官方文档中找到大量示例;在这里,我们只提供一个小的例子来展示namedtupledefaultdictChainMap

namedtuple

namedtuple是一个类似于元组的对象,它可以通过属性查找访问字段,同时又是可索引和可迭代的(它实际上是tuple的子类)。这是完全成熟的对象和元组之间的折衷方案,在那些不需要自定义对象的全功能但只想通过避免位置索引使代码更易读的用例中可能很有用。

另一个用例是,当元组中的项在重构后可能需要改变其位置时,这会迫使程序员也重构所有相关的逻辑,这可能很棘手。

例如,假设我们正在处理有关患者左右眼的数据。我们在这个常规元组中为左眼保存一个值(位置 0)和一个值用于右眼(位置 1)。以下是它可能的样子:

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

现在我们假设我们一直在处理vision对象,在某个时候,设计者决定通过添加关于综合视觉的信息来增强它们,因此vision对象以以下格式存储数据(左眼,综合,右眼)。

您现在看到我们遇到的问题了吗?我们可能有很多代码依赖于vision[0]是左眼信息(它仍然是)和vision[1]是右眼信息(这不再是情况)。我们必须在我们的代码中重构处理这些对象的地方,将vision[1]改为vision[2],这可能很痛苦。我们可能从一开始就通过使用namedtuple来更好地处理这个问题。让我们向您展示我们的意思:

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

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

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

你可以看到通过名称而不是位置来引用这些值是多么方便。毕竟,正如一位智者曾经写过的,“明确优于隐晦”。这个例子可能有点极端;当然,一个合格的程序员不太可能一开始就选择用简单的元组来表示数据,但你可能会惊讶地知道,在专业环境中,类似这个问题的情况有多么频繁,以及在这种情况下重构是多么复杂。

defaultdict

defaultdict 数据类型是我们最喜欢的之一。它允许你在第一次尝试访问时,通过为你插入键来避免检查键是否在字典中,其类型在创建时指定默认值。在某些情况下,这个工具可以非常方便,并稍微缩短你的代码。让我们看看一个快速示例。假设我们正在通过给 age 添加一年来更新它的值。如果 age 不存在,我们假设它是 0 并将其更新为 1

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

现在,让我们看看如何使用 defaultdict 数据类型进一步简化上述代码的第一部分:

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

注意,我们只需要指示 defaultdict 工厂,如果键缺失,我们希望使用 int 类型的数字(我们将得到 0,这是 int 类型的默认值)。此外,请注意,尽管在这个例子中行数没有增加,但可读性的提升确实是肯定的,这非常重要。你还可以使用自己的函数来自定义分配给缺失键的值。要了解更多信息,请参阅官方文档。

ChainMap

ChainMap 是一个在 Python 3.3 中引入的有用数据类型。它表现得像一个普通字典,但根据 Python 文档,它是“为了快速链接多个映射,以便它们可以作为一个单一单元来处理”而提供的。这通常比创建一个字典并在其上运行多个 update 调用要快得多。ChainMap 可以用来模拟嵌套作用域,在模板中很有用。底层映射存储在一个列表中。该列表是公开的,可以通过 maps 属性来访问或更新。查找会依次搜索底层映射,直到找到一个键。相比之下,写入、更新和删除仅对第一个映射进行操作。

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

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

这又是 Python 为我们简化事情的一个例子。你在一个 ChainMap 对象上工作,按照你的需求配置第一个映射,当你需要一个包含所有默认值以及自定义项的完整字典时,你只需将 ChainMap 对象传递给 dict 构造函数。如果你在其他语言中编写过代码,比如 Java 或 C++,你可能会欣赏这一点,以及 Python 如何简化一些任务。

枚举

活在 enum 模块中,并且绝对值得提及的是枚举。它们是在 Python 3.4 中引入的,我们认为给出一个关于它们的例子是完整的。

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

假设你需要表示交通信号灯;在你的代码中,你可能会使用以下方法:

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

这段代码没有什么特别之处。实际上,这是非常常见的一种情况。但是,考虑这样做:

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

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

最终考虑

就这些了。现在你已经看到了你将在 Python 中使用的绝大多数数据结构。我们鼓励你进一步实验本章中我们看到的所有数据类型。我们还建议你快速浏览官方文档,以了解当你用最常见的数据类型表示数据有困难时,你可以利用什么。这种实际知识可能非常有用。

在我们跳到第三章,条件语句和迭代 之前,我们想分享一些关于一些我们认为重要且不应被忽视的方面的最终考虑。

小值缓存

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

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

在前面的例子中,ab 被分配给两个具有相同值的 int 对象,但它们不是同一个对象——正如你所看到的,它们的 id 并不相同。让我们尝试一个更小的值:

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

哎呀!这,我们没想到!为什么这两个对象现在相同了?我们并没有做 a = b = 5;我们分别设置了它们。答案是某种称为对象内部化的东西。

对象池化 是一种内存优化技术,主要用于不可变数据类型,例如 Python 中的字符串和整数。其理念是重用现有对象,而不是每次需要具有相同值的对象时都创建新的对象。

这可以导致显著的内存节省和性能提升,因为它减少了垃圾收集器的负担,并且由于可以通过比较对象身份来执行比较,所以可以加快比较速度。

所有这些都在幕后得到妥善处理,所以你不需要担心,但对于那些直接处理 ID 的情况,了解这个特性是很重要的。

如何选择数据结构

正如我们所看到的,Python 为你提供了几个内置的数据类型,有时,如果你不是那么有经验,选择最适合你的数据类型可能会很棘手,尤其是在处理集合时。例如,假设你有很多字典要存储,每个字典代表一个客户。在客户字典中,有一个唯一的识别码,键为 "id"。你会在哪种集合中放置它们?好吧,除非我们了解更多关于这些客户的信息,否则可能很难给出答案。我们需要提问。我们需要什么样的访问?我们需要对每个项目执行什么样的操作?需要执行多少次?集合会随时间改变吗?我们需要以任何方式修改客户字典吗?我们将要执行的最频繁的操作是什么?

如果你能够回答这些问题,那么你就会知道该选择什么。如果集合永远不会缩小或增长(换句话说,在创建后不需要添加/删除任何客户对象或打乱顺序),那么元组是一个可能的选择。否则,列表是一个更好的选择。不过,每个客户字典都有一个唯一的标识符,所以即使字典也可以工作。让我们为你草拟这些选项:

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最多需要时间t ≤ c * f(n)来完成,其中c是某个正常数,n是输入的大小,f是某个函数。所以,将O(...)视为操作运行时间的上限(当然,它也可以用于其他可测量的数量)。

理解你是否选择了正确的数据结构的另一种方法是查看你为了操作它而编写的代码。如果你发现编写逻辑很容易且自然流畅,那么你可能选择了正确的,但如果你发现自己觉得代码变得过于复杂,那么你可能需要重新考虑你的选择。不过,没有实际案例很难给出建议,所以当你为你的数据选择数据结构时,尽量考虑易用性和性能,并优先考虑你所在情境中最重要的事情。

关于索引和切片

在本章的开头,我们看到了字符串的切片应用。一般来说,切片适用于序列:元组、列表、字符串等。对于列表,切片也可以用于赋值,尽管在实践中这种技术很少使用——至少在我们经验中是这样。字典和集合当然不能切片。让我们更深入地讨论一下索引。

关于 Python 索引的一个特点我们之前还没有提到。我们将通过一个例子来展示。你如何访问一个集合的最后一个元素?让我们看看:

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

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

因此,为了获取最后一个元素,我们需要知道整个列表(或元组、字符串等)的长度,然后减去 1,即len(a) - 1。这是一个非常常见的操作,Python 提供了一种使用负索引来检索元素的方法。这相当有用,因为它简化了代码。"图 2.2"展示了一个关于如何在字符串"HelloThere"(这是欧比旺·肯 obi 在《星球大战:III——西斯复仇》中对格里夫斯将军讽刺问候)上工作的清晰图解:

img

图 2.2:Python 索引

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

关于命名

你可能已经注意到,为了使示例尽可能简短,我们使用简单的字母命名了许多对象,例如 abcd 等等。这在控制台调试或展示 a + b == 7 这样的表达式时是完全可以接受的,但在编写专业代码(或任何类型的代码)时,这并不是一个好的做法。我们希望你在我们这样做的时候能宽容一些;这样做的原因是为了以更紧凑的方式展示代码。

然而,在实际环境中,当你为你的数据选择名称时,你应该仔细选择——它们应该反映数据的内容。所以,如果你有一组 Customer 对象,customers 是一个很好的名称。customers_listcustomers_tuplecustomers_collection 也能用吗?思考一下。将集合的名称与数据类型绑定是否合适?我们认为不一定,除非有充分的理由。背后的推理是,一旦 customers_tuple 在代码的不同部分开始被使用,你意识到你实际上想使用列表而不是元组,你将会有一个与错误数据类型绑定的名称,这意味着你将不得不重构。数据名称应该是名词,函数名称应该是动词。名称应该尽可能具有表达性。Python 在名称方面实际上是一个非常好的例子。大多数时候,如果你知道一个函数的功能,你就可以猜出它的名称。

《代码整洁之道》(Clean Code)一书的第二章完全致力于名称。这是一本伟大的书,它以许多不同的方式帮助我们改进了编码风格——如果你想要将技能提升到下一个层次,这是一本必读的书。

摘要

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

我们已经看到了数字类型、序列、集合、映射、日期、时间、集合和枚举。我们还了解到一切都是对象,并学习了可变和不可变之间的区别。我们还学习了切片和索引。

我们通过简单的示例展示了这些情况,但关于这个主题,你还有很多可以学习的内容,所以请深入官方文档去探索!

最重要的是,我们鼓励你自己尝试所有练习——让你的手指习惯那种代码,建立一些肌肉记忆,并实验,实验,再实验。学习当你除以零时会发生什么,当你组合不同的数字类型时会发生什么,以及当你处理字符串时会发生什么。与所有数据类型玩耍。练习它们,破坏它们,发现它们的所有方法,享受它们,并且非常非常熟练地学习它们。如果你的基础不是坚如磐石,你的代码能有多好?数据是一切的基础;数据塑造了围绕它的东西。

随着你对这本书的进度越来越深入,你可能会发现我们(或你自己的)代码中存在一些差异或小错误。你可能会收到错误信息,或者某些东西会出问题。那真是太好了!当你编写代码时,事情会出错,你必须调试它们,这是常有的事,所以请把错误视为学习新语言的有用练习,而不是失败或问题。错误会不断出现,这是肯定的,所以你现在就开始学会与它们和平共处吧。

下一章将介绍条件和迭代。我们将看到如何实际使用集合并根据我们呈现的数据做出决策。现在你的知识正在积累,我们将开始稍微加快速度,所以在继续下一章之前,请确保你对本章的内容感到舒适。再次提醒,享受乐趣,探索,并打破事物——这是学习的好方法。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第三章:条件和迭代

“你能告诉我,请,我应该从这里走哪条路吗?”

“这很大程度上取决于你想要去哪里。”

——刘易斯·卡罗尔,《爱丽丝梦游仙境》

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

根据维基百科:

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

控制程序流程的两种主要方式是条件编程(也称为分支)和循环。这些技术可以组合起来产生无数种程序。我们不会尝试记录所有组合循环和分支的方法,而是会给你一个概述 Python 中可用的流程控制结构。然后,我们将带你通过几个示例程序。这样,你应该能更好地理解条件编程和循环是如何被使用的。

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

  • 条件编程

  • Python 中的循环

  • 赋值表达式

  • 快速浏览 itertools 模块

条件编程

条件编程,或分支,是你每天每时每刻都在做的事情。本质上,它包括评估条件和决定采取什么行动:如果绿灯亮了,那么我可以过马路如果下雨了,那么我会带伞如果我上班迟到了,那么我会给经理打电话

if 语句

Python 中条件编程的主要工具是 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() 语句。我们可以通过添加 else 子句来扩展基本的 if 语句。这提供了一组替代指令,在 if 子句中的表达式评估为 False 时执行。

# 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: elif

到目前为止,当你只有一个条件要评估,并且最多只有两条替代路径(ifelse子句)时,我们所看到的是足够的。然而,有时你可能需要评估多个条件以从多个路径中选择。为了演示这一点,我们需要一个有更多选项可供选择的例子。

这次,我们将创建一个简单的税务计算器。假设税收是这样确定的:如果你的收入低于$10,000,你不需要支付任何税款。如果它在$10,000 和$30,000 之间,你必须支付 20%的税款。如果它在$30,000 和$100,000 之间,你支付 35%的税款,如果你有幸赚得超过$100,000,你必须支付 45%的税款。让我们把这个翻译成 Python 代码:

# taxes.py
income = 15000
if income < 10000:
    tax_coefficient = 0.0  # 1
elif income < 30000:
    tax_coefficient = 0.2  # 2
elif income < 100000:
    tax_coefficient = 0.35  # 3
else:
    tax_coefficient = 0.45  # 4
print(f"You will pay: ${income * tax_coefficient} in taxes") 

当我们执行这段代码时,会得到以下输出:

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

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

控制权传递到下一个条件:elif income < 30000。这个条件评估为True;因此,代码块# 2被执行,因此 Python 随后在完整的if / elif / elif / else结构(从现在起我们可以简单地称之为if语句)之后继续执行。if语句之后只有一个指令:print()调用,它产生输出告诉我们今年我们将支付$3000.0 的税款(15,000 * 20%)。注意,顺序是强制性的:if首先,然后(可选地)尽可能多的elif子句,最后(可选地)一个单独的else子句。

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

尝试修改 income 的值,直到你可以随意执行任何代码块。还要测试在 ifelif 子句中布尔表达式的值发生变化的 边界 上的行为。彻底测试边界对于确保代码的正确性至关重要。我们应该允许你在 18 岁或 17 岁时驾驶吗?我们是用 age < 18 还是 age <= 18 来检查你的年龄?你无法想象我们有多少次不得不修复由使用错误的运算符引起的微妙错误,所以请继续实验代码。将一些 < 改为 <=,并将 income 设置为边界值之一(10,000、30,000 或 100,000),以及任何介于这些值之间的值。看看结果如何变化,并在继续之前对其有一个良好的理解。

嵌套 if 语句

你也可以嵌套 if 语句。让我们看看另一个例子来展示如何做到这一点。比如说,如果你的程序遇到了错误。如果警报系统是控制台,我们就打印错误信息。

如果警报系统是电子邮件,错误的严重性决定了我们应该将警报发送到哪个地址。如果警报系统不是控制台或电子邮件,我们不知道该怎么做,所以我们什么也不做。让我们把这个写成代码:

# errorsalert.py
alert_system = "console"  # other value can be "email"
error_severity = "critical"  # other values: "medium" or "low"
error_message = "Something terrible happened!"
if alert_system == "console":  # outer
    print(error_message)  # 1
elif alert_system == "email":
    if error_severity == "critical":  # inner
        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 语句的 elif 子句体中的 内部 if 语句。注意,嵌套是通过缩进内部 if 语句来实现的。

让我们逐步执行代码,看看会发生什么。我们首先为 alert_systemerror_severityerror_message 赋值。当我们进入外部 if 语句时,如果 alert_system == "console" 评估为 True,则执行代码块 # 1,然后不再发生其他事情。另一方面,如果 alert_system == "email" 评估为 True,那么我们就进入内部 if 语句。在内部 if 语句中,error_severity 决定了我们应该向管理员、一级支持还是二级支持发送电子邮件(代码块 # 2# 3# 4)。在这个例子中,send_email() 函数没有定义,所以尝试运行它会给你一个错误。在本书源代码中可以找到的 errorsalert.py 模块中,我们包括了一个技巧来将那个调用重定向到一个普通的 print() 函数,这样你就可以在控制台上进行实验,而实际上并不发送电子邮件。尝试更改值,看看它如何工作。

三元运算符

我们接下来想展示的是 三元运算符。在 Python 中,这也被称为 条件表达式。它看起来和表现就像是一个简短的、内联的 if 语句。当你只想根据某个条件在两个值之间进行选择时,使用三元运算符有时比使用完整的 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.py
# ternary operator
discount = 25 if order_total > 100 else 0
print(order_total, discount) 

对于这种简单的情况,我们觉得能够用一行而不是四行来表示这种逻辑非常方便。记住,作为一个程序员,你花在阅读代码上的时间要比编写代码的时间多得多,所以 Python 的简洁性是无价的。

在某些语言(如 C 或 JavaScript)中,三元运算符甚至更加简洁。例如,上面的代码可以写成:

discount = order_total > 100 ? 25 : 0; 

虽然 Python 的版本稍微有点冗长,但我们认为它通过更容易阅读和理解来弥补了这一点。

你清楚三元运算符的工作原理吗?它相当简单;something if condition else something-else在条件condition评估为True时评估为something。否则,如果conditionFalse,表达式评估为something-else

模式匹配

结构化模式匹配,通常简称为模式匹配,是一个相对较新的特性,它是在 Python 3.10 版本中通过 PEP 634(peps.python.org/pep-0634)引入的。它部分受到了像 Haskel、Erlang、Scala、Elixir 和 Ruby 等语言的模式匹配能力的影响。

简而言之,match语句将一个值与一个或多个模式进行比较,然后执行与第一个匹配的模式关联的代码块。让我们看一个简单的例子:

# match.py
day_number = 4
**match** day_number:
    **case** 1 | 2 | 3 | 4 | 5:
        print("Weekday")
    **case** 6:
        print("Saturday")
    **case** 7:
        print("Sunday")
    **case** _:
        print(f"{day_number} is not a valid day number") 

我们在进入match语句之前初始化day_numbermatch语句将尝试将day_number的值与一系列模式匹配,每个模式都由case关键字引入。在我们的例子中,我们有四个模式。第一个1 | 2 | 3 | 4 | 5将匹配任何值12345。这被称为或模式;它由多个通过|分隔的子模式组成。当任何子模式(在本例中,是字面值12345)匹配时,它就会匹配。我们例子中的第二个和第三个模式分别只包含整数字面量67。最后一个模式_是一个通配符模式;它是一个通用的匹配任何值的模式。一个match语句最多只能有一个通配符模式,如果存在,它必须是最后一个模式。

将会执行第一个与模式匹配的 case 块的主体。之后,执行将继续在match语句下方进行,而不会评估任何剩余的模式。如果没有任何模式匹配,执行将继续在match语句下方进行,而不会执行任何 case 主体。在我们的例子中,第一个模式匹配,所以print("Weekday")被执行。花点时间实验这个例子。尝试改变day_number的值,看看运行时会发生什么。

match 语句类似于 C++ 和 JavaScript 等语言中的 switch / case 语句。然而,它比这更强大。可用的不同类型的模式种类繁多,以及组合模式的能力,让你能够做比简单的 C++ switch 语句更多的事情。例如,Python 允许你匹配序列、字典,甚至自定义类。你还可以在模式中捕获并将值分配给名称。我们在这里没有足够的空间涵盖你可以用模式匹配做的一切,但我们鼓励你学习 PEP 636 中的教程(peps.python.org/pep-0636)以了解更多信息。

现在你已经了解了关于控制代码路径的一切,让我们继续下一个主题:循环

循环

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

虽然技术上可以使用任何一个来完成需要循环的任务,但它们确实有不同的用途。我们将在本章中彻底探讨这种差异。到本章结束时,你将知道何时使用 for 循环,何时使用 while 循环。

for 循环

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

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

这段简单的代码,当执行时,会打印出从 04 的所有数字。for 循环的主体(print() 行)对于列表 [0, 1, 2, 3, 4] 中的每个值都会执行一次。在第一次迭代中,number 被分配给序列中的第一个值;在第二次迭代中,number 取第二个值;依此类推。在序列的最后一个项目之后,循环结束,执行恢复正常,继续执行循环之后的代码。

遍历范围

我们经常需要遍历一系列数字,如果必须通过硬编码列表来这样做,将会非常繁琐。在这种情况下,range() 函数就派上用场了。让我们看看之前代码片段的等效代码:

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

range() 函数在 Python 程序中广泛用于创建序列。你可以用单个值调用它,该值作为 stop(计数将从 0 开始)。你也可以传递两个值(startstop),甚至三个(startstopstep)。查看以下示例:

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

目前,忽略我们需要将 range(...) 包裹在一个列表中。我们将在 第五章 列表推导式和生成器 中解释这样做的原因。你可以看到,其行为类似于切片(我们在上一章中描述过):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) 是姓氏列表的长度: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 中,很少看到这样的代码。你可以直接遍历任何序列或集合,因此没有必要在每次迭代中获取位置列表并从序列中检索元素。让我们将示例改为更 Pythonic 的形式:

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

for 循环可以遍历 surnames 列表,并在每次迭代中按顺序返回每个元素。运行此代码将逐个打印出三个姓氏,这使阅读更加容易。

然而,如果你想要打印位置呢?或者如果你需要它呢?你应该回到 range(len(...)) 的形式吗?不。你可以使用内置的 enumerate() 函数,如下所示:

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

这段代码也非常有趣。注意,enumerate() 在每次迭代中返回一个包含 (position, surname) 的二元组,但它仍然比 range(len(...)) 例子更易读(并且更高效)。你可以使用 start 参数调用 enumerate(),例如 enumerate(iterable, start),它将从 start 开始,而不是 0。这只是另一个小细节,展示了在设计 Python 时投入了多少思考,以便让生活更轻松。

你可以使用for循环遍历列表、元组,以及在 Python 中称为可迭代对象的任何东西。这是一个重要的概念,所以让我们更详细地讨论它。

迭代器和可迭代对象

根据 Python 文档(docs.python.org/3.12/glossary.html#term-iterable),可迭代对象是:

能够一次返回其成员的一个对象。可迭代对象包括所有序列类型(如列表、str 和 tuple)以及一些非序列类型,如 dict 和文件对象。

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

一些数据结构在迭代时按顺序产生它们的元素,例如列表、元组、字典和字符串,而其他数据结构,如集合,则不按顺序。Python 通过一种称为迭代器的对象类型为我们提供了遍历可迭代对象的能力,迭代器是一个表示数据流的对象。

在实践中,整个可迭代/迭代器机制都隐藏在代码背后。除非你需要出于某种原因编写自己的可迭代或迭代器,否则你不必过多担心这一点。然而,了解 Python 如何处理这个关键的控制流方面非常重要,因为它决定了我们编写代码的方式。

我们将在第五章理解与生成器和第六章面向对象编程、装饰器和迭代器中更详细地介绍迭代。

遍历多个序列

让我们看看另一个如何遍历相同长度的两个序列并成对处理它们各自的元素的例子。假设我们有一个包含人名的列表和一个表示他们年龄的数字列表。我们想要为每个人打印一行包含人名/年龄的配对。让我们从一个例子开始,我们将逐步对其进行改进:

# multiple.sequences.py
people = ["Nick", "Rick", "Roger", "Syd"]
ages = [23, 24, 23, 21]
for position in range(len(people)):
    person = people[position]
    age = ages[position]
    print(person, age) 

到现在为止,这段代码应该是直截了当的。我们遍历位置列表(0123),因为我们想要从两个不同的列表中检索元素。执行它,我们得到以下结果:

$ python multiple.sequences.py
Nick 23
Rick 24
Roger 23
Syd 21 

代码是可行的,但并不非常符合 Python 风格。必须获取people的长度、构造一个range,然后遍历它,这显得有些繁琐。对于某些数据结构,按位置检索项目可能也很昂贵。如果能直接遍历序列,就像处理单个序列一样,那就更好了。让我们尝试使用enumerate()来改进它:

# multiple.sequences.enumerate.py
people = ["Nick", "Rick", "Roger", "Syd"]
ages = [23, 24, 23, 21]
for position, person in enumerate(people):
    age = ages[position]
    print(person, age) 

这样更好,但仍然不完美。我们正确地迭代了people,但我们仍然使用位置索引获取age,这是我们想要丢弃的。我们可以通过使用我们在上一章中遇到的zip()函数来实现这一点。让我们使用它:

# multiple.sequences.zip.py
people = ["Nick", "Rick", "Roger", "Syd"]
ages = [23, 24, 23, 21]
for person, age in zip(people, ages):
    print(person, age) 

这比原始版本要优雅得多。当for循环请求zip(sequenceA, sequenceB)的下一个元素时,它得到一个元组,该元组被解包到personage中。元组将包含与提供给zip()函数的序列数量一样多的元素。让我们在先前的例子上稍作扩展:

# multiple.sequences.unpack.py
people = ["Nick", "Rick", "Roger", "Syd"]
ages = [23, 24, 23, 21]
instruments = ["Drums", "Keyboards", "Bass", "Guitar"]
for person, age, instrument in zip(people, ages, instruments):
    print(person, age, instrument) 

在前面的代码中,我们添加了instruments列表。现在,我们向zip()函数提供了三个序列,每次迭代for循环都会返回一个三元组。元组的元素被解包并分配给personageinstrument。请注意,元组中元素的顺序与zip()调用中序列的顺序一致。执行代码将产生以下结果:

$ python multiple.sequences.unpack.py
Nick 23 Drums
Rick 24 Keyboards
Roger 23 Bass
Syd 21 Guitar 

注意,在遍历多个序列时,不需要解包元组。你可能在for循环体内部将元组作为一个整体进行操作。当然,这样做是完全可能的:

# multiple.sequences.tuple.py
people = ["Nick", "Rick", "Roger", "Syd"]
ages = [23, 24, 23, 21]
instruments = ["Drums", "Keyboards", "Bass", "Guitar"]
for data in zip(people, ages, instruments):
    print(data) 

这几乎与先前的例子相同。不同之处在于,我们不是解包从zip(...)得到的元组,而是将整个元组赋值给data

while循环

在前面的页面中,我们看到了for循环的实际应用。当你需要遍历一个序列或集合时,它很有用。当你需要决定使用哪种循环结构时,要记住的关键点是for循环最适合在必须遍历容器对象或其他可迭代对象的元素的情况下使用。

然而,还有其他情况,你可能只需要循环直到满足某个条件,或者无限循环直到应用程序停止。在这种情况下,我们没有可以迭代的,所以for循环可能不是一个好的选择。对于这种情况,while循环更为合适。

while循环与for循环类似,因为两者都会重复执行一系列指令。不同之处在于while循环不是遍历一个序列。相反,只要满足某个条件,它就会循环。当条件不再满足时,循环结束。

如同往常,让我们看一个例子,以帮助我们更清晰地理解。我们想要打印一个正数的二进制表示。为此,我们可以使用一个简单的算法,通过不断除以二直到为零,并收集余数。当我们反转收集到的余数列表时,我们得到我们开始时的数字的二进制表示。例如,如果我们想要十进制数 6 的二进制表示,步骤如下:

  1. 6 / 2 = 30

  2. 3 / 2 = 11

  3. 1 / 2 = 01

  4. 余数列表是0, 1, 1

  5. 反转这个结果,我们得到1, 1, 0,这也是6的二进制表示110

让我们将这个例子翻译成 Python 代码。我们将计算数字 39 的二进制表示,它是 100111:

# 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
remainders.reverse()
print(remainders) 

在前面的代码中,我们突出了n > 0,这是保持循环的条件。注意代码如何与我们所描述的算法相匹配:只要n大于0,我们就除以2并将余数添加到列表中。在最后(当n达到0时),我们反转余数列表以获取n原始值的二进制表示。

我们可以使用divmod()函数使代码更简洁(并且更符合 Python 风格)。divmod()函数接受一个数和一个除数,并返回一个元组,包含整数除法的结果及其余数。例如,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)
remainders.reverse()
print(remainders) 

现在,我们将n重新赋值为除以2的结果,并将余数添加到余数列表中,这一行就完成了。

内置函数bin()返回一个数的二进制表示。所以,除了示例或作为练习,你不需要在 Python 中自己实现它。

注意,while循环中的条件是继续循环的条件。如果它评估为True,则执行主体,然后进行另一个评估,依此类推,直到条件评估为False。当这种情况发生时,循环会立即停止,而不会执行其主体。如果条件永远不会评估为False,则循环会变成所谓的无限循环。无限循环在从网络设备轮询时使用,例如:你询问套接字是否有数据,如果有,你将对其进行一些操作,然后你等待一小段时间,然后再次询问套接字,如此反复,永远不会停止。

为了更好地说明for循环和while循环之间的区别,让我们使用while循环修改之前的例子(multiple.sequences.py):

# multiple.sequences.while.py
people = ["Nick", "Rick", "Roger", "Syd"]
ages = [23, 24, 23, 21]
**position =** **0**
while **position <** **len****(people)**:
    person = people[position]
    age = ages[position]
    print(person, age)
    **position +=** **1** 

在前面的代码中,我们突出了position变量的初始化条件更新,这使得通过手动处理迭代来模拟等效的for循环代码成为可能。任何可以用for循环完成的事情也可以用while循环完成,尽管你可以看到为了达到相同的结果,你需要经过一些样板代码。反之亦然,但除非你有这样做的原因,否则你应该使用适合的工具来完成工作。

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

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

break 和 continue 语句

有时候你需要改变循环的正常流程。你可以跳过单个迭代(你想跳过多少次就跳过多少次),或者你可以完全跳出循环。跳过迭代的常见用例是,例如,当你正在遍历一个项目列表,但你只需要处理满足某些条件的那些项目。另一方面,如果你正在遍历一个集合以搜索满足某些要求的项,你可能想在找到你想要的东西时立即跳出循环。有无数可能的情况;让我们一起分析几个例子,以展示这在实践中是如何工作的。

假设你想要对今天到期的所有产品应用 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:
    print("Processing sku", product["sku"])
    if product["expiration_date"] != today:
        **continue**
    product["price"] *= 0.8  # equivalent to applying 20% discount
    print("Sku", product["sku"], "price is now", product["price"]) 

我们首先导入datetimedelta对象,然后设置我们的产品。那些sku13的产品有一个到期日期为today,这意味着我们想要对它们应用 20%的折扣。我们遍历每个product并检查到期日期。如果到期日期不匹配today,我们不想执行体中的其余部分,所以我们执行continue语句。循环体的执行停止,继续到下一个迭代。如果我们运行discount.py模块,这是输出:

$ python discount.py
Processing sku 1
Sku 1 price is now 80.0
Processing sku 2
Processing sku 3
Sku 3 price 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 a "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") 

上述代码使用了常见的编程模式;你在开始检查项目之前设置一个标志变量。如果你找到一个符合你标准的元素(在这个例子中,评估为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类似,因为它立即停止执行循环体,但它还阻止了进一步的迭代运行,实际上是从循环中跳出的。

没有必要编写代码来检测序列中是否至少有一个元素评估为True,因为内置函数any()正是做这个。

你可以在循环体(forwhile)的任何地方使用你需要的任意多个continuebreak语句,你甚至可以在同一个循环中使用两者。

特殊的 else 子句

我们在 Python 语言中看到的一个独特功能是在循环之后有一个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.") 

再次注意标志模式。我们将driver设置为None,然后如果我们找到一个人,我们更新driver标志。在循环结束时,我们检查它以查看是否找到了一个人。注意,如果没有找到驾驶员,将抛出DriverException,向程序发出信号,表示无法继续执行(我们缺少驾驶员)。

现在,让我们看看如何在for循环中使用else子句来完成这个操作:

# 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.") 

注意,我们不再需要标志模式。异常作为循环逻辑的一部分被抛出,这很有意义,因为循环会检查某些条件。我们唯一需要做的是设置一个driver对象,以防我们找到它;这样,其余的代码就可以使用driver对象进行进一步处理。注意,代码变得更短、更优雅,因为逻辑现在被正确地组合在一起,放在了合适的位置。

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

赋值表达式

在我们查看一些更复杂的例子之前,我们想简要介绍一下 Python 3.8 中添加的一个功能,该功能通过 PEP 572(peps.python.org/pep-0572)实现。赋值表达式允许我们在不允许正常赋值语句的地方将值绑定到名称上。而不是正常的赋值运算符=,赋值表达式使用:=(被称为海象运算符,因为它与海象的眼睛和獠牙相似)。

语句和表达式

要理解正常赋值和赋值表达式之间的区别,我们需要理解语句和表达式之间的区别。根据 Python 文档(docs.python.org/3.12/glossary.html#term-statement),一个 语句 是:

…是代码块(一个“代码块”)的一部分。一个语句要么是一个表达式,要么是具有关键字的一些构造之一,例如 ifwhilefor

另一方面,一个 表达式 是:

一个可以评估为某个值的语法。换句话说,一个表达式是像字面量、名字、属性访问、运算符或函数调用这样的表达式元素的累积,所有这些都会返回一个值。

表达式的关键区分特征是它有一个值。注意,一个表达式可以是一个语句,但并不是所有的语句都是表达式。特别是,像 name = "heinrich" 这样的赋值不是表达式,因此它们没有值。这意味着你不能在 while 循环或 if 语句的条件表达式中(或任何需要值的地方)使用赋值语句。

这就是为什么当你在 Python 控制台中给一个名字赋值时,它不会打印值的原因。例如:

>>> name = "heinrich"
>>> 

是一个语句,它没有返回值以打印。

使用 walrus 运算符

如果你想将一个值绑定到一个名字并使用该值在一个表达式中,没有赋值表达式,你就必须使用两个独立的语句。例如,我们经常看到这样的代码:

# walrus.if.py
remainder = value % modulus
if remainder:
    print(f"Not divisible! The remainder is {remainder}.") 

使用赋值表达式,我们可以将这段代码重写为:

# walrus.if.py
if remainder := value % modulus:
    print(f"Not divisible! The remainder is {remainder}.") 

赋值表达式允许我们编写更少的代码行。谨慎使用,它们还可以使代码更简洁、更易于理解。让我们看一个稍微大一点的例子,看看赋值表达式如何简化 while 循环。

在交互式脚本中,我们经常需要让用户在多个选项之间进行选择。例如,假设我们正在编写一个交互式脚本,允许冰淇淋店的顾客选择他们想要的口味。为了避免在准备订单时产生混淆,我们希望确保用户选择了一个可用的口味。如果没有赋值表达式,我们可能会写出类似这样的代码:

# menu.no.walrus.py
flavors = ["pistachio", "malaga", "vanilla", "chocolate"]
prompt = "Choose your flavor: "
print(flavors)
while **True**:
    choice = input(prompt)
    if **choice** **in** **flavors**:
        **break**
    print(f"Sorry, '{choice}' is not a valid option.")
print(f"You chose '{choice}'.") 

请花一点时间仔细阅读这段代码。注意循环的条件:while True 表示“无限循环”,这并不是我们想要的。我们希望在用户输入一个有效的口味(choice in flavors)时停止循环。为了实现这一点,我们在循环中有一个 if 语句和一个 break。控制循环的逻辑并不立即明显。尽管如此,当需要控制循环的值只能在循环内部获得时,这实际上是一种相当常见的模式。

input() 函数在交互式脚本中非常有用。它提示用户输入并返回一个字符串。

我们如何改进这一点?让我们尝试使用赋值表达式:

# menu.walrus.py
flavors = ["pistachio", "malaga", "vanilla", "chocolate"]
prompt = "Choose your flavor: "
print(flavors)
while **(choice :=** **input****(prompt))** not in flavors:
    print(f"Sorry, '{choice}' is not a valid option.")
print(f"You chose '{choice}'.") 

现在,循环条件正好是我们想要的。这要容易理解得多。代码也短了三行。

在这个例子中,我们需要在赋值表达式周围加上括号,因为 := 运算符的优先级低于 not in 运算符。试着去掉它们,看看会发生什么。

我们已经看到了在ifwhile语句中使用赋值表达式的例子。除了这些用例之外,赋值表达式在lambda 表达式(你将在第四章函数,代码的构建块中遇到)以及推导式生成器(你将在第五章推导式和生成器中学习)中也非常有用。

一个警告

Python 中引入 walrus 运算符有些有争议。有些人担心这会让编写丑陋的非 Pythonic 代码变得过于容易。我们认为这些担忧并不完全合理。正如你上面看到的,walrus 运算符可以改进代码并使其更容易阅读。然而,像任何强大的功能一样,它也可能被滥用来编写晦涩难懂的代码。我们建议你谨慎使用。始终仔细思考它对你的代码可读性的影响。

把所有这些放在一起

现在我们已经涵盖了条件语句和循环的基础,我们可以继续到本章开头承诺的示例程序。我们将混合使用,这样你就可以看到如何将这些概念一起使用。

一个素数生成器

让我们先写一些代码来生成一个包含素数的列表,直到(包括)某个限制。请记住,我们将编写一个非常低效和原始的算法来寻找素数。重要的是要专注于代码中属于本章主题的部分。

根据 Wolfram MathWorld:

素数(或素整数,通常简称为“素数”)是一个大于 1 的正整数 p,它除了 1 和它本身之外没有其他正整数除数。更简洁地说,素数 p 是一个只有一个正除数(除了 1)的正整数,这意味着它是一个不能分解的数。

根据这个定义,如果我们考虑前 10 个自然数,我们可以看到 2、3、5 和 7 是素数,而 1、4、6、8、9 和 10 不是。要确定一个数,N,是否为素数,你可以将它除以范围[2, N)内的每一个自然数。如果任何除法的余数为零,则该数不是素数。

要生成素数的序列,我们将考虑从 2 开始的自然数,直到限制,并测试它是否是素数。我们将编写两个版本,第二个版本将利用for...else语法:

# primes.py
primes = []  # this will contain the primes at 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,因为我们希望它是包含的,所以我们必须在最外层for循环中遍历range(2, upto + 1)(记住range(2, upto)将停止在upto - 1)。最外层循环遍历候选素数——即从2upto的所有自然数。这个循环的每次迭代都会测试一个数字,以确定它是否是素数。在最外层循环的每次迭代中,我们设置一个标志(每次迭代都设置为True),然后开始将当前值n除以从2n - 1的所有数字。如果我们找到n的一个合适的除数,这意味着n是合数,因此我们将标志设置为False并退出循环。请注意,当我们退出内层循环时,外层循环会像往常一样继续进行。我们在找到n的合适除数后退出是因为我们不需要任何进一步的信息就能判断出n不是素数。

在内层循环之后检查is_prime标志,如果它仍然是True,这意味着我们在[2, n)范围内没有找到任何是n的合适除数的数字;因此,n是素数。我们将n添加到primes列表中,并继续下一次迭代,直到n等于100

运行此代码会输出:

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

在继续之前,我们将提出以下问题:外层循环的某次迭代与其他迭代不同。你能指出这是哪一次迭代——为什么?花一点时间思考一下,回到代码中,尝试自己解决它,然后再继续阅读。

你找到答案了吗?如果你没有找到,请不要感到难过,仅通过观察代码就能理解其功能的技能需要时间和经验来学习。尽管如此,这对于程序员来说是一个重要的技能,所以尽量在可能的情况下练习它。现在我们将告诉你答案:第一次迭代与其他所有迭代的行为不同。原因是第一次迭代中,n2。因此,最内层的for循环根本不会运行,因为它是一个遍历range(2, 2)for循环,这是一个空的范围。自己试一试,写一个简单的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) 

在内层循环中使用else子句,我们可以去除is_prime标志。相反,当我们知道内层循环没有遇到任何break语句时,我们将n添加到primes列表中。这仅仅减少了两个代码行,但代码更简单、更干净,也更易于阅读。这在编程中非常重要,因为简洁性和可读性非常重要。始终寻找简化代码并使其更容易阅读的方法。当你几个月后再次回到它时,你将感谢自己当时所做的努力,以便试图理解你之前做了什么。

应用折扣

在这个例子中,我们想向您展示一种称为查找表的技术,我们非常喜欢。我们将从简单地编写一些代码开始,根据客户的优惠券价值为它们分配折扣。我们将尽量简化逻辑——记住,我们真正关心的是理解条件语句和循环:

# 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:
    match customer["coupon_code"]:
        case "F20":
            customer["discount"] = 20.0
        case "F15":
            customer["discount"] = 15.0
        case "P30":
            customer["discount"] = customer["total"] * 0.3
        case "P50":
            customer["discount"] = customer["total"] * 0.5
        case _:
            customer["discount"] = 0.0
for customer in customers:
    print(customer["id"], customer["total"], customer["discount"]) 

我们首先设置一些客户。他们有一个订单总额、一个优惠券代码和一个 ID。我们编造了四种类型的优惠券:两种是固定金额的,两种是基于百分比的。我们使用一个match语句,每个优惠券代码都有一个case,以及一个通配符来处理无效的优惠券。我们计算折扣,并将其设置为customer字典中的"discount"键。

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

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

这段代码易于理解,但所有这些match情况都在逻辑上造成了混乱。添加更多优惠券代码需要添加额外的案例,并为每个案例实现折扣计算。在大多数情况下,折扣计算非常相似,这使得代码重复,违反了不要重复自己DRY)原则。在这种情况下,你可以利用字典的优势,如下所示:

# 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)来确保我们可以处理不在字典中的代码,并提供一个默认值。

除了可读性之外,这种方法的另一个主要优点是,我们可以轻松地添加新的优惠券代码(或删除旧的代码),而无需更改实现;我们只需要更改查找表中的*数据*。在实际应用中,我们甚至可以将查找表存储在数据库中,并为用户提供一个界面,以便在运行时添加或删除优惠券代码。

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

这种技术与 调度表 非常相关,调度表将函数作为表中的值存储。这提供了更大的灵活性。一些面向对象的编程语言在内部使用这种技术来实现诸如虚拟方法等特性。

如果你仍然不清楚这是如何工作的,我们建议你花些时间亲自实验。更改值并添加 print() 语句,以查看程序运行时的具体情况。

快速浏览 itertools 模块

一章关于可迭代对象、迭代器、条件逻辑和循环的内容,如果没有几句话关于 itertools 模块,就不会完整。根据 Python 官方文档(docs.python.org/3.12/library/itertools.html),itertools 模块:

…实现了许多由 APL、Haskell 和 SML 构造启发的迭代器构建块。每个构建块都已被重新塑形,以适应 Python。

该模块标准化了一组快速、内存高效的工具,这些工具本身或组合使用都很有用。它们共同构成了一种“迭代器代数”,使得在纯 Python 中简洁且高效地构建专用工具成为可能。

我们没有足够的空间向您展示这个模块所能提供的一切,所以我们鼓励您自己进一步探索。然而,我们可以保证您会喜欢它。它为您提供了三种广泛的迭代器类别。作为介绍,我们将给出每个类别中一个迭代器的小示例。

无限迭代器

无限迭代器允许你使用 for 循环作为无限循环,遍历一个永远不会结束的序列:

# 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() 的例子来说明。这个迭代器接受一个 data 序列和一个 selectors 序列,只产生与 selectors 序列中的 True 值相对应的数据序列中的值。例如,compress("ABC", (1, 0, 1)) 会返回 "A""C",因为它们对应于 1。让我们看看一个简单的例子:

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

注意到 odd_selectoreven_selector 的长度都是 20 个元素,而 data 只有 10 个。compress() 函数会在 data 产生最后一个元素时停止。运行此代码会产生以下结果:

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

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

组合生成器

itertools 的第三类迭代器是组合生成器。让我们看看排列的一个简单例子。根据 Wolfram MathWorld:

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

例如,ABC 有六种排列:ABCACBBACBCACABCBA

如果一个集合有 N 个元素,那么这些元素的排列数是 N!N 的阶乘)。例如,字符串 ABC3! = 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')] 

在玩排列时请小心。它们的数量以与您正在排列的元素数量的阶乘成比例的速度增长,而且这个数字可以变得非常大,非常快。

有一个名为 more-itertools 的第三方库扩展了 itertools 模块。您可以在 more-itertools.readthedocs.io/ 找到它的文档。

摘要

在本章中,我们朝着扩展我们的 Python 词汇表又迈出了另一步。我们看到了如何通过评估条件来驱动代码的执行,以及如何循环遍历序列和对象集合。这赋予了我们控制代码运行时发生什么的能力,这意味着我们得到了如何塑造它以实现我们想要的功能,并使其能够对动态变化的数据做出反应的想法。

我们也看到了如何在几个简单的例子中将所有内容结合起来,最后,我们简要地浏览了 itertools 模块,它充满了有趣的迭代器,可以让我们用 Python 的能力得到更大的丰富。

现在,是时候转换方向,再迈出一步,来谈谈函数。下一章全部都是关于它们的,它们非常重要。确保你对到目前为止的内容感到舒适。我们想给你提供一些有趣的例子,让我们开始吧。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

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

“创造架构就是整理。整理什么?函数和对象。”

—勒·柯布西耶

在前面的章节中,我们已经看到在 Python 中一切都是对象,函数也不例外。但函数究竟是什么呢?函数是一个可重复使用的代码块,旨在执行特定的任务或一组相关的任务。这个单元可以随后导入并在需要的地方使用。使用函数在代码中有许多优点,我们将在下面看到。

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

  • 函数——它们是什么以及为什么我们应该使用它们

  • 作用域和名称解析

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

  • 递归和匿名函数

  • 导入对象以实现代码重用

我们相信“一图胜千言”这句话在向对这一概念新手解释函数时尤其正确,所以请看一下以下图例:

img

图 4.1:函数的一个示例

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

Python 中的函数使用def关键字定义,之后跟函数名,以一对括号(可能包含输入参数)结束;然后是一个冒号(:),表示函数定义的结束。紧接着,缩进四个空格,我们找到函数体,这是函数被调用时将执行的指令集合。

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

函数可能返回也可能不返回输出。如果函数想要返回输出,它将通过使用return关键字,后跟所需的输出来实现。你可能已经注意到了前一个图例输出部分中Optional后面的那个小*。这是因为 Python 中的函数总是返回某些东西,即使你没有显式使用return语句。如果函数体内没有return语句,或者没有给return语句本身提供值,函数将返回None

这个设计选择基于几个原因,其中最重要的原因是:

  • 简洁性和一致性:无论函数是否显式返回值,其行为都是一致的。

  • 复杂性降低:几种语言在函数(返回值的函数)和过程(不返回值的函数)之间做出区分。Python 中的函数可以同时充当这两种角色,无需单独的结构。这最小化了程序员必须学习的概念数量。

  • 多路径一致性:具有多个条件分支的函数在没有执行其他返回语句时将返回None。因此,None是一个有用的默认值。

列表展示了众多可能影响看似简单的设计决策的因素。正是这些支撑 Python 设计的精心和深思熟虑的选择,使其具有优雅、简单和多功能性。

为什么使用函数?

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

  • 它们减少了程序中的代码重复。将任务的指令封装在我们可以导入和随时调用的函数中,使我们能够避免重复实现。

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

  • 他们隐藏了实现细节,不让用户知道。

  • 它们提高了可追溯性。

  • 它们提高了可读性。

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

减少代码重复

想象一下,你正在编写一段科学软件,你需要计算到一定限制的素数——就像我们在上一章中做的那样。你有一个计算它们的算法,所以你把它复制并粘贴到你需要使用它的任何地方。然而,有一天,一个同事给你一个更高效的算法来计算素数。在这个时候,你需要遍历整个代码库,用新代码替换旧代码。

这个过程很容易出错。你可能会不小心删除周围代码的一部分,或者未能删除你打算替换的某些代码。你还可能错过一些主要计算完成的地方,导致你的软件处于不一致的状态,同一操作以不同的方式执行。如果,你不仅需要用更好的版本替换代码,还需要修复一个错误而你却错过了某个地方,那会变得更糟。如果旧算法中变量的名称与新的不同,这也会使事情变得复杂。

为了避免所有这些,你编写一个函数,get_prime_numbers(upto),并在你需要计算素数列表的任何地方使用它。当你的同事给你一个更好的实现时,你只需要替换那个函数的主体为新代码。其余的软件将自动适应,因为它只是调用这个函数。

你的代码会更短,并且不会出现旧方法和新方法执行任务时的一致性问题。你也更不可能留下由于复制粘贴失败或疏忽而产生的未被发现的问题。

将复杂任务拆分

函数还有助于将长或复杂的任务拆分成更小的任务。结果是代码在可读性、可测试性和可重用性等方面受益。

为了给你一个简单的例子,想象你正在准备一份报告。你的代码需要从数据源获取数据,解析它,过滤它,并对其进行润色,然后需要对它运行一系列算法,以生成将被写入报告的结果。常见的情况是,这样的流程通常只有一个大的do_report(data_source)函数。在最终生成报告之前,可能会有数百行代码需要运行。

缺乏经验的程序员,如果不擅长编写简单、结构良好的代码,可能会编写出数百行代码的函数。这些函数很难跟踪,很难找到事物改变上下文的地方(例如,完成一项任务并开始下一项任务)。让我们展示一个更好的方法:

# 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()只是一个又长又大的函数那样被迫去深入。要理解发生了什么,我们不得不阅读并理解它的每一行代码。当它被分解成更小的函数时,我们不一定需要阅读每一个函数的每一行代码来理解代码的功能。这减少了我们阅读代码所花费的时间,因为在专业环境中阅读代码所花费的时间比编写代码多得多,因此将其减少到最低限度是很重要的。

提高可读性

程序员有时看不到编写一个只有一或两行代码的函数的意义,所以让我们看看一个例子,说明为什么你可能仍然应该这样做。

想象你需要乘以两个矩阵,就像下面的例子:

img

你是否更喜欢阅读以下代码:

# 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
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相乘的结果要容易得多,阅读代码也更容易。如果我们不需要修改这个乘法逻辑,我们甚至不需要进入matrix_mul()函数的实现细节。因此,在这里可读性得到了提高,而在第一个片段中,我们可能需要花费时间试图理解那个复杂的列表推导式在做什么。

如果你不理解列表推导式,请不要担心,我们将在第五章推导式和生成器中学习它们。

提高可追溯性

假设我们为电子商务网站编写了一些代码。我们在几个页面上显示产品价格。想象一下,数据库中的价格没有包含增值税(销售税),但我们想在网站上以 20%的增值税显示它们。这里有几种从不含增值税价格计算含增值税价格的方法:

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

这四种计算含增值税价格的方法都是完全可以接受的;我们在过去几年中遇到的所有专业代码中都有遇到过。

现在,假设我们开始在多个国家销售产品,其中一些国家有不同的增值税率,因此我们需要重构代码(在整个网站上)以使增值税计算动态化。

我们如何追踪所有执行增值税计算的地方?现在的编码是一个协作任务,我们无法确定增值税是否只使用其中一种形式进行计算。这将是一件困难的事情。

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

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

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

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

范围和命名解析

第一章Python 的温和介绍中,我们讨论了范围和命名空间。现在我们将扩展这个概念。最后,我们可以用函数来讨论,这将使一切更容易理解。让我们从一个简单的例子开始:

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

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

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

很明显,test = 1遮蔽了my_function()中的test = 0赋值。在全局范围内,test仍然是0,正如你可以从程序输出中看到的那样,但我们再次在函数体中定义了test名称,并将其设置为指向整数1。因此,两个test名称都存在:一个在全局作用域中,指向值为0的 int 对象,另一个在my_function()作用域中,指向值为1int对象。让我们注释掉包含test = 1的行。Python 会在下一个封装的作用域中搜索test名称(回想一下LEGB规则:localenclosingglobalbuilt-in,在第一章Python 的温柔介绍中描述),在这种情况下,我们会看到0被打印两次。在你的代码中试一试。

现在,让我们给你一个更复杂的例子,包含嵌套函数:

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

在前面的代码中,我们有两个层次的遮蔽。一个层次在outer()函数中,另一个层次在inner()函数中。

如果我们运行代码,我们会得到:

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

尝试注释掉test = 1行。你能想出结果会是什么吗?当到达print('outer:', test)行时,Python 将不得不在下一个封装的作用域中寻找test;因此,它会找到并打印0,而不是1。在继续之前,确保你也注释掉了test = 2,以确保你理解发生了什么,以及 LEGB 规则是否对你来说很清晰。

另一个需要注意的事情是,Python 给了我们定义一个函数在另一个函数中的能力。inner()函数的名称是在outer()函数的作用域中定义的,这与任何其他名称的情况完全一样。

全局和非局部语句

在前面的例子中,我们可以通过使用这两个特殊语句之一来改变对 test 名称的遮蔽:globalnonlocal。正如你所看到的,当我们定义test = 2inner()函数中时,我们没有覆盖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 (which is 'outer')
        print("inner:", test)
    inner()
    print("outer:", test)
test = 0  # global scope
outer()
print("global:", test) 

注意在inner()函数体中我们如何声明test名称为nonlocal。运行此代码会产生以下结果:

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

inner() 函数中将 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() 函数中起作用,因为在这种情况下,我们正在引用全局作用域。

亲自尝试并看看会发生什么变化。花些时间熟悉作用域和名称解析——这非常重要。作为附加问题,你能告诉我如果在先前的例子中将 inner() 定义在 outer() 之外会发生什么吗?

输入参数

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

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

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

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

在我们进一步探讨参数主题之前,请允许我们稍微澄清一下术语。根据官方 Python 文档:

“参数是由函数定义中出现的名称定义的,而参数是调用函数时实际传递给函数的值。参数定义了函数可以接受哪些类型的参数。”

我们在提及参数和参数时将尽量做到精确,但值得注意的是,它们有时也被同义使用。现在让我们看看一些例子。

参数传递

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

# key.points.argument.passing.py
x = 3
def func(y):
    print(y)
func(x)  # prints: 3 

当使用 x 调用 func() 函数时,在其局部作用域内,会创建一个名为 y 的变量,并且它指向与 x 相同的对象。这可以在 图 4.2 中更清楚地理解(不用担心这个例子是用 Python 3.11 运行的——这是一个没有改变的特性)。

img

图 4.2:使用 Python Tutor 理解参数传递

图 4.2的右侧展示了程序执行到达末尾时的状态,在func()返回(None)之后。看看列,你会注意到我们在全局命名空间(全局帧)中有两个名称,xfunc(),分别指向一个整数(值为3)和一个function对象。在其下方,在标题为func的矩形中,我们可以看到函数的局部命名空间,其中只定义了一个名称:y。因为我们用x(图左侧的第 6 行)调用了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 

在前面的代码中,当我们用func(x)调用函数时,x = 7指令在func()函数的局部作用域中执行;名称x指向一个值为7的整数,而全局的x保持不变。

修改可变对象

修改可变对象会影响调用者。这一点很重要,因为尽管 Python 在处理可变对象时看起来行为不同,但实际上其行为是完全一致的。让我们来看一个例子:

# key.points.mutable.py
x = [1, 2, 3]
def func(x):
    x[1] = 42  # this affects the `x` argument!
func(x)
print(x)  # prints: [1, 42, 3] 

如你所见,我们改变了原始对象。如果你仔细想想,这种行为并没有什么奇怪之处。当我们调用func(x)时,函数命名空间中的x名称被设置为指向与全局x相同的对象。在函数体内部,我们并没有改变全局的x,也就是说,我们并没有改变它指向的对象。我们只是在那个对象的位置 1 访问元素并改变其值。

记住输入参数部分中的第 2 点在函数内部将对象赋值给参数名不会影响调用者。如果你明白了这一点,下面的代码应该不会让你感到惊讶:

# key.points.mutable.assignment.py
x = [1, 2, 3]
def func(x):
    x[1] = 42  # this changes the original `x` argument!
    x = "something else"  # this points x to a new string object
func(x)
print(x)  # still prints: [1, 42, 3] 

看看我们标记的两行。一开始,就像之前一样,我们再次访问调用者对象,在位置 1,并将该值改为数字42。然后,我们将x重新赋值以指向字符串'something else'。这不会改变调用者,实际上,输出与之前的代码片段相同。

仔细研究这个概念,并通过打印和调用 id() 函数进行实验,直到你心中的一切都清晰。这是 Python 的一个关键特性,它必须非常清晰,否则你可能会在代码中引入微妙的错误。再次强调,Python Tutor 网站(www.pythontutor.com/)通过提供这些概念的可视化表示,将极大地帮助你。

现在我们已经很好地理解了输入参数及其行为,让我们看看传递参数给函数的不同方式。

传递参数

传递函数参数有四种不同的方式:

  • 位置参数

  • 关键字参数

  • 可迭代解包

  • 字典解包

让我们逐一来看。

位置参数

当我们调用函数时,每个位置参数都被分配到函数定义中相应 位置 的参数:

# arguments.positional.py
def func(a, b, c):
    print(a, b, c)
func(1, 2, 3)  # prints: 1 2 3 

这是在某些编程语言中传递参数最常见的方式(在某些编程语言中,这是唯一的方式)。

关键字参数

函数调用中的关键字参数使用 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 

当我们使用关键字参数时,参数的顺序不需要与函数定义中参数的顺序匹配。这可以使我们的代码更容易阅读和调试。我们不需要记住(或查找)函数定义中参数的顺序。我们可以查看函数调用,并立即知道哪个参数对应哪个参数。

你也可以同时使用位置参数和关键字参数:

# arguments.positional.keyword.py
def func(a, b, c):
    print(a, b, c)
func(42, b=1, c=2) 

然而,请记住,位置参数必须始终在关键字参数之前列出。例如,如果你尝试这样做:

# arguments.positional.keyword.py
func(b=1, c=2, 42)  # positional arg after keyword args 

你将得到以下错误:

$ python arguments.positional.keyword.py
  File "arguments.positional.keyword.py", line 7
    func(b=1, c=2, 42)  # positional arg after keyword args
                     ^
SyntaxError: positional argument follows keyword argument 

可迭代解包

可迭代解包使用 *iterable_name 语法将可迭代对象的元素作为位置参数传递给函数:

# arguments.unpack.iterable.py
def func(a, b, c):
    print(a, b, c)
values = (1, 3, -7)
func(*values)  # equivalent to: func(1, 3, -7) 

这是一个非常有用的特性,尤其是在我们需要以编程方式为函数生成参数时。

字典解包

字典解包对于关键字参数来说,就像可迭代解包对于位置参数一样。我们使用 **dictionary_name 语法将字典的键和值构造的关键字参数传递给函数:

# arguments.unpack.dict.py
def func(a, b, c):
    print(a, b, c)
values = {"b": 1, "c": 2, "a": 42}
func(**values)  # equivalent to func(b=1, c=2, a=42) 

结合参数类型

我们已经看到,位置参数和关键字参数可以一起使用,只要它们按照正确的顺序传递。我们还可以将解包(两种类型)与正常的位置参数和关键字参数结合使用。我们甚至可以将多个可迭代对象和字典进行解包。

参数必须按照以下顺序传递:

  • 首先,位置参数:普通(name)和解包(*name

  • 接下来是关键字参数(name=value),它可以与可迭代解包 (*name) 混合使用

  • 最后,还有字典解包(**name),它可以与关键字参数(name=value)混合使用

这将通过一个例子更容易理解:

# arguments.combined.py
def func(a, b, c, d, e, f):
    print(a, b, c, d, e, f)
func(1, *(2, 3), f=6, *(4, 5))
func(*(1, 2), e=5, *(3, 4), f=6)
func(1, **{"b": 2, "c": 3}, d=4, **{"e": 5, "f": 6})
func(c=3, *(1, 2), **{"d": 4}, e=5, **{"f": 6}) 

所有上述对 func() 的调用都是等效的,并打印 1 2 3 4 5 6。玩转这个例子,直到你确信你理解了它。当顺序错误时,请特别注意你得到的错误。

能够解包多个可迭代对象和字典的能力是由 PEP 448 引入到 Python 中的。这个 PEP 还引入了在函数调用之外使用解包的能力。你可以在peps.python.org/pep-0448/上阅读所有关于它的内容。

当结合位置参数和关键字参数时,重要的是要记住每个参数在参数列表中只能出现一次:

# arguments.multiple.value.py
def func(a, b, c):
    print(a, b, c)
func(2, 3, a=1) 

在这里,我们为参数 a 传递了两个值:位置参数 2 和关键字参数 a=1。这是非法的,所以当我们尝试运行它时,我们会得到一个错误:

$ python arguments.multiple.value.py
Traceback (most recent call last):
  File "arguments.multiple.value.py", line 5, in <module>
    func(2, 3, a=1)
TypeError: func() got multiple values for argument 'a' 

定义参数

函数参数可以分为五组。

  • 位置或关键字参数:允许位置参数和关键字参数

  • 可变位置参数:在一个元组中收集任意数量的位置参数

  • 可变关键字参数:在一个字典中收集任意数量的关键字参数

  • 仅位置参数:只能作为位置参数传递

  • 仅关键字参数:只能作为关键字参数传递

本章中我们看到的所有参数都是常规的位置参数或关键字参数。我们已经看到了它们可以作为位置参数和关键字参数传递的方式。关于它们,没有太多可以说的,所以让我们看看其他类别。不过,在我们这样做之前,让我们简要地看看可选参数。

可选参数

除了我们在这里看到的类别之外,参数还可以被分类为必需可选可选参数在函数定义中指定了默认值。语法是 name=value

# parameters.default.py
def func(a, b=4, c=88):
    print(a, b, c)
func(1)  # prints: 1 4 88
func(b=5, a=7, c=9)  # prints: 7 5 9
func(42, c=9)  # prints: 42 4 9
func(42, 43, 44)  # prints: 42, 43, 44 

在这里,a 是必需的,而 b 有默认值 4c 有默认值 88。重要的是要注意,除了仅关键字参数外,必需参数必须始终位于函数定义中所有可选参数的左侧。尝试在上面的例子中移除 c 的默认值,看看会发生什么。

可变位置参数

有时候你可能更喜欢不指定函数的确切位置参数数量;Python 通过使用可变位置参数为你提供了这样做的能力。让我们看看一个非常常见的用例,即 minimum() 函数。这是一个计算输入值最小值的函数:

# parameters.variable.positional.py
def minimum(*n):
    # print(type(n))  # n is a tuple
    if n:  # explained after the code
        mn = n[0]
        for value in n[1:]:
            if value < mn:
                mn = value
        print(mn)
minimum(1, 3, -7, 9)  # n = (1, 3, -7, 9) - prints: -7
minimum()  # n = () - prints: nothing 

如你所见,当我们定义一个带有前缀星号*的参数时,我们是在告诉 Python,当函数被调用时,这个参数将收集一个可变数量的位置参数。在函数内部,n是一个元组。取消注释print(type(n))来亲自查看,并稍作尝试。

注意,一个函数最多只能有一个可变位置参数——拥有更多的参数是没有意义的。Python 将无法决定如何在这些参数之间分配参数。你也不能为可变位置参数指定一个默认值。默认值始终是一个空元组。

你有没有注意到我们是如何用简单的if n:来检查n是否为空的?这是因为集合对象在非空时在 Python 中评估为True,否则为False。这对元组、集合、列表、字典等都是如此。

另一点需要注意的是,当我们调用函数而没有参数时,我们可能希望抛出一个错误,而不是默默地什么也不做。在这种情况下,我们并不关心使这个函数健壮,而是理解可变位置参数。

你有没有注意到定义可变位置参数的语法看起来非常像可迭代解包的语法?这并非巧合。毕竟,这两个特性是相互对应的。它们也经常一起使用,因为可变位置参数可以让你不必担心你解包的可迭代对象的长度是否与函数定义中参数的数量相匹配。

可变关键字参数

可变关键字参数与可变位置参数非常相似。唯一的区别是语法(使用**而不是*)以及它们被收集在一个字典中:

# parameters.variable.keyword.py
def func(**kwargs):
    print(kwargs)
func(a=1, b=42)  # prints {'a': 1, 'b': 42}
func()  # prints {}
func(a=1, b=46, c=99)  # prints {'a': 1, 'b': 46, 'c': 99} 

你可以看到,在函数定义中将**添加到参数名之前告诉 Python 使用该名称来收集一个可变数量的关键字参数。与可变位置参数的情况一样,每个函数最多只能有一个可变关键字参数——你不能指定一个默认值。

就像可变位置参数类似于可迭代解包一样,可变关键字参数类似于字典解包。字典解包也常用于向具有可变关键字参数的函数传递参数。

能够传递可变数量的关键字参数之所以如此重要,可能现在还不明显,那么让我们来看一个更实际的例子?让我们定义一个连接数据库的函数:我们希望通过不带参数调用这个函数来连接默认数据库。我们还想通过向函数传递适当的参数来连接任何其他数据库。在你继续阅读之前,试着花几分钟时间自己想出一个解决方案:

# parameters.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 parameters.variable.db.py
{'host': '127.0.0.1', 'port': 5432, 'user': '', 'pwd': ''}
{'host': '127.0.0.42', 'port': 5433, 'user': '', 'pwd': ''}
{'host': '127.0.0.1', 'port': 5431, 'user': 'fab', 'pwd': 'gandalf'} 

注意函数调用与输出的对应关系,以及默认值是如何根据传递给函数的内容被覆盖的。

仅位置参数

从 Python 3.8 开始,PEP 570 (peps.python.org/pep-0570/) 引入了仅位置参数。有一个新的函数参数语法 /,表示必须以位置指定一组函数参数,并且不能作为关键字参数传递。让我们看一个简单的示例:

# parameters.positional.only.py
def func(a, b, /, c):
    print(a, b, c)
func(1, 2, 3)  # prints: 1 2 3
func(1, 2, c=3)  # prints 1 2 3 

在前面的示例中,我们定义了一个函数 func(),它指定了三个参数:abc。函数签名中的 / 表示 ab 必须通过位置传递,也就是说,不能通过关键字传递。

示例中的最后两行显示,我们可以通过位置传递所有三个参数来调用函数,或者我们可以通过关键字传递 c。这两种情况都工作得很好,因为 c 在函数签名中的 / 之后被定义。如果我们尝试通过关键字传递 ab 来调用函数,如下所示:

func(1, b=2, c=3) 

这会产生以下跟踪回溯:

Traceback (most recent call last):
  File "arguments.positional.only.py", line 7, in <module>
    func(1, b=2, c=3)
TypeError: func() got some positional-only arguments
passed as keyword arguments: 'b' 

前面的示例显示,Python 现在正在抱怨我们如何调用 func()。我们通过关键字传递了 b,但我们不允许这样做。

仅位置参数也可以是可选的:

# parameters.positional.only.optional.py
def func(a, b=2, /):
    print(a, b)
func(4, 5)  # prints 4 5
func(3)  # prints 3 2 

让我们通过从官方文档中借用的一些示例来看看这个特性给语言带来了什么。一个优点是能够完全模拟现有 C 编码函数的行为:

def divmod(a, b, /):
    "Emulate the built in divmod() function"
    return (a // b, a % b) 

另一个重要的用例是在参数名称没有帮助时排除关键字参数:

len(obj='hello') 

在前面的示例中,obj 关键字参数损害了可读性。此外,如果我们希望重构 len 函数的内部结构,并将 obj 重命名为 the_object(或任何其他名称),这种更改将保证不会破坏任何客户端代码,因为不会有任何调用 len() 函数涉及现在过时的 obj 参数名称。

最后,使用仅位置参数意味着 / 左边的任何内容都可以用于变量关键字参数,如下面的示例所示:

def func_name(name, /, **kwargs):
    print(name)
    print(kwargs)
func_name("Positional-only name", name="Name in **kwargs")
# Prints:
# Positional-only name
# {'name': 'Name in **kwargs'} 

在函数签名中保留参数名称以在 **kwargs 中使用的能力可以导致代码更简单、更干净。

现在,让我们探索仅位置参数的镜像版本:仅关键字参数。

仅关键字参数

Python 3 引入了仅关键字参数。我们将只简要研究它们,因为它们的使用场景并不频繁。指定它们有两种方式,要么在变量位置参数之后,要么在裸星号 * 之后。让我们看看两种方式的例子:

# parameters.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。你可以取消注释第三个调用以查看产生的错误。

现在你已经知道了如何指定不同类型的输入参数,让我们看看如何在函数定义中组合它们。

组合输入参数

你可以在同一个函数中组合不同类型的参数(实际上,这样做通常非常有用)。正如在同一个函数调用中组合不同类型的参数一样,有一些关于顺序的限制:

  • 仅位置参数首先出现,后面跟着一个 /

  • 正常参数位于任何位置仅参数之后。

  • 变量位置参数位于正常参数之后。

  • 仅关键字参数位于变量位置参数之后。

  • 变量关键字参数总是放在最后。

  • 对于位置仅和正常参数,任何必需的参数都必须在任何可选参数之前定义。这意味着如果你有一个可选的位置仅参数,所有你的正常参数也必须是可选的。这条规则不影响仅关键字参数。

这些规则在没有例子的情况下可能有点难以理解,所以让我们看看几个例子:

# parameters.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") 

注意函数定义中参数的顺序。执行结果如下:

$ python parameters.all.py
a, b, c: 1 2 3
args: (5, 7, 9)
kwargs: {'A': 'a', 'B': 'b'} 

现在我们来看一个关于仅关键字参数的例子:

# parameters.all.pkwonly.py
def allparams(a, /, b, c=42, *args, d=256, e, **kwargs):
    print("a, b, c:", a, b, c)
    print("d, e:", d, e)
    print("args:", args)
    print("kwargs:", kwargs)
allparams(1, 2, 3, 4, 5, 6, e=7, f=9, g=10) 

注意我们在函数声明中既有位置仅参数也有仅关键字参数:a 是位置仅参数,而 de 是仅关键字参数。它们在 *args 变量位置参数之后,如果它们直接跟在单个 * 之后(在这种情况下将没有变量位置参数),结果将是相同的。执行结果如下:

$ python parameters.all.pkwonly.py
a, b, c: 1 2 3
d, e: 256 7
args: (4, 5, 6)
kwargs: {'f': 9, 'g': 10} 

另有一点需要注意,我们给变量位置参数和关键字参数取的名字。你可以自由选择不同的名字,但请注意,argskwargs 是这些参数的传统名称,至少在通用意义上是这样。

更多签名示例

为了简要回顾使用位置和仅关键字指定符的函数签名,这里有一些进一步的例子。为了简洁起见,省略了变量位置和关键字参数,我们得到以下语法:

def func_name(positional_only_parameters, /,
    positional_or_keyword_parameters, *,
    keyword_only_parameters): 

首先,我们有位置仅参数,然后是位置或关键字参数,最后是仅关键字参数。

下面展示了其他一些有效的签名:

def func_name(p1, p2, /, p_or_kw, *, kw):
def func_name(p1, p2=None, /, p_or_kw=None, *, kw):
def func_name(p1, p2=None, /, *, kw):
def func_name(p1, p2=None, /):
def func_name(p1, p2, /, p_or_kw):
def func_name(p1, p2, /): 

所有的上述签名都是有效的,而以下将是无效的:

def func_name(p1, p2=None, /, p_or_kw, *, kw):
def func_name(p1=None, p2, /, p_or_kw=None, *, kw):
def func_name(p1=None, p2, /): 

你可以在官方文档中阅读关于语法规范的说明:

docs.python.org/3/reference/compound_stmts.html#function-definitions

在这个阶段,对你来说一个有用的练习是实现上述示例签名中的任何一个,打印出那些参数的值,就像我们在之前的练习中所做的那样,并尝试以不同的方式传递参数。

避免陷阱!可变默认值

在 Python 中,有一件事需要注意,那就是默认值是在定义时创建的;因此,对同一函数的后续调用可能会根据其默认值的可变性而表现出不同的行为。让我们来看一个例子:

# parameters.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 parameters.defaults.mutable.py
[]
{}
############
[0]
{1: 1}
############
[0, 1]
{1: 1, 2: 2}
############ 

虽然这种行为一开始可能看起来很奇怪,但实际上是有道理的,而且非常方便——例如,当使用记忆化技术时。更有趣的是,当在调用之间引入一个不使用默认值的调用时会发生什么,比如这个:

# parameters.defaults.mutable.intermediate.call.py
func()
func(a=[1, 2, 3], b={"B": 1})
func() 

当我们运行此代码时,这是输出:

$ python parameters.defaults.mutable.intermediate.call.py
[]
{}
############
[1, 2, 3]
{'B': 1}
############
[0]
{1: 1}
############ 

这个输出显示,即使我们用其他值调用函数,默认值也会被保留。一个自然而然的问题就是,我如何每次都得到一个全新的空值?嗯,惯例是这样的:

# parameters.defaults.mutable.no.trap.py
def func(a=None):
    if a is None:
        a = []
    # do whatever you want with `a` ... 

注意,通过使用前面的技术,如果在调用函数时没有传递a,我们总是会得到一个全新的、空的列表。

在详细阐述了输入参数之后,现在我们来看看硬币的另一面,即返回输出值。

返回值

我们已经说过,要从函数中返回某些内容,我们需要使用return语句,后面跟着我们想要返回的内容。一个函数体内可以有任意多的return语句。

另一方面,如果在函数体内我们没有返回任何内容,或者调用了裸的return语句,函数将返回None。当不需要这种行为时,这种行为是无害的,但它允许有趣的模式,并证实 Python 是一种非常一致的语言。

我们说这是无害的,因为你永远不会被迫收集函数调用的结果。我们将用一个例子来展示我们的意思:

# return.none.py
def func():
    pass
func()  # the return of this call won't be collected. It's lost.
a = func()  # the return of this one instead is collected into `a`
print(a)  # prints: None 

注意,函数的主体仅由pass语句组成。正如官方文档所述,pass是一个空操作,当它执行时,没有任何事情发生。它在需要语法上的语句但不需要执行代码时非常有用。在其他语言中,我们可能会用一对花括号({})来表示这一点,这定义了一个空范围;但在 Python 中,范围是通过缩进来定义的,因此需要一个如pass这样的语句。

注意,func()的第一个调用返回一个值(None),我们没有收集。正如我们之前提到的,收集函数调用的返回值不是强制性的。

让我们看看一个更有趣的例子。记住,在第一章Python 的温柔介绍中,我们讨论了阶乘函数。让我们在这里编写我们自己的实现(为了简单起见,我们将假设函数总是以适当的值正确调用,因此我们不需要对输入参数进行合理性检查):

# return.single.value.py
def factorial(n):
    if n in (0, 1):
        return 1
    result = n
    for k in range(2, n):
        result *= k
    return result
f5 = factorial(5)  # f5 = 120 

注意,我们有两个返回点。如果n01,我们返回1。否则,我们执行所需的计算并返回result

在 Python 中,使用in运算符进行成员检查是很常见的,就像我们在前一个例子中所做的那样,而不是更冗长的:

if n == 0 or n == 1:
    ... 

现在我们尝试将这个函数写得更简洁一些:

# 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 既优雅又简洁。即使我们没有见过reduce()mul(),这个实现也是可读的。如果你不能阅读或理解它,请留出几分钟时间,在 Python 文档中做一些研究,直到它的行为对你来说很清楚。在文档中查找函数并理解别人编写的代码是每个开发者都需要能够执行的任务。

为了这个目的,确保你查阅了help()函数,这在用控制台探索时非常有帮助。

返回多个值

要返回多个值很简单:你只需使用元组。让我们看看一个简单的例子,它模仿了内置的divmod()函数:

# return.multiple.py
def moddiv(a, b):
    return a // b, a % b
print(moddiv(20, 7))  # prints (2, 6) 

我们原本可以在前述代码中用括号括起高亮的部分,但这样做没有必要。前述函数同时返回了除法的结果和余数。

在这个例子的源代码中,我们留下了一个简单的测试函数示例,以确保代码正在执行正确的计算。

一些有用的提示

在编写函数时,遵循一些指南非常有用,这样你就能写出好的函数。我们将很快指出其中的一些:

  • 函数应该只做一件事:只有一个功能的函数可以用一句话简单描述;做多件事的函数可以拆分成做一件事的小函数。这些较小的函数通常更容易阅读和理解。

  • 函数应尽量小:它们越小,就越容易测试和编写,以便它们只做一件事。

  • 输入参数越少越好:接受很多参数的函数很快就会变得难以管理(以及其他问题)。

  • 函数的返回值应保持一致:返回False和返回None是两回事,即使在布尔上下文中它们都评估为FalseFalse表示我们有信息(False),而None表示没有信息。尝试编写在逻辑中无论发生什么都能以一致方式返回结果的函数。

  • 函数应无副作用:在函数式编程中,有纯函数的概念。这种类型的函数遵循两个主要原则:

    • 确定性输出:这意味着给定相同的输入集,产生的输出将始终相同。换句话说,函数的行为不依赖于任何可能在执行期间改变的外部或全局状态。

    • 无副作用:这意味着纯函数不会在系统中引起任何可观察的副作用。也就是说,它们不会改变任何外部状态,如修改全局变量或执行 I/O 操作,如从文件或显示中读取或写入。

虽然你应该尽可能编写纯函数,但重要的是你编写的函数至少不应有副作用。它们不应影响它们被调用的参数的值。

这可能是目前最难理解的说法,所以我们将用列表举例。在下面的代码中,注意numbers没有被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·马丁(Robert C. Martin)所著,专门讨论函数,这是我们读过的关于这个主题最好的指南之一。

递归函数

当一个函数调用自身以产生结果时,它被称为递归。有时递归函数非常有用,因为它们使编写逻辑变得更容易。有些算法使用递归编写非常容易,而有些则不然。没有递归函数不能以迭代方式重写,所以通常取决于程序员选择最适合当前情况的最好方法。

递归函数的主体通常有两个部分:一个部分返回值依赖于对自身的后续调用,另一个部分则不(称为基本情况)。

例如,我们可以考虑(希望现在熟悉的)阶乘函数 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))) 

注意我们是如何使用 is_multiple_of_five() 来过滤前 n 个自然数的。这看起来有点过度——任务很简单,我们不需要保留 is_multiple_of_five() 函数用于其他任何事情。让我们用 lambda 函数重写它:

# filter.lambda.py
def get_multiples_of_five(n):
    return list(filter(lambda k: not k % 5, range(n))) 

逻辑是相同的,但现在过滤函数是一个 lambda。定义 lambda 非常简单,遵循以下形式:

func_name = lambda [parameter_list]: expression 

返回的是一个函数对象,它相当于以下内容:

def func_name([parameter_list]):
    return expression 

注意,可选参数按照常见的语法用方括号括起来表示。

让我们看看几个等价函数的例子,这些函数以两种形式定义:

# lambda.explained.py
# example 1: adder
def adder(a, b):
    return a + b
# is equivalent to:
adder_lambda = lambda a, b: a + b
# example 2: to uppercase
def to_upper(s):
    return s.upper()
# is equivalent to:
to_upper_lambda = lambda s: s.upper() 

上述例子非常简单。第一个例子是加两个数字,第二个例子是产生字符串的大写版本。请注意,我们已将 lambda 表达式返回的内容分配给一个名称(adder_lambdato_upper_lambda),但在 filter() 例子中我们以这种方式使用 lambda 时并不需要这样做。

函数属性

每个函数都是一个完整的对象,因此它有几个属性。其中一些是特殊的,可以在运行时以自省的方式检查函数对象。以下脚本是一个示例,展示了其中的一些属性以及如何显示示例函数的值:

# func.attributes.py
def multiplication(a, b=1):
    """Return a multiplied by b."""
    return a * b
if __name__ == "__main__":
    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 0x102ce1550,
             file "func.attributes.py", line 2>
__globals__ -> {... omitted ...}
__dict__ -> {}
__closure__ -> None
__annotations__ -> {}
__kwdefaults__ -> None 

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

Python 3 数据模型标准层次结构

你可以使用内置的dir()函数来获取任何对象的全部属性列表。

在前面的例子中要注意的一点是这条语句的使用:

if __name__ == "__main__": 

这行代码确保只有当模块直接运行时,随后的代码才会执行。当你运行一个 Python 脚本时,Python 会将该脚本中的__name__变量设置为"__main__"。相反,当你将 Python 脚本作为模块导入到另一个脚本中时,__name__变量会被设置为正在导入的脚本/模块的名称。

内置函数

Python 自带了许多内置函数。它们在任何地方都可用,你可以通过检查builtins模块使用dir(__builtins__)或通过访问官方 Python 文档来获取它们的列表。不幸的是,我们没有足够的空间在这里介绍它们的所有内容。我们已经看到了一些,例如anybinbooldivmodfilterfloatgetattridintlenlistminprintsettupletypezip,但还有很多其他的,你至少应该阅读一次。熟悉它们,进行实验,为每个函数编写一小段代码,并确保你能够随时使用它们。

你可以在官方文档中找到内置函数的列表,这里:docs.python.org/3/library/functions.html

记录你的代码

我们是无需文档的代码的忠实粉丝。当我们遵循既定原则编写优雅的代码时,代码应该自然地具有自解释性,文档几乎变得不必要。然而,给函数添加一个 docstring,或者添加包含重要信息的注释,可能非常有用。

你可以在 PEP 257 – Docstring conventions 中找到记录 Python 的指南,该指南位于peps.python.org/pep-0257/,但我们将在这里向您展示基础知识。

Python 使用字符串进行文档记录,这些字符串恰当地被称为docstrings。任何对象都可以进行文档记录,我们可以使用单行或多行 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() 函数进行了文档说明:

def connect(host, port, user, password):
    """Connect to a database.
    Connect to a PostgreSQL database directly, using the given
    parameters.
    :param host: The host IP.
    :param port: The desired port.
    :param user: The connection username.
    :param password: The connection password.
    :return: The connection object.
    """
    # body of the function here...
    return connection 

Sphinx 是创建 Python 文档最广泛使用的工具之一——实际上,官方的 Python 文档就是用它编写的。花些时间检查它绝对值得。

help() 内置函数,旨在用于交互式使用,它使用对象的文档字符串为其创建一个文档页面。

导入对象

现在我们已经对函数有了很多了解,让我们看看如何使用它们。编写函数的全部意义在于能够以后重用它们,在 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 

为了让你了解导入的样子,这里有一个来自 Fabrizio 的项目测试模块的示例(注意,导入块之间的空白行遵循 PEP 8 的指南 peps.python.org/pep-0008/#imports :首先是标准库,然后是第三方库,最后是本地代码):

# imports.py
from datetime import datetime, timezone  # two imports, same line
from unittest.mock import patch  # single import
import pytest  # third party library
from core.models import (  # multiline import
    Exam,
    Exercise,
    Solution,
) 

当我们的文件结构从项目的根目录开始时,我们可以使用点符号来获取我们想要导入到当前命名空间的对象,无论是包、模块、类、函数还是其他任何东西。

from module import 语法还允许一个通配符子句,from module import * ,有时用于一次性将模块中的所有名称导入到当前命名空间中。这种做法因性能和静默覆盖其他名称的风险而被不推荐。你可以在官方 Python 文档中找到有关导入的所有信息,但在我们离开这个主题之前,让我们给你一个更好的例子。

想象一下,我们已经在funcdef.py模块中定义了几个函数,例如square(n)cube(n),这个模块位于util文件夹中。我们希望在位于与util文件夹同一级别的几个模块中使用它们,这些模块分别叫做func_import.pyfunc_from.py。展示该项目的树状结构会产生如下内容:

├── func_from.py
├── func_import.py
├── util
│   ├── __init__.py
│   └── funcdef.py 

在我们向您展示每个模块的代码之前,请记住,为了告诉 Python 它实际上是一个包,我们需要在它里面放置一个__init__.py模块。

关于__init__.py文件有两点需要注意。首先,它是一个完整的 Python 模块,所以你可以像其他任何模块一样在其中放置代码。其次,从 Python 3.3 开始,它的存在不再是将文件夹解释为 Python 包所必需的。

代码如下:

# util/funcdef.py
def square(n):
    return n**2
def cube(n):
    return n**3
# func_import.py
import util.funcdef
print(util.funcdef.square(10))
print(util.funcdef.cube(10))
# func_from.py
from util.funcdef import square, cube
print(square(10))
print(cube(10)) 

这两个文件在执行时都会打印出1001000。你可以看到我们如何根据当前作用域中导入的方式和内容,以不同的方式访问squarecube函数。

相对导入

我们迄今为止看到的导入类型被称为绝对导入;也就是说,它定义了我们要导入的模块或从中导入对象的整个路径。还有一种将对象导入 Python 的方法,称为相对导入。相对导入是通过在模块前添加与我们需要回溯的文件夹数量相同数量的前导点来完成的,以找到我们正在寻找的内容。简单来说,就是如下所示:

from .mymodule import myfunc 

相对导入在重构项目时非常有用。在导入中不使用完整路径允许开发者移动东西,而无需重命名太多这些路径。

对于相对导入的完整解释,请参阅 PEP 328:peps.python.org/pep-0328/

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

最后的一个例子

在我们结束本章之前,让我们再看一个最后的例子。我们可以编写一个函数来生成一个直到某个限制的素数列表;我们已经在第三章条件与迭代中看到了这个代码,所以让我们将其变成一个函数,并且为了保持其趣味性,让我们对其进行一点优化。

首先,我们不需要除以从2N-1的所有数字来决定一个数N是否为素数。我们可以在√NN的平方根)处停止。此外,我们不需要测试从2√N的所有数字的除法,因为我们可以直接使用该范围内的素数。如果你对此感兴趣,我们可以留给你去思考为什么这会起作用。

让我们看看代码是如何变化的:

# primes.py
from math import sqrt, ceil
def get_primes(n):
    """Calculate a list of primes up to n (included)."""
    primelist = []
    for candidate in range(2, n + 1):
        is_prime = True
        root = ceil(sqrt(candidate))  # division limit
        for prime in primelist:  # we try only the primes
            if prime > root:  # no need to check any further
                break
            if candidate % prime == 0:
                is_prime = False
                break
        if is_prime:
            primelist.append(candidate)
    return primelist 

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

摘要

在本章中,我们探索了函数的世界。它们非常重要,从现在起,我们将几乎在所有事情中使用它们。我们讨论了使用函数的主要原因,其中最重要的是代码重用和实现隐藏。

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

现在,你应该知道如何编写函数、记录其文档、将其导入到你的代码中,并调用它。

在下一章中,我们将稍微加快节奏,所以我们建议你花一些时间通过实验代码和阅读 Python 官方文档来巩固和丰富你迄今为止所积累的知识。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第五章:理解和生成器

“不是每天的增量,而是每天的减量。砍掉不必要的东西。”

——李小龙

上述引言的第二部分,“砍掉不必要的东西”,对我们来说正是使计算机程序优雅的原因。我们不断努力寻找更好的做事方式,以便我们不浪费时间或内存。

有一些合理的理由不将我们的代码推到最大极限。例如,有时我们不得不牺牲可读性或可维护性以换取微小的改进。当我们可以用可读、干净的代码在 1.05 秒内提供服务时,用难以阅读、复杂的代码在 1 秒内提供服务是没有意义的。

另一方面,有时尝试从函数中节省一毫秒是完全合理的,特别是当函数打算被调用数千次时。在数千次调用中节省的一毫秒可以累积成秒,这可能会对你的应用程序有意义。

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

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

  • map()zip()filter()函数

  • 理解

  • 生成器

  • 性能

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

看看这段代码:

# squares.py
def square1(n):
    return n**2  # squaring through the power operator
def square2(n):
    return n * n  # squaring through multiplication 

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

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

那么,何时优化变得重要呢?一个常见的例子是当你必须处理大量数据时。如果你要对一百万个customer对象应用相同的函数,那么你希望你的函数调优到最佳状态。对一个被调用一百万次的函数节省十分之一秒可以节省 100,000 秒,这大约是 27.7 小时。因此,让我们关注集合,看看 Python 为你提供了哪些工具来高效、优雅地处理它们。

本章中我们将看到的大多数概念都是基于迭代器和可迭代对象,我们在 第三章条件语句和迭代 中遇到了它们。我们将看到如何在 第六章面向对象编程、装饰器和迭代器 中编码自定义迭代器和可迭代对象。

本章我们将探索的一些对象是迭代器,它们通过一次只操作集合中的一个元素来节省内存,而不是创建一个修改后的副本。因此,如果我们只想显示操作的结果,就需要做一些额外的工作。我们通常会求助于将迭代器包裹在 list() 构造函数中。这是因为将迭代器传递给 list() 会耗尽它,并将所有生成的项放入一个新创建的列表中,我们可以轻松地打印出来以显示其内容。让我们看看在 range 对象上使用该技术的例子:

# list.iterable.txt
>>> range(7)
**range****(****0****,** **7****)**
>>> list(range(7))  # put all elements in a list to view them
[0, 1, 2, 3, 4, 5, 6] 

我们已经突出显示了在 Python 控制台中输入 range(7) 的结果。注意,它并没有显示 range 的内容,因为 range 从来不会实际将整个数字序列加载到内存中。第二个突出显示的行显示了将 range 包裹在 list() 中是如何使我们能够看到它生成的数字的。

让我们开始查看 Python 为高效操作数据集合提供的各种工具。

mapzipfilter 函数

我们将首先回顾 map()filter()zip(),这些是处理集合时可以使用的内置函数的主要函数,然后我们将学习如何使用两个重要的结构:列表推导式生成器来实现相同的结果。

map

根据 Python 的官方文档(docs.python.org/3/library/functions.html#map),以下内容是正确的:

map(function, iterable, *iterables)

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

我们将在本章后面解释生成器的概念。现在,让我们将其转换为代码——我们将使用一个接受可变数量位置参数的 lambda 函数,并返回一个元组:

# map.example.txt
>>> map(lambda *a: a, range(3))  # 1 iterable
<map object at 0x7f0db97adae0>  # Not useful! Let us use list
>>> list(map(lambda *a: a, range(3)))  # 1 iterable
[(0,), (1,), (2,)]
>>> 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')] 

在前面的代码中,你可以看到为什么我们必须将调用包裹在 list() 中。没有它,我们会得到一个 map 对象的字符串表示形式。Python 对象的默认字符串表示会给出它们的类型和内存位置,在这个上下文中,这对我们来说并不特别有用。

您还可以注意到每个可迭代元素的函数应用方式;最初,每个可迭代元素的第一个元素被应用,然后是每个可迭代元素的第二个元素,依此类推。请注意,map() 在我们调用的最短可迭代对象耗尽时停止。这是一个非常有用的行为;它不会强迫我们将所有可迭代对象调整到相同的长度,也不会在它们长度不同时中断。

作为更有趣的例子,假设我们有一个包含学生字典的集合,每个字典中都有一个嵌套的学生学分字典。我们希望根据学生学分的总和对学生进行排序。然而,现有的数据并不允许直接应用排序函数。

为了解决这个问题,我们将应用 装饰-排序-取消装饰 惯用(也称为 Schwartzian 转换)。这是一种在较旧的 Python 版本中相当流行的技术,当时排序不支持使用 键函数。如今,它不再经常需要,但它偶尔仍然很有用。

装饰 一个对象意味着对其进行转换,无论是向其添加额外数据还是将其放入另一个对象中。相反,要 取消装饰 一个对象意味着将装饰过的对象恢复到其原始形式。

这种技术与 Python 装饰器无关,我们将在本书的后面部分探讨。

在以下示例中,我们可以看到 map() 是如何应用这个惯用的:

# decorate.sort.undecorate.py
from pprint import pprint
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]
print(students[0])
print(decorate(students[0])
students = sorted(**map****(decorate, students)**, reverse=True)
students = list(**map****(undecorate, students)**)
pprint(students) 

让我们先来了解每个学生对象是什么。实际上,让我们打印第一个:

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

您可以看到这是一个包含两个键的字典:idcreditscredits 的值也是一个字典,其中包含三个科目/成绩键值对。如您从 第二章内置数据类型 中回忆的那样,调用 dict.values() 返回一个可迭代对象,其中只有字典的值。因此,第一个学生的 sum(student["credits"].values()) 等同于 sum((9, 6, 7))

让我们打印调用 decorate 时第一个学生的结果:

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

如果我们这样装饰所有学生,我们只需对元组列表进行排序,就可以根据他们的总学分数对他们进行排序。为了将装饰应用到 students 中的每个项上,我们调用 map(decorate, students)。我们排序结果,然后以类似的方式取消装饰。

在运行整个代码后打印 students 会得到以下结果:

[{'credits': {'chemistry': 10, 'history': 8, 'physics': 9}, 'id': 2},
 {'credits': {'latin': 10, 'math': 6, 'physics': 7}, 'id': 1},
 {'credits': {'history': 7, 'math': 9, 'physics': 6}, 'id': 0},
 {'credits': {'geography': 7, 'math': 5, 'physics': 5}, 'id': 3}] 

如您所见,学生对象确实是根据他们学分的总和进行排序的。

关于 装饰-排序-取消装饰 惯用,官方 Python 文档的 排序 HOW TO 部分有一个很好的介绍:

docs.python.org/3.12/howto/sorting.html#decorate-sort-undecorate

在排序部分需要注意的一点是,当两个或多个学生的总分相同时会发生什么。排序算法将接着通过比较 student 对象来对元组进行排序。这没有任何意义,并且在更复杂的情况下可能会导致不可预测的结果,甚至错误。如果你想要避免这个问题,一个简单的解决方案是创建一个三重元组而不是双重元组,将学分总和放在第一个位置,student 对象在原始 students 列表中的位置放在第二个位置,student 对象本身放在第三个位置。这样,如果学分总和相同,元组将根据位置进行排序,位置总是不同的,因此足以解决任何一对元组之间的排序问题。

zip

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

根据 Python 文档(docs.python.org/3/library/functions.html#zip),以下适用:

zip(*iterables, strict=False)

... 返回一个元组的迭代器,其中第 i 个元组包含来自每个参数可迭代对象的第 i 个元素。

另一种思考 zip() 的方式是它将行转换为列,将列转换为行。这与矩阵转置类似。

让我们看看一个例子:

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

在这里,我们将每个学生的平均分和最后一次考试的成绩进行组合。注意,使用 map()(示例中的最后两条指令)来重现 zip() 是多么简单。再次,我们必须使用 list() 来可视化结果。

map() 类似,zip() 通常会在到达最短可迭代对象的末尾时停止。然而,这可能会掩盖输入数据的问题,导致错误。例如,假设我们需要将学生名单和成绩列表合并到一个字典中,将每个学生的名字映射到他们的成绩。数据输入错误可能会导致成绩列表比学生名单短。以下是一个例子:

# zip.strict.txt
>>> students = ["Sophie", "Alex", "Charlie", "Alice"]
>>> grades = ["A", "C", "B"]
>>> dict(zip(students, grades))
{'Sophie': 'A', 'Alex': 'C', 'Charlie': 'B'} 

注意,字典中没有 "Alice" 的条目。zip() 的默认行为掩盖了数据错误。因此,在 Python 3.10 中添加了仅关键字参数 strict。如果 zip() 接收到 strict=True 作为参数,当可迭代对象长度不相同时,它会引发异常:

>>> dict(zip(students, grades, strict=True))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: zip() argument 2 is shorter than argument 1 

itertools 模块还提供了一个 zip_longest() 函数。它的行为类似于 zip(),但只有在最长的可迭代对象耗尽时才会停止。较短的迭代器将被指定为参数的值填充,默认为 None

filter

根据 Python 文档(docs.python.org/3/library/functions.html#filter),以下适用:

filter(function, iterable)

从可迭代对象中构建一个迭代器,该迭代器对于函数为真。可迭代对象可以是序列、支持迭代的容器或迭代器。如果函数是 None,则假定是恒等函数,即移除可迭代对象中所有为假的元素。

让我们看看一个快速示例:

# filter.txt
>>> 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 在特定情况下的行为。

有了 map()zip()filter()(以及 Python 标准库中的几个其他函数),我们可以非常有效地操作序列。但这些都是实现方式之一。让我们看看 Python 最强大的功能之一:理解

理解

理解是一种对一组对象中的每个元素执行某些操作或选择满足某些条件的元素子集的简洁表示。它们借鉴了函数式编程语言 Haskell (www.haskell.org/),并与迭代器和生成器一起,为 Python 增添了函数式风格。

Python 提供了多种类型的理解:列表、字典和集合。我们将专注于列表理解;一旦你理解了它们,其他类型就会很容易掌握。

让我们从简单的例子开始。我们想要计算一个包含前 10 个自然数的平方的列表。我们可以使用 for 循环并在每次迭代中将一个平方数追加到列表中:

# squares.for.txt
>>> squares = []
>>> for n in range(10):
...     squares.append(n**2)
...
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 

这不是很优雅,因为我们必须首先初始化列表。使用 map(),我们可以在一行代码中实现相同的功能:

# squares.map.txt
>>> squares = list(map(lambda n: n**2, range(10)))
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 

现在,让我们看看如何使用列表理解达到相同的结果:

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

这更容易阅读,我们不再需要使用 lambda。我们已经在方括号内放置了一个 for 循环。现在让我们过滤掉奇数平方。我们首先会展示如何使用 map()filter() 来实现,然后再使用列表理解:

# even.squares.py
# using map and filter
sq1 = list(
    map(lambda n: n**2, filter(lambda n: not n % 2, range(10)))
)
# equivalent, but using list comprehensions
sq2 = [n**2 for n in range(10) if not n % 2]
print(sq1, sq1 == sq2)  # prints: [0, 4, 16, 36, 64] True 

我们认为,现在可读性的差异已经很明显。列表理解读起来更顺畅。它几乎就像英语:如果 n 是偶数,请给出 0 到 9 之间所有平方数(n**2)。

根据 Python 文档 (docs.python.org/3.12/tutorial/datastructures.html#list-comprehensions),以下是真的:

列表理解由包含一个表达式、后跟一个 for 子句、然后是零个或多个 forif 子句的括号组成。结果将是一个新列表,该列表是在评估随后的 forif 子句的上下文中的表达式后生成的。

嵌套列表推导

让我们看看嵌套循环的一个例子。这相当常见,因为许多算法都涉及使用两个占位符迭代一个序列。第一个占位符从左到右遍历整个序列。第二个占位符也这样做,但它从第一个占位符开始,而不是从 0 开始。这个概念是测试所有对而不重复。让我们看看经典的for循环等效:

# pairs.for.loop.py
items = "ABCD"
pairs = []
for a in range(len(items)):
    for b in range(a, len(items)):
        pairs.append((items[a], items[b])) 

如果您在最后打印pairs,您将得到以下内容:

$ python pairs.for.loop.py
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'B'), ('B', 'C'), ('B', 'D'), ('C', 'C'), ('C', 'D'), ('D', 'D')] 

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

# pairs.list.comprehension.py
items = "ABCD"
pairs = [
    (items[a], items[b])    
    for a in range(len(items))
    for b in range(a, len(items))
] 

注意,因为for循环中的b依赖于a,所以在推导中它必须位于afor循环之后。如果您交换它们的位置,您将得到一个命名错误。

实现相同结果的另一种方法是使用itertools模块中的combinations_with_replacement()函数(我们在第三章条件与迭代中简要介绍了它)。您可以在官方 Python 文档中了解更多信息。

推导的过滤

我们也可以将过滤应用于推导。让我们首先使用filter(),找到所有短边小于 10 的毕达哥拉斯三元组。毕达哥拉斯三元组是一组满足方程a² + b² = c²的整数数的三元组(a, b, c)

我们显然不希望测试组合两次,因此,我们将使用与上一个示例中看到类似的技巧:

# pythagorean.triple.py
from math import sqrt
# this will generate all possible pairs
mx = 10
triples = [
    (a, b, sqrt(a**2 + b**2))
    for a in range(1, mx)
    for b in range(a, mx)
]
# this will filter out all non-Pythagorean triples
triples = list(
    filter(lambda triple: triple[2].is_integer(), triples)
)
print(triples)  # prints: [(3, 4, 5.0), (6, 8, 10.0)] 

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

在生成所有triples之后,我们需要过滤掉那些斜边不是整数数的所有情况。为了实现这一点,我们根据float_number.is_integer()True进行过滤。这意味着在我们刚刚向您展示的两个示例元组中,斜边为5.0的那个将被保留,而斜边为8.602325267042627的那个将被丢弃。

这很好,但我们不喜欢三元组中有两个整数数和一个浮点数——它们都应该都是整数。我们可以使用map()来解决这个问题:

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

注意我们添加的步骤。我们切片triples中的每个元素,只取前两个元素。然后,我们将切片与包含我们不喜欢的那浮点数的整数版本的单一元组连接起来。这段代码变得越来越复杂。我们可以用更简单的列表推导来实现相同的结果:

# pythagorean.triple.comprehension.py
from math import sqrt
# this step is the same as before
mx = 10
triples = [
    (a, b, sqrt(a**2 + b**2))
    for a in range(1, mx)
    for b in range(a, mx)
]
# here we combine filter and map in one CLEAN list comprehension
triples = [
    (a, b, int(c)) for a, b, c in triples if c.is_integer()
]
print(triples)  # prints: [(3, 4, 5), (6, 8, 10)] 

这样更简洁、更易读、更短。尽管如此,仍有改进的空间。我们仍然在构建一个包含许多最终会被丢弃的三元组的列表中浪费内存。我们可以通过将两个推导式合并为一个来解决这个问题:

# pythagorean.triple.walrus.py
from math import sqrt
# this step is the same as before
mx = 10
# We can combine generating and filtering in one comprehension
triples = [
    (a, b, int(c))
    for a in range(1, mx)
    for b in range(a, mx)
    if (**c := sqrt(a******2** **+ b******2****)**).is_integer()
]
print(triples)  # prints: [(3, 4, 5), (6, 8, 10)] 

现在这是优雅的。通过在同一个列表推导式中生成三元组和过滤它们,我们避免了在内存中保留任何未通过测试的三元组。注意,我们使用了一个赋值表达式来避免需要两次计算sqrt(a**2 + b**2)的值。

字典推导式

字典推导式与列表推导式的工作方式完全相同,但用于构建字典。在语法上只有细微的差别。以下示例足以解释你需要知道的一切:

# dictionary.comprehensions.py
from string import ascii_lowercase
lettermap = {c: k for k, c in enumerate(ascii_lowercase, 1)} 

如果你打印lettermap,你会看到以下内容:

$ python dictionary.comprehensions.py
{'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8,
'i': 9, 'j': 10, 'k': 11, 'l': 12, 'm': 13, 'n': 14, 'o': 15,
'p': 16, 'q': 17, 'r': 18, 's': 19, 't': 20, 'u': 21, 'v': 22,
'w': 23, 'x': 24, 'y': 25, 'z': 26} 

在前面的代码中,我们正在枚举所有小写 ASCII 字母的序列(使用enumerate函数)。然后我们构建一个字典,将结果字母/数字对作为键和值。注意语法与熟悉的字典语法相似。

还有另一种做同样事情的方法:

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

在这种情况下,我们向dict构造函数提供了一个生成器表达式(我们将在本章后面更多地讨论这些内容)。

字典不允许重复键,如下例所示:

# dictionary.comprehensions.duplicates.py
word = "Hello"
swaps = {c: c.swapcase() for c in word}
print(swaps)  # prints: {'H': 'h', 'e': 'E', 'l': 'L', 'o': 'O'} 

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

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

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

集合推导式

集合推导式与列表和字典推导式类似。让我们看一个快速示例:

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

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

创建letters1使用的语法与字典推导式类似。你只能通过以下事实来发现差异:字典需要键和值,通过冒号分隔,而集合不需要。对于letters2,我们向set()构造函数提供了一个生成器表达式。

生成器

生成器基于我们之前提到的迭代概念,并允许结合优雅与效率的编码模式。

生成器有两种类型:

  • 生成器函数:这些与常规函数类似,但它们不是通过return语句返回结果,而是使用yield,这允许它们在每次调用之间挂起和恢复其状态。

  • 生成器表达式:这些与我们在本章中看到的列表推导式类似,但它们返回的对象会逐个产生结果。

生成器函数

生成器函数在所有方面都表现得像常规函数,只有一个区别:它们不是一次性收集结果并返回,而是自动转换为迭代器,一次产生一个结果。

假设我们要求你从 1 数到 1,000,000。你开始数,在某个时刻,我们要求你停下来。过了一段时间,我们要求你继续。只要你记得你最后到达的数字,你就能从你离开的地方继续。例如,如果我们在你数到 31,415 后停下来,你就可以从 31,416 继续数下去。关键是,你不需要记住你之前说的所有数字,也不需要将它们写下来。生成器的行为与此非常相似。

仔细看看下面的代码:

# 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 do not return
print(list(get_squares_gen(10))) 

两个print语句的结果将是相同的:[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]。但这两个函数之间有一个重要的区别。get_squares()是一个经典函数,它将[0, n)区间内所有数字的平方收集到一个列表中,并返回它。另一方面,get_squares_gen()是一个生成器,其行为不同。每次解释器到达yield行时,它的执行就会暂停。那些print语句返回相同结果的原因仅仅是因为我们将get_squares_gen()传递给了list()构造函数,它通过请求下一个元素直到抛出StopIteration异常来完全耗尽生成器。让我们详细看看这一点:

# first.n.squares.manual.py
def get_squares_gen(n):
    for x in range(n):
        yield x**2
squares = get_squares_gen(4)  # this creates a generator object
print(squares)  # <generator object get_squares_gen at 0x10dd...>
print(next(squares))  # prints: 0
print(next(squares))  # prints: 1
print(next(squares))  # prints: 4
print(next(squares))  # prints: 9
# the following raises StopIteration, the generator is exhausted,
# any further call to next will keep raising StopIteration
print(next(squares)) 

每次我们对生成器对象调用next()时,我们要么启动它(第一次next()),要么从最后一个暂停点恢复(任何其他next())。我们第一次调用next()时,得到 0,这是 0 的平方,然后是 1,然后是 4,然后是 9,由于for循环在那之后停止(n是 4),生成器自然结束。在这一点上,一个经典函数会简单地返回None,但为了遵守迭代协议,生成器会抛出一个StopIteration异常。

这解释了for循环的工作原理。当你调用for k in range(n)时,幕后发生的事情是for循环从range(n)中获取一个迭代器,并开始调用它的next方法,直到抛出StopIteration异常,这告诉for循环迭代已达到其结束。

将这种行为内置到 Python 的每个迭代方面,使得生成器更加强大,因为一旦我们编写了它们,我们就能将它们插入到我们想要的任何迭代机制中。

到目前为止,你可能正在问自己,为什么你想使用生成器而不是常规函数。答案是节省时间和(尤其是)内存。

我们将在稍后讨论性能,但现在,让我们集中在一个方面:有时,生成器允许你做一些用简单列表不可能做到的事情。例如,假设你想分析一个序列的所有排列。如果序列的长度为N,那么它的排列数是N!。这意味着如果序列有 10 个元素,排列数是 3,628,800。但 20 个元素的序列将有 2,432,902,008,176,640,000 个排列。它们以阶乘的方式增长。

现在想象一下,你有一个经典函数,试图计算所有排列,将它们放入列表中,然后返回给你。对于 10 个元素,可能只需要几秒钟,但对于 20 个元素,根本无法完成(它可能需要数千年,并需要数十亿千兆字节的内存)。

另一方面,生成器函数能够开始计算,并给你返回第一个排列,然后是第二个,以此类推。当然,你可能没有时间处理它们全部——因为太多了——但至少你将能够处理其中的一些。有时,你必须迭代的数量数据如此巨大,以至于你不能将它们全部保存在列表中。在这种情况下,生成器是无价的:它们使得那些在其他情况下不可能的事情成为可能。

因此,为了节省内存(和时间),尽可能使用生成器函数。

还值得注意的是,你可以在生成器函数中使用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) 

上述代码生成了几何级数的所有项,a, aq, aq2, aq3, ...。当级数产生一个大于 100,000 的项时,生成器停止(使用return语句)。运行代码会产生以下结果:

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

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

超越next

生成器对象有方法可以让我们控制它们的行为:send()throw()close()send()方法允许我们向生成器对象发送一个值,而throw()close()分别允许我们在生成器内部引发异常并关闭它。它们的使用相当高级,我们在这里不会详细讨论,但我们要简单谈谈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 

前面的迭代器创建了一个会无限运行的生成器对象。你可以不断调用它,它永远不会停止。但如果你想在某个时刻停止它呢?一个解决方案是使用全局变量来控制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 循环将退出,接下来的 next 调用将引发一个 StopIteration 异常。这个技巧是可行的,但不是一个令人满意的解决方案。函数依赖于外部变量,这可能导致问题。例如,如果另一个无关的函数更改了全局变量,生成器可能会意外停止。函数理想上应该是自包含的,不应依赖于全局状态。

生成器的 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 16, in <module>
    print(c.send("Q")) # F
          ^^^^^^^^^^^
StopIteration 

我们认为逐行分析这段代码是值得的,就像我们正在执行它一样,以了解正在发生什么。

我们通过调用 next()#C)开始生成器的执行。在生成器内部,n 被设置为与 start 相同的值。进入 while 循环,执行停止(#A),并将 n0)返回给调用者。0 在控制台上打印。

然后我们调用 send()#D),执行继续,result 被设置为 "Wow!"(仍然是 #A),其类型和值再次在控制台上打印(#B)。result 不是 "Q",所以 n 增加 1,执行回到循环的顶部。while 条件为 True,因此开始另一个循环迭代。执行再次在 #A 处停止,并将 n1)返回给调用者。1 在控制台上打印。

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

现在我们再次调用 send#F),这次传递参数 "Q"。生成器继续执行,result 被设置为 "Q"#A),其类型和值再次在控制台上打印(#B)。当我们再次到达 if 语句时,result == "Q" 评估为 Truewhile 循环通过 break 语句停止。生成器自然终止,这意味着引发了一个 StopIteration 异常。您可以在控制台打印的最后几行中看到异常的跟踪信息。

这一点在最初并不容易理解,所以如果您对此感到困惑,请不要气馁。您可以继续阅读,稍后再回到这个例子。

使用send()允许有趣的模式,并且值得注意的是,send()也可以用来启动生成器的执行(只要你用None调用它)。

yield from表达式

另一个有趣的构造是yield from表达式。这个表达式允许你从子迭代器产生值。它的使用允许相当高级的模式,所以让我们快速看看它的一个例子:

# gen.yield.for.py
def print_squares(start, end):
    for n in range(start, end):
        yield n**2
for n in print_squares(2, 5):
    print(n) 

上面的代码在控制台(单独的行)上打印了数字4916。到现在为止,我们希望你自己能够理解它,但让我们快速回顾一下发生了什么。函数外部的for循环从print_squares(2, 5)获取一个迭代器,并对其调用next()直到迭代结束。每次调用生成器时,执行会在yield n**2处暂停(稍后恢复),这返回了当前n的平方。让我们看看我们如何使用yield from表达式来实现相同的结果:

# gen.yield.from.py
def print_squares(start, end):
    yield from (n**2 for n in range(start, end))
for n in print_squares(2, 5):
    print(n) 

这段代码产生了相同的结果,但正如你所见,yield from实际上是在运行一个子迭代器(n**2 ...)yield from表达式返回子迭代器产生的每个值。它更短,读起来更好。

生成器表达式

除了生成器函数之外,生成器还可以使用生成器表达式来创建。创建生成器表达式的语法与列表推导式相同,只是我们使用圆括号而不是方括号。

生成器表达式将生成与等效列表推导式相同的值序列。然而,生成器不会立即在内存中创建包含整个序列的列表对象,而是逐个产生值。重要的是要记住,你只能迭代生成器一次。之后,它将耗尽。

让我们看看一个例子:

# generator.expressions.txt
>>> 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 0x7f08b2004860>
>>> 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()构造函数,这正是我们所做的。

注意,一旦生成器耗尽,就无法再次从中恢复相同的元素。如果我们想从头开始再次使用它,我们需要重新创建它。

在接下来的几个例子中,让我们看看如何使用生成器表达式来重现map()filter()。首先,让我们看看map()

# gen.map.py
def adder(*n):
    return sum(n)
s1 = sum(map(adder, 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, ...)。我们发现生成器表达式语法更容易阅读。

现在来看filter()

# 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, n3),其中n是 3 或 5 的倍数。如果你打印从任一迭代器获得的值列表,你会得到以下结果:[(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()。这既浪费了时间又浪费了内存,因为我们首先创建了一个包含一百万个元素的列表(必须存储在内存中)。然后我们将列表传递给sum,它遍历这个列表,之后我们丢弃这个列表。使用生成器表达式会更好,因为我们不需要等待列表构建完成,也不需要将一百万个值的整个序列存储在内存中。

因此,在编写表达式时要小心额外的括号。这样的细节很容易忽略,但它们可以产生重大差异。例如,看看下面的代码:

# sum.example.2.py
s = sum([n**2 for n in range(10**10)])  # this is killed
# s = sum(n**2 for n in range(10**10))  # this succeeds
print(s)  # prints: 333333333283333333335000000000 

如果我们运行这段代码,我们会得到:

$ python sum.example.2.py
Killed 

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

$ python sum.example.2.py
333333333283333333335000000000 

这两行代码之间的区别在于,在第一行中,Python 解释器必须构建一个包含前十亿个数的平方的列表,以传递给sum函数。这个列表非常大,所以我们耗尽了内存,操作系统杀死了进程。

当我们移除方括号时,我们不再有一个列表。sum函数接收一个生成器,它产生 0、1、4、9 等,并计算总和,而不需要将所有值都保留在内存中。

一些性能考虑

通常有几种方法可以达到相同的结果。我们可以使用map()zip()filter()的任何组合,或者选择使用推导式或生成器。我们甚至可以决定使用for循环。在决定这些方法之间的选择时,可读性通常是一个因素。列表推导式或生成器表达式通常比复杂的map()filter()组合更容易阅读。对于更复杂的操作,生成器函数或for循环通常更好。

然而,除了可读性方面的考虑,我们在决定使用哪种方法时还必须考虑性能。在比较不同实现性能时需要考虑两个因素:空间时间

空间指的是你的数据结构将要使用的内存量。最好的选择是问问自己你是否真的需要一个列表(或元组),或者是否可以使用生成器。

如果对后者的回答是肯定的,就选择生成器,因为它会节省大量空间。对于函数也是如此:如果你实际上不需要它们返回列表或元组,那么你也可以将它们转换为生成器函数。

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

时间比空间复杂一些,因为它依赖于更多的变量,并且对于所有情况,我们并不总是能够绝对肯定地说“X 比 Y 快”。然而,基于今天在 Python 上运行的测试,我们可以这样说,平均而言,map()的性能与推导式和生成器表达式相似,而for循环则始终较慢。

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

Python 有几种不同的实现方式。最初的一个,也是目前最常见的一个,是 CPython(github.com/python/cpython),它是用 C 语言编写的。C 语言是今天仍在使用的最强大和最受欢迎的编程语言之一。

在本节的剩余部分,我们将进行一些简单的实验来验证这些性能主张。我们将编写一小段代码,收集整数对(a, b)divmod(a, b)的结果。我们将使用time模块中的time()函数来计算我们执行的操作的经过时间:

# performance.py
from time import time
mx = 5000
t = time()  # start time for the for loop
floop = []
for a in range(1, mx):
    for b in range(a, mx):
        floop.append(divmod(a, b))
print("for loop: {:.4f} s".format(time() - t))  # elapsed time
t = time()  # start time for the list comprehension
compr = [divmod(a, b) for a in range(1, mx) for b in range(a, mx)]
print("list comprehension: {:.4f} s".format(time() - t))
t = time()  # start time for the generator expression
gener = list(
    divmod(a, b) for a in range(1, mx) for b in range(a, mx)
)
print("generator expression: {:.4f} s".format(time() - t)) 

如你所见,我们正在创建三个列表:floopcomprgener。运行代码会产生以下结果:

$ python performance.py
for loop: 2.3832 s
list comprehension: 1.6882 s
generator expression: 1.6525 s 

列表推导式的执行时间大约占for循环时间的 71%。生成器表达式的执行速度略快,大约为 69%。列表推导式和生成器表达式之间的时间差异几乎不显著,如果你多次重新运行示例,你可能会看到列表推导式比生成器表达式用时更少。

值得注意的是,在for循环的主体中,我们正在向列表中追加数据。这意味着在幕后,Python 解释器偶尔需要调整列表的大小,为追加更多项分配空间。我们猜测,创建一个零列表,并简单地填充结果,可能会加快for循环的速度,但我们错了。自己试试看;你只需要mx * (mx - 1) // 2个元素预先分配。

我们在这里用于计时执行的方法相当天真。在第十一章调试和性能分析中,我们将探讨更好的代码性能分析和计时方法。

让我们看看一个类似的例子,比较for循环和map()调用:

# performance.map.py
from time import time
mx = 2 * 10**7
t = time()
absloop = []
for n in range(mx):
    absloop.append(abs(n))
print("for loop: {:.4f} s".format(time() - t))
t = time()
abslist = [abs(n) for n in range(mx)]
print("list comprehension: {:.4f} s".format(time() - t))
t = time()
absmap = list(map(abs, range(mx)))
print("map: {:.4f} s".format(time() - t)) 

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

$ python performance.map.py
for loop: 1.9009 s
list comprehension: 1.0973 s
map: 0.5862 s 

这次,map是最快的:它所需的时间是列表推导式的约 53%,是for循环所需时间的约 31%。

这些实验的结果给我们提供了一个关于for循环、列表推导式、生成器表达式和map()函数相对速度的大致指示。然而,不要过分依赖这些结果,因为我们在这里进行的实验相当简单,准确测量和比较执行时间是很困难的。测量很容易受到多个因素的影响,例如在同一台计算机上运行的其它进程。性能结果也严重依赖于硬件、操作系统和 Python 版本。

很明显,for循环比列表推导式或map()函数慢,所以讨论为什么我们仍然经常选择它们而不是替代方案是值得的。

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

我们已经看到了列表推导式和生成器表达式有多么强大。然而,我们发现,你在一个单一的列表推导式或生成器表达式中尝试做的事情越多,就越难阅读、理解和维护或更改。

如果你再次考虑 Python 的禅意,有几行代码我们认为在处理优化代码时值得记住:

>>> import this
...
Explicit is better than implicit.
Simple is better than complex.
...
Readability counts.
...
If the implementation is hard to explain, it's a bad idea.
... 

列表推导式和生成器表达式比显式表达式更隐晦,可能相当难以阅读和理解,也可能难以解释。有时,你必须使用从内到外的技术将其分解,才能理解正在发生的事情。

为了给你一个例子,让我们再详细谈谈毕达哥拉斯三元组。只是为了提醒你,毕达哥拉斯三元组是一个正整数元组 (a, b, c),其中 a² + b² = c²。我们在过滤列表推导式部分看到了如何计算它们,但我们以一种非常低效的方式做了这件事。我们扫描了低于某个阈值的所有数字对,计算斜边,并过滤掉那些不是有效的毕达哥拉斯三元组的数字对。

获取毕达哥拉斯三元组的更好方法是直接生成它们。你可以使用许多不同的公式来做这件事;在这里,我们将使用 欧几里得公式。这个公式表明,任何三元组 (a, b, c),其中 a = m ² - n ²b = 2mnc = m* ² + n ²,mn 是满足 m > n 的正整数,都是一个毕达哥拉斯三元组。例如,当 m = 2n = 1 时,我们找到最小的三元组:(3, 4, 5)

但是有一个问题:考虑三元组 (6, 8, 10),它类似于 (3, 4, 5),只是所有的数字都乘以了 2。这个三元组是毕达哥拉斯三元组,因为 6 ² + 8 ² = 10 ²,但我们可以通过将它的每个元素乘以 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 的原始毕达哥拉斯三元组。这意味着我们想要所有满足 m ² + n ² ≤ N 的三元组。当 n1 时,公式看起来是这样的:m ² ≤ N - 1,这意味着我们可以用 m ≤ N ^(1/2) 的上界来近似计算。

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

实数 xfloor 函数给出最大的整数 n,使得 n < x,例如,floor(3.8) = 3floor(13.1) = 13。取 floor(sqrt(N)) + 1 的意思是取 N 的平方根的整数部分,并加上一个最小的边距,以确保我们不会错过任何数字。

让我们一步步将这些内容放入代码中。我们首先编写一个简单的 gcd() 函数,它使用 欧几里得算法

# functions.py
def gcd(a, b):
    """Calculate the Greatest Common Divisor of (a, b)."""
    while b != 0:
        a, b = b, a % b
    return a 

欧几里得算法的解释可以在网上找到,所以我们不会在这里花费时间讨论它,因为我们需要专注于生成器表达式。下一步是使用我们之前收集的知识来生成一个原始毕达哥拉斯三元组的列表:

# pythagorean.triple.generation.py
from functions import gcd
N = 50
triples = sorted(  # 1
    (
        (a, b, c) for a, b, c in (  # 2
            ((m**2 - n**2), (2 * m * n), (m**2 + n**2))  # 3
            for m in range(1, int(N**.5) + 1)  # 4
            for n in range(1, m)  # 5
            if (m - n) % 2 and gcd(m, n) == 1  # 6
        )
        if c <= N  # 7
    ),
    key=sum  # 8
) 

这段代码不易阅读,让我们逐行分析。在#3行,我们开始一个生成器表达式,创建三元组。你可以从#4#5看到,我们在*[1, M]*上循环m,其中M*sqrt(N)*的整数部分,加上*1*。另一方面,n*[1, m)*范围内循环,以遵守*m > n*规则。值得注意的是我们如何计算*sqrt(N)*,即N**.5,这是我们想展示的另一种方法。

#6处,你可以看到用于使三元组成为原始的筛选条件:(m - n) % 2(m - n)为奇数时评估为True,而gcd(m, n) == 1意味着mn是互质的。有了这些条件,我们知道三元组将是原始的。这解决了最内层的生成器表达式。最外层的生成器表达式从#2开始,到#7结束。我们取三元组(a, b, c),其中c <= N,来自(...innermost generator...)

最后,在#1处,我们应用排序来按顺序展示列表。在#8处,在外层生成器表达式关闭后,你可以看到我们指定排序键为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=sum)  # 9 

这段代码更容易阅读。让我们逐行分析。你会发现它也更容易理解。

我们从#1#2开始循环,范围与上一个示例相同。在#3行,我们筛选原始三元组。在#4行,我们稍微偏离了之前的行为:我们计算c,在#5行,我们筛选c小于或等于N。我们只计算ab,如果c满足该条件,则产生结果元组。我们本可以在更早的时候计算ab的值,但通过推迟到我们知道所有有效三元组的条件都满足,我们避免了浪费时间和 CPU 周期。在最后一行,我们使用与生成器表达式示例中相同的键进行排序。

我们希望你会同意这个示例更容易理解。如果我们需要修改代码,这将更容易,并且与生成器表达式相比,工作起来更不容易出错。

如果你打印出这两个示例的结果,你会得到以下内容:

[(3, 4, 5), (5, 12, 13), (15, 8, 17), (7, 24, 25), (21, 20, 29), (35, 12, 37), (9, 40, 41)] 

在性能和可读性之间往往存在权衡,而且并不总是容易找到平衡点。我们的建议是尽可能使用列表推导和生成器表达式。但如果代码开始变得难以修改或难以阅读或解释,你可能想要将其重构为更易读的形式。

名称本地化

现在我们已经熟悉了所有类型的推导式和生成器表达式,让我们来谈谈它们内部的名称本地化。Python 3 在所有四种推导式形式中本地化循环变量:列表、字典、集合和生成器表达式。这种行为与 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 = {A: 2 * A for A in range(5)}
print(A)  # prints: 100
ex4 = {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,但它们都没有改变全局名称 A。另一方面,最后的 for 循环确实修改了全局的 A。最后的 print 语句打印了 4。

让我们看看如果全局的 A 不存在会发生什么:

# scopes.noglobal.py
ex1 = [A for A in range(5)]
print(A)  # breaks: NameError: name 'A' is not defined 

前面的代码在处理任何其他类型的理解或生成器表达式时都会以相同的方式工作。运行第一行后,A 在全局命名空间中未定义。再次,for 循环的行为不同:

# scopes.for.py
s = 0
for A in range(5):
    s += A
print(A) # prints: 4
print(globals()) 

前面的代码表明,在 for 循环之后,如果循环变量在它之前未定义,我们可以在全局命名空间中找到它。我们可以通过检查 globals() 内置函数返回的字典来验证这一点:

$ python scopes.for.py
4
{'__name__': '__main__', '__doc__': None, ..., 's': 10, 'A': 4} 

除了各种内置的全局名称(我们在此没有重复),我们还看到了 'A': 4

内置函数的生成器行为

生成器行为在内置类型和函数中相当常见。这是 Python 2 和 Python 3 之间的一个主要区别。在 Python 2 中,map()zip()filter() 等函数返回列表而不是可迭代对象。这种变化背后的想法是,如果你需要创建一个包含这些结果的列表,你总是可以用 list() 类来包装调用。另一方面,如果你只需要迭代并且希望尽可能减少内存影响,你可以安全地使用这些函数。另一个值得注意的例子是 range() 函数。在 Python 2 中,它返回一个列表,还有一个名为 xrange() 的函数,其行为与 Python 3 中 range() 函数的行为相似。

函数和方法返回可迭代对象的想法相当普遍。你可以在 open() 函数中找到它,该函数用于操作文件对象(我们将在 第八章文件和数据持久性 中看到它),也可以在 enumerate()、字典的 keys()values()items() 方法以及几个其他地方找到。

这一切都有道理:Python 旨在通过尽可能避免浪费空间来减少内存占用,尤其是在那些在大多数情况下广泛使用的函数和方法中。

在本章的开头,我们说过,优化必须处理大量对象代码的性能比从每天调用两次的函数中节省几毫秒更有意义。这正是 Python 本身在这里所做的事情。

最后一个例子

在我们完成这一章之前,我们将向你展示一个简单的问题,Fabrizio 曾经用它来测试应聘他曾经工作过的公司 Python 开发者职位的候选人。

问题如下:编写一个函数,返回数列 0 1 1 2 3 5 8 13 21 ... 的项,直到某个限制 N

如果你还没有认出它,那就是斐波那契数列,它被定义为 F(0) = 0, F(1) = 1 ,并且对于任何 n > 1F(n) = F(n-1) + F(n-2) 。这个数列非常适合测试关于递归、记忆化技术以及其他技术细节的知识,但在这个情况下,这是一个检查候选人是否了解生成器的好机会。

让我们从最基础版本开始,然后对其进行改进:

# fibonacci.first.py
def fibonacci(N):
    """Return all fibonacci numbers up to N."""
    result = [0]
    next_n = 1
    while next_n <= N:
        result.append(next_n)
        next_n = sum(result[-2:])
    return result
print(fibonacci(0))  # [0]
print(fibonacci(1))  # [0, 1, 1]
print(fibonacci(50))  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] 

从顶部开始:我们将 result 列表设置为起始值 [0]。然后我们从下一个元素(next_n)开始迭代,它是 1。当下一个元素不大于 N 时,我们继续将其追加到列表中,并计算序列中的下一个值。我们通过从 result 列表中的最后两个元素中取一个切片并将其传递给 sum 函数来计算下一个元素。

如果你难以理解代码,添加一些 print() 语句可能会有所帮助,这样你就可以看到在执行过程中值是如何变化的。

当循环条件评估为 False 时,我们退出循环并返回 result。你可以看到每个 print 语句旁边的注释中的结果。

到这一点,Fabrizio 会问候选人以下问题:如果我只想迭代这些数字怎么办? 一个好的候选人会相应地更改代码如下:

# 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,然后,如果 N 是 0,我们 return(这将引发 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)如何有助于使代码更短、更易读。

摘要

在这一章中,我们更深入地探讨了迭代和生成的概念。我们详细研究了 map()zip()filter() 函数,并学习了如何将它们用作常规 for 循环方法的替代方案。

然后,我们介绍了构建列表、字典和集合的推导式概念。我们探讨了它们的语法以及如何将它们用作经典for循环方法以及map()zip()filter()函数的替代方案。

最后,我们讨论了生成器的两种形式:生成器函数和表达式。我们学习了如何通过使用生成技术来节省时间和空间。我们还看到了原本用列表无法执行的操作,可以用生成器来完成。

我们讨论了性能,并看到在速度方面,for循环排在最后,但它们提供了最佳的可读性和灵活性,便于更改。另一方面,map()filter()等函数以及推导式可以更快。

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

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

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

加入我们的 Discord 社区

二维码

第六章:OOP、装饰器和迭代器

“La classe non è acqua.”(“类胜于水。”)

——意大利谚语

面向对象编程(OOP)是一个如此广泛的话题,以至于关于它的整本书都已经被写出来了。在本章中,我们面临着在广度和深度之间找到平衡的挑战。要讨论的事情实在太多了,如果深入描述,其中许多内容将需要超过整个章节的篇幅。因此,我们将尝试为您提供我们认为的良好全景式的基本概念,以及一些可能在下一章中派上用场的知识点。Python 的官方文档将帮助填补这些空白。

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

  • 装饰器

  • 使用 Python 进行 OOP

  • 迭代器

装饰器

第五章理解与生成器中,我们测量了各种表达式的执行时间。

如果您还记得,我们不得不捕获开始时间,并在执行后从当前时间中减去它来计算经过的时间。我们还必须在每次测量后在控制台上打印它。这很不实用。

每当我们发现自己重复某些事情时,应该响起一个警钟。我们能否将那段代码放入函数中,避免重复?大多数时候,答案是是的,所以让我们看看一个例子:

# decorators/time.measure.start.py
from time import sleep, time
def f():
    sleep(0.3)
def g():
    sleep(0.5)
t = time()
f()
print("f took:", time() - t)  # f took: 0.3028988838195801
t = time()
g()
print("g took:", time() - t)  # g took: 0.507941722869873 

在前面的代码中,我们定义了两个函数,f()g(),它们什么也不做,只是休眠(分别休眠 0.3 秒和 0.5 秒)。我们使用sleep()函数来暂停执行所需的时间。注意时间测量的准确性。现在,我们如何避免重复那段代码和那些计算呢?一个潜在的第一种方法可能是以下这样:

# decorators/time.measure.dry.py
from time import sleep, time
def f():
    sleep(0.3)
def g():
    sleep(0.5)
def measure(func):
    t = time()
    func()
    print(func.__name__, "took:", time() - t)
measure(f)  # f took: 0.3043971061706543
measure(g)  # g took: 0.5050859451293945 

好多了。整个计时机制都被封装在一个函数中,所以我们不需要重复代码。我们动态地打印函数名,代码也很直接。如果我们需要将任何参数传递给我们要测量的函数呢?这段代码将变得稍微复杂一些。让我们看看一个例子:

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

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

这很好,但我们还可以稍作改进。假设我们想要以某种方式将那种计时行为内置到f()函数中,使我们只需调用它就能进行测量。下面是我们可以这样做的示例:

# decorators/time.measure.deco1.py
from time import sleep, time
def f(sleep_time=0.1):
    sleep(sleep_time)
def measure(func):
    def wrapper(*args, **kwargs):
        t = time()
        func(*args, **kwargs)
        print(func.__name__, "took:", time() - t)
    return wrapper
f = measure(f)  # decoration point
f(0.2)  # f took: 0.20128178596496582
f(sleep_time=0.3)  # f took: 0.30509519577026367
print(f.__name__)  # wrapper  <- ouch! 

前面的代码并不那么简单明了。让我们看看这里发生了什么。魔法在于装饰点。当我们用f()作为参数调用measure()时,我们将f()重新赋值为measure()返回的任何内容。在measure()内部,我们定义了另一个函数wrapper(),然后返回它。因此,最终的效果是,在装饰点之后,当我们调用f()时,实际上是在调用wrapper()(你可以在代码的最后一行看到这一点)。由于wrapper()内部调用的是func(),在这个例子中是f()的引用,所以我们闭合了循环。

wrapper()函数,不出所料,是一个包装器。它接受可变的位置参数和关键字参数,并使用这些参数调用f()。它还在调用周围进行时间测量计算。

这种技术被称为装饰,而measure()实际上是一个装饰器。这种范式变得如此流行和广泛使用,以至于在 Python 2.4 版本中,Python 为其添加了特殊的语法。你可以在 PEP 318(peps.python.org/pep-0318/)中阅读具体细节。在 Python 3.0 中,我们看到了 PEP 3129(peps.python.org/pep-3129/),它定义了类装饰器。最后,在 Python 3.9 中,装饰器语法略有修改,以放宽一些语法限制;这一变化是在 PEP 614(peps.python.org/pep-0614/)中实现的。

现在,让我们探讨三种情况:一个装饰器、两个装饰器和接受参数的一个装饰器。首先,单个装饰器的情况:

# 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/time.measure.deco1.py
def measure(func):
    def wrapper(*args, **kwargs):
        …
    return wrapper
f = measure(f)  # decoration point
**print****(f.__name__)** **# wrapper  <- ouch!** 

我们不希望在装饰函数时丢失原始函数的名称和文档字符串。但是,因为被装饰的函数f被重新赋值为wrapper,它的原始属性丢失了,被wrapper()的属性所取代。functools模块中有一个简单的解决方案。我们将修复这个问题,并重写代码以使用@操作符:

# decorators/time.measure.deco2.py
from time import sleep, time
**from** **functools** **import** **wraps**
def measure(func):
    **@wraps(****func****)**
    def wrapper(*args, **kwargs):
        t = time()
        func(*args, **kwargs)
        print(func.__name__, "took:", time() - t)
    return wrapper
@measure
def f(sleep_time=0.1):
    """I'm a cat. I love to sleep!"""
    sleep(sleep_time)
f(sleep_time=0.3)  # f took: 0.30042004585266113
print(f.__name__)  # f
print(f.__doc__ )  # I'm a cat. I love to sleep! 

一切看起来都很正常。正如你所看到的,我们只需要告诉 Pythonwrapper实际上包装了func()(通过上面代码高亮部分的wraps()函数),你可以看到原始的名称和文档字符串都得到了保留。

关于func()重新分配的函数属性的全列表,请查看functools.update_wrapper()函数的官方文档,链接如下:docs.python.org/3/library/functools.html?#functools.update_wrapper

让我们再看另一个例子。我们想要一个装饰器,当函数的结果大于某个特定阈值时,它会打印出一个错误信息。我们也将借此机会向你展示如何同时应用两个装饰器:

# decorators/two.decorators.py
from time import 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(
                f"Result is too big ({result}). "
                "Max allowed is 100."
            )
        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: 9.5367431640625e-07
8
Result is too big (125). Max allowed is 100.
cube took: 3.0994415283203125e-06
125 

为了方便起见,我们用空行分隔了两次调用的结果。在第一次调用中,结果是 8,通过了阈值检查。测量并打印出运行时间。最后,我们打印出结果(8)。

在第二次调用时,结果是 125,因此打印出错误信息并返回结果;然后轮到measure(),它再次打印运行时间,最后我们打印出结果(125)。

如果我们用相同的两个装饰器但以不同的顺序装饰cube()函数,打印出的消息顺序也会不同。

装饰器工厂

一些装饰器可以接受参数。这种技术通常用于生成另一个装饰器(在这种情况下,该对象可以被称为装饰器工厂)。让我们看看其语法,然后我们将看到一个例子:

# decorators/syntax.py
def func(arg1, arg2, ...):
    pass
func = decoarg(arg_a, arg_b)(func)
# is equivalent to the following:
@decoarg(arg_a, arg_b)
def func(arg1, arg2, ...):
    pass 

如你所见,这个情况有点不同。首先,decoarg()用给定的参数被调用,然后它的返回值(实际的装饰器)用func()被调用。

让我们现在改进一下例子。我们将回到一个单独的装饰器: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(
                    f"Result is too big ({result})."
                    f"Max allowed is {threshold}."
                )
            return result
        return wrapper
    return decorator
@max_result(75)
def cube(n):
    return n**3 

上述代码展示了如何编写一个装饰器工厂。如果你还记得,使用带参数的装饰器装饰一个函数与编写func = decorator(argA, argB)(func)是相同的,所以当我们用max_result(75)装饰cube()时,我们实际上是在做cube = max_result(75)(cube)

让我们一步一步地看看发生了什么。当我们调用max_result(75)时,我们进入其主体。在max_result(75)函数内部定义了一个decorator()函数,它只接受一个函数作为其唯一参数。在该函数内部,我们找到了常见的装饰模式。我们定义了wrapper(),在其中我们检查原始函数调用的结果。这种方法的优点是从最内层开始,我们仍然可以引用func()threshold,这使我们能够动态地设置阈值。

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 中非常流行。它们被频繁使用,并且使代码更加简洁和优雅。

OOP

现在我们已经了解了装饰模式的基础,是时候探索面向对象编程了。我们将使用来自Kindler, E.; Krivy, I. (2011). Object-oriented simulation of systems with sophisticated control (International Journal of General Systems)的定义,并将其应用于 Python:

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

Python 完全支持这种范式。实际上,正如我们之前所说的,Python 中的一切都是对象,这表明面向对象编程(OOP)不仅被 Python 支持,而且是该语言的核心特性。

面向对象编程中的两个主要角色是对象。类用于创建对象,我们说对象是类的实例

如果你难以理解对象和类之间的区别,可以这样想。当你听到“笔”这个词时,你知道这个词所代表的对象类型(或类)是什么。然而,如果我们说“这支笔”,那么我们不是指一个对象类,而是指那个类的“实例”:一个真实对象。

当从类创建对象时,它们继承了类属性和方法。它们代表程序域中的具体项目。

最简单的 Python 类

我们将从你可以在 Python 中编写的最简单的类开始:

# oop/simplest.class.py
class Simplest:
    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) is Simplest)  # There's a better way to do this 

让我们运行前面的代码并逐行解释:

$ python simplest.class.py
<class 'type'>
<class '__main__.Simplest'>
True 

我们定义的 Simplest 类在其主体中只有 pass 指令,这意味着它没有自定义属性或方法。我们将打印其类型(__main__ 是顶层代码执行的命名空间名称),并且我们知道,在突出显示的注释中,我们写了 object 而不是 class 。正如你通过那个 print 语句的结果所看到的,类实际上也是对象本身。为了更精确,它们是 type 的实例。解释这个概念会引导我们进入关于 元类元编程 的讨论,这些是高级概念,需要牢固掌握基础知识才能理解,并且超出了本章的范围。像往常一样,我们提到它是为了给你留下一个线索,以便当你准备好更深入地探索时。

让我们回到例子:我们创建了 simp ,它是 Simplest 类的一个实例。你可以看到创建实例的语法与调用函数的语法相同。接下来,我们打印 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。当我们创建 Point 的一个实例 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,以表明它仍然是 10

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

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

你认为将 xy 坐标作为类属性怎么样?你认为这是一个好主意吗?如果我们创建了 Point 的另一个实例,这会帮助我们展示为什么需要实例属性吗?

self 参数

在类方法内部,我们可以通过一个特殊的参数来引用一个实例,这个参数传统上被称为 selfself 总是实例方法的第一个属性。让我们检查这种行为,以及我们如何不仅共享属性,还共享方法给所有实例:

# oop/class.self.py
class Square:
    side = 8
    def area(self):  # self is a reference to an instance
        return self.side**2
sq = Square()
print(sq.area())  # 64 (side is found on the class)
print(Square.area(sq))  # 64 (equivalent to sq.area())
sq.side = 10
print(sq.area())  # 100 (side is found on the instance) 

注意 area() 方法是如何被 sq 使用的。这两个调用,Square.area(sq)sq.area(),是等价的,并且它们教会我们机制是如何工作的。你可以将实例传递给方法调用(Square.area(sq)),在方法内部将名称 self,或者你可以使用更舒适的语法,sq.area(),Python 会为你幕后转换。

让我们看看更好的例子:

# oop/class.price.py
class Price:
    def final_price(self, vat, discount=0):
        """Returns price after applying vat and fixed discount."""
        return (self.net_price * (100 + vat) / 100) - discount
p1 = Price()
p1.net_price = 100
print(Price.final_price(p1, 20, 10))  # 110 (100 * 1.2 - 10)
print(p1.final_price(20, 10))  # equivalent 

上述代码显示,我们可以在声明方法时使用参数,没有任何阻止。我们可以使用与函数完全相同的语法,但我们需要记住,第一个参数将始终是方法将要绑定到的实例。我们不必一定称它为 self,但这是一个约定,这也是我们必须遵守的少数几个重要案例之一。

初始化实例

你有没有注意到,在上面的代码中在调用 p1.final_price() 之前,我们必须将 net_price 赋值给 p1?有更好的方法来做这件事。在其他语言中,这会被称为构造函数,但在 Python 中并不是这样。它实际上是一个初始化器,因为它作用于已经创建的实例,因此被称为 __init__()。它是一个魔法方法,在对象创建后立即运行。Python 对象还有一个 __new__() 方法,它是实际的构造函数。然而,在实践中,通常不需要重写它;这是一种主要用于编写元类的技术。现在让我们看看如何在 Python 中初始化对象的例子:

# oop/class.init.py
class Rectangle:
    def __init__(self, side_a, side_b):
        self.side_a = side_a
        self.side_b = side_b
    def area(self):
        return self.side_a * self.side_b
r1 = Rectangle(10, 4)
print(r1.side_a, r1.side_b)  # 10 4
print(r1.area())  # 40
r2 = Rectangle(7, 3)
print(r2.area())  # 21 

事情终于开始成形。当一个对象被创建时,__init__() 方法会自动为我们运行。在这种情况下,我们这样写,当我们创建一个 Rectangle 对象(通过像函数一样调用类名),我们向创建调用传递参数,就像在任何常规函数调用中一样。我们传递参数的方式遵循 __init__() 方法的签名,因此,在两个创建语句中,104 将是 r1side_aside_b,而 73 将是 r2side_aside_b。你可以看到,从 r1r2 调用 area() 反映出它们有不同的实例参数。以这种方式设置对象更方便。

面向对象编程(OOP)是关于代码复用

到现在为止,应该已经很清楚:面向对象编程(OOP)的全部都是关于代码复用。我们定义一个类,创建实例,这些实例可以使用在类中定义的方法。它们的行为将根据实例是如何通过初始化器设置的而有所不同。

继承和组合

然而,这仅仅是一半的故事;面向对象编程不仅仅是这个。我们有两种主要的设计结构可以使用:继承和组合。

继承意味着两个对象通过(Is-A)类型的关系相关联。另一方面,组合意味着两个对象通过(Has-A)关系相关联。让我们用一个例子来解释,其中我们声明了不同类型的引擎类:

# 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(
            f"Starting {self.engine.__class__.__name__} for "
            f"{self.__class__.__name__}... Wroom, wroom!"
        )
        self.engine.start()
    def stop(self):
        self.engine.stop()
class RaceCar(Car):  # Is-A Car
    engine_cls = V8Engine
class CityCar(Car):  # Is-A Car
    engine_cls = ElectricEngine
class F1Car(RaceCar):  # Is-A RaceCar and also Is-A Car
    pass  # engine_cls same as parent
car = Car()
racecar = RaceCar()
citycar = CityCar()
f1car = F1Car()
cars = [car, racecar, citycar, f1car]
for car in cars:
    car.start() 

运行上述代码会打印以下内容:

$ python class_inheritance.py
Starting Engine for Car... Wroom, wroom!
Starting V8Engine for RaceCar... Wroom, wroom!
Starting ElectricEngine for CityCar... Wroom, wroom!
Starting V8Engine for F1Car... Wroom, wroom! 

上述示例展示了(Is-A)和(Has-A)类型的关系。首先,让我们考虑Engine。它是一个简单的类,有两个方法,start()stop()。然后我们定义ElectricEngineV8Engine,它们都从它继承。你可以从它们的定义中看到这一点,定义中在名称后面括号内包含了Engine

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

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

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

这就是继承机制。

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

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

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

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

在使用面向对象编程(OOP)时,以这种方式描述对象非常重要,这样我们才能正确地组织我们的代码。

注意,我们不得不避免在class_inheritance.py脚本名称中使用点,因为模块名称中的点会使导入变得困难。本书的源代码中的大多数模块都旨在作为独立脚本运行,因此我们选择在可能的情况下添加点以增强可读性,但通常,你想要避免在模块名称中使用点。

在我们离开这个段落之前,让我们用另一个示例来验证我们上面所说的内容是否正确:

# oop/class.issubclass.isinstance.py
from class_inheritance import Car, RaceCar, F1Car
car = Car()
racecar = RaceCar()
f1car = F1Car()
cars = [(car, "car"), (racecar, "racecar"), (f1car, "f1car")]
car_classes = [Car, RaceCar, F1Car]
for car, car_name in cars:
    for class_ in car_classes:
        belongs = isinstance(car, class_)
        msg = "is a" if belongs else "is not a"
        print(car_name, msg, class_.__name__)
""" Prints:
… (starting enging messages omitted)
**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)的一个实例。同样,一个香蕉Banana的一个实例。但是,它也是一个水果。同样,它也是食物,对吧?同一个概念。要检查一个对象是否是某个类的实例,请使用isinstance()函数。它比单纯的类型比较(type(object) is 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,而函数和方法则使用 snake case,如this_way_is_correct。此外,如果你想在代码中使用一个与 Python 保留关键字或内置函数或类冲突的名称,惯例是在名称后添加一个尾随下划线。在第一个for 循环示例中,我们使用for class_ in ...遍历类名,因为class是一个保留字。你可以通过阅读 PEP 8 来刷新你对约定的了解。

为了帮助说明Is-AHas-A之间的区别,请看以下图表:

img

图 6.1:Is-A 与 Has-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 中。通常,我们希望基类中的更改反映在其子类中。让我们看看一种修复这个问题的方法:

# oop/super.explicit.py
class Book:
    def __init__(self, title, publisher, pages):
        self.title = title
        self.publisher = publisher
        self.pages = pages
class Ebook(Book):
    def __init__(self, title, publisher, pages, format_):
        Book.__init__(self, title, publisher, pages)
        self.format_ = format_
ebook = Ebook(
    "Learn Python Programming", "Packt Publishing", 500, "PDF"
)
print(ebook.title)  # Learn Python Programming
print(ebook.publisher)  # Packt Publishing
print(ebook.pages)  # 500
print(ebook.format_)  # PDF 

更好了。我们已经去除了代码重复。在这个例子中,我们告诉 Python 调用 Book 类的 __init__() 方法;我们将 self 传递给这个调用,确保我们将其绑定到当前实例。

如果我们修改 Book 类的 __init__() 方法中的逻辑,我们不需要触及 Ebook;这个更改将自动传递。

这种方法很好,但它仍然存在一个小问题。比如说,我们将 Book 的名字改为 Liber,这是“书”的拉丁语。那么我们就必须更改 Ebook__init__() 方法以反映这个更改。这可以通过使用 super 来避免:

# oop/super.implicit.py
class Book:
    def __init__(self, title, publisher, pages):
        self.title = title
        self.publisher = publisher
        self.pages = pages
class Ebook(Book):
    def __init__(self, title, publisher, pages, format_):
        super().__init__(title, publisher, pages)
        # Another way to do the same thing is:
        # super(Ebook, self).__init__(title, publisher, pages)
        self.format_ = format_
ebook = Ebook(
    "Learn Python Programming", "Packt Publishing", 500, "PDF"
)
print(ebook.title)  # Learn Python Programming
print(ebook.publisher)  # Packt Publishing
print(ebook.pages)  # 500
print(ebook.format_)  # PDF 

super() 是一个函数,它返回一个代理对象,该对象将方法调用委托给父类或兄弟类。

如果两个类有相同的父类,它们就是兄弟类。

在这种情况下,super() 将委托调用到 Book.__init__(),这种方法的优点是现在我们可以自由地将 Book 改为 Liber,而无需触及 Ebook__init__() 方法中的任何逻辑。

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

多重继承

在 Python 中,我们可以定义从多个基类继承的类。这被称为 多重继承。当一个类有多个基类时,属性搜索可以遵循多条路径。看看下面的图示:

img

图 6.2:类继承图

如你所见,ShapePlotter 作为所有其他类的基类。Polygon 直接从它们继承,RegularPolygonPolygon 继承,而 RegularHexagonSquare 都从 RegularPolygon 继承。还要注意,ShapePlotter 隐式地从 object 继承,所以从 Polygonobject,我们有一个所谓的 菱形。用更简单的话说,我们有超过一条路径可以到达基类。我们将在几分钟后看到这为什么很重要。让我们将这个图示转换成代码:

# 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**0.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 和两个方法,area()get_geometric_type()。使用基类(例如我们的例子中的 Shape)来定义一个 接口,即一组子类必须提供实现的函数,这是相当常见的。但是,我们目前想要使这个例子尽可能简单。

我们还有一个Plotter类,它添加了plot()方法,从而为继承自它的任何类提供了绘图能力。当然,在这个例子中,plot()的实现只是一个简单的print()。第一个有趣的类是Polygon,它从ShapePlotter类继承。

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

这个结构相当长,但希望它能给你一个如何专门化你的对象分类的想法。

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

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

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

方法解析顺序

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

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

MRO 是在查找成员时搜索基类的顺序。自 2.3 版本以来,Python 使用一个称为 C3 的算法,它保证了单调性。

让我们看看上一个例子中 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(),但如果你需要从一个实例访问 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,但我们认为至少在这个段落中提到它很重要,这样,如果你陷入复杂的混入结构中,你将能够找到出路。

类和静态方法

到目前为止,我们编写的类具有数据形式的属性和实例方法,但在类定义中我们还可以找到两种其他类型的方法:静态方法类方法

静态方法

当你创建一个类对象时,Python 会给它分配一个名称。这个名称充当命名空间,有时,将功能分组在其下是有意义的。静态方法非常适合这种用途。与实例方法不同,在调用时它们不需要传递实例。让我们来看一个例子:

# oop/static.methods.py
class StringUtil:
    @staticmethod
    def is_palindrome(s, case_insensitive=True):
        # we allow only letters and numbers
        s = "".join(c for c in s if c.isalnum())  # Study this!
        # For case insensitive comparison, we lower-case s
        if case_insensitive:
            s = s.lower()
        for c in range(len(s) // 2):
            if s[c] != s[-c - 1]:
                return False
        return True
    @staticmethod
    def get_unique_words(sentence):
        return set(sentence.split())
print(
    StringUtil.is_palindrome("Radar", case_insensitive=False)
)  # False: Case Sensitive
print(StringUtil.is_palindrome("A nut for a jar of tuna"))  # True
print(StringUtil.is_palindrome("Never Odd, Or Even!"))  # True
print(
    StringUtil.is_palindrome(
        "In Girum Imus Nocte Et Consumimur Igni"
    )  # Latin palindrome
)  # True
print(
    StringUtil.get_unique_words(
        "I love palindromes. I really really love them!"
    )
)
# {'them!', 'palindromes.', 'I', 'really', 'love'} 

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

我们有一个名为 StringUtil 的类,它充当函数的容器。另一种方法是在单独的模块中包含函数。这实际上是一个风格问题,大多数情况下都是如此。

到现在为止,is_palindrome() 中的逻辑应该对您来说已经很直观了,但以防万一,让我们过一遍。首先,我们从 s 中移除所有既不是字母也不是数字的字符。我们使用字符串对象的 join() 方法来做这件事。通过在空字符串上调用 join(),结果是所有传递给 join() 的可迭代元素将被连接在一起。我们向 join() 提供一个生成器表达式,该表达式按顺序生成 s 中的所有字母数字字符。这是分析回文时的正常程序。

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

注意,这段代码无论字符串长度是奇数还是偶数都能正确工作。度量 len(s) // 2 达到 s 的一半,如果 s 的长度是奇数个字符,中间的那个字符不会被检查(例如,在 RaDaR 中,D 不会被检查),但我们并不在意,因为它会与自身比较。

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

StringUtil 类为我们提供了一个容器命名空间,用于存放旨在处理字符串的方法。另一个例子可能是一个 MathUtil 类,其中包含一些用于处理数字的静态方法。

类方法

类方法与静态方法略有不同,因为它们像实例方法一样也接收一个特殊的第一个参数。在这种情况下,它是类对象本身,而不是实例。类方法的一个非常常见的用例是为类提供工厂能力,这意味着有其他方式可以创建类的实例。让我们看一个例子:

# oop/class.methods.factory.py
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    @classmethod
    def from_tuple(cls, coords):  # cls is Point
        return cls(*coords)
    @classmethod
    def from_point(cls, point):  # cls is Point
        return cls(point.x, point.y)
p = Point.from_tuple((3, 7))
print(p.x, p.y)  # 3 7
q = Point.from_point(p)
print(q.x, q.y)  # 3 7 

在前面的代码中,我们向您展示了如何使用类方法为 Point 类创建一个工厂。在这种情况下,我们希望通过传递两个坐标(常规创建 p = Point(3, 7))来创建一个 Point 实例,但我们还希望能够通过传递一个元组(Point.from_tuple())或另一个实例(Point.from_point())来创建一个实例。

在每个类方法中,cls 参数指的是 Point 类。与实例方法一样,实例方法以 self 作为第一个参数,类方法也接受一个 cls 参数。selfcls 都是按照一种约定来命名的,虽然你不强制遵守,但强烈建议你尊重这种约定。这是任何专业的 Python 程序员都不会改变的事情;这是一个如此强烈的约定,以至于许多工具,如解析器、linters 等,都依赖于它。

类方法和静态方法可以很好地协同工作。静态方法特别有用,可以将类方法的逻辑拆分,以改善其布局。

让我们通过重构 StringUtil 类来举一个例子:

# oop/class.methods.split.py
class StringUtil:
    @classmethod
    def is_palindrome(cls, s, case_insensitive=True):
        s = cls._strip_string(s)
        # For case insensitive comparison, we lower-case s
        if case_insensitive:
            s = s.lower()
        return cls._is_palindrome(s)
    @staticmethod
    def _strip_string(s):
        return "".join(c for c in s if c.isalnum())
    @staticmethod
    def _is_palindrome(s):
        for c in range(len(s) // 2):
            if s[c] != s[-c - 1]:
                return False
        return True
    @staticmethod
    def get_unique_words(sentence):
        return set(sentence.split())
print(StringUtil.is_palindrome("radar"))  # True
print(StringUtil.is_palindrome("not a palindrome"))  # False 

将此代码与上一个版本进行比较。首先,请注意,尽管 is_palindrome() 现在是一个类方法,但我们调用它的方式与它是静态方法时相同。我们将其更改为类方法的原因是,在将其逻辑分解(到 _strip_string()_is_palindrome())之后,我们需要获取对这些方法的引用,如果没有 cls 在我们的方法中,唯一的选择就是通过使用类本身的名称来调用它们,如下所示:StringUtil._strip_string()StringUtil._is_palindrome()

然而,这并不是一个好的做法,因为我们会在 is_palindrome() 方法中硬编码类名,从而让我们处于每次想要更改类名时都必须修改它的位置。使用 cls 意味着它将作为类名,这意味着如果类名更改,我们的代码就不需要任何修改。

注意到新的逻辑比上一个版本读起来要好得多。此外,注意通过在分解出的方法名称前加上一个前导下划线,我们暗示这些方法不应该从类外部调用,但这将是下一段的主题。

私有方法和名称改写

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

在 Python 中,没有这种东西。一切都是公开的;因此,我们依赖于约定,并且为了隐私,依赖于一种称为 名称改写 的机制。

以下是惯例:如果一个属性的名称没有前导下划线,它被认为是公共的。这意味着你可以自由地访问和修改它。当名称有一个前导下划线时,属性被认为是私有的,这意味着它打算内部使用,你不应该从外部修改或调用它。私有属性的一个非常常见的用例是辅助方法,这些方法应该由公共方法使用(可能在调用链中与其他方法一起使用)。另一个用例是内部数据,例如缩放因子,或者我们理想中会放入常量、一旦定义就不能更改的变量中的任何其他数据。然而,Python 没有常量的概念。

我们认识一些程序员对 Python 的这个方面感到不舒服。根据我们的经验,我们从未遇到过因为 Python 缺乏私有属性而导致错误的情况。这是一个纪律、最佳实践和遵循惯例的问题。

Python 给开发者提供的自由度是它有时被称为“成人语言”的原因。当然,每个设计选择都有其利弊。最终,有些人更喜欢允许更多权力且可能需要更多责任的语言,而有些人更喜欢更具约束性的语言。各取所需;这不是对错的问题。

话虽如此,对隐私的调用实际上是有意义的,因为没有它,你可能会真的把错误引入到你的代码中。让我们展示一下我们的意思:

# oop/private.attrs.py
class A:
    def __init__(self, factor):
        self._factor = factor
    def op1(self):
        print("Op1 with factor {}...".format(self._factor))
class B(A):
    def op2(self, factor):
        self._factor = factor
        print("Op2 with factor {}...".format(self._factor))
obj = B(100)
obj.op1()  # Op1 with factor 100...
obj.op2(42)  # Op2 with factor 42...
obj.op1()  # Op1 with factor 42...  <- This is BAD 

在前面的代码中,我们有一个名为 _factor 的属性,让我们假设它非常重要,以至于实例创建后不应该在运行时修改,因为 op1() 函数依赖于它来正确运行。我们用前导下划线命名了它,但问题在于调用 obj.op2(42) 修改了它,这随后反映在后续对 op1() 的调用中。

我们可以通过添加第二个前导下划线来修复这种不希望的行为:

# oop/private.attrs.fixed.py
class A:
    def __init__(self, factor):
        self.__factor = factor
    def op1(self):
        print("Op1 with factor {}...".format(self.__factor))
class B(A):
    def op2(self, factor):
        self.__factor = factor
        print("Op2 with factor {}...".format(self.__factor))
obj = B(100)
obj.op1()  # Op1 with factor 100...
obj.op2(42)  # Op2 with factor 42...
obj.op1()  # Op1 with factor 100...  <- 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没有影响。

如果你正在设计一个旨在被其他开发者使用和扩展的库,你需要记住这一点,以避免无意中覆盖你的属性。这样的错误可能很微妙,难以发现。

属性装饰器

另一个不容忽视的问题是属性装饰器。想象一下,你有一个Person类中的age属性,在某个时刻,你想要确保当你更改其值时,你也检查age是否在适当的范围内,例如[18, 99]。你可以编写访问器方法,如get_age()set_age()(也称为获取器设置器),并将逻辑放在那里。get_age()很可能会只返回age,而set_age()在检查其有效性后设置其值。问题是,你可能已经有一些代码直接访问age属性,这意味着你现在需要准备一些重构。像 Java 这样的语言通过默认使用访问器模式来克服这个问题。许多 Java集成开发环境IDE)会自动完成属性声明,并为你即时编写获取器和设置器访问方法占位符。

但我们不是在学习 Java。Python 使用property装饰器实现了相同的结果。当你用property装饰一个方法时,你可以使用方法的名称,就像它是一个数据属性一样。正因为如此,最好避免在这样方法中放置需要花费很长时间才能完成的逻辑,因为,通过将它们作为属性访问,我们并不期望等待。

让我们来看一个例子:

# oop/property.py
class Person:
    def __init__(self, age):
        self.age = age  # anyone can modify this freely
class PersonWithAccessors:
    def __init__(self, age):
        self._age = age
    def get_age(self):
        return self._age
    def set_age(self, age):
        if 18 <= age <= 99:
            self._age = age
        else:
            raise ValueError("Age must be within [18, 99]")
class PersonPythonic:
    def __init__(self, age):
        self._age = age
    @property
    def age(self):
        return self._age
    @age.setter
    def age(self, age):
        if 18 <= age <= 99:
            self._age = age
        else:
            raise ValueError("Age must be within [18, 99]")
person = PersonPythonic(39)
print(person.age)  # 39 - Notice we access as data attribute
person.age = 42  # Notice we access as data attribute
print(person.age)  # 42
person.age = 100  # ValueError: Age must be within [18, 99] 

Person类可能是我们最初写的版本。然后,我们意识到我们需要放置范围逻辑,因此,在另一种语言中,我们不得不将Person重写为PersonWithAccessors类,并重构使用Person.age的代码。在 Python 中,我们将Person重写为PersonPythonic(当然,你通常不会更改名称;这只是为了说明)。在PersonPythonic中,年龄存储在一个私有_age变量中,我们使用显示的装饰器定义属性获取器和设置器,这使得我们能够像以前一样继续使用person实例。获取器是在我们访问属性进行读取时调用的方法。另一方面,设置器是在我们访问属性以写入它时调用的方法。

与使用获取器/设置器范式的语言不同,Python 允许我们开始编写简单的代码,并在需要时进行重构,没有必要仅仅因为它们可能在将来有帮助而污染代码。

property 装饰器还允许只读数据(通过不编写设置器对应部分)以及在删除属性时执行特殊操作。请参阅官方文档以深入了解。

cached_property 装饰器

属性的一个方便用途是在我们需要运行一些代码来设置我们想要使用的对象时。例如,让我们假设我们需要连接到数据库(或 API)。

在这两种情况下,我们可能需要设置一个知道如何与数据库(或 API)通信的客户端对象。在这些情况下,使用属性是很常见的,这样我们就可以隐藏设置客户端的复杂性,并简单地使用它。让我们给你一个简单的例子:

# oop/cached.property.py
class Client:
    def __init__(self):
        print("Setting up the client...")
    def query(self, **kwargs):
        print(f"Performing a query: {kwargs}")
class Manager:
    @property
    def client(self):
        return Client()
    def perform_query(self, **kwargs):
        return self.client.query(**kwargs) 

在前面的例子中,我们有一个虚拟的 Client 类,每次我们创建一个新的实例时,它都会打印出字符串 "Setting up the client…"。它还有一个模拟的 query() 方法,也会打印出一个字符串。然后我们有一个名为 Manager 的类,它有一个 client 属性,每次被调用时(例如,通过调用 perform_query())都会创建一个新的 Client 实例。

如果我们运行此代码,我们会注意到每次我们在管理器上调用 perform_query() 时,都会看到打印出字符串 "Setting up the client…"。当创建客户端很昂贵时,这段代码会浪费资源,所以可能最好缓存那个客户端,如下所示:

# oop/cached.property.py
class ManualCacheManager:
    @property
    def client(self):
        if not hasattr(self, "_client"):
            self._client = Client()
        return self._client
    def perform_query(self, **kwargs):
        return self.client.query(**kwargs) 

ManualCacheManager 类稍微聪明一点:client 属性首先通过调用内置的 hasattr() 函数检查实例上是否存在属性 _client。如果不存在,它将 _client 分配给一个新的 Client 实例。最后,它简单地返回它。反复访问此类上的 client 属性将只会创建一个 Client 实例,第一次调用时。从第二次调用开始,_client 将直接返回,而不会创建新的实例。

这是一个如此常见的需求,以至于在 Python 3.8 中,functools 模块添加了 cached_property 装饰器。使用它的美在于,与我们的手动解决方案相比,如果我们需要刷新客户端,我们可以简单地删除 client 属性,下次调用它时,它将为我们重新创建一个全新的 Client。让我们看一个例子:

# oop/cached.property.py
from functools import cached_property
class CachedPropertyManager:
    @cached_property
    def client(self):
        return Client()
    def perform_query(self, **kwargs):
        return self.client.query(**kwargs)
manager = CachedPropertyManager()
manager.perform_query(object_id=42)
manager.perform_query(name_ilike="%Python%")
del manager.client  # This causes a new Client on next call
manager.perform_query(age_gte=18) 

运行此代码将得到以下结果:

$ python cached.property.py
Setting up the client...                         # New Client
Performing a query: {'object_id': 42}            # first query
Performing a query: {'name_ilike': '%Python%'}   # second query
Setting up the client...                         # Another Client
Performing a query: {'age_gte': 18}              # Third query 

如您所见,只有在我们手动删除 manager.client 属性后,当我们再次调用 manager.perform_query() 时,我们才会得到一个新的。

Python 3.9 引入了 cache 装饰器,它可以与 property 装饰器一起使用,以覆盖 cached_property 不适用的场景。一如既往,我们鼓励您阅读官方 Python 文档中的所有详细信息并进行实验。

运算符重载

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 中,情况不同。多态是隐式的,而且没有任何东西阻止您调用一个对象的方法;因此,从技术上讲,不需要实现接口或其他模式。

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

多态还允许 Python 程序员简单地使用从对象中暴露的接口(方法和属性),而不是必须检查它是从哪个类实例化的。这使得代码更加紧凑,感觉更加自然。

我们不能在多态上花费太多时间,但我们鼓励您自己检查它;这将扩展您对面向对象编程(OOP)的理解。

数据类

在我们离开面向对象编程领域之前,我们还想提到最后一件事:数据类。由 PEP 557(peps.python.org/pep-0557/)在 Python 3.7 中引入,它们可以被描述为可变的命名元组,具有默认值。您可以在第二章内置数据类型中复习命名元组。让我们直接进入一个示例:

# oop/dataclass.py
from dataclasses import dataclass
@dataclass
class Body:
    """Class to represent a physical body."""
    name: str
    mass: float = 0.0  # Kg
    speed: float = 1.0  # m/s
    def kinetic_energy(self) -> float:
        return (self.mass * self.speed**2) / 2
body = Body("Ball", 19, 3.1415)
print(body.kinetic_energy())  # 93.755711375 Joule
print(body)  # Body(name='Ball', mass=19, speed=3.1415) 

在之前的代码中,我们创建了一个类来表示一个物理体,其中有一个方法允许我们计算其动能(使用公式 E [k] =½mv ²)。请注意,name应该是一个字符串,而massspeed都是浮点数,并且都指定了默认值。还有一点很有趣,我们不需要编写任何__init__()方法;这是由dataclass装饰器为我们完成的,包括比较方法和生成对象字符串表示的方法(在最后一行通过print隐式调用)。

另一点需要注意的是如何定义namemassspeed。这种技术被称为类型提示,将在第十二章类型提示简介中详细介绍。

如果你对 PEP 557 中的所有规范感兴趣,可以阅读,但就目前而言,只需记住,如果需要,数据类可能提供比命名元组更优雅、功能略强的替代方案。

编写自定义迭代器

现在,我们已经拥有了所有工具来欣赏我们如何编写自己的自定义迭代器。让我们首先定义可迭代和迭代器意味着什么:

  • 可迭代:如果一个对象可以一次返回其成员,则称该对象为可迭代。列表、元组、字符串和字典都是可迭代的。定义了__iter__()__getitem__()方法的自定义对象也是可迭代的。

  • 迭代器:如果一个对象代表数据流,则称该对象为迭代器。需要自定义迭代器来提供__iter__()方法的实现,该方法返回对象本身,以及提供__next__()方法的实现,该方法返回数据流的下一个项目,直到流耗尽,此时所有后续对__next__()的调用将简单地引发StopIteration异常。内置函数,如iter()next(),在幕后映射到调用对象的__iter__()__next__()方法。

异常将是第七章异常和上下文管理器的主题。它们可以表示代码执行期间的错误,但也用于控制执行流程,Python 依赖于它们来实现迭代协议等机制。

让我们编写一个迭代器,首先返回字符串中的所有奇数字符,然后是偶数字符:

# 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("0123456789")
print("".join(c for c in oddeven))  # 0246813579
oddeven = OddEven("ABCD")  # or manually...
it = iter(oddeven)  # this calls oddeven.__iter__ internally
print(next(it))  # A
print(next(it))  # C
print(next(it))  # B
print(next(it))  # D 

因此,我们提供了一个__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 中面向对象编程(OOP)的表面。我们涵盖了所有基础知识,因此你现在应该能够理解未来章节中的代码。我们讨论了在类中可以编写的各种方法和属性;我们探讨了继承与组合、方法重写、属性、运算符重载和多态。

最后,我们非常简要地提到了迭代器,这应该会丰富你对生成器的理解。

在下一章,我们将学习异常和上下文管理器。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第七章:异常和上下文管理器

最精心的计划,老鼠和人类都会出错。

– 罗伯特·彭斯

罗伯特·彭斯这句著名的话应该铭刻在每个程序员的脑海中。即使我们的代码是正确的,错误仍然会发生。如果我们没有正确处理它们,它们可能会使我们的精心策划的计划走向歧途。

未处理的错误可能导致软件崩溃或行为异常。根据所涉及软件的性质,这可能会产生严重后果。因此,学习如何检测和处理错误非常重要。我们鼓励你养成总是思考可能发生的错误以及当它们发生时你的代码应该如何响应的习惯。

本章全部关于错误和应对意外情况。我们将学习关于异常的内容,这是 Python 表示错误或其他异常事件发生的方式。我们还将讨论上下文管理器,它提供了一种封装和重用错误处理代码的机制。

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

  • 异常

  • 上下文管理器

异常

尽管我们还没有涉及这个主题,但我们预计到现在你至少对异常有一个模糊的概念。在前几章中,我们看到当迭代器耗尽时,对其调用next()会引发StopIteration异常。当我们尝试访问一个不在有效范围内的列表位置时,我们得到了IndexError。当我们尝试访问一个对象上不存在属性时,我们遇到了AttributeError,当我们尝试访问字典中不存在的键时,我们遇到了KeyError。在本章中,我们将更深入地讨论异常。

即使一个操作或一段代码是正确的,也常常存在可能出现错误的情况。例如,如果我们正在将用户输入从str转换为int,用户可能会不小心输入了一个字母代替数字,这使得我们无法将那个值转换为数字。在除法操作中,我们可能事先不知道是否可能尝试除以 0。在打开文件时,它可能不存在或已损坏。

当在执行过程中检测到错误时,它被称为异常。异常并不一定是致命的;实际上,StopIteration异常已经深深集成到 Python 的生成器和迭代器机制中。然而,通常情况下,如果你不采取必要的预防措施,异常将导致你的应用程序崩溃。有时,这是期望的行为,但在其他情况下,我们希望防止和控制这些问题。例如,如果用户尝试打开一个损坏的文件,我们可以提醒他们问题,并给他们机会修复它。让我们看看几个异常的例子:

# exceptions/first.example.txt 
>>> gen = (n for n in range(2)) 
>>> next(gen) 
0 
>>> next(gen) 
1 
>>> next(gen) 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
StopIteration 
>>> print(undefined_name) 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
NameError: name 'undefined_name' is not defined 
>>> mylist = [1, 2, 3] 
>>> mylist[5] 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
IndexError: list index out of range 
>>> mydict = {"a": "A", "b": "B"} 
>>> mydict["c"] 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
KeyError: 'c' 
>>> 1 / 0 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
ZeroDivisionError: division by zero 

如你所见,Python 的 shell 非常宽容。我们可以看到Traceback,这样我们就有关于错误的信息,但 shell 本身仍然正常运行。这是一种特殊的行为;一个常规程序或脚本如果没有处理异常,通常会立即退出。让我们看一个快速示例:

# exceptions/unhandled.py
1 + "one"
print("This line will never be reached") 

如果我们运行这段代码,我们会得到以下输出:

$ python exceptions/unhandled.py
Traceback (most recent call last):
  File "exceptions/unhandled.py", line 2, in <module>
    1 + "one"
    ~~^~~~~~~
TypeError: unsupported operand type(s) for +: 'int' and 'str' 

因为我们没有做任何处理异常的事情,所以一旦发生异常,Python 就会立即退出(在打印出错误信息之后)。

引发异常

我们之前看到的异常是由 Python 解释器在检测到错误时引发的。然而,你也可以在发生你自己的代码认为的错误的情况时引发异常。要引发异常,请使用raise语句。以下是一个示例:

# exceptions/raising.txt
>>> raise NotImplementedError("I'm afraid I can't do that")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NotImplementedError: I'm afraid I can't do that 

你可以引发任何类型的异常没有限制。这允许你选择最能描述已发生错误条件的异常类型。你还可以定义自己的异常类型(我们将在下一刻看到如何做到这一点)。请注意,我们传递给Exception类的参数被打印出来作为错误消息的一部分。

Python 有太多内置异常无法在此列出,但它们都在docs.python.org/3.12/library/exceptions.html#bltin-exceptions中进行了文档说明。

定义自己的异常

正如我们在上一节中提到的,你可以定义自己的自定义异常。实际上,对于库来说,定义自己的异常是很常见的。

你需要做的只是定义一个继承自任何其他异常类的类。所有异常都源自BaseException;然而,这个类并不打算被直接子类化。你的自定义异常应该继承自Exception。实际上,几乎所有内置异常也都继承自Exception。不继承自Exception的异常是打算由 Python 解释器内部使用的。

Tracebacks

当 Python 遇到未处理的异常时打印的traceback可能一开始看起来令人畏惧,但它对于理解导致异常的原因非常有用。在这个例子中,我们使用一个数学公式来解二次方程;如果你不熟悉它,没关系,因为你不需要理解它。让我们看看 traceback,看看它能告诉我们什么:

# exceptions/trace.back.py
def squareroot(number):
    if number < 0:
        raise ValueError("No negative numbers please") 
    return number**.5

def quadratic(a, b, c):
    d = b**2 - 4 * a * c
    return (
        (-b - squareroot(d)) / (2 * a),
        (-b + squareroot(d)) / (2 * a)
    )
quadratic(1, 0, 1)  # x**2 + 1 == 0 

在这里,我们定义了一个名为quadratic()的函数,它使用著名的二次公式来找到二次方程的解。我们不是使用math模块中的sqrt()函数,而是编写了自己的版本(squareroot()),如果数字是负数,它会引发异常。当我们调用quadratic(1, 0, 1)来解方程x² + 1 = 0 时,我们会得到一个ValueError,因为d是负数。当我们运行这个程序时,我们得到以下结果:

$ python exceptions/trace.back.py
Traceback (most recent call last):
  File "exceptions/trace.back.py", line 16, in <module>
    quadratic(1, 0, 1)  # x**2 + 1 == 0
    ^^^^^^^^^^^^^^^^^^
  File "exceptions/trace.back.py", line 11, in quadratic
    (-b - squareroot(d)) / (2 * a),
          ^^^^^^^^^^^^^
  File "exceptions/trace.back.py", line 4, in squareroot
    raise ValueError("No negative numbers please")
ValueError: No negative numbers please 

从下到上阅读堆栈跟踪通常很有用。在最后一行,我们有错误消息,告诉我们出了什么问题:ValueError: No negative numbers please。前面的行告诉我们异常是在哪里引发的(squareroot() 函数中的 exceptions/trace.back.py 文件的第 4 行)。

我们还可以看到导致异常发生的函数调用序列:在模块的最顶层,函数 quadratic() 在第 16 行被调用,它又调用了在第 11 行的 squareroot() 函数。正如你所看到的,堆栈跟踪就像一张地图,显示了代码中异常发生的位置。沿着这条路径检查每个函数中的代码,当你想要了解异常发生的原因时通常很有帮助。

Python 3.10、3.11 和 3.12 中对错误消息进行了几次改进。例如,在 Python 3.11 中添加了 ^^^^ 字符,在堆栈跟踪中下划线了导致异常的每个语句或表达式的确切部分。

异常处理

要在 Python 中处理异常,你使用 try 语句。当你进入 try 子句时,Python 会监视一个或多个不同类型的异常(根据你的指示),如果它们被引发,它允许你做出反应。

try 语句由 try 子句组成,它打开语句,后面跟着一个或多个 except 子句,这些子句定义了在捕获异常时要执行的操作。except 子句后面可以有一个可选的 else 子句,当 try 子句在没有引发任何异常的情况下退出时执行。在 exceptelse 子句之后,我们可以有一个可选的 finally 子句,其代码无论在其他子句中发生什么都会执行。finally 子句通常用于清理资源。你也可以省略 exceptelse 子句,只保留一个 try 子句后跟一个 finally 子句。如果我们希望异常在其他地方传播和处理,但我们必须执行一些无论是否发生异常都必须执行的清理代码,这很有帮助。

子句的顺序很重要。它必须是 tryexceptelse,然后是 finally。同时,记住 try 后必须跟至少一个 except 子句或一个 finally 子句。让我们看一个例子:

# exceptions/try.syntax.py 
def try_syntax(numerator, denominator): 
    try: 
        print(f"In the try block: {numerator}/{denominator}") 
        result = numerator / denominator 
    except ZeroDivisionError as zde: 
        print(zde) 
    else: 
        print("The result is:", result) 
        return result 
    finally: 
        print("Exiting")
print(try_syntax(12, 4)) 
print(try_syntax(11, 0)) 

这个例子定义了一个简单的 try_syntax() 函数。我们执行两个数的除法。我们准备捕获一个 ZeroDivisionError 异常,如果用 denominator = 0 调用函数,这个异常就会发生。最初,代码进入 try 块。如果 denominator 不是 0,则计算 result,然后离开 try 块后,执行继续在 else 块中。我们打印 result 并返回它。看看输出,你会发现就在返回 result 之前,这是函数的退出点,Python 执行了 finally 子句。

denominator0 时,情况会改变。我们尝试计算 numerator / denominator 会引发一个 ZeroDivisionError。因此,我们进入 except 块并打印 zde

else 块没有执行,因为在 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 块时,你可能想要捕获多个异常。例如,当调用 divmod() 函数时,如果第二个参数是 0,你会得到一个 ZeroDivisionError,如果任一参数不是数字,你会得到一个 TypeError。如果你想以相同的方式处理这两个异常,你可以这样组织你的代码:

# exceptions/multiple.py
values = (1, 2)
try:
    q, r = divmod(*values)
except (ZeroDivisionError, TypeError) as e:
    print(type(e), e) 

这段代码将捕获 ZeroDivisionErrorTypeError。尝试将 values = (1, 2) 改为 values = (1, 0)values = ('one', 2),你将看到输出发生变化。

如果你需要以不同的方式处理不同的异常类型,你可以使用多个 except 子句,如下所示:

# exceptions/multiple.py 
try:
    q, r = divmod(*values)
except ZeroDivisionError:
    print("You tried to divide by zero!")
except TypeError as e:
    print(e) 

请记住,异常是在第一个匹配该异常类或其基类的块中处理的。因此,当你像我们这里这样做多个 except 子句时,确保将特定的异常放在顶部,通用的异常放在底部。在面向对象编程术语中,派生类应该放在其基类之前。此外,请记住,当引发异常时,只有一个 except 处理器被执行。

Python 还允许你使用一个不指定任何异常类型的 except 子句(这相当于写 except BaseException)。然而,你应该避免这样做,因为这意味着你也会捕获到那些打算由解释器内部使用的异常。这些包括所谓的 退出系统异常。这些是 SystemExit,当解释器通过调用 exit() 函数退出时引发,以及 KeyboardInterrupt,当用户通过按下 Ctrl + C(或在某些系统上是 Delete)来终止应用程序时引发。

你也可以在 except 子句内部引发异常。例如,你可能想用一个自定义异常替换内置异常(或第三方库中的异常)。当编写库时,这是一个相当常见的技巧,因为它有助于保护用户免受库的实现细节的影响。让我们看一个例子:

# exceptions/replace.txt
>>> class NotFoundError(Exception):
...     pass
...
>>> vowels = {"a": 1, "e": 5, "i": 9, "o": 15, "u": 21}
>>> try:
...     pos = vowels["y"]
... except KeyError as e:
...     raise NotFoundError(*e.args)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
KeyError: 'y'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
NotFoundError: y 

默认情况下,Python 假设发生在 except 子句中的异常是一个意外错误,并且会友好地打印出两个异常的跟踪信息。我们可以通过使用 raise from 语句来告诉解释器我们故意引发新的异常:

# exceptions/replace.py
>>> try:
...     pos = vowels["y"]
... except KeyError as e:
...     raise NotFoundError(*e.args) from e
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
KeyError: 'y'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
NotFoundError: y 

错误信息已更改,但我们仍然得到两个跟踪信息,这对于调试非常有用。如果你真的想完全抑制原始异常,可以使用 from None 而不是 from e(自己试试)。

你也可以仅使用raise,而不指定新的异常,来重新引发原始异常。如果你只想记录异常发生的事实,而不抑制或替换异常,这有时是有用的。

自 Python 3.11 以来,也可以向异常添加注释。这允许你添加额外的信息,作为跟踪信息的一部分显示,而不抑制或替换原始异常。为了了解这是如何工作的,我们将修改本章前面提到的二次公式示例,并向异常添加注释:

# exceptions/note.py
def squareroot(number):
    if number < 0:
        raise ValueError("No negative numbers please")
    return number**0.5
def quadratic(a, b, c):
    d = b**2 - 4 * a * c
    try:
        return (
            (-b - squareroot(d)) / (2 * a),
            (-b + squareroot(d)) / (2 * a),
        )
    except ValueError as e:
        **e.add_note(f"Cannot solve {a}x******2** **+ {b}x + {c} ==** **0****")**
        **raise**
quadratic(1, 0, 1) 

我们已经突出显示了添加注释和重新引发异常的行。运行此代码时的输出如下:

$ python exceptions/note.py
Traceback (most recent call last):
  File "exceptions/note.py", line 20, in <module>
    quadratic(1, 0, 1)
  File "exceptions/note.py", line 12, in quadratic
    (-b - squareroot(d)) / (2 * a),
          ^^^^^^^^^^^^^
  File "exceptions/note.py", line 4, in squareroot
    raise ValueError("No negative numbers please")
ValueError: No negative numbers please
Cannot solve 1x**2 + 0x + 1 == 0 

注释已打印在原始错误消息下方。你可以通过多次调用add_note()来添加所需数量的注释。所有注释都必须是字符串。

使用异常进行编程可能很棘手。你可能会无意中隐藏那些本应提醒你其存在的错误。通过牢记以下简单指南来确保安全:

  • 尽量使try子句尽可能短。它应该只包含可能引发你想要处理的异常(s)的代码。

  • 尽可能使except子句尽可能具体。可能有人会想只写except Exception,但如果你这样做,你几乎肯定会捕获到你实际上并不想捕获的异常。

  • 使用测试来确保你的代码能够正确处理预期的和意外的错误。我们将在第十章测试中更详细地讨论编写测试。

如果你遵循这些建议,你将最大限度地减少出错的可能性。

异常组

当处理大量数据集时,如果发生错误,立即停止并引发异常可能不方便。通常更好的做法是处理所有数据,并在最后报告所有发生的错误。这使用户能够一次性处理所有错误,而不是需要多次重新运行过程,逐个修复错误。

实现这一目标的一种方法是通过构建一个错误列表并返回它。然而,这种方法有一个缺点,那就是你不能使用try / except语句来处理错误。一些库通过创建一个容器异常类并将收集到的错误包装在这个类的实例中来解决这个问题。这允许你在except子句中处理容器异常,并检查它以访问嵌套的异常。

自 Python 3.11 以来,有一个新的内置异常类ExceptionGroup,它被专门设计为这样的容器异常。将此功能内置到语言中的优点是,跟踪信息也会显示每个嵌套异常的跟踪信息。

例如,假设我们需要验证一个年龄列表,以确保所有值都是正整数。我们可以编写如下内容:

# exceptions/groups/util.py
def validate_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Not an integer: {age}")
    if age < 0:
        raise ValueError(f"Negative age: {age}")
def validate_ages(ages):
    errors = []
    for age in ages:
        try:
            validate_age(age)
        except Exception as e:
            errors.append(e)
    if errors:
        raise ExceptionGroup("Validation errors", errors) 

validate_ages() 函数对 ages 的每个元素调用 validate_age()。它捕获发生的任何异常并将它们追加到 errors 列表中。如果循环完成后错误列表不为空,我们抛出 ExceptionGroup,传入错误消息 "Validation errors" 和发生的错误列表。

如果我们从 Python 控制台调用这个函数,并传入包含一些无效年龄的列表,我们会得到以下跟踪输出:

# exceptions/groups/exc.group.txt
>>> from util import validate_ages
>>> validate_ages([24, -5, "ninety", 30, None])
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  |   File "exceptions/groups/util.py", line 20, in validate_ages
  |     raise ExceptionGroup("Validation errors", errors)
  | ExceptionGroup: Validation errors (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "exceptions/groups/util.py", line 15, in validate_ages
    |     validate_age(age)
    |   File "exceptions/groups/util.py", line 8, in validate_age
    |     raise ValueError(f"Negative age: {age}")
    | ValueError: Negative age: -5
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "exceptions/groups/util.py", line 15, in validate_ages
    |     validate_age(age)
    |   File "exceptions/groups/util.py", line 6, in validate_age
    |     raise TypeError(f"Not an integer: {age}")
    | TypeError: Not an integer: ninety
    +---------------- 3 ----------------
    | Traceback (most recent call last):
    |   File "exceptions/groups/util.py", line 15, in validate_ages
    |     validate_age(age)
    |   File "exceptions/groups/util.py", line 6, in validate_age
    |     raise TypeError(f"Not an integer: {age}")
    | TypeError: Not an integer: None
    +------------------------------------ 

注意,我们得到了 ExceptionGroup 的跟踪输出,包括我们抛出时指定的错误消息("Validation errors")以及指示该组包含三个子异常。在此之下缩进,我们得到每个嵌套子异常的跟踪输出。为了提高可读性,子异常跟踪输出被编号并用虚线分隔。

我们可以像处理其他类型的异常一样处理 ExceptionGroup 异常:

# exceptions/groups/handle.group.txt
>>> from util import validate_ages
>>> try:
...     validate_ages([24, -5, "ninety", 30, None])
... except ExceptionGroup as e:
...     print(e)
...     print(e.exceptions)
...
Validation errors (3 sub-exceptions)
(ValueError('Negative age: -5'),
 TypeError('Not an integer: ninety'),
 TypeError('Not an integer: None')) 

注意,我们可以通过(只读的)exceptions 属性访问嵌套的子异常列表。

PEP 654(peps.python.org/pep-0654/),它将 ExceptionGroup 引入语言,还引入了 try / except 语句的新变体,允许我们在 ExceptionGroup 内部处理特定类型的嵌套子异常。这种新语法使用关键字 except* 而不是 except。在我们的验证示例中,这允许我们对无效类型和无效值进行单独处理,而无需手动迭代和过滤异常:

# exceptions/groups/handle.nested.txt
>>> from util import validate_ages
>>> try:
...     validate_ages([24, -5, "ninety", 30, None])
... except* TypeError as e:
...     print("Invalid types")
...     print(type(e), e)
...     print(e.exceptions)
... except* ValueError as e:
...     print("Invalid values")
...     print(type(e), e)
...     print(e.exceptions)
...
Invalid types
<class 'ExceptionGroup'> Validation errors (2 sub-exceptions)
(TypeError('Not an integer: ninety'),
 TypeError('Not an integer: None'))
Invalid values
<class 'ExceptionGroup'> Validation errors (1 sub-exception)
(ValueError('Negative age: -5'),) 

validate_ages() 的调用抛出一个包含三个异常的异常组:两个 TypeError 实例和一个 ValueError。解释器将每个 except* 子句与嵌套异常匹配。第一个子句匹配,因此解释器创建一个新的 ExceptionGroup,包含原始组中的所有 TypeError 实例,并将其分配给此子句体内的 e。我们打印字符串 "Invalid types",然后是 e 的类型和值以及 e.exceptions。然后剩余的异常将与下一个 except* 子句匹配。

这次,所有的 ValueError 实例都匹配,因此 e 被分配给一个新的包含这些异常的 ExceptionGroup。我们打印字符串 "Invalid values",然后是 type(e)ee.exceptions。此时,组中不再有未处理的异常,因此执行恢复正常。

重要的是要注意,这种行为与正常的 try / except 语句不同。在正常的 try / except 语句中,只有一个 except 子句被执行:第一个匹配抛出异常的子句。在 try / except* 语句中,每个匹配的 except* 子句都会被执行,直到组中不再有未处理的异常。如果在所有 except* 子句处理完毕后仍有未处理的异常,它们将在最后作为新的 ExceptionGroup 重新抛出:

# exceptions/groups/handle.nested.txt
>>> try:
...     validate_ages([24, -5, "ninety", 30, None])
... except* ValueError as e:
...     print("Invalid values")
...
Invalid values
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  |   File "exceptions/groups/util.py", line 20, in validate_ages
  |     raise ExceptionGroup("Validation errors", errors)
  | ExceptionGroup: Validation errors (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "exceptions/groups/util.py", line 15, in validate_ages
    |     validate_age(age)
    |   File "exceptions/groups/util.py", line 6, in validate_age
    |     raise TypeError(f"Not an integer: {age}")
    | TypeError: Not an integer: ninety
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "exceptions/groups/util.py", line 15, in validate_ages
    |     validate_age(age)
    |   File "exceptions/groups/util.py", line 6, in validate_age
    |     raise TypeError(f"Not an integer: {age}")
    | TypeError: Not an integer: None
    +------------------------------------ 

另一个需要注意的重要点是,如果在不是ExceptionGroup实例的try / except*语句中抛出异常,其类型将与except*子句进行匹配。如果找到匹配项,异常将在传递到except*主体之前被包装在一个ExceptionGroup中:

# exceptions/groups/handle.nested.txt
>>> try:
...     raise RuntimeError("Ungrouped")
... except* RuntimeError as e:
...     print(type(e), e)
...     print(e.exceptions)
...
<class 'ExceptionGroup'>  (1 sub-exception)
(RuntimeError('Ungrouped'),) 

这意味着我们总是可以安全地假设在except*子句中处理的异常是一个ExceptionGroup实例。

不仅用于错误

在我们继续讨论上下文管理器之前,我们想向你展示异常的不同用法。在这个例子中,我们将演示异常不仅可以用于错误:

# 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
                break 

在上面的代码中,我们使用三个嵌套循环来找到一个满足特定方程的三个整数(abc)的组合。在每个外部循环的开始,我们检查一个标志(found)的值,当我们找到一个方程的解时,该标志被设置为True。这使我们能够在找到解时尽可能快地跳出所有三个循环。我们认为检查标志的逻辑相当不优雅,因为它掩盖了其余的代码,所以我们想出了一个替代方法:

# exceptions/for.loop.py 
class ExitLoopException(Exception): 
    pass 

try: 
    n = 100 
    for a in range(n): 
        for b in range(n): 
            for c in range(n): 
                if 42 * a + 17 * b + c == 5096: 
                    raise ExitLoopException(a, b, c) 
except ExitLoopException as ele: 
    print(ele.args)  # (79, 99, 95) 

希望你能欣赏这种方式的优雅。现在,跳出逻辑完全由一个简单的异常来处理,其名称甚至暗示了其目的。一旦找到结果,我们就使用满足我们条件的值抛出ExitLoopException,然后立即将控制权交给处理它的except子句。注意,我们可以使用异常的args属性来获取传递给构造函数的值。

现在我们应该已经很好地理解了异常是什么,以及它们是如何被用来管理错误、流程和异常情况的。我们准备好继续下一个主题:上下文管理器

上下文管理器

当与外部资源一起工作时,我们通常在完成工作后需要执行一些清理步骤。例如,在将数据写入文件后,我们需要关闭文件。未能正确清理可能会导致各种错误。因此,我们必须确保即使在发生异常的情况下,我们的清理代码也会被执行。我们可以使用try / finally语句,但这并不总是方便,并且可能会导致大量重复,因为我们经常在处理特定类型的资源时必须执行类似的清理步骤。上下文管理器通过创建一个执行上下文来解决这个问题,在这个上下文中我们可以使用资源,并在离开该上下文时自动执行任何必要的清理,即使抛出了异常。

上下文管理器的另一个用途是暂时更改我们程序的全球状态。我们可能想要暂时修改的全球状态的一个例子是十进制计算的精度。例如,在数据科学应用中,我们有时需要以特定的精度执行特定的计算,但我们希望保留其余计算中的默认精度。我们可以通过以下方式实现这一点:

# context/decimal.prec.py
from decimal import Context, Decimal, getcontext, setcontext
one = Decimal("1")
three = Decimal("3")
orig_ctx = getcontext()
ctx = Context(prec=5)
**setcontext(ctx)**
print(f"{ctx}\n")
print("Custom decimal context:", one / three)
**setcontext(orig_ctx)**
print("Original context restored:", one / three) 

注意,我们存储了当前上下文,设置了一个新的上下文(具有修改后的精度),执行了我们的计算,最后恢复了原始上下文。

你可能还记得,Decimal 类允许我们使用十进制数进行任意精度的计算。如果不记得,现在你可以回顾一下 第二章内置数据类型,的相关部分。

运行此代码会产生以下输出:

$ python context/decimal.prec.py
Context(prec=5, rounding=ROUND_HALF_EVEN, Emin=-999999,
        Emax=999999, capitals=1, clamp=0, flags=[],
        traps=[InvalidOperation, DivisionByZero, Overflow])
Custom decimal context: 0.33333
Original context restored: 0.3333333333333333333333333333 

在上面的例子中,我们打印了 context 对象以显示它包含的内容。其余的代码看起来没有问题,但如果在恢复原始上下文之前发生异常,所有后续计算的结果都将是不正确的。我们可以通过使用 try / finally 语句来修复这个问题:

# context/decimal.prec.try.py
from decimal import Context, Decimal, getcontext, setcontext
one = Decimal("1")
three = Decimal("3")
orig_ctx = getcontext()
ctx = Context(prec=5)
setcontext(ctx)
try:
    print("Custom decimal context:", one / three)
finally:
    setcontext(orig_ctx)
print("Original context restored:", one / three) 

这样更安全。即使 try 块中发生异常,我们也会始终恢复原始上下文。但是,每次需要使用修改后的精度工作时,都必须保存上下文并在 try / finally 语句中恢复它,这并不方便。这样做也会违反 DRY 原则。我们可以通过使用 decimal 模块中的 localcontext 上下文管理器来避免这种情况。这个上下文管理器会为我们设置和恢复上下文:

# context/decimal.prec.ctx.py
from decimal import Context, Decimal, localcontext
one = Decimal("1")
three = Decimal("3")
**with** localcontext(Context(prec=5)) as ctx:
    print("Custom decimal context:", one / three)
print("Original context restored:", one / three) 

with 语句用于进入由 localcontext 上下文管理器定义的运行时上下文。当退出由 with 语句分隔的代码块时,上下文管理器定义的任何清理操作(在这种情况下,恢复十进制上下文)会自动执行。

还有可能在一个 with 语句中组合多个上下文管理器。这在需要同时处理多个资源的情况下非常有用:

# context/multiple.py
from decimal import Context, Decimal, localcontext
one = Decimal("1")
three = Decimal("3")
with (
    localcontext(Context(prec=5)),
    open("output.txt", "w") as out_file
):
    out_file.write(f"{one} / {three} = {one / three}\n") 

在这里,我们进入一个局部上下文,并在一个 with 语句中打开一个文件(它充当上下文管理器)。我们执行计算并将结果写入文件。当我们退出 with 块时,文件会自动关闭,并且默认的十进制上下文会恢复。现在不必太担心与文件操作相关的细节;我们将在 第八章文件和数据持久性 中详细讨论。

在 Python 3.10 之前,像我们这里这样在多个上下文管理器周围使用括号会导致 SyntaxError。在 Python 的旧版本中,我们必须将两个上下文管理器放入一行代码中,或者将换行符放在 localcontext()open() 调用的括号内。

除了十进制上下文和文件之外,Python 标准库中的许多其他对象也可以用作上下文管理器。以下是一些示例:

  • 实现低级网络接口的套接字对象可以用作上下文管理器来自动关闭网络连接。

  • 在并发编程中用于同步的锁类使用上下文管理器协议来自动释放锁。

在本章的其余部分,我们将向您展示如何实现您自己的上下文管理器。

基于类的上下文管理器

上下文管理器通过两个魔术方法工作:__enter__()在进入with语句的主体之前被调用,而__exit__()在退出with语句主体时被调用。这意味着您可以通过编写一个实现这些方法的类来创建自己的上下文管理器:

# context/manager.class.py
class MyContextManager:
    def __init__(self):
        print("MyContextManager init", id(self))
    def __enter__(self):
        print("Entering 'with' context")
        **return****self**
    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"{exc_type=} {exc_val=} {exc_tb=}")
        print("Exiting 'with' context")
        **return****True** 

在这里,我们定义了一个名为MyContextManager的上下文管理器类。关于这个类有几个有趣的地方需要注意。请注意,__enter__()方法返回self。这是很常见的,但并非必须;您可以从__enter__()返回任何您想要的内容,甚至None__enter__()方法的返回值将被分配给with语句中as子句中命名的变量。此外,请注意__exit__()函数的exc_typeexc_valexc_tb参数。如果在with语句的主体内部抛出异常,解释器将通过这些参数将异常的类型跟踪信息作为参数传递。如果没有抛出异常,所有三个参数都将为None

此外,请注意__exit__()方法返回True。这将导致在with语句主体内部抛出的任何异常被抑制(就像我们在try / except语句中处理它一样)。如果我们返回False而不是True,那么这样的异常在__exit__()方法执行后将继续传播。抑制异常的能力意味着上下文管理器可以用作异常处理程序。这种做法的好处是我们可以在需要的地方重用我们的异常处理逻辑。

让我们看看我们的上下文管理器是如何工作的:

# context/manager.class.py
**ctx_mgr = MyContextManager()**
print("About to enter 'with' context")
**with** **ctx_mgr** **as** **mgr:**
    print("Inside 'with' context")
    print(id(mgr))
    raise Exception("Exception inside 'with' context")
    print("This line will never be reached")
print("After 'with' context") 

在这里,我们在with语句之前单独声明了我们的上下文管理器。我们这样做是为了让您更容易看到正在发生的事情。然而,将这些步骤合并,如with MyContextManager() as mgr,更为常见。运行此代码会产生以下输出:

$ python context/manager.class.py
MyContextManager init 140340228792272
About to enter 'with' context
Entering 'with' context
Inside 'with' context
140340228792272
exc_type=<class 'Exception'> exc_val=Exception("Exception inside
'with' context") exc_tb=<traceback object at 0x7fa3817c5340>
Exiting 'with' context
After 'with' context 

仔细研究这个输出,以确保您理解正在发生的事情。我们打印了一些 ID,以帮助验证分配给mgr的对象确实是来自__enter__()返回的同一个对象。尝试更改__enter__()__exit__()方法的返回值,看看会有什么影响。

基于生成器的上下文管理器

如果你正在实现一个表示需要获取和释放的资源类的类,将其实现为上下文管理器是有意义的。然而,有时我们想要实现上下文管理器行为,但没有一个类适合附加这种行为。例如,我们可能只想使用上下文管理器来重用一些错误处理逻辑。在这种情况下,不得不编写一个额外的类来纯粹实现所需的上下文管理器行为,这会相当繁琐。

来自 contextlib 模块的 contextmanager 装饰器对于这种情况非常有用。它接受一个 生成器函数 并将其转换为上下文管理器(如果你不记得生成器函数是如何工作的,你应该回顾一下 第五章列表推导式和生成器)。装饰器将生成器包装在一个上下文管理器对象中。该对象的 __enter__() 方法启动生成器并返回生成器产生的任何内容。如果在 with 语句的主体内部发生异常,__exit__() 方法将异常传递给生成器(使用生成器的 throw 方法)。否则,__exit__() 简单地调用生成器的 next 方法。请注意,生成器只能产生一次;如果生成器第二次产生,将引发 RuntimeError。让我们将之前的示例转换为基于生成器的上下文管理器:

# context/generator.py
from contextlib import contextmanager
@contextmanager
def my_context_manager():
    print("Entering 'with' context")
    val = object()
    print(id(val))
    try:
        **yield** **val**
    except Exception as e:
        print(f"{type(e)=} {e=} {e.__traceback__=}")
    finally:
        print("Exiting 'with' context")
print("About to enter 'with' context")
with my_context_manager() as val:
    print("Inside 'with' context")
    print(id(val))
    raise Exception("Exception inside 'with' context")
    print("This line will never be reached")
print("After 'with' context") 

运行此代码的输出与之前的示例类似:

$ python context/generator.py
About to enter 'with' context
Entering 'with' context
139768531985040
Inside 'with' context
139768531985040
type(e)=<class 'Exception'> e=Exception("Exception inside 'with'
context") e.__traceback__=<traceback object at 0x7f1e65a42800>
Exiting 'with' context
After 'with' context 

大多数基于生成器的上下文管理器生成器在这个示例中具有类似的结构 my_context_manager()。它们有一些设置代码,然后是在 try 语句内部的 yield。在这里,我们产生了一个任意对象,以便你可以看到通过 with 语句的 as 子句提供了相同的对象。通常,也会有一个不带值的裸 yield(在这种情况下,产生 None)。这相当于从上下文管理器类的方法 __enter__() 中返回 None。在这种情况下,with 语句的 as 子句通常会被省略。

基于生成器的上下文管理器的另一个有用特性是它们也可以用作函数装饰器。这意味着如果函数的全部主体需要位于 with 语句的上下文中,你可以节省一个缩进级别,只需装饰该函数即可。

除了 contextmanager 装饰器之外,contextlib 模块还包含许多有用的上下文管理器。文档还提供了使用和实现上下文管理器的几个有帮助的示例。确保你阅读了它:docs.python.org/3/library/contextlib.html

我们在本节中给出的示例并没有做任何有用的事情。它们被创建纯粹是为了向您展示上下文管理器是如何工作的。仔细研究这些示例,直到您确信您完全理解了它们。然后开始编写您自己的上下文管理器(无论是作为类还是生成器)。尝试将本章前面看到的用于从嵌套循环中退出的 try / except 语句转换为上下文管理器。我们在 第六章 中编写的 measure 装饰器也是一个很好的候选,可以转换为上下文管理器。

摘要

在本章中,我们探讨了异常和上下文管理器。

我们看到异常是 Python 用来表示发生错误的方式。我们向您展示了如何捕获异常,以便在错误不可避免地发生时,您的程序不会失败。

我们还向您展示了您如何在自己的代码检测到错误时引发异常,并且您可以定义自己的异常类型。我们看到了异常组和扩展 except 子句的新语法。我们通过看到异常不仅用于表示错误,还可以用作流程控制机制来结束对异常的探索。

我们在本章的结尾简要概述了上下文管理器。我们展示了如何使用 with 语句进入由上下文管理器定义的上下文,当退出上下文时,上下文管理器会执行清理操作。我们还向您展示了如何创建自己的上下文管理器,无论是作为类的一部分还是通过使用生成器函数。

我们将在下一章中看到更多上下文管理器的实际应用,该章重点介绍文件和数据持久性。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第八章:文件和数据持久化

“不是因为我有多聪明,只是我愿意与问题相处更久。”

– 阿尔伯特·爱因斯坦

在前面的章节中,我们探讨了 Python 的几个不同方面。由于示例具有教学目的,我们在简单的 Python shell 或 Python 模块的形式下运行它们。它们运行,可能在控制台上打印了一些内容,然后终止,没有留下它们短暂存在的痕迹。

实际应用相当不同。自然地,它们仍然在内存中运行,但它们与网络、磁盘和数据库交互。它们还与其他应用程序和设备交换信息,使用适合该情况格式的格式。

在本章中,我们将通过探索以下内容来开始关注现实世界:

  • 文件和目录

  • 压缩

  • 网络和流

  • JSON 数据交换格式

  • 使用标准库中的pickleshelve进行数据持久化

  • 使用 SQLAlchemy 进行数据持久化

  • 配置文件

如往常一样,我们将尝试平衡广度和深度,以便到本章结束时,你将牢固掌握基础知识,并知道如何从网络中获取更多信息。

与文件和目录一起工作

当涉及到文件和目录时,Python 提供了许多有用的工具。在下面的示例中,我们将使用ospathlibshutil模块。由于我们将要在磁盘上进行读写操作,我们将使用一个文件fear.txt作为一些示例的基础,该文件包含来自一行禅宗大师一行禅的《恐惧》摘录。

打开文件

在 Python 中打开文件简单直观。实际上,我们只需要使用open()函数。让我们看看一个快速示例:

# files/open_try.py
fh = open("fear.txt", "rt")  # r: read, t: text
for line in fh.readlines():
    print(line.strip())  # remove whitespace and print
fh.close() 

之前的代码很简单。我们调用open(),传递文件名,并告诉open()我们想要以文本模式(通过"rt"标志)读取它。文件名之前没有路径信息;因此,open()将假设文件位于脚本运行的同一文件夹中。这意味着如果我们从这个files文件夹外部运行此脚本,那么fear.txt将找不到。

一旦文件被打开,我们就获得了一个文件对象,fh,我们可以用它来处理文件的内容。我们选择这个名字是因为,在 Python 中,文件对象本质上是一个高级抽象,它封装了底层的文件句柄(fh)。在这种情况下,我们使用readlines()方法遍历文件中的所有行并打印它们。我们对每一行调用strip()以去除内容周围的任何额外空格,包括行终止字符,因为print()已经为我们添加了一个。这是一个快速且简单的解决方案,在这个例子中有效,但如果文件内容包含需要保留的有意义的空格,你将不得不在清理数据时更加小心。在脚本末尾,我们关闭流。

关闭文件很重要,因为我们不希望冒无法释放我们对它的句柄(fh)的风险。当这种情况发生时,你可能会遇到内存泄漏或令人烦恼的 “你不能删除此文件” 弹窗,告诉你某些软件仍在使用它。

因此,我们需要采取一些预防措施,并将之前的逻辑包装在 try/finally 块中。这意味着无论我们尝试打开和读取文件时可能发生的任何错误,我们都可以确信 close() 会被调用:

# files/open_try.py
fh = open("fear.txt", "rt")
try:
    for line in fh.readlines():
        print(line.strip())
finally:
    fh.close() 

逻辑是相同的,但现在它也是安全的。

如果你不太熟悉 try / finally 块,请确保你回到 第七章处理异常 部分,异常和上下文管理器,并学习它。

我们可以进一步简化之前的例子,如下所示:

# files/open_try.py
fh = open("fear.txt")  # rt is default
try:
    for line in fh:  # we can iterate directly on fh
        print(line.strip())
finally:
    fh.close() 

打开文件的默认模式是 "rt",因此我们不需要指定它。此外,我们可以简单地迭代 fh,而不需要显式调用 readlines()。Python 经常给我们提供简写,使我们的代码更加紧凑且易于阅读。

所有的前一个例子都会在控制台上打印出文件的内容(查看源代码以读取全部内容):

An excerpt from Fear - By Thich Nhat Hanh
The Present Is Free from Fear
When we are not fully present, we are not really living. We are not
really there, either for our loved ones or for ourselves. If we are
not there, then where are we? We are running, running, running,
even during our sleep. We run because we are trying to escape from
our fear. […] 

使用上下文管理器打开文件

为了避免在代码中到处使用 try / finally 块,Python 给我们提供了一种更优雅且同样安全的做法:通过使用上下文管理器。让我们先看看代码:

# files/open_with.py
with open("fear.txt") as fh:
    for line in fh:
        print(line.strip()) 

这个例子与上一个例子等效,但读起来更好。当通过上下文管理器调用时,open() 函数返回一个文件对象,并且当执行退出上下文管理器的作用域时,它会方便地自动调用 fh.close()。即使发生错误,也会发生这种情况。

从文件中读取和写入

现在我们知道了如何打开文件,让我们看看如何从文件中读取和写入:

# files/print_file.py
with open("print_example.txt", "w") as fw:
    print("Hey I am printing into a file!!!", file=fw) 

这种第一种方法使用 print() 函数,我们已经在之前的章节中熟悉了它。在获得文件对象后,这次指定我们打算写入它("w"),我们可以告诉 print() 调用将输出定向到文件,而不是像通常那样定向到 标准输出 流。

在 Python 中,标准输入、输出和错误流由文件对象 sys.stdinsys.stdoutsys.stderr 表示。除非输入或输出被重定向,否则从 sys.stdin 读取通常对应于从键盘读取,而将内容写入 sys.stdoutsys.stderr 通常会在控制台屏幕上打印。

之前的代码会在文件不存在时创建 print_example.txt 文件,或者如果它已存在,则截断它,并将行 Hey I am printing into a file!!! 写入其中。

截断文件意味着在不删除文件的情况下擦除其内容。截断后,文件仍然存在于文件系统中,但它为空。

这个例子完成了工作,但这并不是我们写入文件时通常会做的事情。让我们看看一种更常见的方法:

# files/read_write.py
with open("fear.txt") as f:
    lines = [line.rstrip() for line in f]
with open("fear_copy.txt", "w") as fw:  # w - write
    fw.write("\n".join(lines)) 

在这个例子中,我们首先打开 fear.txt 并逐行收集其内容到一个列表中。注意,这次我们调用了一个不同的方法 rstrip(),作为一个例子,以确保我们只去除每行的右侧空白。

在代码片段的第二部分,我们创建了一个新文件 fear_copy.txt,并将 lines 中的所有字符串写入其中,通过换行符 \n 连接。Python 默认使用 通用换行符,这意味着即使原始文件可能有与 \n 不同的换行符,它也会在我们返回行之前自动为我们转换。这种行为当然是可以定制的,但通常这正是我们想要的。说到换行符,你能想到在复制中可能缺失的一个吗?

以二进制模式读写

注意,通过打开一个文件并传递 t 选项(或者省略它,因为它默认是这样),我们是以文本模式打开文件的。这意味着文件的内容被当作文本处理和解释。

如果你希望向文件写入字节,你可以以 二进制模式 打开它。当你处理不包含纯文本的文件时,这是一个常见的需求,例如图像、音频/视频,以及通常的任何其他专有格式。

要以二进制模式处理文件,只需在打开时指定 b 标志,如下面的例子所示:

# files/read_write_bin.py
with open("example.bin", "wb") as fw:
    fw.write(b"This is binary data...")
with open("example.bin", "rb") as f:
    print(f.read())  # prints: b'This is binary data...' 

在这个例子中,我们仍然使用文本作为二进制数据,为了简单起见,但它可以是任何你想要的东西。你可以看到它被当作二进制处理,因为输出字符串中有 b 前缀。

防止覆盖现有文件

正如我们所见,Python 给我们提供了打开文件进行写入的能力。通过使用 w 标志,我们打开一个文件并截断其内容。这意味着文件被一个空文件覆盖,原始内容丢失。如果你希望只有在文件不存在时才打开文件进行写入,你可以使用 x 标志,如下面的例子所示:

# files/write_not_exists.py
with open("write_x.txt", "x") as fw:  # this succeeds
    fw.write("Writing line 1")
with open("write_x.txt", "x") as fw:  # this fails
    fw.write("Writing line 2") 

如果你运行这个代码片段,你将在你的目录中找到一个名为 write_x.txt 的文件,其中只包含一行文本。实际上,代码片段的第二部分未能执行。这是我们控制台上的输出(为了编辑目的,文件路径已被缩短):

$ python write_not_exists.py
Traceback (most recent call last):
  File "write_not_exists.py", line 6, in <module>
    with open("write_x.txt", "x") as fw:  # this fails
         ^^^^^^^^^^^^^^^^^^^^^^^^
FileExistsError: [Errno 17] File exists: 'write_x.txt' 

正如我们所见,打开文件有不同的模式。你可以在 docs.python.org/3/library/functions.html#open 找到完整的标志列表。

检查文件和目录是否存在

如果你想要确保一个文件或目录存在(或者不存在),你需要使用 pathlib 模块。让我们看一个小例子:

# files/existence.py
from pathlib import Path
p = Path("fear.txt")
path = p.parent.absolute()
print(p.is_file())  # True
print(path)  # /Users/fab/code/lpp4ed/ch08/files
print(path.is_dir())  # True
q = Path("/Users/fab/code/lpp4ed/ch08/files")
print(q.is_dir())  # True 

在前面的代码片段中,我们创建了一个 Path 对象,我们用要检查的文本文件的名字来设置它。我们使用 parent() 方法来检索包含文件的文件夹,并对其调用 absolute() 方法以提取绝对路径信息。

我们检查"fear.txt"是否是一个文件,以及它所在的文件夹确实是一个文件夹(或目录,两者等价)。

以前执行这些操作的方法是使用标准库中的os.path模块。虽然os.path在字符串上工作,但pathlib提供了表示文件系统路径的类,具有适用于不同操作系统的语义。因此,我们建议尽可能使用pathlib,只有在没有其他选择的情况下才回退到旧的方法。

文件和目录操作

让我们看看几个快速示例,说明如何操作文件和目录。第一个示例操作内容:

# files/manipulation.py
from collections import Counter
from string import ascii_letters
chars = ascii_letters + " "
def sanitize(s, chars):
    return "".join(c for c in s if c in chars)
def reverse(s):
    return s[::-1]
with open("fear.txt") as stream:
    lines = [line.rstrip() for line in stream]
# let us write the mirrored version of the file
with open("raef.txt", "w") as stream:
    stream.write("\n".join(reverse(line) for line in lines))
# now we can calculate some statistics
lines = [sanitize(line, chars) for line in lines]
whole = " ".join(lines)
# we perform comparisons on the lowercased version of `whole`
cnt = Counter(whole.lower().split())
# we can print the N most common words
print(cnt.most_common(3)) # [('we', 17), ('the', 13), ('were', 7)] 

此示例定义了两个函数:sanitize()reverse()。它们是简单的函数,其目的是从一个字符串中移除所有非字母或空格的字符,并分别产生字符串的反转副本。

我们打开fear.txt并将其内容读取到一个列表中。然后我们创建一个新的文件raef.txt,它将包含原始的横向镜像版本。我们通过在换行符上使用join操作,一次性写入lines中的所有内容。也许更有趣的是结尾的部分。首先,我们通过列表推导式将lines重新赋值为它的净化版本。然后我们将这些行组合成一个whole字符串,最后,我们将结果传递给一个Counter对象。请注意,我们将字符串的小写版本拆分成一个单词列表。这样,每个单词都会被正确计数,无论其大小写如何,而且,多亏了split(),我们不需要担心任何地方的额外空格。当我们打印最常见的三个单词时,我们意识到,确实,一行禅的焦点是他人,因为“我们”是文本中最常见的单词:

$ python manipulation.py
[('we', 17), ('the', 13), ('were', 7)] 

现在我们来看一个更接近磁盘操作的示例,我们将使用shutil模块:

# files/ops_create.py
import shutil
from pathlib import Path
base_path = Path("ops_example")
# let us perform an initial cleanup just in case
if base_path.exists() and base_path.is_dir():
    shutil.rmtree(base_path)
# now we create the directory
base_path.mkdir()
path_b = base_path / "A" / "B"
path_c = base_path / "A" / "C"
path_d = base_path / "A" / "D"
path_b.mkdir(parents=True)
path_c.mkdir()  # no need for parents now, as 'A' has been created
# we add three files in `ops_example/A/B`
for filename in ("ex1.txt", "ex2.txt", "ex3.txt"):
    with open(path_b / filename, "w") as stream:
        stream.write(f"Some content here in {filename}\n")
shutil.move(path_b, path_d)
# we can also rename files
ex1 = path_d / "ex1.txt"
ex1.rename(ex1.parent / "ex1.renamed.txt") 

在前面的代码中,我们首先声明了一个基础路径,该路径将包含我们即将创建的所有文件和文件夹。然后,我们使用mkdir()创建两个目录:ops_example/A/Bops_example/A/C。请注意,在调用path_c.mkdir()时,我们不需要指定parents=True,因为所有父目录都已经在之前的path_b调用中创建好了。

我们使用/运算符来连接目录名称;pathlib会为我们处理背后的正确路径分隔符。

在创建目录后,我们在目录B中循环创建三个文件。然后,我们将目录B及其内容移动到不同的名称:D。我们也可以用另一种方式来做这件事:path_b.rename(path_d)

最后,我们将ex1.txt重命名为ex1.renamed.txt。如果你打开该文件,你会看到它仍然包含循环逻辑中的原始文本。对结果调用tree会产生以下内容:

$ tree ops_example
ops_example
└── A
    ├── C
    └── D
        ├── ex1.renamed.txt
        ├── ex2.txt
        └── ex3.txt 

操作路径名

让我们通过一个示例来更深入地探索pathlib的能力:

# files/paths.py
from pathlib import Path
p = Path("fear.txt")
print(p.absolute())
print(p.name)
print(p.parent.absolute())
print(p.suffix)
print(p.parts)
print(p.absolute().parts)
readme_path = p.parent / ".." / ".." / "README.rst"
print(readme_path.absolute())
print(readme_path.resolve()) 

阅读结果可能是对这个简单例子足够好的解释:

$ python paths.py
/Users/fab/code/lpp4ed/ch08/files/fear.txt
fear.txt
/Users/fab/code/lpp4ed/ch08/files
.txt
('fear.txt',)
(
    '/', 'Users', 'fab', 'code', 'lpp4ed',
    'ch08', 'files', 'fear.txt'
)
/Users/fab/code/lpp4ed/ch08/files/../../README.rst
/Users/fab/code/lpp4ed/README.rst 

注意,在最后两行中,我们有同一路径的两种不同表示。第一个(readme_path.absolute())显示了两个 " ..",每个在路径术语中都表示切换到父文件夹。因此,通过连续两次切换到父文件夹,从 …/lpp4e/ch08/files/,我们回到了 …/lpp4e/。这由示例中的最后一行确认,它显示了 readme_path.resolve() 的输出。

临时文件和目录

有时候,创建一个临时目录或文件是有用的。例如,当编写影响磁盘的测试时,你可以使用临时文件和目录来运行你的逻辑并断言它是正确的,并且确保在测试运行结束时,测试文件夹没有遗留物。让我们看看如何在 Python 中做到这一点:

# files/tmp.py
from tempfile import NamedTemporaryFile, TemporaryDirectory
with TemporaryDirectory(dir=".") as td:
    print("Temp directory:", td)
    with NamedTemporaryFile(dir=td) as t:
        name = t.name
        print(name) 

前面的例子相当简单:我们在当前目录(".")中创建一个临时目录,并在其中创建一个命名的临时文件。我们打印文件名,以及它的完整路径:

$ python tmp.py
Temp directory: /Users/fab/code/lpp4ed/ch08/files/tmpqq4quhbc
/Users/fab/code/lpp4ed/ch08/files/tmpqq4quhbc/tmpypwwhpwq 

运行这个脚本将每次产生不同的结果,因为这些是临时的随机名称。

目录内容

使用 Python,你还可以检查目录的内容。我们将向你展示两种方法。这是第一种:

# files/listing.py
from pathlib import Path
p = Path(".")
for entry in p.glob("*"):
    print("File:" if entry.is_file() else "Folder:", entry) 

这个代码片段使用了 Path 对象的 glob() 方法,从当前目录应用。我们遍历结果,每个结果都是一个 Path 子类的实例(PosixPathWindowsPath,根据我们运行的操作系统)。对于每个 entry,我们检查它是否是目录,并相应地打印。运行代码将产生以下结果(为了简洁,我们省略了一些结果):

$ python listing.py
File: existence.py
File: manipulation.py
…
File: open_try.py
File: walking.pathlib.py 

另一种方法是使用 Path.walk() 方法来扫描目录树。让我们看一个例子:

# files/walking.pathlib.py
from pathlib import Path
p = Path(".")
for root, dirs, files in p.walk():
    print(f"{root=}")
    if dirs:
        print("Directories:")
        for dir_ in dirs:
            print(dir_)
        print()
    if files:
        print("Files:")
        for filename in files:
            print(filename)
        print() 

运行前面的代码片段将生成当前目录中所有文件和目录的列表,并且它将为每个子目录做同样的事情。在本书的源代码中,你会找到一个名为 walking.py 的模块,它做的是完全相同的事情,但使用的是 os.walk() 函数。

文件和目录压缩

在我们离开这个部分之前,让我们给你一个如何创建压缩文件的例子。在本章的源代码中,在 files/compression 文件夹中,我们有两个例子:一个创建 .zip 文件,而另一个创建 tar.gz 文件。Python 允许你以多种不同的方式和格式创建压缩文件。在这里,我们将向你展示如何创建最常见的一种,ZIP

# files/compression/zip.py
from zipfile import ZipFile
with ZipFile("example.zip", "w") as zp:
    zp.write("content1.txt")
    zp.write("content2.txt")
    zp.write("subfolder/content3.txt")
    zp.write("subfolder/content4.txt")
with ZipFile("example.zip") as zp:
    zp.extract("content1.txt", "extract_zip")
    zp.extract("subfolder/content3.txt", "extract_zip") 

在前面的代码中,我们导入ZipFile,然后在上下文管理器中写入四个文件(其中两个位于子文件夹中,以展示 ZIP 如何保留完整路径)。之后,作为一个例子,我们打开压缩文件,从中提取一些文件到extract_zip目录。如果您对数据压缩感兴趣,请确保查看标准库中的数据压缩和归档部分(docs.python.org/3.9/library/archiving.html),在那里您可以了解有关此主题的所有内容。

数据交换格式

现代软件架构倾向于将应用程序拆分为几个组件。无论您是采用面向服务的架构范式,还是将其进一步推进到微服务领域,这些组件都必须要交换数据。但即使您正在编写一个单体应用程序,其代码库包含在一个项目中,您仍然可能需要与 API 或程序交换数据,或者简单地处理网站的前端和后端部分之间的数据流,这些部分可能不会使用相同的语言。

选择正确的信息交换格式至关重要。语言特定的格式具有优势,因为该语言本身很可能为您提供所有工具,使序列化反序列化变得轻而易举。然而,您将无法与用同一语言的不同版本或完全不同的语言编写的其他组件进行本地通信。无论未来看起来如何,只有在是给定情况下唯一可能的选择时,才应该采用语言特定的格式。

根据维基百科(en.wikipedia.org/wiki/Serialization):

在计算机科学中,序列化是将数据结构或对象状态转换为一种可以存储(例如,在文件或内存数据缓冲区中)或传输(例如,通过计算机网络)的格式,并在以后重建(可能在不同的计算机环境中)的过程。

一种更安全的方法是选择一种语言无关的格式。在软件中,一些流行的格式已经成为数据交换的事实标准。最著名的可能是XMLYAMLJSON。Python 标准库提供了xmljson模块,在 PyPI(pypi.org/)上,您可以找到一些用于处理 YAML 的不同包。

在 Python 环境中,JSON 可能是最常用的格式。它之所以胜过其他两种,是因为它是标准库的一部分,以及它的简单性。XML 往往相当冗长,难以阅读。

此外,当与像 PostgreSQL 这样的数据库一起工作时,能够使用原生 JSON 字段的能力使得在应用程序中也采用 JSON 具有很大的吸引力。

使用 JSON

JSONJavaScript Object Notation的缩写,它是 JavaScript 语言的一个子集。它已经存在了近二十年,因此它广为人知,并被大多数语言广泛采用,尽管它实际上是语言无关的。你可以在其网站上阅读所有关于它的信息(www.json.org/),但现在我们将给你一个快速介绍。

JSON 基于两种结构:

  • 一组名称/值对

  • 值的有序列表

毫不奇怪,这两个对象分别映射到 Python 中的dictlist数据类型。作为数据类型,JSON 提供字符串、数字、对象以及由truefalsenull组成的值。让我们通过一个快速示例开始:

# json_examples/json_basic.py
import sys
import json
data = {
    "big_number": 2**3141,
    "max_float": sys.float_info.max,
    "a_list": [2, 3, 5, 7],
}
json_data = json.dumps(data)
data_out = json.loads(json_data)
assert data == data_out  # json and back, data matches 

我们首先导入sysjson模块。然后,我们创建一个包含一些数字和一个整数列表的简单字典。我们想测试使用非常大的数字进行序列化和反序列化,包括intfloat,所以我们放入了 2 的 3141 次方以及系统可以处理的最大浮点数。

我们使用json.dumps()进行序列化,它将数据转换为 JSON 格式的字符串。然后,该数据被输入到json.loads()中,它执行相反的操作:从一个 JSON 格式的字符串中,它将数据重构为 Python。

注意,JSON 模块还提供了dumpload函数,它们可以将数据转换为文件对象并从文件对象转换数据。

在最后一行,通过断言,我们确保原始数据和通过 JSON 序列化/反序列化的结果相匹配。如果断言语句后面的条件为假,那么该语句将引发AssertionError。我们将在第十章测试中更详细地介绍断言。

在编程中,术语falsy指的是在布尔上下文中评估时被认为是假的对象或条件。

让我们看看如果我们打印 JSON 数据会是什么样子:

# json_examples/json_basic.py
info = {
    "full_name": "Sherlock Holmes",
    "address": {
        "street": "221B Baker St",
        "zip": "NW1 6XE",
        "city": "London",
        "country": "UK",
    },
}
print(json.dumps(info, indent=2, sort_keys=True)) 

在这个例子中,我们创建了一个包含福尔摩斯数据的字典。如果你像我们一样是福尔摩斯的粉丝,并且身处伦敦,你会在那个地址找到他的博物馆(我们推荐你去参观;虽然不大,但非常不错)。

注意我们是如何调用json.dumps()的。我们指示它使用两个空格缩进并按字母顺序排序键。结果是这个:

$ python json_basic.py
{
  "address": {
    "city": "London",
    "country": "UK",
    "street": "221B Baker St",
    "zip": "NW1 6XE"
  },
  "full_name": "Sherlock Holmes"
} 

与 Python 的相似性显而易见。唯一的区别是,如果你在字典中的最后一个元素后面放置一个逗号,这在 Python 中是惯例,JSON 将会抱怨。

让我们展示一些有趣的东西:

# json_examples/json_tuple.py
import json
data_in = {
    "a_tuple": (1, 2, 3, 4, 5),
}
json_data = json.dumps(data_in)
print(json_data)  # {"a_tuple": [1, 2, 3, 4, 5]}
data_out = json.loads(json_data)
print(data_out)  # {'a_tuple': [1, 2, 3, 4, 5]} 

在这个例子中,我们使用了一个元组而不是列表。有趣的是,从概念上讲,元组也是一个有序项列表。它没有列表的灵活性,但仍然,从 JSON 的角度来看,它被认为是相同的。因此,正如你通过第一个print()看到的,在 JSON 中元组被转换成了列表。自然地,那么,原始对象是一个元组的信息就丢失了,在反序列化发生时,原本是元组的东西被转换成了 Python 列表。在处理数据时,这一点很重要,因为涉及到只包含你可用数据结构子集的格式转换过程可能意味着信息丢失。在这种情况下,我们丢失了关于类型(元组与列表)的信息。

这实际上是一个常见问题。例如,你不能将所有 Python 对象序列化为 JSON,因为并不总是清楚 JSON 应该如何还原那个对象。以datetime为例。该类的一个实例是一个 JSON 无法序列化的 Python 对象。如果我们将其转换为如2018-03-04T12:00:30Z这样的字符串,这是 ISO 8601 格式的日期和时间以及时区信息,那么在反序列化时 JSON 应该怎么做?它应该决定这可以反序列化为 datetime 对象,所以我最好这么做,还是简单地将其视为字符串并保持原样?对于可以有多种解释的数据类型呢?

答案是,在处理数据交换时,我们通常需要在将对象序列化为 JSON 之前将其转换为更简单的格式。我们能够使数据简化得越多,在像 JSON 这样的格式中表示数据就越容易,而 JSON 有其局限性。

在某些情况下,尤其是内部使用时,能够序列化自定义对象非常有用,所以为了好玩,我们将通过两个例子来展示如何实现:复数和datetime对象。

使用 JSON 进行自定义编码/解码

在 JSON 的世界里,我们可以将编码/解码术语视为序列化/反序列化的同义词。它们基本上意味着转换到和从 JSON 转换回来。

在下面的例子中,我们将学习如何通过编写自定义编码器来编码复数——默认情况下复数不能序列化为 JSON:

# json_examples/json_cplx.py
import json
class ComplexEncoder(json.JSONEncoder):
    def default(self, obj):
        print(f"ComplexEncoder.default: {obj=}")
        if isinstance(obj, complex):
            return {
                "_meta": "complex",
                "num": [obj.real, obj.imag],
            }
        return super().default(obj)
data = {
    "an_int": 42,
    "a_float": 3.14159265,
    "a_complex": 3 + 4j,
}
json_data = json.dumps(data, cls=ComplexEncoder)
print(json_data)
def object_hook(obj):
    print(f"object_hook: {obj=}")
    try:
        if obj["_meta"] == "complex":
            return complex(*obj["num"])
    except KeyError:
        return obj
data_out = json.loads(json_data, object_hook=object_hook)
print(data_out) 

我们首先定义一个ComplexEncoder类,作为JSONEncoder的子类。这个类重写了default()方法。每当编码器遇到它无法原生编码的对象时,都会调用这个方法,并期望它返回该对象的可编码表示。

default() 方法检查其参数是否是一个 complex 对象,如果是的话,它将返回一个包含一些自定义元信息和包含数字实部和虚部的列表的字典。这就是我们避免丢失复数信息所需做的全部工作。如果我们收到除 complex 实例之外的其他任何内容,我们将从父类调用 default() 方法。

在示例中,我们随后调用了 json.dumps(),但这次我们使用 cls 参数来指定自定义编码器。最后,结果被打印出来:

$ python json_cplx.py
ComplexEncoder.default: obj=(3+4j)
{
    "an_int": 42, "a_float": 3.14159265,
    "a_complex": {"_meta": "complex", "num": [3.0, 4.0]}
} 

有一半的工作已经完成了。对于反序列化部分,我们本可以编写另一个从 JSONDecoder 继承的类,但相反,我们选择使用一种更简单的技术,它使用一个小的函数:object_hook()

object_hook() 的主体中,我们找到一个 try 块。重要的是 try 块主体中的两行。该函数接收一个对象(注意,只有当 obj 是字典时,该函数才会被调用),如果元数据与我们的复数约定相匹配,我们将实部和虚部传递给 complex() 函数。try / except 块的存在是因为我们的函数将为每个解码的字典对象被调用,因此我们需要处理 _meta 键不存在的情况。

示例的反序列化部分输出:

object_hook:
  obj={'_meta': 'complex', 'num': [3.0, 4.0]}
object_hook:
  obj={'an_int': 42, 'a_float': 3.14159265, 'a_complex': (3+4j)}
{'an_int': 42, 'a_float': 3.14159265, 'a_complex': (3+4j)} 

你可以看到 a_complex 已经被正确反序列化。作为练习,我们建议编写你自己的自定义编码器,用于 FractionDecimal 对象。

让我们考虑一个稍微复杂一些(不是字面意义上的)例子:处理 datetime 对象。我们将把代码分成两个部分,首先是序列化部分,然后是反序列化部分:

# json_examples/json_datetime.py
import json
from datetime import datetime, timedelta, timezone
now = datetime.now()
now_tz = datetime.now(tz=timezone(timedelta(hours=1)))
class DatetimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            try:
                off = obj.utcoffset().seconds
            except AttributeError:
                off = None
            return {
                "_meta": "datetime",
                "data": obj.timetuple()[:6] + (obj.microsecond,),
                "utcoffset": off,
            }
        return super().default(obj)
data = {
    "an_int": 42,
    "a_float": 3.14159265,
    "a_datetime": now,
    "a_datetime_tz": now_tz,
}
json_data = json.dumps(data, cls=DatetimeEncoder)
print(json_data) 

这个例子之所以稍微复杂一些,是因为 Python 中的 datetime 对象可以是时区感知的,也可以不是;因此,我们需要小心处理它们。流程与之前相同,只是我们现在处理的是不同的数据类型。我们首先获取当前的日期和时间信息,并且我们在没有(now)和有(now_tz)时区感知的情况下都这样做。然后我们继续定义一个自定义编码器,就像之前一样,覆盖了 default() 方法。该方法中的重要部分是我们如何获取时区偏移量(off)信息(以秒为单位),以及我们如何构建返回数据的字典。这次,元数据表明这是 datetime 信息。我们将时间元组的前六项(年、月、日、时、分和秒)保存在 data 键中,以及之后的微秒,然后是偏移量。如果你能看出 "data" 的值是元组的拼接,那么你做得很好。

在自定义编码器之后,我们继续创建一些数据,然后对其进行序列化。print() 语句输出了以下内容(我们已重新格式化输出,使其更易于阅读):

$ python json_datetime.py
{
    "an_int": 42,
    "a_float": 3.14159265,
    "a_datetime": {
        "_meta": "datetime",
        "data": [2024, 3, 29, 23, 24, 22, 232302],
        "utcoffset": null,
    },
    "a_datetime_tz": {
        "_meta": "datetime",
        "data": [2024, 3, 30, 0, 24, 22, 232316],
        "utcoffset": 3600,
    },
} 

有趣的是,我们发现 None 被翻译成了其 JavaScript 等价物 null。此外,我们可以看到数据似乎已经被正确编码。让我们继续脚本的第二部分:

# json_examples/json_datetime.py
def object_hook(obj):
    try:
        if obj["_meta"] == "datetime":
            if obj["utcoffset"] is None:
                tz = None
            else:
                tz = timezone(timedelta(seconds=obj["utcoffset"]))
            return datetime(*obj["data"], tzinfo=tz)
    except KeyError:
        return obj
data_out = json.loads(json_data, object_hook=object_hook)
print(data_out) 

再次,我们首先验证元数据告诉我们它是一个 datetime,然后我们继续获取时区信息。一旦我们有了它,我们就将 7 元组(使用 * 在调用中解包其值)和时区信息传递给 datetime() 调用,得到我们原始的对象。让我们通过打印 data_out 来验证它:

{
    "an_int": 42,
    "a_float": 3.14159265,
    "a_datetime": datetime.datetime(
        2024, 3, 29, 23, 24, 22, 232302
    ),
    "a_datetime_tz": datetime.datetime(
        2024, 3, 30, 0, 24, 22, 232316,
        tzinfo=datetime.timezone(
            datetime.timedelta(seconds=3600)
        ),
    ),
} 

正如你所见,我们正确地获取了所有内容。作为练习,我们建议你编写相同的逻辑,但针对 date 对象,这应该会简单一些。

在我们继续下一个主题之前,有一个警告。可能这听起来有些反直觉,但处理 datetime 对象可能相当棘手,所以我们虽然相当确信这段代码正在做它应该做的事情,但我们想强调我们只是对其进行了表面测试。因此,如果你打算使用它,请务必彻底测试。测试不同的时区,测试夏令时是否开启或关闭,测试纪元之前的日期,等等。你可能会发现本节中的代码需要一些修改才能适应你的情况。

I/O、流和请求

I/O 代表 输入/输出,它广泛地指代计算机与外部世界之间的通信。有几种不同的 I/O 类型,本章的范围不包括解释所有这些类型,但值得通过几个例子来了解。第一个例子将介绍 io.StringIO 类,这是一个用于文本 I/O 的内存流。第二个例子将超出我们计算机的本地性,演示如何执行 HTTP 请求。

使用内存中的流

内存中的对象在多种情况下都可能很有用。内存比硬盘快得多,它总是可用,对于少量数据来说可能是完美的选择。

让我们看看第一个例子:

# io_examples/string_io.py
import io
stream = io.StringIO()
stream.write("Learning Python Programming.\n")
print("Become a Python ninja!", file=stream)
contents = stream.getvalue()
print(contents)
stream.close() 

在前面的代码片段中,我们从标准库中导入了 io 模块。这个模块包含了许多与流和 I/O 相关的工具。其中之一是 StringIO,它是一个内存缓冲区,我们在其中使用了两种不同的方法写入了两个句子,就像我们在本章的第一个例子中使用文件一样。

当你需要时,StringIO 很有用:

  • 模拟字符串的文件-like 行为。

  • 测试与文件对象一起工作的代码,而不使用实际文件。

  • 高效地构建或操作大字符串。

  • 为了测试目的捕获或模拟输入/输出。测试运行得更快,因为它们避免了磁盘 I/O。

我们可以调用 StringIO.write(),或者我们可以使用 print(),指示它将数据导向我们的流。

通过调用 getvalue(),我们可以获取流的内 容。然后我们继续打印它,最后关闭它。调用 close() 会导致文本缓冲区立即被丢弃。

有一种更优雅的方式来编写之前的代码:

# io_examples/string_io.py
with io.StringIO() as stream:
    stream.write("Learning Python Programming.\n")
    print("Become a Python ninja!", file=stream)
    contents = stream.getvalue()
    print(contents) 

就像内置的 open() 函数一样,io.StringIO() 也在上下文管理器块中工作得很好。注意与 open() 的相似性;在这种情况下,我们也不需要手动关闭流。

当运行脚本时,输出如下:

$ python string_io.py
Learning Python Programming.
Become a Python ninja! 

现在让我们继续第二个示例。

发送 HTTP 请求

在本节中,我们探讨了两个 HTTP 请求的例子。我们将使用 requests 库来演示这些例子,你可以使用 pip 安装它,并且它包含在本章节的要求文件中。

我们将向 httpbin.org(httpbin.org/)API 发起 HTTP 请求,有趣的是,这个 API 是由 requests 库的创建者 Kenneth Reitz 开发的。Httpbin 是一个简单的 HTTP 请求和响应服务,当我们想要实验 HTTP 协议时非常有用。

这个库是最广泛采用的之一:

# io_examples/reqs.py
import requests
urls = {
    "get": "https://httpbin.org/get?t=learn+python+programming",
    "headers": "https://httpbin.org/headers",
    "ip": "https://httpbin.org/ip",
    "user-agent": "https://httpbin.org/user-agent",
    "UUID": "https://httpbin.org/uuid",
    "JSON": "https://httpbin.org/json",
}
def get_content(title, url):
    resp = requests.get(url)
    print(f"Response for {title}")
    print(resp.json())
for title, url in urls.items():
    get_content(title, url)
    print("-" * 40) 

上述代码片段应该很简单。我们声明了一个字典,其中包含了我们想要对其发起 HTTP 请求的 URL。我们将执行请求的代码封装到了 get_content() 函数中。正如你所看到的,我们执行了一个 GET 请求(通过使用 requests.get() ),并打印了响应的标题和 JSON 解码后的响应体。让我们花点时间来谈谈最后这部分。

当我们向一个网站或 API 发起请求时,我们会收到一个响应对象,该对象封装了服务器返回的数据。httpbin.org 的一些响应体恰好是 JSON 编码的,因此我们不是直接读取 resp.text 并手动调用 json.loads() 来解码,而是通过使用响应对象的 json() 方法将两者结合起来。requests 包之所以被广泛采用,有很多原因,其中之一就是它的易用性。

现在,当你在你应用程序中发起请求时,你将希望有一个更健壮的方法来处理错误等,但在这个章节中,一个简单的例子就足够了。我们将在 第十四章API 开发简介 中看到更多请求的例子。

回到我们的代码,最后我们运行一个 for 循环并获取所有 URL。当你运行它时,你将在控制台上看到每个调用的结果打印出来,它应该看起来像这样(为了简洁而进行了美化并裁剪):

$ python reqs.py
Response for get
{
    "args": {"t": "learn python programming"},
    "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate",
        "Host": "httpbin.org",
        "User-Agent": "python-requests/2.31.0",
        "X-Amzn-Trace-Id": "Root=1-123abc-123abc",
    },
    "origin": "86.14.44.233",
    "url": "https://httpbin.org/get?t=learn+python+programming",
}
… rest of the output omitted … 

注意,你可能会得到一些关于版本号和 IP 的不同输出,这是正常的。现在,GET 只是 HTTP 动词之一,尽管是最常用的之一。让我们也看看如何使用 POST 动词。当你需要向服务器发送数据时,例如请求创建资源,你会发起一个 POST 请求。所以,让我们尝试通过编程来发起一个请求:

# io_examples/reqs_post.py
import requests
url = "https://httpbin.org/post"
data = dict(title="Learn Python Programming")
resp = requests.post(url, data=data)
print("Response for POST")
print(resp.json()) 

前面的代码与我们之前看到的非常相似,只是这次我们没有调用get(),而是调用post(),因为我们想发送一些数据,我们在调用中指定了这一点。requests库提供了比这更多的功能。这是一个我们鼓励你检查和探索的项目,因为它很可能你也会用到它。

运行前面的脚本(并对输出应用一些美化魔法)会产生以下结果:

$ python reqs_post.py
Response for POST
{
    "args": {},
    "data": "",
    "files": {},
    "form": {"title": "Learn Python Programming"},
    "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate",
        "Content-Length": "30",
        "Content-Type": "application/x-www-form-urlencoded",
        "Host": "httpbin.org",
        "User-Agent": "python-requests/2.31.0",
        "X-Amzn-Trace-Id": "Root=1-123abc-123abc",
    },
    "json": None,
    "origin": "86.14.44.233",
    "url": "https://httpbin.org/post",
} 

注意现在头部已经不同了,我们找到了以响应体的键/值对形式发送的数据。

我们希望这些简短的例子足以让你开始,特别是对于请求。网络每天都在变化,因此学习基础知识并时不时地复习是值得的。

在磁盘上持久化数据

在本章的这一节中,我们将探讨如何以三种不同的格式在磁盘上持久化数据。持久化数据意味着数据被写入非易失性存储,例如硬盘驱动器,并且当写入它的进程结束其生命周期时,数据不会被删除。我们将探讨pickleshelve模块,以及一个简短的例子,该例子将涉及使用SQLAlchemy访问数据库,SQLAlchemy 可能是 Python 生态系统中最广泛采用的 ORM 库。

使用 pickle 序列化数据

Python 标准库中的pickle模块提供了将 Python 对象转换为字节流以及相反的工具。尽管picklejson暴露的 API 有部分重叠,但这两个模块相当不同。正如我们在本章前面所见,JSON 是一种人类可读的文本格式,语言无关,仅支持 Python 数据类型的一个受限子集。另一方面,pickle模块不是人类可读的,转换为字节,是 Python 特定的,并且,多亏了 Python 出色的内省能力,支持大量数据类型。

除了picklejson之间的这些差异之外,还有一些重要的安全问题需要你注意,如果你考虑使用pickle的话。从不受信任的来源反序列化错误或恶意数据可能是危险的,因此如果我们决定在我们的应用程序中采用它,我们需要格外小心。

如果你确实使用pickle,你应该考虑使用加密签名来确保你的序列化数据没有被篡改。我们将在第九章密码学和令牌中看到如何在 Python 中生成加密签名。

话虽如此,让我们通过一个简单的例子来看看它的实际应用:

# persistence/pickler.py
import pickle
from dataclasses import dataclass
@dataclass
class Person:
    first_name: str
    last_name: str
    id: int
    def greet(self):
        print(
            f"Hi, I am {self.first_name} {self.last_name}"
            f" and my ID is {self.id}"
        )
people = [
    Person("Obi-Wan", "Kenobi", 123),
    Person("Anakin", "Skywalker", 456),
]
# save data in binary format to a file
with open("data.pickle", "wb") as stream:
    pickle.dump(people, stream)
# load data from a file
with open("data.pickle", "rb") as stream:
    peeps = pickle.load(stream)
for person in peeps:
    person.greet() 

在这个例子中,我们使用dataclass装饰器创建了一个Person类,这在第六章面向对象编程、装饰器和迭代器中我们见过。我们之所以用dataclass写这个例子,只是为了向你展示pickle处理它有多么轻松,我们不需要为简单数据类型做任何额外的事情。

这个类有三个属性:first_namelast_nameid。它还公开了一个greet()方法,该方法使用实例数据打印一条问候消息。

我们创建一个实例列表并将其保存到文件中。为此,我们使用pickle.dump(),向其中提供要序列化的内容,以及我们想要写入的流。紧接着,我们使用pickle.load()从同一个文件中读取,将流中的整个内容转换回 Python 对象。为了确保对象已正确转换,我们在它们两个上调用greet()方法。结果是以下内容:

$ python pickler.py
Hi, I am Obi-Wan Kenobi and my ID is 123
Hi, I am Anakin Skywalker and my ID is 456 

pickle模块还允许你通过dumps()loads()函数(注意两个名称末尾的s)将数据转换为(和从)字节对象。在日常应用中,pickle通常在我们需要持久化不应与其他应用程序交换的 Python 数据时使用。几年前我们遇到的一个例子是一个flask插件的会话管理器,它在将会话对象存储到 Redis 数据库之前将其序列化。然而,在实践中,你不太可能经常需要处理这个库。

另一个可能使用得更少的工具,但在资源不足时证明是有用的,是shelve

使用 shelve 保存数据

“书架”是一个持久的类似字典的对象。它的美妙之处在于,你可以将任何可以pickle的对象保存到书架中,因此你不会像使用数据库那样受到限制。尽管有趣且有用,但在实际应用中shelve模块的使用相当罕见。为了完整性,让我们快速看看它的工作示例:

# persistence/shelf.py
import shelve
class Person:
    def __init__(self, name, id):
        self.name = name
        self.id = id
with shelve.open("shelf1.shelve") as db:
    db["obi1"] = Person("Obi-Wan", 123)
    db["ani"] = Person("Anakin", 456)
    db["a_list"] = [2, 3, 5]
    db["delete_me"] = "we will have to delete this one..."
    print(
        list(db.keys())
    )  # ['ani', 'delete_me', 'a_list', 'obi1']
    del db["delete_me"]  # gone!
    print(list(db.keys()))  # ['ani', 'a_list', 'obi1']
    print("delete_me" in db)  # False
    print("ani" in db)  # True
    a_list = db["a_list"]
    a_list.append(7)
    db["a_list"] = a_list
    print(db["a_list"])  # [2, 3, 5, 7] 

除了相关的连接和模板代码之外,这个例子类似于字典练习。我们创建一个Person类,然后在上下文管理器中打开一个shelve文件。正如你所看到的,我们使用字典语法存储了四个对象:两个Person实例、一个列表和一个字符串。如果我们打印键,我们会得到一个包含我们使用的四个键的列表。在打印之后,我们立即从书架中删除(恰如其名)的delete_me键/值对。再次打印键时,我们可以看到删除已成功。然后我们测试几个键的成员资格,最后,我们将数字7追加到a_list。注意,我们必须从书架中提取列表,修改它,然后再保存。

另一种打开书架的方法可以稍微加快这个过程:

# persistence/shelf.py
with shelve.open("shelf2.shelve", writeback=True) as db:
    db["a_list"] = [11, 13, 17]
    db["a_list"].append(19)  # in-place append!
    print(db["a_list"])  # [11, 13, 17, 19] 

通过以writeback=True打开书架,我们启用了writeback功能,这使得我们可以像在常规字典中添加值一样简单地追加到a_list。这个功能默认不激活的原因是,它伴随着你在内存消耗和关闭书架时需要付出的代价。

既然我们已经向与数据持久性相关的标准库模块致敬,让我们来看看 Python 生态系统中最广泛采用的 ORM 之一:SQLAlchemy。

将数据保存到数据库中

对于这个例子,我们将使用一个内存数据库,这将使事情对我们来说更简单。在本书的源代码中,我们留下了一些注释来展示如何生成 SQLite 文件,所以我们希望您也能探索这个选项。

您可以在dbeaver.io/找到免费的 SQLite 数据库浏览器。DBeaver 是一款免费的跨平台数据库工具,适用于开发者、数据库管理员、分析师以及所有需要与数据库打交道的人。它支持所有流行的数据库:MySQL、PostgreSQL、SQLite、Oracle、DB2、SQL Server、Sybase、MS Access、Teradata、Firebird、Apache Hive、Phoenix、Presto 等。

在我们深入代码之前,让我们简要介绍一下关系数据库的概念。

关系数据库是一种允许您按照 1969 年由爱德华·F·科德发明的关系模型保存数据的数据库。在这个模型中,数据存储在一个或多个表中。每个表都有行(也称为记录元组),每行代表表中的一个条目。表也有列(也称为属性),每列代表记录的一个属性。每个记录通过一个唯一键来识别,更常见的是主键,它由表中的一列或多列组成。为了给您一个例子:想象一个名为Users的表,有idusernamepasswordnamesurname列。

这样的表非常适合包含我们系统的用户;每一行代表一个不同的用户。例如,具有值3fabmy_wonderful_pwdFabrizioRomano的行将代表系统中的 Fabrizio 用户。

该模型被称为关系,因为您可以在表之间建立关系。例如,如果您向这个数据库添加一个名为PhoneNumbers的表,您可以将电话号码插入其中,然后通过关系确定哪个电话号码属于哪个用户。

要查询关系型数据库,我们需要一种特殊的语言。主要的标准化语言被称为SQL,即结构化查询语言。它起源于关系代数,这是一种用于操作和查询存储在关系型数据库中的数据的正式系统和理论框架。你可以执行的最常见操作通常涉及对行或列进行过滤、连接表、根据某些标准聚合结果等。以英语为例,对我们假设的数据库进行查询可能是:检索所有用户名以“m”开头且最多有一个电话号码的用户(username, name, surname)。在这个例子中,我们正在查询数据库中行的一个子集,并且只对User表中的三个列感兴趣的结果。我们通过只选择以字母m开头的用户进行过滤,更进一步,只选择最多有一个电话号码的用户。

每个数据库都附带其自己的风味SQL。它们都在某种程度上尊重标准,但没有一个完全遵守,它们在某种程度上都各不相同。这在现代软件开发中引发了一个问题。如果我们的应用程序包含原始 SQL 代码,那么如果我们决定使用不同的数据库引擎,或者可能是同一引擎的不同版本,我们可能需要修改应用程序中的 SQL 代码。

这可能会相当痛苦,尤其是由于 SQL 查询可能非常复杂。为了减轻这个问题,计算机科学家们创建了将编程语言的对象映射到关系型数据库表的代码。不出所料,这种工具的名称是对象关系映射ORM)。

在现代应用程序开发中,人们通常会通过使用 ORM(对象关系映射)来开始与数据库交互。如果他们发现自己无法通过 ORM 执行某个查询,那么他们就会,而且只有在这种情况下,才会直接使用 SQL。这是在完全没有 SQL 和完全不使用 ORM 之间的一种良好折衷,这意味着专门化与数据库交互的代码,具有上述缺点。

在本节中,我们想展示一个利用 SQLAlchemy 的例子,它是最受欢迎的第三方 Python ORM 之一。您需要将此章节的虚拟环境中安装它。我们将定义两个模型(PersonEmail),每个模型都映射到一个表,然后我们将填充数据库并在其上执行一些查询。

让我们从模型声明开始:

# persistence/alchemy_models.py
from sqlalchemy import ForeignKey, String, Integer
from sqlalchemy.orm import (
    DeclarativeBase,
    mapped_column,
    relationship,
) 

在开始时,我们导入一些函数和类型。然后我们继续编写PersonEmail类,以及它们必需的基类。让我们看看这些定义:

# persistence/alchemy_models.py
class Base(DeclarativeBase):
    pass
class Person(Base):
    __tablename__ = "person"
    id = mapped_column(Integer, primary_key=True)
    name = mapped_column(String)
    age = mapped_column(Integer)
    emails = relationship(
        "Email",
        back_populates="person",
        order_by="Email.email",
        cascade="all, delete-orphan",
    )
    def __repr__(self):
        return f"{self.name}(id={self.id})"
class Email(Base):
    __tablename__ = "email"
    id = mapped_column(Integer, primary_key=True)
    email = mapped_column(String)
    person_id = mapped_column(ForeignKey("person.id"))
    person = relationship("Person", back_populates="emails")
    def __str__(self):
        return self.email
    __repr__ = __str__ 

每个模型都继承自 Base 类,在这个例子中,它是一个简单地继承自 SQLAlchemy 的 DeclarativeBase 的类。我们定义了 Person,它映射到名为 "person" 的表,并公开了 idnameage 属性。我们还通过声明访问 emails 属性将检索与特定 Person 实例相关的 "Email" 表中的所有条目来声明与 "Email" 模型的关系。cascade 选项影响创建和删除的工作方式,但它是一个更高级的概念,所以我们建议你现在忽略它,也许以后再深入研究。

我们最后声明的是 __repr__() 方法,它为我们提供了对象的官方字符串表示形式。这个表示形式应该能够用来完全重建对象,但在这个例子中,我们只是简单地用它来提供输出。Python 将 repr(obj) 重定向到对 obj.__repr__() 的调用。

我们还声明了 "Email" 模型,它映射到名为 "email" 的表,并将包含电子邮件地址以及属于这些电子邮件地址的人的引用。你可以看到 person_idperson 属性都是关于在 "Email""Person" 类之间设置关系。注意我们还如何在 "Email" 上声明 __str__() 方法,然后将其分配给一个名为 __repr__() 的别名。这意味着在 "Email" 对象上调用 repr()str() 最终都会调用 __str__() 方法。这在 Python 中是一种相当常见的技巧,用于避免重复相同的代码,所以我们有机会在这里向你展示它。

对这段代码的深入理解需要比我们所能提供的空间更多,所以我们鼓励你阅读有关 数据库管理系统DBMS)、SQL、关系代数和 SQLAlchemy 的资料。

现在我们有了我们的模型,让我们使用它们来持久化一些数据。

看看下面的例子(这里展示的所有片段,除非另有说明,都属于 persistence 文件夹中的 alchemy.py 文件):

# persistence/alchemy.py
from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session
from alchemy_models import Person, Email, Base
# swap these lines to work with an actual DB file
# engine = create_engine('sqlite:///example.db')
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine) 

首先,我们导入我们需要的函数和类。然后我们继续为应用程序创建一个引擎,最后我们指示 SQLAlchemy 通过给定的引擎创建所有表。

create_engine() 函数支持一个名为 echo 的参数,可以设置为 TrueFalse 或字符串 "debug",以启用不同级别的所有语句和它们参数的 repr() 的日志记录。请参阅 SQLAlchemy 的官方文档以获取更多信息。

在 SQLAlchemy 中,引擎是一个核心组件,它作为 Python 应用程序和数据库之间的主要接口。它管理数据库交互的两个关键方面:连接和 SQL 语句执行。

在导入和创建引擎以及表之后,我们通过使用我们刚刚创建的引擎设置了一个会话,使用上下文管理器。我们首先创建两个 Person 对象:

with Session(engine) as session:
    anakin = Person(name="Anakin Skywalker", age=32)
    obione = Person(name="Obi-Wan Kenobi", age=40) 

我们随后使用两种不同的技术给这两个对象添加电子邮件地址。一种是将它们分配给一个列表,另一种则是简单地追加:

 obione.emails = [
        Email(email="obi1@example.com"),
        Email(email="wanwan@example.com"),
    ]
    anakin.emails.append(Email(email="ani@example.com"))
    anakin.emails.append(Email(email="evil.dart@example.com"))
    anakin.emails.append(Email(email="vader@example.com")) 

我们还没有接触数据库。只有当我们使用session对象时,其中才会发生实际的操作:

 session.add(anakin)
    session.add(obione)
    session.commit() 

添加两个Person实例也足以添加它们的电子邮件地址(这要归功于级联效应)。调用commit()会导致 SQLAlchemy 提交事务并将数据保存到数据库中。

事务是一个提供类似沙盒的操作,但在数据库上下文中。只要事务没有被提交,我们就可以回滚对数据库所做的任何修改,并通过这样做,回到开始事务之前的状态。SQLAlchemy 提供了更复杂和更细粒度的处理事务的方法,您可以在其官方文档中学习,这是一个相当高级的话题。

我们现在使用like()查询所有名字以Obi开头的所有人,这会连接到 SQL 中的LIKE运算符:

 obione = session.scalar(
        select(Person).where(Person.name.like("Obi%"))
    )
    print(obione, obione.emails) 

我们查询该查询的第一个结果(我们知道我们只有欧比旺)并打印它。然后我们通过使用对名字的精确匹配来获取anakin,只是为了展示另一种过滤方式:

 anakin = session.scalar(
        select(Person).where(Person.name == "Anakin Skywalker")
    )
    print(anakin, anakin.emails) 

我们随后捕获安纳金的 ID,并从全局框架中删除anakin对象(这并不会从数据库中删除条目):

 anakin_id = anakin.id
    del anakin 

我们这样做的原因是我们想向您展示如何通过 ID 获取对象。为了显示数据库的全部内容,我们编写了一个display_info()函数。它通过从Email的关系中获取电子邮件地址和人员对象来工作,同时也提供了每个模型的所有对象的计数。在这个模块中,这个函数在进入提供会话的上下文管理器之前定义:

def display_info(session):
    # get all emails first
    emails = select(Email)
    # display results
    print("All emails:")
    for email in session.scalars(emails):
        print(f" - {email.person.name} <{email.email}>")
    # display how many objects we have in total
    people = session.scalar(
        select(func.count()).select_from(Person)
    )
    emails = session.scalar(
        select(func.count()).select_from(Email)
    )
    print("Summary:")
    print(f" {people=}, {emails=}") 

我们调用这个函数,然后获取并删除anakin。最后,我们再次显示信息以验证他确实已经从数据库中消失:

 display_info(session)
    anakin = session.get(Person, anakin_id)
    session.delete(anakin)
    session.commit()
    display_info(session) 

所有这些片段的输出合并在一起如下(为了您的方便,我们已经将输出分为四个部分,以反映产生该输出的四个代码块):

$ python alchemy.py
Obi-Wan Kenobi(id=2) [obi1@example.com, wanwan@example.com]
Anakin Skywalker(id=1) [
    ani@example.com, evil.dart@example.com, vader@example.com
]
All emails:
 - Anakin Skywalker <ani@example.com>
 - Anakin Skywalker <evil.dart@example.com>
 - Anakin Skywalker <vader@example.com>
 - Obi-Wan Kenobi <obi1@example.com>
 - Obi-Wan Kenobi <wanwan@example.com>
Summary:
 people=2, emails=5
All emails:
 - Obi-Wan Kenobi <obi1@example.com>
 - Obi-Wan Kenobi <wanwan@example.com>
Summary:
 people=1, emails=2 

如您从最后两个块中看到的,删除anakin已经删除了一个Person对象及其关联的三个电子邮件地址。再次强调,这是因为当我们删除anakin时发生了级联。

这就结束了我们对数据持久性的简要介绍。这是一个庞大且有时复杂的领域,我们鼓励您去探索,尽可能多地学习理论。在数据库系统方面,知识的缺乏或理解不当可能会影响系统中的错误数量以及其性能。

配置文件

配置文件是许多 Python 应用程序的关键组成部分。它们允许开发者将主应用程序代码与设置和参数分离。这种分离对于维护、管理和分发软件非常有帮助,尤其是在应用程序需要在不同的环境中运行时——例如开发、生产和测试——并且具有不同的配置。

配置文件允许:

  • 灵活性:用户可以在不修改应用程序代码的情况下更改应用程序的行为。这对于在不同环境中部署的应用程序或需要数据库、API 密钥等凭证的应用程序特别有用。

  • 安全性:敏感信息,如认证凭证、API 密钥或秘密令牌,应从源代码中移除,并独立于代码库进行管理。

常见格式

配置文件可以写成几种格式,每种格式都有自己的语法和功能。一些流行的格式是 INIJSONYAMLTOML.env

在本节中,我们将简要探讨 INITOML 格式。在 第十四章API 开发简介 中,我们还将使用 .env 文件。

INI 配置格式

INI 格式是一个简单的文本文件,分为几个部分。每个部分包含以键/值对形式表示的属性。

要了解更多关于此格式的信息,请访问 en.wikipedia.org/wiki/INI_file

让我们看看一个示例 INI 配置文件:

# config_files/config.ini
[owner]
name = Fabrizio Romano
dob = 1975-12-29T11:50:00Z
[DEFAULT]
title = Config INI example
host = 192.168.1.1
[database]
host = 192.168.1.255
user = redis
password = redis-password
db_range = [0, 32]
[database.primary]
port = 6379
connection_max = 5000
[database.secondary]
port = 6380
connection_max = 4000 

在前面的文本中,有一些部分专门用于数据库连接。常见属性可以在 database 部分找到,而特定属性则放在 .primary.secondary 部分中,分别代表连接到 数据库的配置。还有一个 owner 部分和一个 DEFAULT 部分。

要在应用程序中读取此配置,我们可以使用标准库中的 configparser 模块(docs.python.org/3/library/configparser.html)。它非常直观,因为它将生成一个类似于字典的对象,并且额外的好处是 DEFAULT 部分会自动为所有其他部分提供值。

让我们看看一个来自 Python 脚本的一个示例会话:

# config_files/config-ini.txt
>>> import configparser
>>> config = configparser.ConfigParser()
>>> config.read("config.ini")
['config.ini']
>>> config.sections()
['owner', 'database', 'database.primary', 'database.secondary']
>>> config.items("database")
[
    ('title', 'Config INI example'), ('host', '192.168.1.255'),
    ('user', 'redis'), ('password', 'redis-password'),
    ('db_range', '[0, 32]')
]
>>> config["database"]
<Section: database>
>>> dict(config["database"])
{
    'host': '192.168.1.255', 'user': 'redis',
    'password': 'redis-password', 'db_range': '[0, 32]',
    'title': 'Config INI example'
}
>>> config["DEFAULT"]["host"]
'192.168.1.1'
>>> dict(config["database.secondary"])
{
    'port': '6380', 'connection_max': '4000',
    'title': 'Config INI example', 'host': '192.168.1.1'
}
>>> config.getint("database.primary", "port")
6379 

注意我们如何导入 configparser 并使用它来创建一个 config 对象。此对象公开了各种方法;您可以获取部分列表,以及检索其中的任何值。

在内部,configparser 将值存储为字符串,因此如果我们想将它们用作它们所代表的 Python 对象,我们需要适当地进行类型转换。ConfigParser 对象上有一些方法,例如 getint()getfloat()getboolean(),它们将检索一个值并将其转换为指定的类型,但如您所见,这个列表相当短。

注意,来自 DEFAULT 部分的属性被注入到所有其他部分中。此外,当一个部分定义了一个也存在于 DEFAULT 部分的键时,原始部分的值不会被 DEFAULT 部分的值覆盖。你可以在高亮显示的代码中看到一个示例,它显示 title 属性存在于 database 部分中,而 host 属性存在于两个部分中,它正确地保留了 '192.168.1.255' 的值。

TOML 配置格式

TOML 格式在 Python 应用中相当流行,与 INI 格式相比,它具有更丰富的功能集。如果您想了解其语法,请参阅 toml.io/

这里,我们将看到一个快速示例,它遵循之前的示例。

# config_file/config.toml
title = "Config Example"
[owner]
name = "Fabrizio Romano"
dob = 1975-12-29T11:50:00Z
[database]
host = "192.168.1.255"
user = "redis"
password = "redis-password"
db_range = [0, 32]
[database.primary]
port = 6379
connection_max = 5000
[database.secondary]
port = 6380
connection_max = 4000 

这次,我们没有 DEFAULT 部分,属性指定略有不同,即字符串被引号包围,而数字则不是。

我们将使用标准库中的 tomllib 模块(docs.python.org/3/library/tomllib.html)来读取此配置:

# config_files/config-toml.txt
>>> import tomllib
>>> with open("config.toml", "rb") as f:
...     config = tomllib.load(f)
...
>>> config
{
    'title': 'Config Example',
    'owner': {
        'name': 'Fabrizio Romano',
        'dob': datetime.datetime(
            1975, 12, 29, 11, 50, tzinfo=datetime.timezone.utc
        )
    },
    'database': {
        'host': '192.168.1.255',
        'user': 'redis',
        'password': 'redis-password',
        'db_range': [0, 32],
        'primary': {'port': 6379, 'connection_max': 5000},
        'secondary': {'port': 6380, 'connection_max': 4000}
    }
}
>>> config["title"]
'Config Example'
>>> config["owner"]
{
    'name': 'Fabrizio Romano',
    'dob': datetime.datetime(
        1975, 12, 29, 11, 50, tzinfo=datetime.timezone.utc
    )
}
>>> config["database"]["primary"]
{'port': 6379, 'connection_max': 5000}
>>> config["database"]["db_range"]
[0, 32] 

注意这次,config 对象是一个字典。由于我们指定了 database.primarydatabase.secondary 部分,tomllib 创建了一个嵌套结构来表示它们。

使用 TOML,值会被正确地转换为 Python 对象。我们有字符串、数字、列表,甚至是从代表 Fabrizio 出生日期的 iso 格式化字符串创建的 datetime 对象。在 tomllib 文档页面上,你可以找到一个包含所有可能转换的表格。

摘要

在本章中,我们探讨了与文件和目录一起工作。我们学习了如何读取和写入文件,以及如何通过使用上下文管理器优雅地完成这些操作。我们还探讨了目录:如何递归和非递归地列出其内容。我们还了解了路径,它们是访问文件和目录的门户。

我们简要地看到了如何创建 ZIP 归档并提取其内容。本书的源代码还包含了一个使用不同压缩格式的示例:tar.gz

我们讨论了数据交换格式,并深入探讨了 JSON。我们编写了一些自定义编码器和解码器,用于特定的 Python 数据类型,并从中获得了乐趣。

然后,我们探讨了 I/O,包括内存流和 HTTP 请求。

我们看到了如何使用 pickleshelve 和 SQLAlchemy ORM 库持久化数据。

最后,我们探索了两种配置文件示例,使用了 INI 和 TOML 格式。

现在,你应该已经很好地理解了如何处理文件和数据持久化,我们希望你能花时间自己深入探索这些主题。

下一章将探讨密码学和令牌。

加入我们的 Discord 社区

加入我们的 Discord 社区空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第九章:密码学和令牌

“三个可以保守一个秘密,如果其中两个已经死了。”

——本杰明·富兰克林,《穷理查年鉴》

在这一简短的章节中,我们将为您简要概述 Python 标准库提供的加密服务。我们还将涉及 JSON Web Tokens,这是一种用于在双方之间安全表示声明的有趣标准。

我们将探讨以下内容:

  • Hashlib

  • HMAC

  • 秘密

  • 使用 PyJWT 处理 JSON Web Tokens,这似乎是处理 JWT 最流行的 Python 库

让我们先花一点时间来谈谈密码学及其为什么如此重要。

密码学的必要性

据估计,截至 2024 年,全球大约有 53.5 亿到 54.4 亿人使用互联网。每年,使用在线银行服务、在线购物或只是在社交媒体上与朋友和家人交谈的人数都在增加。所有这些人期望他们的钱是安全的,他们的交易是安全的,他们的对话是私密的。

因此,如果您是应用程序开发者,您必须非常重视安全性。无论您的应用程序有多小或有多不重要:安全性始终应该是您关注的焦点。

信息技术的安全性是通过采用多种不同手段实现的,但迄今为止,最重要的手段是密码学。您使用计算机或手机做的几乎所有事情都应该包含一个密码学层。例如,密码学用于确保在线支付的安全,以在网络中传输消息,即使有人截获它们,也无法读取它们,以及当您在云中备份文件时加密您的文件。

本章的目的不是教您所有密码学的复杂性——有整本书是专门讨论这个主题的。相反,我们将向您展示如何使用 Python 提供的工具来创建摘要、令牌,并在需要实现与密码学相关的内容时,总体上确保更安全。在阅读本章时,请注意,密码学远不止加密和解密数据;实际上,在整个章节中,您将找不到任何加密或解密的示例!

有用的指南

总是记住以下规则:不要尝试创建自己的哈希或加密函数。简单地说,不要这样做。使用已经存在的工具和函数。发明一个良好、坚实、健壮的算法来进行哈希或加密是非常困难的,因此最好将其留给专业密码学家。

理解密码学很重要,所以尽量多了解这个主题。网上有大量信息,但为了您的方便,我们将在本章末尾提供一些有用的参考资料。

现在,让我们深入探讨我们想要向您展示的第一个标准库模块:hashlib

Hashlib

此模块提供了对各种加密哈希算法的访问。这些是数学函数,可以接受任何大小的消息并产生一个固定大小的结果,该结果被称为哈希摘要。加密哈希有许多用途,从验证数据完整性到安全存储和验证密码。

理想情况下,加密哈希算法应该是:

  • 确定性:相同的消息应该总是产生相同的哈希值。

  • 不可逆性:从哈希值中确定原始消息应该是不可行的。

  • 抗碰撞性:找到两个不同的消息,它们产生相同的哈希值应该是困难的。

这些属性对于哈希的安全应用至关重要。例如,将密码仅以哈希形式存储被认为是强制性的。

不可逆属性确保即使发生数据泄露,攻击者掌握了密码数据库,他们也无法获取原始密码。将密码仅存储为哈希意味着验证用户登录时密码的唯一方法是计算他们提供的密码的哈希值,并将其与存储的哈希值进行比较。当然,如果哈希算法不是确定的,这将不起作用。

抗碰撞性也很重要。它确保数据完整性,因为如果哈希用于为数据提供指纹,那么当数据发生变化时,指纹也应该发生变化。抗碰撞性防止攻击者用一个具有相同哈希值的不同文档替换文档。此外,许多安全协议依赖于抗碰撞哈希函数保证的唯一性。

通过hashlib可用的算法集取决于您平台使用的底层库。然而,某些算法在所有系统中都是保证存在的。让我们看看如何找出可用的内容(请注意,您的结果可能与我们的不同):

# hlib.txt
>>> import hashlib
>>> hashlib.algorithms_available
{'sha3_256', 'sha224', 'blake2b', 'sha512_224', 'ripemd160',
 'sha1', 'sha512_256', 'sha3_512', 'sha512', 'sha384', 'sha3_384',
'sha3_224', 'shake_256', 'shake_128', 'sm3', 'md5-sha1', 'sha256',
'md5', 'blake2s'}
>>> hashlib.algorithms_guaranteed
{'sha512', 'sha3_256', 'shake_128', 'sha224', 'blake2b',
 'shake_256', 'sha384', 'sha1', 'sha3_512', 'sha3_384', 'sha256',
 'sha3_224', 'md5', 'blake2s'} 

通过打开 Python shell,我们可以获取我们系统可用的算法集。如果我们的应用程序与第三方应用程序通信,最好从保证的集合中选择一个算法,因为这意味着每个平台都支持它们。注意,其中许多以sha开头,代表安全哈希算法

让我们在同一个 shell 中继续;我们将为字节字符串b"Hash me now!"创建一个哈希:

>>> h = hashlib.blake2b()
>>> h.update(b"Hash me")
>>> h.update(b" now!")
>>> h.hexdigest()
'56441b566db9aafcf8cdad3a4729fa4b2bfaab0ada36155ece29f52ff70e1e9d'
'7f54cacfe44bc97c7e904cf79944357d023877929430bc58eb2dae168e73cedf'
>>> h.digest()
b'VD\x1bVm\xb9\xaa\xfc\xf8\xcd\xad:G)\xfaK+\xfa\xab\n\xda6\x15^'
b'\xce)\xf5/\xf7\x0e\x1e\x9d\x7fT\xca\xcf\xe4K\xc9|~\x90L\xf7'
b'\x99D5}\x028w\x92\x940\xbcX\xeb-\xae\x16\x8es\xce\xdf'
>>> h.block_size
128
>>> h.digest_size
64
>>> h.name
'blake2b' 

在这里,我们使用了blake2b()加密函数,它相当复杂,是在 Python 3.6 中添加的。在创建哈希对象h之后,我们分两步更新其消息。虽然我们不需要这样做,但有时我们需要对一次不可用的数据进行哈希处理,因此了解我们可以分步骤进行是很好的。

一旦我们添加了整个消息,我们就得到了摘要的十六进制表示。这将使用每个字节两个字符(因为每个字符代表四个位,即半个字节)。我们还得到了摘要的字节表示,然后我们检查其细节:它有一个块大小(散列算法的内部块大小,以字节为单位)为 128 字节,摘要大小(生成的散列大小,以字节为单位)为 64 字节,以及一个名称。

让我们看看如果我们使用sha512()而不是blake2b()函数,我们会得到什么:

>>> hashlib.sha512(b"Hash me too!").hexdigest()
'a0d169ac9487fc6c78c7db64b54aefd01bd245bbd1b90b6fe5648c3c4eb0ea7d'
'93e1be50127164f21bc8ddb3dd45a6b4306dfe9209f2677518259502fed27686' 

生成的散列与blake2b的长度相同。请注意,我们可以用一行代码构造散列对象并计算摘要。

散列是一个有趣的话题,当然,我们迄今为止看到的简单示例只是开始。blake2b()函数由于可以调整的许多参数,为我们提供了很大的灵活性。这意味着它可以适应不同的应用程序或调整以防止特定类型的攻击。

在这里,我们将简要讨论这些参数中的一个;对于完整细节,请参阅官方文档docs.python.org/3/library/hashlib.htmlperson参数非常有趣。它用于个性化散列,迫使它对相同的消息产生不同的摘要。这有助于提高在同一个应用程序中为不同目的使用相同散列函数时的安全性:

>>> import hashlib
>>> h1 = hashlib.blake2b(
...    b"Important data", digest_size=16, person=b"part-1")
>>> h2 = hashlib.blake2b(
...    b"Important data", digest_size=16, person=b"part-2")
>>> h3 = hashlib.blake2b(
...    b"Important data", digest_size=16)
>>> h1.hexdigest()
'c06b9af95d5aa6307e7e3fd025a15646'
>>> h2.hexdigest()
'9cb03be8f3114d0f06bddaedce2079c4'
>>> h3.hexdigest()
'7d35308ca3b042b5184728d2b1283d0d' 

在这里,我们还使用了digest_size参数来获取只有 16 字节长的散列。

通用散列函数,如blake2b()sha512(),不适合安全存储密码。通用散列函数在现代计算机上计算速度很快,这使得攻击者可以通过暴力破解(每秒尝试数百万种可能性,直到找到匹配项)来反转散列。像pbkdf2_hmac()这样的密钥派生算法被设计得足够慢,以使这种暴力破解攻击变得不可行。pbkdf2_hmac()密钥派生算法通过使用许多重复应用通用散列函数(迭代次数可以作为参数指定)来实现这一点。随着计算机变得越来越强大,随着时间的推移增加迭代次数变得很重要;否则,随着时间的推移,对我们数据进行暴力破解攻击成功的可能性会增加。

好的密码散列函数也应该使用。盐是一段随机数据,用于初始化散列函数;这随机化了算法的输出,并保护了针对已知散列表的攻击。pbkdf2_hmac()函数通过必需的salt参数支持盐的使用。

这是您可以使用pbkdf2_hmac()来散列密码的方法:

>>> import os
>>> dk = hashlib.pbkdf2_hmac("sha256", b"password123",
...     salt=os.urandom(16), iterations=200000)
>>> dk.hex()
'ac34579350cf6d05e01e745eb403fc50ac0e62fbeb553cbb895e834a77c37aed' 

注意,我们使用了os.urandom()来提供一个 16 字节的随机盐,正如文档中建议的那样。

通常,盐的值与哈希值一起存储。当用户尝试登录时,您的程序使用存储的盐来创建给定密码的哈希值,然后将其与存储的哈希值进行比较。使用相同的盐值确保当密码正确时,哈希值将是相同的。

我们鼓励您探索和实验这个模块,因为最终您将不得不使用它。现在,让我们继续讨论hmac模块。

HMAC

此模块实现了 RFC 2104(datatracker.ietf.org/doc/html/rfc2104.html)中描述的HMAC算法。HMAC(根据询问对象的不同,代表基于哈希的消息认证码密钥哈希消息认证码)是用于验证消息和验证它们没有被篡改的广泛使用机制。

该算法将消息与密钥结合并生成组合的哈希值。这个哈希值被称为消息认证码MAC)或签名。签名与消息一起存储或传输。您可以通过使用相同的密钥重新计算签名并与之前计算的签名进行比较来验证消息没有被篡改。密钥必须被仔细保护;否则,能够访问密钥的攻击者将能够修改消息并替换签名,从而破坏认证机制。

让我们看看如何计算 MAC 的一个小例子:

# hmc.py
import hmac
import hashlib
def calc_digest(key, message):
    key = bytes(key, "utf-8")
    message = bytes(message, "utf-8")
    dig = hmac.new(key, message, hashlib.sha256)
    return dig.hexdigest()
mac = calc_digest("secret-key", "Important Message") 

hmac.new()函数接受一个密钥、一个消息和要使用的哈希算法。它返回一个hmac对象,该对象具有与hashlib中的哈希对象类似的接口。密钥必须是一个bytesbytearray对象,而消息可以是任何bytes-like 对象。因此,我们在创建hmac实例(dig)之前将我们的keymessage转换为字节,我们使用它来获取哈希值的十六进制表示。

我们将在本章后面讨论 JWT 时,更详细地了解如何使用 HMAC 签名。在此之前,我们将快速查看secrets模块。

秘密

这个小模块是在 Python 3.6 中添加的,处理三件事:随机数、令牌和摘要比较。它使用底层操作系统提供的最安全的随机数生成器来生成适合在加密应用中使用令牌和随机数。让我们快速看看它提供了什么。

随机对象

我们可以使用三个函数来生成随机对象:

# secrs/secr_rand.py
import secrets
print(secrets.choice("Choose one of these words".split()))
print(secrets.randbelow(10**6))
print(secrets.randbits(32)) 

第一个函数choice()从非空序列中随机返回一个元素。第二个函数randbelow()生成介于 0 和您调用它的参数之间的随机整数,第三个函数randbits()生成包含给定数量随机位的整数。运行此代码将产生以下输出(当然,每次运行都会不同):

$ python secr_rand.py
one
133025
1509555468 

当你在密码学环境中需要随机性时,你应该使用这些函数而不是random模块中的那些函数,因为这些函数是专门为此任务设计的。让我们看看这个模块为令牌提供了什么。

令牌生成

再次,我们有三个用于生成令牌的函数,每个函数的格式都不同。让我们看看示例:

# secrs/secr_rand.py
import secrets
print(secrets.token_bytes(16))
print(secrets.token_hex(32))
print(secrets.token_urlsafe(32)) 

token_bytes()函数简单地返回一个包含指定字节数的随机字节字符串(在这个例子中是 16 个字节)。其他两个函数做的是同样的事情,但token_hex()返回一个以十六进制格式表示的令牌,而token_urlsafe()返回一个只包含适合包含在 URL 中的字符的令牌。以下是输出(这是前一个运行的延续):

b'\x0f\x8b\x8f\x0f\xe3\xceJ\xbc\x18\xf2\x1e\xe0i\xee1\x99'
98e80cddf6c371811318045672399b0950b8e3207d18b50d99d724d31d17f0a7
63eNkRalj8dgZqmkezjbEYoGddVcutgvwJthSLf5kho 

让我们看看我们如何使用这些工具来编写我们自己的随机密码生成器:

# secrs/secr_gen.py
import secrets
from string import digits, ascii_letters
def generate_pwd(length=8):
    chars = digits + ascii_letters
    return "".join(secrets.choice(chars) for c in range(length))
def generate_secure_pwd(length=16, upper=3, digits=3):
    if length < upper + digits + 1:
        raise ValueError("Nice try!")
    while True:
        pwd = generate_pwd(length)
        if (
            any(c.islower() for c in pwd)
            and sum(c.isupper() for c in pwd) >= upper
            and sum(c.isdigit() for c in pwd) >= digits
        ):
            return pwd
print(generate_secure_pwd())
print(generate_secure_pwd(length=3, upper=1, digits=1)) 

generate_pwd()函数简单地通过将随机从包含所有字母(小写和大写)和 10 个十进制数字的字符串中随机选择的length个字符连接起来,生成指定长度的随机字符串。

然后,我们定义另一个函数,generate_secure_pwd(),它简单地不断调用generate_pwd(),直到我们得到的随机字符串符合一些基本要求。密码必须是length个字符长,至少包含一个小写字母,upper个大写字母,以及digits个数字。

如果参数指定的总大写字母、小写字母和数字的数量大于我们正在生成的密码的长度,我们就永远无法满足条件。我们在循环开始之前检查这一点,如果给定的参数会导致无限循环,则引发ValueError

while循环的主体很简单:首先,我们生成随机密码,然后使用any()sum()来验证条件。any()函数在其调用的可迭代对象中的任何项评估为True时返回Truesum()的使用实际上在这里稍微复杂一些,因为它利用了多态性。如您从第二章内置数据类型中回忆的那样,bool类型是int的子类,因此当对TrueFalse值的可迭代对象求和时,它们将被sum()函数自动解释为整数(值为 1 和 0)。这是一个多态性的例子,我们在第六章面向对象编程、装饰器和迭代器中简要讨论了它。

运行示例产生以下结果:

$ python secr_gen.py
mgQ3Hj57KjD1LI7M
b8G 

当然,你不会想使用长度为 3 的密码。

随机令牌的一个常见用途是在网站的密码重置 URL 中。以下是如何生成此类 URL 的示例:

# secrs/secr_reset.py
import secrets
def get_reset_pwd_url(token_length=16):
    token = secrets.token_urlsafe(token_length)
    return f"https://example.com/reset-pwd/{token}"
print(get_reset_pwd_url()) 

运行上述代码产生了以下输出:

$ python secr_reset.py
https://example.com/reset-pwd/ML_6_2wxDpXmDJLHrDnrRA 

摘要比较

这可能相当令人惊讶,但 secrets 模块还提供了一个 compare_digest(a, b) 函数,这是通过简单地执行 a == b 来比较两个摘要的等效函数。那么,我们为什么需要这个函数呢?因为它被设计用来防止时间攻击。这类攻击可以根据比较失败所需的时间推断出两个摘要开始不同的信息。因此,compare_digest() 通过消除时间和失败之间的相关性来防止这种攻击。我们认为这是一个复杂的攻击方法如何复杂的绝佳例子。如果您惊讶地扬起了眉毛,现在可能更清楚为什么我们说永远不要自己实现加密函数。

这使我们到达了 Python 标准库中加密服务的游览结束。现在,让我们继续探讨另一种类型的令牌:JWT。

JSON Web Tokens

JSON Web Token(JWT),是一个基于 JSON 的开放标准,用于创建断言一系列 声明 的令牌。JWT 经常用作身份验证令牌。在这种情况下,声明通常是关于已验证用户身份和权限的陈述。这些令牌是经过加密签名的,这使得验证令牌内容自签发以来未被修改成为可能。您可以在网站上了解所有关于这项技术的信息(jwt.io)。

这种类型的令牌由三个部分组成,通过点连接在一起,格式为 A.B.CB 是有效载荷,这是我们放置声明的地方。C 是签名,用于验证令牌的有效性,而 A 是头部,它标识令牌为 JWT,并指示计算签名的算法。ABC 都使用 URL 安全的 Base64 编码(我们将其称为 Base64URL)。Base64URL 编码使得 JWT 可以作为 URL 的一部分使用(通常作为查询参数);然而,JWT 也会出现在其他地方,包括 HTTP 头部。

Base64 是一种流行的二进制到文本编码方案,通过将其转换为 Radix-64 表示来以 ASCII 字符串格式表示二进制数据。Radix-64 表示使用字母 A-Z、a-z 和数字 0-9,以及两个符号 + 和 /,总共 64 个符号。Base64 用于例如编码电子邮件中附加的图像。它无缝发生,所以绝大多数用户对此一无所知。Base64URLBase64 编码的一个变体,其中将具有特定 URL 上下文意义的 + 和 / 字符替换为 - 和 _。用于 Base64 填充的 = 字符在 URL 中也有特殊含义,并在 Base64URL 中省略。

这种类型的令牌的工作方式与我们在本章中迄今为止所看到的不同。事实上,令牌携带的信息始终是可见的。你只需要将 AB 从 Base64URL 解码,以获取算法和有效载荷。安全性部分在于 C ,它是头部和有效载荷的 HMAC 签名。如果你尝试通过编辑头部或有效载荷来修改 AB 部分,将其编码回 Base64URL,并在令牌中替换它,签名将不会匹配,因此令牌将无效。

这意味着我们可以构建包含诸如 已登录为管理员 或类似内容的有效载荷,只要令牌有效,我们就知道我们可以信任该用户已登录为管理员。

在处理 JWT 时,你想要确保你已经研究了如何安全地处理它们。像不接受未签名令牌或限制你用于编码和解码的算法列表,以及其他安全措施,这些都非常重要,你应该花时间调查和学习它们。

对于这段代码,你必须安装 PyJWTcryptography Python 包。像往常一样,你可以在本章源代码的要求中找到它们。

让我们从简单的例子开始:

# jwt/tok.py
import jwt
data = {"payload": "data", "id": 123456789}
algs = ["HS256", "HS512"]
token = jwt.encode(data, "secret-key")
data_out = jwt.decode(token, "secret-key", algorithms=algs)
print(token)
print(data_out) 

我们定义了包含 ID 和一些有效载荷数据的 data 有效载荷。我们使用 jwt.encode() 函数创建令牌,该函数接受有效载荷和一个密钥。密钥用于生成令牌头部和有效载荷的 HMAC 签名。接下来,我们再次解码令牌,指定我们愿意接受的签名算法。默认的算法用于计算令牌是 HS256;在这个例子中,我们在解码时接受 HS256HS512(如果令牌是使用不同的算法生成的,它将被异常拒绝)。以下是输出:

$ python jwt/tok.py
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXlsb2FkIjoiZGF0YSIsIm...
{'payload': 'data', 'id': 123456789} 

如你所见,令牌是由 Base64URL 编码的数据片段组成的二进制字符串(为了适应一行而进行了缩写)。我们调用 jwt.decode() ,提供正确的密钥。如果我们提供了错误的密钥,我们会得到一个错误,因为签名只能用生成它的相同密钥进行验证。

JWT 通常用于在双方之间传输信息。例如,允许网站依赖第三方身份提供者来验证用户的身份验证协议通常使用 JWT。在这种情况下,用于签名令牌的密钥需要在双方之间共享。因此,它通常被称为 共享密钥

必须小心保护共享密钥,因为任何有权访问它的人都可以生成有效的令牌。

有时,你可能希望在验证签名之前先检查令牌的内容。你可以通过简单地调用 decode() 来这样做:

# jwt/tok.py
jwt.decode(token, options={"verify_signature": False}) 

这很有用,例如,当需要在令牌有效载荷中恢复密钥时,但该技术相当高级,所以我们不会在本上下文中花费时间讨论它。相反,让我们看看我们如何指定用于计算签名的不同算法:

# jwt/tok.py
token512 = jwt.encode(data, "secret-key", algorithm="HS512")
data_out = jwt.decode(
    token512, "secret-key", algorithms=["HS512"]
)
print(data_out) 

在这里,我们使用了HS512算法生成令牌,并在解码时指定我们只接受使用HS512算法生成的令牌。输出是原始的有效载荷字典。

现在,虽然你可以自由地将任何内容放入令牌有效载荷中,但有一些声明已被标准化;它们对于确保不同系统和应用程序之间的安全性、一致性和互操作性至关重要。

已注册的声明

JWT 标准定义了以下官方已注册声明

  • iss : 令牌的发行者

  • sub : 关于此令牌所携带信息方的主题信息

  • aud : 令牌的受众

  • exp : 令牌过期时间,在此时间之后令牌无效

  • nbf : 不早于(时间),或令牌尚未被视为有效的之前的时间

  • iat : 令牌签发的时间

  • jti : 令牌 ID

未在标准中定义的声明可以归类为公共或私人:

  • 公共 : 为特定目的公开分配的声明。公共声明名称可以通过在 IANA JSON Web Token Claims Registry 中注册来保留。或者,声明应以确保它们不会与其他公共或官方声明名称冲突的方式命名(实现这一目标的一种方法是在声明名称前加上已注册的域名)。

  • 私人 : 不属于上述类别的任何其他声明被称为私人声明。此类声明的含义通常在特定应用的上下文中定义,并且在该上下文之外没有意义。为了防止歧义和混淆,必须小心避免名称冲突。

有关声明的信息,请参阅官方网站。现在,让我们看看几个涉及这些声明子集的代码示例。

与时间相关的声明

这是我们可能使用与时间相关的声明的方式:

# jwt/claims_time.py
from datetime import datetime, timedelta, UTC
from time import sleep, time
import jwt
iat = datetime.now(tz=UTC)
nfb = iat + timedelta(seconds=1)
exp = iat + timedelta(seconds=3)
data = {"payload": "data", "nbf": nfb, "exp": exp, "iat": iat}
def decode(token, secret):
    print(f"{time():.2f}")
    try:
        print(jwt.decode(token, secret, algorithms=["HS256"]))
    except (
        jwt.ImmatureSignatureError,
        jwt.ExpiredSignatureError,
    ) as err:
        print(err)
        print(type(err))
secret = "secret-key"
token = jwt.encode(data, secret)
decode(token, secret)
sleep(2)
decode(token, secret)
sleep(2)
decode(token, secret) 

在本例中,我们将签发时间(iat)声明设置为当前的 UTC 时间(UTC代表协调世界时)。然后,我们将“不早于”(nbf)和“过期时间”(exp)声明分别设置为现在后的 1 秒和 3 秒。我们定义了一个decode()辅助函数,该函数通过捕获适当的异常来响应令牌尚未有效或已过期的行为,然后我们调用它三次,并在两次调用sleep()之间进行。

这样,我们将在令牌有效之前尝试解码令牌,然后在它有效时,最后在它过期后。此函数在尝试解码令牌之前还会打印一个有用的时间戳。让我们看看结果如何(为了可读性,添加了空白行):

$ python jwt/claims_time.py
1716674892.39
The token is not yet valid (nbf)
<class 'jwt.exceptions.ImmatureSignatureError'>
1716674894.39
{'payload': 'data', 'nbf': 1716674893, 'exp': 1716674895, 'iat': 1716674892}
1716674896.39
Signature has expired
<class 'jwt.exceptions.ExpiredSignatureError'> 

正如您所看到的,它按预期执行。当令牌有效时,我们从异常中获取描述性消息,并获取原始有效载荷。

身份验证相关声明

在这里,我们有一个快速示例,这次涉及到发行者(iss)和受众(aud)声明。代码在概念上与上一个示例非常相似,我们将以相同的方式练习它:

# jwt/claims_auth.py
import jwt
data = {"payload": "data", "iss": "hein", "aud": "learn-python"}
secret = "secret-key"
token = jwt.encode(data, secret)
def decode(token, secret, issuer=None, audience=None):
    try:
        print(
            jwt.decode(
                token,
                secret,
                issuer=issuer,
                audience=audience,
                algorithms=["HS256"],
            )
        )
    except (
        jwt.InvalidIssuerError,
        jwt.InvalidAudienceError,
    ) as err:
        print(err)
        print(type(err))
# Not providing both the audience and issuer will fail
decode(token, secret)
# Not providing the issuer will succeed
decode(token, secret, audience="learn-python")
# Not providing the audience will fail
decode(token, secret, issuer="hein")
# Both will fail
decode(token, secret, issuer="wrong", audience="learn-python")
decode(token, secret, issuer="hein", audience="wrong")
# This will succeed
decode(token, secret, issuer="hein", audience="learn-python") 

正如您所看到的,这次,我们在创建令牌时指定了发行者(iss)和受众(aud)。即使我们省略了jwt.decode()issuer参数,解码此令牌也能成功。然而,如果提供了issuer但与令牌中的 iss 字段不匹配,解码将失败。另一方面,如果省略了audience参数或与令牌中的aud字段不匹配,jwt.decode()将失败。

与上一个示例一样,我们编写了一个自定义的decode()函数来响应适当的异常。看看您是否能跟上调用和随后的相对输出(我们将用一些空白行来帮助您):

$ python jwt/claims_time.py
Invalid audience
<class 'jwt.exceptions.InvalidAudienceError'>
{'payload': 'data', 'iss': 'hein', 'aud': 'learn-python'}
Invalid audience
<class 'jwt.exceptions.InvalidAudienceError'>
Invalid issuer
<class 'jwt.exceptions.InvalidIssuerError'>
Audience doesn't match
<class 'jwt.exceptions.InvalidAudienceError'>
{'payload': 'data', 'iss': 'hein', 'aud': 'learn-python'} 

注意,在这个例子中,我们改变了jwt.decode()的参数,以向您展示各种场景下的行为。然而,在实际应用中,您通常会为audienceissuer使用固定值,并拒绝任何无法成功解码的令牌。在解码时省略issuer意味着您将接受来自任何发行者的令牌。省略audience意味着您将只接受未指定受众的令牌。

现在,让我们来看一个更复杂用例的最后一个示例。

使用非对称(公钥)算法

有时,使用共享密钥不是最佳选择。在这种情况下,可以使用非对称密钥对而不是 HMAC 来生成 JWT 签名。在这个例子中,我们将使用 RSA 密钥对创建一个令牌(并解码它)。

公钥加密,或非对称加密,是指使用一对密钥的任何加密系统:公钥,可以广泛分发;私钥,只有所有者知道。如果您想了解更多关于这个主题的信息,请参阅本章末尾的建议。可以使用私钥生成签名,而公钥可以用来验证签名。因此,双方可以交换 JWT,并且可以在不共享任何密钥的情况下验证签名。

首先,让我们创建一个 RSA 密钥对。我们将使用 OpenSSH 的ssh-keygen实用程序来完成这项工作。www.ssh.com/academy/ssh/keygen 。在我们的脚本文件夹中,我们创建了一个jwt/rsa子文件夹。在其中,运行以下命令:

$ ssh-keygen -t rsa –m PEM 

当被要求输入文件名时,请输入key(它将保存在当前文件夹中),并在被要求输入口令时简单地按Enter键。

生成我们的密钥后,我们现在可以切换回ch09文件夹并运行此代码:

# jwt/token_rsa.py
import jwt
data = {"payload": "data"}
def encode(data, priv_filename, algorithm="RS256"):
    with open(priv_filename, "rb") as key:
        private_key = key.read()
    return jwt.encode(data, private_key, algorithm=algorithm)
def decode(data, pub_filename, algorithm="RS256"):
    with open(pub_filename, "rb") as key:
        public_key = key.read()
    return jwt.decode(data, public_key, algorithms=[algorithm])
token = encode(data, "jwt/rsa/key")
data_out = decode(token, "jwt/rsa/key.pub")
print(data_out)  # {'payload': 'data'} 

在这个例子中,我们定义了一些自定义函数来使用私钥/公钥对令牌进行编码和解码。正如你在encode()函数中看到的,我们这次使用的是RS256算法。请注意,当我们编码时,我们提供私钥,用于生成 JWT 签名。当我们解码 JWT 时,我们则提供公钥,用于验证签名。

逻辑很简单,我们鼓励你至少思考一个可能比使用共享密钥更合适的用例。

有用参考资料

在这里,如果你想要深入了解迷人的密码学世界,可以找到一份有用的参考资料列表:

网上有很多信息,也有很多书籍可以学习,但我们建议你从主要概念开始,然后逐渐深入到你想要更彻底了解的特定细节。

摘要

在这一章中,我们探索了 Python 标准库中的密码学世界。我们学习了如何使用不同的密码学函数为消息创建哈希(或摘要)。我们还学习了在密码学背景下创建令牌和处理随机数据的方法。

我们随后对标准库外进行了一次小型的巡游,以了解 JSON Web Tokens,这些在现代系统和应用程序中常用于身份验证和声明相关功能。

最重要的就是要理解,在密码学方面,手动操作是非常危险的,因此最好将其留给专业人士,并简单地使用我们可用的工具。

下一章将介绍如何测试我们的代码,以确保它按预期的方式工作。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十章:测试

“正如智者经过加热、切割和摩擦来检验黄金后才会接受它一样,我的话也应该在经过检验后接受,而不是出于对我的尊重。”

– 佛陀

我们非常喜欢佛陀的这句话。在软件世界中,它完美地转化为一个健康的习惯:永远不要仅仅因为某人聪明就信任代码,或者因为它长时间运行良好就信任它。如果它没有被测试,代码就不应该被信任。

为什么测试如此重要?好吧,首先,它们为你提供了可预测性。或者至少,它们帮助你实现高可预测性。不幸的是,代码中总是会有一些漏洞悄悄溜进来。但我们希望我们的代码尽可能可预测。我们不希望有惊喜;换句话说,我们不希望代码以不可预测的方式运行。在检查飞机、火车或核电站传感器的软件中存在不可预测性可能导致灾难性的情况。

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

本章全部关于这一点——确保你的代码准备好面对可怕的外部世界,它足够快,并且能够处理意外或异常条件。

在本章中,我们将探讨以下主题:

  • 通用测试指南

  • 单元测试

  • 简要介绍测试驱动开发TDD

让我们先从理解什么是测试开始。

测试你的应用程序

测试有很多种类;实际上,公司通常有一个专门的部门,称为质量保证QA),由那些负责测试公司开发软件的个人组成。

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

白盒测试是那些锻炼代码内部结构的测试;它们检查到非常详细的程度。另一方面,黑盒测试是那些将待测试的软件视为一个盒子,忽略其内部结构的测试。即使是盒子内部使用的科技或语言,对黑盒测试来说也不重要。它们所做的是将一些输入插入盒子的一个端点,并验证另一个端点的输出——仅此而已。

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

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

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

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

  • 集成测试:它们验证当应用程序的不同组件协同工作并通过接口发送消息时的行为。

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

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

  • 功能测试:它们验证你的软件的功能或功能。

  • 破坏性测试:它们破坏系统的某些部分,模拟故障,以确定系统剩余部分的表现如何。这种测试由需要提供高度可靠服务的公司广泛进行。

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

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

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

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

  • 回归测试:它们为开发者提供有关更新后系统中的某个功能被破坏的有用信息。系统被认为有回归的一些原因是旧错误再次出现、现有功能被破坏,或者引入了新的问题。

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

测试是一种艺术,不幸的是,这种艺术你无法从书中学习。你可以学习所有的定义(你应该这样做),并尽可能多地收集有关测试的知识,但只有当你积累了足够多的经验时,你才可能正确地测试你的软件。

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

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

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

我们可以继续用这种“当你……你学……”的方式来讨论,但我们猜测你已经明白了。你需要付出努力并积累经验。我们的建议?尽可能多地学习理论,然后尝试使用不同的方法进行实验。此外,尝试向经验丰富的程序员学习;这非常有效。

理想情况下,你越有经验,你就越应该感觉到源代码和单元测试不是两件独立的事情。测试不是可选的。它们与代码紧密相连。源代码和单元测试相互影响。

测试的结构

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

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

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

  1. 准备:这是我们设置场景的地方。我们在需要的地方准备所有需要的数据、对象和服务,以便它们可以随时使用。

  2. 执行:这是我们在测试阶段执行逻辑的地方。我们使用在准备阶段设置的数据和接口执行一个动作。

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

虽然测试通常遵循这种结构,但在测试套件中,你通常会找到一些参与测试过程的其它构造:

  • 设置:这是在多个测试中相当常见的东西。这是一种可以定制以运行每个测试、类、模块或整个会话的逻辑。在这个阶段,开发者通常会设置与数据库的连接,用测试所需的数据填充它们,等等。

  • 拆卸:这是设置的反面;拆卸阶段在测试运行之后发生。像设置一样,它可以定制以运行每个测试、类、模块或会话。通常,在这个阶段,我们会销毁为测试套件创建的任何工件,并清理我们的痕迹。这很重要,因为我们不希望有任何残留的对象,这也有助于确保每个测试都是从一张干净的纸开始。

  • 固定装置:这些是在测试中使用的数据片段。通过使用特定的固定装置集,结果是可以预测的,因此测试可以针对它们进行验证。

在本章中,我们将使用pytest Python 库。这是一个强大的工具,它使得测试比仅使用标准库工具要容易得多。pytest提供了大量的辅助工具,以便测试逻辑可以更多地关注实际的测试,而不是围绕它的布线和模板。当你看到代码时,你会发现pytest的一个特点是,固定装置、设置和拆卸通常融合在一起。

测试指南

就像软件一样,测试可以是好的或坏的,中间还有一系列的灰色地带。要编写好的测试,以下是一些指导原则:

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

  • 测试应验证一件事,且仅一件事:保持它们简短并包含在一个范围内非常重要。写多个测试来测试单个对象或函数是完全正常的。我们只需要确保每个测试只有一个且只有一个目的。

  • 测试不应做出任何不必要的假设:起初这可能难以理解,但这是很重要的。验证函数调用的结果是[1, 2, 3]并不等同于说输出是一个包含数字 1、2 和 3 的列表。在前者中,我们还在假设顺序;在后者中,我们只假设列表中包含哪些项目。这些差异有时可能非常微妙,但它们仍然很重要。

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

  • 测试应使用完成工作所需的最小固定数据集:这是另一个关键点。固定数据集往往会随着时间的推移而增长。它们也往往会时不时地发生变化。如果我们使用许多固定数据集并且忽略测试中的冗余,重构将需要更长的时间。发现错误将更加困难。我们应该尽量使用一组足够大的固定数据集,以便测试能够正确执行,但不要过大。

  • 测试应尽可能少地使用资源:这样做的原因是,任何检出我们代码的开发者都应该能够运行测试,无论他们的机器有多强大。这可能是一个瘦虚拟机或 CircleCI 设置;测试应在不消耗太多资源的情况下运行。

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

    CircleCI是今天可用的最大的持续集成/持续交付CI/CD)平台之一。例如,它与 GitHub 等服务的集成非常容易。你只需要在源代码中添加一些配置(通常是文件的形式),当新代码准备合并到当前代码库时,CircleCI 就会运行测试。

单元测试

现在我们已经了解了测试是什么以及为什么我们需要它,让我们来介绍开发者的最佳拍档:单元测试

在我们继续举例之前,让我们分享一些注意事项:我们将尝试向您介绍单元测试的基础知识,但我们并不严格遵循任何特定的思想或方法论。多年来,我们尝试了许多不同的测试方法,最终形成了我们自己的做事方式,这种方式一直在不断演变。用布鲁斯·李的话来说:

吸收有用的,摒弃无用的,增加你自己的独特之处。

编写单元测试

单元测试的名字来源于它们用于测试代码的小单元。为了解释如何编写单元测试,让我们看看一个简单的代码片段:

# 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包中,您可以找到几个工具,这些工具可以帮助您编写测试,这样您就无需模拟与数据库的对话。通过这种方式编写测试,您还可以检查事务、编码以及编程的所有其他数据库相关方面。这种方法的另一个优点是能够检查可能从一个数据库到另一个数据库发生变化的细节。

有时,尽管如此,仍然无法实现。例如,当软件与 API 接口交互,而该 API 没有测试版本时,我们需要使用模拟来模拟该 API。实际上,大多数情况下,我们最终不得不采用混合方法,即使用允许这种方法的技术的测试版本,而对于其他所有内容则使用模拟。现在让我们来谈谈模拟。

模拟对象和修补

首先,在 Python 中,这些模拟对象被称为模拟。直到版本 3.3,mock库是一个第三方库,基本上每个项目都会通过 pip 安装,但从版本 3.3 开始,它已被包含在标准库的unittest模块中,这是合理的,考虑到其重要性和普及程度。

mock替换真实对象或函数(或一般而言,任何数据结构)的行为被称为修补mock库提供了patch工具,它可以作为函数或类装饰器,甚至可以作为上下文管理器,您可以使用它来模拟事物。

断言

验证阶段是通过使用断言来完成的。在大多数情况下,断言是一个可以用来验证对象之间相等性以及其他条件的函数或方法。当条件不满足时,断言将引发一个异常,这将导致测试失败。你可以在unittest模块的文档中找到一个断言列表;然而,当使用pytest时,你通常会使用通用的assert语句,这使得事情变得更加简单。

测试 CSV 生成器

让我们现在采取一种实际的方法。我们将向您展示如何测试一小段代码,并且我们将在这个示例的背景下涉及单元测试的其他重要概念。

我们想要编写一个export()函数,它执行以下操作:接收一个字典列表,每个字典代表一个用户。它创建一个逗号分隔值CSV)文件,在其中放置一个标题,然后继续添加根据某些规则被认为是有效的所有用户。该函数将接受三个参数:用户字典列表、要创建的 CSV 文件名以及一个指示是否应该覆盖具有相同名称的现有文件的标志。

要被认为是有效的并添加到输出文件中,用户字典必须满足以下要求:每个用户必须至少有一个电子邮件、一个姓名和一个年龄。还可以有一个表示角色的第四个字段,但这不是必需的。用户的电子邮件地址必须是有效的,姓名不能为空,年龄必须在 18 到 65 岁之间。

这是我们的任务;因此,现在我们将向您展示代码,然后我们将分析为其编写的测试。但是,首先,在以下代码片段中,我们将使用两个第三方库:marshmallowpytest。它们都包含在该章节源代码的要求中,所以请确保您已经使用 pip 安装了它们。

marshmallowmarshmallow.readthedocs.io/)是一个库,它为我们提供了序列化(或marshmallow术语中的dump)和反序列化(或marshmallow术语中的load)对象的能力,最重要的是,它为我们提供了定义模式的能力,我们可以使用该模式来验证用户字典。我们将在第十四章API 开发简介中看到另一个用于创建模式的库,pydantic

pytestdocs.pytest.org/)是我们所见过的最好的软件之一。它几乎被用于所有地方,并取代了其他库,如nose。它为我们提供了编写测试的有用工具,效率很高。

让我们来看代码。我们将其命名为api.py仅仅是因为它暴露了一个我们可以用来导出 CSV 的函数。我们将分块向您展示:

# api.py
from pathlib import Path
import csv
from copy import deepcopy
from marshmallow import Schema, fields, pre_load
from marshmallow.validate import Length, Range
class UserSchema(Schema):
    """Represent a *valid* user."""
    email = fields.Email(required=True)
    name = fields.Str(required=True, validate=Length(min=1))
    age = fields.Int(
        required=True, validate=Range(min=18, max=65)
    )
    role = fields.Str()
    @pre_load()
    def strip_name(self, data, **kwargs):
        data_copy = deepcopy(data)
        try:
            data_copy["name"] = data_copy["name"].strip()
        except (AttributeError, KeyError, TypeError):
            pass
        return data_copy
schema = UserSchema() 

这一部分是我们导入所有需要的模块(Pathcsvdeepcopy 以及来自 marshmallow 的一些工具),然后我们定义用户的模式。正如你所见,我们继承自 marshmallow.Schema,然后设置四个字段。注意我们使用了两个字符串字段(Str),一个 Email 字段和一个整数(Int)。这些将已经为我们提供了一些来自 marshmallow 的验证。注意在 role 字段中没有 required=True

尽管如此,我们还需要添加一些自定义的代码。我们需要在 age 上添加验证以确保值在我们想要的范围内。如果它不是,marshmallow 将抛出 ValidationError。它还会处理如果我们传递的不是整数时抛出错误。

我们还对 name 字段添加了验证,因为字典中存在 name 键并不保证该名称的值不为空。我们验证字段值的长度至少为一位。注意我们不需要为 email 字段添加任何内容。这是因为 marshmallow 会为我们验证它。

在字段声明之后,我们编写了另一个方法,strip_name(),该方法被 pre_load() marshmallow 辅助器装饰。这个方法将在 marshmallow 反序列化(加载)数据之前运行。正如你所见,我们首先复制 data,因为在当前上下文中直接在可变对象上工作不是一个好主意,然后确保从 data['name'] 中移除前导和尾随空格。这个键代表我们刚才声明的名称字段。我们确保在 try / except 块中这样做,这样即使在出现错误的情况下,反序列化也可以顺利运行。该方法返回修改后的数据副本,而 marshmallow 执行剩余的操作。

我们随后实例化 schema,以便我们可以用它来验证数据。因此,让我们编写 export 函数:

# api.py
def export(filename, users, overwrite=True):
    """Export a CSV file.
    Create a CSV file and fill with valid users.  If `overwrite`
    is False and file already exists, raise IOError.
    """
    if not overwrite and Path(filename).is_file():
        raise IOError(f"'{filename}' already exists.")
    valid_users = get_valid_users(users)
    write_csv(filename, valid_users) 

正如你所见,其逻辑非常直接。如果 overwriteFalse 且文件已存在,我们将抛出带有文件已存在信息的 IOError。否则,如果我们可以继续进行,我们只需获取有效用户的列表并将其传递给 write_csv(),该函数负责实际执行工作。让我们看看所有这些函数是如何定义的:

# api.py
def get_valid_users(users):
    """Yield one valid user at a time from users."""
    yield from filter(is_valid, users)
def is_valid(user):
    """Tell if the user is valid."""
    return not schema.validate(user) 

我们将 get_valid_users() 编码为一个生成器,因为在写入文件之前没有必要创建一个可能很大的列表。我们可以逐个验证和保存它们。is_valid() 函数简单地委托给 marshmallowschema.validate() 来验证用户。此方法返回一个字典,如果数据根据模式有效则为空,否则将包含错误信息。我们不需要收集错误信息来完成这项任务,所以我们简单地忽略它,而 is_valid() 函数如果 schema.validate() 的返回值为空,则简单地返回 True,否则返回 False

本模块中的最后一部分代码是:

# api.py
def write_csv(filename, users):
    """Write a CSV given a filename and a list of users.
    The users are assumed to be valid for the given CSV structure.
    """
    fieldnames = ["email", "name", "age", "role"]
    with open(filename, "w", newline="") as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(users) 

再次强调,逻辑非常直接。我们在fieldnames中定义了表头,然后打开filename进行写入,并指定newline="",这在 CSV 文件的文档中被推荐使用。当文件创建完成后,我们通过使用csv.DictWriter类来获取一个writer对象。这个工具将用户字典映射到字段名,因此我们不需要关心顺序。

我们首先写入表头,然后遍历用户并逐个添加。请注意,这个函数假设传入的是一个有效的用户列表,如果这个假设不成立(使用默认值时,如果任何用户字典有额外的字段,它可能会中断)。

这是你应该尝试记住的代码。我们建议你花点时间再次过一遍。没有必要去记忆它,而且我们使用了具有有意义名称的小辅助函数,这将使你更容易地跟踪测试。

现在我们来探讨有趣的部分:测试export()函数。我们再次将代码分块展示:

# tests/test_api.py
import re
from unittest.mock import patch, mock_open, call
import pytest
from api import is_valid, export, write_csv 

让我们从导入开始:首先,我们从标准库中导入re模块,因为其中一个测试需要它。然后,我们引入unittest.mock中的某些工具,接着是pytest,最后,我们获取我们想要实际测试的三个函数:is_valid()export()write_csv()

在我们可以编写测试之前,我们需要做一些固定装置。正如你将看到的,pytest中的固定装置是一个用pytest.fixture装饰器装饰的函数。固定装置在应用它们的每个测试之前运行。在大多数情况下,我们期望固定装置返回一些内容,这样我们就可以在测试中使用它。我们对用户字典有一些要求,所以让我们写几个用户:一个具有最小要求,另一个具有完整要求。两者都需要是有效的。以下是代码:

# tests/test_api.py
@pytest.fixture
def min_user():
    """Represent a valid user with minimal data."""
    return {
        "email": "minimal@example.com",
        "name": "Primus Minimus",
        "age": 18,
    }
@pytest.fixture
def full_user():
    """Represent valid user with full data."""
    return {
        "email": "full@example.com",
        "name": "Maximus Plenus",
        "age": 65,
        "role": "emperor",
    } 

在这个例子中,用户之间的唯一区别是role键的存在,但这应该足以说明问题。

注意,我们并没有在模块级别简单地声明字典,而是实际上编写了两个返回字典的函数,并用@pytest.fixture装饰器进行了装饰。这是因为当你需要在模块级别声明用于测试的字典时,你需要确保在每个测试的开始处复制它。如果你不这样做,并且任何测试(或被测试的代码)修改了它,那么所有后续的测试可能会受到影响,因为字典将不会保持其原始形式。通过使用这些固定装置,pytest将为每个测试提供一个新字典,因此我们不需要进行复制过程。这有助于遵守独立性原则,即每个测试应该是自包含和独立的。

固定值也是可组合的,这意味着它们可以相互使用,这是 pytest 的一个有用特性。为了展示这一点,让我们为用户列表编写一个固定值,我们将放入我们已有的两个,再加上一个没有年龄将无法通过验证的用户。让我们看一下以下代码:

# tests/test_api.py
@pytest.fixture
def users(min_user, full_user):
    """List of users, two valid and one invalid."""
    bad_user = {
        "email": "invalid@example.com",
        "name": "Horribilis",
    }
    return [min_user, bad_user, full_user] 

我们现在有两个用户可以使用,我们还有一个包含三个用户的列表。

前几个测试将测试我们如何验证用户。我们将为此任务将所有测试分组在一个类中。这有助于为相关测试提供一个命名空间,一个存放的地方。正如我们稍后将会看到的,这也允许我们声明类级别的固定值,这些固定值仅针对属于该类的测试定义。声明类级别固定值的一个好处是,你可以轻松地覆盖一个与类作用域外同名的内容。

尽管在这种情况下,我们发现按类组织测试很方便,但你也可以只在模块级别定义测试。pytest 允许在测试结构方面具有很大的灵活性。

此外,你将注意到,当我们引导你通过示例时,每个测试函数的名称都以 test_ 开头,每个测试类的名称都以 Test 开头。这是为了让 pytest 发现这些函数和类,并将它们视为测试。请参阅 pytest 文档以了解完整的规范。

让我们回到我们的代码。看一下以下内容:

# tests/test_api.py
class TestIsValid:
    """Test how code verifies whether a user is valid or not."""
    def test_minimal(self, min_user):
        assert is_valid(min_user)
    def test_full(self, full_user):
        assert is_valid(full_user) 

我们从确保我们的固定值确实通过验证开始,这有助于确保我们的代码能够正确验证已知有效的用户,无论是最小数据还是完整数据。请注意,我们给每个测试函数提供了一个与固定值名称匹配的参数。这会激活该测试的固定值。当 pytest 运行测试时,它将检查每个测试的参数,并将相应固定值函数的返回值作为参数传递给测试。

在我们继续之前,运行这两个测试以确保一切连接正确会是个好主意。要运行测试,我们在 ch10 文件夹的壳中调用 pytest 命令:

$ pytest tests -vv
===================== test session starts =====================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0 --
  /Users/fab/.virtualenvs/lpp4ed-ch10/bin/python
cachedir: .pytest_cache
rootdir: /Users/fab/code/lpp4ed
configfile: pyproject.toml
collected 2 items
tests/test_api.py::TestIsValid::test_minimal PASSED      [ 50%]
tests/test_api.py::TestIsValid::test_full PASSED         [100%]
====================== 2 passed in 0.03s ====================== 

我们指示命令在 tests 文件夹中搜索测试。此外,为了展示详细信息,我们使用详细标志( -vv )调用了它。

在一些样板代码之后,我们发现了两行被高亮的代码。它们代表了运行的所有测试的完整路径。首先,是包含测试的模块名称,然后在这个例子中,是定义它们的类名称,最后是它们的名称。

在右侧,你可以看到进度,以百分比表示。在这种情况下,我们现在只有两个测试,所以在运行第一个测试后,我们已经完成了测试套件的 50%,在运行第二个测试后完成 100%。它们都通过了。

如果任何测试失败,pytest 会打印错误和一些调试信息,这样我们就可以检查哪里出了问题并修复它。让我们通过从 min_user 固定值中移除 name 键并再次运行测试来模拟一个失败:

$ pytest tests -vv
===================== test session starts =====================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0 --
/Users/fab/.virtualenvs/lpp4ed-ch10/bin/python
cachedir: .pytest_cache
rootdir: /Users/fab/code/lpp4ed
configfile: pyproject.toml
collected 2 items
tests/test_api.py::TestIsValid::test_minimal FAILED     [ 50%]
tests/test_api.py::TestIsValid::test_full PASSED        [100%]
=========================== FAILURES ==========================
___________________ TestIsValid.test_minimal __________________
self = <ch10.tests.test_api.TestIsValid object at 0x103603920>,
       min_user = {'age': 18, 'email': 'minimal@example.com'}
    def test_minimal(self, min_user):
>       assert is_valid(min_user)
E       AssertionError: assert False
E        +  where False = is_valid(
                {'age': 18, 'email': 'minimal@example.com'}
            )
tests/test_api.py:45: AssertionError
=================== short test summary info ===================
FAILED tests/test_api.py::TestIsValid::test_minimal
       - AssertionError: assert False
================= 1 failed, 1 passed in 0.04s ================= 

如您在突出显示的部分所见,pytest 会报告哪些测试失败了,以及发生失败的地方的代码片段,这样我们就可以检查它并发现问题所在。在代码片段的左侧有一个 > 符号,它表示抛出错误的行,下面是两行代表错误本身,在这个例子中是 {'age': 18, 'email': 'minimal@example.com'} 不是一个有效的用户。

既然我们已经知道如何运行测试,请随时运行它们。当我们运行测试时,一个好的做法是确保如果有什么问题,它们会失败,所以请随意玩弄固定值和断言。

现在我们回到测试套件。下一个任务是测试年龄。为了做到这一点,我们将使用参数化。

参数化是一种技术,使我们能够多次运行相同的测试,但向它提供不同的数据。它非常有用,因为它允许我们只写一次测试,没有重复,并且结果将由 pytest 智能处理,它将像实际单独运行那样运行所有这些测试,当它们失败时,会提供清晰的错误信息。另一个解决方案是在一个包含 for 循环的测试中运行我们想要测试的所有数据,但后者质量要低得多,因为框架无法提供像单独运行测试那样的具体信息。此外,如果 for 循环的任何迭代失败,将没有关于之后会发生什么的信息,因为后续迭代将不会发生。最后,由于 for 循环的额外逻辑,测试的主体将更难以理解。因此,参数化对于这个用例来说是一个更好的选择。

它还使我们免于编写大量几乎相同的测试来穷尽所有可能的场景。让我们看看我们如何测试年龄(我们正在为您重复类签名,但省略了已经展示过的测试):

# tests/test_api.py
class TestIsValid:
    …
    **@pytest.mark.parametrize(****"age"****,** **range****(****18****)****)**
    def test_invalid_age_too_young(self, **age**, min_user):
        min_user["age"] = age
        assert not is_valid(min_user) 

我们首先编写一个测试来检查当用户年龄太小时验证失败。根据我们的规则,当用户年龄小于 18 岁时,他们年龄太小。我们通过使用 range() 来检查 0 到 17 岁之间的每个年龄。

如果你看看参数化是如何工作的,你会看到我们声明了一个对象的名字和 age,然后我们指定这个对象将取哪些值。测试将针对每个指定的值运行一次。在这个第一个测试中,值是 range(18) 返回的所有值,这意味着包括从 0 到 17 的所有整数。注意,我们还向测试添加了一个 age 参数。参数化中指定的值将通过这个参数传递给测试。

我们也在这个测试中使用了 min_user() 固定值。在这种情况下,我们在 min_user() 字典中更改 age,然后验证 is_valid(min_user) 的结果是 False。我们通过断言 not FalseTrue 来做到这一点。在 pytest 中,这就是检查某个条件的方法。你只需断言某个条件是真实的。如果是这样,测试就成功了。如果相反,测试将失败。

注意,pytest 将为使用它的每个测试运行重新评估固定值函数,因此我们可以在测试中修改固定值数据,而不会影响其他任何测试。

让我们继续添加所有必要的测试,以便在年龄上使验证失败:

# tests/test_api.py
class TestIsValid:
    ...
    @pytest.mark.parametrize("age", range(66, 100))
    def test_invalid_age_too_old(self, age, min_user):
        min_user["age"] = age
        assert not is_valid(min_user)
    @pytest.mark.parametrize("age", ["NaN", 3.1415, None])
    def test_invalid_age_wrong_type(self, age, min_user):
        min_user["age"] = age
        assert not is_valid(min_user) 

另外两个测试。一个处理从 66 岁到 99 岁的另一端,第二个确保当年龄不是整数时,年龄无效。因此,我们传递一些值,例如字符串、浮点数和 None,以确保这一点。注意,这些测试的结构都是相同的,但多亏了参数化,我们向它提供了不同的输入参数。

现在我们已经整理好了年龄失败的逻辑,让我们添加一个测试来检查年龄是否在有效范围内:

# tests/test_api.py
class TestIsValid:
    ...
    @pytest.mark.parametrize("age", range(18, 66))
    def test_valid_age(self, age, min_user):
        min_user["age"] = age
        assert is_valid(min_user) 

就这么简单。我们传递正确的范围,从 18 到 65 岁,并在断言中移除 not

我们可以将年龄视为已处理。让我们继续编写关于必填字段的测试:

# tests/test_api.py
class TestIsValid:
    ...
    @pytest.mark.parametrize("field", ["email", "name", "age"])
    def test_mandatory_fields(self, field, min_user):
        del min_user[field]
        assert not is_valid(min_user)
    @pytest.mark.parametrize("field", ["email", "name", "age"])
    def test_mandatory_fields_empty(self, field, min_user):
        min_user[field] = ""
        assert not is_valid(min_user)
    def test_name_whitespace_only(self, min_user):
        min_user["name"] = " \n\t"
        assert not is_valid(min_user) 

这三个测试仍然属于同一个类。第一个测试检查当必填字段之一缺失时,用户是否无效。记住,在每次测试运行时,min_user 固定值都会被恢复,所以我们每次测试运行只有一个缺失的字段,这是检查必填字段的正确方式。我们只需从字典中移除那个键。这次,参数化对象取名为 field,通过查看第一个测试,你可以在参数化装饰器中看到所有必填字段:emailnameage

在第二个测试中,事情略有不同。我们不是移除键,而是简单地(逐个)将它们设置为空字符串。最后,在第三个测试中,我们检查名称是否只由空白字符组成。

之前的测试确保了必填字段存在且非空,以及用户name键周围的格式。现在让我们为这个类编写最后两个测试。我们想要检查电子邮件是否有效,在第二个测试中,检查电子邮件、姓名和角色的类型:

# tests/test_api.py
class TestIsValid:
    ...
    @pytest.mark.parametrize(
        ("email", "outcome"),
        [
            ("missing_at.com", False),
            ("@missing_start.com", False),
            ("missing_end@", False),
            ("missing_dot@example", False),
            ("good.one@example.com", True),
            ("δοκιμή@παράδειγμα.δοκιμή", True),
            ("аджай@экзампл.рус", True),
        ],
    )
    def test_email(self, email, outcome, min_user):
        min_user["email"] = email
        assert is_valid(min_user) == outcome 

这次,参数化稍微复杂一些。我们定义了两个对象(emailoutcome),然后我们向装饰器传递一个元组列表,而不是一个简单的列表。每次运行测试时,这些元组中的一个将被解包以填充emailoutcome的值。这允许我们为有效的和无效的电子邮件地址编写一个测试,而不是两个单独的测试。我们定义了一个电子邮件地址,并指定了验证预期的结果。前四个是无效的电子邮件地址,最后三个是有效的。我们使用了一些非 ASCII 字符的例子,只是为了确保我们没有忘记在验证中包括来自世界各地的朋友们。

注意验证是如何进行的,断言调用结果需要与我们设定的结果相匹配。

现在我们写一个简单的测试来确保当我们向字段提供错误类型时验证会失败(再次强调,年龄在之前已经单独处理过了):

# tests/test_api.py
class TestIsValid:
    ...
    @pytest.mark.parametrize(
        ("field", "value"),
        [
            ("email", None),
            ("email", 3.1415),
            ("email", {}),
            ("name", None),
            ("name", 3.1415),
            ("name", {}),
            ("role", None),
            ("role", 3.1415),
            ("role", {}),
        ],
    )
    def test_invalid_types(self, field, value, min_user):
        min_user[field] = value
        assert not is_valid(min_user) 

就像我们之前做的那样,我们传递了三个不同的值,其中没有一个实际上是字符串。这个测试可以扩展以包含更多的值,但老实说,我们不应该需要编写这样的测试。我们在这里包含它只是为了展示什么是可能的,但通常你只会关注确保代码考虑了有效的类型,那些必须被认为是有效的类型,这应该就足够了。

在我们移动到下一个测试类之前,让我们花点时间谈谈我们在测试年龄时简要提到的事情。

边界和粒度

在检查年龄时,我们编写了三个测试来覆盖三个范围:0-17(失败)、18-65(成功)和 66-99(失败)。我们为什么这样做?答案在于我们正在处理两个边界:18 和 65。因此,我们的测试需要集中在这两个边界定义的三个区域:18 之前、18 和 65 之间以及 65 之后。你如何做不重要,只要确保你正确地测试了边界。这意味着如果有人将模式中的验证从18 <= value <= 65更改为18 <= value < 65(注意第二个<=现在变成了<),那么在 65 上必须有一个失败的测试。

这个概念被称为边界,你必须在代码中识别它们,以便可以针对它们进行测试。

另一个重要的事情是要理解我们离边界有多近。换句话说,我应该使用哪个单位来接近它们?

在年龄的情况下,我们处理整数,因此单位为 1 将是完美的选择(这就是为什么我们使用了 16、17、18、19、20 等)。但是,如果你正在测试时间戳呢?在这种情况下,正确的粒度可能不同。如果代码必须根据你的时间戳以不同的方式执行,并且该时间戳代表秒,那么你的测试的粒度应该缩小到秒。如果时间戳代表年,那么你应该使用年作为单位。我们希望你能理解这一点。这个概念被称为 粒度,需要与边界概念相结合,这样通过以正确的粒度绕过边界,你可以确保你的测试没有留下任何偶然性。

让我们继续我们的例子,并测试 export 函数。

测试导出函数

在同一个测试模块中,我们定义了另一个类,它代表 export() 函数的测试套件。下面是它:

# tests/test_api.py
class TestExport:
    """Test behavior of `export` function."""
    @pytest.fixture
    def csv_file(self, tmp_path):
        """Yield a filename in a temporary folder.
        Due to how pytest `tmp_path` fixture works, the file does
        not exist yet.
        """
        csv_path = tmp_path / "out.csv"
        yield csv_path
        csv_path.unlink(missing_ok=True)
    @pytest.fixture
    def existing_file(self, tmp_path):
        """Create a temporary file and put some content in it."""
        existing = tmp_path / "existing.csv"
        existing.write_text("Please leave me alone...")
        return existing 

让我们首先分析设置函数。这次我们在类级别定义了它们,这意味着它们将可用于同一类中的测试。我们不需要在这个类之外使用这些设置函数,所以没有必要像用户设置函数那样在模块级别声明它们。

我们需要两个文件。如果你还记得本章开头我们写的内容,当涉及到与数据库、磁盘、网络等交互时,我们应该模拟一切。然而,当可能时,我们更喜欢使用不同的技术。在这种情况下,我们将使用临时文件夹,这些文件夹将在设置函数中创建和删除。如果我们能够避免模拟,我们会更加高兴。为了创建临时文件夹,我们使用来自 pytesttmp_path 设置函数,它是一个 pathlib.Path 对象。

第一个设置函数 csv_file() 提供了对一个临时文件夹的引用。我们可以将直到并包括 yield 的逻辑视为设置阶段。就数据而言,设置函数本身由临时文件名表示。该文件本身尚不存在。当测试运行时,设置函数被创建,并在测试结束时,执行设置函数的其余代码(如果有 yield 之后的部分)。

这部分可以被视为拆卸阶段。在 csv_file() 设置函数的情况下,它包括调用 csv_path.unlink() 来删除 .csv 文件(如果存在)。你可以在任何设置函数的每个阶段放入更多内容,并且随着经验的积累,你将掌握以这种方式进行设置和拆卸的艺术。这会很快变得自然而然。

在每次测试后严格来说没有必要删除 .csv 文件。tmp_path 设置函数将为每个测试创建一个新的临时目录,因此不存在在此目录中创建的文件干扰其他测试的风险。我们选择在这个设置函数中删除文件只是为了演示在设置函数中使用 yield 的用法。

第二个固定值existing_file()与第一个类似,但我们将使用它来测试当我们用overwrite=False调用export()时,我们能否防止覆盖。因此,我们在临时文件夹中创建了一个文件,并放入了一些内容,只是为了有验证它没有被修改的手段。

现在我们来看看测试(就像我们之前做的那样,我们包括类声明但省略了已经展示过的测试):

# tests/test_api.py
class TestExport:
    ...
    def test_export(self, users, csv_file):
        export(csv_file, users)
        text = csv_file.read_text()
        assert (
            "email,name,age,role\n"
            "minimal@example.com,Primus Minimus,18,\n"
            "full@example.com,Maximus Plenus,65,emperor\n"
        ) == text 

这个测试使用了users()csv_file()固定值,并立即用它们调用export()。我们期望创建了一个文件,并填充了我们拥有的两个有效用户(记住列表中有三个用户,但有一个是无效的)。

为了验证这一点,我们打开临时文件,将其所有文本收集到一个字符串中。然后我们比较文件的内容与我们期望的内容。注意我们只放上了标题和两个有效的用户,并且顺序正确。

现在我们需要另一个测试来确保如果其中一个值中有逗号,我们的 CSV 仍然可以正确生成。作为一个CSV文件,我们需要确保数据中的逗号不会破坏结构:

# tests/test_api.py
class TestExport:
    ...
    def test_export_quoting(self, min_user, csv_file):
        min_user["name"] = "A name, with a comma"
        export(csv_file, [min_user])
        text = csv_file.read_text()
        assert (
            "email,name,age,role\n"
            'minimal@example.com,"A name, with a comma",18,\n'
        ) == text 

这次,我们不需要整个用户列表;我们只需要一个,因为我们正在测试一个特定的事情,我们已经有之前的测试来确保我们能够正确地生成包含所有用户的文件。记住,总是尽量在测试中减少你的工作量。

因此,我们使用min_user()并在其名称中加逗号。然后我们重复之前的程序,这和之前的测试类似,最后,我们确保名称被放在 CSV 文件的双引号内。这对任何好的 CSV 解析器来说都足够理解,它们不应该在双引号内断开逗号。

现在,我们还想进行另一个测试,以确保当文件已经存在而我们不想覆盖它时,我们的代码不会这样做:

# tests/test_api.py
class TestExport:
    ...
    def test_does_not_overwrite(self, users, existing_file):
        with pytest.raises(IOError) as err:
            export(existing_file, users, overwrite=False)
        err.match(
            r"'{}' already exists\.".format(
                re.escape(str(existing_file))
            )
        )
        # let us also verify the file is still intact
        assert existing_file.read_text() == (
            "Please leave me alone..."
        ) 

这是一个有趣的测试,因为它允许我们向您展示如何告诉pytest您期望一个函数调用抛出异常。我们在pytest.raises()提供的上下文中这样做,我们将我们期望从上下文管理器体内进行的调用中获得的异常提供给它。如果异常没有被引发,测试将失败。

我们喜欢在测试中做到彻底,所以不想就此停止。我们还通过使用方便的err.match()辅助函数对消息进行断言。注意,在调用err.match()时,我们不需要使用assert语句。如果参数不匹配,调用将引发AssertionError,导致测试失败。我们还需要转义existing_file的字符串版本,因为在 Windows 上,路径有反斜杠,这会混淆我们提供给err.match()的正则表达式。

最后,我们通过读取文件并比较其内容与最初写入文件中的字符串来确保文件仍然包含其原始内容(这就是为什么我们创建了existing_file()固定值)。

最终考虑

在我们继续下一个主题之前,让我们总结一些考虑因素。

首先,我们希望你已经注意到我们没有测试我们编写的所有函数。具体来说,我们没有测试get_valid_users()validate()write_csv()。原因是这些函数已经被我们的测试套件隐式测试了。我们已经测试了is_valid()export(),这已经足够确保模式正确验证用户,并且export()函数在需要时过滤掉无效用户,尊重现有文件,并正确写入 CSV。我们没有测试的函数是内部函数;它们提供参与执行我们已经彻底测试过的某些操作的逻辑。

为那些函数添加额外的测试是好事还是坏事?答案实际上很难。

我们测试得越多,就越难重构那段代码。就目前而言,我们可以很容易地决定重命名validate(),而且我们不需要更改我们编写的任何测试。如果你这么想,这是有道理的,因为只要validate()get_valid_users()函数提供正确的验证,我们就真的不需要了解它。

如果我们为validate()函数编写了测试,那么如果我们决定重命名它(或更改其签名,例如),我们就必须更改这些测试。

那么,正确的事情是什么?测试还是不测试?这取决于你。你必须找到正确的平衡。我们个人对这个问题的看法是,一切都需要彻底测试,无论是直接还是间接。我们试图编写尽可能小的测试套件,以确保这一点。这样,我们将拥有一个在覆盖率方面的完整测试套件,但不会比必要的更大。我们需要维护这些测试。

我们希望这个例子对你来说是有意义的;我们认为它使我们能够触及重要的主题。

如果你查看这本书的源代码,在test_api.py模块中,你会找到几个额外的测试类,它们会向你展示如果我们决定完全使用模拟进行测试,测试会有何不同。确保你阅读并充分理解那段代码。它相当直接,并且会为你提供一个与我们在这里展示的方法的良好比较。

现在我们来运行完整的测试套件:

$ pytest tests
====================== test session starts ======================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0
rootdir: /Users/fab/code/lpp4ed
configfile: pyproject.toml
collected 132 items
tests/test_api.py ..............................................
................................................................
......................                                    [100%]
====================== 132 passed in 0.14s ====================== 

如前所述,请确保您在ch10文件夹内运行$ pytest test(添加-vv标志以获得详细输出,这将显示参数化如何修改测试名称)。pytest会扫描您的文件和文件夹,寻找以test_开头或结尾的模块,如test_*.py*_test.py。在这些模块中,它会获取以test为前缀的函数或Test为前缀的类中的test为前缀的方法(您可以在pytest文档中阅读完整的规范)。如您所见,132 个测试在 140 毫秒内完成,并且全部成功。我们强烈建议您检查此代码并对其进行实验。在代码中更改一些内容,看看是否有测试失败。理解为什么它失败了(或没有)。

即使代码不再正确,测试是否仍然通过?测试是否过于严格,即使你做出不影响输出正确性的更改,测试也会失败?思考这些问题将帮助您更深入地了解测试的艺术。

我们还建议您学习unittest模块,以及pytest库。这些是您将经常使用的工具,因此您需要熟悉它们。

让我们现在讨论 TDD。

测试驱动开发

让我们简要地谈谈TDD。这是一种由 Kent Beck 重新发现的方法论,他写了《通过示例进行测试驱动开发,Addison Wesley,2002》,我们鼓励您阅读这本书,如果您想了解这个主题的基础知识。

TDD 是一种基于非常短的开发周期持续重复的软件开发方法。

首先,开发者编写一个测试并运行它。这个测试应该检查尚未成为代码一部分的功能。可能是一个要添加的新功能,或者是要删除或修改的内容。运行测试将使其失败,因此这个阶段被称为红色

然后,开发者编写最少的代码以使测试通过。当测试运行成功时,我们就有了所谓的绿色阶段。在这个阶段,可以编写一些欺骗性的代码,只是为了使测试通过。这种技术被称为fake it ‘til you make it。在 TDD 周期的第二次迭代中,测试会添加不同的边缘情况,如果有任何欺骗性代码,它将无法同时满足所有测试,因此开发者必须编写满足测试的实际逻辑。添加其他测试案例有时被称为三角测量

循环的最后一部分是开发者负责重构代码和测试,直到它们达到期望的状态。这个最后阶段被称为重构

因此,TDD 的咒语是红色-绿色-重构

起初,在编写代码之前编写测试可能会感觉有些奇怪,我们必须承认我们花了很长时间才习惯这种方式。但是,如果你坚持下去,并强迫自己学习这种稍微有些反直觉的方法,在某个时刻,几乎会发生某种神奇的事情,你将看到代码质量以其他方式不可能实现的方式提高。

当我们在测试之前编写代码时,我们必须同时关注代码需要做什么以及如何做。另一方面,当我们先编写测试再编写代码时,我们可以几乎完全专注于“是什么”的部分。

之后,当我们编写代码时,我们主要需要关注代码如何实现测试所要求的内容。这种关注点的转变使得我们的思维可以分别专注于“是什么”和“如何做”的部分,从而产生一种令人惊讶的脑力提升。

采用这种技术还有其他一些好处:

  • 提高代码质量:首先编写测试确保代码库得到彻底测试,可以导致生产代码中更少的错误和错误。它鼓励开发者只编写通过测试所必需的代码,这可能导致更干净、更简单的代码。

  • 更好的设计决策:TDD 鼓励开发者从一开始就考虑代码的设计和结构。这种早期的考虑可以导致更好的软件设计和架构。

  • 促进重构:有了全面的测试套件,开发者可以自信地进行重构和改进代码,因为他们知道测试将捕捉到任何由更改引入的回归或问题。

  • 文档:测试本身就是代码库的文档。它们描述了代码应该做什么,这有助于新团队成员或回顾旧代码时。

  • 减少调试时间:通过在开发过程中早期捕捉错误,TDD 可以减少开发者调试代码所花费的时间。

  • 更好的业务需求理解:有一套通过测试的测试套件可以让开发者有信心,他们的代码符合所需的规范,并且按预期行为。

另一方面,这种技术也有一些缺点:

  • 初始减速:在编写功能代码之前编写测试可能会减缓初始的开发过程。这在快速发展的开发环境或紧迫的截止日期下尤其具有挑战性。

  • 学习曲线:TDD(测试驱动开发)需要与许多开发者习惯的编程思维和方式有所不同。学习曲线可能会很陡峭,开发者最初可能会发现编写有效的测试比较困难。

  • 简单更改的额外开销:对于非常简单的更改或修复,编写测试的第一步可能会显得不必要且耗时。

  • 复杂 UI 或外部系统困难:当处理复杂的 UI 或与外部系统、数据库或 API 的交互时,测试可能会变得具有挑战性。模拟和存根可能会有所帮助,但它们也增加了复杂性和维护开销。

我们对 TDD 非常热情。然而,经过多年的应用,我们遇到了一些情况,TDD 证明不太可行。一个典型的例子是面对包含数百甚至数千个测试用例的测试套件。在这种情况下,预先确定要更改以实现源代码中所需更改的具体测试几乎是一项无法克服的任务。有时,直接修改代码并观察哪些测试失败可能更为实际。

尽管如此,我们仍然坚信掌握 TDD 的价值。虽然 TDD 的最大优势可能在于其教育意义而不是其实际应用,但它所传授的知识和心态是无价的。将 TDD 掌握到可以高效应用的程度,在我们的编码实践中留下了不可磨灭的印记,即使在那些不使用 TDD 的项目中,它也影响着我们的方法。

因此,牢记以下原则至关重要:始终严格测试您的代码。这种做法对于确保软件的可靠性和完整性至关重要,无论采用何种开发方法。

摘要

在本章中,我们探讨了测试的世界。

我们试图为您提供关于测试的相当全面的概述,特别是单元测试,这是开发者最常进行的测试类型。我们希望我们已经成功地传达了这样一个信息:测试并不是一个完美定义的东西,你可以从书中学习。在感到舒适之前,你需要花相当长的时间去实验。在所有关于学习和实验的努力中,我们认为测试是最重要的之一。

在下一章中,我们将探讨调试和性能分析,这些技术与测试密切相关。您应该确保您能很好地掌握它们。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十一章:调试和性能分析

“如果调试是移除软件错误的过程,那么编程就必须是引入错误的过程。”

——埃德加·W·迪杰斯特拉

在专业程序员的生涯中,调试和故障排除占据了大量的时间。除了最简单的软件,所有软件都几乎肯定会存在错误。人类并不完美;我们会犯错误。因此,我们编写的代码也不完美。作为开发者,我们花费大量时间阅读其他开发者编写的代码。在我们看来,一个好的软件开发者是在阅读代码时也会留心潜在错误的开发者,即使这些代码并未报告为错误或有缺陷。

能够高效快速地调试代码是每个程序员需要不断改进的技能。就像测试一样,调试是一项最好通过经验来学习的技能。你可以遵循一些指南,但没有一本书能教你成为调试高手所需知道的一切。

我们觉得在这个问题上,我们从同事那里学到了最多的东西。我们惊讶地观察到有人非常擅长解决问题。我们喜欢看到他们采取的步骤,他们验证以排除潜在原因的事情,以及他们如何选择最终引导他们找到解决方案的路径。

我们与之共事的每一位同事都能教会我们一些东西,或者用他们出色的猜测来让我们感到惊讶,而这些猜测最终被证明是正确的。当这种情况发生时,不要只是停留在惊奇(或者更糟,嫉妒)之中,而要抓住这个机会,询问他们是如何得出这个猜测的,为什么会有这样的想法。这个答案将帮助你判断是否有什么东西你可以深入研究,以便下次你能找到错误。

有些错误很容易被发现。它们来自错误,一旦你看到这些错误的效果,就很容易找到解决问题的方法。但还有一些错误更为微妙,需要真正的专业知识以及大量的创造性和跳出思维来处理。最糟糕的错误是非确定性的错误。这些错误有时会发生,有时不会。有些错误只会在特定的环境中发生,而在看似相同的环境中则不会发生。

在专业环境中,我们经常需要在高度紧张的情况下调试代码。如果一个网站宕机,或者客户感到不满,业务就会损失金钱。因此,通常会对开发者施加很大的压力,要求他们立即找到并修复问题。在这种情况下,能够保持冷静至关重要。如果你想要有效地与错误作斗争,这是最重要的技能。压力会负面影响我们寻找和修复错误所需的创造性思维和解决问题的能力。所以,深呼吸,坐好,集中注意力。

在本章中,我们将尝试展示一些有用的技术,您可以根据错误的严重程度来使用这些技术,以及一些希望有助于增强您对抗错误和问题的武器的建议。

具体来说,我们将关注以下内容:

  • 调试技术

  • 故障排除指南

  • 性能分析

调试技术

在这部分,我们将向您介绍我们最常用的几种技术。这不是一个详尽的列表,但它应该能给您一些有用的想法,告诉您在调试自己的 Python 代码时从哪里开始。

使用打印进行调试

理解任何错误的关键是理解错误发生时您的代码正在做什么。因此,我们将探讨一些不同的技术,用于在程序运行时检查程序的状态。

所有技术中最简单的一种是在代码的各个位置添加print()调用。这允许您轻松地看到哪些部分的代码被执行,以及在执行过程中的关键变量的值。例如,如果您正在开发一个 Django 网站,页面上的行为不是您预期的,您可以在视图中添加打印语句,并在重新加载页面时关注控制台。

使用print()进行调试有几个缺点和局限性。要使用这种技术,您需要能够修改源代码并在终端中运行它,以便您可以看到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]):
    from time import time  # local import
    print(*msg)
    if timestamp[0] is None:
        timestamp[0] = time()  # 1
    else:
        now = time()
        print(f" Time elapsed: {now - timestamp[0]:.3f}s")
        timestamp[0] = now  # 2
debug("Entering buggy piece of code...")
sleep(0.3)
debug("First step done.")
sleep(0.5)
debug("Second step done.") 

这有点复杂。首先,注意我们在debug()函数内部使用了import语句来从time模块导入time()函数。这样做可以避免在函数外部添加import语句,从而降低忘记移除它的风险。

看看我们是如何定义timestamp的。它是一个带有列表作为默认值的函数参数。在第四章函数,代码的构建块中,我们警告过不要为参数使用可变默认值,因为默认值是在 Python 解析函数时初始化的,并且相同的对象会在函数的不同调用中持续存在。大多数情况下,这不是你想要的行为。然而,在这种情况下,我们正是利用这一特性来存储函数上一次调用的时间戳,而不必使用外部全局变量。我们是从对闭包的研究中借用这个技巧的,这是一个我们鼓励你阅读的技巧。

在打印消息后,我们检查timestamp中的唯一项的内容。如果它是None,那么我们没有先前的时戳,因此我们将值设置为当前时间(#1)。另一方面,如果我们有一个先前的时戳,我们可以计算一个差值(我们将其格式化为三位小数),最后,我们将当前时间放入timestamp#2)。

运行此代码会输出以下内容:

$ python custom_timestamp.py
Entering buggy piece of code...
First step done.
 Time elapsed: 0.300s
Second step done.
 Time elapsed: 0.500s 

使用自定义的调试函数解决了仅使用print()时的一些问题。它减少了调试代码的重复,并在你不再需要时更容易移除所有调试代码。然而,它仍然需要修改代码并在可以检查输出的控制台中运行它。在本章的后面部分,我们将看到如何通过向代码中添加日志记录来克服这些困难。

使用 Python 调试器

另一种有效的调试 Python 的方法是使用交互式调试器。Python 标准库模块pdb提供了一个这样的调试器;然而,我们通常更喜欢使用第三方pdbpp包。pdbpppdb的替代品,拥有一个相对友好的用户界面和一些实用的额外工具,我们最喜欢的是粘性模式,它允许你在单步执行指令时看到整个函数。

激活调试器(如果你已经安装了pdbpp包,它将代替标准的pdb调试器)有几种不同的方法。最常见的方法是在你的代码中添加一个调用调试器的调用。这被称为在代码中添加断点

当代码运行并且解释器达到断点时,执行会暂停,你将获得对交互式调试会话的控制台访问权限。然后你可以检查当前作用域中的所有名称,并逐行执行程序。你还可以实时更改数据以改变程序的流程。

作为玩具示例,假设我们有一个程序,该程序接收一个字典和一个键的元组作为输入。然后它使用给定的键处理字典项。程序正在引发KeyError,因为其中一个键在字典中缺失。假设我们无法控制输入(可能来自第三方 API),但我们想绕过错误,以便我们可以验证我们的程序在有效输入上是否表现正确。让我们看看我们如何可以使用调试器中断程序,检查并修复数据,然后允许执行继续:

# pdebugger.py
# d comes from an input that we do not control
d = {"first": "v1", "second": "v2", "fourth": "v4"}
# keys also comes from an input we do not 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都来自我们无法控制的输入源。如果我们按原样运行代码,我们会得到以下结果:

$ python pdebugger.py
v1
v2
Traceback (most recent call last):
  File ".../ch11/pdebugger.py", line 13, in <module>
    do_something_with_value(d[key])
                            ~^^^^^
KeyError: 'third' 

我们发现字典中缺少了key,但由于每次运行此代码时,我们可能会得到不同的字典或keys元组,这个信息实际上并没有真正帮助我们。我们希望在程序运行时检查和修改数据,因此让我们在for循环之前插入一个断点。在 Python 的现代版本中,这样做最简单的方法是调用内置的breakpoint()函数:

breakpoint() 

在 Python 3.7 之前,你需要导入pdb模块并调用pdb.set_trace()函数:

import pdb; pdb.set_trace() 

注意,我们使用分号来分隔同一行上的多个语句。PEP 8 不鼓励这样做,但在设置此类断点时相当常见,因为当你不再需要断点时,要删除的行更少。

breakpoint()函数调用sys.breakpointhook(),它反过来调用pdb.set_trace()。你可以通过将PYTHONBREAKPOINT环境变量设置为指向一个替代函数来覆盖sys.breakpointhook()的默认行为,而不是调用pdb.set_trace()

此示例的代码位于pdebugger_pdb.py模块中。如果我们现在运行此代码,事情会变得有趣(注意你的输出可能略有不同,并且此输出中的所有注释都是我们添加的):

$ python pdebugger_pdb.py
[0] > .../ch11/pdebugger_pdb.py(17)<module>()
-> for key in keys:
(Pdb++) l
 16
 17 -> for key in keys:  # breakpoint comes in
 18 do_something_with_value(d[key])
 19
(Pdb++) keys  # inspect the keys tuple
('first', 'second', 'third', 'fourth')
(Pdb++) d.keys()  # inspect keys of d
dict_keys(['first', 'second', 'fourth'])
(Pdb++) d['third'] = 'placeholder'  # add missing item
(Pdb++) c  # continue
v1
v2
placeholder
v4
Validation done. 

首先,请注意,当你达到断点时,你会看到一个控制台,它会告诉你你在哪里(Python 模块)以及下一行将要执行的代码。在这个时候,你可以执行一些探索性操作,例如检查下一行之前和之后的代码,打印堆栈跟踪,以及与对象交互。在我们的例子中,我们首先检查 keys 元组。我们还检查了 d 的键。我们发现 'third' 缺失,所以我们自己添加了它(这会危险吗?想想看)。最后,现在所有的键都已经添加完毕,我们输入 c 来继续正常执行。

调试器还允许你使用 n 命令(对于下一个)逐行执行你的代码。你可以使用 s 命令进入函数以进行更深入的分析,或者使用 b 命令设置额外的断点。有关命令的完整列表,请参阅文档(您可以在 docs.python.org/3.12/library/pdb.html 找到)或在使用调试器控制台时输入 h(对于帮助)。

从前面的运行输出中,你可以看到我们最终到达了验证的末尾。

pdb(或 pdbpp)是我们每天都会使用的无价工具。所以,请尝试使用它。在某个地方设置一个断点并尝试检查它,遵循官方文档,并在你的代码中尝试命令以查看它们的效果并熟练掌握它们。

注意,在这个例子中,我们假设你已经安装了 pdbpp。如果情况不是这样,你可能会发现一些命令在普通的 pdb 中表现略有不同。一个例子是字母 dpdb 将其解释为 down 命令。为了解决这个问题,你需要在 d 前面加上一个 ! 来告诉 pdb 它应该被字面地解释,而不是作为一个命令。

检查日志

另一种调试表现不佳的应用程序的方法是检查其日志。日志是按顺序排列的事件列表,这些事件是在应用程序运行期间发生或采取的动作。如果日志被写入磁盘上的文件,它就被称为日志文件

使用日志进行调试在某些方面与添加 print() 调用或使用自定义调试函数相似。关键区别在于,我们通常从一开始就在代码中添加日志以帮助未来的调试,而不是在调试期间添加它然后再移除。另一个区别是,日志可以轻松地配置为输出到文件或网络位置。这两个方面使得日志非常适合调试可能无法直接访问的远程机器上运行的代码。

事实是,日志记录通常是在发生错误之前添加到代码中的,这确实提出了决定记录什么内容的挑战。我们通常会期望在日志中找到与应用程序内发生的任何重要过程的开始、完成(以及可能的中途步骤)相对应的条目。重要变量的值应包含在这些日志条目中。错误也需要被记录,这样如果出现问题,我们可以检查日志以找出出了什么问题。

Python 中的日志记录几乎各个方面都可以以各种方式配置。这赋予我们很大的权力,因为我们可以通过更改日志配置来改变日志输出的位置、输出的日志消息以及日志消息的格式,而无需更改任何其他代码。在 Python 中涉及日志记录的四个主要类型的对象是:

  • 日志记录器:暴露应用程序代码直接使用的接口

  • 处理器:将日志记录(由日志记录器创建)发送到适当的目的地

  • 过滤器:提供了一种更细粒度的设施来决定要输出哪些日志记录

  • 格式化器:指定最终输出中日志记录的布局

日志记录是通过调用Logger类实例的方法来执行的。你记录的每一行都与一个严重级别相关联。最常用的级别是DEBUGINFOWARNINGERRORCRITICAL。日志记录器使用这些级别来确定要输出哪些日志消息。低于日志记录器级别的任何内容都将被忽略。这意味着你必须小心地以适当的级别进行日志记录。如果你以DEBUG级别记录一切,你需要将你的日志记录器配置在DEBUG级别或以下,才能看到任何消息。这可能会迅速导致你的日志文件变得非常大。如果你以CRITICAL级别记录一切,也会出现类似的问题。

Python 为你提供了多个选择来记录日志。你可以将日志记录到文件、网络位置、队列、控制台、操作系统的日志设施等。你发送日志的位置通常非常依赖于上下文。例如,当你在你开发环境中运行你的代码时,你通常会记录到你的终端。如果你的应用程序在单个机器上运行,你可能将日志记录到文件或将日志发送到操作系统的日志设施。

另一方面,如果你的应用程序使用跨越多个机器的分布式架构(例如在面向服务的或微服务架构的情况下),那么实现一个集中式的日志记录解决方案会更好,这样每个服务的所有日志消息都可以存储和调查在一个地方。这使得调试变得容易得多,因为试图将来自多个来源的巨大文件关联起来以找出出了什么问题,可能会变得真正具有挑战性。

面向服务的架构SOA)是软件设计中的一个架构模式,其中应用程序组件通过通信协议(通常是网络)向其他组件提供服务。这个系统的美妙之处在于,当代码编写得当,每个服务都可以用最合适的语言来编写,以实现其目的。唯一重要的是与其他服务的通信,这需要通过一个公共格式来实现,以便进行数据交换。

微服务架构是 SOA 的演变,但遵循不同的架构模式。

Python 的日志记录的可配置性的缺点是日志机制相对复杂。幸运的是,默认设置通常足够,你只有在有特定定制需求时才需要覆盖设置。让我们看看将几条消息记录到文件中的简单示例:

# log.py
import logging
logging.basicConfig(
    filename="ch11.log",
    level=logging.DEBUG, 
    format="[%(asctime)s] %(levelname)s: %(message)s",
    datefmt="%m/%d/%Y %I:%M:%S %p")
mylist = [1, 2, 3]
logging.info("Starting to process 'mylist'...")
for position in range(4):
    try:
        logging.debug(
            "Value at position %s is %s",
            position,
            mylist[position]
        )
    except IndexError:
        logging.exception("Faulty position: %s", position)
logging.info("Done processing 'mylist'.") 

首先,我们导入logging模块,然后设置基本配置。我们指定一个文件名,配置记录器以输出任何级别为DEBUG或更高的日志消息,并设置消息格式。我们希望记录日期和时间信息、级别和消息。

在配置到位后,我们可以开始记录日志。我们首先记录一条info消息,告诉我们我们即将处理我们的列表。在循环内部,我们将记录每个位置上的值(我们使用debug()函数在DEBUG级别记录)。我们在这里使用debug()是为了将来能够过滤掉这些日志(通过配置记录器的levellogging.INFO或更高),因为我们可能需要处理大型列表,我们不希望总是记录所有值。

如果我们遇到IndexError(我们确实遇到了,因为我们正在遍历range(4)),我们调用logging.exception(),它在ERROR级别记录,但也会输出异常回溯。

在代码的末尾,我们记录了一条另一个info消息,表示我们已经完成。运行此代码后,我们将有一个新的ch11.log文件,其中包含以下内容:

# ch11.log
[10/06/2024 10:08:04 PM] INFO: Starting to process 'mylist'...
[10/06/2024 10:08:04 PM] DEBUG: Value at position 0 is 1
[10/06/2024 10:08:04 PM] DEBUG: Value at position 1 is 2
[10/06/2024 10:08:04 PM] DEBUG: Value at position 2 is 3
[10/06/2024 10:08:04 PM] ERROR: Faulty position: 3
Traceback (most recent call last):
  File ".../ch11/log.py", line 20, in <module>
    mylist[position],
    ~~~~~~^^^^^^^^^^
IndexError: list index out of range
[10/06/2024 10:08:04 PM] INFO: Done processing 'mylist'. 

这正是我们能够调试运行在远程机器上的应用程序而不是我们自己的开发环境所需要的东西。我们可以看到我们的代码做了什么,任何抛出的异常的回溯,等等。

随意修改前一个示例中的日志级别,包括代码和配置。这样,你将能够看到输出如何根据你的设置而变化。

这里提供的示例只是对日志记录的表面了解。对于更深入的解释,你可以在官方 Python 文档的Python HOWTOs部分找到信息:Logging HOWTOLogging Cookbook

记录日志是一种艺术。你需要找到一个在记录一切和记录什么都不记录之间的良好平衡。理想情况下,你应该记录任何你需要确保应用程序正确运行的事情,以及可能的所有错误或异常。

其他技术

我们将简要提及一些其他可能对您有用的调试技术来结束本节的调试部分。

阅读跟踪信息

错误通常表现为未处理的异常。因此,解释异常跟踪信息是成功调试的关键技能。请确保您已经阅读并理解了第七章异常和上下文管理器中关于跟踪信息的部分。如果您试图了解异常发生的原因,检查程序在跟踪信息中提到的行所表示的状态(使用我们上面讨论的技术)通常很有用。

断言

错误通常是我们代码中不正确假设的结果。断言可以帮助验证这些假设。如果我们的假设是有效的,断言通过并正常执行。如果它们不是,我们会得到一个异常,告诉我们哪些假设是不正确的。有时,与其使用调试器或 print() 语句进行检查,不如在代码中添加几个断言来排除可能性更快。让我们看一个例子:

# assertions.py
mylist = [1, 2, 3]  #  pretend this comes from an external source
assert 4 == len(mylist)  # this will break
for position in range(4):
    print(mylist[position]) 

在这个例子中,我们假设 mylist 来自一些外部来源,我们无法控制(可能是用户输入)。for 循环假设 mylist 有四个元素,我们添加了一个断言来验证这个假设。当我们运行代码时,结果是这个:

$ python assertions.py
Traceback (most recent call last):
  File ".../ch11/assertions.py", line 4, in <module>
    assert 4 == len(mylist)  # this will break
           ^^^^^^^^^^^^^^^^
AssertionError 

这告诉我们问题确实在哪里。

当激活 -O 标志运行程序时,Python 将忽略所有断言。如果我们的代码依赖于断言来工作,这一点需要记住。

断言还允许更长的格式,包括第二个表达式,例如:

assert expression1, expression2 

第二个表达式传递给由语句引发的 AssertionError 异常。它通常是一个包含错误信息的字符串。例如,如果我们将上一个例子中的断言更改为以下内容:

assert 4 == len(mylist), f"Mylist has {len(mylist)} elements" 

结果将是:

$ python assertions.py
Traceback (most recent call last):
  File ".../ch11/assertions.py", line 19, in <module>
    assert 4 == len(mylist), f"Mylist has {len(mylist)} elements"
           ^^^^^^^^^^^^^^^^
AssertionError: Mylist has 3 elements 

信息查找位置

官方 Python 文档包含一个专门用于调试和性能分析的章节。在那里,您可以阅读关于 bdb 调试框架以及 faulthandlertimeittracetracemallocpdb 等模块的信息。

让我们现在探索一些故障排除指南。

故障排除指南

在本节简短的部分,我们想向您提供一些来自我们故障排除经验的技巧。

检查位置

我们的第一些建议是关于在哪里放置您的调试断点。无论您是使用 print()、自定义函数、pdb 还是日志记录,您仍然必须选择放置提供信息的调用位置。有些地方肯定比其他地方好,而且有一些处理调试进度的方法比其他方法更好。

我们通常避免在if子句内放置断点。如果包含断点的分支没有被执行,我们就失去了获取我们想要的信息的机会。有时,重现一个错误可能很困难,或者你的代码可能需要一段时间才能到达断点,所以在放置它们之前要仔细思考。

另一个重要的事情是确定从哪里开始。想象一下,你有 100 行代码来处理你的数据。数据从第 1 行开始输入,不知何故,在第 100 行出现了错误。你不知道错误在哪里,那么你该怎么办?你可以在第 1 行设置一个断点,并耐心地逐行检查所有 100 行,检查每一步的数据。在最坏的情况下,经过 99 行(以及许多杯咖啡)后,你发现了错误。所以,考虑使用不同的方法。

从第 50 行开始检查。如果数据是好的,这意味着错误发生在后面,在这种情况下,你将下一个断点设置在第 75 行。如果第 50 行的数据已经不好,你继续通过在第 25 行设置断点。然后,你重复这个过程。每次,你要么向后移动,要么向前移动,移动的距离是上一次跳跃距离的一半。

在我们最坏的情况下,你的调试将从 1, 2, 3, ..., 99,以线性方式,转变为一系列跳跃,如 50, 75, 87, 93, 96, ..., 99,这要快得多。实际上,这是一种对数搜索技术。这种搜索技术被称为二分搜索;它基于分而治之的方法,并且非常有效,所以尽量掌握它。

使用测试进行调试

在第十章的测试部分,我们简要地向你介绍了测试驱动开发TDD)。即使你并不完全接受 TDD,你也应该采纳的一个 TDD 实践是在开始修改代码以修复错误之前,编写重现错误的测试。这样做有几个原因。如果你有一个错误,并且所有测试都通过了,这意味着你的测试代码库中可能存在错误或遗漏。

添加这些测试将帮助你确保你真正修复了错误:只有当错误消失时,测试才能通过。最后,拥有这些测试将保护你免得意外地再次引入相同的错误。

监控

监控同样重要。软件应用程序有时在边缘情况下可能会表现出意外的行为,例如网络中断、队列满或外部组件无响应。在这些情况下,了解问题发生时的大致情况,并将其与相关联的微妙、甚至神秘的事物联系起来,这一点很重要。

你可以监控 API 端点、进程、网页的可用性和加载时间,以及你可以编码的一切。一般来说,当你从头开始启动一个应用程序时,从最早的设计阶段开始考虑你想要如何监控它可能会有所帮助。

现在,让我们继续看看我们如何对 Python 代码进行性能分析。

Python 性能分析

分析意味着在运行应用程序的同时跟踪几个不同的参数,例如函数被调用的次数以及在其中花费的时间量。

分析与调试密切相关。尽管使用的工具和过程相当不同,但这两项活动都涉及探测和分析您的代码,以了解问题的根源,然后进行更改以修复它。区别在于,我们试图解决的问题不是错误的输出或崩溃,而是性能不佳。

有时,分析会指向性能瓶颈所在的位置,此时您需要使用本章前面讨论的调试技术来了解为什么某个特定的代码片段没有像预期的那样表现良好。例如,数据库查询中的错误逻辑可能会导致从表中加载数千行而不是数百行。分析可能会显示某个特定函数被调用的次数比预期多得多,此时您需要使用您的调试技能来找出原因并解决问题。

有几种方法可以分析 Python 应用程序。如果您查看标准库官方文档中的分析部分,您将看到有两种不同的分析接口实现,profilecProfile

  • cProfile是用 C 语言编写的,并且相对较少地增加了开销,这使得它适合分析长时间运行的程序。

  • profile是用纯 Python 实现的,因此为被分析的程序增加了显著的开销。

此接口执行确定性分析,这意味着所有函数调用、函数返回和异常事件都被监控,并在这些事件之间的间隔中进行精确计时。另一种方法称为统计分析,在固定的时间间隔内随机采样程序的调用堆栈,并推断时间花费在哪里。

后者通常涉及较少的开销,但只提供近似的结果。此外,由于 Python 解释器运行代码的方式,确定性分析并不像人们想象的那样增加很多开销,因此我们将向您展示一个使用命令行中的cProfile的简单示例。

有时,即使是相对较低的开销的cProfile也不可接受,例如,如果您需要在实时生产 Web 服务器上分析代码,因为您无法在开发环境中重现性能问题。在这种情况下,您确实需要一个统计分析器。如果您对 Python 的统计分析感兴趣,我们建议您查看py-spygithub.com/benfred/py-spy)。

我们将再次使用以下代码计算毕达哥拉斯三元组:

# 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) ** 0.5
def is_int(n):
    return n.is_integer()
triples = calc_triples(1000) 

脚本很简单;我们用ab遍历区间[1, mx](通过设置b >= a避免重复的配对)并检查它们是否属于直角三角形。我们使用calc_hypotenuse()来获取ab的斜边,然后,使用is_int()检查它是否为整数,这意味着(a, b, 斜边)是一个毕达哥拉斯三元组。

当我们对这个脚本进行性能分析时,我们以表格形式获得信息。列包括ncalls(函数调用的次数)、tottime(每个函数花费的总时间)、percall(每次调用每个函数的平均时间)、cumtime(函数及其调用的所有函数的累积时间)、percall(每次调用的平均累积时间)和filename:lineno(function)。以下是我们的结果(为了节省空间,我们省略了两个percall列):

$ python -m cProfile profiling/triples.py
1502538 function calls in 0.393 seconds
Ordered by: cumulative time
ncalls tottime cumtime filename:lineno(function)
     1   0.000   0.393 {built-in method builtins.exec}
     1   0.000   0.393 triples.py:1(<module>)
     1   0.143   0.393 triples.py:1(calc_triples)
500500   0.087   0.147 triples.py:15(is_int)
500500   0.102   0.102 triples.py:11(calc_hypotenuse)
500500   0.060   0.060 {method 'is_integer' of 'float' objects}
  1034   0.000   0.000 {method 'append' of 'list' objects}
     1   0.000   0.000 {method 'disable' of '_lsprof.Profiler' objects} 

即使只有这么有限的数据,我们仍然可以从中推断出一些关于这段代码的有用信息。首先,我们可以看到我们选择的算法的时间复杂度随着输入大小的平方增长。calc_hypotenuse()函数的调用次数正好是mx (mx + 1) / 2。我们用mx = 1000运行脚本,得到了正好 500,500 次调用。循环内部发生的三件事是:我们调用calc_hypotenuse(),调用is_int(),如果条件满足,就将它添加到triples列表中。

在分析性能报告中的累积时间时,我们注意到程序在is_int()函数内部花费了 0.147 秒,而calc_hypotenuse()函数内部则花费了 0.102 秒。这两个函数被调用的次数相同,因此我们优化的首要目标应该是成本更高的is_int()函数。

如果我们查看tottime列,我们会看到程序在is_int()中花费了 0.087 秒。这排除了在is_int()调用float对象的is_integer()方法中花费的 0.060 秒。然而,is_int()除了调用其参数nis_integer()方法外,没有做任何事情。这意味着仅仅额外的函数调用就增加了 87 毫秒的开销。在这个程序中,is_int()函数并没有带来太多好处,因此我们可以通过直接调用hypotenuse.is_integer()来节省 87 毫秒。

如果我们重新运行性能分析,我们会看到现在我们在calc_hypotenuse()中花费的时间比在is_integer()方法中更多。正如我们在第五章 理解与生成器中提到的,使用**幂运算符来计算一个数的平方比将其自身相乘要昂贵。考虑到这一点,我们可以尝试通过将calc_hypotenuse()更改为以下内容来提高性能:

def calc_hypotenuse(a, b): 
    return (a * a + b * b) ** 0.5 

再次运行性能分析后,我们发现程序现在在calc_hypotenuse()函数中花费了 0.084 秒。我们只获得了 18 毫秒的改进。我们可以通过消除对calc_hypotenuse()函数调用的开销并直接计算斜边来获得更多的改进:

 hypotenuse = (a * a + b * b) ** 0.5 

对这个版本进行性能分析显示,我们可以通过这种方式获得高达 100 毫秒的改进。然而,我们认为,在这种情况下,函数提供的可读性、可维护性和可测试性的好处超过了移除它所带来的性能提升。

你将在本书的源代码中找到这个程序的四个版本。我们鼓励你自己运行性能分析,并尝试对代码进行其他更改以查看它们对性能的影响(例如,你可以尝试将calc_triples()转换为生成器函数)。

当然,这个例子很简单,但足以展示你如何对应用程序进行性能分析。了解对函数调用的次数可以帮助我们更好地理解算法的时间复杂度。例如,许多程序员未能意识到那两个for循环是与输入大小的平方成比例运行的。

我们已经看到了函数的性能分析,但如果需要,也可以达到更高的粒度级别,对代码的每一行进行性能分析。平均而言,Python 程序员在其职业生涯中可能不需要进行太多的性能分析,但这种情况可能会发生,因此了解我们拥有的选项是很好的。

有一个需要提到的事情:性能分析的结果很可能因你所运行的系统而异。因此,如果可能的话,在尽可能接近软件部署的系统上进行软件的性能分析是很重要的,如果不是在它上面的话。

在本节中,我们专注于性能分析和优化程序的运行时间。性能分析也可以用来分析和优化内存使用。Python 中用于内存分析的最受欢迎的工具之一是 memray。你可以在bloomberg.github.io/memray/了解更多信息。

何时进行性能分析

了解何时进行性能分析以及如何处理我们得到的结果是很重要的。唐纳德·克努特曾经说过,“过早的优化是所有邪恶的根源”,虽然我们不会说得这么绝对,但我们确实同意他的观点。例如,为了获得几毫秒的速度提升而牺牲可读性或可维护性通常是不值得的。

你的首要关注点始终应该是正确性。你希望你的代码能够输出正确的结果,因此编写测试,寻找边缘情况,并以你认为合理的方式对代码进行压力测试。不要过于保护;不要将事情放在大脑的后面,因为你认为它们不太可能发生。要全面考虑。

其次,注意编码最佳实践。记住以下几点:可读性、可扩展性、松散耦合、模块化、设计。应用面向对象编程(OOP)原则:封装、抽象、单一职责、开闭原则等。了解这些概念。它们将为你的视野打开新的天地,并扩展你对代码的思考方式。

第三,重构。童子军规则说:

总是保持露营地的清洁,比找到它时更干净。

将此规则应用到你的代码中。

最后,当所有这些都已经处理完毕后,然后,并且只有然后,才开始优化和性能分析。

运行你的分析器并识别瓶颈。在分析分析结果时,关注被调用次数最多的函数。正如我们在第五章列表推导式和生成器中提到的,你甚至可以从对被调用一百万次的函数的微小改进中获得比尝试改进只被调用几次的函数更多的收益。当你对需要解决的瓶颈有了概念后,从最严重的一个开始。有时,修复一个瓶颈会引起连锁反应,从而改变其余代码的工作方式。有时,这种影响可能只是微小的,有时,可能更多一些,这取决于你的代码是如何设计和实现的。因此,从最大的问题开始。

Python 之所以如此受欢迎,其中一个原因是它可以使用用更快、编译语言(如 C 或 C++)编写的模块来扩展。所以,如果你有一些关键的代码,你无法在纯 Python 中达到所需的性能,你总是可以选择将其部分重写为 C。

测量执行时间

在我们完成本章之前,我们想简要地讨论一下测量代码执行时间的话题。有时,测量小块代码的性能以比较它们之间的性能是有帮助的。例如,如果你有几种实现某些操作的方法,并且你真的需要最快的版本,你可能想在不分析整个应用程序的情况下比较它们的性能。

我们在本章前面已经看到了一些测量和比较执行时间的例子,例如,在第五章列表推导式和生成器中,当我们比较for循环、列表推导式和map()函数的性能时。在此阶段,我们想向您介绍一种更好的方法,即使用timeit模块。此模块使用诸如多次重复执行代码以改进测量精度等技术。

timeit模块可能有点难以使用。我们建议你在官方 Python 文档中阅读有关内容,并在那里尝试示例,直到你理解如何使用它。在这里,我们只简要演示如何使用命令行界面来测量上一个例子中calc_hypotenuse()的两个不同版本的执行时间:

$ python -m timeit -s 'a=2; b=3' '(a**2 + b**2) ** .5'
5000000 loops, best of 5: 91 nsec per loop 

在这里,我们正在运行timeit模块,初始化变量a = 2b = 3,然后在执行(a**2 + b**2) ** .5之前计时。在输出中,我们可以看到timeit运行了 5 次重复,每次重复计时 5,000,000 次循环迭代执行我们的计算。在这 5 次重复中,5,000,000 次迭代中的最佳平均执行时间为 91 纳秒。让我们看看替代计算(a*a + b*b) ** .5的表现:

$ python -m timeit -s 'a=2; b=3' '(a*a + b*b) ** .5'
5000000 loops, best of 5: 72.8 nsec per loop 

这次,我们每循环的平均时间为 72.8 纳秒。这再次证实了第二个版本稍微快一点。

timeit模块会自动选择迭代次数,以确保总运行时间至少为 0.2 秒。这有助于通过减少测量开销的相对影响来提高准确性。

关于测量 Python 性能的更多信息,请确保查看pyperf (github.com/psf/pyperf) 和 pyperformance (github.com/python/pyperformance)。

摘要

在这个简短的章节中,我们探讨了调试、故障排除和代码性能分析的不同技术和建议。调试是软件开发者工作的一部分,因此掌握它很重要。

如果以正确的心态去面对,这可以是一件有趣且有益的事情。

我们探讨了使用自定义函数、日志记录、调试器、跟踪信息、性能分析和断言来检查我们的代码的技术。我们看到了其中大多数的简单示例。我们还讨论了一些有助于应对挑战的指导方针。

总是记得要保持冷静和专注,这样调试将会容易得多。这也是一种需要学习并且最重要的技能。一个焦躁和紧张的大脑无法正常、逻辑和创造性地工作。因此,如果你不加强它,你将很难充分利用你的知识。所以,当你面对一个棘手的 bug 时,如果你有机会,确保你进行短暂的散步或小憩——放松。通常,在良好的休息之后,解决方案就会显现出来。

在下一章中,我们将探讨类型提示和静态类型检查器的使用,这有助于减少某些类型错误的可能性。

加入我们的社区 Discord

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

加入我们的 Discord 社区

img

第十二章:类型提示简介

“知己知彼,百战不殆。”

– 亚里士多德

在本章中,我们将探讨类型提示这一主题。类型提示可能是自 Python 2.2 以来 Python 引入的最大变化,它实现了类型和类的统一。

具体来说,我们将研究以下主题:

  • Python 对类型的处理方法。

  • 可用于注解的类型。

  • 协议(简要介绍)。

  • Mypy,Python 的静态类型检查器。

Python 对类型的处理方法

Python 是一种强类型动态类型的语言。

强类型意味着 Python 不允许可能导致意外行为的隐式类型转换。考虑以下php代码:

<?php
$a = 2;
$b = "2";
echo $a + $b; // prints: 4
?> 

在 php 中,变量前面有一个$符号。在上面的代码中,我们将$a设置为整数2,将$b设置为字符串"2"。要将它们相加,php 会执行从字符串到整数的隐式转换。这被称为类型魔术。这看起来可能很方便,但 php 是弱类型的,这可能导致代码中的 bug。如果我们尝试在 Python 中做同样的事情,结果将大不相同:

# example.strongly.typed.py
a = 2
b = "2"
print(a + b) 

运行上述代码会产生:

$ python ch12/example.strongly.typed.py
Traceback (most recent call last):
  File "ch12/example.strongly.typed.py", line 3, in <module>
    print(a + b)
          ~~^~~
TypeError: unsupported operand type(s) for +: 'int' and 'str' 

Python 是强类型的,所以当我们尝试将整数加到字符串上——或者任何不兼容类型的组合——我们会得到TypeError

动态类型意味着 Python 在运行时确定变量的类型,这意味着我们不需要在代码中显式指定类型。

相比之下,像 C++、Java、C#和 Swift 这样的语言都是静态类型的。当我们在这类语言中声明变量时,我们必须指定它们的类型。例如,在 Java 中,常见的变量声明如下:

String name = "John Doe";
int age = 60; 

两种方法都有优点和缺点,所以很难说哪一种最好。Python 被设计成简洁、精炼和优雅。其设计的一个优点就是鸭子类型

鸭子类型

另一个 Python 帮助普及的概念是鸭子类型。本质上,这意味着一个对象的数据类型或类不如它定义的方法或支持的运算重要。俗话说:“如果它看起来像鸭子,游泳像鸭子,叫起来像鸭子,那么它可能就是一只鸭子。”

由于 Python 是动态类型的语言,鸭子类型在 Python 中被广泛使用。它提供了更大的灵活性和代码重用。考虑以下示例:

# duck.typing.py
class Circle:
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * (self.radius**2)
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    def area(self):
        return self.width * self.height
def print_shape_info(shape):
    print(f"{shape.__class__.__name__} area: {shape.area()}")
circle = Circle(5)
rectangle = Rectangle(4, 6)
print_shape_info(circle)  # Circle area: 78.53975
print_shape_info(rectangle)  # Rectangle area: 24 

在上述代码中,print_shape_info()函数不关心shape的具体类型。它只关心shape有一个名为area()的方法。

类型提示的历史

虽然 Python 对类型的处理方法是导致其广泛采用成功的一个特征,但在 Python 3 中,我们看到了一个逐渐且精心设计的演变,旨在整合类型安全特性,同时保持 Python 的动态特性。

这始于 Python 3.0,随着函数注解的引入,由 PEP 3107 (peps.python.org/pep-3107/) 提出。这一新增功能允许开发者向函数参数和返回值添加任意元数据。这些注解最初是作为文档工具而设计的,没有语义意义。这一步是引入显式类型提示支持的基础层。

在 Python 3.5 中,随着 PEP 484 (peps.python.org/pep-0484/) 的落地,类型提示的真正起点出现了。PEP 484 正式化了类型提示的添加,建立在 PEP 3107 提出的语法之上。它定义了声明函数参数、返回值和变量类型的标准方式。

这使得可选的静态类型检查成为可能,开发者现在可以使用 Mypy 等工具在运行前检测类型相关的错误。

在 Python 3.6 中,我们看到了变量声明注解的引入。这是由 PEP 526 (peps.python.org/pep-0526/) 带来的。这一新功能意味着类型可以在整个代码中显式声明,而不仅仅是函数中。它还包括类属性和模块级变量。这进一步提高了 Python 的类型提示能力,并使得静态分析代码变得更加容易。

随后的增强和 PEP 进一步精炼和扩展了 Python 的类型系统。主要的有以下几项:

  • PEP 544 (peps.python.org/pep-0544/) 在 Python 3.8 中落地,引入了协议的概念,这实现了鸭子类型和进一步的静态类型检查。

  • PEP 585 (peps.python.org/pep-0585/) 在 Python 3.9 中落地,并设立了另一个里程碑。它通过直接与 Python 核心集合集成,彻底改变了类型提示。这消除了从 typing 模块导入类型以用于常见数据结构(如字典和列表)的需要。

  • PEP 586 (peps.python.org/pep-0586/) 在 Python 3.8 中落地,并添加了字面量类型,允许函数指定字面量值作为参数。

  • PEP 589 (peps.python.org/pep-0589/) 在 Python 3.8 中落地,引入了 TypedDict,它为具有固定键集的字典提供了精确的类型提示。

  • PEP 604 (peps.python.org/pep-0604/) 在 Python 3.10 中落地,简化了联合类型的语法,从而简化了注解并提高了代码的可读性。

其他值得注意的 PEP 包括:

  • PEP 561 (peps.python.org/pep-0561/) 规定了如何分发支持类型检查的包。

  • PEP 563 (peps.python.org/pep-0563/),它改变了注解的评估方式,使得它们不在函数定义时进行评估。这种延迟在 Python 3.10 中被设置为默认行为。

  • PEP 593 (peps.python.org/pep-0593/),它介绍了一种方法来增强现有的类型提示,使用任意元数据,可能用于第三方工具。

  • PEP 612 (peps.python.org/pep-0612/),它引入了参数规范,允许更复杂的变量注解类型,特别是对于修改函数签名的装饰器非常有用。

  • PEP 647 (peps.python.org/pep-0647/),它引入了类型守卫,这些函数可以在条件块中实现更精确的类型推断。

  • PEP 673 (peps.python.org/pep-0673/),它引入了 Self 类型来表示类体和方法返回中的实例类型,使得涉及类的类型提示更加表达性和准确。

Python 类型提示的演变是由对代码更强大鲁棒性和可扩展性的需求所驱动的,利用了 Python 的动态特性。

虽然 Python 的类型提示流行度似乎持续增加,但重要的是要注意,根据 PEP 484(Guido van Rossum、Jukka Lehtosalo 和 Łukasz Langa)的作者:

“Python 将继续是一种动态类型语言,作者们没有意愿将类型提示强制化,即使是按照惯例。”

Python 类型提示的引入方式,以及该 PEP 的作者和 Python 的主要开发者的哲学方法,表明使用类型提示的选择,现在和将来都将由开发者决定。

让我们看看类型提示的一些主要好处。

类型提示的好处

采用类型提示带来了一些关键好处,如增强代码质量、可维护性和提高开发者效率。我们可以将它们总结成一个列表:

  • 改进代码可读性和文档:类型提示是一种文档形式。它们阐明了函数期望的参数类型以及它返回的类型。这有助于开发者立即理解代码,无需阅读冗长的注释或大量代码。

  • 增强错误检测:静态类型检查器,如 Mypy,可以扫描代码库并在运行前标记错误。这意味着一些错误可以在它们成为问题之前被消除。

  • 更好的 IDE 体验和自动完成:现代 IDE 利用类型提示提供更好的自动完成和增强重构功能。此外,有了类型提示,IDE 可以建议对象适当的方法和属性。

  • 改进的协作和代码审查:类型提示的文档质量使得一眼就能理解代码,这在阅读 pull request 中的更改时可能很有用。

  • 代码灵活性和可重用性:Python 的类型提示包括泛型、自定义类型和协议等特性,这些特性有助于开发者编写结构更清晰、更灵活的代码。

Python 类型提示系统的另一个重要方面是它可以逐步引入。实际上,在代码库中逐步引入类型提示是很常见的,最初将努力限制在最重要的地方。

类型注释

现在我们已经对 Python 的动态特性和其类型提示方法有了基础的了解,让我们开始探索一些示例和概念,看看它在实践中是如何应用的。

虽然我们展示了我们可以用来注释代码的主要类型,但如果您之前在代码中使用过类型注释,您可能会发现一些细微的差异。这很可能是由于 Python 中的类型提示目前仍在不断发展,因此它根据您使用的 Python 版本而有所不同。在本章中,我们将坚持 Python 3.12 的规则。

让我们从基本示例开始。

函数注释

在 Python 中,我们可以注释函数参数及其返回值。让我们从一个基本的greeter函数开始:

# annotations/basic.py
def greeter(name):
    return f"Hello, {name}!" 

这是一个简单的 Python 函数,它接受一个name并返回一个问候语。我们现在可以注释这个函数,指定我们期望name是一个字符串,并且返回的问候语也将是一个字符串。

# annotations/basic.py
def **greeter_annotated****(name:** **str****) ->** **str****:**
    return f"Hello, {name}!" 

如您从代码中高亮显示的部分所示,注释函数的语法很简单。我们使用参数名后的冒号来指定类型。return值通过一个箭头(->)后跟返回的对象类型进行注释。

现在我们添加另一个参数age,我们期望它是一个整数。

# annotations/basic.py
def greeter_annotated_age(name: str, age: int) -> str:
    return f"Hello, {name}! You are {age} years old." 

如您所预期的那样,我们对age参数做了同样的处理,只是这次我们指定了int,而不是str

如果您使用的是相对现代的 IDE,请尝试运行此代码。如果您输入nameage然后输入一个点(.),编辑器应该只会建议与您正在使用的对象类型相关的方法和属性。

这是一个基本示例,旨在向您展示类型注释的语法。请忽略人工命名的部分,如greeter_annotated()greeter_annotated_age();它们不是好的名字,但应该有助于您更快地发现差异。

我们现在将在此基础上进行扩展,向您展示 Python 类型提示的实际功能。

任意类型

这是一种特殊类型的类型。任何没有返回类型或参数类型的函数将隐式地默认使用Any。因此,以下函数声明是等效的。

# annotations/any.py
from typing import Any
def square(n):
    return n**2
def square_annotated(n: Any) -> Any:
    return n**2 

上述声明完全等效。Any 在某些情况下可能很有用,例如在注释数据容器、函数装饰器或当函数设计为处理多种类型的输入时。一个简单的例子可以是:

# annotations/any.py
from typing import Any
def reverse(items: list) -> list:
    return list(reversed(items))
def reverse_any(items: list[Any]) -> list[Any]:
    return list(reversed(items)) 

在这种情况下,这两个定义再次完全等效。唯一的区别是,在第二个定义中,通过使用 Any,我们明确指出列表 items 应该包含 任何 类型的对象。

类型别名

我们几乎可以在类型提示中使用任何 Python 类型。此外,typing 模块引入了几个我们可以利用的结构来扩展它们。

其中一种结构是 类型别名,它使用 type 语句定义。结果是 TypeAliasType 的一个实例。它们是简化代码阅读的便捷方式。让我们看一个例子:

# annotations/type.aliases.py
type DatabasePort = int
def connect_to_database(host: str, port: DatabasePort):
    print(f"Connecting to {host} on port {port}...") 

如上例所示,我们可以定义自己的类型别名。从静态类型检查器的角度来看,DatabasePortint 将被同等对待。

尽管从这样一个示例中可能不明显,但使用类型别名可以增强可读性并简化重构。想象一下,有几个函数期望 DatabasePort 类型:如果我们想重构代码库,使用字符串来表示端口号,我们只需更改定义 DatabasePort 的那一行。如果我们简单地使用 int,我们就需要重构所有函数定义。

特殊形式

特殊形式可以用作注释中的类型。它们都支持使用 [] 进行索引,但每个都有独特的语法。在这里,我们将看到 OptionalUnion;有关所有特殊形式的完整列表,请参阅官方文档docs.python.org/3/library/typing.html#special-forms

Optional

Python 允许在函数参数有默认值时使用可选参数。在这里,我们需要做出区分。让我们重新引入 greeter 函数,并给它添加一个默认值:

# annotations/optional.py
def greeter(name: str = "stranger") -> str:
    return f"Hello, {name}!" 

这次,我们在 greeter() 函数中添加了一个默认值。这意味着如果我们不带任何参数调用它,它将返回字符串 "Hello, stranger!"

这个定义假设当我们调用 greeter() 时,如果我们传递 name,它将是一个字符串。有时,这并不是我们想要的,我们需要在函数调用时提供一个 None 参数,如果没有提供。对于这种情况,typing 模块为我们提供了 Optional 特殊形式:

# annotations/optional.py
def greeter_optional(name: Optional[str] = None) -> str:
    if name is not None:
        return f"Hello, {name}!"
    return "No-one to greet!" 

greeter_optional() 函数中,我们不想在没有传递名字的情况下返回问候语。因为 None 不是一个字符串,我们将 name 标记为可选,并将其默认值设置为 None

Union

有时,一个参数可以有多种类型。例如,在连接数据库时,端口号可以指定为一个整数或一个字符串。在这些情况下,拥有 Union 特殊形式是有用的:

# annotations/union.py
from typing import Union
def connect_to_database(host: str, port: Union[int, str]):
    print(f"Connecting to {host} on port {port}...") 

在上述例子中,对于 port 参数,我们希望接受 intstrUnion 特殊形式允许我们做到这一点。

自 Python 3.10 以来,我们不需要导入 Union,而是可以使用管道(|)来指定联合类型。

# annotations/union.py
def connect_to_db(host: str, port: int | str):
    print(f"Connecting to {host} on port {port}...") 

这看起来更加简洁。

Union 或其管道等价物,顺便提一下,使我们能够避免必须导入 Optional,例如,Optional[str] 可以写成 Union[str, None],或者简单地写成 str | None。让我们通过一个例子来看看后者形式:

# annotations/optional.py
def another_greeter(name: str | None = None) -> str:
    if name is not None:
        return f"Hello, {name}!"
    return "No-one to greet!" 

你有没有注意到,上面定义的一些函数在参数上有类型注解,但在返回值上没有?这是为了向你展示注解是完全可选的。如果我们愿意,甚至可以只注解函数接受的某些参数。

泛型

让我们继续探讨使用类型提示可以实现的内容,通过探索泛型的概念。

假设我们想要编写一个 last() 函数,它接受任何类型的项的列表,并返回最后一个项(如果有),或者返回 None。我们知道列表中的所有项都是同一类型,但我们不知道它是什么类型。我们如何正确地注解这个函数呢?泛型的概念帮助我们做到这一点。语法只是比我们之前看到的稍微复杂一点,但并不复杂。让我们编写这个函数:

# annotations/generics.py
def last**[T]****list****[T]****T**(items: ) ->  | None:
    return items[-1] if items else None 

请特别注意上述代码中突出显示的部分。首先,我们需要在函数名称后添加一个 [T] 作为后缀。然后,我们可以指定 items 是一个对象列表,其类型为 T,无论它是什么类型。最后,我们还可以使用 T 来指定返回值,尽管在这种情况下,我们已经指定了返回类型为 TNone 的联合,以应对 items 为空的情况。

尽管函数签名使用了泛型语法,但你会这样调用它:last([1, 2, 3])

泛型的语法支持是 Python 3.12 的新特性。在此之前,为了达到相同的效果,人们会求助于使用 TypeVar 工厂,如下所示:

# annotations/generics.py
from typing import TypeVar
U = TypeVar("U")
def first(items: list[U]) -> U | None:
    return items[0] if items else None 

注意,在这种情况下,first() 并没有定义为 firstU

由于 Python 3.12 语法上的增强,泛型的使用现在变得更加简单。

变量注解

现在,让我们暂时从函数转向,讨论变量注解。我们可以快速展示一个不需要太多解释的例子:

# annotations/variables.py
x: int = 10
x: float = 3.14
x: bool = True
x: str = "Hello!"
x: bytes = b"Hello!"
# Python 3.9+
x: list[int] = [7, 14, 21]
x: set[int] = {1, 2, 3}
x: dict[str, float] = {"planck": 6.62607015e-34}
# Python 3.8 and earlier
from typing import List, Set, Dict
x: List[int] = [7, 14, 21]
x: Set[int] = {1, 2, 3}
x: Dict[str, float] = {"planck": 6.62607015e-34}
# Python 3.10+
x: list[int | str] = [0, 1, 1, 2, "fibonacci", "rules"]
# Python 3.9 and earlier
from typing import Union
x: list[Union[int, str]] = [0, 1, 1, 2, "fibonacci", "rules"] 

在上面的代码中,我们声明 x 是许多东西,仅作为一个例子。正如你所见,语法很简单:我们声明变量的名称,其类型(冒号之后),以及其值(等号之后)。我们还为你提供了一些如何在前几版 Python 中注解变量的例子。方便的是,在 Python 3.12 中,我们可以直接使用内置类型,而无需从 typing 模块导入很多内容。

容器注解

类型系统假定 Python 容器中的所有元素都将具有相同的类型。对于大多数容器来说这是真的。例如,考虑以下代码:

# annotations/containers.py
# The type checker assumes all elements of the list are integers
a: list[int] = [1, 2, 3]
# We cannot specify two types for the elements of the list
# it only accepts a single type argument
b: list[int, str] = [1, 2, 3, "four"]  # Wrong!
# The type checker will infer that all keys in `c` are strings
# and all values are integers or strings
c: dict[str, int | str] = {"one": 1, "two": "2"} 

如上面的代码所示,list期望一个类型参数。在这种情况下,一个联合,如c注释中的int | str,仍然算作一个类型。然而,类型检查器会对b提出抱怨。这反映了 Python 中的列表通常用于存储任意数量的同一类型的项目。

包含相同类型元素的容器被称为同构的

注意,尽管语法相似,dict期望其键和值都有类型。

注释元组

与大多数其他容器类型不同,元组通常包含固定数量的项目,每个位置都期望有特定的类型。包含不同类型的元组被称为异构的。正因为如此,元组在类型系统中被特殊处理。

有三种方式来注释元组类型:

  • 固定长度的元组,可以进一步分为:

    • 没有命名字段的元组

    • 带有命名字段的元组

  • 任意长度的元组

固定长度元组

让我们看看一个固定长度元组的例子,没有命名字段。

# annotations/tuples.fixed.py
# Tuple `a` is assigned to a tuple of length 1,
# with a single string element.
a: tuple[str] = ("hello",)
# Tuple `b` is assigned to a tuple of length 2,
# with an integer and a string element.
b: tuple[int, str] = (1, "one")
# Type checker error: the annotation indicates a tuple of
# length 1, but the tuple has 3 elements.
c: tuple[float] = (3.14, 1.0, -1.0)  # Wrong! 

在上面的代码中,ab都被正确注释了。然而,c是不正确的,因为注释表明这是一个长度为1的元组,但c的长度是3

带有命名字段的元组

当元组有多个或两个以上的字段,或者它们在代码库的多个地方使用时,使用typing.NamedTuple来注释它们可能很有用。这里有一个简单的例子:

# annotations/tuples.named.py
from typing import NamedTuple
class Person(NamedTuple):
    name: str
    age: int
fab = Person("Fab", 48)
print(fab)  # Person(name='Fab', age=48)
print(fab.name)  # Fab
print(fab.age)  # 48
print(fab[0])  # Fab
print(fab[1])  # 48 

如通过print()调用的结果所见,这相当于声明一个元组,正如我们在第二章内置数据类型中学到的:

# annotations/tuples.named.py
import collections
Person = collections.namedtuple("Person", ["name", "age"]) 

使用typing.NamedTuple不仅允许我们正确注释元组,如果我们愿意,甚至可以指定默认值:

# annotations/tuples.named.py
class Point(NamedTuple):
    x: int
    y: int
    z: int = 0
p = Point(1, 2)
print(p)  # Point(x=1, y=2, z=0) 

注意,在上面的代码中,当我们创建p时没有指定第三个参数,但z仍然正确地分配给了0

任意长度的元组

如果我们想要指定一个任意长度的元组,其中所有元素都是同一类型,我们可以使用特定的语法。这可能在某些情况下很有用,例如,当我们使用元组作为不可变序列时。让我们看看几个例子:

# annotations/tuples.any.length.py
from typing import Any
# We use the ellipsis to indicate that the tuple can have any
# number of elements.
a: tuple[int, ...] = (1, 2, 3)
# All the following are valid, because the tuple can have any
# number of elements.
a = ()
a = (7,)
a = (7, 8, 9, 10, 11)
# But this is an error, because the tuple can only have integers
a = ("hello", "world")
# We can specify a tuple that must be empty
b: tuple[()] = ()
# Finally, if we annotate a tuple like this:
c: tuple = (1, 2, 3)
# The type checker will treat it as if we had written:
c: tuple[Any, ...] = (1, 2, 3)
# And because of that, all the below are valid:
c = ()
c = ("hello", "my", "friend") 

如你所见,有无数种方式可以注释一个元组。你想要有多严格的选择取决于你。记住要与其他代码库保持一致。同时,要意识到注释为你的代码带来的价值。如果你正在编写一个打算发布并供其他开发者使用的库,那么在注释中非常精确可能是有意义的。另一方面,没有充分的理由就过于严格或限制可能会损害你的生产力,尤其是在这种精确度不是必需的情况下。

抽象基类(ABCs)

在 Python 的旧版本中,typing 模块会提供几种类型的泛型版本。例如,列表、元组、集合和字典可以使用泛型具体集合 ListTupleSetDict 等进行注释。

从 Python 3.9 开始,这些泛型集合已经被弃用,转而使用相应的内置类型,这意味着,例如,可以使用 list 本身来注释列表,而不需要 typing.List

文档还指出,这些泛型集合应该用于注释返回值,而参数应该使用抽象集合进行注释。例如,我们应该使用 collections.abc.Sequence 来注释只读和可变序列,如 listtuplestrbytes 等等。

这与 Postel 的法则 一致,也称为 鲁棒性原则 ,它假设:

“发送时要保守,接受时要宽容。”

接受时要宽容 意味着我们在注释参数时不应过于限制。如果一个函数接受一个名为 items 的参数,并且它所做的只是迭代或根据位置访问一个项目,那么 items 是列表还是元组无关紧要。因此,我们不应使用 tuplelist 进行注释,而应使用 collections.abc.Sequence 允许 items 以元组或列表的形式传递。

想象一下这样的场景:你使用 tuple 来注释 items 。过了一段时间,你重构了代码,现在 items 作为列表传递。由于 items 已不再是元组,函数的注释现在是不正确的。如果我们使用了 collections.abc.Sequence ,那么函数就不需要任何修改,因为 tuplelist 都是可行的。

让我们看看一个例子:

# annotations/collections.abcs.py
from collections.abc import Mapping, Sequence
def average_bad(v: list[float]) -> float:
    return sum(v) / len(v)
def average(v: Sequence[float]) -> float:
    return sum(v) / len(v)
def greet_user_bad(user: dict[str, str]) -> str:
    return f"Hello, {user['name']}!"
def greet_user(user: Mapping[str, str]) -> str:
    return f"Hello, {user['name']}!" 

上述函数应该有助于澄清问题。以 average_bad() 为例。如果我们传递 v 作为元组,它就会与我们所使用的函数注释不符,该注释是 list 。另一方面,average() 没有这个问题。当然,我们也可以用同样的推理来分析 greet_user_bad()greet_user()

回到 Postel 的法则,当涉及到返回值时,最好是保守的,这意味着要具体。返回值应该精确地表明函数返回了什么。

这非常重要,特别是对于调用者来说,他们需要知道在调用函数时将接收到的对象类型。

这里有一个简单的例子,来自同一文件,应该有助于理解:

# annotations/collections.abcs.py
def add_defaults_bad(
    data: Mapping[str, str]
) -> **Mapping**[str, str]:
    defaults = {"host": "localhost", "port": "5432"}
    return {**defaults, **data}
def add_defaults(data: Mapping[str, str]) -> **dict**[str, str]:
    defaults = {"host": "localhost", "port": "5432"}
    return {**defaults, **data} 

在上述两个函数中,我们只是将一些假设的连接默认值添加到 data 参数传递的任何内容上(如果 data 中缺少 "host""port" 键)。add_defaults_bad() 函数指定返回类型为 Mapping。问题是这太泛了。例如,dict 和来自 collections 模块的 defaultdictCounterOrderedDictChainMapUserDict 等对象都实现了 Mapping 接口。这使得调用者感到非常困惑。

另一方面,add_defaults() 是一个更好的函数,因为它精确地指定了返回类型:dict

常用的 ABC 包括 IterableIteratorCollectionSequenceMappingSetMutableSequenceMutableMappingMutableSetCallable

让我们看看一个使用 Iterable 的示例:

# annotations/collections.abc.iterable.py
from collections.abc import Iterable
def process_items(items: Iterable) -> None:
    for item in items:
        print(item) 

process_items() 函数需要做的只是遍历 items;因此,我们使用 Iterable 来注释它。

对于 Callable 可以提供一个更有趣的例子:

# annotations/collections.abc.iterable.py
from collections.abc import Callable
def process_callback(
    arg: str, callback: Callable[[str], str]
) -> str:
    return callback(arg)
def greeter(name: str) -> str:
    return f"Hello, {name}!"
def reverse(name: str) -> str:
    return name[::-1] 

这里,我们有 process_callback() 函数,它定义了一个字符串参数 arg 和一个 callback 可调用对象。接下来有两个函数,它们的签名指定了输入参数为字符串,返回值为字符串对象。注意 callback 的类型注解 Callable[[str], str],它表明 callback 参数应该接受一个字符串输入参数并返回一个字符串输出。当我们用以下代码调用这些函数时,我们得到的是内联注释中指示的输出。

# annotations/collections.abc.iterable.py
print(process_callback("Alice", greeter))  # Hello, Alice!
print(process_callback("Alice", reverse))  # ecilA 

这就结束了我们对抽象基类的巡礼。

特殊类型原语

typing 模块中,还有一个称为 特殊类型原语 的对象类别,它们非常有趣,了解其中最常见的一些是有用的。我们已经看到了一个例子:Any

其他值得注意的例子包括:

  • AnyStr:用于注释可以接受 strbytes 参数但不能混合两者的函数。这被称为 约束类型变量,意味着类型只能是给定的约束之一。在 AnyStr 的情况下,它要么是 str,要么是 bytes

  • LiteralString:一个只包含字面字符串的特殊类型。

  • Never / NoReturn:可以用来表示函数永远不会返回——例如,它可能会引发异常。

  • TypeAlias:已被 type 语句取代。

最后,Self 类型值得更多关注。

Self 类型

Self 类型是在 Python 3.11 中添加的,它是一个特殊类型,用于表示当前封装的类。让我们看一个例子:

# annotations/self.py
from typing import Self
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass
class Point:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0
    def magnitude(self) -> float:
        return (self.x**2 + self.y**2 + self.z**2) ** 0.5
    @classmethod
    def sum_points(cls, points: Iterable[**Self**]) -> **Self**:
        return cls(
            sum(p.x for p in points),
            sum(p.y for p in points),
            sum(p.z for p in points),
        ) 

在上面的代码中,我们创建了一个简单的类Point,它表示空间中的一个三维点。为了展示如何使用Self类型,我们创建了一个sum_points()类方法,它接受一个可迭代的points,并返回一个Point对象,其坐标是points中所有项对应坐标的总和。

要注解points参数,我们将Self传递给Iterable,并且对于方法的返回值也做同样的处理。在引入Self类型之前,我们不得不为每个需要它的类创建一个独特的“self”类型变量。你可以在官方文档中找到一个例子,见docs.python.org/3/library/typing.html#typing.Self

注意,按照惯例,selfcls参数都没有类型注解。

现在,让我们继续看看如何注解可变参数。

注解变量参数

要注解可变位置参数和关键字参数,我们使用迄今为止看到的相同语法。一个快速示例胜过任何解释:

# annotations/variable.parameters.py
def add_query_params(
    ***urls:** **str**, ****query_params:** **str**
) -> list[str]:
    params = "&".join(f"{k}={v}" for k, v in query_params.items())
    return [f"{url}?{params}" for url in urls]
urls = add_query_params(
    "https://example1.com",
    "https://example2.com",
    "https://example3.com",
    limit="10",
    offset="20",
    sort="desc",
)
print(urls)
# ['https://example1.com?limit=10&offset=20&sort=desc',
#  'https://example2.com?limit=10&offset=20&sort=desc',
#  'https://example3.com?limit=10&offset=20&sort=desc'] 

在上面的例子中,我们编写了一个虚拟函数add_query_params(),它向一组 URL 添加一些查询参数。注意,在函数定义中,我们只需要指定元组urls中包含的对象的类型,以及query_params字典中值的类型。声明*urls: str等同于tuple[str, …],而**query_params: str等同于dict[str, str]

协议

让我们通过讨论协议来结束我们对类型注解的探索。在面向对象编程中,协议定义了一组类必须实现的方法,而不强制从任何特定类继承。它们类似于其他语言中的接口概念,但更加灵活和非正式。它们通过允许不同类在遵循相同协议的情况下可以互换使用来促进多态的使用,即使它们没有共享一个共同的基类。

这个概念从 Python 的开始就一直是其一部分。这种类型的协议通常被称为动态协议,并在Python 语言参考的“数据模型”章节(docs.python.org/3/reference/datamodel.html)中进行了描述。

然而,在类型提示的上下文中,协议是typing.Protocol的子类,它定义了一个类型检查器可以验证的接口。

由 PEP 544(peps.python.org/pep-0544/)引入,它们实现了结构化子类型化(非正式地称为静态鸭子类型),我们在本章的开头简要探讨了这一点。

一个对象与协议的兼容性是由某些方法或属性的存在来确定的,而不是从特定类继承。

因此,在那些我们无法轻松定义类型的情况下,协议非常有用,并且更方便以“它应该支持某些方法或具有某些属性”的形式表达注释。

由 PEP 544 定义的协议通常被称为静态协议

动态和静态协议存在两个关键区别:

  • 动态协议允许部分实现。这意味着一个对象可以只为协议中的一部分方法提供实现,并且仍然是有用的。然而,静态协议要求对象提供协议中声明的所有方法,即使软件不需要它们全部。

  • 静态协议可以被静态类型检查器验证,而动态协议则不能。

你可以在这里找到typing模块提供的协议列表:docs.python.org/3/library/typing.html#protocols。它们的名称以前缀词Supports开头,后面跟着它们声明的支持方法的标题化版本。一些例子包括:

  • SupportsAbs:一个具有一个抽象方法__abs__的 ABC。

  • SupportsBytes:一个具有一个抽象方法__bytes__的 ABC。

  • SupportsComplex:一个具有一个抽象方法__complex__的 ABC。

其他曾经存在于typing模块但现在已迁移到collections.abc的协议包括IterableIteratorSizedContainerCollectionReversibleContextManager,仅举几个例子。

你可以在 Mypy 文档中找到完整的列表:mypy.readthedocs.io/en/stable/protocols.html#predefined-protocols-reference

既然我们已经对协议有了概念,在类型提示的背景下,让我们看看一个例子,展示如何创建一个简单的自定义协议:

# annotations/protocols.py
from typing import Iterable, Protocol
class SupportsStart(Protocol):
    def start(self) -> None: ...
class Worker:  # No SupportsStart base class.
    def __init__(self, name: str) -> None:
        self.name = name
    def start(self) -> None:
        print(f"Starting worker {self.name}")
def start_workers(workers: Iterable[SupportsStart]) -> None:
    for worker in workers:
        worker.start()
workers = [Worker("Alice"), Worker("Bob")]
start_workers(workers) 

在上面的代码中,我们定义了一个协议类SupportsStart,它有一个方法:start()。为了使其成为静态协议,SupportsStartProtocol继承。有趣的部分就在这里,当我们创建Worker类时。请注意,没有必要从SupportsStart类继承。Worker类只需要满足协议,这意味着它需要一个start()方法。

我们还编写了一个函数start_workers(),它接受一个参数workers,被注释为Iterable[SupportsStart]。这就是使用协议所需的所有内容。我们定义了几个工人,AliceBob,并将它们传递给函数调用。

运行上面的例子将产生以下输出:

Starting worker Alice
Starting worker Bob 

现在想象一下,我们还想停止一个工人。这是一个更有趣的情况,因为它允许我们讨论如何子类化协议。让我们看看一个例子:

# annotations/protocols.subclassing.py
from typing import Iterable, Protocol
class SupportsStart(Protocol):
    def start(self) -> None: ...
class SupportsStop(Protocol):
    def stop(self) -> None: ...
class SupportsWorkCycle(**SupportsStart, SupportsStop, Protocol**):
    pass
class Worker:
    def __init__(self, name: str) -> None:
        self.name = name
    def start(self) -> None:
        print(f"Starting worker {self.name}")
    def stop(self) -> None:
        print(f"Stopping worker {self.name}")
def start_workers(workers: Iterable[SupportsWorkCycle]) -> None:
    for worker in workers:
        worker.start()
        worker.stop()
workers = [Worker("Alice"), Worker("Bob")]
start_workers(workers) 

在上面的示例中,我们了解到我们可以像使用混入(mixins)一样组合协议。一个关键的区别是,当我们从协议类继承,例如在SupportsWorkCycle的情况下,我们仍然需要显式地将Protocol添加到基类列表中。如果我们不这样做,静态类型检查器会报错。这是因为从现有协议继承并不会自动将子类转换为协议。它只会创建一个实现给定协议的常规类或 ABC。

您可以在 Mypy 文档中找到有关协议的更多信息:mypy.readthedocs.io/en/stable/protocols.html

现在,让我们讨论 Mypy,这是 Python 社区最广泛采用的静态类型检查器。

Mypy 静态类型检查器

目前,Python 有几种静态类型检查器。目前最广泛采用的是:

  • Mypy:设计用于与 Python 的 PEP 484 定义的类型注解无缝工作,支持渐进式类型,与现有代码库集成良好,并拥有广泛的文档。您可以在mypy.readthedocs.io/找到它。

  • Pyright:由 Microsoft 开发,这是一个快速的类型检查器,针对 Visual Studio Code 进行了优化。它进行增量分析以实现快速类型检查,并支持 TypeScript 和 Python。您可以在github.com/microsoft/pyright找到它。

  • Pylint:一个综合的静态分析工具,包括类型检查、代码质量检查以及代码风格检查。它高度可配置,支持自定义插件,并生成详细的代码质量报告。您可以在pylint.org/找到它。

  • Pyre:由 Facebook 开发,它速度快,可扩展,并且与大型代码库配合良好。它支持渐进式类型,并拥有强大的类型推断引擎。它还很好地与持续集成系统集成。您可以在pyre-check.org/找到它。

  • Pytype:由 Google 开发,它自动推断类型并减少了对显式注解的需求,它可以生成这些注解。它与 Google 开源工具集成良好。您可以在github.com/google/pytype找到它。

对于本章的这一部分,我们决定使用 Mypy,因为它目前似乎是最受欢迎的。

要在虚拟环境中安装它,您可以运行以下命令:

$ pip install mypy 

Mypy 也被包含在本章的要求文件中。当 Mypy 安装后,您可以对任何文件或文件夹运行它。Mypy 将递归遍历任何文件夹以查找 Python 模块(*.py文件)。以下是一个示例:

$ mypy program.py some_folder another_folder 

命令提供了一组庞大的选项,我们鼓励您通过运行以下命令来探索:

$ mypy --help 

让我们从一个非常简单的没有注解的函数示例开始,看看运行mypy后的结果。

# mypy_src/simple_function.py
def hypothenuse(a, b):
    return (a**2 + b**2) ** 0.5 

在此模块上运行 mypy 得到以下结果:

$ mypy simple_function.py
Success: no issues found in 1 source file 

这可能不是您预期的结果,但 Mypy 被设计成支持逐步向现有代码库添加类型注解。为未注解的代码输出错误信息会阻止开发者以这种方式使用它。因此,默认行为是忽略没有注解的函数。如果我们想无论如何都让 Mypy 检查 hypothenuse() 函数,我们可以这样运行它(注意我们已重新格式化输出以适应书籍的宽度):

$ mypy --strict mypy_src/simple_function.py
mypy_src/simple_function.py:4:
    error: Function is missing a type annotation  [no-untyped-def]
Found 1 error in 1 file (checked 1 source file) 

现在 Mypy 告诉我们,该函数缺少类型注解,因此让我们修复它。

# mypy_src/simple_function_annotated.py
def hypothenuse(a: float, b: float) -> float:
    return (a**2 + b**2) ** 0.5 

我们可以再次运行 mypy

$ mypy simple_function_annotated.py 
Success: no issues found in 1 source file 

优秀——现在函数已添加注解,mypy 运行成功。让我们尝试一些函数调用:

print(hypothenuse(3, 4))  # This is fine
print(hypothenuse(3.5, 4.9))  # This is also fine
print(hypothenuse(complex(1, 2), 10))  # Type checker error 

前两个调用是好的,但最后一个产生了错误:

$ mypy mypy_src/simple_function_annotated.py
mypy_src/simple_function_annotated.py:10:
    error: Argument 1 to "hypothenuse" has incompatible
    type "complex"; expected "float"  [arg-type]
Found 1 error in 1 file (checked 1 source file) 

Mypy 通知我们,传递一个 complex 类型而不是所需的 float 类型是不可以的。这两种类型不兼容。

让我们尝试一个稍微复杂一点的例子(无意中打趣):

# mypy_src/case.py
from collections.abc import Iterable
def title(names: Iterable[str]) -> list[str]:
    return [name.title() for name in names]
print(title(["ALICE", "bob"]))  # ['Alice', 'Bob'] - mypy OK
print(title([b"ALICE", b"bob"]))  # [b'Alice', b'Bob'] - mypy ERR 

上述函数将 names 中的每个字符串应用为首字母大写格式。首先,我们用字符串 "ALICE""bob" 调用它一次,然后我们用 bytes 对象 b"ALICE"b"bob" 调用它。这两个调用都成功了,因为 strbytes 对象都有 title() 方法。然而,运行 mypy 得到以下结果:

$ mypy mypy_src/case.py
mypy_src/case.py:10:
    error: List item 0 has incompatible type "bytes";
    expected "str"  [list-item]
mypy_src/case.py:10:
    error: List item 1 has incompatible type "bytes";
    expected "str"  [list-item]
Found 2 errors in 1 file (checked 1 source file) 

再次强调,Mypy 指出两种类型的兼容性问题——这次是 strbytes。我们可以很容易地修复这个问题,通过修改第二个调用或更改函数上的类型注解。让我们做后者:

# mypy_src/case.fixed.py
from collections.abc import Iterable
def title(names: Iterable[**str** **|** **bytes**]) -> list[**str** **|** **bytes**]:
    return [name.title() for name in names]
print(title(["ALICE", "bob"]))  # ['Alice', 'Bob'] - mypy OK
print(title([b"ALICE", b"bob"]))  # [b'Alice', b'Bob'] - mypy OK 

现在,我们在注解中使用 strbytes 类型的联合,mypy 运行成功。

我们的建议是安装 Mypy 并将其运行在您可能拥有的任何现有代码库上。尝试逐步引入类型注解,并使用 Mypy 检查您代码的正确性。这项练习将帮助您熟悉类型提示,并且对您的代码也有益处。

一些有用的资源

我们建议您阅读本章开头列出的所有 PEP(Python Enhancement Proposals)。我们还建议您研究我们在过程中提到的各种资源,其中一些列在下面供您方便查阅:

  • Python 类型提示文档:

typing.readthedocs.io/en/latest/

  • 使用 Python 进行静态类型:

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

  • 抽象基类:

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

  • 容器的抽象基类:

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

  • Mypy 文档:

mypy.readthedocs.io/en/stable/

在 FastAPI 框架文档中还有一个快速的 Python 类型简介 部分,我们建议您阅读:fastapi.tiangolo.com/python-types/

FastAPI 是一个用于构建 API 的现代 Python 框架。第十四章API 开发简介,专门介绍它,所以我们建议在阅读该章节之前至少阅读关于类型的介绍。

摘要

在本章中,我们探讨了 Python 中的类型提示主题。我们首先理解了 Python 对类型的原生方法,并回顾了类型提示的历史,这些类型提示是从 Python 3 逐步引入的,并且仍在不断发展。

我们研究了类型提示的好处,然后学习了如何注释函数、类和变量。我们探讨了基础知识,并讨论了主要内置类型,同时也涉猎了更高级的主题,如泛型、抽象基类和协议。

最后,我们提供了一些如何使用最受欢迎的静态类型检查器 Mypy 的示例,以逐步引入代码库中的类型检查,并以一个简短的回顾结束本章,回顾了您进一步研究此主题最有用的资源。

这本书的理论部分到此结束。剩余的章节是项目导向的,采用更实际的方法,从数据科学的介绍开始。通过学习第一部分的内容所获得的知识应该足以支持您在阅读下一章时的学习。

加入我们的 Discord 社区

加入我们的 Discord 社区空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十三章:简明数据分析

“如果我们有数据,就让我们看看数据。如果我们只有意见,那就听我的。”

——吉姆·巴克斯代尔,前网景 CEO

数据分析是一个广泛的概念,其含义可能因上下文、理解、工具等因素而异。要做好数据分析,至少需要了解数学和统计学。然后,你可能还想深入研究其他主题,如模式识别和机器学习,当然,还有大量的语言和工具可供使用。

我们在这里无法讨论所有内容。因此,为了使这一章节有意义,我们将一起完成一个项目。

大约在 2012/2013 年,Fabrizio 在伦敦的一家顶级社交媒体公司工作。他在那里工作了两年,有幸与几位非常聪明的人共事。这家公司是世界上第一个能够访问 Twitter Ads API 的公司,他们也是 Facebook 的合作伙伴。这意味着有大量的数据。

他们的分析师正在处理大量的活动,他们正在努力应对他们必须完成的工作量,因此 Fabrizio 所在的开发团队试图通过向他们介绍 Python 和 Python 提供的数据处理工具来帮助他们。这是一段有趣的旅程,使他成为公司中几位人的导师,最终带他去马尼拉,在那里他为那里的分析师提供了一周两期的 Python 和数据分析强化培训课程。

本章我们将要做的项目是 Fabrizio 在马尼拉向他的学生展示的最终示例的一个轻量级版本。我们将其改写为适合本章的大小,并对教学目的进行了一些调整,但所有主要概念都在其中,所以这应该会很有趣,也很有教育意义。

具体来说,我们将探索以下内容:

  • Jupyter Notebook 和 JupyterLab

  • pandas 和 NumPy:Python 数据分析的主要库

  • 关于 pandas 的DataFrame类的一些概念

  • 创建和处理数据集

让我们先从罗马神祇说起。

IPython 和 Jupyter Notebook

2001 年,Fernando Perez 是科罗拉多大学博尔德分校的物理研究生,他试图改进 Python 壳,以便他能够在使用 Mathematica 和 Maple 等工具时享受到他习惯的便利。这些努力的成果被命名为IPython

那个小脚本最初是 Python 壳的一个增强版本,通过其他程序员的努力,以及几家不同公司的资助,它最终成为了今天这个成功的项目。在其诞生后的大约 10 年后,一个笔记本环境被创建,它由 WebSocket、Tornado 网络服务器、jQuery、CodeMirror 和 MathJax 等技术驱动。ZeroMQ 库也被用来处理笔记本界面和其背后的 Python 核心之间的消息。

IPython Notebook 因其流行和广泛使用,随着时间的推移,它被添加了众多功能。它可以处理小部件、并行计算、各种媒体格式等等。此外,在某个时刻,从 Notebook 内部使用除 Python 之外的语言进行编码也成为可能。

最终,项目被拆分为两个部分:IPython 被简化以更多地关注内核和外壳,而 Notebook 成为了一个新的项目,称为 Jupyter。Jupyter 允许在超过 40 种语言中进行交互式科学计算。最近,Jupyter 项目创建了 JupyterLab,这是一个基于网页的 IDE,它集成了 Jupyter 笔记本、交互式控制台、代码编辑器等等。

本章的项目都将使用 Jupyter Notebook 编写和运行,因此让我们简要解释一下什么是 Notebook。Notebook 环境是一个网页,它暴露了一个简单的菜单和单元格,你可以在这里运行 Python 代码。尽管单元格是独立的实体,你可以单独运行它们,但它们都共享同一个 Python 内核。这意味着你在其中一个单元格(变量、函数等)中定义的所有名称都将可在任何其他单元格中使用。

简而言之,Python 内核是 Python 运行的一个进程。Notebook 网页是提供给用户驱动这个内核的界面。网页通过快速消息系统与之通信。

除了所有图形优势之外,拥有这样一个环境的美妙之处在于能够分块运行 Python 脚本,这可以是一个巨大的优势。假设有一个脚本连接到数据库以获取数据,然后操作这些数据。如果你用传统的 Python 脚本方式来做,每次你想实验它时都必须重新获取数据。在 Notebook 环境中,你可以在一个单元格中获取数据,然后在其他单元格中操作和实验它,因此不需要每次都获取数据。

Notebook 环境对于数据科学也很有帮助,因为它允许逐步检查结果。你完成一块工作后,然后验证它。然后你做另一块工作并再次验证,依此类推。

它对于原型设计也非常有价值,因为结果就在你眼前,立即可用。

如果你想了解更多关于这些工具的信息,请访问 ipython.orgjupyter.org

我们创建了一个简单的 Notebook 示例,其中包含一个 fibonacci() 函数,它可以给出小于给定 N 的所有斐波那契数的列表。它看起来是这样的:

img

图 13.1:一个 Jupyter Notebook

每个单元都有一个方括号中的标签,如 [1]。如果方括号中没有内容,则表示该单元从未被执行。如果有数字,则表示该单元已被执行,数字代表单元执行的顺序。一个星号,如 *****,表示该单元目前正在执行。

您可以在截图看到,在第一个单元中我们定义了 fibonacci() 函数并执行了它。这会将 fibonacci 名称放置在笔记本关联的全局作用域中,因此 fibonacci() 函数现在也对其他单元可用。实际上,在第二个单元中,我们可以运行 list(fibonacci(100)) 并在下面的单元 [2](每个单元的输出都标记与单元相同的数字)中看到结果输出。在第三个单元中,我们向您展示了笔记本中可以找到的几个“魔法”函数之一:%timeit 函数多次运行代码并提供基准(这是使用我们在 第十一章,调试和性能分析 中简要介绍的 timeit 模块实现的)。

您可以按需多次执行一个单元,并改变它们的执行顺序。单元非常灵活:您还可以有原始单元,其中包含纯文本,或者 Markdown 单元,这对于添加格式化的文本说明或标题非常有用。

Markdown 是一种轻量级标记语言,具有纯文本格式化语法,旨在能够将其转换为 HTML 和其他多种格式。

另一个有用的功能是,无论您在单元的最后一行放置什么内容,它都会自动为您打印出来。这意味着您不必每次想要检查一个值时都明确写出 print(…)

使用 Anaconda

如同往常,您可以使用该章节源代码中的 requirements.txt 文件安装本章节所需的库。有时,安装数据科学库可能会相当痛苦。如果您在虚拟环境中安装本章节的库有困难,您可以安装 Anaconda。Anaconda 是 Python 和 R 编程语言的一个免费开源数据科学和机器学习相关应用的发行版,旨在简化包管理和部署。您可以从 anaconda.org 网站下载它。安装后,您可以使用 Anaconda 界面创建虚拟环境并安装 requirements.in 文件中列出的包,该文件也可以在章节源代码中找到。

开始使用笔记本

一旦安装了所有必需的库,您可以使用以下命令开始一个笔记本:

$ jupyter notebook 

如果你通过 Anaconda 安装需求,你也可以从 Anaconda 界面启动笔记本。在任何情况下,你都会在网页浏览器的此地址(端口号可能不同)打开一个页面:localhost:8888/

你也可以从 Anaconda 启动 JupyterLab,或者使用以下命令:

$ jupyter lab 

它也会在你的网页浏览器中作为一个新页面打开。

探索这两个界面。创建一个新的笔记本或打开我们上面展示的example.ipynb笔记本。看看你更喜欢哪个界面,并在继续本章的其余部分之前熟悉它。我们在本章的源代码中包含了包含本章其余部分使用的笔记本的保存 JupyterLab 工作空间(文件名为ch13.jupyterlab-workspace)。你可以使用它来在 JupyterLab 中跟随,或者如果你更喜欢,可以坚持使用经典笔记本界面。

或者,如果你使用现代 IDE 来跟随本章的示例,你很可能会安装一个插件,直接在 IDE 中工作与笔记本。

为了帮助你跟上进度,我们将在这个章节中为每个代码示例标记它所属的笔记本单元格编号。

如果你熟悉键盘快捷键(在经典笔记本的帮助菜单或 JupyterLab 的设置编辑器中查找),你将能够在单元格之间移动并处理它们的内容,而无需伸手去拿鼠标。这将使你在笔记本中工作更快。

现在,让我们继续前进,谈谈本章最有趣的部分:数据。

处理数据

通常,当你处理数据时,你会经过以下路径:你获取它;你清理和操作它;然后你分析它,并以值、电子表格、图表等形式展示结果。我们希望你能独立完成这个过程的所有三个步骤,而不需要依赖任何外部数据提供者,因此我们将执行以下操作:

  1. 创建数据,模拟它以不完美或未准备好工作的格式到来。

  2. 清理它,并将其提供给我们在项目中将使用的主要工具,即来自pandas库的DataFrame

  3. DataFrame中操作数据。

  4. DataFrame保存到不同格式的文件中。

  5. 分析数据并从中获取一些结果。

设置笔记本

首先,让我们生成数据。我们从ch13-dataprep笔记本开始。单元格#1负责导入:

#1
import json
import random
from datetime import date, timedelta
import faker 

我们还没有遇到的最主要的模块是randomfakerrandom是一个用于生成伪随机数的标准库模块。faker是一个用于生成假数据的第三方模块。它在测试中特别有用,当你准备你的固定数据时,可以获取各种东西,如姓名、电子邮件地址、电话号码和信用卡详情。

准备数据

我们希望达到以下数据结构:我们将有一个用户对象的列表。每个用户对象将链接到几个活动对象。在 Python 中,一切都是对象,所以我们以通用方式使用这个术语。用户对象可能是一个字符串、一个字典或其他东西。

在社交媒体世界中,活动是指媒体代理代表客户在社交媒体网络上运行的促销活动。请记住,我们将准备这些数据,以便它们不是完美的形状。首先,我们实例化我们将用于创建数据的Faker

#2
fake = faker.Faker() 

这将在内部跟踪已生成的值,并且只产生唯一的值。我们使用列表推导式生成 1,000 个唯一的用户名。

接下来,我们创建一个users列表。我们将生成 1,000 个包含诸如usernamenamegenderemail等详细信息的user字典。然后,每个user字典被转换为 JSON 格式并添加到列表中。这种数据结构当然不是最优的,但我们正在模拟用户以这种方式来到我们这里的场景。

#3
def get_users(no_of_users):
    usernames = (
        fake.unique.user_name() for i in range(usernames_no)
    )
    genders = random.choices(
        ["M", "F", "O"], weights=[0.43, 0.47, 0.1], k=no_of_users
    )
    for username, gender in zip(usernames, genders):
        name = get_random_name(gender)
        user = {
            "username": username,
            "name": name,
            "gender": gender,
            "email": fake.email(),
            "age": fake.random_int(min=18, max=90),
            "address": fake.address(),
        }
        yield json.dumps(user)
def get_random_name(gender):
    match gender:
        case "F":
            name = fake.name_female()
        case "M":
            name = fake.name_male()
        case _:
            name = fake.name_nonbinary()
    return name
users = get_users(1000)
users[:3] 

get_users()生成函数接受要创建的用户数量作为参数。我们使用生成表达式中的fake.unique.user_name()来生成唯一的用户名。fake.unique属性跟踪已生成的值,并且只产生唯一的值。接下来,我们调用random.choices()来从列表["M", "F", "O"](代表男性、女性或其他性别)中生成一个包含no_of_users个随机元素的列表。权重 0.43、0.47 和 0.1 将确保我们大约 43%的用户是男性,47%是女性,10%不认同为男性或女性。我们使用zip()遍历用户名和性别。对于每个用户,我们调用get_random_name(),它使用match语句生成适合性别的名字,然后生成一个假的电子邮件地址、年龄和地址。我们将用户数据转换为 JSON 字符串并yield它。

还请注意单元格中的最后一行。每个单元格都会自动打印最后一行上的内容;因此,#3 的输出是一个包含前三个用户的列表:

 ['{"username": "epennington", "name": "Stephanie Gonzalez", ...}',
 '{"username": "joshua61", "name": "Diana Richards", ...}',
 '{"username": "dmoore", "name": "Erin Rose", "gender": "F",...}'] 

我们希望您正在跟随自己的笔记本进行学习。如果您正在这样做,请注意,所有数据都是使用随机函数和值生成的;因此,您将看到不同的结果。每次您执行笔记本时,它们都会发生变化。另外请注意,我们不得不裁剪本章的大部分输出以适应页面,所以您在笔记本中看到的输出将比我们在这里复制的要多得多。

分析师们经常使用电子表格,他们创建各种编码技术,尽可能将尽可能多的信息压缩到活动名称中。我们选择这种格式是那种技术的简单示例——有一个代码告诉我们活动类型,然后是开始和结束日期,然后是目标年龄性别"M"代表男性,"F"代表女性,或"A"代表任何),最后是货币。

所有值都用下划线分隔。生成这些营销活动名称的代码可以在单元格 #4 中找到:

#4
# campaign name format:
# InternalType_StartDate_EndDate_TargetAge_TargetGender_Currency
def get_type():
    # just some meaningless example 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_range():
    age = random.randrange(20, 46, 5)
    diff = random.randrange(5, 26, 5)
    return "{}-{}".format(age, age + diff)
def get_gender():
    return random.choice(("M", "F", "A"))
def get_currency():
    return random.choice(("GBP", "EUR", "USD"))
def get_campaign_name():
    separator = "_"
    type_ = get_type()
    start, end = get_start_end_dates()
    age_range = get_age_range()
    gender = get_gender()
    currency = get_currency()
    return separator.join(
        (type_, start, end, age_range, gender, currency)
    ) 

get_type() 函数中,我们使用 random.choice() 从一个集合中随机获取一个值。get_start_end_dates() 函数稍微有趣一些。我们计算两个随机整数:活动的 duration(天数,介于一天和两年之间)和一个 offset(介于-365 和 365 天之间的天数)。我们从今天的日期减去 offset(作为一个 timedelta )以得到开始日期,并加上 duration 以得到结束日期。最后,我们返回两个日期的字符串表示。

get_age_range() 函数生成一个随机的目标年龄范围,其中两个端点都是五的倍数。我们使用 random.randrange() 函数,该函数返回一个由 startstopstep 参数定义的范围内的随机数(这些参数的含义与我们在 第三章,条件语句和迭代 中首次遇到的 range 对象相同)。我们生成随机数 age(20 到 46 岁之间的 5 的倍数)和 diff(5 到 26 岁之间的 5 的倍数)。我们将 diff 加到 age 上以得到年龄范围的上限,并返回年龄范围的字符串表示。

其余的函数只是对 random.choice() 的一些应用,最后一个函数 get_campaign_name() 将所有片段组合起来并返回最终的营销活动名称。

#5 中,我们编写了一个函数来创建一个完整的营销活动对象:

#5
# campaign data:
# name, budget, spent, clicks, impressions
def get_campaign_data():
    name = get_campaign_name()
    budget = random.randint(10**3, 10**6)
    spent = random.randint(10**2, budget)
    clicks = int(random.triangular(10**2, 10**5, 0.2 * 10**5))
    impressions = int(random.gauss(0.5 * 10**6, 2))
    return {
        "cmp_name": name,
        "cmp_bgt": budget,
        "cmp_spent": spent,
        "cmp_clicks": clicks,
        "cmp_impr": impressions,
    } 

我们使用了一些来自 random 模块的功能。random.randint() 给你两个极端之间的整数。它遵循均匀概率分布,这意味着区间内的任何数字出现的概率相同。为了避免我们的所有数据看起来都相似,我们选择使用 triangular()gauss(),用于 clicksimpressions。它们使用不同的概率分布,这样我们最终会看到更有趣的东西。

只为了确保我们对术语的理解一致:clicks 代表对营销活动广告的点击次数,budget 是分配给活动的总金额,spent 是已经花费的金额,而 impressions 是活动被展示的次数,无论在活动中执行了多少点击。通常,impressions 的数量大于 clicks 的数量,因为广告通常被查看而没有被点击。

现在我们有了数据,我们可以将其全部组合起来:

#6
def get_data(users):
    data = []
    for user in users:
        campaigns = [
            get_campaign_data()
            for _ in range(random.randint(2, 8))
        ]
        data.append({"user": user, "campaigns": campaigns})
    return data 

如您所见,data 中的每个条目都是一个包含 user 和与该 user 关联的营销活动列表的字典。

数据清理

接下来,我们可以开始清理数据:

#7
rough_data = get_data(users)
rough_data[:2]  # let us take a peek 

我们模拟从源中获取数据,然后检查它。笔记本是检查您步骤的完美工具。

您可以根据需要调整粒度。rough_data 中的第一个条目看起来像这样:

 {'user': '{"username": "epennington", "name": ...}',
  'campaigns': [{'cmp_name': 'KTR_20250404_20250916_35-50_A_EUR',
    'cmp_bgt': 964496,
    'cmp_spent': 29586,
    'cmp_clicks': 36632,
    'cmp_impr': 500001},
   {'cmp_name': 'AKX_20240130_20241017_20-25_M_GBP',
    'cmp_bgt': 344739,
    'cmp_spent': 166010,
    'cmp_clicks': 67325,
    'cmp_impr': 499999}]} 

现在,我们可以开始处理数据。为了能够使用这些 data,我们首先需要做的事情是将它去规范化。去规范化是一个将数据重新结构化到单个表中的过程。这涉及到合并来自多个表的数据或展开嵌套的数据结构。它通常会引入一些数据重复;然而,通过消除处理嵌套结构或跨多个表查找相关数据的需求,它简化了数据分析。在我们的案例中,这意味着将 data 转换为一个列表,其项是带有它们相对 user 字典的战役字典。用户将在他们关联的每个战役中重复:

#8
data = []
for datum in rough_data:
    for campaign in datum["campaigns"]:
        campaign.update({"user": datum["user"]})
        data.append(campaign)
data[:2]  # let us take another peek 

data 中的第一个项目现在看起来像这样:

 {'cmp_name': 'KTR_20250404_20250916_35-50_A_EUR',
 'cmp_bgt': 964496,
 'cmp_spent': 29586,
 'cmp_clicks': 36632,
 'cmp_impr': 500001,
 'user': '{"username": "epennington", ...}'}, 

现在,我们想要帮助你并提供本章的确定性第二部分,因此我们将保存这里生成的数据,以便我们(以及你)能够从下一个笔记本中加载它,然后我们应该得到相同的结果:

#9
with open("data.json", "w") as stream:
    stream.write(json.dumps(data)) 

你应该在书的源代码中找到 data.json 文件。现在,我们已经完成了 ch13-dataprep,所以我们可以关闭它并打开 ch13 笔记本。

创建 DataFrame

现在我们已经准备好了数据,我们可以开始分析它。首先,我们进行另一轮导入:

#1
import json
import arrow
import pandas as pd
from pandas import DataFrame 

我们已经在 第八章,文件和数据持久化 中看到了 json 模块。我们也在 第二章,内置数据类型 中简要介绍了 arrow。这是一个非常实用的第三方库,它使得处理日期和时间变得容易得多。pandas 是整个项目的基础。pandas 代表 Python 数据分析库。在许多其他功能中,它提供了 DataFrame,这是一个具有高级处理能力的类似矩阵的数据结构。通常我们会 import pandas as pd 并单独导入 DataFrame

导入之后,我们使用 pandas.read_json() 函数将我们的数据加载到一个 DataFrame 中:

#2
df = pd.read_json("data.json")
df.head() 

我们使用 DataFramehead() 方法检查前五行。你应该看到类似这样的内容:

img

图 13.2:DataFrame 的前几行

Jupyter 会自动将 df.head() 调用的输出渲染为 HTML。要获取纯文本表示,你可以在 df.head() 中包裹一个 print 调用。

DataFrame 结构允许你对它的内容执行各种操作。你可以按行或列进行筛选,聚合数据,等等。你可以对整个行或列进行操作,而无需支付如果你使用纯 Python 处理数据时必须支付的时间惩罚。这是可能的,因为底层 pandas 利用 NumPy 库的力量,而 NumPy 本身从其核心的低级实现中获得了惊人的速度。

NumPy 代表 Numeric Python。它是数据科学环境中最广泛使用的库之一。

使用 DataFrame 允许我们将 NumPy 的强大功能与类似电子表格的能力结合起来,这样我们就可以以类似于分析师通常所做的方式处理我们的数据,只是我们用代码来做。

让我们看看两种快速获取数据概览的方法:

#3
df.count() 

count() 方法返回每列中所有非空单元格的计数。这有助于您了解您的数据有多稀疏。在我们的例子中,我们没有缺失值,所以输出是:

cmp_name 5065
cmp_bgt 5065
cmp_spent 5065
cmp_clicks 5065
cmp_impr 5065
user 5065
dtype: int64

我们有 5,065 行。考虑到我们有 1,000 个用户,每个用户的活动数量是一个介于 2 和 8 之间的随机数,这与我们的预期相符。

输出末尾的 dtype: int64 行表示 df.count() 返回的值是 NumPy 的 int64 对象。在这里,dtype 代表“数据类型”,而 int64 表示 64 位整数。NumPy 主要用 C 语言实现,它不使用 Python 的内置数字类型,而是使用自己的类型,这些类型与 C 语言的数据类型密切相关。这使得它能够比纯 Python 更快地执行数值运算。

describe 方法有助于快速获取数据的统计摘要:

#4
df.describe() 

如下面的输出所示,它提供了几个度量,如 countmeanstd(标准差)、minmax,并显示了数据在各个四分位数中的分布情况。多亏了这种方法,我们已经有了一个关于数据结构的大致了解:

cmp_bgt cmp_spent cmp_clicks cmp_impr
count 5065.000000 5065.000000 5065.000000 5065.000000
mean 502965.054097 253389.854689 40265.781639 499999.474630
std 290468.998656 222774.897138 21840.783154 2.023801
min 1764.000000 107.000000 899.000000 499992.000000
25% 251171.000000 67071.000000 22575.000000 499998.000000
50% 500694.000000 187743.000000 36746.000000 499999.000000
75% 756850.000000 391790.000000 55817.000000 500001.000000
max 999565.000000 984705.000000 98379.000000 500007.000000

我们可以使用 sort_values()head() 方法查看预算最高的活动:

#5
df.sort_values(by=["cmp_bgt"], ascending=False).head(3) 

这将给出以下输出(我们省略了一些列以适应页面输出):

cmp_name cmp_bgt cmp_clicks cmp_impr
3186 GRZ_20230914_20230929_40-60_A_EUR 999565 63869 499998
3168 KTR_20250315_20260507_25-40_M_USD 999487 21097 500000
3624 GRZ_20250227_20250617_30-45_F_USD 999482 3435 499998

使用 tail() 而不是 head() 可以显示预算最低的活动:

#6
df.sort_values(by=["cmp_bgt"], ascending=False).tail(3) 

接下来,我们将承担一些更复杂的任务。

解包活动名称

首先,我们想要去除活动名称列(cmp_name)。我们需要将其分解成部分,并将每个部分放入其专用的列中。我们将使用Series对象的apply()方法来完成此操作。

pandas.core.series.Series类是一个围绕数组的强大包装器(将其视为具有增强功能的列表)。我们可以通过以字典中键的方式访问它来从DataFrame中提取Series对象。然后我们将使用Series对象的apply()方法对Series中的每个项目调用一个函数,并获取一个包含结果的新的Series。最后,我们将结果组合成一个新的DataFrame,然后将其与df连接。

我们首先定义一个函数,将活动名称拆分为包含类型、开始和结束日期、目标年龄、目标性别和货币的元组。请注意,我们使用arrow.get()将开始和结束日期字符串转换为date对象。

#7
def unpack_campaign_name(name):
    # very optimistic method, assumes data in campaign name
    # is always in good state
    type_, start, end, age, gender, currency = name.split("_")
    start = arrow.get(start, "YYYYMMDD").date()
    end = arrow.get(end, "YYYYMMDD").date()
    return type_, start, end, age, gender, currency 

接下来,我们从df中提取带有活动名称的Series,并将unpack_campaign_name()函数应用于每个名称。

#8
campaign_data = df["cmp_name"].apply(unpack_campaign_name) 

现在,我们可以从campaign_data构建一个新的DataFrame

#9
campaign_cols = [
    "Type",
    "Start",
    "End",
    "Target Age",
    "Target Gender",
    "Currency",
]
campaign_df = DataFrame.from_records(
    campaign_data, columns=campaign_cols, index=df.index
)
campaign_df.head(3) 

快速查看前几行显示:

类型 开始 结束 目标年龄 目标性别 货币
0 KTR 2025-04-04 2025-09-16 35-50 A EUR
1 AKX 2024-01-30 2024-10-17 20-25 M GBP
2 BYU 2023-08-28 2025-01-15 25-45 M GBP

看起来更好。现在,我们可以更容易地处理由列名称表示的数据。要记住的一个重要事项:即使日期以字符串的形式打印出来,它们也只是存储在DataFrame中的真实date对象的表示。

最后,我们可以将原始DataFramedf)和campaign_df连接成一个单一的DataFrame。在连接两个DataFrame实例时,它们必须具有相同的index,否则pandas无法匹配行。我们通过在创建campaign_df时显式使用df的索引来处理这个问题。

#10
df = df.join(campaign_df) 

让我们检查数据以验证一切是否正确匹配:

#11
df[["cmp_name"] + campaign_cols].head(3) 

输出的前几列如下:

cmp_name 类型 开始 结束
0 KTR_20250404_20250916_35-50_A_EUR KTR 2025-04-04 2025-09-16
1 AKX_20240130_20241017_20-25_M_GBP AKX 2024-01-30 2024-10-17
2 BYU_20230828_20250115_25-45_M_GBP BYU 2023-08-28 2025-01-15

如您所见,join()操作成功;活动名称和分离的列代表相同的数据。

注意我们如何使用方括号语法访问DataFrame,传递一个列名列表。这将产生一个新的DataFrame,其中包含这些列(按相同顺序),然后我们调用head()方法。

解包用户数据

现在,我们对每一份 user JSON 数据做同样的事情。我们在 user 系列上调用 apply(),运行 unpack_user_json() 函数,该函数接收一个 JSON user 对象并将其转换为字段列表。我们使用这些数据创建一个新的 DataFrame,名为 user_df

#12
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.from_records(
    user_data, columns=user_cols, index=df.index
) 

接下来,我们将 user_dfdf(就像我们之前对 campaign_df 做的那样)连接起来,并检查结果:

#13
df = df.join(user_df)
df[["user"] + user_cols].head(2) 

输出显示一切正常。

重命名列

如果你在一个单元格中评估 df.columns,你会看到我们列的名称仍然很丑陋。让我们来改变一下:

#14
new_column_names = {
    "cmp_bgt": "Budget",
    "cmp_spent": "Spent",
    "cmp_clicks": "Clicks",
    "cmp_impr": "Impressions",
}
df.rename(columns=new_column_names, inplace=True) 

rename() 方法可以用来更改列(或行)标签。我们给它提供了一个映射旧列名到我们首选名称的字典。任何未在字典中提到的列将保持不变。

计算一些指标

我们下一步将添加一些额外的列。对于每个活动,我们都有点击数和展示数,以及花费的金额。这使我们能够引入三个测量比率:CTRCPCCPI。它们分别代表 点击通过率每点击成本每展示成本

最后两个很简单,但 CTR 不一样。简单来说,它是点击和展示的比率。它衡量了每展示一次活动广告时点击的次数——这个数字越高,广告在吸引用户点击方面就越成功。让我们编写一个函数来计算所有三个比率并将它们添加到 DataFrame 中:

#15
def calculate_metrics(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_metrics(df) 

注意,我们通过一行代码添加了这三个列,但 DataFrame 会自动(在这种情况下是除法)对适当列中的每一对单元格执行操作。所以,尽管看起来我们只做了三次除法,但实际上有 5,140 * 3 次除法,因为它们是针对每一行执行的。pandas 在为我们做大量工作的同时,隐藏了其中的许多复杂性。

calculate_metrics() 函数接收一个 DataFramedf)并在其上直接操作。这种操作模式被称为原地。这类似于 list.sort() 方法对列表进行排序的方式。你也可以说这个函数不是纯函数,这意味着它有副作用,因为它修改了作为参数传递的可变对象。

我们可以通过筛选相关列并调用 head() 来查看结果:

#16
df[["Spent", "Clicks", "Impressions", "CTR", "CPC", "CPI"]].head(
    3
) 

这表明计算已经在每一行上正确执行:

花费 点击 展示 CTR CPC CPI
0 29586 36632 500001 0.073264 0.807655 0.059172
1 166010 67325 499999 0.134650 2.465800 0.332021
2 125738 29989 499997 0.059978 4.192804 0.251478

我们还可以手动验证第一行的结果准确性:

#17
clicks = df["Clicks"][0]
impressions = df["Impressions"][0]
spent = df["Spent"][0]
CTR = df["CTR"][0]
CPC = df["CPC"][0]
CPI = df["CPI"][0]
print("CTR:", CTR, clicks / impressions)
print("CPC:", CPC, spent / clicks)
print("CPI:", CPI, spent / impressions) 

这产生了以下输出:

CTR: 0.07326385347229306 0.07326385347229306
CPC: 0.8076545097182791 0.8076545097182791
CPI: 0.059171881656236686 0.059171881656236686 

值匹配,确认我们的计算是正确的。当然,我们通常不需要这样做,但我们想向你展示如何执行此类计算。你可以通过将名称传递给方括号中的DataFrame来访问一个Series(一个列)。然后你可以通过其位置访问列中的每一行,就像你使用常规列表或元组一样。

我们几乎完成了我们的DataFrame。我们现在缺少的只是一个告诉我们活动持续时间的列,以及一个告诉我们每个活动开始是星期几的列。持续时间很重要,因为它允许我们将诸如花费金额或展示次数等数据与活动的持续时间联系起来(我们可能预计持续时间较长的活动花费更多,并且有更多的展示次数)。星期几也可能很有用;例如,一些活动可能与特定星期的某些事件相关联(例如在周末举行的体育赛事)。

#18
def get_day_of_the_week(day):
    return day.strftime("%A")
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="columns") 

get_day_of_the_week()接受一个date对象并将其格式化为只包含相应星期名称的字符串。get_duration()更有趣。首先,注意它接受整个行,而不仅仅是单个值。这个函数从活动的开始日期减去结束日期。当你减去date对象时,结果是timedelta对象,它表示给定的时间量。我们取其.days属性的值来获取以天为单位的时间长度。

我们通过将get_day_of_the_week()应用于Start列(作为一个Series对象)来计算每个活动的开始周,这与我们对"user""cmp_name"所做的是类似的。接下来,我们将get_duration()应用于整个DataFrame。请注意,我们通过传递axis="columns"来指示pandas在行上操作。这看起来可能有些反直觉,但把它想象成将所有列传递给get_duration()的每一次调用。

我们可以像下面这样验证结果:

#19
df[["Start", "End", "Duration", "Day of Week"]].head(3) 

这给出了以下输出:

开始 结束 持续时间 星期
0 2025-04-04 2025-09-16 165 星期五
1 2024-01-30 2024-10-17 261 星期二
2 2023-08-28 2025-01-15 506 星期一

因此,我们现在知道从 2025 年 4 月 4 日到 2025 年 9 月 16 日之间有 165 天,而 2025 年 1 月 15 日是星期一。

清理一切

既然我们已经拥有了我们想要的一切,现在是时候进行最后的清洁工作了;记住我们仍然有"cmp_name""user"列。这些不再需要,所以我们将移除它们。我们还想重新排列DataFrame中的列,以便它们与现在包含的数据更加相关。我们可以通过在想要的列上过滤df来实现这一点。结果是新的DataFrame,我们可以将其重新分配给名称df

#19
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:

#20
df.to_csv("df.csv") 

然后,JSON:

#21
df.to_json("df.json") 

最后,在一个 Excel 电子表格中:

#22
df.to_excel("df.xlsx") 

to_excel()方法需要安装openpyxl包。它包含在本章的requirements.txt文件中,所以如果你使用它来安装需求,你应该在你的虚拟环境中拥有它。

如你所见,保存DataFrame为多种格式很容易。好消息是,反过来也是如此:将电子表格加载到DataFrame中(只需使用pandas read_csv()read_excel()函数)同样容易。

可视化结果

在本节中,我们将可视化一些结果。从数据科学的角度来看,我们不会尝试对数据进行深入分析,或试图从中得出任何结论。这样做没有太多意义,因为数据是完全随机的。然而,这个例子应该足以让你开始使用图表和其他功能。

我们通过艰难的方式学到的教训之一是,外观会影响人们对你的工作的看法。如果你想得到重视,仔细考虑你如何展示你的数据,并尝试让你的图表和表格看起来吸引人。

pandas使用 Matplotlib 绘图库来绘制图形。我们不会直接使用它,除非配置绘图样式并将图形保存到磁盘。你可以在matplotlib.org/了解更多关于这个多功能绘图库的信息。

首先,我们将配置 Notebook,以便将 Matplotlib 图表作为交互式小部件在单元格输出框架中渲染。我们通过以下方式完成:

#23
%matplotlib widget 

这将允许你平移和缩放图形,并将一个(低分辨率)快照保存到磁盘。

默认情况下(没有%matplotlib widget),图形将以静态图像的形式在单元格输出框架中渲染。使用交互式小部件模式需要安装ipympl包。它包含在本章的requirements.txt文件中,所以如果你使用它来安装需求,你应该在你的虚拟环境中拥有它。

然后,我们为我们的绘图设置一些样式:

#24
import matplotlib.pyplot as plt
plt.style.use(["classic", "ggplot"])
plt.rc("font", family="serif"})
plt.rc("savefig", dpi=300) 

我们使用 matplotlib.pyplot 接口设置绘图样式。我们选择使用 classicggplot 样式表的组合。样式表是从左到右应用的,因此在这里 ggplot 将覆盖在两者中定义的任何样式项。我们还设置了绘图中所使用的字体家族为 serif。调用 plt.rc("savefig", dpi=300) 配置了 savefig() 方法以生成适合打印的高分辨率图像文件。

在我们生成任何图表之前,让我们再次运行 df.describe()#26)。结果应该如下所示:

img

图 13.3:我们清理后的数据的某些统计信息

这种快速的结果非常适合满足那些只有 20 秒时间可以用来关注你并且只想得到粗略数字的经理们。

再次提醒,请注意我们的活动有不同的货币,所以这些数字没有意义。这里的目的是展示 DataFrame 的功能,而不是对真实数据进行正确或详细的分析。

或者,通常来说,图表比数字表格要好得多,因为它更容易阅读,并且能立即给出反馈。所以,让我们绘制我们关于每个活动的四条信息:“预算”、"花费"、“点击量”和“展示次数”:

#27
df[["Budget", "Spent", "Clicks", "Impressions"]].hist(
    bins=16, figsize=(16, 6)
)
plt.savefig("Figure13.4.png") 

我们提取这四个列(这将给我们另一个只包含这些列的 DataFrame)并调用 hist() 方法来获取直方图。我们给出一些参数来指定箱数和图形大小,其余的自动完成。结果如下所示:

img

图 13.4:活动数据的直方图

我们还调用 plt.savefig("Figure13.4.png") 将图像保存到名为 Figure13.4.png 的文件中。这将使用我们之前配置的 300dpi 设置来生成高分辨率图像。请注意,plt.savefig() 将保存 Matplotlib 生成的最新图像。从生成图表的同一单元格中调用它确保我们保存了正确的图像到正确的文件名。

尽管尝试解释随机数据的图表没有意义,但我们至少可以验证我们看到的结果与我们根据数据生成方式可能预期的结果相匹配。

  • 预算 是从区间中随机选择的,所以我们期望一个均匀分布。从图表上看,这确实是我们所看到的。

  • 花费 也是均匀分布的,但其上限是预算,这不是恒定的。这意味着我们应该期望一个从左到右递减的对数曲线。再次,这与图表显示的一致。

  • 点击量 是通过具有区间大小平均 20%的三角分布生成的,你可以看到峰值就在那里,大约在左侧 20%的位置。

  • Impressions呈高斯分布,这假设了著名的钟形形状。平均值正好在中间,我们有一个标准差为 2。您可以看到图表与这些参数相匹配。

让我们也绘制我们计算出的指标:

#28
df[["CTR", "CPC", "CPI"]].hist(bins=20, figsize=(16, 6))
plt.savefig("Figure13.5.png") 

这是图表表示:

img

图 13.5:计算出的度量值的直方图

我们可以看到,CPC严重向左倾斜,这意味着大多数CPC值都很低。CPI具有相似的形状,但不太极端。

现在,假设您只想分析数据的一个特定部分。我们可以对DataFrame应用一个掩码,以便我们得到一个新的DataFrame,其中只包含满足掩码条件的行。这就像应用一个全局的、按行操作的if子句:

#29
selector = df.Spent > df.Budget * 0.75
df[selector][["Budget", "Spent", "Clicks", "Impressions"]].hist(
    bins=15, figsize=(16, 6), color="green"
)
plt.savefig("Figure13.6.png") 

在这个例子中,我们准备了selector来筛选出所有花费金额小于或等于预算 75%的行。换句话说,我们只包括那些至少花费了预算四分之三的活动。请注意,在selector中,我们向您展示了一种获取DataFrame列的替代方法,即通过直接属性访问(object.property_name),而不是字典式访问(object['property_name'])。如果property_name是一个有效的 Python 名称,您可以使用这两种方式互换。

selector的应用方式与访问字典时使用键的方式相同。当我们对df应用selector时,我们得到一个新的DataFrame。我们只从其中选择相关的列,并再次调用hist()。这次,我们将color设置为green,只是为了展示如何做到这一点。

img

图 13.6:至少花费了 75%预算的活动的直方图

注意,除了Spent图表之外,图表的形状变化不大,Spent图表相当不同。这是因为我们只选择了花费金额至少为预算 75%的行。这意味着我们只包括花费金额接近预算的行。预算数字来自均匀分布。因此,Spent图表现在呈现出这种形状。如果您将边界设置得更紧,要求 85%或更多,您将看到Spent图表越来越接近Budget

让我们也看看不同类型的图表。我们将绘制每天的花费"Spent""Clicks""Impressions"的总和。

#30
df_weekday = df.groupby(["Day of Week"]).sum(numeric_only=True)
df_weekday[["Impressions", "Spent", "Clicks"]].plot(
    figsize=(16, 6), subplots=True
)
plt.savefig("Figure13.7.png") 

第一行创建了一个新的DataFrame,名为df_weekday,通过在df上请求按"Day of Week"进行分组。然后我们通过在每个组内计算总和来进行聚合。请注意,我们必须传递numeric_only=True以避免在尝试对包含非数值数据的列求和时出错。我们也可以通过在分组和求和之前只选择我们需要的列("Day of Week""Impressions""Spent""Clicks")来采取不同的方法。

注意,这次我们调用 plot() 而不是 hist()。这将绘制一个折线图而不是直方图。subplots=True 选项使 plot 绘制三个单独的图表:

img

图 13.7:按星期几汇总的活动数据图

这些图表显示,周四开始的活动获得了最多的点击和印象,而周六开始的活动花费的钱最少。如果这是真实数据,这可能会是我们的客户需要的重要信息。

注意,这些天是按字母顺序排序的,这使得图表难以阅读。我们将把这个作为练习留给你,找到一种方法来解决这个问题。

让我们在结束这个演示部分之前再补充几点。首先,一个简单的聚合。我们希望按 "Target Gender""Target Age" 进行分组,并计算每个分组中 "Impressions""Spent" 的平均值和标准差 ("std")

#31
agg_config = {
    "Impressions": ["mean", "std"],
    "Spent": ["mean", "std"],
}
df.groupby(["Target Gender", "Target Age"]).agg(agg_config) 

我们准备了一个字典作为配置使用。然后,我们在 "Target Gender""Target Age" 列上执行分组,并将我们的配置字典传递给 agg() 方法。输出看起来像这样:

Impressions Spent
mean std
Target Gender Target Age
A 20-25 499999.245614 2.189918
20-30 499999.465517 2.210148 252261.637931
20-35 499998.564103 1.774006 218726.410256
20-40 499999.459016 1.971241 255598.213115
20-45 499999.574074 2.245346 216527.666667
... ... ... ...
M 45-50 499999.480769 2.128153
45-55 499999.306122 2.053494 267137.938776
45-60 499999.500000 1.984063 236623.312500
45-65 499999.679245 1.503503 215634.528302
45-70 499998.870370 1.822773 310267.944444

在我们结束这一章之前,让我们再做一些事情。我们想向你展示一个叫做 交叉表 的东西。交叉表是一种分组数据、为每个组计算聚合值并在表格形式中显示结果的方法。交叉表是数据分析的一个基本工具,所以让我们看看一个简单的例子:

#31
df.pivot_table(
    values=["Impressions", "Clicks", "Spent"],
    index=["Target Age"],
    columns=["Target Gender"],
    aggfunc="sum"
) 

我们创建了一个交叉表,显示了 "Target Age""Impressions"、"Clicks" 和 "Spent" 之间的相关性。最后这三个将根据 " Target Gender" 进行细分。aggfunc 参数指定要使用的聚合函数,它可以是函数对象、函数名、函数列表或映射列名到函数的字典。在这种情况下,我们使用 "sum"(默认值,如果没有指定函数,则计算平均值)。结果是包含交叉表的新 DataFrame

img

图 13.8:交叉表

这就结束了我们的数据分析项目。我们将让你去探索关于 IPython、Jupyter 和数据科学的奇妙世界。我们强烈建议你熟悉 Notebook 环境。它比控制台更好,实用且使用起来有趣,你甚至可以用它来创建幻灯片和文档。

我们接下来该去哪里?

数据科学确实是一个迷人的主题。正如我们在引言中所说,那些想要深入其迷宫的人需要具备扎实的数学和统计学基础。处理被错误插值的数据会使任何关于它的结果都变得无用。同样,对于被错误外推或以错误频率采样的数据也是如此。为了给你一个例子,想象一个排列成队列的个人群体。如果出于某种原因,这个群体的性别在男性和女性之间交替,队列看起来可能就像这样:F-M-F-M-F-M-F-M-F...

如果你只采样偶数元素,你会得出结论说这个群体只由男性组成,而采样奇数元素则会告诉你正好相反。

当然,这只是一个愚蠢的例子,但在这个领域很容易出错,尤其是在处理需要采样的大数据集时,因此,你分析的质量首先取决于采样的质量。

当谈到数据科学和 Python 时,这些是你想要查看的主要工具:

  • NumPy (www.numpy.org/): 这是使用 Python 进行科学计算的主要包。它包含一个强大的 N 维数组对象,复杂的(广播)函数,用于集成 C/C++和 Fortran 代码的工具,有用的线性代数,傅里叶变换,随机数功能,以及更多。

  • scikit-learn (scikit-learn.org/): 这是 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、Jupyter Notebook、Web 应用服务器以及多个图形用户界面工具包。

  • Seaborn (seaborn.pydata.org/): 这是一个基于 Matplotlib 的 Python 数据可视化库。它提供了一个高级接口,用于绘制吸引人且信息丰富的统计图形。

  • Numba (numba.pydata.org/): 这让你能够使用直接在 Python 中编写的性能函数来加速你的应用程序。通过一些注释,数组导向和数学密集型的 Python 代码可以被即时编译成本地机器指令,其性能与 C、C++和 Fortran 相似,而无需切换语言或 Python 解释器。

  • Bokeh (bokeh.pydata.org/): 这是一个针对现代 Web 浏览器的 Python 交互式可视化库,用于展示。它的目标是提供类似于 D3.js 风格的优雅、简洁的图形构建,同时也能在大型或流式数据集上提供高性能的交互性。

除了这些单个库之外,你还可以找到生态系统,例如SciPy (scipy.org/)和前面提到的Anaconda (anaconda.org/),它们捆绑了多个不同的包,以便你能够以“开箱即用”的方式获得所需的功能。

在某些系统上安装所有这些工具及其依赖项可能很困难,因此我们建议你也尝试生态系统,看看你是否对他们感到舒适。这可能值得尝试。

摘要

在本章中,我们讨论了数据科学。我们并没有试图解释这个广泛主题的任何内容,而是深入一个项目。我们熟悉了 Jupyter Notebook,以及不同的库,如pandasMatplotlibNumPy

当然,必须将所有这些信息压缩到一章中意味着我们只能简要地涉及我们提出的主题。我们希望我们共同完成的项目足够全面,能够给你一个在这个领域工作时遵循的工作流程的思路。

下一章将专门介绍 API 开发。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十四章:API 开发简介

“有同情心的沟通的唯一目标是帮助他人减少痛苦。”

– 释达·南欣

在本章中,我们将学习 应用程序编程接口API)的概念。

我们将讨论以下内容:

  • HTTP 协议

  • API 设计简介

  • 一个完整的 API 示例

我们将简要介绍 HTTP 协议,因为它是我们将构建 API 的基础设施。此外,由于我们将使用的框架 FastAPI 广泛使用类型提示,您可能需要确保您已经阅读了 第十二章,类型提示简介,作为先决条件。

在对 API 进行简短的一般介绍之后,我们将向您展示一个铁路项目,您可以在本章的源代码中找到其完整状态,包括其需求和一份 README 文件,该文件解释了您运行 API 和查询 API 所需要了解的所有内容。

FastAPI 已被证明是这个项目的最佳选择。得益于其功能,我们能够创建一个具有清晰、简洁和表达性的 API。我们相信它是一个很好的起点,让您探索和扩展。

作为一名开发者,在您的职业生涯中某个时刻,您很可能需要处理 API。技术和框架不断演变,因此我们建议您也关注我们将要介绍的理论概念,因为这种知识将帮助您减少对任何特定框架或库的依赖。

让我们从 HTTP 开始。

超文本传输协议

万维网WWW)或简单地称为 Web,是一种使用 互联网 访问信息的方式。互联网是一个庞大的网络网络,是一个网络基础设施。其目的是连接全球数十亿设备,使它们能够相互通信。信息通过互联网以丰富的语言(称为 协议)传输,这些协议允许不同的设备共享内容。

互联网是一个信息共享模型,建立在互联网之上,它使用 超文本传输协议HTTP)作为数据通信的基础。因此,万维网只是互联网上信息交换的几种方式之一;电子邮件、即时消息、新闻组等等,都依赖于不同的协议。

HTTP 是如何工作的?

HTTP 是一种非对称的 请求-响应 客户端-服务器 协议。一个 HTTP 客户端——例如,您的网页浏览器——向 HTTP 服务器发送一个请求消息。服务器反过来返回一个响应消息。HTTP 主要是一种 基于拉取的协议,这意味着客户端从服务器拉取信息,而不是服务器推送给客户端。有一些在 HTTP 上实现的技巧可以模拟基于推送的行为,例如长轮询、WebSocket 和 HTTP/2 服务器推送。尽管如此,HTTP 的基础仍然是一个基于拉取的协议,其中客户端是发起请求的一方。请看图 14.1

img

图 14.1:HTTP 协议的简化表示

HTTP 通过 传输控制协议/互联网协议TCP/IP)传输,它为在互联网上进行可靠的通信交换提供了工具。

HTTP 协议的一个重要特性是它是 无状态的。这意味着当前请求不知道之前请求发生了什么。这种技术限制存在合理的理由,但很容易克服。在实践中,大多数网站都提供了“登录”功能,以及从一页到另一页携带状态的错觉。当用户登录到网站时,会保存一个用户信息的令牌(通常在客户端,在称为 cookies 的特殊文件中),这样用户发出的每个请求都携带了服务器识别用户并提供定制界面的手段,例如显示他们的名字,保持购物车内容,等等。

HTTP 定义了一组方法——也称为 动词 ——来指示对给定资源要执行的操作。每个方法都不同,但其中一些方法有一些共同的特征。特别是,我们将在我们的 API 中使用以下方法:

  • GETGET 方法请求指定资源的表示。使用 GET 的请求应仅用于检索数据。

  • POSTPOST 方法用于向指定的资源提交一个实体,通常会导致服务器状态的变化或副作用。

  • PUTPUT 方法请求目标资源使用请求中封装的表示创建或更新其状态。

  • DELETEDELETE 方法请求目标资源删除其状态。

其他方法有 HEADCONNECTOPTIONSTRACEPATCH。关于所有这些方法的全面解释,请参阅 developer.mozilla.org/en-US/docs/Web/HTTP

我们将要编写的 API 将通过 HTTP 工作,这意味着我们将编写代码来执行和处理 HTTP 请求和响应。从现在起,我们将不再在“请求”和“响应”这两个词前加上“HTTP”,因为我们相信不会引起任何混淆。

响应状态码

关于 HTTP 响应,有一件事需要知道的是,它们包括一个状态码,以简洁的方式表达请求的结果。状态码由一个数字和简短描述组成,例如,404 未找到。您可以在developer.mozilla.org/en-US/docs/Web/HTTP/Status上查看完整的 HTTP 状态码列表。

状态码是这样分类的:

  • 1xx 信息响应:请求已接收,继续处理。

  • 2xx 成功:请求已成功接收、理解和接受。

  • 3xx 重定向:需要采取进一步的操作以完成请求。

  • 4xx 客户端错误:请求包含错误的语法或无法满足。

  • 5xx 服务器错误:服务器未能满足有效的请求。

在使用 API 时,我们将在响应中接收到状态码,因此您至少应该了解它们的意义。

API – 简介

在我们深入探讨本章具体项目的细节之前,让我们花一点时间来谈谈 API 的一般情况。

什么是 API?

正如我们在本章开头提到的,API 代表应用程序编程接口。API 是一套规则、协议和工具,用于构建软件和应用程序。它充当计算机或计算机程序之间的连接层。相比之下,用户界面在计算机和人们之间提供桥梁。

一个 API 通常伴随着一份规范文档标准,它作为蓝图概述了软件组件应该如何交互。符合规范的系统被称为实现了或公开了 API。术语 API 可以描述实现和规范。

从本质上讲,API 定义了开发人员可以使用的方法和数据格式来与程序、网络服务或任何其他软件进行交互。

从广义上讲,API 有两种类型:

  • Web API:可通过互联网访问,通常用于使 Web 应用程序能够相互交互或与后端服务器交互。它们是 Web 开发的骨架,使诸如从服务器获取数据、发布数据或与第三方服务(如媒体平台、支付网关等)集成等功能成为可能。

  • 框架和软件库:这些提供了一组执行特定任务的函数和过程,并通过为开发人员提供构建块来加速应用程序开发。

在本章中,我们将重点关注 Web API。

一个 API 通常由不同的部分组成。这些部分有不同的名称,其中最常见的是方法子程序端点(在本章中我们将称其为端点)。当我们使用这些部分时,这个技术的术语是调用它们。

API 规范指导你如何调用每个端点,要发送什么类型的请求,传递哪些参数和头信息,要到达哪些地址,等等。

API 的目的是什么?

在系统中引入 API 有几个原因。我们已经提到的一个原因是创建不同应用程序之间通信的手段。

另一个重要原因是,通过提供一个外部世界可以与之通信的系统层的访问权限,来提供对系统的访问。

API 层通过执行用户的认证授权,以及所有在通信中交换的数据的验证来负责安全。

认证意味着系统可以验证用户凭据,明确地识别他们。授权意味着系统可以验证用户可以访问的内容。

用户、系统和数据在边境进行检查和验证,如果通过检查,他们就可以通过 API 与系统的其余部分进行交互。

这个机制在概念上类似于在机场降落并需要向边境控制出示我们的护照才能与系统(即我们降落的该国)进行交互。

API 层隐藏系统内部结构对外部世界的这一事实提供了另一个好处:如果内部系统在技术、语言或甚至工作流程方面发生变化,API 可以调整其与系统交互的方式,但仍然向公众提供一致的接口。如果我们把一封信放入信箱,我们不需要知道或控制邮政服务如何处理它,只要信件到达目的地即可。因此,接口(信箱)保持一致,而另一边(邮递员、他们的车辆、技术、工作流程等)可以自由地改变和进化。

我们今天拥有的几乎所有连接到网络的电子设备都在与(可能广泛的)一系列 API 进行通信以执行其任务,这并不令人惊讶。

API 协议

有几种类型的 API。它们可以是公开的,也可以是私有的。它们可以提供对数据、服务或两者的访问。API 可以使用不同的方法和标准编写和设计,并且可以采用不同的协议。

这些是最常见的协议:

  • 超文本传输协议/安全(HTTP/HTTPS):Web 数据通信的基础。

  • 表征状态转移(REST):技术上不是一个协议,而是一种在 HTTP 之上构建的架构类型,按照这种风格设计的 API 被称为 RESTful API。它们是无状态的,并且能够利用数据缓存。

  • 简单对象访问协议(SOAP):一个建立已久的用于构建 Web 服务的协议,其消息通常是 XML 格式的,其规范相当严格,这就是为什么这个协议适用于需要高安全标准和交易可靠性的情况。

  • GraphQL:一种用于 API 的查询语言,它使用类型系统来定义数据。与 REST 不同,GraphQL 使用单个端点允许客户端获取他们需要的仅有的数据。

  • WebSocket:非常适合需要客户端和服务器之间双向通信以及实时数据更新的应用程序。它们通过单个 TCP 连接提供全双工通信。

  • 远程过程调用 (RPC):它允许程序员通过远程调用一个过程(因此得名)在服务器端执行代码。这些 API 与服务器实现紧密耦合,因此它们通常不用于公共消费。

API 数据交换格式

我们说过,API 是至少两个计算机系统之间的接口。在与其他系统接口时,必须将数据格式化为它们实现的任何格式,这将是相当不切实际的。因此,提供系统之间通信层的 API 不仅指定了通信协议,还指定了可以采用的数据交换格式。

今天最常见的数据交换格式是JSONXMLYAML。我们在第八章,文件和数据持久性中看到了 JSON,我们也将使用它作为本章 API 的格式。JSON 今天被许多 API 广泛采用,许多框架提供将数据从 JSON 转换为 JSON 以及从 JSON 转换为其他格式的功能。

铁路 API

现在我们已经对 API 有了实际的知识,让我们转向更具体的内容。

在我们向您展示代码之前,请允许我们强调,这段代码不是生产就绪的,因为如果要在书籍的章节中展示,这将太长且过于复杂。然而,这段代码是完全功能性的,它应该为你提供一个良好的起点来学习更多,特别是如果你对其进行实验。我们将在本章末尾提供如何做到这一点的建议。

我们有一个数据库,其中包含一些实体,这些实体模拟了一个铁路应用程序。我们希望允许外部系统对数据库执行CRUD操作,因此我们将编写一个 API 作为其接口。

CRUD代表创建读取更新删除。这是四个基本的数据库操作。许多 HTTP 服务也通过 REST 或类似 REST 的 API 来模拟 CRUD 操作。

让我们先看看项目文件,这样你就可以知道它们在哪里。你可以在本章的文件夹中找到它们,在源代码中:

$ tree -a api_code
api_code
├── .env.example
├── api
│   ├── __init__.py
│   ├── admin.py
│   ├── config.py
│   ├── crud.py
│   ├── database.py
│   ├── deps.py
│   ├── models.py
│   ├── schemas.py
│   ├── stations.py
│   ├── tickets.py
│   ├── trains.py
│   ├── users.py
│   └── util.py
├── dummy_data.py
├── main.py
├── queries.md
└── train.db 

api_code文件夹中,你可以找到属于 FastAPI 项目的所有文件。主要应用程序模块是main.py。我们在代码中留下了dummy_data.py脚本,你可以使用它来生成新的train.db数据库文件。请确保阅读本章文件夹中的README.md,以获取有关如何使用它的说明。我们还为你收集了一组 API 查询,你可以复制并尝试,在queries.md中。

api包中,我们有应用程序模块。数据库模型在models.py中,用于描述它们的 API 的架构在schemas.py中。其他模块的用途可以从它们的名称中看出:users.pystations.pytickets.pytrains.pyadmin.py都包含 API 相应端点的定义。util.py包含一些实用函数;deps.py定义依赖提供者;config.py包含配置设置;crud.py包含在数据库上执行 CRUD 操作的功能,最后,.env.example是你创建.env文件的模板,以向应用程序提供设置。

在软件工程中,依赖注入是一种设计模式,其中对象接收它所依赖的其他对象,这些对象被称为依赖。负责构建和注入这些依赖的软件被称为注入器提供者。因此,依赖提供者是一段创建并提供依赖的软件,这样软件的其他部分就可以使用它,而无需担心创建、设置和销毁它。要了解更多关于这个模式的信息,请参阅这个维基百科页面:

en.wikipedia.org/wiki/Dependency_injection

模型化数据库

在为这个项目准备实体-关系架构时,我们试图设计一些有趣的东西,同时又要简单且易于控制。这个应用程序考虑了四个实体:StationsTrainsTicketsUsers。一列火车是从一个车站到另一个车站的旅程。一张票是火车和用户之间的连接。用户可以是乘客或管理员,根据他们应该能够使用 API 做什么。

图 14.2中,你可以看到数据库的实体关系ER)模型。它描述了四个实体以及它们之间的关系:

img

图 14.2:数据库的 ER 模型

我们使用 SQLAlchemy 定义了数据库模型,并选择了 SQLite 作为 DBMS,以保持简单。

如果你跳过了第八章,文件和数据持久性,现在是一个很好的时候去阅读它,因为它将为你提供理解本章项目模型的基础。

让我们看看models模块:

# api_code/api/models.py
import hashlib
import os
import secrets
from enum import StrEnum, auto
from sqlalchemy import (
    DateTime,
    Enum,
    ForeignKey,
    Unicode,
)
from sqlalchemy.orm import mapped_column, relationship, Mapped
from .database import Base
UNICODE_LEN = 128
SALT_LEN = 64
# Enums
class Classes(StrEnum):
    first = auto()
    second = auto()
class Roles(StrEnum):
    admin = auto()
    passenger = auto() 

如往常一样,在模块顶部,我们导入所有必要的组件。然后我们定义了几个变量来表示 Unicode 字段的默认长度(UNICODE_LEN)和用于散列密码的盐的长度(SALT_LEN)。

要回顾一下盐的定义,请参阅第九章,密码学和令牌

我们还定义了两个枚举:ClassesRoles,这些枚举将在模型定义中使用。我们使用了 Python 3.11 中引入的StrEnum类作为基类,这使得可以直接将枚举成员与字符串进行比较。auto()函数会自动为Enum属性生成值。对于StrEnum,它将值设置为属性名称的小写版本。

让我们看看Station模型的定义:

# api_code/api/models.py
class Station(Base):
    __tablename__ = "station"
    id: Mapped[int] = mapped_column(primary_key=True)
    code: Mapped[str] = mapped_column(
        Unicode(UNICODE_LEN), unique=True
    )
    country: Mapped[str] = mapped_column(Unicode(UNICODE_LEN))
    city: Mapped[str] = mapped_column(Unicode(UNICODE_LEN))
    departures: Mapped[list["Train"]] = relationship(
        foreign_keys="[Train.station_from_id]",
        back_populates="station_from",
    )
    arrivals: Mapped[list["Train"]] = relationship(
        foreign_keys="[Train.station_to_id]",
        back_populates="station_to",
    )
    def __repr__(self):
        return f"<{self.code}: id={self.id} city={self.city}>"
    __str__ = __repr__ 

Station模型相当简单。有几个属性:id作为主键,然后是codecountrycity,这些属性(当结合使用时)告诉我们关于站点的所有需要知道的信息。有两个关系将站点实例与所有从该站点出发和到达的火车联系起来。其余的代码定义了__repr__()方法,它为实例提供字符串表示形式,并且其实现也被分配给__str__(),所以无论我们调用str(station_instance)还是repr(station_instance),输出都将相同。这种技术相当常见,用于防止代码重复。

注意,我们在code字段上定义了一个唯一约束,以确保数据库中不会存在两个具有相同代码的站点。像罗马、伦敦和巴黎这样的大城市有多个火车站,所以对于位于同一城市的站点,citycountry 字段可以相同,但每个站点都必须有自己的唯一code

接着,我们找到了Train模型的定义:

# api_code/api/models.py
class Train(Base):
    __tablename__ = "train"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(Unicode(UNICODE_LEN))
    station_from_id: Mapped[int] = mapped_column(
        ForeignKey("station.id")
    )
    station_from: Mapped["Station"] = relationship(
        foreign_keys=[station_from_id],
        back_populates="departures",
    )
    station_to_id: Mapped[int] = mapped_column(
        ForeignKey("station.id")
    )
    station_to: Mapped["Station"] = relationship(
        foreign_keys=[station_to_id],
        back_populates="arrivals",
    )
    departs_at: Mapped[DateTime] = mapped_column(
        DateTime(timezone=True)
    )
    arrives_at: Mapped[DateTime] = mapped_column(
        DateTime(timezone=True)
    )
    first_class: Mapped[int] = mapped_column(default=0)
    second_class: Mapped[int] = mapped_column(default=0)
    seats_per_car: Mapped[int] = mapped_column(default=0)
    tickets: Mapped[list["Ticket"]] = relationship(
        back_populates="train"
    )
    def __repr__(self):
        return f"<{self.name}: id={self.id}>"
    __str__ = __repr__ 

Train模型中,我们找到了描述火车实例所需的所有属性,以及一个方便的关系tickets,它使我们能够访问针对火车的所有已创建的票。first_classsecond_class字段表示火车有多少个一等和二等车厢。

我们还添加了与站点实例的关系:station_fromstation_to。这些关系使我们能够以对象的形式获取站点实例,而不仅仅是它们的 ID。

接下来是Ticket模型:

# api_code/api/models.py
class Ticket(Base):
    __tablename__ = "ticket"
    id: Mapped[int] = mapped_column(primary_key=True)
    created_at: Mapped[DateTime] = mapped_column(
        DateTime(timezone=True)
    )
    user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
    user: Mapped["User"] = relationship(
        foreign_keys=[user_id], back_populates="tickets"
    )
    train_id: Mapped[int] = mapped_column(ForeignKey("train.id"))
    train: Mapped["Train"] = relationship(
        foreign_keys=[train_id], back_populates="tickets"
    )
    price: Mapped[float] = mapped_column(default=0)
    car_class: Mapped[Enum] = mapped_column(Enum(Classes))
    def __repr__(self):
        return (
            f"<id={self.id} user={self.user} train={self.train}>"
        )
    __str__ = __repr__ 

Ticket也有一些属性,并包括两个关系:usertrain,分别指向购买票的用户和票对应的火车。

注意我们在car_class属性的定义中使用了Classes枚举。这对应于数据库模式定义中的枚举字段。

最后,是User模型:

# api_code/api/models.py
class User(Base):
    __tablename__ = "user"
    pwd_separator = "#"
    id: Mapped[int] = mapped_column(primary_key=True)
    full_name: Mapped[str] = mapped_column(
        Unicode(UNICODE_LEN), nullable=False
    )
    email: Mapped[str] = mapped_column(
        Unicode(2 * UNICODE_LEN), unique=True
    )
    password: Mapped[str] = mapped_column(
        Unicode(2 * UNICODE_LEN)
    )
    role: Mapped[Enum] = mapped_column(Enum(Roles))
    tickets: Mapped[list["Ticket"]] = relationship(
        back_populates="user"
    )
    def is_valid_password(self, password: str):
        """Tell if password matches the one stored in DB."""
        salt, stored_hash = self.password.split(
            self.pwd_separator
        )
        _, computed_hash = _hash(
            password=password, salt=bytes.fromhex(salt)
        )
        return secrets.compare_digest(stored_hash, computed_hash)
    @classmethod
    def hash_password(cls, password: str, salt: bytes = None):
        salt, hashed = _hash(password=password, salt=salt)
        return f"{salt}{cls.pwd_separator}{hashed}"
    def __repr__(self):
        return (
            f"<{self.full_name}: id={self.id} "
            f"role={self.role.name}>"
        )
    __str__ = __repr__ 

User模型为每个用户定义了一些属性。注意这里我们使用了另一个枚举来表示用户的角色。用户可以是乘客或管理员。这将允许我们向您展示一个简单的示例,说明如何编写一个端点,该端点只允许授权用户访问。

User 模型上有几个方法用于散列和验证密码。您可能还记得在 第九章,密码学和令牌 中提到,密码永远不应该以纯文本形式存储在数据库中(这意味着,正如它们所是的那样)。因此,在我们的 API 中,当为用户保存密码时,我们创建一个散列并将其与用于加密的盐一起存储。在本书的源代码中,您将在本模块的末尾找到 _hash() 函数的实现,我们在这里省略了它以节省篇幅。

主要设置和配置

现在我们已经了解了数据库模型,让我们检查应用程序的主要入口点:

# api_code/main.py
from api import admin, config, stations, tickets, trains, users
from fastapi import FastAPI
settings = config.Settings()
app = FastAPI()
app.include_router(admin.router)
app.include_router(stations.router)
app.include_router(trains.router)
app.include_router(users.router)
app.include_router(tickets.router)
@app.get("/")
def root():
    return {
        "message": (
            f"Welcome to version {settings.api_version} "
            f"of our API"
        )
    } 

这是在 main.py 模块中的所有代码。它导入了各种端点模块,并将它们的路由器包含在主应用程序中。通过在主应用程序中包含一个路由器,我们使应用程序能够为使用该特定路由器声明的所有端点提供服务。我们将在本章后面解释什么是路由器。

主模块中只有一个端点,它充当问候消息。端点是一个简单的函数——在这种情况下,root() 函数——它包含在对其发起请求时要执行的代码。这个函数何时以及如何被调用取决于应用于函数的装饰器。在这种情况下,app.get() 装饰器指示 API 在收到 GET 请求时提供此端点。装饰器接受一个参数来指定端点将提供的 URL 路径。在这里,我们使用 "/" 来指定此端点将在根目录下找到,这是应用程序运行的基本 URL。

如果这个 API 在基本 URL http://localhost:8000 上提供服务,当请求 http://localhost:8000http://localhost:8000/(注意尾部的斜杠不同)时,这个端点会被调用。

应用程序设置

在最后一段代码的问候消息中,有一个变量 api_version,它来自 settings 对象。所有框架都允许将设置集合注入到应用程序中以配置其行为。在这个示例项目中,我们实际上并不需要使用设置——我们可以在主模块中直接硬编码这些值——但我们认为展示它们的工作方式是值得的:

# api_code/api/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    secret_key: str
    debug: bool
    api_version: str 

设置是在 Pydantic 模型中定义的 (github.com/pydantic/pydantic). Pydantic 是一个提供使用 Python 类型注解进行数据验证的库。Pydantic 的旧版本也提供了设置管理功能,但该功能已被提取到一个名为 pydantic-settings 的独立库中 (github.com/pydantic/pydantic-settings),该库还增加了额外的设置管理功能。在这种情况下,设置中包含三条信息:

  • secret_key:用于签名和验证 JSON Web Tokens (JWT)。

  • debug:当设置为True时,它指示 SQLAlchemy 引擎详细记录,这对于调试查询很有帮助。

  • api_version:API 的版本。我们实际上并没有真正使用这个信息,除了在问候消息中显示它之外,但通常版本起着重要的作用,因为它与特定的 API 规范相关联。

FastAPI 从创建SettingsConfigDict实例时指定的.env文件中提取这些设置。以下是该文件的外观:

# api_code/.env
SECRET_KEY="018ea65f62337ed59567a794b19dcaf8"
DEBUG=false
API_VERSION=2.0.0 

为了使这可行,FastAPI 需要从名为python-dotenv的库中获得帮助。它是本章要求的一部分,所以如果你已经在你的虚拟环境中安装了它们,你就准备好了。

站点端点

我们将探索一些 FastAPI 端点。因为这个 API 是面向 CRUD 的,所以代码中有些重复。因此,我们将为每个 CRUD 操作展示一个示例,我们将通过使用Station端点示例来实现这一点。请参考源代码以探索与其它模型相关的端点。你会发现它们都遵循相同的模式和约定。主要区别是它们与不同的数据库模型相关。

读取数据

让我们从一个 GET 请求开始我们的探索。在这种情况下,我们将获取数据库中的所有站点。

# api_code/api/stations.py
from typing import Optional
from fastapi import (
    APIRouter,
    Depends,
    HTTPException,
    Response,
    status,
)
from sqlalchemy.orm import Session
from . import crud
from .deps import get_db
from .schemas import Station, StationCreate, StationUpdate, Train
router = APIRouter(prefix="/stations")
@router.get("", tags=["Stations"])
def get_stations(
    db: Session = Depends(get_db), code: Optional[str] = None
) -> list[Station]:
    return crud.get_stations(db=db, code=code) 

stations.py模块中,我们首先从typing模块和fastapi模块导入必要的对象。我们还从sqlalchemy导入Session,以及从本地代码库导入一些其他工具。

get_stations()端点用router对象装饰,而不是像主文件中那样用appAPIRouter可以被视为一个迷你FastAPI类,因为它接受所有相同的参数。我们声明router并给它分配一个前缀(在这种情况下是"/stations"),这意味着所有用这个router装饰的函数都成为可以调用以http://localhost:8000/stations开头的地址的端点。在这种情况下,传递给router.get()方法的空字符串指示应用程序在这个路由器的根 URL 上提供此端点,这将是基础 URL 和路由器前缀的连接,如上所述。

FastAPI 提供了一些指定从端点返回的数据类型的方法。一种是将response_model参数传递给装饰器。然而,在我们的情况下,仅使用类型注解指定函数的返回值就足够了。对于此端点,我们返回一个Station实例的列表。我们很快就会看到它们的实现。

tags参数用于文档目的。

函数本身接受一些参数,这些参数是一个数据库会话,db,以及一个可选的字符串,code,当指定时,将指示端点只提供code字段与提供的匹配的站点。

几个需要注意的事项:

  • 随请求一起发送的数据,如查询参数,在端点声明中指定。如果端点函数需要在请求体中发送数据,则使用 Pydantic 模型(在本项目中,它们在schemas.py模块中定义)来指定。

  • 端点返回的任何内容都成为响应体。FastAPI 将尝试将返回的数据序列化为 JSON。然而,当设置了响应模型时,序列化首先通过response_model中指定的 Pydantic 模型进行,然后从 Pydantic 模型到 JSON。

  • 要在端点体中使用数据库会话,我们使用依赖提供者,在这种情况下,使用Depends类来指定,我们将get_db()函数传递给它。此函数产生一个本地数据库会话,并在端点调用结束时关闭它。

  • 我们使用typing模块中的Optional类来指定请求中的参数是可选的。

get_stations()函数的体只是调用来自crud模块的同名函数,并返回结果值。所有管理数据库交互的函数都位于crud.py模块中。

这是一个设计选择,应该使此代码更容易重用和测试。此外,它简化了读取入口点代码。让我们看看crud.py模块中get_stations()函数的体:

# api_code/api/crud.py
from datetime import UTC, datetime
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session, aliased
from . import models, schemas
def get_stations(db: Session, code: str | None = None):
    stm = select(models.Station)
    if code is not None:
        stm = stm.where(models.Station.code.ilike(code))
    return db.scalars(stm).all() 

注意这个函数的签名与调用它的端点签名是多么相似。get_stations()选择并返回所有Station实例,可选地按code(如果它不是None)过滤。

要启动 API,请激活您的虚拟环境,并在api_code文件夹内运行以下命令:

$ uvicorn main:app --reload 

Uvicorn是一个闪电般的ASGI 服务器,基于uvloophttptools构建。它与正常和异步函数无缝工作。

从 ASGI 文档页面(asgi.readthedocs.io/):

ASGI异步服务器网关接口)是WSGIWeb 服务器网关接口)的精神继承者,旨在为具有异步能力的 Python 网络服务器、框架和应用程序提供标准接口。

WSGI 为同步 Python 应用提供了一个标准,ASGI 为异步和同步应用提供了一个标准,具有 WSGI 向后兼容的实现以及多个服务器和应用程序框架。

对于本章的项目,我们选择编写同步代码,因为异步代码会使代码更难以理解。

如果您熟悉编写异步代码,请参阅 FastAPI 文档(fastapi.tiangolo.com),了解如何编写异步端点。

上面的 uvicorn 命令中的 --reload 标志配置服务器在文件保存时自动重新加载。这是可选的,但当你正在处理 API 源代码时,它可以节省很多时间。

如果我们调用 get_stations() 端点,我们会看到以下内容:

$ http http://localhost:8000/stations
HTTP/1.1 200 OK
content-length: 702
content-type: application/json
date: Thu, 04 Apr 2024 09:46:29 GMT
server: uvicorn
[
    {
        "city": "Rome",
        "code": "ROM",
        "country": "Italy",
        "id": 0
    },
    {
        "city": "Paris",
        "code": "PAR",
        "country": "France",
        "id": 1
    },
    ... some stations omitted ...
    {
        "city": "Sofia",
        "code": "SFA",
        "country": "Bulgaria",
        "id": 11
    }
] 

注意我们用来调用 API 的命令:http。这是一个随 Httpie 工具一起提供的命令。

你可以在 httpie.io 找到 Httpie。Httpie 是一个针对 API 时代的用户友好的命令行 HTTP 客户端。它包含 JSON 支持、语法高亮、持久会话、类似 wget 的下载、插件等功能。还有其他工具可以执行请求,例如 curl。选择权在你,因为使用哪个工具从命令行发送请求并没有区别。

默认情况下,API 在 http://localhost:8000 上提供服务。如果你愿意,你可以向 uvicorn 命令添加参数来自定义这一点。

响应的前几行是来自 API 引擎的信息。我们了解到使用的协议是 HTTP1.1,请求成功(状态码 200 OK)。我们还有关于内容长度和类型的详细信息,它是 JSON 类型。最后,我们得到了一个时间戳和服务器类型。从现在开始,我们将省略这些信息中重复的部分。

响应体是一个 Station 实例的列表,它们的 JSON 表示形式,这要归功于我们在函数签名中指定的 list[Station] 类型注解。

例如,如果我们按 code 搜索,比如伦敦车站,我们可以使用以下命令:

$ http http://localhost:8000/stations?code=LDN 

上述命令使用与之前相同的 URL,但添加了 code 查询参数(用 ? 与 URL 路径分开)。结果是以下内容:

$ http http://localhost:8000/stations?code=LDN
HTTP/1.1 200 OK
...
[
    {
        "city": "London",
        "code": "LDN",
        "country": "UK",
        "id": 2
    }
] 

注意我们得到了一个匹配项,对应于伦敦车站,但仍然以列表的形式返回,正如该端点的类型注解所指示的。

现在我们来探索一个用于通过 ID 获取单个车站的端点:

# api_code/api/stations.py
@router.get("/{station_id}", tags=["Stations"])
def get_station(
    station_id: int, db: Session = Depends(get_db)
) -> Station:
    db_station = crud.get_station(db=db, station_id=station_id)
    if db_station is None:
        raise HTTPException(
            status_code=404,
            detail=f"Station {station_id} not found.",
        )
    return db_station 

对于此端点,我们配置路由器以接受 GET 请求,在 URL http://localhost:8000/stations/{station_id} 上,其中 station_id 将是一个整数。希望 URL 的构建方式对你来说开始变得有意义。有基础部分 http://localhost:8000,然后是路由器的前缀 /stations,最后是我们提供给每个端点的特定 URL 信息,在这种情况下是 /{station_id}

让我们获取 ID 为 3 的基辅车站:

$ http http://localhost:8000/stations/3
HTTP/1.1 200 OK
...
{
    "city": "Kyiv",
    "code": "KYV",
    "country": "Ukraine",
    "id": 3
} 

注意这次我们得到了一个单独的对象,而不是像在 get_stations() 端点那样被列表包裹。这与该端点的类型注解一致,设置为 Station,这是有道理的,因为我们通过 ID 获取单个对象。

get_station()函数接受一个station_id,类型注解为整数,以及通常的db会话对象。使用类型注解来指定参数允许 FastAPI 在我们调用端点时对参数的类型进行数据验证。

如果我们为station_id传递一个非整数值,会发生以下情况:

$ http http://localhost:8000/stations/kyiv
HTTP/1.1 422 Unprocessable Entity
...
{
    "detail": [
        {
            "input": "kyiv",
            "loc": [
                "path",
                "station_id"
            ],
            "msg": "Input should be a valid integer, …",
            "type": "int_parsing",
            "url": "https://errors.pydantic.dev/2.6/v/int_parsing"
        }
    ]
} 

注意我们不得不缩短错误信息,因为它太长了。FastAPI 响应提供了有用的信息:来自路径的station_id不是一个有效的整数。注意,这次状态码是422 Unprocessable Entity,而不是200 OK。一般来说,四百(4xx)的错误表示客户端错误,而五百(5xx)的错误表示服务器错误。在这种情况下,我们使用了一个错误的 URL(我们没有使用整数)。因此,这是一个客户端的错误。在其他 API 框架中,相同的场景可能会返回一个简单的400 Bad Request状态码,但 FastAPI 返回了奇特的特定状态码422 Unprocessable Entity。不过,在 FastAPI 中,很容易自定义在请求错误时返回的状态码;官方文档中有示例。

让我们看看当我们尝试获取一个不存在的 ID 的站点时会发生什么:

$ http http://localhost:8000/stations/100
HTTP/1.1 404 Not Found
...
{
    "detail": "Station 100 not found."
} 

这次 URL 是正确的,因为station_id是一个整数;然而,没有 ID 为 100 的站点。API 返回了404 Not Found状态,正如响应体所告知的。

如果你回到这个端点的代码,你会注意到它的逻辑是多么简单:只要传入的参数是正确的——换句话说,它们尊重类型——它就会尝试通过crud模块中的另一个简单函数从数据库中获取相应的站点。如果找不到站点,它将抛出一个带有期望状态码(404)和详细信息的HTTPException,希望这能帮助消费者理解出了什么问题。如果找到了站点,则将其返回。返回对象的 JSON 序列化版本的过程是由 FastAPI 自动完成的。从数据库检索到的对象是Station类的 SQLAlchemy 实例(models.Station)。该实例被喂给 Pydantic 的Station类(schemas.Station),用于生成随后由端点返回的 JSON 表示。

这可能看起来很复杂,但它是一个优秀的解耦示例。FastAPI 负责工作流程,而我们只需要负责连接:请求参数、响应模型、依赖关系等等。

创建数据

现在我们来看一些更有趣的东西:如何创建一个站点。首先,是端点:

# api_code/api/stations.py
@router.post(
    "",
    status_code=status.HTTP_201_CREATED,
    tags=["Stations"],
)
def create_station(
    station: StationCreate, db: Session = Depends(get_db)
) -> Station:
    db_station = crud.get_station_by_code(
        db=db, code=station.code
    )
    if db_station:
        raise HTTPException(
            status_code=400,
            detail=f"Station {station.code} already exists.",
        )
    return crud.create_station(db=db, station=station) 

这次,我们指示路由器我们想要接受对根 URL 的 POST 请求(记住:基础部分,加上路由器前缀)。我们将返回类型注解为Station,因为端点将返回新创建的对象,我们还指定了响应的默认状态码,即201 Created

create_station() 函数接受一个 db 会话和一个 station 对象。station 对象是由我们创建的,在幕后。FastAPI 从请求体中提取数据并将其馈送到 Pydantic 模式 StationCreate 。该模式定义了我们需要接收的所有数据,结果是 station 对象。

主体中的逻辑遵循以下流程:它尝试使用提供的代码获取一个站点;如果找到了站点,我们无法使用该数据创建一个相同的站点。代码字段被定义为唯一的。因此,使用相同代码创建站点会导致数据库错误。因此,我们返回状态码 400 Bad Request ,通知调用者该站点已存在。如果未找到站点,我们可以继续创建它并返回它。让我们看看涉及的 Pydantic 模式的声明:

# api_code/api/schemas.py
from pydantic import BaseModel, ConfigDict
class StationBase(BaseModel):
    code: str
    country: str
    city: str
class Station(StationBase):
    model_config = ConfigDict(from_attributes=True)
    id: int
class StationCreate(StationBase):
    pass 

注意我们如何使用继承来定义模式。通常的做法是有一个提供所有子类共同功能的基模式。然后,每个子类分别指定其需求。在这种情况下,在基模式中,我们有 codecountrycity。在检索站点时,我们还想返回 id,所以我们指定了 Station 类。此外,由于此类用于转换 SQLAlchemy 对象,我们需要告诉模型关于它的信息,我们通过指定 model_config 属性来实现。记住,SQLAlchemy 是一个 对象关系映射ORM),因此我们需要告诉模型通过设置 from_attributes=True 来读取对象的属性。

StationCreate 模型不需要额外内容,所以我们只需使用 pass 指令作为主体。

现在让我们看看此端点的 CRUD 函数:

# api_code/api/crud.py
def get_station_by_code(db: Session, code: str):
    return db.scalar(
        select(models.Station).where(
            models.Station.code.ilike(code)
        )
    )
def create_station(
    db: Session,
    station: schemas.StationCreate,
):
    db_station = models.Station(**station.model_dump())
    db.add(db_station)
    db.commit()
    return db_station 

get_station_by_code() 函数相当简单。它通过在 code 上进行不区分大小写的匹配来选择一个 Station 对象(ilike() 中的“i”前缀表示不区分大小写)。

有其他方法可以进行不区分大小写的比较,而不涉及使用 ilike。当性能很重要时,这些可能正是正确的做法,但为了本章的目的,我们发现 ilike 的简单性正是我们所需要的。

create_station() 函数接受一个 db 会话和一个 StationCreate 实例。首先,我们以 Python 字典的形式获取站点数据(通过调用 model_dump())。我们知道所有数据都必须在那里;否则,端点已经在初始 Pydantic 验证阶段失败。

使用 station.model_dump() 的数据,我们创建了一个 SQLAlchemy Station 模型的实例。我们将其添加到数据库中,提交事务,并返回它。请注意,当我们最初创建 db_station 对象时,它没有 id 属性。id 是在将行插入 stations 表(在我们调用 db.commit() 时发生)时由数据库引擎自动分配的。当调用 commit() 时,SQLAlchemy 将自动设置 id 属性。

让我们看看这个端点的实际效果。注意我们如何需要指定POSThttp命令,这允许我们在请求体中发送数据,格式为 JSON。之前的请求是 GET 类型,这是http命令的默认类型。注意,我们还因为书籍的行长度限制而将命令拆分成了两行:

$ http POST http://localhost:8000/stations \
code=TMP country=Temporary-Country city=tmp-city
HTTP/1.1 201 Created
...
{
    "city": "tmp-city",
    "code": "TMP",
    "country": "Temporary-Country",
    "id": 12
} 

我们成功创建了一个站点。现在让我们再次尝试,但这次省略了强制性的代码:

$ http POST http://localhost:8000/stations \
country=Another-Country city=another-city
HTTP/1.1 422 Unprocessable Entity
...
{
    "detail": [
        {
            "input": {
                "city": "another-city",
                "country": "Another-Country"
            },
            "loc": [
                "body",
                "code"
            ],
            "msg": "Field required",
            "type": "missing",
            "url": "https://errors.pydantic.dev/2.6/v/missing"
        }
    ]
} 

如预期,我们再次得到了422 Unprocessable Entity状态码,因为 Pydantic StationCreate模型验证失败,响应体告诉我们原因:请求体中缺少code。它还提供了一个有用的链接来查找错误。

更新数据

更新站点的逻辑稍微复杂一些。让我们一起来过一遍。首先,端点:

# api_code/api/stations.py
@router.put("/{station_id}", tags=["Stations"])
def update_station(
    station_id: int,
    station: StationUpdate,
    db: Session = Depends(get_db),
):
    db_station = crud.get_station(db=db, station_id=station_id)
    if db_station is None:
        raise HTTPException(
            status_code=404,
            detail=f"Station {station_id} not found.",
        )
    else:
        crud.update_station(
            db=db, station=station, station_id=station_id
        )
        return Response(status_code=status.HTTP_204_NO_CONTENT) 

路由被指示监听 PUT 请求,这是您应该用于修改网络资源的类型。URL 以station_id结尾,它标识了我们想要更新的站点。该函数接受station_id、Pydantic StationUpdate实例以及常规的db会话。

我们首先从数据库中检索所需的站点。如果站点在数据库中未找到,我们简单地返回404 Not Found状态码,因为没有要更新的内容。否则,我们更新站点并返回204 No Content状态码,这是对 PUT 请求的常见响应方式。我们也可以返回200 OK,但那样的话,我们应该在响应体中返回更新的资源。

站点更新的 Pydantic 模型如下:

# api_code/api/schemas.py
from typing import Optional
class StationUpdate(StationBase):
    code: Optional[str] = None
    country: Optional[str] = None
    city: Optional[str] = None 

所有属性都声明为Optional,因为我们希望允许调用者只传递他们希望更新的内容。

让我们看看负责更新站点的 CRUD 函数的代码:

# api_code/api/crud.py
def update_station(
    db: Session, station: schemas.StationUpdate, station_id: int
):
    stm = (
        update(models.Station)
        .where(models.Station.id == station_id)
        .values(station.model_dump(exclude_unset=True))
    )
    result = db.execute(stm)
    db.commit()
    return result.rowcount 

update_station()函数接受识别要更新的站点的必要参数,以及将用于更新数据库中记录的站点数据,以及常规的db会话。

我们使用sqlalchemyupdate()辅助函数构建一个语句。我们使用where()通过id过滤站点,并通过要求 Pydantic 站点对象给我们一个 Python 字典(排除任何未传递给调用的内容)来指定新值。这有助于执行部分更新。如果我们从代码中省略了exclude_unset=True,任何未传递的参数最终都会出现在字典中,并设置为默认值(None)。

严格来说,我们应该使用 PATCH 请求进行部分更新,但使用 PUT 请求进行完整和部分更新相当常见。

我们执行该语句并返回此操作影响的行数。我们不在端点体中使用此信息,但这对您来说是一个很好的练习。我们将看到如何在删除站点的端点中使用这些信息。

让我们使用上一节中创建的 ID 为12的站点的此端点:

$ http PUT http://localhost:8000/stations/12 \
code=SMC country=Some-Country city=Some-city
HTTP/1.1 204 No Content
... 

我们得到了预期的结果。让我们验证更新是否成功:

$ http http://localhost:8000/stations/12
HTTP/1.1 200 OK
...
{
    "city": "Some-city",
    "code": "SMC",
    "country": "Some-Country",
    "id": 12
} 

对 ID 为12的对象的所有三个属性都已更改。现在让我们尝试部分更新:

$ http PUT http://localhost:8000/stations/12 code=xxx
HTTP/1.1 204 No Content
... 

这次我们只更新了站点代码。让我们再次验证:

$ http http://localhost:8000/stations/12
HTTP/1.1 200 OK
...
{
    "city": "Some-city",
    "code": "xxx",
    "country": "Some-Country",
    "id": 12
} 

如预期,只有code被更改。

删除数据

最后,让我们探索如何删除一个站点。像往常一样,让我们从端点开始:

# api_code/api/stations.py
@router.delete("/{station_id}", tags=["Stations"])
def delete_station(
    station_id: int, db: Session = Depends(get_db)
):
    row_count = crud.delete_station(db=db, station_id=station_id)
    if row_count:
        return Response(status_code=status.HTTP_204_NO_CONTENT)
    return Response(status_code=status.HTTP_404_NOT_FOUND) 

要删除站点,我们指示路由器监听 DELETE 请求。URL 与我们用来获取单个站点以及更新站点的 URL 相同。我们选择的 HTTP 动词触发了正确的端点。delete_station()函数接受station_iddb会话。

在端点的主体内部,我们获取操作影响的行数。在这种情况下,如果有,我们返回一个204 No Content状态码,这告诉调用者删除是成功的。如果没有行受到影响,我们返回一个404 Not Found状态码。请注意,我们可以像这样编写更新方法,利用受影响的行数,但我们选择了另一种方法,这样你就有不同的例子可以学习。

让我们看看CRUD函数:

# api_code/api/crud.py
def delete_station(db: Session, station_id: int):
    stm = delete(models.Station).where(
        models.Station.id == station_id
    )
    result = db.execute(stm)
    db.commit()
    return result.rowcount 

此函数使用了来自sqlalchemydelete()辅助函数。类似于我们为更新场景所做的那样,我们创建了一个通过 ID 识别站点并指示其删除的语句。我们执行该语句并返回受影响的行数。

让我们看看这个端点在实际场景中的表现,首先是一个成功的场景:

$ http DELETE http://localhost:8000/stations/12
HTTP/1.1 204 No Content
... 

我们得到了一个204 No Content状态码,这告诉我们删除是成功的。让我们通过再次尝试删除 ID 为12的站点来间接验证它。这次我们预计站点将消失,并返回一个404 Not Found状态码:

$ http DELETE http://localhost:8000/stations/12
HTTP/1.1 404 Not Found
... 

如预期,我们收到了404 Not Found状态码,这意味着 ID 为 12 的站点未找到,这证明了删除它的第一次尝试是成功的。stations.py模块中还有几个更多端点,你应该检查一下。

我们编写的其他端点用于创建、读取、更新和删除用户、火车和车票。除了它们作用于不同的数据库和 Pydantic 模型之外,它们实际上不会为这个展示带来更多的见解。因此,让我们看看如何验证用户身份的一个例子。

用户身份验证

在这个项目中,身份验证是通过 JWT 完成的。请再次参考第九章,密码学和令牌,以刷新 JWT 的知识。

让我们从users.py模块中的身份验证端点开始:

# api_code/api/users.py
from .util import InvalidToken, create_token, extract_payload
@router.post("/authenticate", tags=["Auth"])
def authenticate(
    auth: Auth,
    db: Session = Depends(get_db),
    settings: Settings = Depends(get_settings),
):
    db_user = crud.get_user_by_email(db=db, email=auth.email)
    if db_user is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"User {auth.email} not found.",
        )
    if not db_user.is_valid_password(auth.password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Wrong username/password.",
        )
    payload = {
        "email": auth.email,
        "role": db_user.role.value,
    }
    return create_token(payload, settings.secret_key) 

此路由器具有前缀" /users" 。为了验证用户,我们需要向此端点发送 POST 请求。它需要一个 Pydantic Auth模式,一个常规的db会话,以及settings对象,该对象用于提供用于创建令牌的秘密密钥。

如果找不到用户,我们返回404 Not Found状态码。如果找到用户,但提供的密码与数据库记录中的密码不匹配,我们返回状态码401 Unauthorized。最后,如果找到用户且密码正确,我们创建一个带有两个声明的令牌:emailrole。我们将使用角色来执行授权功能。

create_token()函数是jwt.encode()的包装器,它还在令牌的有效载荷中添加了几个时间戳。在这里展示那段代码不值得。让我们看看Auth模型:

# api_code/api/schemas.py
class Auth(BaseModel):
    email: str
    password: str 

我们使用用户的电子邮件(作为用户名)和密码来认证用户。这就是为什么在 SQLAlchemy 的User模型中,我们在email字段上设置了一个唯一性约束。我们需要每个用户都有一个唯一的用户名,而电子邮件是满足这一需求常用的字段。

让我们练习这个端点:

$ http POST http://localhost:8000/users/authenticate \
email="fabrizio.romano@example.com" password="f4bPassword"
HTTP/1.1 200 OK
...
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....01GK4QyzZje8NKMzBBVckc" 

我们收到了一个令牌(在片段中省略),因此我们可以使用它。我们认证的用户是管理员,因此我们将向您展示如果我们只想允许管理员这样做,我们如何编写删除端点的代码。让我们看看代码:

# api_code/api/admin.py
from .util import is_admin
router = APIRouter(prefix="/admin")
def ensure_admin(settings: Settings, authorization: str):
    if not is_admin(
        settings=settings, authorization=authorization
    ):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="You must be admin to access this endpoint.",
        )
@router.delete("/stations/{station_id}", tags=["Admin"])
def admin_delete_station(
    station_id: int,
    authorization: Optional[str] = Header(None),
    settings: Settings = Depends(get_settings),
    db: Session = Depends(get_db),
):
    ensure_admin(settings, authorization)
    row_count = crud.delete_station(db=db, station_id=station_id)
    if row_count:
        return Response(status_code=status.HTTP_204_NO_CONTENT)
    return Response(status_code=status.HTTP_404_NOT_FOUND) 

在此示例中,您可以看到端点声明和主体几乎与它们的直观对应物相同,但有一个重要的区别:在尝试删除任何内容之前,我们调用ensure_admin()。在端点中,我们需要从请求中获取授权头,它负责携带令牌信息,以便我们可以将其传递给ensure_admin()函数。我们通过在函数签名中声明它来实现,作为一个来自Header对象的可选字符串。

ensure_admin()函数委托给util.is_admin()函数,该函数解包令牌,验证其有效性,并检查有效载荷中的role字段以查看它是否是管理员。如果所有检查都成功,它返回True,否则返回False。当检查成功时,ensure_admin()函数不执行任何操作,但在检查失败时,它会抛出一个带有403 Forbidden状态码的HTTPException。这意味着如果由于任何原因用户没有权限执行此调用,端点主体的执行将立即停止并在第一行后返回。

有更复杂的方法来进行身份验证和授权,但将它们包含在章节中是不切实际的。这个简单的例子足以作为入门,了解如何在 API 中实现此功能。

记录 API 文档

记录 API 文档是一项繁琐的活动。使用 FastAPI 的一个优点是您不需要记录您的项目:文档由框架自动生成。这是由于使用了类型注解和 Pydantic 才成为可能。

确保您的 API 正在运行,然后打开浏览器并导航到http://localhost:8000/docs。将打开一个页面,其外观应类似于以下内容:

img

图 14.3:FastAPI 自生成的文档的部分截图

图 14.3 中,你可以看到一个端点列表。它们使用 tags 参数进行分类,我们在每个端点声明中指定了该参数。这份文档不仅允许你详细检查每个端点,而且还是交互式的,这意味着你可以通过直接从页面发起请求来测试端点。

我们接下来该做什么?

现在,你应该对 API 设计和主要概念有一个基本的了解。当然,研究本章的代码将加深你的理解,并可能引发一些问题。如果你希望在该主题上学习更多,以下是一些建议:

  • 学好 FastAPI。网站提供了针对初学者和高级程序员的教程。它们相当详尽,涵盖了比我们能在单个章节中包含的更多内容。

  • 使用本章的源代码,通过添加高级搜索和过滤功能来增强 API。尝试实现更复杂的认证系统,并探索使用后台任务、排序和分页。你也可以通过添加仅针对管理员用户的端点来扩展管理部分。

  • 修改预订车票的端点,以便检查火车上是否有空座位。每辆火车都指定了有多少个一等和二等车厢,以及每节车厢的座位数。我们特意以这种方式设计火车模型,以便你可以通过这个练习进行练习。

  • 为现有的端点添加测试,并为源代码中添加的任何其他内容添加测试。

  • 了解 WSGI (wsgi.readthedocs.io/),如果你熟悉异步编程,了解 ASGI,它是异步的等效物 (asgi.readthedocs.io/)。

  • 了解 FastAPI 中的中间件和像 跨源资源共享 (CORS) 这样的概念,这在我们在现实世界中运行 API 时非常重要。

  • 了解其他 API 框架,如 Falcon (falcon.readthedocs.io/) 或 Django Rest Framework (www.django-rest-framework.org)。

  • 了解更多关于表示状态转移 (REST) 的知识。它无处不在,但使用它编写 API 的方式有很多。

  • 了解更多高级 API 概念,例如版本控制、数据格式、协议等。深入了解头部可以做什么。

  • 最后,如果你熟悉异步编程,我们建议重写本章的代码,使其异步。

记得在 .env 文件中设置 DEBUG=true,当与 API 一起工作时,这样你就可以自动将所有数据库查询记录到你的终端中,并检查它们产生的 SQL 代码是否反映了你的意图。当 SQLAlchemy 操作变得稍微复杂一些时,这是一个非常有用的工具。

API 设计是一项如此重要的技能。我们无法强调掌握这一主题的重要性。

摘要

在本章中,我们探索了 API 的世界。我们从一个关于 Web 的简要概述开始,然后转向 FastAPI,它利用了类型注解。这些注解在第十二章,类型提示简介中介绍。

我们接着以通用术语讨论了 API。我们看到了不同的分类方式,以及它们的使用目的和好处。我们还探讨了协议和数据交换格式。

最后,我们深入研究了源代码,分析了本章我们为该项目编写的 FastAPI 项目的一部分。

我们以一系列下一步的建议结束了本章。

下一章将讨论使用 Python 开发 CLI 应用程序。

加入我们的社区 Discord

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十五章:命令行应用程序

用户界面就像一个笑话。如果你不得不解释它,那就不是很好。

– 马丁·勒布兰

在本章中,我们将学习如何在 Python 中创建命令行界面CLI)应用程序,也称为命令行应用程序。CLI 是一种用户界面,用户可以在控制台或终端中输入命令。显著的例子包括 macOS、Linux 和其他基于 UNIX 操作系统的BashZsh外壳,以及 Windows 的命令提示符PowerShell。CLI 应用程序是在这种命令行外壳环境中主要使用的应用程序。通过在 shell 中输入一个命令(可能后跟一些参数)来执行 CLI 应用程序。

虽然图形用户界面(GUIs)和 Web 应用程序更为流行,但命令行应用程序仍然有其位置。它们在开发者、系统管理员、网络管理员和其他技术用户中特别受欢迎。这种受欢迎的原因有几个。一旦你熟悉了所需的命令,你通常可以通过在 CLI 中输入命令而不是在 GUI 的菜单和按钮中点击来更快地完成任务。大多数 shell 还允许将一个命令的输出直接连接到另一个命令的输入。这被称为管道,它允许用户将简单的命令组合成数据处理管道以执行更复杂的任务。命令序列可以保存在脚本中,从而实现可重复性和自动化。通过提供确切的要输入的命令来执行任务,而不是解释如何导航 GUI 或 Web 界面,也更容易记录执行任务的说明。

命令行应用程序比图形或 Web 界面更快、更容易开发和维护。因此,开发团队有时更愿意将工具作为命令行应用程序来实现,以便减少构建内部工具的时间和精力,并更多地关注面向客户的功能。学习如何构建命令行应用程序也是学习如何构建更复杂软件(如 GUI 应用程序或分布式应用程序)的绝佳跳板。

在本章中,我们将创建一个命令行应用程序,用于与我们在上一章中学习的铁路 API 进行交互。我们将利用这个项目来探讨以下主题:

  • 解析命令行参数

  • 通过分解为子命令来构建 CLI 应用程序的结构

  • 安全处理密码

我们将在本章结束时提供一些进一步资源建议,你可以在那里了解更多关于命令行应用程序的信息。

命令行参数

命令行应用程序的主要用户界面由可以传递给它的命令行参数组成。在我们开始探索铁路 CLI 项目之前,让我们简要地了解一下命令行参数以及 Python 提供用于处理它们的机制。

大多数应用程序接受各种选项(或标志)以及位置参数。一些应用程序由几个子命令组成,每个子命令都有自己的独特选项和位置参数。

位置参数

位置参数表示应用程序应操作的主要数据或对象。它们必须按特定顺序提供,通常不是可选的。例如,考虑以下命令:

$ cp original.txt copy.txt 

此命令将创建一个名为 copy.txtoriginal.txt 文件的副本。必须提供两个位置参数(original.txtcopy.txt),改变它们的顺序将改变命令的含义。

选项

选项用于修改应用程序的行为。它们通常是可选的,通常由一个带有一个连字符的单个字母或带有两个连字符的单词组成。选项不需要出现在命令行的任何特定顺序或位置。它们甚至可以放在位置参数之后或之间。例如,许多应用程序接受 -v--verbose 选项以启用详细输出。一些选项的行为类似于开关,仅通过其存在(或不存在)来简单地打开(或关闭)某些功能。其他选项需要额外的参数作为值。例如,考虑以下命令:

$ grep -r --exclude '*.txt' hello . 

这将递归地进入当前目录,并在所有不以 .txt 结尾的文件中搜索字符串 hello-r 选项使 grep 递归地搜索目录。如果没有此选项,当被要求搜索目录而不是常规文件时,它会退出并显示错误。--exclude 选项需要一个文件名模式('*.txt')作为参数,并导致 grep 排除与模式匹配的文件从搜索中。

在 Windows 上,选项传统上以正斜杠字符(/)作为前缀,而不是连字符。然而,许多现代和跨平台的应用程序使用连字符以与其他操作系统保持一致性。

子命令

复杂的应用程序通常被分为几个子命令。Git 版本控制系统是这一点的绝佳例子。例如,考虑以下命令

$ git commit -m "Fix some bugs" 

以及

$ git ls-files -m 

在这里,commitls-filesgit 应用程序的子命令。commit 子命令创建一个新的提交,使用传递给 -m 选项("Fix some bugs")的文本作为提交信息。ls-files 命令显示 Git 仓库中文件的信息。-m 选项对 ls-files 指示命令仅显示尚未提交的修改的文件。

子命令的使用有助于结构化和组织应用程序界面,使用户更容易找到他们需要的功能。每个子命令也可以有自己的帮助信息,这意味着用户可以更容易地学习如何使用一个功能,而无需阅读整个应用程序的完整文档。它还促进了代码的模块化,这提高了可维护性,并允许在不修改现有代码的情况下添加新命令。

参数解析

Python 应用程序可以通过sys.argv访问传递给它们的命令行参数。让我们编写一个简单的脚本,只打印sys.argv的值:

# argument_parsing/argv.py
import sys
print(sys.argv) 

当我们不传递任何参数运行此脚本时,输出如下所示:

$ python argument_parsing/argv.py
['argument_parsing/argv.py'] 

如果我们传递一些参数,我们会得到以下结果:

$ python argument_parsing/argv.py your lucky number is 13
['argument_parsing/argv.py', 'your', 'lucky', 'number', 'is', '13'] 

如您所见,sys.argv是一个字符串列表。第一个元素是运行应用程序使用的命令。其余元素包含命令行参数。

不接受任何选项的简单应用程序可以直接从sys.argv中提取位置参数。然而,对于接受选项的应用程序,参数解析逻辑可能会变得复杂。幸运的是,Python 标准库中的argparse模块提供了一个命令行参数解析器,这使得解析参数变得容易,而无需编写任何复杂的逻辑。

有几个第三方库可以作为argparse的替代方案。在本章中,我们不会介绍这些库,但我们将提供一些链接到一些最受欢迎的库。

例如,我们编写了一个脚本,它接受nameage作为位置参数,并打印出问候语。给定名字Heinrich和年龄42,它应该打印出"Hi Heinrich. You are 42 years old."。它接受一个自定义的问候语来代替"Hi",通过-g选项。在命令行中添加-r--reverse会导致在打印之前反转信息。

# argument_parsing/greet.argparse.py
import argparse
def main():
    args = parse_arguments()
    print(args)
    msg = "{greet} {name}. You are {age} years old.".format(
        **vars(args)
    )
    if args.reverse:
        msg = msg[::-1]
    print(msg)
def parse_arguments():
    parser = argparse.ArgumentParser()
    parser.add_argument("name", help="Your name")
    parser.add_argument("age", type=int, help="Age")
    parser.add_argument("-r", "--reverse", action="store_true")
    parser.add_argument(
        "-g", default="Hi", help="Custom greeting", dest="greet"
    )
    return parser.parse_args()
if __name__ == "__main__":
    main() 

让我们更仔细地看看parse_arguments()函数。我们首先创建ArgumentParser类的实例。然后,我们通过调用add_argument()方法来定义我们接受的参数。我们从nameage位置参数开始,为每个参数提供帮助字符串,并指定age必须是一个整数。如果没有指定type,则参数将被解析为字符串。下一个参数是一个选项,可以在命令行上指定为-r--reverse。最后一个参数是"-g"选项,默认值为"``Hi"。最后,我们调用解析器的parse_args()方法,以解析命令行参数。这将返回一个包含从命令行解析的参数值的Namespace对象。

add_argument() 函数的 action 关键字参数定义了解析器应该如何处理相应的命令行参数。如果没有指定,默认的行为是 "store",这意味着将命令行提供的值存储为解析参数时返回的对象的属性。"store_true" 行为意味着该选项将被视为一个开关。如果它在命令行上存在,解析器将存储值 True;如果不存在,我们得到 Falsedest 关键字参数指定了将存储值的属性的名称。如果没有指定 dest,解析器默认使用位置参数的名称,或者选项参数的第一个长选项字符串(去除前导 --)。如果只提供了一个短选项字符串,则使用该字符串(去除前导 -)。

让我们看看运行此脚本会发生什么。

$ python argument_parsing/greet.argparse.py Heinrich -r 42
Namespace(name='Heinrich', age=42, reverse=True, greet='Hi')
.dlo sraey 24 era uoY .hcirnieH iH 

如果我们提供错误的参数,我们会得到一个 usage 消息,指示预期的参数是什么,以及一个错误消息告诉我们我们做错了什么:

$ python argument_parsing/greet.argparse.py -g -r Heinrich 42
usage: greet.argparse.py [-h] [-r] [-g GREET] name age
greet.argparse.py: error: argument -g: expected one argument 

usage 消息提到了一个 -h 选项,我们没有添加。让我们看看它做了什么:

$ python argument_parsing/greet.argparse.py -h
usage: greet.argparse.py [-h] [-r] [-g GREET] name age
positional arguments:
  name           Your name
  age            Age
options:
  -h, --help     show this help message and exit
  -r, --reverse
  -g GREET       Custom greeting 

解析器自动添加一个 help 选项,它显示了详细的用法信息,包括我们传递给 add_argument() 方法的 help 字符串。

为了帮助您欣赏 argparse 的强大功能,我们在本章的源代码中添加了一个不使用 argparse 的问候脚本版本。您可以在 argument_parsing/greet.argv.py 文件中找到它。

在本节中,我们只是刚刚触及了 argparse 的功能。在下一节中,我们将展示一些更高级的功能,当我们探索铁路 CLI 应用程序时。

为铁路 API 构建 CLI 客户端

现在我们已经涵盖了命令行参数解析的基础,我们可以开始着手构建一个更复杂的 CLI 应用程序了。您可以在本章源代码的项目目录下找到该应用程序的代码。让我们先看看 project 目录的内容。

$ tree -a project
project
├── .env.example
├── railway_cli
│   ├── __init__.py
│   ├── __main__.py
│   ├── api
│   │   ├── __init__.py
│   │   ├── client.py
│   │   └── schemas.py
│   ├── cli.py
│   ├── commands
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── base.py
│   │   └── stations.py
│   ├── config.py
│   └── exceptions.py
└── secrets
    ├── railway_api_email
    └── railway_api_password 

.env.example 文件是为创建铁路应用程序的 .env 配置文件而创建的模板。secrets 目录中的文件包含作为管理员用户与铁路 API 进行身份验证所需的凭证。

要成功运行本节中的示例,您需要运行 第十四章,API 开发简介 中的 API。您还需要创建一个名为 .env.env.example 文件副本,并确保它包含 API 的正确 URL。

railway_cli 目录是铁路 CLI 应用的 Python 包。api 子包包含与铁路 API 交互的代码。在 commands 子包中,你可以找到应用程序子命令的实现。exceptions.py 模块定义了应用程序中可能发生的错误异常。config.py 包含处理全局配置设置的代码。驱动 CLI 应用的主函数位于 cli.py 中。__main__.py 模块是一个特殊的文件,使得包可执行。当使用类似以下命令执行包时

$ python -m railway_cli 

Python 将加载并执行 __main__.py 模块。其内容如下:

# project/railway_cli/__main__.py
from . import cli
cli.main() 

该模块所做的一切就是导入 cli 模块并调用 cli.main() 函数,这是 CLI 应用程序的主要入口点。

与铁路 API 交互

在我们深入研究命令行界面代码之前,我们想简要地讨论一下 API 客户端代码。我们不会详细查看代码,而是只提供一个高级概述。我们将深入研究代码作为你的练习。

api 子包中,你可以找到两个模块,client.pyschemas.py

  • schemas.py 定义了 pydantic 模型来表示我们期望从 API 收到的对象(我们只为车站和火车定义了模型)。

  • client.py 包含三个类和一些辅助函数:

    • HTTPClient 是一个用于发送 HTTP 请求的通用类。它是 requests 库中的 Session 对象的包装器。它具有与 API 使用的 HTTP 动词(getpostputdelete)相对应的方法。这个类负责错误处理和从 API 响应中提取数据。

    • StationClient 是一个用于与 API 的车站端点交互的更高级客户端。

    • AdminClient 是一个用于处理管理端点的更高级客户端。它有一个使用 users/authenticate 端点进行用户认证的方法,以及一个通过 admin/stations/{station_id} 端点删除车站的方法。

创建命令行界面

我们将从一个名为 cli.py 的模块开始探索代码。我们将分块检查它,从 main() 函数开始。

# project/railway_cli/cli.py
import argparse
import sys
from . import __version__, commands, config, exceptions
from .commands.base import Command
def main(cmdline: list[str] | None = None) -> None:
    arg_parser = get_arg_parser()
    args = arg_parser.parse_args(cmdline)
    try:
        # args.command is expected to be a Command class
        command: Command = args.command(args)
        command.execute()
    except exceptions.APIError as exc:
        sys.exit(f"API error: {exc}")
    except exceptions.CommandError as exc:
        sys.exit(f"Command error: {exc}")
    except exceptions.ConfigurationError as exc:
        sys.exit(f"Configuration error: {exc}") 

我们首先导入标准库模块 argparsesys。我们还从当前包中导入 __version__configcommandsexceptions,以及从 commands.base 模块导入 Command 类。

对于 Python 模块和包来说,将版本号暴露在 __version__ 名称下是一个常见的约定。它通常是一个字符串,并且通常定义在包的顶级 __init__.py 文件中。

main()函数中,我们调用get_arg_parser()来获取一个ArgumentParser实例,并调用其parse_args()方法来解析命令行参数。我们期望返回的Namespace对象具有一个command属性,它应该是一个Command类。我们创建这个类的实例,并将解析后的参数传递给它。最后,我们调用命令的execute()方法。

我们通过调用sys.exit()来处理APIErrorCommandErrorConfigurationError异常,以退出并显示针对发生异常类型的错误消息。这些是我们应用程序代码中抛出的唯一异常。如果发生任何其他意外错误,Python 将终止应用程序并向用户的控制台打印完整的异常跟踪信息。这可能看起来不太友好,但它将使调试变得容易得多。CLI 应用程序的用户也往往更技术熟练,因此他们不太可能因为异常跟踪信息而感到沮丧,与 GUI 或 Web 应用程序的用户相比。

注意,main()函数有一个可选参数cmdline,我们将其传递给parse_args()方法。如果cmdlineNoneparse_args()将默认解析来自sys.argv的参数。然而,如果我们传递一个字符串列表,parse_args()将解析这个列表。以这种方式结构化代码对于单元测试特别有用,因为它允许我们在测试中避免操作全局的sys.argv

我们将很快查看Command类以及如何设置参数解析器以返回Command类来执行。不过,让我们首先关注get_arg_parser()函数。

# project/railway_cli/cli.py
def get_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog=__package__,
        description="Commandline interface for the Railway API",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-V",
        "--version",
        action="version",
        version=f"%(prog)s {__version__}",
    )
    config.configure_arg_parser(parser)
    commands.configure_parsers(parser)
    return parser 

get_arg_parser()函数为应用程序创建并配置一个ArgumentParser实例。prog参数指定用于帮助信息的程序名称。通常,argparsesys.argv[0]获取这个值;然而,对于通过python -m package_name执行的包,它是__main__.py,所以我们用包的名称覆盖它。description参数提供了程序在帮助信息中显示的简要描述。formatter_class确定帮助信息的格式化方式(ArgumentDefaultsHelpFormatter将所有选项的默认值添加到帮助信息中)。我们使用"version"操作添加一个"-V""--version"选项,如果命令行中遇到此选项,将打印版本信息并退出。最后,我们调用config.configure_arg_parser()commands.configure_parsers()函数来进一步配置解析器,然后再返回它。

Python 的导入系统将每个导入模块的__package__属性设置为它所属的包名。

在接下来的几节中,我们将查看configcommands模块的命令行参数配置,首先是config

配置文件和秘密

除了命令行参数之外,许多 CLI 应用程序也会从配置文件中读取设置。配置文件通常用于 API URL 等设置,这些设置通常不会从一个应用程序的调用改变到下一个调用。每次运行应用程序时都要求用户在命令行上提供这些设置会相当繁琐。

配置文件的另一个常见用途是提供密码和其他秘密。将密码作为命令行参数提供并不被认为是好的安全实践,因为在大多数操作系统中,任何已登录的用户都可以看到任何正在运行的应用程序的完整命令行。大多数 shell 也具有命令历史功能,这可能会暴露作为命令行参数传递的密码。在配置文件或专门的秘密文件中提供密码要安全得多,这些文件用于配置秘密,例如密码。文件名对应于秘密的名称,文件内容是秘密本身。

非常重要的是要记住,将秘密与我们的代码一起存储永远是不安全的。特别是如果您使用版本控制系统,如 Git 或 Mercurial,请务必小心,不要将任何秘密与源代码一起提交。

railway_cli应用程序中,config模块负责处理配置文件和秘密。我们使用pydantic-settings库,我们在第十四章,API 开发简介中已经遇到过的库,来管理配置。让我们分块查看代码。

# project/railway_cli/config.py
import argparse
from getpass import getpass
from pydantic import EmailStr, Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
from .exceptions import ConfigurationError
class Settings(BaseSettings):
    url: str
    secrets_dir: str | None = None
class AdminCredentials(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="railway_api_")
    email: EmailStr
    password: SecretStr = Field(
        default_factory=lambda: SecretStr(
            getpass(prompt="Admin Password: ")
        )
    ) 

在文件顶部的导入之后,我们有两个类:SettingsAdminCredentials。两者都继承自pydantic_settings.BaseSettingsSettings类定义了railway_cli应用程序的一般配置:

  • url:用于配置 railway API URL。

  • secrets_dir:可以用来配置包含秘密文件的目录的路径。API 管理员凭据将从该目录中的秘密文件中加载。

AdminCredentials类定义了作为管理员用户进行 API 身份验证所需的凭据。SettingsConfigDictenv_prefix参数将在查找秘密目录中的值时添加到字段名称前。例如,password将在名为railway_api_password的文件中查找。AdminCredentials类包含以下字段:

  • email:将包含用于身份验证的管理员电子邮件地址。我们使用pydantic.EmailStr类型来确保它包含有效的电子邮件地址。

  • password:将包含管理员密码。我们使用 pydantic.SecretStr 类型来确保当打印(例如,在应用程序日志中)时,值将被星号屏蔽。如果类实例化时(通过秘密文件或类构造函数的参数)没有提供值,pydantic 将调用通过 Field 函数的 default_factory 参数提供的函数。我们使用这个来调用标准库中的 getpass 函数,以安全地提示用户输入管理员密码。

在这些类定义下方,你可以找到 configure_arg_parser() 函数。现在让我们看看它:

# project/railway_cli/config.py
def configure_arg_parser(parser: argparse.ArgumentParser) -> None:
    config_group = parser.add_argument_group(
        "configuration",
        description="""The API URL must be set in the
        configuration file. The admin email and password should be
        configured via secrets files named email and password in a
        secrets directory.""",
    )
    config_group.add_argument(
        "--config-file",
        help="Load configuration from a file",
        default=".env",
    )
    config_group.add_argument(
        "--secrets-dir",
        help="""The secrets directory. Can also be set via the
        configuration file.""",
    ) 

我们使用参数解析器的 add_argument_group() 方法创建一个名为 "configuration" 的参数组,并给它一个 description 描述。我们向这个组添加了允许用户指定配置文件名和秘密目录的选项。请注意,参数组不会影响参数的解析或返回方式。它仅仅意味着这些参数将在帮助信息中的公共标题下分组。

为了简化起见,我们在这个示例中将默认配置文件路径设置为 .env。然而,使用应用程序运行的平台的标准配置文件位置被认为是最佳实践。platformdirs 库(platformdirs.readthedocs.io)在这方面特别有帮助。

config.py 模块的最后一部分包括用于检索设置和管理员凭证的辅助函数:

# project/railway_cli/config.py
def get_settings(args: argparse.Namespace) -> Settings:
    try:
        return Settings(_env_file=args.config_file)
    except ValidationError as exc:
        raise ConfigurationError(str(exc)) from exc
def get_admin_credentials(
    args: argparse.Namespace, settings: Settings
) -> AdminCredentials:
    secrets_dir = args.secrets_dir
    if secrets_dir is None:
        secrets_dir = settings.secrets_dir
    try:
        return AdminCredentials(_secrets_dir=secrets_dir)
    except ValidationError as exc:
        raise ConfigurationError(str(exc)) from exc 

get_settings() 函数创建并返回 Settings 类的一个实例。_env_file=args.config_file 参数告诉 pydantic-settings 从通过 --config-file 命令行选项指定的文件(默认为 .env)中加载设置。get_admin_credentials() 函数创建并返回 AdminCredentials 类的一个实例。类中的 _secrets_dir 参数指定了 pydantic-settings 将在其中查找凭证的秘密目录。如果命令行上设置了 --secrets-dir 选项,我们将使用该选项;否则,使用 settings.secrets_dir。如果这也是 None,则不会使用秘密目录。

创建子命令

铁路 API 有用于列出车站、创建车站、获取出发时间等单独的端点。在我们的应用程序中拥有类似的架构是有意义的。有许多方法可以组织子命令的代码。在这个应用程序中,我们选择使用面向对象的方法。每个命令都实现为一个类,其中包含一个配置参数解析器的方法,以及一个执行命令的方法。所有命令都是 Command 基类子类。你可以在 commands/base.py 模块中找到它:

# project/railway_cli/commands/base.py
import argparse
from typing import ClassVar
from ..api.client import HTTPClient
from ..config import get_settings
class Command:
    name: ClassVar[str]
    help: ClassVar[str]
    def __init__(self, args: argparse.Namespace) -> None:
        self.args = args
        self.settings = get_settings(args)
        self.api_client = HTTPClient(self.settings.url)
    @classmethod
    def configure_arg_parser(
        cls, parser: argparse.ArgumentParser
    ) -> None:
        raise NotImplementedError
    def execute(self) -> None:
        raise NotImplementedError 

如您所见,Command 类是一个普通类。namehelp 上的 ClassVar 注解表明这些是期望作为类属性,而不是实例属性。__init__() 方法接受一个 argparse.Namespace 对象,并将其分配给 self.args。它调用 get_settings() 来加载配置文件。在返回之前,它还创建了一个 HTTPClient 对象(来自 api/client.py)并将其分配给 self.api_client

configure_arg_parser() 类方法和 execute() 方法在调用时都会抛出 NotImplementedError,这意味着子类需要用它们自己的实现覆盖这些方法。

为了设置子命令的参数解析,我们需要为每个子命令创建一个解析器,并通过调用 Command 类的 configure_arg_parser() 类方法来配置它。commands.configure_parsers() 函数负责这个过程。现在让我们看看这个。

# project/railway_cli/commands/__init__.py
import argparse
from .admin import admin_commands
from .base import Command
from .stations import station_commands
def configure_parsers(parser: argparse.ArgumentParser) -> None:
    subparsers = parser.add_subparsers(
        description="Available commands", required=True
    )
    command: type[Command]
    for command in [*admin_commands, *station_commands]:
        command_parser = subparsers.add_parser(
            command.name, help=command.help
        )
        command.configure_arg_parser(command_parser)
        **command_parser.set_defaults(command=command)** 

parser.add_subparsers() 方法返回一个对象,可以用来将子命令解析器附加到主解析器。description 参数用于生成子命令的帮助文本,required=True 确保如果命令行上没有提供子命令,解析器将产生错误。

我们遍历 admin_commandsstation_commands 列表,并为每个创建一个子解析器。add_parser() 方法期望子命令的 name 和可以传递给 ArgumentParser 类的任何参数。它返回一个新的 ArgumentParser 实例,我们将其传递给 command.configure_arg_parser() 类方法。请注意,command: type[Command] 类型注解表明我们期望 admin_commandsstation_commands 的所有元素都是 Command 的子类。

set_defaults() 方法允许我们独立于命令行上的内容,在解析器返回的命名空间上设置属性。我们使用这个方法将每个子解析器的 command 属性设置为相应的 Command 子类。parse_args() 方法返回的 Namespace 对象将只包含来自恰好一个子解析器的属性(与命令行上提供的子命令相对应)。因此,当我们调用 cli.main() 函数中的 args.command(args=args) 时,我们保证会得到用户选择的命令类的实例。

现在我们已经将配置参数解析器的所有代码组合在一起,我们可以查看当我们使用 -h 选项运行应用程序时生成的帮助文本。

$ python -m railway_cli -h
usage: railway_cli [-h] [-V] [--config-file CONFIG_FILE]
                   [--secrets-dir SECRETS_DIR]
                   {admin-delete-station,get-station,...}
                   ...
Commandline interface for the Railway API
options:
  -h, --help            show this help message and exit
  -V, --version         show program's version number and exit
configuration:
  The API URL must be set in the configuration file. ...
  --config-file CONFIG_FILE
                        Load configuration from a file (default:
                        .env)
  --secrets-dir SECRETS_DIR
                        The secrets directory. Can also be set
                        via the configuration file. (default:
                        None)
subcommands:
  Available commands
  {admin-delete-station,get-station,list-stations,...}
    admin-delete-station
                        Delete a station
    get-station         Get a station
    list-stations       List stations
    create-station      Create a station
    update-station      Update an existing station
    get-arrivals        Get arrivals for a station
    get-departures      Get departures for a station 

我们已经缩减了一些输出并删除了空白行,但您可以看到有一个usage摘要显示了如何使用该命令,然后是我们在创建参数解析器时设置的description。接着是全局的options部分,包括-h--help选项和-V--version选项。接下来是configuration部分,其中包含了在config.configure_arg_parser()函数中配置的描述和选项。最后,我们有一个subcommands部分,其中包含了我们在commands.configure_parsers()中传递给参数解析器add_subparsers()方法的描述,以及所有可用子命令的列表和为每个子命令设置的帮助字符串。

我们已经看到了子命令的基类和配置参数解析器以与子命令一起工作的代码。现在让我们看看子命令的实现。

实现子命令

子命令解析器是完全独立的,因此我们可以实现子命令而无需担心它们的命令行选项可能会相互冲突。我们只需要确保命令名称是唯一的。这意味着我们可以通过添加命令来扩展应用程序,而无需修改任何现有代码。我们为这个应用程序选择基于类的处理方法,这使得添加命令变得容易。我们只需创建一个新的Command子类,定义其namehelp文本,并实现configure_arg_parser()execute()方法。作为一个例子,让我们看看create-station命令的代码。

# project/railway_cli/commands/stations.py
class CreateStation(Command):
    name = "create-station"
    help = "Create a station"
    @classmethod
    def configure_arg_parser(
        cls, parser: argparse.ArgumentParser
    ) -> None:
        parser.add_argument(
            "--code", help="The station code", required=True
        )
        parser.add_argument(
            "--country", help="The station country", required=True
        )
        parser.add_argument(
            "--city", help="The station city", required=True
        )
    def execute(self) -> None:
        station_client = StationClient(self.api_client)
        station = station_client.create(
            code=self.args.code,
            country=self.args.country,
            city=self.args.city,
        )
        print(station) 

注意,我们没有在这里重现commands/stations.py模块顶部的导入。如您所见,命令的代码相当简单。configure_arg_parser()类方法为站点代码、城市和国家添加了选项。

注意,这三个选项都被标记为required。Python 的argparse文档不鼓励使用required选项;然而,在某些情况下,它可以导致更用户友好的界面。如果一个命令需要超过两个具有不同意义的参数,用户可能很难记住正确的顺序。使用选项意味着顺序不重要,并且每个参数的含义立即显而易见。

让我们看看运行此命令时会发生什么。首先,使用-h选项查看帮助信息:

$ python -m railway_cli create-station -h
usage: railway_cli create-station [-h] --code CODE --country
                                  COUNTRY --city CITY
options:
  -h, --help         show this help message and exit
  --code CODE        The station code
  --country COUNTRY  The station country
  --city CITY        The station city 

帮助信息清楚地显示了如何使用该命令。现在我们可以创建一个站点:

$ python -m railway_cli create-station --code LSB --city Lisbon \
    --country Portugal
id=12 code='LSB' country='Portugal' city='Lisbon' 

输出显示站点已成功创建并分配了id 12

这就带我们结束了对铁路 CLI 应用程序的探索。

其他资源和工具

我们将本章以一些链接结束,这些链接指向您可以了解更多信息和一些用于开发 CLI 应用程序的有用库:

  • 尽管我们已经尽力使本章内容尽可能全面,但 argparse 模块的功能远不止我们在这里展示的。不过,官方文档在 docs.python.org/3/library/argparse.html 上非常优秀。

  • 如果 argparse 不符合您的喜好,还有几个第三方库可用于命令行参数解析。我们建议您都尝试一下:

    • Click 是迄今为止最受欢迎的第三方 CLI 库。除了命令行解析外,它还提供了创建交互式应用程序(如输入提示)和生成彩色输出的功能。您可以在 click.palletsprojects.com 了解更多信息。

    • Typer 由与 FastAPI 相同的开发者创建。它的目标是将 FastAPI 应用于 API 开发的相同原则应用于 CLI 开发。您可以在 typer.tiangolo.com/ 了解更多信息。

  • Pydantic Settings,我们在本章和第十四章,API 开发简介中用于配置管理,也支持解析命令行参数。有关更多信息,请参阅docs.pydantic.dev/latest/concepts/pydantic_settings/#command-line-support

  • 大多数现代 shell 都支持可编程的命令行自动补全。提供命令行补全可以使您的 CLI 应用程序更容易使用。argcomplete 库(kislyuk.github.io/argcomplete/)为使用 argparse 处理命令行参数的应用程序提供了 bashzsh shell 的命令行补全功能。

  • 命令行界面指南》(clig.dev/)是一个全面的开源资源,提供了设计用户友好的命令行界面的优秀建议。

摘要

在本章中,我们通过为我们在第十四章,API 开发简介中创建的铁路 API 开发 CLI 客户端来学习命令行应用程序。我们学习了如何使用标准库 argparse 模块解析命令行参数。我们探讨了如何通过使用子命令来构建 CLI 应用程序界面,并看到这如何帮助我们构建易于维护和扩展的模块化应用程序。我们以一些链接到其他用于 Python CLI 应用程序开发的库以及一些您可以了解更多信息的资源来结束本章。

与命令行应用程序一起工作是练习本书所学技能的绝佳方式。我们鼓励您研究本章的代码,通过添加更多命令来扩展它,并通过添加日志和测试来改进它。

在下一章中,我们将学习如何打包和发布 Python 应用程序。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十六章:打包 Python 应用程序

“你总共有任何奶酪吗?”“没有。”

– 蒙提·派森,"奶酪店"草图

在本章中,我们将学习如何为 Python 项目创建一个可安装的包,并将其发布供他人使用。

有许多原因说明你应该发布你的代码。在第一章,Python 的温和介绍中,我们提到 Python 的一个好处是拥有庞大的第三方包生态系统,你可以使用pip免费安装这些包。其中大部分都是由像你一样的开发者创建的,通过贡献你自己的项目,你将帮助确保社区持续繁荣。从长远来看,这也有助于改进你的代码,因为将其暴露给更多用户意味着错误可能会更快被发现。最后,如果你试图成为一名软件开发人员,能够指向你参与过的项目将非常有帮助。

了解打包的最佳方式是通过创建包并发布的过程。这正是本章我们将要做的。我们将要工作的项目是在第十五章,CLI 应用程序中开发的铁路命令行界面CLI)应用程序。

在本章中,我们将学习以下内容:

  • 如何为你的项目创建一个发行版包

  • 如何发布你的包

  • 打包的不同工具

在我们开始打包和发布我们的项目之前,我们将简要介绍Python 包索引PyPI)以及 Python 打包的一些重要术语。

Python 包索引

PyPI是一个在线的 Python 包仓库,托管在pypi.org 。它有一个网页界面,可以用来浏览或搜索包,并查看它们的详细信息。它还提供了 API,如pip,用于查找和下载安装包。PyPI 对任何人开放,可以免费注册并免费分发他们的项目。任何人也可以免费从 PyPI 安装任何包。

仓库被组织成项目发布发行包。一个项目是一个带有其相关数据或资源的库、脚本或应用程序。例如,FastAPIRequestspandasSQLAlchemy以及我们的 Railway CLI 应用程序都是项目。pip本身也是一个项目。发布是项目的特定版本(或时间点的快照)。发布通过版本号来标识。例如,pip24.2版本是pip项目的发布。发布以发行包的形式分发。这些是带有发布版本的归档文件,包含构成发布的 Python 模块、数据文件等。发行包还包含有关项目和发布的项目元数据,例如项目名称、作者、发布版本以及需要安装的依赖项。发行包也被称为distributions或简称为packages

在 Python 中,单词package也用来指代一个可导入的模块,它可以包含其他模块,通常是以包含一个__init__.py文件的文件夹形式存在。重要的是不要将这种可导入的包与发行包混淆。在本章中,我们将主要使用术语package来指代发行包。在存在歧义的情况下,我们将使用术语importable packagedistribution package

发行包可以是源发行包(也称为sdists),在安装之前需要构建步骤,或者构建发行包,在安装过程中只需将归档内容移动到正确的位置。源发行包的当前格式最初由 PEP 517 定义。正式规范可以在packaging.python.org/specifications/source-distribution-format/找到。标准的构建发行包格式称为wheel,最初在 PEP 427 中定义。当前版本的 wheel 规范可以在packaging.python.org/specifications/binary-distribution-format/找到。wheel 格式取代了(现在已弃用的)egg构建发行包格式。

PyPI 最初被昵称为 Cheese Shop,这个名字来源于我们本章开头引用的著名蒙提·派森素描。因此,轮分布格式并不是以汽车轮子命名,而是以奶酪轮子命名。

为了帮助理解所有这些,让我们快速通过一个例子来看看运行pip install时会发生什么。我们将使用pip安装requests项目的发布版2.32.3。为了让我们看到pip正在做什么,我们将使用三次-v命令行选项,以使输出尽可能详细。我们还将添加--no-cache命令行选项,强制pip从 PyPI 下载包,而不是使用任何可能本地缓存的包。输出看起来像这样(请注意,我们已经将输出裁剪以适应页面,并省略了多行;你可以在本章源代码文件pip_install.txt中找到完整的输出):

$ pip install -vvv --no-cache requests==2.32.3
Using pip 24.2 from ... (python 3.12)
...
1 location(s) to search for versions of requests:
* https://pypi.org/simple/requests/ 

Pip告诉我们,它已经在pypi.org/simple/requests/找到了关于requests项目的信息。输出接着列出了requests项目的所有可用发行版:

 Found link https://.../requests-0.2.0.tar.gz..., version: 0.2.0
  ...
  Found link https://.../requests-2.32.3-py3-none-any.whl...
    (requires-python:>=3.8), version: 2.32.3
  Found link https://.../requests-2.32.3.tar.gz...
    (requires-python:>=3.8), version: 2.32.3 

现在,pip收集我们请求的发布版的发行版。它下载 wheel 发行版的元数据:

Collecting requests==2.32.3
  ...
  Downloading requests-2.32.3-py3-none-any.whl.metadata (4.6 kB) 

接下来,pip从包元数据中提取依赖项列表,并继续以相同的方式查找和收集它们的元数据。一旦找到所有必需的包,就可以下载并安装:

Downloading requests-2.32.3-py3-none-any.whl (64 kB)
...
Installing collected packages: urllib3, ..., certifi, requests
...
Successfully installed ... requests-2.32.3 ... 

如果pip为任何包下载了源发行版(如果没有合适的 wheel 可用,可能会发生这种情况),在安装之前需要构建该包。

现在我们知道了项目、发布版和包之间的区别,我们可以开始准备发布版,并为铁路 CLI 应用程序构建发行版包。

使用 Setuptools 进行打包

我们将使用Setuptools库来打包我们的项目。Setuptools 是 Python 最古老的活跃开发打包工具,并且仍然是最受欢迎的。它是原始标准库distutils打包系统的扩展。distutils模块在 Python 3.10 中被弃用,并在 Python 3.12 中从标准库中移除。

在本节中,我们将探讨如何设置我们的项目以使用 Setuptools 构建包。

项目布局

在 Python 项目中布局文件有两种流行的约定:

  • src 布局中,需要分发的可导入包放置在主项目文件夹内的src文件夹中。

  • 扁平布局中,可导入的包直接放置在顶层项目文件夹中。

src布局的优势在于明确指出哪些文件将被包含在发行版中。这减少了意外包含其他文件的可能性,例如仅用于开发期间使用的脚本。然而,src布局在开发期间可能不太方便,因为在不首先在虚拟环境中安装发行版的情况下,无法从顶层项目文件夹中的脚本或 Python 控制台导入包。

支持 src 布局的倡导者认为,在开发过程中被迫安装发行版包实际上是一种好处,因为它增加了在开发过程中发现创建发行版包错误的可能性。

对于这个项目,我们选择使用 src 布局:

$ tree -a railway-project
railway-project
├── .flake8
├── CHANGELOG.md
├── LICENSE
├── README.md
├── pyproject.toml
├── src
│   └── railway_cli
│       ├── __init__.py
│       └── ...
└── test
    ├── __init__.py
    └── test_get_station.py 

src/railway_cli 文件夹包含了 railway_cli 可导入包的代码。我们还在 test 文件夹中添加了一些测试,作为您扩展的示例。.flake8 文件包含了 flake8 的配置,flake8 是一个 Python 代码风格检查器,可以帮助指出我们代码中的 PEP 8 违规。

我们将在下面更详细地描述剩余的每个文件。在此之前,让我们看看如何在开发过程中在虚拟环境中安装项目。

开发安装

src 布局一起工作的最常见方法是使用 开发安装,也称为 开发模式,或 可编辑安装。这将构建并安装项目的 wheel。然而,它不会将代码复制到虚拟环境中,而是在虚拟环境中添加一个指向项目文件夹中源代码的链接。这允许您像安装一样导入和运行代码,但您对代码所做的任何更改都将生效,而无需重新构建和重新安装。

让我们现在尝试一下。打开一个控制台,转到本章的源代码文件夹,创建一个新的虚拟环境,激活它,并运行以下命令:

$ pip install -e railway-project
Obtaining file:///.../ch16/railway-project
  Installing build dependencies ... done
  Checking if build backend supports build_editable ... done
  Getting requirements to build editable ... done
  Preparing editable metadata (pyproject.toml) ... done
...
Building wheels for collected packages: railway-cli
  Building editable for railway-cli (pyproject.toml) ... done
  Created wheel for railway-cli: filename=...
  ...
Successfully built railway-cli
Installing collected packages: ... railway-cli
Successfully installed ... railway-cli-0.0.1 ... 

如输出所示,当我们使用 -e(或 --editable)选项运行 pip install 并给它项目文件夹的路径时,它会构建一个可编辑的 wheel 并将其安装在虚拟环境中。

现在,如果您运行:

$ python -m railway_cli 

它应该像上一章中那样工作。您可以通过在代码中更改某些内容(例如,添加一个 print() 语句)并再次运行来验证我们确实有一个可编辑的安装。

现在我们已经有一个可工作的可编辑项目安装,让我们更详细地讨论项目文件夹中的每个文件。

更新日志

虽然这不是必需的,但包含一个变更日志文件与您的项目一起被认为是良好的实践。此文件总结了项目每个版本中进行的更改。变更日志对于通知您的用户新功能或他们需要了解的软件行为变化非常有用。

我们的更新日志文件名为 CHANGELOG.md,并使用 Markdown 格式编写。

许可证

您应该包含一个定义您的代码分发条款的许可证。有许多软件许可证可供选择。如果您不确定选择哪个,choosealicense.com/ 网站是一个有用的资源,可以帮助您。然而,如果您对法律后果有任何疑问,或需要建议,您应该咨询法律专业人士。

我们在 MIT 许可证下分发我们的铁路 CLI 项目。这是一个简单的许可证,允许任何人使用、分发或修改代码,只要他们包含我们的原始版权声明和许可证。

按照惯例,许可证包含在一个名为LICENSELICENSE.txt的文本文件中,尽管一些项目也使用其他名称,例如COPYING

README

您的项目还应包含一个README文件,描述项目内容、项目存在的原因以及一些基本的使用说明。该文件可以是纯文本格式,也可以使用如reStructuredTextMarkdown这样的标记语法。如果是一个纯文本文件,通常命名为READMEREADME.txt,如果是 reStructuredText,则命名为README.rst,如果是 Markdown,则命名为README.md

我们的README.md文件包含一个简短的段落,描述了项目的目的和一些简单的使用说明。

Markdown 和 reStructuredText 是广泛使用的标记语言,旨在易于以原始形式阅读或编写,但也可以轻松转换为 HTML 以创建简单的网页。您可以在daringfireball.net/projects/markdown/docutils.sourceforge.io/rst.html上了解更多关于它们的信息。

pyproject.toml

该文件由 PEP 518(peps.python.org/pep-0518/)引入并由 PEP 517(peps.python.org/pep-0517/)扩展。这些 PEP 的目标是定义标准,以便项目可以指定它们的构建依赖项以及用于构建它们的包的构建工具。对于使用 Setuptools 的项目,这看起来是这样的:

# railway-project/pyproject.toml
[build-system]
requires = ["setuptools>=66.1.0", "wheel"]
build-backend = "setuptools.build_meta" 

在这里,我们指定了至少需要 Setuptools 的 66.1.0 版本(这是与 Python 3.12 兼容的最旧版本)以及wheel项目的任何版本,wheel项目是轮分布格式的参考实现。请注意,这里的requires字段不列出运行我们的代码的依赖项,只列出构建分发包的依赖项。我们将在本章后面讨论如何指定运行我们的项目的依赖项。

build-backend字段指定了将用于构建包的 Python 对象。对于 Setuptools,这是setuptools(可导入)包中的build_meta模块。

PEP 518 还允许您在pyproject.toml文件中放置其他开发工具的配置。当然,相关的工具也需要支持从该文件中读取它们的配置:

# railway-project/pyproject.toml
[tool.black]
line-length = 66
[tool.isort]
profile = 'black'
line_length = 66 

我们在我们的pyproject.toml文件中添加了black(一个流行的代码格式化工具)和isort(一个按字母顺序排序导入的工具)的配置。我们已将这两个工具配置为使用 66 个字符的行长度,以确保我们的代码可以适应书页。我们还配置了isort以与black保持兼容。

你可以在它们的网站上了解更多关于 blackisort 的信息:black.readthedocs.io/pycqa.github.io/isort

PEP 621 (peps.python.org/pep-0621/) 引入了在 pyproject.toml 文件中指定所有项目元数据的能力。这自 Setuptools 61.0.0 版本以来一直得到支持。我们将在下一节中详细探讨这一点。

包元数据

项目元数据定义在 pyproject.toml 文件中的 project 表格中。让我们一次查看几个条目:

# railway-project/pyproject.toml
[project]
name = "railway-cli"
authors = [
  {name="Heinrich Kruger", email="heinrich@example.com"},
  {name="Fabrizio Romano", email="fabrizio@example.com"},
] 

表格从 [项目] 标题开始。我们的前两个元数据条目包括我们项目的 名称 和作者名单,包括姓名和电子邮件地址。在这个示例项目中,我们使用了假电子邮件地址,但在实际项目中,你应该使用你的真实电子邮件地址。

PyPI 要求所有项目都必须有唯一的名称。在你开始项目时检查这一点是个好主意,以确保没有其他项目已经使用了你想要的名称。还建议确保你的项目名称不会轻易与其他项目混淆;这将减少任何人意外安装错误包的可能性。

项目 表格中的下一个条目是我们项目的描述:

# railway-project/pyproject.toml
[project]
...
description = "A CLI client for the railway API"
readme = "README.md" 

description 字段应该是项目的简短、单句总结,而 readme 应该指向包含更详细描述的 README 文件。

readme 也可以指定为 TOML 表格,在这种情况下,它应该包含一个 content-type 键,以及一个带有 README 文件路径的 file 键或一个带有完整 README 文本的 text 键。

项目许可证也应指定在元数据中:

# railway-project/pyproject.toml
[project]
...
license = {file = "LICENSE"} 

license 字段是一个 TOML 表格,包含一个带有项目许可证文件路径的 file 键,或者一个带有许可证全文的 text 键。

下几个元数据条目旨在帮助潜在用户在 PyPI 上找到我们的项目:

# railway-project/pyproject.toml
[project]
...
classifiers = [
    "Environment :: Console",
    "License :: OSI Approved :: MIT License",
    "Operating System :: MacOS",
    "Operating System :: Microsoft :: Windows",
    "Operating System :: POSIX :: Linux",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]
keywords = ["packaging example", "CLI"] 

classifiers 字段可以用来指定一个 trove 分类器 列表,这些分类器用于在 PyPI 上对项目进行分类。PyPI 网站允许用户在搜索项目时通过 trove 分类器进行筛选。你的项目分类器必须从 pypi.org/classifiers/ 上的官方分类器列表中选择。

我们使用了分类器来表明我们的项目旨在在控制台环境中使用,它是在 MIT 许可证下发布的,它可以在 macOS、Windows 和 Linux 上运行,并且与 Python 3 兼容(具体为 3.10、3.11 和 3.12 版本)。请注意,分类器纯粹是为了向用户提供信息并帮助他们找到 PyPI 网站上的项目。它们对您的软件包可以安装的操作系统或 Python 版本没有影响。

keywords字段可以用来提供额外的关键词,以帮助用户找到您的项目。与分类器不同,对您可以使用的关键词没有限制。

版本控制和动态元数据

项目元数据必须包含一个版本号。我们选择将版本作为一个动态字段来指定,而不是直接指定版本(通过version键)。pyproject.toml规范允许指定除name之外的所有项目元数据,由其他工具动态指定。动态字段的名称通过dynamic键指定:

# railway-project/pyproject.toml
[project]
...
dynamic = ["version"] 

要使用与 Setuptools 兼容的动态元数据,我们需要使用tool.setuptools.dynamic表来指定如何计算值。version可以从文件中读取(使用带有file键的表指定)或从 Python 模块中的属性中读取(使用带有attr键的表指定)。对于本项目,我们从railway_cli可导入包的__version__属性中获取版本:

# railway-project/pyproject.toml
[tool.setuptools.dynamic]
version = {"attr" = "railway_cli.__version__"} 

__version__属性在railway_cli/__init__.py文件中定义:

# railway-project/src/railway_cli/__init__.py
__version__ = "0.0.1" 

使用动态字段意味着我们可以在代码和项目元数据中使用相同的版本号,而无需定义两次。

您可以选择适合您项目的任何版本控制方案,但它必须遵守 PEP 440(peps.python.org/pep-0440/)中定义的规则。一个 PEP 440 兼容的版本由一系列由点分隔的数字组成,后面可以跟有可选的预发布、发布后或开发版本指示符。预发布指示符可以由字母a(表示alpha)、b(表示beta)或rc(表示release-candidate)后跟一个数字组成。发布后指示符由单词post后跟一个数字组成。开发版本指示符由单词dev后跟一个数字组成。没有发布指示符的版本号被称为final发布。例如:

  • 1.0.0.dev1 是我们项目 1.0.0 版本的第一个开发版本。

  • 1.0.0.a1 是第一个 alpha 版本。

  • 1.0.0.b1 是第一个 beta 版本。

  • 1.0.0.rc1 是第一个发布候选版本。

  • 1.0.0 是 1.0.0 版本的最终发布版本。

  • 1.0.0.post1 是第一个发布后版本。

开发版本、预发布版本、最终发布版本和发布后版本,如果主版本号相同,它们的顺序如上列表所示。

流行的版本控制方案包括语义版本控制,该方案旨在通过版本控制方案传达关于发布之间兼容性的信息,以及基于日期的版本控制,通常使用发布的年份和月份来表示版本。

语义版本控制使用由三个数字组成的版本号,称为主版本次版本修订版本,由点分隔。这导致了一个看起来像major.minor.patch的版本。如果一个新版本与其前一个版本完全兼容,则只增加修订号;通常,这样的版本只包含小的错误修复。对于添加新功能而不破坏与先前版本兼容性的版本,应增加次版本号。如果发布版本与旧版本不兼容,则应增加主版本号。您可以在semver.org/上了解有关语义版本控制的全部内容。

指定依赖项

正如我们在本章开头所看到的,一个发行版包可以提供一个它所依赖的项目列表,并且pip在安装包时会确保安装这些项目的版本。这些依赖项应在project表的dependencies键中指定:

# railway-project/pyproject.toml
[project]
...
dependencies = [
    "pydantic[email]>=2.8.2,<3.0.0",
    "pydantic-settings~=2.4.0",
    "requests~=2.0",
] 

我们的项目依赖于pydanticpydantic-settingsrequests项目。方括号中的[email]单词表示pydantic依赖项还要求一些与处理电子邮件地址相关的pydantic项目的可选依赖项。我们将在稍后更详细地讨论可选依赖项。

我们可以使用版本指定符来指示我们需要的依赖项的版本。除了正常的 Python 比较运算符之外,版本指定符还可以使用~=来指示一个兼容版本。兼容版本指定符是一种表示在语义版本控制方案下可能兼容的版本的方式。例如,requests~=2.0表示我们需要requests项目的任何 2.x 版本,从 2.0 到 3.0(不包括 3.0)。版本指定符还可以接受一个逗号分隔的版本子句列表,必须满足所有这些子句。例如,pydantic>=2.8.2,<3.0.0表示我们想要至少pydantic版本 2.8.2,但不能是版本 3.0.0 或更高版本。请注意,这与pydantic~=2.8.2不同,后者意味着至少版本 2.8.2,但不能是版本 2.9.0 或更高版本。有关依赖项语法和版本匹配的完整细节,请参阅 PEP 508(peps.python.org/pep-0508/)。

您应该小心不要使您的依赖项版本指定过于严格。请记住,您的包可能将与同一虚拟环境中的各种其他包一起安装。这对于库或开发者工具尤其如此。在您的依赖项所需版本上尽可能提供最大自由度意味着,依赖于您的项目不太可能遇到您的包与其他项目依赖项之间的依赖项冲突。使您的版本指定过于严格还意味着,除非您也发布新版本来更新您的版本指定,否则您的用户将无法从您的依赖项中的错误修复或安全补丁中受益。

除了对其他项目的依赖项之外,您还可以指定您的项目需要哪些版本的 Python。在我们的项目中,我们使用了 Python 3.10 中添加的功能,因此我们指定至少需要 Python 3.10:

# railway-project/pyproject.toml
[project]
...
requires-python = ">=3.10" 

dependencies 一样,最好避免过多限制您支持的 Python 版本。只有当您知道您的代码在所有活跃支持的 Python 3 版本上都无法工作的情况下,您才应该限制 Python 版本。

您可以在官方 Python 下载页面上找到 Active Python Releases 列表:www.python.org/downloads/

您应该确保您的代码确实可以在您在设置配置中支持的 Python 和依赖项的所有版本上运行。完成此操作的一种方法是为不同的 Python 版本和不同版本的依赖项创建几个虚拟环境。然后,您可以在所有这些环境中运行您的测试套件。手动执行此操作将非常耗时。幸运的是,有一些工具可以为您自动化此过程。最受欢迎的这些工具之一被称为 tox。您可以在 tox.wiki/ 上了解更多信息。

您还可以为您的包指定可选依赖项。pip 只会在用户明确请求时安装这些依赖项。如果某些依赖项仅适用于许多用户不太可能需要的特性,这很有用。想要额外特性的用户可以安装可选依赖项,而其他人则可以节省磁盘空间和网络带宽。

例如,我们在 第九章,密码学和令牌 中使用的 PyJWT 项目,依赖于密码学项目使用非对称密钥签名 JWT。许多 PyJWT 用户不使用此功能,因此开发者将密码学作为可选依赖项。

可选(或额外)依赖项在 pyproject.toml 文件中的 project.optional-dependencies 表中指定。本节可以包含任意数量的命名可选依赖项列表。这些列表被称为 extras。在我们的项目中,我们有一个名为 dev 的额外依赖项:

# railway-project/pyproject.toml
dev = [
    "black",
    "isort",
    "flake8",
    "mypy",
    "types-requests",
    "pytest",
    "pytest-mock",
    "requests-mock",
] 

这是在项目开发期间列出有用的工具作为可选依赖项的常见约定。许多项目还有一个额外的 test 依赖项,用于安装仅用于运行项目测试套件的包。

当安装包时包含可选依赖项,你必须在运行 pip install 时在方括号中添加你想要的额外依赖项的名称。例如,为了包含 dev 依赖项进行我们的项目的可编辑安装,你可以运行:

$ pip install -e './railway-project[dev]' 

注意,在这个 pip install 命令中我们需要使用引号来防止 shell 将方括号解释为文件名模式。

项目 URL

你还可以在 project 元数据表的 urls 子表中包含与你的项目相关的网站 URL 列表:

# railway-project/pyproject.toml
[project.urls]
Homepage = "https://github.com/PacktPublishing/Learn-Python-..."
"Learn Python Programming Book" = "https://www.packtpub.com/..." 

URLs 表的键可以是描述 URL 的任意字符串。在项目中包含指向源代码托管服务(如 GitHub 或 GitLab)的链接是很常见的。许多项目也链接到在线文档或错误跟踪器。我们已使用此字段添加了指向本书在 GitHub 上的源代码仓库的链接以及有关本书在出版社网站上的信息。

脚本和入口点

到目前为止,我们通过键入以下内容来运行我们的应用程序:

$ python -m railway_cli 

这并不是特别用户友好。如果我们能只通过键入以下内容来运行我们的应用程序会更好:

$ railway-cli 

我们可以通过为我们的发行版配置脚本 入口点 来实现这一点。脚本入口点是我们希望能够作为命令行或 GUI 脚本执行的功能。当我们的包被安装时,pip 将自动生成导入指定函数并运行它们的脚本。

我们在 pyproject.toml 文件中的 project.scripts 表中配置脚本入口点:

# railway-project/pyproject.toml
[project.scripts]
railway-cli = "railway_cli.cli:main" 

在此表中,每个键定义了在安装包时应生成的脚本的名称。相应的值是一个对象引用,指向在执行脚本时应调用的函数。如果我们正在打包一个 GUI 应用程序,我们需要使用 project.gui-scripts 表。

Windows 操作系统以不同的方式处理控制台和 GUI 应用程序。控制台应用程序在控制台窗口中启动,可以通过控制台打印到屏幕并读取键盘输入。GUI 应用程序在没有控制台窗口的情况下启动。在其他操作系统上,scriptsgui-scripts 之间没有区别。

现在,当我们在一个虚拟环境中安装项目时,pip 将在虚拟环境 bin 文件夹(或在 Windows 上的 Scripts 文件夹)中生成一个 railway-cli 脚本。它看起来像这样:

#!/.../ch16/railway-project/venv/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from railway_cli.cli import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(
        r'(-script\.pyw|\.exe)?$', '', sys.argv[0]
    )
    sys.exit(main()) 

文件顶部的#!/.../ch16/railway-project/venv/bin/python注释被称为shebang(来自 hash + bang – bang 是感叹号的另一个名称)。它指定了将用于运行脚本的 Python 可执行文件的路径。脚本从railway_cli.main模块导入main()函数,对sys.argv[0]中的程序名称进行一些操作,然后调用main()并将返回值传递给sys.exit()

除了脚本入口点之外,还可以创建任意的入口点组。这些组由project.entry-points表下的子表定义。pip不会为其他组中的入口点生成脚本,但它们可以用于其他目的。具体来说,许多支持通过插件扩展其功能的项目使用特定的入口点组名称进行插件发现。这是一个更高级的主题,我们在这里不会详细讨论,但如果您感兴趣,您可以在入口点规范中了解更多信息:packaging.python.org/specifications/entry-points/

定义包内容

对于使用 src 布局的项目,Setuptools 通常可以自动确定要包含在分发中的 Python 模块。对于平面布局,如果项目文件夹中只有一个可导入的包,则自动发现才会工作。如果存在任何其他 Python 文件,您将需要通过pyproject.toml文件中的tools.setuptools表配置要包含或排除的包和模块。

如果您使用 src 布局,只有当您为包含代码的文件夹使用除src之外的其他名称,或者当src文件夹中有一些模块需要从分发中排除时,您才需要额外的配置。在我们的 railway-cli 项目中,我们依赖于自动发现,因此我们pyproject.toml文件中没有任何包发现配置。您可以在 Setuptools 用户指南中了解更多关于 Setuptools 的自动发现以及如何自定义它的信息:setuptools.pypa.io/en/latest/userguide/

我们的包元数据配置现在已经完成。在我们继续构建和发布包之前,我们将简要看看如何在我们的代码中访问元数据。

在您的代码中访问元数据

我们已经看到如何使用动态元数据在分发配置和代码之间共享版本号。对于其他动态元数据,Setuptools 仅支持从文件中加载,而不是从代码中的属性加载。除非我们为此添加显式配置,否则这些文件将不会包含在 wheel 分发中。然而,有一个更方便的方法可以从代码中访问分发元数据。

importlib.metadata标准库模块提供了访问任何已安装包的发行版元数据的接口。为了演示这一点,我们已向 Railway CLI 应用程序添加了一个用于显示项目许可证的命令行选项:

from importlib.metadata import metadata, packages_distributions
def get_arg_parser() -> argparse.ArgumentParser:
    ...
    parser.add_argument(
        "-L",
        "--license",
        action="version",
        version=get_license(),
    )
    ...
def get_license() -> str:
    default = "License not found"
    all_distributions = packages_distributions()
    try:
        distribution = all_distributions[__package__][0]
    except KeyError:
        return default
    meta = metadata(distribution)
    return meta["License"] or default 

我们使用"version"参数解析器操作来打印许可证字符串,并在命令行上存在-L--license选项时退出。要获取许可证文本,我们首先需要找到与我们的可导入包对应的发行版。packages_distributions()函数返回一个字典,其键是虚拟环境中可导入包的名称。值是提供相应可导入包的发行版包的列表。我们假设没有其他已安装的发行版提供与我们的包同名的一个包,所以我们只取all_distributions[__package__]列表的第一个元素。

metadata函数返回一个类似dict的对象,包含元数据。键与用于定义元数据的pyproject.toml条目名称相似,但并不完全相同。所有键及其含义的详细信息可以在packaging.python.org/specifications/core-metadata/的元数据规范中找到。

注意,如果我们的包未安装,当我们尝试在packages_distributions()返回的字典中查找它时,将会得到一个KeyError。在这种情况下,或者在没有在项目元数据中指定"License"的情况下,我们返回一个默认值来表示无法找到许可证。

让我们看看输出是什么样子:

$ railway-cli -L
Copyright (c) 2024 Heinrich Kruger, Fabrizio Romano
Permission is hereby granted, free of charge, ... 

我们在这里已经裁剪了输出以节省空间,但如果你亲自运行它,你应该能看到打印出的完整许可证文本。

现在,我们已经准备好进行构建和发布发行版包。

构建和发布包

我们将使用由build项目提供的包构建器(pypi.org/project/build/)来构建我们的发行版包。我们还需要twinepypi.org/project/twine/)工具来上传我们的包到 PyPI。您可以从本章源代码提供的requirements/build.txt文件中安装这些工具。我们建议在新的虚拟环境中安装这些工具。

由于 PyPI 上的项目名称必须是唯一的,因此您在更改名称之前无法上传railway-cli项目。在构建发行版包之前,您应该将pyptoject.toml文件中的name更改为一个唯一的名称。请注意,这意味着您的发行版包的文件名也将与我们不同。

构建

build项目提供了一个简单的脚本,用于根据 PEP 517 规范构建包。它将为我们处理构建分发包的所有细节。当我们运行它时,build将执行以下操作:

  1. 创建一个虚拟环境。

  2. 在虚拟环境中安装pyproject.toml文件中列出的构建需求。

  3. 导入pyproject.toml文件中指定的构建后端并运行它以构建源分发。

  4. 创建另一个虚拟环境并安装构建需求。

  5. 导入构建后端并使用它从步骤 3中构建的源分发构建一个轮子。

让我们看看它是如何工作的。进入章节源代码中的railway-project文件夹,并运行以下命令:

$ python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - setuptools>=66.1.0
  - wheel
* Getting build dependencies for sdist...
...
* Building sdist...
...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - setuptools>=66.1.0
  - wheel
* Getting build dependencies for wheel...
...
* Building wheel...
...
Successfully built railway_cli-0.0.1.tar.gz and
railway_cli-0.0.1-py3-none-any.whl 

我们已经从输出中删除了很多行,以便更容易地看到它如何遵循我们上面列出的步骤。如果您查看railway-project文件夹的内容,您会注意到有一个名为dist的新文件夹,其中包含两个文件:railway_cli-0.0.1.tar.gz是我们的源分发,而railway_cli-0.0.1-py3-none-any.whl是轮子。

在上传您的包之前,建议您进行一些检查以确保它正确构建。首先,我们可以使用twine来验证readme是否会在 PyPI 网站上正确渲染:

$ twine check dist/*
Checking dist/railway_cli-0.0.1-py3-none-any.whl: PASSED
Checking dist/railway_cli-0.0.1.tar.gz: PASSED 

如果twine报告了任何问题,您应该修复它们并重新构建包。在我们的例子中,检查通过了,因此让我们安装我们的轮子并确保它工作。在一个单独的虚拟环境中运行:

$ pip install dist./railway_cli-0.0.1-py3-none-any.whl 

在您的虚拟环境中安装了轮子后,尝试运行应用程序,最好从项目目录外部运行。如果在安装或运行代码时遇到任何错误,请仔细检查您的配置是否有误。

我们的包似乎已经成功构建,因此让我们继续发布它。

发布

由于这是一个示例项目,我们将将其上传到 TestPyPI 而不是真正的 PyPI。这是一个专门创建的包索引的独立实例,旨在允许开发者测试包上传并实验打包工具和流程。

在您上传包之前,您需要注册一个账户。您可以通过访问test.pypi.org并点击注册来现在就完成此操作。完成注册过程并验证您的电子邮件地址后,您需要生成一个 API 令牌。您可以在 TestPyPI 网站的账户设置页面完成此操作。确保在关闭页面之前复制令牌并保存它。您应该将令牌保存到用户主目录中名为.pypirc的文件中。该文件应如下所示:

[testpypi]
  username = __token__
  password = pypi-... 

应将password值替换为您的实际令牌。

我们强烈建议您为您的 TestPyPI 账户以及尤其是您的真实 PyPI 账户启用双因素认证。

现在,您已经准备好运行twine来上传您的分发包:

$ twine upload --repository testpypi dist/*
Uploading distributions to https://test.pypi.org/legacy/
Uploading railway_cli-0.0.1-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━ 19.3/19.3 kB • 00:00 • 7.3 MB/s
Uploading railway_cli-0.0.1.tar.gz
100% ━━━━━━━━━━━━━━━━━━ 17.7/17.7 kB • 00:00 • 9.4 MB/s
View at:
https://test.pypi.org/project/railway-cli/0.0.1/ 

twine 显示进度条以显示上传进度。一旦上传完成,它将打印出一个 URL,你可以在这里看到你包的详细信息。在浏览器中打开它,你应该会看到我们的项目描述以及 README.md 文件的内容。在页面的左侧,你会看到项目 URL、作者详情、许可信息、关键词和分类器的链接。

计算机屏幕截图  自动生成描述

图 16.1:我们在 TestPyPI 网站上的项目页面

图 16.1 展示了我们的 railway-cli 项目在这个页面上的样子。你应该仔细检查页面上的所有信息,并确保它们与你期望看到的一致。如果不一致,你将不得不修复 pyproject.toml 中的元数据,重新构建并重新上传。

PyPI 不会允许你重新上传与之前上传的包具有相同文件名的分发。为了修复你的元数据,你必须增加你包的版本号。在你完全确定一切正确无误之前使用开发版本号可以帮助你避免仅仅为了修复打包错误而不必要地增加版本号。

现在,我们可以从 TestPyPI 仓库安装我们的包。在新的虚拟环境中运行以下命令:

pip install --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ railway-cli==1.0.0 

--index-url 选项指示 pip 使用 https://test.pypi.org/simple/ 作为主要包索引。我们使用 --extra-index-url https://pypi.org/simple/ 来告诉 pip 还要查找标准 PyPI 中的包,以便可以安装 TestPyPI 中不可用的依赖项。包安装成功,这证实了我们的包已正确构建和上传。

如果这是一个真实的项目,我们现在将开始上传到真实的 PyPI。过程与 TestPyPI 相同。当你保存 PyPI API 密钥时,你应该将其添加到现有 .pypirc 文件下的 [pypi] 标题下,如下所示:

[pypi]
  username = __token__
  password = pypi-… 

你也不需要使用 --repository 选项将你的包上传到真实的 PyPI;你只需运行以下命令:

$ twine upload dist/* 

如你所见,打包和发布项目并不困难,但确实有很多细节需要注意。好消息是,大部分工作只需要做一次:当你发布第一个版本时。对于后续版本,你通常只需要更新版本号,也许调整一下依赖项。在下一节中,我们将给你一些建议,这应该会使整个过程更容易。

新项目启动建议

一次性完成所有包装准备工作的过程可能相当繁琐。如果您试图在首次发布包之前编写所有包配置,很容易犯诸如忘记列出所需依赖项之类的错误。从简单的 pyproject.toml 开始,其中只包含基本配置和元数据,会更容易一些。您可以在项目开发过程中逐步添加元数据和配置。例如,每次您开始在代码中使用新的第三方项目时,您都可以立即将其添加到您的 dependencies 列表中。这也有助于您尽早开始编写 README 文件,并在进展过程中对其进行扩展。您甚至可能会发现,写一段或两段描述您项目的段落有助于您更清晰地思考您试图实现的目标。

为了帮助您,我们为新的项目创建了一个初始骨架。您可以在本章源代码的 skeleton-project 文件夹中找到它:

$ tree skeleton-project
skeleton-project
├── README.md
├── pyproject.toml
├── src
│   └── example
│       └── __init__.py
└── tests
    └── __init__.py 

将此内容复制下来,按需修改,并将其用作您自己项目的起点。

cookiecutter 项目(cookiecutter.readthedocs.io)允许您创建模板,用作项目的起点。这可以使启动新项目的流程变得更加简单。

其他文件

本章中向您展示的配置文件就是您包装和分发大多数现代 Python 项目所需的所有文件。然而,如果您查看 PyPI 上的其他项目,可能会遇到一些其他文件:

  • 在 Setuptools 的早期版本中,每个项目都需要包含一个 setup.py 脚本,该脚本用于构建项目。大多数这样的脚本仅包含对 setuptools.setup() 函数的调用,并将项目元数据作为参数指定。setup.py 的使用尚未被弃用,这仍然是一种配置 Setuptools 的有效方法。

  • Setuptools 也支持从 setup.cfg 文件中读取其配置。在 pyproject.toml 得到广泛采用之前,这是配置 Setuptools 的首选方式。

  • 如果您需要在您的分发包中包含数据文件或其他非标准文件,您将需要使用 MANIFEST.in 文件来指定要包含的文件。您可以在 packaging.python.org/guides/using-manifest-in/ 上了解更多关于此文件的使用信息。

替代工具

在我们结束本章之前,让我们简要讨论一些您用于包装项目的替代选项。在 PEP 517 和 PEP 518 之前,除了 Setuptools 之外,很难使用其他任何工具来构建包。项目无法指定构建它们所需的库或构建方式,因此 pip 和其他工具只是假设应该使用 Setuptools 来构建包。

多亏了pyproject.toml文件中的构建系统信息,现在使用任何你想要的打包库都变得容易。有几种选择可供选择,包括:

  • Flit 项目(flit.pypa.io)在启发 PEP 517 和 PEP 518 标准(Flit 的创建者是 PEP 517 的共同作者)的发展中起到了关键作用。Flit 旨在使不需要复杂构建步骤(如编译 C 代码)的纯 Python 项目打包尽可能简单。Flit 还提供了一个 CLI 用于构建软件包并将它们上传到 PyPI(因此你不需要build工具或twine)。

  • 诗歌(python-poetry.org/)也提供了用于构建和发布软件包的 CLI 以及一个轻量级的 PEP 517 构建后端。然而,诗歌真正出色的地方在于其高级依赖管理功能。诗歌甚至可以为你管理虚拟环境。

  • Hatch(hatch.pypa.io)是一个可扩展的 Python 项目管理工具。它包括安装和管理 Python 版本、管理虚拟环境、运行测试等功能。它还包含一个名为 hatchling 的 PEP 517 构建后端。

  • PDM(pdm-project.org/)是另一个包含构建后端的软件包和依赖管理器。像 Hatch 一样,它可以用来管理虚拟环境和安装 Python 版本。还有许多插件可用于扩展 PDM 的功能。

  • Enscons(dholth.github.io/enscons/)基于 SCons(scons.org/)通用构建系统。这意味着,与 Flit 或 Poetry 不同,enscons 可以用来构建包含 C 语言扩展的发行版。

  • Meson(mesonbuild.com/)是另一个流行的通用构建系统。meson-pythonmesonbuild.com/meson-python/)项目提供了一个基于 Meson 的 PEP 517 构建后端。这意味着它可以构建包含使用 Meson 构建的扩展模块的发行版。

  • Maturin(www.maturin.rs/)是另一个构建工具,可以用来构建包含使用 Rust 编程语言实现的扩展模块的发行版。

我们在本章中讨论的工具都专注于通过 PyPI 分发软件包。根据你的目标受众,这不一定总是最佳选择。PyPI 主要存在是为了分发项目,如库和 Python 开发者的开发工具。从 PyPI 安装和使用软件包还需要有一个正常工作的 Python 安装,以及至少足够的 Python 知识,知道如何使用pip安装软件包。

如果你的项目是一个面向技术能力较弱的受众的应用程序,你可能需要考虑其他选项。Python 打包用户指南提供了关于分发应用程序的各种选项的有用概述;它可在packaging.python.org/overview/#packaging-applications找到。

这标志着我们打包之旅的结束。

进一步阅读

我们将本章结束于一些链接,你可以通过这些链接阅读更多关于打包的资源:

  • Python 打包权威机构的打包历史页面(www.pypa.io/en/latest/history/)是了解 Python 打包演变的有用资源。

  • Python 打包用户指南(packaging.python.org/)包含有用的教程和指南,以及打包术语表、打包规范的链接和与打包相关的各种有趣项目的总结。

  • Setuptools 文档(setuptools.readthedocs.io/)包含大量有用信息。

  • 如果你正在分发一个使用类型提示的库,你可能希望分发类型信息,以便依赖你的库的开发者可以对他们的代码运行类型检查。Python 类型文档包括一些关于分发类型信息的有用信息:typing.readthedocs.io/en/latest/spec/distributing.html

  • 在本章中,我们展示了如何在代码中访问分发元数据。大多数打包工具还允许你在分发包中包含数据文件。标准库 importlib.resources 模块提供了对这些包资源的访问。你可以在docs.python.org/3/library/importlib.resources.html了解更多信息。

当你阅读这些(以及其他打包资源)时,值得记住的是,尽管 PEP 517、PEP 518 和 PEP 621 几年前就已经最终确定,但许多项目尚未完全迁移到使用 PEP 517 构建,或在 pyproject.toml 文件中配置项目元数据。大部分可用的文档也仍然引用了较旧的做法。

摘要

在本章中,我们探讨了如何通过 PyPI 打包和分发 Python 项目。我们首先介绍了一些关于打包的理论,并引入了 PyPI 上的项目、发布和分发等概念。

我们学习了 Setuptools,这是 Python 最广泛使用的打包库,并了解了使用 Setuptools 准备项目打包的过程。在这个过程中,我们看到了需要添加到项目中以进行打包的各种文件,以及每个文件的作用。

我们讨论了你应该提供哪些元数据来描述你的项目并帮助用户在 PyPI 上找到它,以及如何将代码添加到发行版中,如何指定我们的依赖项,以及如何定义入口点,以便 pip 自动为我们生成脚本。我们还探讨了 Python 提供的用于访问发行版元数据的工具。

我们继续讨论如何构建发行包以及如何使用 twine 将这些包上传到 PyPI。我们还给你提供了一些关于启动新项目的建议。在简要介绍了一些 Setuptools 的替代方案后,我们指出了你可以学习更多关于打包知识的一些资源。

我们真的非常鼓励你开始在 PyPI 上分发你的代码。无论你认为它可能多么微不足道,世界上某个地方的其他人可能觉得它很有用。为社区做出贡献并回馈社区的感觉真的很好,而且,这对你的简历也有好处。

下一章将带你进入竞技编程的世界。我们将稍微偏离专业编程,学习一些关于编程挑战及其为何如此有趣、有用的知识。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

第十七章:编程挑战

“注意上述代码中的错误;我只证明了它是正确的,而没有尝试运行它。”

——唐纳德·克努特(Donald Knuth)

在本章中,我们将偏离专业编程,讨论竞赛编程和编程挑战的世界。

编程挑战是可以用相对简短的程序解决的问题。有几个网站提供了大量的挑战集合。大多数网站根据难度和其他因素,如:

  • 解决挑战所需的算法类型。

  • 挑战涉及的数据结构类型,如树、链表、n 维向量等。

  • 挑战涉及的具体数据结构类型。例如,对于 Python,它们可以是列表、元组、字典等。

  • 解决挑战所需的方法类型,如动态规划、递归等。

这个列表并不全面,但它给出了我们可以期待的内容。

提供编程挑战的网站出于各种原因。有些帮助程序员准备面试,有些只是出于娱乐,还有些作为教授编程的方式。

一些网站还举办比赛,其中参赛者提供的解决方案在执行速度、正确性和内存占用等方面进行衡量,仅举几例。

竞赛编程的世界非常有趣,编程挑战是学习新语言和提高编程技能的绝佳方式。

在本章中,我们将解决来自Advent of Codehttps://adventofcode.com/)的两个问题,这是我们最喜欢的挑战网站。解决挑战很有趣,而且这是我们在相对较短的时间内展示 Python 几个特性的有效方式。

Advent of Code

《Advent of Code》是由埃里克·瓦斯特尔(Eric Wastl)在 2015 年创建的。引用网站上的话:

“《Advent of Code》是一个包含各种技能水平和技能集的小型编程谜题日历,可以用你喜欢的任何编程语言解决。人们使用它们作为面试准备、公司培训、大学课程、练习问题、速度竞赛或相互挑战。”

你不需要计算机科学背景来参与——只需要一点编程知识和一些解决问题的技能,你就能走得很远。你也不需要一台高级的电脑;每个问题都有一个解决方案,在十年前的硬件上最多只需要 15 秒就能完成。”

每个问题有两个部分。根据埃里的说法,第一个部分是确保你理解任务的方式,第二个部分是实际问题。通常,第二部分比第一部分更具挑战性。每个问题都附有可以从网站上下载的输入数据。

这个网站有一些独特的特点。首先,每个问题都是故事的一部分,程序员通过解决挑战来帮助圣诞老人或他的助手,小精灵,拯救圣诞节。因此,每个问题都是一次持续 25 天的冒险的一部分,从 12 月 1 日到 12 月 25 日。展示中充满了幽默,每个问题都是独特的,需要一定程度的逻辑和创造性思维。这在许多其他类似网站上并不常见,那里的挑战通常非常枯燥,它们的解决方案通常围绕应用某些算法或使用适当的数据结构或模式。

在决定添加这一章节之前,我们与埃里克进行了交谈,他唯一的要求是我们不要逐字逐句地复制全部挑战。根据这一点,我们已缩减了问题陈述,只呈现问题指令。

我们即将呈现的解决方案只是解决这些挑战的几种方法之一。它们旨在让我们与您讨论一些最终概念。我们的建议是:在阅读完这一章后,尝试提出自己对所提问题的解决方案。

让我们开始吧。

骆驼牌

第一个问题,骆驼牌,来自 2023 年 7 月 7 日。在这个挑战中,你可以在adventofcode.com/2023/day/7找到,我们必须编写一个程序来解决扑克游戏的变体。

第一部分 – 问题陈述

这是对原文的缩减版本:

“在骆驼牌中,你将得到一串手牌,你的目标是根据每手牌的强度来排序。一双手牌由五张标记为AKQJT98765432的牌组成。每张牌的相对强度遵循以下顺序,其中A为最高,2为最低。

每一手牌都是一种类型。从最强到最弱,它们是:

  • 五张同花,其中所有五张牌都有相同的标记:AAAAA

  • 四张同花,其中四张牌有相同的标记,另一张牌有不同的标记:AA8AA

  • 满贯,其中三张牌有相同的标记,剩下的两张牌共享不同的标记:23332

  • 三张同花,其中三张牌有相同的标记,剩下的两张牌与手中的任何其他牌都不同:TTT98

  • 两对,其中两张牌共享一个标记,另外两张牌共享第二个标记,剩下的牌有第三个标记:23432

  • 一对,其中两张牌共享一个标记,其他三张牌与这对牌和彼此都有不同的标记:A23A4

  • 高牌,其中所有牌的标记都是不同的:23456

手牌主要按类型排序;例如,每个满贯都比任何三张同花更强。

如果有两手牌类型相同,则生效第二个排序规则。首先比较每手牌中的第一张牌。如果这些牌不同,则拥有更强第一张牌的手牌被认为是更强的。如果每手牌中的第一张牌标签相同,则继续考虑每手牌中的第二张牌。如果它们不同,则拥有更高第二张牌的手牌获胜;否则,继续考虑每手牌中的第三张牌,然后是第四张,然后是第五张。

因此,333322AAAA都是四条牌的手牌,但33332更强,因为它的第一张牌更强。同样,7788877788都是满贯牌,但77888更强,因为它的第三张牌更强(而且这两手牌都有相同的第一张和第二张牌)。

要玩骆驼牌,你会得到一串手牌及其相应的叫牌(你的谜题输入)。例如:

32T3K 765
T55J5 684
KK677 28
KTJJT 220
QQQJA 483 

这个例子展示了五手牌;每手牌后面跟着它的叫牌金额。每手牌赢得的金额等于其叫牌金额乘以其排名,其中最弱的手牌排名为 1,第二弱的手牌排名为 2,依此类推,直到最强的手牌。因为在这个例子中有五手牌,所以最强的手牌将拥有排名 5,其叫牌金额将乘以 5。

因此,第一步是将手牌按照力量顺序排列:

  • 32T3K是唯一的一对牌,而其他手牌都是更强类型,所以它获得排名 1。

  • KK677KTJJT都是两对牌。它们的第一张牌标签都相同,但KK677的第二张牌更强(KT),所以KTJJT获得排名 2,KK677获得排名 3。

  • T55J5QQQJA都是三张牌。QQQJA的第一张牌更强,所以它获得排名 5,而T55J5获得排名 4。

  • 现在,你可以通过将每手牌的叫牌金额乘以其排名的结果相加来确定这组手牌的总共赢利(765 * 1 + 220 * 2 + 28 * 3 + 684 * 4 + 483 * 5)。因此,在这个例子中的总共赢利是6440

找出你手中每手牌的排名。总共赢利是多少?”

我们的任务是按照给定的说明找到总共赢利。

第一部分 – 解决方案

解决方案的实施很简单。因为问题的第二部分与第一部分相当相似,所以我们已经将公共逻辑封装到一个基类Solver中。这个类提供了我们解决问题所需的所有方法,除了一个,即type()方法,它在每个子类PartOnePartTwo中实现。这是防止代码重复的许多可能方法之一。让我们看看代码的第一部分:

# day7.py
from collections import Counter
from functools import cmp_to_key
from util import get_input
class Solver:
    strengths: str = ""
    def __init__(self) -> None:
        self.ins: list[str] = get_input("input7.txt")
    def solve(self) -> int:
        hands = dict(self.parse_line(line) for line in self.ins)
        sorted_hands = sorted(
            hands.keys(), key=cmp_to_key(self.cmp)
        )
        return sum(
            rank * hands[hand]
            for rank, hand in enumerate(sorted_hands, start=1)
        )
    def parse_line(self, line: str) -> tuple[str, int]:
        hand, bid = line.split()
        return hand, int(bid) 

我们从标准库中导入Countercmp_to_key(),从util.py模块中导入get_input()。后者是一个辅助函数,它读取输入文件并返回一个字符串列表,例如["9A35J 469", "75T32 237", ...]

我们定义了一个Solver类,这里只部分展示了该类,它会在初始化时读取输入,并包含一个solve()方法,该方法运行算法。步骤很简单:我们使用parse_line()方法将字符串列表转换为字典(hands),其中键是手牌,值是它们各自的出价,这些出价已经被转换为整数。

在转换输入数据后,我们创建了一个手牌列表,按照其等级排序,等级是根据问题陈述中给出的规则计算的。我们将在稍后分析自定义的cmp()方法。现在,只需注意它是如何被使用的,用于执行排序。由于比较器需要两个对象进行比较,所以它不适合作为sorted()函数的key参数的参数。因此,Python 提供了一个cmp_to_key()函数,它接受一个比较器函数作为输入,并产生一个可以作为排序键的对象。

在创建好排序好的手牌列表后,我们返回每对手牌的等级和其出价之间的所有乘积的总和。

让我们现在来检查这个类的更有趣的部分:

# day7.py
…
class Solver:
    …
    def type(self, hand: str) -> list[int]:
        raise NotImplementedError
    def cmp(self, hand1: str, hand2: str) -> int:
        """-1 if hand1 < hand2 else 1, or 0 if hand1 == hand2"""
        type1 = self.type(hand1)
        type2 = self.type(hand2)
        if type1 == type2:
            for card1, card2 in zip(hand1, hand2):
                strength1 = self.strengths.index(card1)
                strength2 = self.strengths.index(card2)
                if strength1 == strength2:
                    continue
                return -1 if strength1 < strength2 else 1
            return 0
        return -1 if type1 < type2 else 1 

在类的其余代码中,我们定义了一个自定义比较器cmp()。它接受两个手牌并按照问题的规则进行比较。首先,我们计算每个手牌的类型。如果类型不同,如果hand2hand1强,则返回-1,反之则返回1。如果两个手牌的类型相同,我们需要检查每张牌的强度。根据检查结果,我们使用之前相同的标准返回-11。为了完整性,如果两个手牌的类型和强度都相同,则返回0。我们知道,根据问题陈述,这种情况永远不会发生,否则最终的排名可能会依赖于输入顺序。

比较器正在使用type()方法,其逻辑没有在这个类中实现。由于牌的强度和手牌类型的计算在第一部分和第二部分之间是不同的,所以我们把它们实现在了两个专门的类中。

让我们看看PartOne的实现:

# day7.py
…
class PartOne(Solver):
    strengths: str = "23456789TJQKA"
    def type(self, hand: str) -> list[int]:
        return [count for _, count in Counter(hand).most_common()] 

PartOne中,我们设置了strengths类属性并实现了type()方法。当我们用几个示例手牌调用它时,这些是结果:

type("KK444") = [3, 2]
type("QQQ6Q") = [4, 1]
type("5JA22") = [2, 1, 1, 1]
type("7A264") = [1, 1, 1, 1, 1]
type("TTKTT") = [4, 1] 

这是type()的工作方式:首先将手牌输入到Counter中。这个对象将计算手牌中每个字符出现的次数。在第一个例子中,Counter("KK444")的结果是{'K': 2, '4': 3},这是一个类似字典的对象。正如预期的那样,我们有两个K和三个4。使用most_common()方法,我们可以获取该对象的值的列表,按从高到低的顺序排序。这将产生上面的结果:[3, 2]。这些列表,如[1, 1, 1, 1, 1][3, 2][4, 1]等,可以相互比较以计算等级。我们在solve()方法中计算sorted_hands时这样做。

现在,是时候创建一个PartOne的实例并运行它的solve()方法了:

# day7.py
part_one = PartOne()
print(part_one.solve()) 

注意,我们将PartOne.strengths设置为字符串" 23456789TJQKA"。这是根据问题陈述评估强度的顺序,其中2是最弱的,A是最强的。使用它们的位置来表示它们的相对权重,使我们能够在cmp()中使用它们的索引进行比较。索引越高,牌越强。

第一部分现在已经结束,让我们继续到第二部分。

第二部分 – 问题陈述

当我们在网站上输入第一部分的正确解决方案时,我们可以访问第二部分。现在规则有所改变,因为游戏中引入了王牌牌。

“现在,J牌是王牌——可以像任何牌一样行动的百搭牌。为了平衡这一点,J牌现在是单个牌中最弱的,甚至比2还弱。其他牌保持相同的顺序:AKQT98765432J

J牌可以假装成任何最适合确定手牌类型的牌;例如,QJJQ2现在被认为是四条。然而,为了在相同类型的两张手牌之间打破平局,J始终被当作J来处理,而不是它假装成的牌:JKKK2QQQQ2弱,因为JQ弱。

现在,上面的例子会有很大的不同:

32T3K 765
T55J5 684
KK677 28
KTJJT 220
QQQJA 483 
  • 32T3K仍然是唯一的单对牌;它不包含任何王牌,所以它的强度没有增加。

  • KK677现在是唯一的两对牌,使其成为第二最弱的牌。

  • T55J5KTJJTQQQJA现在都是四条!T55J5得到第 3 名,QQQJA得到第 4 名,KTJJT得到第 5 名。

使用新的王牌规则,本例中的总奖金为5905。使用新的王牌规则,找出你手中每张牌的等级。新的总奖金是多少?”

让我们深入到解决方案中。

第二部分 – 解决方案

对于这部分,我们只需要提供正确的新的一组强度和一个不同的type()方法实现。

让我们看看代码,这是从同一个模块继续的:

# day7.py
…
class PartTwo(Solver):
    strengths: str = "J23456789TQKA"
    def type(self, hand: str) -> list[int]:
        card_counts = Counter(hand.replace("J", "")).most_common()
        h = [count for _, count in card_counts]
        return [h[0] + hand.count("J"), *h[1:]] if h else [5]
part_two = PartTwo()
print(part_two.solve()) 

上述内容是我们解决问题第二部分所需的所有内容。type() 方法的新的版本是这样工作的:我们仍然将 hand 字符串喂给一个 Counter,但这次我们从手牌中移除了任何王牌。我们再次使用 most_common() 方法按逆序排序值。在这个时候,我们最终会遇到以下这些情况之一:

  • 对于完全由王牌组成的手牌 "JJJJJ"h 将为空,因此 type() 返回 [5]。这是正确的,因为这个手牌的最高等级是 五带一

  • 对于没有王牌的手牌,逻辑行为类似于 PartOne.type()

  • 对于至少有一张王牌但不到五张的手牌,我们首先忽略王牌计算其类型。之后,我们返回一个修改过的结果版本,其中第一个元素增加手牌中王牌的数量。例如,手牌 "KKJJ4" 将产生(忽略王牌)列表 [2, 1](两个 K 和一个 4)。为了最大化其等级,最聪明的事情是将两张王牌当作 K 使用。这意味着拥有等效的手牌 "KKKK4"。为此,我们将 2(王牌的数量)加到列表 [2, 1] 的第一个元素上,因此我们返回 [4, 1],这是 "KKKK4" 手牌的正确类型。

PartTwo 中唯一另一个重要的细节是新的大牌顺序,现在是 "J23456789TQKA"。对于这部分,大牌顺序也考虑了王牌牌,它是最弱的,因此放在字符串中的最低索引位置。

这就结束了问题的第二部分。我们选择这个问题是因为它允许我们向你展示如何使用面向对象编程(OOP)来防止代码重复,以及使用 Countercmp_to_key()

你可以在书籍源代码的 ch17 文件夹中找到这个问题的输入,以及 util.py 模块中 get_input() 函数的实现。

现在,让我们继续进行第二个挑战。

宇宙膨胀

第二个问题,宇宙膨胀,来自 2023 年 11 月 11 日。在这个挑战中,你可以在 adventofcode.com/2023/day/11 找到,我们需要扩展一个宇宙并计算所有星系对之间最短路径的长度。

第一部分 – 问题陈述

这里是原始文本的简略版:

“研究员收集了一堆数据,并将数据编译成一张巨大的图像(你的谜题输入)。图像包括空白空间(.)和星系(#)。例如:

...#......
.......#..
#.........
..........
......#...
.#........
.........#
..........
.......#..
#...#..... 

研究员正在试图找出每对星系之间最短路径长度的总和。然而,有一个陷阱:在那些星系的光到达观测站之前,宇宙已经膨胀了。

只有部分空间会膨胀。实际上,任何包含没有星系的行或列都应该扩大一倍。在上面的例子中,有三列和两行没有星系。这些行和列需要扩大一倍;因此,宇宙膨胀的结果看起来像这样:

....#........
.........#...
#............
.............
.............
........#....
.#...........
............#
.............
.............
.........#...
#....#....... 

配备了这个扩展宇宙,可以找到每对星系之间的最短路径。这有助于为每个星系分配一个唯一的数字。在这 9 个星系中,有 36 对。只计算每对一次;对内的顺序不重要。对于每一对,使用只向上、向下、向左或向右移动一步的步骤(每次移动正好一步或 #)来找到两个星系之间的任何最短路径。(两个星系之间的最短路径允许穿过另一个星系。)

分配唯一的数字,并用 x 突出显示从星系 5 到星系 9 的其中一条最短路径,我们得到这个:

....1........
.........2...
3............
.............
.............
........4....
.5...........
.xx.........6
..xx.........
...xx........
....xx...7...
8....9....... 

从星系 5 到星系 9(8 个 x 加上进入星系 9 的步骤)至少需要 9 步。这里有一些其他最短路径长度的例子:

在星系 1 和星系 7 之间:15

在星系 8 和星系 9 之间:5

在这个例子中,在扩展宇宙之后,所有 36 对星系之间最短路径的总和是 374。扩展宇宙,然后找到每对星系之间最短路径的长度。这些长度的总和是多少?”

让我们现在看看这一部分的解答。

第一部分 – 解答

让我们先定义基本块:

# day11.py
from itertools import combinations
from typing import NamedTuple, Self
from util import get_input
universe = get_input("input11.txt")
class Galaxy(NamedTuple):
    x: int
    y: int
    @classmethod
    def expand(
        cls, galaxy: Self, xfactor: int, yfactor: int
    ) -> Self:
        return cls(galaxy.x + xfactor, galaxy.y + yfactor)
    @classmethod
    def manhattan(cls, a: Self, b: Self) -> int:
        return abs(a.x - b.x) + abs(a.y - b.y)
class ExpansionCoeff(NamedTuple):
    x: int = 0
    y: int = 0 

代码从一些导入开始。我们需要计算所有星系对,这可以通过使用 itertools.combinations() 来实现。我们还需要从 typing 模块中的 NamedTupleSelf ,以及当然,我们的自定义 get_input() 函数来读取输入文件,我们将它命名为 universe

在这个问题中,我们选择创建一个 Galaxy 类,它代表空间中的一个点。它从 NamedTuple 继承,这提供了一些开箱即用的有用功能。其他适合这种数据的选择是 dataclasses.dataclasscomplex ,或者甚至只是一个裸露的自定义类。

Galaxy 有两个坐标, xy ;它定义了一个 expand() 方法,该方法返回具有偏移坐标的新 Galaxy ,以及一个 manhattan() 方法,该方法使用 出租车几何 来计算两个星系之间的距离。要了解更多信息,请访问 en.wikipedia.org/wiki/Taxicab_geometry 。简单来说,这是在整数平面上垂直和水平方向上受到运动限制时计算距离的方式。

我们还定义了一个 ExpansionCoeff 类来表示膨胀系数。

这里是求解器逻辑的主要部分:

# day11.py
…
def coords_to_expand(universe: list[str]) -> list[int]:
    return [
        coord
        for coord, row in enumerate(universe)
        if set(row) == {"."}
    ]
def expand_universe(universe: list[str], coeff: int) -> set:
    galaxies = parse(universe)
    rows_to_expand = coords_to_expand(universe)
    galaxies = expand_dimension(
        galaxies, rows_to_expand, ExpansionCoeff(y=coeff - 1)
    )
    **transposed_universe = [****""****.join(col)** **for** **col** **in****zip****(*universe)]**
    cols_to_expand = coords_to_expand(transposed_universe)
    return expand_dimension(
        galaxies, cols_to_expand, ExpansionCoeff(x=coeff - 1)
    )
def parse(ins: list[str]) -> set:
    return {
        Galaxy(x, y)
        for y, row in enumerate(ins)
        for x, val in enumerate(row)
        if val == "#"
    }
def solve(universe: list[str], coeff) -> int:
    expanded_universe = expand_universe(universe, coeff)
    return sum(
        Galaxy.manhattan(g1, g2)
        for g1, g2 in combinations(expanded_universe, 2)
    ) 

solve() 函数计算扩展宇宙并返回每对之间最短路径的总和。主要任务在 expand_universe() 方法中执行。

在这里,我们首先解析宇宙,提取一组Galaxy实例。扩展分为两个步骤:首先,我们沿着垂直方向扩展,然后是水平方向。因为宇宙是一个字符串列表,它可以被视为一个二维矩阵。为了沿两个正交方向扩展,我们有两种选择:一种是为每个方向编写一个单独的函数并传递宇宙作为参数。第二种选择,即我们实现的,是只为垂直方向编写扩展代码,一次使用原始宇宙调用它,然后再次传递宇宙的转置版本。这相当于沿水平方向扩展。

如果你不太熟悉转置矩阵的概念,你可以在en.wikipedia.org/wiki/Transpose上了解它。简单来说,矩阵的转置版本就是将矩阵沿对角线翻转的结果。结果是原始的行变成了列,反之亦然。

在高亮行中,你可以看到如何计算宇宙的转置版本。我们本可以使用zip(*universe),但这将需要对类型注解进行一些调整,因为这样不会返回一个字符串列表。我们选择遵守更简单的类型注解,并将宇宙的转置版本作为一个字符串列表来保持与原始宇宙版本的一致性。

值得注意的是,在专业代码中,更好的选择是将注解适应到代码,而不是相反,但在这个案例中,我们希望尽可能保持代码的简洁性,以便于阅读。

关于coords_to_expand()方法,我们实现宇宙扩展方向的算法依赖于坐标是排序的事实。你可以看到,通过从上到下遍历宇宙并返回不包含星系的行的坐标列表,我们仍然在不需要显式调用sorted()的情况下产生了一个排序列表。

我们需要的最后一段代码是执行一维扩展的部分:

# day11.py
…
def expand_dimension(
    galaxies: set,
    coords_to_expand: list[int],
    expansion_coeff: ExpansionCoeff,
) -> set:
    dimension = "x" if expansion_coeff.y == 0 else "y"
    for coord in reversed(coords_to_expand):
        new_galaxies = set()
        for galaxy in galaxies:
            if getattr(galaxy, dimension) >= coord:
                galaxy = Galaxy.expand(galaxy, *expansion_coeff)
            new_galaxies.add(galaxy)
        galaxies = new_galaxies
    return new_galaxies 

expand_dimension()函数可能不是那么直接,让我们逐行分析它。我们首先通过检查expansion_coeff对象来获取我们应该扩展的维度。如果它的y属性是0,我们就在x维度上扩展,反之,如果x属性是0,我们就在y维度上扩展。

然后我们进入一个嵌套循环。它的外层遍历所有需要扩展的坐标。我们以相反的顺序遍历它们,这样我们就不会将星系移动到我们尚未考虑的坐标上,这可能会导致星系移动超过应有的距离。

在进入内部循环之前,我们创建一个集合,new_galaxies,它将包含当前坐标被使用后的所有星系。其中一些新星系可能会移动,而另一些则可能不会。

内部循环结束后,我们将galaxies赋值为new_galaxies,然后移动到下一个坐标进行扩展。在这个过程中,所有星系都将被适当地移动。

就这样。我们现在只需要调用带有正确系数的求解器:

# day11.py
print(solve(universe, coeff=2)) 

这将给出第一部分的解决方案。

第二部分 – 问题陈述

与第一个问题一样,第二部分只是第一部分的微小变化。让我们看看问题陈述:

“现在,不再使用你之前所做的扩展,将每个空行或列扩大一百万倍。也就是说,每个空行应替换为 100 万个空行,每个空列应替换为 100 万个空列。

(在上面的例子中,如果每个空行或列只是 100 倍更大,每对星系之间最短路径的总和将是 8,410。然而,你的宇宙需要扩展到远超过这些值。)

从相同的初始图像开始,根据这些新规则扩展宇宙,然后找到每对星系之间最短路径的长度。这些长度的总和是多少?”

在 Advent of Code 中,通常在第二部分我们会意识到第一部分的实现是否足够好。在这种情况下,因为我们选择使用一组Galaxy对象来表示星系,而不是坚持使用字符串列表(或列表的列表),我们不需要在我们的代码中做任何改变,除了传递给solve()函数的扩展系数。

第二部分 – 解答

让我们看看我们是如何调用solve()来解决第二部分的:

# day11.py
print(solve(universe, coeff=int(1e6))) 

就这样。现在,我们传递 1,000,000,而不是 2,我们就能得到第二部分的正确结果。

关键的收获是,通过在第一部分选择合适的数据结构,我们可以通过任何系数扩展宇宙,而不会产生任何惩罚。

我们使用的技术是称为稀疏矩阵稀疏数组的概念的一个版本。你可以在en.wikipedia.org/wiki/Sparse_matrix上了解更多信息。

当处理矩阵形式的数据时,我们通常使用的数据结构是列表的列表(或者,在这个问题的情况下,是字符串的列表)。然而,有时矩阵大部分是空的,而重要的数据只是整个数据的一小部分。

在问题的宇宙中,例如,星系只是整个宇宙的一小部分,其余部分是空的。因此,用列表的列表(或字符串的列表)来表示它们是不合适的,我们选择不同的数据结构。在这里,我们选择了一组坐标,因为它足以保留我们所需的所有信息。

在其他情况下,可能需要一个字典。比如说,每个星系都有一个与之相关的亮度因子。使用字典,我们可以通过设置坐标作为键,亮度因子作为值来表示每个星系。主要观点仍然是相同的:我们只会存储星系数据,而不会存储如果我们使用列表的列表必须存储的空空间。

正如我们在前面的章节中提到的,选择合适的数据结构至关重要。在这个问题中,如果我们选择通过将宇宙存储在另一个字符串列表(或列表的列表)中创建一个扩展版本的宇宙,那么对于第一部分来说是可以的,但对于第二部分来说就不行了。

最终考虑

在结束本章之前,这里有一些最后的考虑。

首先,正如本章开头所提到的,我们提出的两个问题的解决方案并不声称是最优雅的,也不是最有效的。我们本可以编写更快的算法,在处理更大规模的数据输入时表现会更好。

此外,我们结构代码的方式,使用面向对象编程来处理骆驼卡和使用函数式方法来处理宇宙扩张,只是为了确保我们可以向您展示解决给定问题的不同代码结构方式。

此外,我们还可以选择其他方法将解决方案拆分为类和函数,我们还可以使用不同的数据结构来表示数据。

我们尽量优先考虑可读性和简洁性,同时仍然向您展示我们在本书其他部分未能探索的概念,例如使用自定义比较函数或稀疏矩阵。

我们希望您喜欢这次从专业 Python的短暂偏离,我们也希望激发您的一些好奇心。

我们的建议是注册 Advent of Code,并至少尝试为本章中的问题提出自己的解决方案。尝试在我们没有使用面向对象编程的地方使用它,反之亦然。尝试使用不同的算法和其他方式来组织数据。最重要的是,享受乐趣。解决编程挑战可以非常有趣,如果你像我们一样,可能会上瘾!

我们以一个编程挑战网站的列表结束本章,我们希望您会发现这些网站很有趣。

其他编程挑战网站

这里有一些我们最喜欢的挑战网站列表。其中一些是免费的,一些不是。有些是数学导向的,而有些则更专注于纯编程。有些有助于面试准备,而有些只是为了娱乐。

面试准备:

竞赛编程:

技能建设和学习:

乐趣与社区:

机器学习:

我们希望您能花些时间探索其中的一些内容;它们将帮助您保持头脑清醒,复习或学习算法、数据结构和新的编程语言。

摘要

在本章中,我们探索了编程挑战的世界。我们解决了 Advent of Code 网站上的两个问题,并了解了一个不同的宇宙,在那里编程被用于学习、娱乐、准备面试和比赛。

我们还学习了自定义比较函数和稀疏矩阵,并看到了我们之前章节中学到的某些概念如何应用于解决问题。

现在,我们的旅程即将结束。保持势头,充分利用这些页面上学到的知识,这取决于您。我们试图为您提供坚实的基础,这将足以支持您在知识和方法方面的下一步行动。

我们希望我们已经成功地传达了我们的热情和经验,并相信它将伴随您走向任何地方。

希望您喜欢阅读这本书,祝您好运!

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.com/invite/uaKmaz7FEC

img

img

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及行业领先的工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。

为什么订阅?

  • 使用来自 4,000 多位行业专业人士的实用电子书和视频,减少学习时间,增加编码时间

  • 通过为您量身定制的技能计划提高您的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速获取关键信息

  • 复制粘贴、打印和收藏内容

www.packt.com ,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 的其他书籍也感兴趣:

img[(https://www.packtpub.com/en-in/product/modern-python-cookbook-9781835460757)]

现代 Python 食谱

Steven F. Lott

ISBN: 9781835466384

  • 掌握核心 Python 数据结构、算法和设计模式

  • 实现面向对象的设计和函数式编程特性

  • 使用类型匹配和注解来编写更具表达性的程序

  • 使用 Matplotlib 和 Pyplot 创建有用的数据可视化

  • 有效管理项目依赖和虚拟环境

  • 遵循代码风格和测试的最佳实践

  • 为你的项目创建清晰和可信的文档

img[(https://www.packtpub.com/en-in/product/mastering-python-2e-9781800202108)]

精通 Python 2E

Rick Hattem

ISBN: 9781800207721

  • 编写优美的 Python 代码并避免常见的 Python 编码错误

  • 应用装饰器、生成器、协程和元类的力量

  • 使用不同的测试系统,如 pytest、unittest 和 doctest

  • 跟踪和优化应用程序的性能,包括内存和 CPU 使用

  • 使用 PDB、Werkzeug 和 faulthandler 调试你的应用程序

  • 通过 asyncio、多进程和分布式计算提高你的性能

  • 探索流行的库,如 Dask、NumPy、SciPy、pandas、TensorFlow 和 scikit-learn

  • 使用 C/C++库和系统调用扩展 Python 的功能

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并申请。我们已经与成千上万的开发者和科技专业人士合作,就像你一样,帮助他们将见解分享给全球科技社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了 Python 编程学习 第四版,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(20)  评论(0)    收藏  举报