现代-Python-秘籍第三版-全-
现代 Python 秘籍第三版(全)
原文:
zh.annas-archive.org/md5/6de5aad446152651bf30741f0cb614b7译者:飞龙
前言
Python 是开发者、工程师、数据科学家和爱好者首选的语言。它是一种强大的脚本语言,可以为您的应用程序提供速度、安全性和可扩展性。通过将 Python 作为一系列简单的菜谱展示,这本书可以帮助您在具体上下文中深入了解特定的语言特性。理念是避免对语言特性的抽象讨论,而专注于将语言应用于具体的数据和处理问题。
你需要这本书什么
要跟随本书中的示例,你只需要一台运行任何 Python(版本 3.12 或更高)的计算机。许多示例可以修改以适应 Python 3.12 之前的版本。第十章的材料描述了 Python 3.10 中引入的匹配语句。
我们强烈建议安装一个新的 Python 版本,避免使用预安装的操作系统 Python。语言运行时可以从 www.python.org/downloads/ 下载。另一种选择是使用 Miniconda 工具(docs.conda.io/en/latest/miniconda.html)并使用 conda 创建一个 Python 3.12(或更高版本)的环境。
Python 2 已无法使用。自 2020 年起,Python 2 已不再是替代品。
这本书面向谁
本书面向网络开发者、程序员、企业程序员、工程师和大数据科学家。如果您是初学者,这本书可以帮助您入门。如果您有经验,它将扩展您的知识库。基本编程知识将有所帮助;虽然涵盖了一些基础主题,但这不是编程或 Python 的教程。
这本书涵盖的内容
本书包含超过 130 个菜谱。我们可以将它们分解为四个一般领域:
-
Python 基础知识
第一章,数字、字符串和元组,将探讨不同类型的数字,如何处理字符串,如何使用元组,以及如何使用 Python 中的基本内置类型。我们还将展示如何利用 Unicode 字符集的全部功能。
第二章,语句和语法,将介绍创建脚本文件的一些基础知识。然后我们将转向查看一些复杂语句,包括 if、while、for、break、try、raise 和 with。
第三章,函数定义,将探讨多种函数定义技术。我们将用几个菜谱来介绍各种类型的类型提示。我们还将通过使用函数和主导入切换来讨论设计可测试脚本的一个要素。
第四章, 内置数据结构第一部分:列表和集合, 开始概述可用的内置数据结构及其解决的问题。这包括一系列展示列表和集合操作的食谱,包括列表和集合推导式。
第五章, 内置数据结构第二部分:字典, 继续探讨内置数据结构,详细考察字典。本章还将探讨一些与 Python 处理对象引用相关的更高级主题。它还展示了如何将可变对象作为函数参数的默认值处理。
第六章, 用户输入和输出,解释了如何使用 print()函数的不同功能。我们还将探讨用于提供用户输入的不同函数。f-string 用于调试以及 argparse 模块用于命令行输入的特点被介绍。
-
面向对象和函数式设计方法
第七章, 类和对象的基础,开始介绍面向对象编程。它展示了如何创建类以及与类定义相关的类型提示。本节从之前的版本扩展而来,以涵盖 dataclasses。它展示了如何扩展内置类,以及如何创建上下文管理器来管理资源。
第八章, 更高级的类设计,继续探索面向对象设计和编程。这包括对组合与继承问题的探讨,并展示了如何管理 Python 的“鸭子类型”原则。
第九章, 函数式编程特性,探讨了 Python 的函数式编程特性。这种编程风格强调函数定义和无状态、不可变对象。食谱探讨了生成器表达式,使用 map()、filter()和 reduce()函数。我们还探讨了创建部分函数的方法以及用不可变对象集合构建的数据结构替换有状态对象的一些示例。
-
更复杂的设计
第十章, 使用类型匹配和注解,更深入地探讨了类型提示和 match 语句。这包括使用 Pydantic 创建具有更严格运行时类型检查的类。它还探讨了注解类型的内省。
第十一章,输入/输出、物理格式和逻辑布局,将处理一般路径和文件。它将探讨在多种文件格式中读取和写入数据,包括 CSV、JSON(和 YAML)、XML 和 HTML。HTML 部分将强调使用 Beautiful Soup 提取数据。
第十二章,使用 Jupyter Lab 进行图形和可视化,将使用 Jupyter Lab 创建使用 Python 进行数据分析和可视化的笔记本。这将展示如何将数据导入笔记本以创建图表,以及如何使用 Markdown 从笔记本创建有用的文档和报告。
第十三章,应用程序集成:配置,将开始探讨我们可以设计更大应用程序的方法。本章的食谱将解决处理配置文件的不同方式以及如何管理日志。
第十四章,应用程序集成:组合,将继续探讨从更小的组件创建组合应用程序的方法。这将探讨面向对象的设计模式和命令行界面(CLI)应用程序。它还将探讨使用子进程模块在 Python 的控制下运行现有应用程序。
-
完成项目:完善和完成
第十五章,测试,提供了使用 Python 内置的 doctest 和 unittest 测试框架的食谱。此外,食谱还将涵盖 pytest 工具。
第十六章,依赖和虚拟环境,涵盖了用于管理虚拟环境的工具。内置的 venv、conda 和 poetry 都将被介绍。管理虚拟环境有众多解决方案,我们无法涵盖所有。
第十七章,文档和风格,涵盖了可以帮助创建高质量软件的额外工具。这特别关注 sphinx 创建全面、易读的文档。我们还将探讨 tox 来自动运行测试。
为了充分利用这本书
为了充分利用这本书,你可以按照以下说明下载示例代码文件和彩色图像。
下载示例代码文件
本书的相关代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-Python-Cookbook-Third-Edition。这个仓库也是开始讨论书中特定主题的最佳地方。如果您想与作者或其他读者交流,请随意提出问题。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835466384。
使用的约定
在这本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们可以通过使用 include 指令来包含其他上下文。”
代码块设置如下:
if distance is None:
distance = rate * time
elif rate is None:
rate = distance / time
elif time is None:
time = distance / rate
任何命令行输入或输出都如下所示:
>>> import math
>>> math.factorial(52)
80658175170943878571660636856403766975289505440883277824000000000000
新术语和重要词汇以粗体显示。
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请将反馈发送至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 与我们联系。
错误清单:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问packtpub.com/support/errata,选择您的书籍,点击错误提交表单链接,并输入详细信息。
侵权:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《现代 Python 食谱》第三版,我们很乐意听听您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面,分享您的反馈或在该购买网站上留下评论。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?
别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱。
按照以下简单步骤获取优惠:
-
扫描下面的二维码或访问以下链接
![PIC]()
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱
第一章:1
数字、字符串和元组
本章将探讨 Python 对象的一些核心类型。我们将探讨处理不同类型的数字、处理字符串和使用元组。这些是 Python 处理的最简单的数据类型。在后面的章节中,我们将探讨建立在这些基础上的数据结构。
虽然这些食谱从对 Python 3.12 的入门级理解开始,但它们也为熟悉该语言的人提供了一些更深入的知识背景。特别是,我们将探讨一些关于数字如何内部表示的细节,因为这在面对更高级的数值编程问题时可能会有所帮助。这将帮助我们区分丰富多样的数值类型的用例。
我们还将探讨两种不同的除法运算符。它们有特定的用例,我们将探讨一种需要截断除法的算法。
当处理字符串时,有几个常见的操作很重要。我们将探讨字节(在我们的操作系统文件中使用)与用于表示 Unicode 文本的字符串之间的差异。我们将看看如何利用 Unicode 字符集的全部力量。
在本章中,我们将像在交互式 Python 的 >>> 提示符下工作一样展示这些食谱。这是在命令行运行 python 或在许多集成开发环境(IDE)工具中的 Python 控制台中提供的提示符。这有时被称为读取-评估-打印循环(REPL)。在后面的章节中,我们将改变风格,使其看起来更像脚本文件。本章的一个目标是通过交互式探索来鼓励学习,因为这是一种学习语言的好方法。
我们将介绍这些食谱来介绍基本的 Python 数据类型:
-
在 float、decimal 和 fraction 之间进行选择
-
在真除法和地板除法之间进行选择
-
使用正则表达式解析字符串
-
使用 f-strings 构建复杂的字符串
-
从字符串列表构建复杂的字符串
-
使用键盘上没有的 Unicode 字符
-
编码字符串 – 创建 ASCII 和 UTF-8 字节
-
解码字节 – 如何从一些字节中获取正确的字符
-
使用项目元组
-
使用 NamedTuples 简化元组中的项目访问
我们将从数字开始,逐步过渡到字符串,最后以元组和 NamedTuple 对象的简单组合形式处理对象。
1.1 在 float、decimal 和 fraction 之间进行选择
Python 提供了多种处理有理数和无理数近似值的方法。我们有三种基本选择:
-
浮点数
-
小数
-
分数
当我们有选择时,有一些标准来做出选择是有帮助的。
1.1.1 准备工作
涉及到整数以外的数字的表达式有三个一般情况,它们是:
-
货币:美元、分、欧元等等。货币通常有固定的位数和小数点后舍入规则,以正确量化结果。
-
有理数或分数:当我们把为八人准备的食谱缩小到五人份时,例如,我们正在使用
的缩放因子进行分数数学。 -
浮点数:这包括所有其他类型的计算。这也包括无理数,如π、根提取和对数。
当我们遇到前两种情况之一时,我们应该避免使用浮点数。
1.1.2 如何操作...
我们将分别查看三个案例。
进行货币计算
当处理货币时,我们应该始终使用 decimal 模块。如果我们尝试使用 Python 内置的 float 类型的值,我们可能会遇到数字舍入和截断的问题:
-
要处理货币,从 decimal 模块导入 Decimal 类:
>>> from decimal import Decimal -
我们需要从字符串或整数创建 Decimal 对象。在这种情况下,我们想要 7.25%,即
。我们可以使用 Decimal 对象来计算这个值:>>> tax_rate = Decimal(’7.25’)/Decimal(100) >>> purchase_amount = Decimal(’2.95’) >>> tax_rate * purchase_amount Decimal(’0.213875’)我们也可以使用 Decimal('0.0725')而不是显式地进行除法。
-
要四舍五入到最近的便士,创建一个便士对象:
>>> penny = Decimal(’0.01’) -
使用便士对象量化结果:
>>> total_amount = purchase_amount + tax_rate * purchase_amount >>> total_amount.quantize(penny) Decimal(’3.16’)
这使用的是默认的 ROUND_HALF_EVEN 舍入规则。Decimal 模块提供了其他舍入变体。例如,我们可能做如下操作:
>>> import decimal
>>> total_amount.quantize(penny, decimal.ROUND_UP)
Decimal(’3.17’)
这显示了使用不同舍入规则的结果。
分数计算
当我们进行具有精确分数值的计算时,我们可以使用 fractions 模块来创建有理数。在这个例子中,我们想要将八人份的食谱缩小到五人份,使用
的每种成分。当食谱要求 2
杯大米时,这会缩小到多少?
要处理分数,我们将这样做:
-
从 fractions 模块导入 Fraction 类:
>>> from fractions import Fraction -
从字符串、整数或整数对创建 Fraction 对象。我们从一个字符串创建了一个分数,'2.5'。我们从一个浮点表达式创建第二个分数,5 / 8。这仅在分母是 2 的幂时才有效:
>>> sugar_cups = Fraction(’2.5’) >>> scale_factor = Fraction(5/8) >>> sugar_cups * scale_factor Fraction(25, 16)
我们可以看到,我们将使用几乎一升半的大米来调整八人份的食谱,而不是五人份。虽然浮点数对于有理分数通常很有用,但除非分母是 2 的幂,否则它们可能不精确。
浮点近似
Python 内置的 float 类型可以表示各种值。这里的权衡是浮点值通常是一个近似值。可能会有一个小的差异,揭示了 float 的实现与无理数的数学理想之间的差异:
-
为了与浮点数一起工作,我们通常需要四舍五入值以使其看起来合理。重要的是要认识到所有浮点数计算都是近似值:
>>> (19/155)*(155/19) 0.9999999999999999 -
从数学上讲,值应该是 1。由于使用了近似值,计算结果并不完全等于 1。我们可以使用 round(answer, 3)将其四舍五入到三位数字,创建一个更有用的值:
>>> answer = (19/155)*(155/19) >>> round(answer, 3) 1.0
近似值有非常重要的后果。
不要比较浮点数的精确相等性。
使用精确的等于(==)测试浮点数的代码,当两个近似值只相差一个比特时,可能会引起问题的潜在风险。
浮点数近似规则来自 IEEE,并不是 Python 的独特特性。许多编程语言都使用浮点数近似,并且具有相同的行为。
1.1.3 它是如何工作的...
对于这些数值类型,Python 提供了各种运算符:+、-、*、/、//、%和**。这些分别用于加法、减法、乘法、真除法、截断除法、取模和求幂。我们将探讨两个除法运算符/和//,在选择真除法和截断除法的食谱中。
Python 会在各种数值类型之间进行一些转换。我们可以混合使用整型(int)和浮点型(float)值;整数将被提升为浮点数以提供尽可能精确的答案。同样,我们也可以混合使用整型与分数(Fraction)以及整型与十进制(Decimal)。请注意,我们无法随意混合十进制与浮点数或分数;需要显式转换函数。
重要的是要注意,浮点数是近似值。Python 语法允许我们使用十进制数字来编写浮点值;然而,这并不是值在内部表示的方式。
我们可以在 Python 中这样写出值 8.066 × 10⁶⁷:
>>> 8.066e+67
8.066e+67
实际内部使用的值将涉及我们写入的十进制值的二进制近似。此示例的内部值如下:
>>> (6737037547376141/(2**53))*(2**226)
8.066e+67
分子是一个大数,6737037547376141。分母始终是 2 的 53 次方。这就是为什么值可能会被截断。
我们可以使用 math.frexp()函数来查看一个数字的内部细节:
>>> import math
>>> math.frexp(8.066E+67)
(0.7479614202861186, 226)
这两部分被称为尾数(或有效数字)和指数。如果我们把尾数乘以 2 的 53 次方,我们总是得到一个整数,这是二进制分数的分子。
与内置的浮点数不同,分数(Fraction)是两个整数值的精确比率。我们可以创建涉及具有非常大量数字的整数的比率。我们不受固定分母的限制。
类似地,十进制值基于一个非常大的整数值,以及一个缩放因子来确定小数点的位置。这些数字可以非常大,并且不会遭受奇特的表示问题。
1.1.4 更多...
Python 的 math 模块包含用于处理浮点值的一些专用函数。此模块包括常见的初等函数,如平方根、对数和各种三角函数。它还有一些其他函数,如伽玛、阶乘和高斯误差函数。
math 模块包含几个函数,可以帮助我们进行更精确的浮点计算。例如,math.fsum()函数将比内置的 sum()函数更仔细地计算浮点数之和。它对近似问题不太敏感。
我们还可以使用 math.isclose()函数来比较两个浮点值、一个表达式和一个字面量 1.0,以查看它们是否近似相等:
>>> (19/155)*(155/19) == 1.0
False
>>> math.isclose((19/155)*(155/19), 1.0)
True
此函数为我们提供了一种有意义地比较两个浮点数近似的手段。
Python 还提供了复数。复数有一个实部和虚部。在 Python 中,我们用 3.14+2.78j 来表示复数 3.14 + 2.78
。Python 可以轻松地在浮点数和复数之间进行转换。我们有一组常用的运算符可用于复数。
为了支持复数,有 cmath 包。例如,cmath.sqrt()函数在提取负数的平方根时将返回一个复数值,而不是抛出异常。以下是一个示例:
>>> math.sqrt(-2)
Traceback (most recent call last):
...
ValueError: math domain error
>>> import cmath
>>> cmath.sqrt(-2)
1.4142135623730951j
此模块在处理复数时很有帮助。
1.1.5 参见
-
我们将在选择真除法和截断除法菜谱中更多地讨论浮点数和分数。
1.2 选择真除法和截断除法
Python 为我们提供了两种除法运算符。它们是什么,我们如何知道该使用哪一个?我们还将探讨 Python 的除法规则以及它们如何应用于整数值。
1.2.1 准备工作
除法有几个通用情况:
-
一个除法-取模对:我们想要两个部分——商和余数。这个名字指的是将除法和取模操作组合在一起。我们可以将商和余数总结为 q,r = (⌊
⌋,a mod b)。我们经常在将一个基数的值转换为另一个基数时使用它。当我们把秒转换为小时、分钟和秒时,我们将进行一种除法-取模的运算。我们不需要确切的小时数;我们想要一个截断的小时数,余数将被转换为分钟和秒。
-
真实值:这是一个典型的浮点值;它将是商的一个很好的近似。例如,如果我们正在计算几个测量的平均值,我们通常期望结果是浮点数,即使输入值都是整数。
-
有理分数值:当在美国单位英尺、英寸和杯中工作时,这通常是必要的。为此,我们应该使用分数类。当我们除以分数对象时,我们总是得到精确的答案。
我们需要决定这些情况中的哪一个适用,以便我们知道要使用哪个除法运算符。
1.2.2 如何做...
我们将分别查看这三个案例。
执行截断除法
当我们进行除法-模运算时,我们可能会使用整除运算符//和取模运算符%。表达式 a % b 给出了 a // b 的整数除法的余数。或者,我们可能会使用内置的 divmod()函数同时计算这两个值:
-
我们将秒数除以 3,600 以得到小时值。使用%运算符计算的模数,或除法中的余数,可以单独转换为分钟和秒:
>>> total_seconds = 7385 >>> hours = total_seconds // 3600 >>> remaining_seconds = total_seconds % 3600 -
接下来,我们将秒数除以 60 以得到分钟数;余数是小于 60 的秒数:
>>> minutes = remaining_seconds // 60 >>> seconds = remaining_seconds % 60 >>> hours, minutes, seconds (2, 3, 5)
这里是另一种方法,使用 divmod()函数同时计算商和模数:
-
同时计算商和余数:
>>> total_seconds = 7385 >>> hours, remaining_seconds = divmod(total_seconds, 3600) -
再次计算商和余数:
>>> minutes, seconds = divmod(remaining_seconds, 60) >>> hours, minutes, seconds (2, 3, 5)
执行真正的除法
执行真正的除法计算给出一个浮点近似值作为结果。例如,7,385 秒大约是多少小时?这里使用真正的除法运算符:
>>> total_seconds = 7385
>>> hours = total_seconds / 3600
>>> round(hours, 4)
2.0514
我们提供了两个整数值,但得到了一个浮点精确结果。与我们的先前的配方一致,当使用浮点值时,我们将结果四舍五入以避免查看微小的误差数字。
有理分数计算
我们可以使用分数对象和整数进行除法。这迫使结果成为一个数学上精确的有理数:
-
至少创建一个分数值:
>>> from fractions import Fraction >>> total_seconds = Fraction(7385) -
在计算中使用分数值。任何整数都将提升为分数:
>>> hours = total_seconds / 3600 >>> hours Fraction(1477, 720)720 的分母似乎不太有意义。使用这种分数需要一点技巧来找到对人们有意义的分母。否则,转换为浮点值可能是有用的。
-
如果需要,将精确的分数转换为浮点近似值:
>>> round(float(hours), 4) 2.0514
首先,我们为总秒数创建了一个分数对象。当我们对分数进行算术运算时,Python 会将任何整数提升为分数对象;这种提升意味着数学运算尽可能精确。
1.2.3 它是如何工作的...
Python 有两个除法运算符:
-
/ 真除法运算符产生一个真正的浮点结果。即使两个操作数都是整数,它也会这样做。在这方面,这是一个不寻常的运算符。所有其他运算符都保留数据类型。当应用于整数时,真正的除法操作产生一个浮点结果。
-
// 截断除法运算符总是产生截断结果。对于两个整数操作数,这是截断商。当使用浮点操作数时,这是截断浮点结果:
>>> 7358.0 // 3600.0 2.0
1.2.4 参见
-
更多关于浮点数和分数之间选择的信息,请参阅选择浮点数、十进制和分数配方。
-
查看PEP-238。
1.3 使用正则表达式进行字符串解析
我们如何分解一个复杂的字符串?如果我们有复杂、棘手的标点符号怎么办?或者——更糟糕的是——如果我们没有标点符号,但必须依赖数字的模式来定位有意义的信息呢?
1.3.1 准备工作
分解复杂字符串的最简单方法是将字符串泛化为一个模式,然后编写一个描述该模式的正则表达式。
正则表达式可以描述的模式的数量是有限的。当我们面对像 HTML、XML 或 JSON 这样的深度嵌套文档时,我们经常会遇到问题,并被禁止使用正则表达式。
re 模块包含我们创建和使用正则表达式所需的所有各种类和函数。
假设我们想要分解来自食谱网站的文本。每一行看起来像这样:
>>> ingredient = "Kumquat: 2 cups"
我们希望将成分与测量值分开。
1.3.2 如何操作...
要编写和使用正则表达式,我们通常这样做:
-
将示例进行泛化。在我们的情况下,我们有一些可以泛化的东西:
(ingredient words): (amount digits) (unit words) -
我们将文本替换成了两部分总结:它的含义和它的表示方式。例如,成分用单词表示,而数量用数字表示。导入 re 模块:
>>> import re -
将模式重写为正则表达式(RE)表示法:
>>> pattern_text = r’([\w\s]+):\s+(\d+)\s+(\w+)’我们将成分词、字母和空格的混合表示法替换为[\w\s]+。我们将数量数字替换为\d+。我们将单个空格替换为\s+,以便可以使用一个或多个空格作为标点符号。我们保留了冒号,因为在正则表达式表示法中,冒号匹配自身。
对于数据字段的每个字段,我们使用()捕获匹配模式的匹配数据。我们没有捕获冒号或空格,因为我们不需要标点符号字符。
正则表达式通常使用很多\字符。为了在 Python 中使其工作得很好,我们几乎总是使用原始字符串。r’告诉 Python 不要查看\字符,也不要将它们替换成不在我们键盘上的特殊字符。
-
编译模式:
>>> pattern = re.compile(pattern_text) -
将模式与输入文本进行匹配。如果输入与模式匹配,我们将得到一个匹配对象,它显示了匹配的子字符串的详细信息:
>>> match = pattern.match(ingredient) >>> match is None False >>> match.groups() (’Kumquat’, ’2’, ’cups’) -
从匹配对象中提取命名的字符组:
>>> match.group(1) ’Kumquat’ >>> match.group(2) ’2’ >>> match.group(3) ’cups’
每个组通过正则表达式的捕获 () 部分的顺序来标识。这给我们一个从字符串中捕获的不同字段的元组。我们将在使用项目元组菜谱中返回到元组数据结构的使用。在更复杂的正则表达式中,这可能会令人困惑;有一种方法可以提供名称,而不是数字位置来标识捕获组。
1.3.3 它是如何工作的...
我们可以用正则表达式描述很多不同种类的字符串模式。
我们已经展示了多个字符类:
-
\w 匹配任何字母数字字符(a 到 z,A 到 Z,0 到 9)。
-
\d 匹配任何十进制数字。
-
\s 匹配任何空格或制表符字符。
这些类也有它们的逆:
-
\W 匹配任何非字母或数字字符。
-
\D 匹配任何非数字字符。
-
\S 匹配任何非空格或制表符字符。
许多字符匹配自身。然而,一些字符具有特殊含义,我们必须使用 \ 来转义这种特殊含义:
-
我们看到,作为后缀的 + 表示匹配前面的一个或多个模式。\d+ 匹配一个或多个数字。要匹配普通的 +,我们需要使用 +。
-
我们还有 * 作为后缀,它匹配前面的零个或多个模式。\w* 匹配零个或多个字符。要匹配一个 *,我们需要使用 *。
-
我们有 ? 作为后缀,它匹配前面的零个或一个表达式。这个字符在其他地方也被使用,但在其他上下文中具有不同的含义。我们将在 ?P
...)|, 其中它位于 \verb|)— 内部,用来定义分组特殊属性。 -
. 字符匹配任何单个字符。要特定地匹配一个 .,我们需要使用 ..
我们可以使用 [] 来创建我们自己的唯一字符集。我们可能有一些像这样的东西:
(?P<name>\w+)\s*[=:]\s*(?P<value>.*)
这有一个 \w+ 来匹配任意数量的字母数字字符。这将收集到一个名为 name 的组中。它使用 \s* 来匹配一个可选的空格序列。它匹配集合 [=:] 中的任何字符。这个集合中的两个字符中恰好有一个必须存在。它再次使用 \s* 来匹配一个可选的空格序列。最后,它使用 .* 来匹配字符串中的其他所有内容。这被收集到一个名为 value 的组中。
我们可以使用它来解析字符串,如下所示:
size = 12
weight: 14
通过对标点符号的灵活性,我们可以使程序更容易使用。我们将容忍任意数量的空格,以及 = 或 : 作为分隔符。
1.3.4 更多...
一个长的正则表达式可能难以阅读。我们有一个巧妙的 Pythonic 方法来以一种更容易阅读的方式呈现表达式:
>>> ingredient_pattern = re.compile(
... r’(?P<ingredient>[\w\s]+):\s+’ # name of the ingredient up to the ":"
... r’(?P<amount>\d+)\s+’ # amount, all digits up to a space
... r’(?P<unit>\w+)’ # units, alphanumeric characters
... )
这利用了三个语法规则:
-
一个语句只有在 () 字符匹配后才会完成。
-
相邻的字符串字面量会被静默地连接成一个单个的长字符串。
-
任何在 # 和行尾之间的内容都是一个注释,并且会被忽略。
我们在我们的正则表达式的重要子句后面添加了 Python 注释。这可以帮助我们理解我们做了什么,也许有助于我们以后诊断问题。
我们还可以使用正则表达式的“详细”模式在正则表达式字符串内添加多余的空白和注释。为此,我们必须在编译正则表达式时使用 re.X 作为选项,以便使空白和注释成为可能。这种修改后的语法看起来像这样:
>>> ingredient_pattern_x = re.compile(r’’’
... (?P<ingredient>[\w\s]+):\s+ # name of the ingredient up to the ":"
... (?P<amount>\d+)\s+ # amount, all digits up to a space
... (?P<unit>\w+) # units, alphanumeric characters
... ’’’, re.X)
我们可以将模式分解为单独的字符串组件,或者使用扩展语法使正则表达式更易于阅读。提供名称的好处在于,当我们使用 match 对象的 groupdict() 方法通过捕获模式的关联名称提取解析值时。
1.3.5 相关阅读
-
解码字节 – 如何从一些字节中获取正确的字符 的配方。
-
关于正则表达式和 Python 正则表达式的书籍有很多,比如《精通 Python 正则表达式》
www.packtpub.com/application-development/mastering-python-regular-expressions。
1.4 使用 f 字符串构建复杂的字符串
在许多方面,创建复杂的字符串与解析复杂的字符串正好相反。我们通常使用模板和替换规则来将数据放入更复杂的格式中。
1.4.1 准备工作
假设我们有一些需要转换为格式化消息的数据。我们可能有的数据包括以下内容:
>>> id = "IAD"
>>> location = "Dulles Intl Airport"
>>> max_temp = 32
>>> min_temp = 13
>>> precipitation = 0.4
我们希望得到一条看起来像这样的线:
IAD : Dulles Intl Airport : 32 / 13 / 0.40
1.4.2 如何做...
-
为结果创建一个 f 字符串,将所有数据项替换为占位符。在每个占位符内部,放置一个变量名(或一个表达式)。请注意,字符串使用 f' 前缀。这个前缀创建了一个复杂的字符串对象,其中值在字符串被使用时被插入到模板中:
f’{id} : {location} : {max_temp} / {min_temp} / {precipitation}’ -
对于每个名称或表达式,可以在模板字符串中的名称后附加一个可选的数据类型。基本数据类型代码如下:
-
s 用于字符串
-
d 用于十进制数字
-
f 用于浮点数
它看起来会是这样:
f’{id:s} : {location:s} : {max_temp:d} / {min_temp:d} / {precipitation:f}’由于这本书的边距很窄,字符串已经被断开。
适应页面。这是一行(非常宽)的代码。
-
-
在需要的地方添加长度信息。长度信息并不总是必需的,在某些情况下,甚至可能不希望有。然而,在这个例子中,长度信息确保了每条消息都有统一的格式。对于字符串和十进制数字,使用以下格式添加长度:19s 或 3d。对于浮点数,使用两部分的格式前缀,如 5.2f,以指定总长度为五个字符,其中小数点右边有两个字符。以下是整个格式:
>>> f’{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}’ ’IAD : Dulles Intl Airport : 32 / 13 / 0.40’
1.4.3 它是如何工作的...
F 字符串可以通过将数据插入模板来实现许多相对复杂的字符串组装。有几种转换可用。
我们已经看到了三种格式化转换——s、d、f,但还有很多其他的。详细信息可以在 Python 标准库的格式化字符串字面量部分找到:docs.python.org/3/reference/lexical_analysis.html#formatted-string-literals。
这里是一些我们可能使用的格式转换:
-
b 是用于二进制,基数 2。
-
c 是用于 Unicode 字符。值必须是一个数字,它将被转换成一个字符。通常,我们使用十六进制数字来表示这些字符,所以你可能想尝试 0x2661 到 0x2666 之间的值来查看有趣的 Unicode 符号。
-
d 是用于十进制数。
-
E 和 e 是用于科学记数法。6.626E-34 或 6.626e-34,具体取决于使用的 E 或 e 字符。
-
F 和 f 是用于浮点数。对于非数字,f 格式显示小写的 nan;F 格式显示大写的 NAN。
-
G 和 g 是用于通用。这会自动在 E 和 F(或 e 和 f)之间切换,以保持输出在给定的字段大小内。对于 20.5G 的格式,最多显示 20 位数字,使用 F 格式。更大的数字将使用 E 格式。
-
n 是用于特定地区的十进制数字。这将插入逗号或点字符,具体取决于当前的地区设置。默认地区可能没有定义 1,000 分隔符。更多信息,请参阅地区模块。
-
o 是用于八进制,基数 8。
-
s 是用于字符串。
-
X 和 x 是用于十六进制,基数 16。数字包括大写 A-F 和小写 a-f,具体取决于使用的 X 或 x 格式字符。
-
%是用于百分比。数字乘以 100,输出包括一个%字符。
我们可以使用多个前缀来表示这些不同类型。最常见的一个是长度。我们可以使用{name:5d}来插入一个 5 位数。对于前述类型,有几个前缀可以使用:
-
填充和对齐:我们可以指定一个特定的填充字符(默认为空格)和对齐方式。数字通常右对齐,字符串左对齐。我们可以使用<、>或^来改变这一点。这分别强制左对齐、右对齐或居中对齐。还有一个特殊的=对齐,用于在符号前填充。
-
符号:默认规则是在需要的地方有一个前导负号。我们可以使用+来给所有数字加上符号,-来只给负数加上符号,或者使用空格来用空格代替正数的加号。在科学输出中,我们经常使用{value:5.3f}。空格确保留出空间给符号,确保所有的小数点对齐得很好。
-
交替形式:我们可以使用#来获取交替形式。我们可能有一些像{0:#x}、{0:#o}或{0:#b}这样的格式,以在十六进制、八进制或二进制值上添加前缀。带有前缀的数字将看起来像 0xnnn、0onnn 或 0bnnn。默认情况下,省略两个字符的前缀。
-
领先零:我们可以包含 0 以在数字前面填充前导零。例如,{code:08x} 将生成一个填充到八位的十六进制值。
-
宽度和精度:对于整数值和字符串,我们只提供宽度。对于浮点值,我们通常提供宽度.精度。
有时候我们不会使用 {name:format} 说明符。有时,我们需要使用 {name!conversion} 说明符。只有三种转换可用:
-
{name!r} 显示由 repr(name) 生成的表示。
-
{name!s} 显示由 str(name) 生成的字符串值;这是如果不指定任何转换时的默认行为。使用 !s 明确地允许你添加字符串类型格式说明符。
-
{name!a} 显示由 ascii(name) 生成的 ASCII 值。
-
此外,还有一个方便的调试格式说明符可用。我们可以在变量或表达式后包含一个尾随等号,=,以获取一个方便的变量或表达式转储。以下示例使用了这两种形式:
>>> value = 2**12-1 >>> f’{value=} {2**7+1=}’ ’value=4095 2**7+1=129’
f-string 显示了名为 value 的变量的值以及表达式 2**7+1 的结果。
在第七章,我们将利用 {name!r} 格式说明符的想法来简化显示有关相关对象的信息。
1.4.4 更多...
f-string 处理依赖于字符串的 format() 方法。我们可以利用这个方法和相关的 format_map() 方法来处理更复杂的数据结构。
期待第五章,我们可能有一个字典,其键是符合 format_map() 规则的简单字符串:
>>> data = dict(
... id=id, location=location, max_temp=max_temp,
... min_temp=min_temp, precipitation=precipitation
... )
>>> ’{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}’.format_map(data)
’IAD : Dulles Intl Airport : 32 / 13 / 0.40’
我们创建了一个包含多个值的字典对象,data,其键是有效的 Python 标识符:id、location、max_temp、min_temp 和 precipitation。然后我们可以使用这个字典和 format_map() 方法来使用键从字典中提取值。
注意,这里的格式模板不是 f-string。它没有 f" 前缀。我们不是使用 f-string 的自动格式化功能,而是通过 f-string 的 format_map() 方法“硬编码”了插值。
1.4.5 参见
- 更多详细信息可以在 Python 标准库的格式化字符串字面量部分找到:
docs.python.org/3/reference/lexical_analysis.html#formatted-string-literals。
1.5 从字符串列表构建复杂的字符串
我们如何对不可变的字符串进行复杂更改?我们能从单个字符组装一个字符串吗?
在大多数情况下,我们之前看到的食谱为我们提供了创建和修改字符串的许多工具。我们还有更多处理字符串操作问题的方法。在这个食谱中,我们将探讨使用列表对象作为分解和重建字符串的方法。这将与第四章中的一些食谱相呼应。
1.5.1 准备工作
这是一个我们想要重新排列的字符串:
>>> title = "Recipe 5: Rewriting an Immutable String"
我们想要进行两种转换:
-
移除冒号前的部分。
-
用下划线替换标点符号,并将所有字符转换为小写。
我们将利用字符串模块:
>>> from string import whitespace, punctuation
这有两个重要的常数:
-
string.whitespace 列出了所有也是 ASCII 一部分的空白字符,包括空格和制表符。
-
string.punctuation 列出了也是 ASCII 一部分的标点符号。Unicode 有大量的标点符号。这是一个广泛使用的子集。
1.5.2 如何做...
我们可以处理分解成列表的字符串。我们将在第四章中更深入地探讨列表:
-
将字符串分解成列表对象:
>>> title_list = list(title) -
找到分隔字符。列表的 index()方法与字符串的 index()方法具有相同的语义。它定位给定值的索引位置:
>>> colon_position = title_list.index(’:’) -
删除不再需要的字符。del 语句可以从列表中删除项。与字符串不同,列表是可变的数据结构:
>>> del title_list[:colon_position+1] -
通过遍历每个位置来替换标点符号。在这种情况下,我们将使用一个 for 语句来访问字符串中的每个索引:
>>> for position in range(len(title_list)): ... if title_list[position] in whitespace+punctuation: ... title_list[position]= ’_’ -
表达式 range(len(title_list))生成介于 0 和 len(title_list)-1 之间的所有值。这确保了 position 的值将是列表中的每个索引值。将字符列表连接起来创建一个新的字符串。当将字符串连接在一起时,使用零长度字符串''作为分隔符看起来有点奇怪。然而,它工作得很好:
>>> title = ’’.join(title_list) >>> title ’_Rewriting_an_Immutable_String’
我们将生成的字符串重新赋值给原始变量。原始字符串对象,该变量曾引用它,不再需要:它将自动从内存中删除(这被称为垃圾回收)。新的字符串对象替换了变量的值。
1.5.3 它是如何工作的...
这是一个表示技巧的改变。由于字符串是不可变的,我们无法更新它。然而,我们可以将其转换为可变形式;在这种情况下,是一个列表。我们可以对可变列表对象进行所需的任何更改。当我们完成时,我们可以将表示从列表转换回字符串,并替换原始变量的值。
列表提供了一些字符串没有的特性。反过来,字符串也提供了一些列表没有的特性。例如,我们不能像转换字符串那样将列表转换为小写。
这里有一个重要的权衡:
-
字符串是不可变的,这使得它们非常快。字符串专注于 Unicode 字符。当我们查看映射和集合时,我们可以使用字符串作为映射的键和集合中的项,因为值是不可变的。
-
列表是可变的。操作较慢。列表可以持有任何类型的项。我们不能使用列表作为映射的键或集合中的项,因为列表值可能会改变。
字符串和列表都是特殊类型的序列。因此,它们有许多共同特性。基本的项索引和切片特性是共享的。同样,列表使用与字符串相同的负索引值:表达式 list[-1] 是列表对象中的最后一个项。
我们将在第四章返回可变数据结构。
1.5.4 参考内容
-
有时,我们需要构建一个字符串,然后将其转换为字节。请参阅编码字符串——如何创建 ASCII 和 UTF-8 字节配方,了解我们如何做到这一点。
-
有时,我们还需要将字节转换为字符串。请参阅解码字节——如何从一些字节中获取正确的字符配方以获取更多信息。
1.6 使用键盘上没有的 Unicode 字符
一个大键盘可能有近 100 个独立的按键。通常,其中不到 50 个键是字母、数字和标点符号。至少有十几个是功能键,它们执行的操作不仅仅是简单地插入字母到文档中。一些按键是不同类型的修饰符,它们旨在与另一个键一起使用——例如,我们可能有 Shift、Ctrl、Option 和 Command。
大多数操作系统将接受创建大约 100 个字符左右的简单键组合。更复杂的键组合可能创建另外大约 100 个不太流行的字符。这甚至不足以涵盖世界上所有字母表中的字符领域。而且,在我们的计算机字体中还有大量的图标、表情符号和装饰符号。我们如何访问所有这些符号呢?
1.6.1 准备工作
Python 使用 Unicode。有数千个独立的 Unicode 字符可用。
我们可以在 en.wikipedia.org/wiki/List_of_Unicode_characters 以及 www.unicode.org/charts/ 看到所有可用的字符。
我们需要 Unicode 字符编号。我们可能还需要 Unicode 字符名称。
我们计算机上的某个字体可能没有设计为为所有这些字符提供符号。特别是,Windows 计算机字体可能难以显示这些字符中的某些字符。有时需要使用以下 Windows 命令来切换到代码页 65001:
chcp 65001
Linux 和 macOS 很少出现 Unicode 字符问题。
1.6.2 如何操作...
Python 使用转义序列来扩展我们可以输入的普通字符,以覆盖 Unicode 字符的广阔空间。每个转义序列都以一个 \ 字符开始。下一个字符告诉我们需要创建哪个 Unicode 字符。找到所需的字符。获取名称或数字。数字总是以十六进制形式给出,基数为 16。描述 Unicode 的网站通常将字符写作 U+2680。名称可能是 DIE FACE-1。使用 \unnnn,其中 nnnn 是最多四位数的数字。或者,使用 \N{name},其中包含完整的名称。如果数字超过四位,使用 \Unnnnnnnn,将数字填充到正好八位:
>>> ’You Rolled \u2680’
’You Rolled ’
>>> ’You drew \U0001F000’
’You drew ’
>>> ’Discard \N{MAHJONG TILE RED DRAGON}’
’Discard ’
是的,我们可以在 Python 输出中包含各种字符。要在字符串中放置一个反斜杠(\)而不让后面的字符成为转义序列的一部分,我们需要使用\. 例如,我们可能需要这样做来表示 Windows 文件路径。
1.6.3 它是如何工作的...
Python 在内部使用 Unicode。我们可以直接使用键盘输入的 128 个左右字符都有方便的内部 Unicode 数字。
当我们写:
’HELLO’
Python 将其视为以下简写:
’\u0048\u0045\u004c\u004c\u004f’
一旦我们超出键盘上的字符,剩下的数千个字符仅通过它们的数字来识别。
当字符串被 Python 编译时,\uxxxx、\Uxxxxxxxx 和 \N{name} 都会被替换为正确的 Unicode 字符。如果我们有语法错误——例如,\N{name with no closing }——Python 的内部语法检查会立即报错。
正则表达式使用了很多 \ 字符,并且我们明确不希望 Python 的正常编译器触及它们;我们在正则表达式字符串上使用了 r’ 前缀,以防止 \ 被视为转义并可能被转换为其他内容。要使用 Unicode 字符的全域,我们无法避免使用 \ 作为转义。
如果我们需要在正则表达式中使用 Unicode,我们需要在正则表达式中到处使用 \。我们可能会看到类似这样的:’\w+[\u2680\u2681\u2682\u2683\u2684\u2685]\d+’。
我们不能在字符串上使用 r’ 前缀,因为我们需要处理 Unicode 转义。这迫使我们使用 \ 作为正则表达式的元素。我们使用了 \uxxxx 来表示模式中的 Unicode 字符。Python 的内部编译器会将 \uxxxx 替换为 Unicode 字符,而 \w 将在内部变为所需的 \w。
当我们在 >>> 提示符下查看字符串时,Python 会以规范形式显示字符串。Python 倾向于使用单引号(')作为分隔符,当字符串包含单引号时使用双引号(")。在编写代码时,我们可以使用单引号或双引号作为字符串分隔符。Python 通常不会显示原始字符串;相反,它会将所有必要的转义序列放回字符串中:
>>> r"\w+"
’\\w+’
我们提供了一个原始格式的字符串。Python 以规范形式显示了它。
1.6.4 参见
-
在编码字符串 – 创建 ASCII 和 UTF-8 字节和解码字节 – 如何从某些字节中获取正确的字符的菜谱中,我们将探讨 Unicode 字符是如何转换成字节序列的,这样我们就可以将它们写入文件。我们还将探讨文件中的字节(或从网站下载的)是如何转换成 Unicode 字符,以便进行处理。
-
如果你对历史感兴趣,你可以在这里了解 ASCII 和 EBCDIC 以及其他旧式字符编码:
www.unicode.org/charts/。
1.7 编码字符串 – 创建 ASCII 和 UTF-8 字节
我们的计算机文件是字节。当我们从互联网上传或下载时,通信是以字节为单位的。一个字节只有 256 个不同的值。我们的 Python 字符是 Unicode。Unicode 字符的数量远远超过 256 个。
我们如何将 Unicode 字符映射到字节以写入文件或进行传输?
1.7.1 准备工作
从历史上看,一个字符占用 1 个字节。Python 利用旧的 ASCII 编码方案来处理字节;这有时会导致字节和 Unicode 字符的文本字符串之间的混淆。
Unicode 字符被编码成一系列的字节。有许多标准化的编码和许多非标准化的编码。
此外,还有一些编码只适用于 Unicode 字符的一个小子集。我们尽量避免使用这些编码,但在某些情况下,我们可能需要使用子集编码方案。
除非我们有充分的理由不这样做,否则我们几乎总是使用 UTF-8 编码来表示 Unicode 字符。它的主要优点是它是对拉丁字母的紧凑表示,拉丁字母被用于英语和许多欧洲语言。
有时,一个互联网协议需要 ASCII 字符。这是一个需要特别注意的特殊情况,因为 ASCII 编码只能处理 Unicode 字符的一个小子集。
1.7.2 如何操作...
Python 通常使用我们操作系统的默认编码来处理文件和互联网流量。这些细节对每个操作系统都是独特的:
-
我们可以使用 PYTHONIOENCODING 环境变量来设置一个通用设置。我们将其设置在 Python 之外,以确保在所有地方都使用特定的编码。当使用 Linux 或 macOS 时,使用 shell 的 export 语句来设置环境变量。对于 Windows,使用 set 命令或 PowerShell 的 Set-Item 命令。对于 Linux,它看起来像这样:
(cookbook3) % export PYTHONIOENCODING=UTF-8 -
运行 Python:
(cookbook3) % python -
当我们在脚本内部打开文件时,我们有时需要做出特定的设置。我们将在第十一章中回到这个话题。以指定的编码打开文件。向文件中读取或写入 Unicode 字符:
>>> with open(’some_file.txt’, ’w’, encoding=’utf-8’) as output: ... print(’You drew \U0001F000’, file=output) >>> with open(’some_file.txt’, ’r’, encoding=’utf-8’) as input: ... text = input.read() >>> text ’You drew ’
在极少数需要以字节模式打开文件的情况下,我们也可以手动编码字符;如果我们使用 wb 模式,我们还需要对每个字符串进行手动编码:
>>> string_bytes = ’You drew \U0001F000’.encode(’utf-8’)
>>> string_bytes
b’You drew \xf0\x9f\x80\x80’
我们可以看到,一个字节序列 (\xf0\x9f\x80\x80) 被用来编码单个 Unicode 字符,U+1F000,
。
1.7.3 它是如何工作的...
Unicode 定义了多种编码方案。虽然 UTF-8 是最流行的,但也有 UTF-16 和 UTF-32。数字是每个字符的典型位数。一个包含 1,000 个字符的 UTF-32 编码文件将是 4,000 个 8 位字节。一个包含 1,000 个字符的 UTF-8 编码文件可能只有 1,000 个字节,具体取决于字符的确切组合。在 UTF-8 编码中,Unicode 编号高于 U+007F 的字符需要多个字节。
各种操作系统都有自己的编码方案。macOS 文件可以编码为 Mac Roman 或 Latin-1。Windows 文件可能使用 CP1252 编码。
所有这些方案的目的都是为了有一个可以映射到 Unicode 字符的序列的字节,以及反过来将每个 Unicode 字符映射到一个或多个字节的方法。理想情况下,所有 Unicode 字符都应该被考虑到。实际情况下,这些编码方案中的一些是不完整的。
ASCII 编码的历史形式只能表示大约 100 个 Unicode 字符作为字节。很容易创建一个无法使用 ASCII 方案编码的字符串。
这就是错误的样子:
>>> ’You drew \U0001F000’.encode(’ascii’)
Traceback (most recent call last):
...
UnicodeEncodeError: ’ascii’ codec can’t encode character ’\U0001f000’ in position 9: ordinal not in range(128
当我们意外地以不是广泛使用的 UTF-8 标准的编码打开文件时,我们可能会看到这种错误。当我们看到这种错误时,我们需要更改我们的处理过程,以选择创建文件实际使用的编码。猜测使用了哪种编码几乎是不可能的,因此可能需要进行一些研究以定位关于文件编码的元数据。
字节通常使用可打印字符来显示。我们将看到 b’hello’ 作为五个字节的值的缩写。字母是使用旧的 ASCII 编码方案选择的,其中从 0x20 到 0x7F 的字节值将显示为字符,而在此范围之外,将使用更复杂的转义序列。
这种用字符表示字节值的方法可能会令人困惑。b’ 前缀是我们的提示,表明我们正在查看字节,而不是正确的 Unicode 字符。
1.7.4 参见
-
有多种构建数据字符串的方法。请参阅 使用 f-strings 构建复杂的字符串 和 从字符串列表构建复杂的字符串 的配方,以了解创建复杂字符串的示例。其思想是,我们可能有一个构建复杂字符串的应用程序,然后将其编码为字节。
-
有关 UTF-8 编码的更多信息,请参阅
en.wikipedia.org/wiki/UTF-8。 -
有关 Unicode 编码的一般信息,请参阅
unicode.org/faq/utf_bom.html。
1.8 解码字节 - 如何从一些字节中获取正确的字符
我们如何处理未正确编码的文件?对于使用 ASCII 编码编写的文件,我们该怎么办?
从互联网下载的内容几乎总是以字节形式存在——不是字符。我们如何从这串字节流中解码字符?
此外,当我们使用 subprocess 模块时,操作系统命令的结果是以字节形式出现的。我们如何恢复正确的字符?
这其中大部分内容也与第十一章的材料相关。我们在这里包含这个配方,因为它与之前的配方相反,编码字符串——创建 ASCII 和 UTF-8 字节。
1.8.1 准备工作
假设我们对离岸海洋天气预报感兴趣。这可能是因为我们即将离开切萨皮克湾前往加勒比海。
弗吉尼亚州韦克菲尔德的国家气象服务机构是否有任何特殊的警告?
这是链接:forecast.weather.gov/product.php?site=AKQ&product=SMW&issuedby=AKQ。
我们可以使用 Python 的 urllib 模块下载它:
>>> import urllib.request
>>> warnings_uri = (
... ’https://forecast.weather.gov/’
... ’product.php?site=AKQ&product=SMW&issuedby=AKQ’
... )
>>> with urllib.request.urlopen(warnings_uri) as source:
... forecast_text = source.read()
注意,我们已经将 URI 字符串放在括号中,并将其分成两个单独的字符串字面量。Python 会将这两个相邻的字面量连接成一个单一的字符串。我们将在第二章中深入探讨这一点。
作为替代,我们可以使用 curl 或 wget 等程序来获取这些信息。在操作系统终端提示符下,我们可能会运行以下(长)命令:
(cookbook3) % curl ’https://forecast.weather.gov/product.php?site=AKQ&product=SMW&issuedby=AKQ’ -o AKQ.html
排版这本书时,往往会将命令拆分成多行。实际上,它是一行非常长的命令。
代码库包括一个示例文件,ch01/Text Products for SMW Issued by AKQ.html。
forecast_text 值是一串字节。它不是一个正确的字符串。我们可以通过它像这样开始来判断:
>>> forecast_text[:80]
b’<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/x’
数据持续了一段时间,从网页上提供了详细信息。因为显示的值以 b'开头,所以它是字节,而不是正确的 Unicode 字符。它可能被 UTF-8 编码,这意味着一些字符可能用奇特的\xnn 转义序列代替了正确的字符。我们想要的是正确的字符。
虽然这些数据有许多易于阅读的字符,但 b'前缀表明它是一组字节值,而不是正确的文本。一般来说,字节对象的行为类似于字符串对象。有时,我们可以直接处理字节。大多数时候,我们想要解码字节并从中创建正确的 Unicode 字符。
1.8.2 如何操作...
-
如果可能,确定编码方案。为了将字节解码成正确的 Unicode 字符,我们需要知道使用了哪种编码方案。当我们读取 XML 文档时,文档内部提供了很大的提示:
<?xml version="1.0" encoding="UTF-8"?>在浏览网页时,通常会有一个包含此信息的头部:
Content-Type: text/html; charset=ISO-8859-4有时,一个 HTML 页面可能将此作为头部的一部分包含:
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">在其他情况下,我们只能猜测。在处理美国天气数据的情况下,一个好的初步猜测是 UTF-8。另一个好的猜测是 ISO-8859-1。在某些情况下,猜测将取决于语言。
-
codecs — Python 标准库中的编解码器注册表和基类部分列出了可用的标准编码。解码数据:
>>> document = forecast_text.decode("UTF-8") >>> document[:80] ’<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/x’b'前缀不再用于表示这些是字节。我们已经从字节流中创建了一个正确的 Unicode 字符字符串。
-
如果这一步因异常而失败,我们可能猜错了编码。我们需要尝试另一种编码来解析生成的文档。
由于这是一个 HTML 文档,我们应该使用 Beautiful Soup 来提取数据。请参阅www.crummy.com/software/BeautifulSoup/。
然而,我们可以在不完整解析 HTML 的情况下从这个文档中提取一条信息:
>>> import re
>>> content_pattern = re.compile(r"// CONTENT STARTS(.*?)// CONTENT ENDS", re.MULTILINE | re.DOTALL)
>>> content_pattern.search(document)
<re.Match object; span=(8530, 9113), match=’// CONTENT STARTS HERE -->\n\n<span style="font-s>
这告诉我们我们需要知道的信息:目前没有警告。这并不意味着一切顺利,但这确实意味着没有可能引发灾难的主要天气系统。
1.8.3 它是如何工作的...
请参阅编码字符串 – 创建 ASCII 和 UTF-8 字节配方,了解更多关于 Unicode 及其不同方式的信息,这些方式可以将 Unicode 字符编码成字节流。
在操作系统的底层,文件和网络连接是由字节构建的。是我们的软件解码字节以发现内容。可能是字符、图像或声音。在某些情况下,默认的假设是错误的,我们需要进行自己的解码。
1.8.4 参考信息
-
一旦我们恢复了字符串数据,我们就有多种方式来解析或重写它。请参阅使用正则表达式进行字符串解析配方中的示例,了解如何解析复杂字符串。
-
更多关于编码的信息,请参阅
en.wikipedia.org/wiki/UTF-8和unicode.org/faq/utf_bom.html。
1.9 使用项目元组
最好的方式来表示简单的(x,y)和(r,g,b)值组是什么?我们如何将成对的值,例如纬度和经度,放在一起?
1.9.1 准备工作
在使用正则表达式进行字符串解析配方中,我们跳过了一个有趣的数据结构。
我们有如下看起来这样的数据:
>>> ingredient = "Kumquat: 2 cups"
我们使用正则表达式解析了这些有意义的数据,如下所示:
>>> import re
>>> ingredient_pattern = re.compile(r’(?P<ingredient>\w+):\s+(?P<amount>\d+)\s+(?P<unit>\w+)’)
>>> match = ingredient_pattern.match(ingredient)
>>> match.groups()
(’Kumquat’, ’2’, ’cups’)
结果是一个包含三份数据的元组对象。这种分组数据在很多地方都能派上用场。
1.9.2 如何操作...
我们将从这个方面探讨两个问题:将事物放入元组和从元组中取出事物。
创建元组
Python 在很多地方都会为我们创建数据元组。在使用正则表达式进行字符串解析配方的准备工作部分,我们向您展示了正则表达式匹配对象将如何从字符串中解析出文本元组。
我们也可以创建自己的元组。以下是步骤:
-
将数据括在括号()内。
-
使用逗号分隔项目。
>>> from fractions import Fraction >>> my_data = (’Rice’, Fraction(1/4), ’cups’)
对于单元素元组,有一个重要的特殊情况。即使元组中只有一个项目,我们也必须包含逗号:
>>> one_tuple = (’item’, )
>>> len(one_tuple)
1
括号字符并不总是必需的。有些时候我们可以省略它们。省略它们并不是一个好主意。
是逗号创建了值的元组。这意味着当我们有一个额外的逗号时,我们可能会看到一些奇怪的事情:
>>> 355,
(355,)
355 后面的逗号将值转换成单元素元组。
我们还可以通过从另一个序列转换来创建元组。例如,tuple([355])从一个单元素列表创建一个单元素元组。
从元组中提取项目
元组的概念是成为一个容器,其项目数量由问题域固定:例如,对于(red, green, blue)颜色编号,项目数量始终是三个。
在我们的例子中,我们有一个成分,数量和单位。这必须是一个三项集合。我们可以以两种方式查看单个项目:
-
通过索引位置;也就是说,位置从左到右开始编号,从零开始:
>>> my_data[1] Fraction(1, 4) -
使用多重赋值:
>>> ingredient, amount, unit = my_data >>> ingredient ’Rice’ >>> unit ’cups’
元组——就像字符串一样——是不可变的。我们无法更改元组内部的单个项目。当我们想要将数据放在一起时,我们会使用元组。
1.9.3 它是如何工作的...
元组是更一般序列类的一个例子。我们可以对序列做几件事情。
这里有一个我们可以操作的示例元组:
>>> t = (’Kumquat’, ’2’, ’cups’)
这里有一些我们可以对这个元组执行的操作:
-
t 中有多少个项目?
>>> len(t) 3 -
特定值在 t 中出现了多少次?
>>> t.count(’2’) 1 -
哪个位置有特定值?
>>> t.index(’cups’) 2 >>> t[2] ’cups’ -
当一个项目不存在时,我们会得到一个异常:
>>> t.index(’Rice’) Traceback (most recent call last): ... ValueError: tuple.index(x): x not in tuple -
特定值是否存在?
>>> ’Rice’ in t False
1.9.4 更多...
元组,就像字符串一样,是一系列项目的序列。在字符串的情况下,它是一系列字符的序列。在元组的情况下,它是一系列许多事物的序列。因为它们都是序列,所以它们有一些共同的特征。我们已经注意到,我们可以通过索引位置提取单个项目。我们可以使用 index()方法来定位项目的位置。
相似之处到此为止。字符串有许多方法可以用来创建一个新的字符串,这个新字符串是原始字符串的转换,还有解析字符串的方法,以及确定字符串内容的方法。元组没有这些附加功能。它——可能是——最简单的可能的数据结构。
1.9.5 参见
-
我们在从字符串列表构建复杂的字符串配方中查看了一个其他序列,列表。
-
我们还会在第四章中查看序列。
1.10 使用 NamedTuples 简化元组中的项目访问
当我们处理元组时,我们必须记住位置作为数字。当我们使用(r,g,b)元组来表示颜色时,我们可以用"red"代替零,用"green"代替 1,用"blue"代替 2 吗?
1.10.1 准备工作
让我们继续查看食谱中的项目。解析字符串的正则表达式有三个属性:成分、数量和单位。我们使用了以下模式,为各种子字符串命名:
r’(?P<ingredient>\w+):\s+(?P<amount>\d+)\s+(?P<unit>\w+)’)
生成的数据元组看起来是这样的:
>>> item = match.groups()
>>> item
(’Kumquat’, ’2’, ’cups’)
虽然成分、数量和单位的匹配相当清晰,但使用以下方式并不理想。1 代表什么?它真的是数量吗?
>>> from fractions import Fraction
>>> Fraction(item[1])
Fraction(2, 1)
我们希望定义带有名称和位置的元组。
1.10.2 如何做到...
-
我们将使用来自 typing 包的 NamedTuple 类定义:
>>> from typing import NamedTuple -
使用这个基类定义,我们可以定义我们自己的独特元组,为项目命名:
>>> class Ingredient(NamedTuple): ... ingredient: str ... amount: str ... unit: str -
现在,我们可以通过使用类名来创建这种独特类型的元组实例:
>>> item_2 = Ingredient(’Kumquat’, ’2’, ’cups’) -
当我们想要从元组中获取一个值时,我们可以使用名称而不是位置:
>>> Fraction(item_2.amount) Fraction(2, 1) >>> f"Use {item_2.amount} {item_2.unit} fresh {item_2.ingredient}" ’Use 2 cups fresh Kumquat’
1.10.3 它是如何工作的...
NamedTuple 类定义引入了第七章的核心概念。我们扩展了基类定义,为我们的应用程序添加了独特功能。在这种情况下,我们为每个成分元组必须包含的三个属性命名。
由于 NamedTuple 类的子类是元组,属性名称的顺序是固定的。我们可以使用类似 item_2[0]的表达式以及 item_2.ingredient 这样的表达式。这两个名称都指的是元组中索引为 0 的项目,item_2。
核心元组类型可以称为“匿名元组”或“仅索引元组”。这有助于将它们与通过 typing 模块引入的更复杂的“命名元组”区分开来。
元组作为紧密相关数据的微型容器非常有用。使用 NamedTuple 类定义使它们更容易处理。
1.10.4 更多内容...
我们可以在元组或命名元组中混合使用各种值。在我们构建元组之前,我们需要进行转换。重要的是要记住,元组永远不能被更改。它是一个不可变对象,在许多方面与字符串和数字的不可变性相似。
例如,我们可能想要处理精确分数的金额。这里有一个更复杂的定义:
>>> from typing import NamedTuple
>>> from fractions import Fraction
>>> class IngredientF(NamedTuple):
... ingredient: str
... amount: Fraction
... unit: str
这些对象在创建时需要一些小心。如果我们使用一大堆字符串,我们不能简单地从三个字符串值构建这个对象;我们需要将数量转换为 Fraction 实例。以下是一个使用 Fraction 转换创建项目的示例:
>>> item_3 = IngredientF(’Kumquat’, Fraction(’2’), ’cups’)
这个元组对于每种成分的数量来说更有用。我们现在可以对数量进行数学运算:
>>> f’{item_3.ingredient} doubled: {item_3.amount * 2}’
’Kumquat doubled: 4’
在 NamedTuple 类定义中明确声明数据类型非常方便。实际上,Python 并不直接使用类型信息。例如,mypy 这样的工具可以检查 NamedTuple 中的类型提示与代码中其他操作的一致性。
1.10.5 参见
- 我们将在第七章中查看类定义。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第二章:2
语句和语法
Python 语法被设计得非常简单。在本章中,我们将通过查看语言中最常用的语句来了解规则。具体的例子可以帮助阐明语言的语法。
我们将首先介绍创建脚本文件的一些基础知识。然后我们将转向查看一些更常用的语句。Python 在语言中只有大约 20 种不同的命令式语句。我们已经在第一章(ch005_split_000.xhtml#x1-170001)中看到了两种语句,即赋值语句和表达式语句。
当我们编写如下内容时:
>>> print("hello world")
hello world
我们实际上执行的是一个只包含函数评估的语句,即 print()。这种类型的语句——评估一个函数或对象的某个方法——是很常见的。
我们已经看到的其他类型的语句是赋值语句。Python 在这方面有很多变体。大多数时候,我们都是将单个值赋给单个变量。当一个函数返回一个元组作为结果时,我们可以解包这个集合,并在单个赋值语句中同时赋值给多个变量。这样做如下:
>>> quotient, remainder = divmod(355, 113)
本章中的食谱将探讨 if、while、for、with 和 try 语句。我们还将简要介绍一些更简单的语句,如 pass、break 和 raise。
在后面的章节中,我们将探讨其他语句。以下是一个总结:
|
|
|
| 语句 | 章节 |
|---|
|
|
|
| def | 第三章(ch007_split_000.xhtml#x1-1610003) |
|---|---|
| return | 第三章(ch007_split_000.xhtml#x1-1610003) |
| import | 第三章(ch007_split_000.xhtml#x1-1610003) |
| del | 第四章(ch008_split_000.xhtml#x1-2240004) |
| class | 第七章(ch011_split_000.xhtml#x1-3760007) |
| match | 第八章(ch012.xhtml#x1-4520008) |
| type | 第十章(ch014.xhtml#x1-57300010) |
| assert | 第十章(ch014.xhtml#x1-57300010) |
|
|
|
表 2.1:Python 语句和章节
在本章中,我们将探讨以下内容:
-
编写 Python 脚本和模块文件——语法基础
-
编写长行代码
-
包括描述和文档
-
使用 RST 标记编写更好的 docstrings
-
设计复杂的 if...elif 链
-
使用“walrus”运算符保存中间结果
-
避免 break 语句的潜在问题
-
利用异常匹配规则
-
避免 except 子句的潜在问题
-
隐藏异常的根本原因
-
使用 with 语句管理上下文
我们将从宏观的角度开始,即脚本和模块,然后我们将深入到单个语句的细节。
2.1 编写 Python 脚本和模块文件 – 语法基础
Python(以及一般编程)的目的是创建涉及数据和处理的自动化解决方案。此外,我们编写的软件是一种知识表示;这意味着清晰性可能是软件最重要的质量方面。
在 Python 中,我们通过创建脚本文件来实现自动化解决方案。这些是 Python 编程的顶级、主要程序。除了主脚本外,我们还可以创建模块(以及模块的包)来帮助将软件组织成智力上可管理的块。脚本是一个模块;然而,它有一个明确的意图,当由操作系统启动时执行有用的处理。
创建清晰、可读的 Python 文件的关键部分是确保我们的代码遵循广泛采用的约定。
例如,我们需要确保将文件以 UTF-8 编码保存。虽然 Python 仍然支持 ASCII 编码,但它对于现代编程来说是一个较差的选择。我们还需要确保我们的编辑器使用空格而不是制表符。这通常是编程编辑器中的一个配置设置。使用 Unix 换行符也有助于可移植性。
2.1.1 准备工作
为了编辑 Python 脚本,我们需要一个好的编程编辑器。几乎不可能只推荐一个。所以我们将推荐几个。
JetBrains PyCharm 编辑器具有许多功能。社区版是免费的。请参阅www.jetbrains.com/pycharm/download/。
ActiveState 的 Komodo IDE 非常复杂。Komodo Edit 版本是免费的,并且与完整的 Komodo IDE 做了一些相同的事情。请参阅komodoide.com/komodo-edit/。
Notepad++非常适合 Windows 开发者。请参阅notepad-plus-plus.org。
BBEdit 非常适合 macOS X 开发者。请参阅www.barebones.com/products/bbedit/。Sublime 在 macOS X 上也很受欢迎。请参阅www.sublimetext.com。
对于 Linux 开发者,有几个内置的编辑器,包括 Vim 和 gedit。由于 Linux 倾向于偏向开发者,因此可用的编辑器都适合编写 Python。
在工作时有两个窗口打开是有帮助的:
-
一个编辑器来创建最终的脚本或模块文件。
-
一个带有 Python 的>>>提示符的终端会话,我们可以尝试一些事情来看看哪些可行,哪些不可行。
大多数编辑器都识别.py 扩展名,并根据PEP-8提供适当的格式化。这通常包括以下内容:
-
文件编码应该是 UTF-8。
-
缩进应该是四个空格。
-
我们希望键盘上的 Tab 键插入空格而不是制表符,\t。
一旦配置了编辑器,我们就可以编写一个其他人可以轻松使用或扩展的脚本文件。
2.1.2 如何做到...
这是创建脚本文件的方法:
-
大多数 Python 脚本文件的第一行看起来像这样:
#!/usr/bin/env python3这在您正在编写的文件和 Python 之间设置了一个关联。如果文件的权限设置为可执行,并且目录在 OS PATH 列表中,那么脚本将是一个一等应用,就像任何内置命令一样可用。
对于 Windows,文件名到程序的关联是通过默认程序控制面板中的一个设置完成的。找到设置关联的面板,并确保 .py 文件绑定到 Python 程序。这通常由安装程序设置,我们很少需要更改它或手动设置它。
-
在前言之后,惯例建议我们包括一个三引号文本块。这是我们即将创建的文件的文档字符串(称为 docstring):
""" A summary of this script. """由于 Python 的三引号字符串可以无限长,请随意写尽可能多的内容。这应该是描述脚本或库模块的主要工具。这甚至可以包括如何工作的示例。
-
现在是脚本的有趣部分:真正做事情的这部分。我们可以编写完成工作所需的所有语句。目前,我们将使用它作为占位符:
print(’hello world’)这并不多,但至少脚本做了些事情。创建函数和类定义,以及编写使用函数和类做事情的语句是很常见的。
对于我们的第一个简单脚本,所有的语句都必须从左边界开始,并且必须在单行内完成。Python 中有许多语句包含嵌套的语句块。这些内部语句块将被缩进来明确它们的范围。通常——因为我们把缩进设置为四个空格——我们可以按 Tab 键来正确地缩进代码。
我们文件应该看起来像这样:
#!/usr/bin/env python3
"""
My First Script: Calculate an important value.
"""
print(355 / 113)
2.1.3 它是如何工作的...
与其他语言不同,Python 中几乎没有样板代码。只有一行开销,甚至 #!/usr/bin/env python3 行通常是可选的。
为什么我们要将编码设置为 UTF-8?虽然该语言最初是为了仅使用原始的 128 个 ASCII 字符而设计的,但我们经常发现 ASCII 是有限的。如果我们以 UTF-8 格式保存文件,这是合法的 Python:
= 355/113
print()
在 Python 中选择空格和制表符时保持一致性很重要。它们都是或多或少不可见的,混合使用它们很容易在尝试运行脚本时出错。建议使用空格。
初始的#!行是一个注释。因为这两个字符有时被称为 sharp 和 bang,所以组合被称为“shebang”。#和行尾之间的所有内容都被忽略。Linux 加载器(一个名为 execve 的程序)查看文件的第一个几个字节以确定文件内容。这些第一个几个字节有时被称为 magic bytes,因为加载器的行为看起来很神奇。当存在时,这个由#!组成的两字符序列后面跟着处理文件中其余数据的程序的路径。我们更喜欢使用/usr/bin/env 为我们启动 Python 程序。我们可以利用 env 程序来设置 Python 特定的环境变量。
2.1.4 更多内容...
Python 标准库文档部分来源于模块文件中存在的文档字符串。在模块、包和脚本中编写复杂的 docstrings 是常见做法。有像 pydoc 和 Sphinx 这样的工具可以将模块 docstrings 重新格式化为优雅的文档。我们将在使用 RST 标记编写更好的 docstrings 的菜谱中查看这一点,以及第十七章中的使用 Sphinx autodoc 创建 API 参考的菜谱。
此外,单元测试用例可以包含在 docstrings 中。像 doctest 这样的工具可以从文档字符串中提取示例并执行代码,以查看文档中的答案是否与运行代码找到的答案匹配。这是第十五章中许多菜谱的主题。本书中的许多示例都经过 doctest 验证。
三引号文档字符串比#注释更受欢迎。虽然#和行尾之间的所有文本都被忽略,但这仅限于单行;传统做法是尽量少用。docstring 可以是不定长度的;它们被广泛使用。
有时还会包含一些额外的开销。Vim 和 gedit 编辑器允许我们在文件中保留编辑偏好。这被称为 modeline。以下是一个典型的 modeline,对 Python 很有用:
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
这确保任何制表符字符都将转换为八个空格;当我们按下 Tab 键时,我们将移动四个空格。这被广泛使用,因为传统上制表符字符缩进八个空格,这种替换很可能创建正确的缩进。这个设置嵌入在代码中;我们不需要对 Vim 进行任何设置来将这些设置应用到我们的 Python 脚本文件中。
2.1.5 参考阅读
-
我们将在包括描述和文档和使用 RST 标记编写更好的 docstrings 的菜谱中查看如何编写有用的文档字符串。
-
关于建议的样式更多信息,请参阅PEP-8。
2.2 编写长行代码
有很多次我们需要编写非常长的代码行,以至于很难阅读。很多人喜欢将代码行的长度限制在 80 个字符或更少。图形设计的一个众所周知的原则是,较窄的文本区域更容易阅读。有关行宽和可读性的更深入讨论,请参阅 webtypography.net/2.1.2。
尽管每行字符较少对眼睛更友好,但我们的代码可能会拒绝遵守这个原则。我们如何将长 Python 语句拆分成更易管理的部分?
2.2.1 准备工作
假设我们有一些像这样的事情:
>>> import math
>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))
>>> mantissa_fraction, exponent = math.frexp(example_value)
>>> mantissa_whole = int(mantissa_fraction*2**53)
>>> message_text = f’the internal representation is {mantissa_whole:d}/2**53*2**{exponent:d}’
>>> print(message_text)
the internal representation is 7074237752514592/2**53*2**2
这段代码包含一个长公式和一个长格式字符串,我们将值注入其中。当在书中排版时,这看起来很糟糕;f-string 行可能被错误地断行。当尝试编辑此脚本时,它可能看起来很糟糕。(有关 f-string 的更多信息,请参阅第一章的 构建复杂的字符串与 f-string)。
我们不能随意将 Python 语句拆分成块。语法规则明确指出,一个语句必须在单个逻辑行上完整。
“逻辑行”这个术语为我们提供了如何继续进行的线索。Python 在逻辑行和物理行之间做出了区分;我们将利用这些语法规则来拆分长语句。
2.2.2 如何操作...
Python 给我们提供了几种方法来包装长语句,使它们更易读:
-
我们可以在一行的末尾使用 \ 来将逻辑行延续到下一物理行。虽然这总是可行的,但有时候很难找到 \。
-
Python 有一个规则,即一个语句可以跨越多个逻辑行,因为括号、方括号和花括号必须平衡。此外,我们还可以利用 Python 自动连接相邻字符串字面量以形成一个较长的字符串字面量的方式:("a" "b") 与 "ab" 相同。
-
在某些情况下,我们可以通过将中间结果分配给不同的变量来将一个语句分解成多个语句。
我们将在这份食谱的单独部分逐一查看这些内容。
使用反斜杠将长语句拆分为逻辑行
-
如果有有意义的断行,插入 \ 来分隔语句:
>>> message_text = f’the internal representation is \ ... {mantissa_whole:d}/2**53*2**{exponent:d}’
为了使这可行,\ 必须是行的最后一个字符。反斜杠后面的额外空格很难看到;需要一些小心。PEP-8 www.python.org/dev/peps/pep-0008/ 建议提供了格式化指南,并倾向于不鼓励这种技术。
尽管这有点难以看清,但 \ 总是可以使用的。把它看作是使代码行更易读的最后一招。
使用括号将长语句拆分成合理的部分
-
即使令人困惑,也要将整个语句写在一行上:
>>> import math >>> example_value1 = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))添加额外的括号,这些括号不会改变值,但允许将表达式拆分成多行:
>>> example_value2 = (63/25) * ( (17+15*math.sqrt(5)) / (7+15*math.sqrt(5)) ) >>> example_value2 == example_value1 True -
在括号内断行:
>>> example_value3 = (63/25) * ( ... (17+15*math.sqrt(5)) ... / (7+15*math.sqrt(5)) ... ) >>> example_value3 == example_value1 True
匹配()字符的技术非常强大,并且可以在许多情况下工作。这被广泛使用,并且强烈推荐。
我们几乎总是可以找到一种方法在语句中添加额外的()字符。在极少数情况下,当我们不能添加()字符时,我们可以退回到使用\来将语句分成几个部分。
使用字符串字面量连接
我们可以将()字符与另一个规则结合起来,该规则连接相邻的字符串字面量。这对于长而复杂的格式字符串特别有效:
-
将长字符串值包裹在()字符中。
-
将字符串分解成有意义的子字符串:
>>> message_text = ( ... f’the internal representation ’ ... f’is {mantissa_whole:d}/2**53*2**{exponent:d}’ ... ) >>> message_text ’the internal representation is 7074237752514592/2**53*2**2’
我们总是可以将长字符串字面量分解成相邻的部分。然后我们可以使用我们需要的物理行断行。对于字符串字面量值,不需要显式运算符。
将中间结果分配给单独的变量
这里是这个技术的背景:
>>> import math
>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))
我们可以将这个表达式分解成三个中间值:
-
在整体表达式中识别子表达式。将这些分配给变量:
>>> a = (63/25) >>> b = (17+15*math.sqrt(5)) >>> c = (7+15*math.sqrt(5)) -
用创建的变量替换子表达式:
>>> example_value = a * b / c
我们总是可以将子表达式分配给变量,并在子表达式被使用的地方使用该变量。15*sqrt(5)的乘积被重复;这也是重构表达式的良好候选。
我们没有给出这些变量有描述性的名称。在某些情况下,子表达式有一些语义,我们可以用有意义的名称来捕捉。
2.2.3 它是如何工作的...
Python 语言手册在逻辑行和物理行之间做出了区分。一个逻辑行包含一个完整的语句。它可以通过称为行连接的技术跨越多个物理行。手册确定了两种技术:显式行连接和隐式行连接。
使用\进行显式行连接有时是有帮助的。因为它很容易被忽视,所以通常不鼓励这样做。PEP-8建议这应该是最后的手段。
在许多情况下可以使用()进行隐式行连接。它通常与表达式的结构在语义上相匹配,因此是鼓励的。
2.2.4 更多...
表达式在许多 Python 语句中被广泛使用。任何表达式都可以添加()字符。这给了我们很多灵活性。
然而,有几个地方我们可能有一个长的语句,它并不特别涉及长的表达式。最显著的例子是导入语句——它可以变得很长,但并不使用任何表达式。尽管没有适当的表达式,但它确实仍然允许使用()。以下示例显示我们可以围绕一个非常长的导入名称列表:
>>> from math import (
... sin, cos, tan,
... sqrt, log, frexp)
虽然()字符绝对不是表达式的一部分,但它们是可用的语法的一部分,有助于使语句更易于阅读。
2.2.5 参见
- 隐式行连接也适用于匹配的[]和{}字符。这些适用于我们在第四章中将要查看的集合数据结构。
2.3 包含描述和文档
当我们有一个有用的脚本时,我们经常需要为自己和其他人留下关于它做什么、如何解决某些特定问题以及何时应该使用的笔记。这个配方包含一个建议的大纲,以帮助使文档合理完整。
2.3.1 准备工作
如果我们使用了编写 Python 脚本和模块文件 – 语法基础配方来开始脚本文件,我们将有一个小的文档字符串。我们将在这个配方中扩展这个文档字符串。
在其他地方也应该使用文档字符串。我们将在第三章和第七章中查看这些附加位置。
我们将编写总结文档字符串的两种通用类型的模块:
-
库模块:这些文件将主要包含函数定义以及类定义。文档字符串摘要应侧重于模块中的定义,描述模块是什么。文档字符串可以提供使用模块中定义的函数和类的示例。在第三章和第七章中,我们将更详细地查看这些模块。
-
脚本:这些是我们通常期望会执行一些实际工作的文件。文档字符串应该描述模块的功能和使用方法。选项、环境变量和配置文件是文档字符串的重要组成部分。
我们有时会创建包含两者一部分的文件。这需要在行动和存在之间保持适当的平衡。
2.3.2 如何操作...
编写文档的第一步对库模块和脚本都是相同的:
- 简要总结脚本或模块是什么或做什么。总结不需要深入挖掘其工作原理。就像报纸文章的开头一样,它介绍了模块的谁、什么、何时、何地、如何和为什么。细节将在文档字符串的主体中跟随。
这有助于避免像“此脚本”这样的不必要的短语。我们可能以这样的方式开始模块的文档字符串:
"""
Downloads and decodes the current Special Marine Warning (SMW)
for the area ’AKQ’.
"""
我们将根据模块的一般重点来区分其他步骤。
为脚本编写文档字符串
当我们记录脚本时,我们需要关注将使用脚本的人的需求。
-
按照前面所示开始,创建一个总结句。
-
为文档字符串的其余部分草拟一个大纲。我们将使用 ReStructuredText(RST)标记。在一行上写上主题,然后在主题下方写一行=,使其成为合适的标题。记住,在每个主题之间留一个空行。
主题可能包括:
-
概要:如何运行此脚本的摘要。如果脚本使用 argparse 模块处理命令行参数,argparse 生成的帮助文本是理想的概要文本。其他可安装的工具,如 click 或 invoke,也可以生成优雅的概要文本。(参见第六章中的使用 argparse 获取命令行输入)
-
描述:解释此脚本的功能。
-
选项:这提供了所有参数和选项的详细信息。(参见第六章中的使用 argparse 获取命令行输入)
-
环境:这提供了描述环境变量及其含义的地方。(参见第六章中的使用 OS 环境设置)
-
文件:脚本创建或读取的文件名是非常重要的信息。
-
示例:一些使用脚本的示例总是很有帮助。在某些情况下,这可能是用户唯一会阅读的部分。
-
参见:任何相关的脚本或背景信息。
其他可能有趣的主题包括退出状态、作者、错误、报告错误、历史或版权。在某些情况下,例如报告错误的建议可能并不真正属于模块的文档字符串,而更可能位于项目的 GitHub 或 SourceForge 页面的其他地方。
-
-
在每个主题下填写详细信息。准确性很重要。由于文档与代码位于同一文件中,因此更容易做到正确、完整和一致。
这里是一个脚本的文档字符串示例:
"""
Downloads and decodes the current Special Marine Warning (SMW)
for the area \textquotesingle AKQ\textquotesingle{}
SYNOPSIS
========
::
python3 akq_weather.py
DESCRIPTION
===========
Downloads the Special Marine Warnings
Files
=====
Writes a file, ’’AKW.html’’.
EXAMPLES
========
Here’s an example::
slott\$ python3 akq_weather.py
None issued by this office recently.
在概要部分,我们使用了 :: 作为独立的段落。在示例部分,我们在段落的末尾使用了 ::。这两种版本都是对 RST 处理工具的提示,表明接下来的缩进部分应该被格式化为代码。参见第十七章,文档和风格。
为库模块编写文档字符串
当我们记录库模块时,我们需要关注将导入模块以在他们的代码中使用程序员的需需求:
-
为文档字符串的其余部分绘制一个大纲。我们将使用 RST 标记。在一行上写下主题。在每个主题下方包括一行等号字符,以将主题转换为适当的标题。请记住,在每段之间留一个空行。
-
按照之前所示开始,创建一个总结句子:
-
描述:模块内容的摘要以及为什么模块是有用的
-
模块内容:在此模块中定义的类和函数
-
示例:使用此模块的示例
-
-
为每个主题填写详细信息。模块内容可能是一长串类或函数定义的列表。文档字符串应该是一个摘要。在每个类或函数中,我们将有一个单独的文档字符串,其中包含该项目的详细信息。
2.3.3 它是如何工作的...
几十年来,man 页面大纲已经发展成为一个包含 Linux 命令完整描述的工具。这种编写文档的通用方法已被证明是有用且具有弹性的。我们可以利用大量的经验,并使我们的文档结构遵循 man 页面模型。
我们想要准备模块文档字符串,这些文档字符串可以被 Sphinx Python 文档生成器使用(参见 www.sphinx-doc.org/en/stable/)。这是用于生成 Python 文档文件的工具。Sphinx 中的 autodoc 扩展会读取我们模块、类和函数上的文档字符串标题,以生成最终看起来像 Python 生态系统中的其他模块的文档。
2.3.4 更多内容...
RST 标记具有简单、中心的语法规则:段落由空白行分隔。
这条规则使得编写可以被各种 RST 处理工具检查并重新格式化以看起来很棒的文档变得容易。
编写好的软件文档可能具有挑战性。信息不足和重复代码中明显细节的文档之间存在很大的鸿沟。
重要的是要关注那些对软件或其工作方式不太了解的人的需求,但他们可以阅读 Python 代码。为这样的半知识用户提供他们需要的信息,以便理解软件的功能和使用方法。
2.3.5 参考信息
-
我们在 使用 RST 标记编写更好的文档字符串 中探讨了额外的技术。
-
如果我们使用了 编写 Python 脚本和模块文件 – 语法基础 的配方,我们将在脚本文件中放置一个文档字符串。当我们构建第三章 3 中的函数和第七章 7 中的类时,我们将查看其他可以放置文档字符串的地方。
-
更多关于 Sphinx 的信息,请参见
www.sphinx-doc.org/en/stable/。 -
更多关于 man 页面大纲的背景信息,请参见
en.wikipedia.org/wiki/Man_page。
2.4 使用 RST 标记编写更好的文档字符串
当我们有一个有用的脚本时,我们经常需要留下关于它做什么、如何工作以及何时应该使用的笔记。包括 Docutils 在内的许多生成文档的工具都与 RST 标记一起工作。这允许我们编写纯文本文档。它可以包括一些特殊标点符号,以选择粗体或斜体字体变体来强调细节。此外,RST 允许通过列表和章节标题来组织内容。
2.4.1 准备工作
在 包含描述和文档 的配方中,我们探讨了将一些基本文档放入模块中。我们将查看一些 RST 格式化规则,用于创建可读的文档。
2.4.2 如何做...
-
从关键点的概述开始,创建 RST 部分标题以组织材料。一个部分标题有一行标题,后面跟着一行与标题长度相等的下划线字符,使用 =、-、^、~。
一个标题看起来像这样:
Topic =====标题文本在一行,下划线字符在下一行。这必须被空白行包围。下划线字符可以多于标题字符,但决不能少于。
RST 工具将推断我们选择的下划线字符的使用模式。只要下划线字符使用一致,docutil 工具就能检测文档的结构。
在开始时,有一个明确的标题下划线标准可能会有所帮助:
|
|
|
字符 级别 |
|
|
= 1 - 2 ^ 3 ~ 4 |
|
|
-
填写各种段落。段落(包括部分标题)至少由一个空行分隔。
-
如果编程编辑器有拼写检查器,请使用它。这样做可能会很令人沮丧,因为代码示例通常包含拼写检查失败的缩写。
2.4.3 它是如何工作的...
Docutils 转换程序将检查文档,寻找部分和主体元素。部分通过标题识别。下划线用于将部分组织成适当的嵌套层次结构。
一个正确嵌套的文档可能具有以下下划线字符序列:
TITLE
=====
SOMETHING
----------
MORE
^^^^^
EXTRA
^^^^^
LEVEL 2
-------
LEVEL 3
^^^^^^^^
当从文档创建 HTML 文件时,它将具有
、
和
标签以表示不同级别。创建 LaTeX 文件需要一些额外的配置选择,但常见的文章模板意味着生成的文档将使用 \section、\subsection 和 \subsubsection 标题。当我们写作时,这些最终的表现选择并不是我们的主要关注点;最重要的是使用适当的下划线来反映所需的组织结构。
RST 解析器可以识别几种不同的主体元素。我们已经展示了几个。更完整的列表包括:
-
文本段落:这些可能使用内联标记来表示不同类型的强调或突出显示。
-
文本块:这些通过 :: 引入,并缩进四个空格。它们也可以通过 .. parsed-literal:: 指令引入。doctest 块缩进四个空格,并包含 Python >>> 提示符。
-
列表、表格和块引用:我们稍后会讨论这些。这些可以包含其他主体元素。
-
脚注:这些是特殊的段落。在渲染时,它们可能显示在页面底部或部分末尾。它们也可以包含其他主体元素。
-
超链接目标、替换定义和 RST 注释:这些是更专业的文本项,我们在这里不会详细讨论。
2.4.4 更多内容...
在包含描述和文档的食谱中,我们查看了几种不同的主体元素,我们可能会使用:
-
文本段落:这是一个由空白行包围的文本块。在这些段落中,我们可以使用内联标记来强调单词或短语。我们将在使用 RST 标记编写更好的 docstrings 菜谱中查看内联标记。
-
列表:这些段落以看起来像数字或项目符号的东西开始。我们可能会有这样的段落。
It helps to have bullets because: - They can help clarify - They can help organize行首可以使用其他字符,但 - 和 * 似乎是最常见的选项。
-
编号列表:存在多种可识别的模式。这包括以数字或字母开头,后跟 . 或 )。使用 # 而不是数字或字母将延续前一段落的值。
-
文字块:代码示例以纯文本形式呈现,不寻找 RST 元素。此文本必须缩进。一个方便的前缀是 ::。也可以使用 .. code-block:: 指令。
-
指令:指令通常看起来像 .. directive::。它可能有一些内容,这些内容缩进以包含在指令内。它可能看起来像这样:
.. important:: Do not flip the bozo bit... important:: 文本是指令。这后面跟着指令内的缩进文本。
使用指令
Docutils 有几个内置指令。Sphinx 工具添加了大量具有各种功能的额外指令。
最常用的指令之一是警告:注意、警告、危险、错误、提示、重要、注意、技巧、警告和通用警告。这些是复合体元素,因为它们内部有嵌套文本。上面,我们提供了一个重要警告的例子。
使用内联标记
在一个段落中,我们有几种内联标记可以使用:
-
我们可以用 * 将单词或短语包围起来以实现 强调。这通常以斜体形式排版。
-
我们可以用 ** 将单词或短语包围起来以实现 强烈强调。这通常以粗体形式排版。
-
我们用单反引号
‘将引用包围起来,链接后跟下划线_. 我们可能使用‘section title‘来引用文档中的特定部分。我们通常不需要在 URL 周围放置任何标记。Docutils 工具可以识别这些。有时我们希望显示一个单词或短语,而隐藏 URL。我们可以使用这个:‘the \textbf{Sphinx} documentation http://www.sphinx-doc.org/en/stable/‘`。 -
我们可以用双反引号
‘‘将与代码相关的单词包围起来,使它们看起来像‘‘代码‘‘。这将作为代码排版。
此外,还有一种更通用的技术称为角色。角色以 :word: 作为角色名称开始,后跟单引号 ‘ 中的适用单词或短语。一个文本角色看起来像这样::strong:‘this‘。
有许多标准角色名称,包括 :emphasis:、:literal:、:code:、:math:、:pep-reference:、:rfc-reference:、:strong:、:subscript:、:superscript: 和 :title-reference:。其中一些也可以使用更简单的标记,如 emphasis 或 strong。
此外,我们可以使用指令定义新的角色。如果我们想进行非常复杂的处理,我们可以为 Docutils 工具提供处理新角色的类定义。这允许我们调整文档的处理方式。
2.4.5 参考信息
-
关于 RST 语法的更多信息,请参阅
docutils.sourceforge.net。这包括对 Docutils 工具的描述。 -
关于 Sphinx Python 文档生成器的信息,请参阅
www.sphinx-doc.org/en/stable/。
2.5 设计复杂的 if...elif 链
在大多数情况下,我们的脚本将涉及许多选择。有时选择很简单,我们可以通过查看代码来判断设计的质量。在其他情况下,选择更复杂,很难确定我们的 if 语句是否设计得当,能够处理所有条件。
在最简单的情况下,我们有一个条件 C 及其逆否¬C。这是 if...else 语句的两个条件。一个条件 C 在 if 子句中声明;逆否条件¬C 在 else 子句中隐含。
这遵循排中律:我们声称在两个条件 C 和¬C 之间没有缺失的替代方案。然而,对于复杂条件,这可能很难可视化。
如果我们有类似以下的情况:
if weather == Weather.RAIN and plan == Plan.GO_OUT:
bring("umbrella")
else:
bring("sunglasses")
这可能一开始并不明显,但我们已经省略了许多可能的替代方案。天气和计划变量有四种不同的值组合。其中一种条件是明确声明的,其他三种则是假设的:
-
weather == RAIN and plan == GO_OUT。带伞似乎是正确的。
-
weather != RAIN and plan == GO_OUT。带太阳镜似乎是合适的。
-
weather == RAIN and plan != GO_OUT。如果我们待在家里,那么似乎没有哪个附加物品是合适的。
-
weather != RAIN and plan != GO_OUT。如果我们不出门,附加问题似乎就无关紧要了。
我们如何确保我们没有遗漏任何东西?我们如何确保我们没有将太多东西合并到一个假设的条件中,而不是明确声明?
2.5.1 准备工作
让我们来看一个 if...elif 链的具体例子。在骰子游戏 Craps 中,有一些规则适用于两个骰子的投掷。这些规则适用于游戏的第一次投掷,称为开场投掷:
-
2, 3, 或 12 是 Craps,对于大多数赌注来说,这是输。
-
7 或 11 是大多数赌注的赢家。
-
剩余的数字确定一个点。掷骰子将继续根据另一组规则进行。
我们将使用这组三个条件作为例子来查看这个食谱,因为它包含一个可能含糊不清的条款。
2.5.2 如何做到...
当我们编写 if 语句时,即使它看起来很 trivial,我们也需要确保所有条件都已涵盖。
-
列举我们已知的条件。在我们的例子中,我们有三个规则:(2, 3, 12)规则,(7, 11)规则,以及“剩余的数字”的模糊陈述。这可以形成一个 if 语句的第一稿。
-
确定所有可能选择的全集。对于这个例子,有 11 种可能的结果:从 2 到 12 的数字,包括 2 和 12。
-
将各种 if 和 elif 条件 C 与选择的全集 U 进行比较。存在三种可能的设计模式:
-
在代码中,我们有的 if 条件比选择的全集 C ⊂ U 中的可能条件要多。最常见的原因是未能完全列举宇宙中所有可能的选择。例如,我们可能用 0 到 5 而不是 1 到 6 来模拟骰子。选择的全集看起来是从 0 到 10 的值,但实际上有针对 11 和 12 的条件。
-
我们代码中的条件有间隙,U ∖ C≠∅。宇宙中未明确声明的 if 条件的选择的最常见原因是未能完全理解代码中的条件。例如,我们可能将值列举为两个元组而不是总和。2、3 和 12 由一对数字定义,包括(1, 1)、(1, 2)和(6, 6)。可能会忽略条件(2, 1),使得 if 语句的任何子句都没有对其进行测试。
-
我们可以证明代码中表达的条件与选择的全集 U 之间有匹配,即 U ≡ C。这是理想的。所有可能选择的全集与语句的 if 和 elif 子句中的所有条件相匹配。
-
在这个例子中,列举所有可能的选择很容易。在其他情况下,可能需要一些仔细的推理来理解任何间隙或遗漏。
在这个例子中,我们有一个模糊的术语,剩余的数字,我们可以用值列表(4, 5, 6, 8, 9, 10)来替换。提供列表可以消除任何可能的间隙和疑问。
当恰好有两个选择时,我们可以为其中一个选择编写条件表达式。另一个条件可以隐含;if 和 else 将工作。
当我们有超过两个选择时,我们可以使用这个方法来编写一系列的 if 和 elif 语句,每个选择一个语句:
-
编写一个涵盖所有已知选择的 if ... elif ... elif 链。对于我们的例子,它可能开始如下:
dice = die_1 + die_2 if dice in (2, 3, 12): game.craps() elif dice in (7, 11): game.winner() elif dice in (4, 5, 6, 8, 9, 10): game.point(dice) -
添加一个引发异常的 else 子句,如下所示:
else: raise Exception(’Design Problem’)
这个额外的 else 为我们提供了一种方法来正确定位逻辑问题时。我们可以确信,我们做出的任何设计错误在程序运行时都会导致一个明显的问题。理想情况下,我们将在单元测试期间发现任何问题。
在这种情况下,很明显,所有 11 个选择都被 if 语句的条件所覆盖。额外的 else 永远不会被使用。并非所有现实世界的问题都有这种简单的方法来证明所有选择都被条件所覆盖。这有助于提供一个嘈杂的故障模式。
2.5.3 它是如何工作的...
我们的目的是确保我们的程序可靠地工作。虽然测试有帮助,但在设计和创建测试用例时,我们仍然可能做出错误的假设。
虽然严格的逻辑是必不可少的,但我们仍然可能犯错。此外,进行普通软件维护的人可能会引入错误。向复杂 if 语句中添加新功能可能是问题的潜在来源。
这种 Else-Raise 设计模式迫使我们对每个条件都进行明确说明。没有任何假设。正如我们之前提到的,如果异常被抛出,我们逻辑中的任何错误都将被揭露。
在设计问题时,通过异常崩溃是一种合理的行为。虽然另一种选择是将消息写入错误日志,但具有这种深刻设计缺陷的程序应被视为致命损坏。
2.5.4 更多内容...
在许多情况下,我们可以通过检查程序处理过程中的某个点的期望后置条件来推导出 if...elif...elif 链。例如,我们可能需要一个语句来建立类似于 m 等于 a 或 b 中较大的数。
(为了处理逻辑,我们将避免使用 Python 的便捷的 m = max(a, b),并关注我们如何从排他性选择中计算结果。)
我们可以像这样形式化最终条件:

我们可以通过将目标写成断言语句来从这个最终条件反向工作:
# do something
assert (m == a or m == b) and m >= a and m >= b
一旦我们明确了目标,我们就可以识别出导致该目标实现的语句。显然,像 m = a 或 m = b 这样的赋值语句是合适的,但每个都只在有限条件下有效。
我们可以推导出这些语句应该使用的前提条件。赋值语句的前提条件将用 if 和 elif 表达式编写。
当 a >= b 时,我们需要使用语句 m = a。同样,当 b >= a 时,我们需要使用语句 m = b。将逻辑重新排列成代码,我们得到如下:
if a >= b:
m = a
elif b >= a:
m = b
else:
raise Exception(’Design Problem’)
assert (m == a or m == b) and m >= a and m >= b
注意,我们的条件集合 U = {a ≥ b, b ≥ a}是完整的;没有其他可能的关系。此外,请注意,在 a = b 的边缘情况下,我们实际上并不关心使用哪个赋值语句。Python 将按顺序处理决策,并执行 m = a。这种选择的一致性不应该对我们的 if...elif...elif 链的设计有任何影响。我们可以不考虑子句的评估顺序来设计条件。
2.5.5 参见
-
这与“悬挂 else”的语法问题有些相似。参见
docs.oracle.com/javase/specs/jls/se9/html/jls-14.html。这不是同一个问题;Python 的缩进消除了“悬挂 else”语法问题。这是一个相邻的语义问题,试图确保在复杂的 if...elif...elif 链中所有条件都得到适当考虑。
2.6 使用 := “walrus” 运算符保存中间结果
有时候我们会遇到一个复杂的情况,需要保留一个昂贵的中间结果以供后续使用。想象一下一个涉及复杂计算的条件;计算的成本很高,以时间、输入输出操作、内存资源或三者兼而有之来衡量。
一个例子是使用正则表达式(re)包进行重复搜索。match()方法可以在返回 Match 对象或 None 对象(表示没有找到模式)之前进行相当多的计算。一旦这个计算完成,我们可能需要使用这个结果,并且我们绝对不希望再次进行计算。通常,最初的使用是简单地检查结果是否是 Match 对象或 None。
这是一个可以给表达式的值命名并也在 if 语句中使用表达式的例子。我们将探讨如何使用“赋值表达式”或“walrus”操作符。它被称为 walrus,因为赋值表达式操作符 := 对某些人来说看起来像海象的脸。
2.6.1 准备工作
这里有一个求和示例,最终每个项都变得如此之小,以至于继续将其加到总和中已经没有意义了:

实际上,这类似于以下求和函数:
>>> s = sum((1 / (2 * n + 1)) ** 2 for n in range(0, 20_000))
不清楚的是需要多少项的问题。在示例中,我们求和了 20,000 个值。但如果 16,000 个就足以提供一个准确的答案呢?
我们不想编写这样的求和:
>>> b = 0
>>> for n in range(0, 20_000):
... if (1 / ( 2 * n + 1)) ** 2 >= 0.000_000_001:
... b = b + (1 / (2 * n + 1)) ** 2
这个例子重复了一个昂贵的计算,(1/(2*n+1))**2。我们可以通过使用 walrus 操作符来避免包含这种浪费时间开销的处理。
2.6.2 如何做...
-
首先,我们隔离一个昂贵的操作,它是条件测试的一部分。在这个例子中,变量 term 用于保存昂贵的计算结果:
>>> p = 0 >>> for n in range(0, 20_000): ... term = (1 / (2 * n + 1)) ** 2 ... if term >= 0.000_000_001: ... p = p + term -
将赋值语句重写为使用 := 赋值操作符。这取代了 if 语句的简单条件。
-
添加一个 else 条件以跳出 for 语句,如果不再需要更多项的话。以下是这两个步骤的结果:
>>> q = 0 >>> for n in range(0, 20_000): ... if (term := (1 / (2 * n + 1)) ** 2) >= 0.000_000_001: ... q = q + term ... else: ... break注意,我们改变了求和变量。在食谱的先前的步骤中,它是 p。在这个步骤中,它是 q。这允许我们轻松地进行并排比较,以确保结果仍然是正确的。
赋值表达式 := 允许我们在 if 语句中做两件事。
2.6.3 它是如何工作的...
赋值表达式操作符 := 保存了一个中间结果。操作符的结果值与右侧操作数相同。这意味着表达式 a + (b := c+d) 与表达式 a+(c+d) 相同。表达式 a + (b := c+d) 与表达式 a+(c+d) 之间的区别在于在评估过程中设置 b 变量值的副作用。
赋值表达式可以在 Python 中几乎任何允许表达式的地方使用。最常见的情况是 if 语句。另一个好主意是放在 while 条件中。
它在几个地方也是被禁止的。它们不能用作表达式语句中的运算符。我们特别禁止将:=2 写为一个语句:这里有一个完美的赋值语句用于此目的,而赋值表达式,虽然意图相似,但可能存在混淆。
2.6.4 更多内容...
我们可以对前面这个菜谱中展示的无限求和示例进行一些优化。使用 for 语句和 range()对象看起来很简单。问题是,我们希望提前结束 for 语句——当被加的项非常小,对最终总和没有显著变化时。
我们可以将提前退出与项计算结合起来:
>>> r = 0
>>> n = 0
>>> while (term := (1 / (2 * n + 1)) ** 2) >= 0.000_000_001:
... r += term
... n += 1
我们使用了一个带有赋值表达式运算符的 while 语句。这将计算一个值,使用(1/(2*n+1))**2,并将其分配给 term 变量。如果这个值是显著的,我们将它加到总和 r 上,并增加 n 变量的值。如果分配给 term 的值太小,不足以显著,while 语句将结束。
这里有一个例子,展示了如何计算一系列值的运行总和。这预示了第四章中的概念。具体来说,这展示了使用赋值表达式运算符构建的列表推导式:
>>> data = [11, 13, 17, 19, 23, 29]
>>> total = 0
>>> running_sum = [(total := total + d) for d in data]
>>> total
112
>>> running_sum
[11, 24, 41, 60, 83, 112]
我们从一些数据开始,在 data 变量中。这可能是大多数一周内每天的锻炼分钟数。final_running_sum 变量的值是一个列表对象,通过评估表达式(total := total + d)对 data 变量中的每个值 d 进行计算而构建。因为赋值表达式改变了 total 变量的值,所以得到的列表是每个新值累加的结果。
2.6.5 参考内容
- 关于赋值表达式的详细信息,请参阅PEP-572,其中首次描述了该功能。
2.7 避免使用 break 语句可能引起的问题
理解 for 语句的常见方式是它创建了一个对所有条件的 for 条件。在语句的末尾,我们可以断言,对于集合中的所有项目,语句体内的处理已经完成。
for 语句并不只有一种含义。当 break 语句在 for 语句的体内使用时,它改变了语义为存在。当 break 语句离开 for(或 while)语句时,我们可以断言至少存在一个项目导致了包含语句的结束。
这里有一个附带问题。如果 for 语句在没有执行 break 语句的情况下结束,会怎样?无论如何,我们都在 for 语句之后的语句。使用 break 语句退出 for 或 while 语句时,所得到的条件可能是模糊的。我们无法轻易判断;这个方案提供了一些设计指导。
当我们有多条 break 语句,每条都有其自己的条件时,问题会放大。我们如何最小化由这些复杂的条件造成的退出 for 或 while 语句时产生的问题?
2.7.1 准备工作
在解析配置文件时,我们经常需要在字符串中找到第一个:或=字符。属性文件格式使用属性名和:或=后跟值。
找到标点符号是 for 语句存在修改的一个例子。我们不想处理所有字符;我们想知道最左边的:或=字符在哪里被找到。
这里是我们要用作例子的样本数据:
>>> sample_1 = "some_name = the_value"
这里有一个小的 for 语句,用于在样本字符串值中定位最左边的:或=字符:
>>> for position in range(len(sample_1)):
... if sample_1[position] in ’=:’:
... break
>>> print(f"name={sample_1[:position]!r}",
... f"value={sample_1[position+1:]!r}")
name=’some_name ’ value=’ the_value’
当发现等号(=)字符时,break 语句结束 for 语句。位置变量的值显示了所需字符被找到的位置。
关于以下边缘情况怎么办?
>>> sample_2 = "name_only"
>>> for position in range(len(sample_2)):
... if sample_2[position] in ’=:’:
... break
>>> print(f"name={sample_2[:position]!r}",
... f"value={sample_2[position+1:]!r}")
name=’name_onl’ value=’’
结果是尴尬的错误:name 的值中丢失了 y 字符。这是为什么?更重要的是,我们如何使 for 语句末尾的条件更清晰?
2.7.2 如何实现...
每个语句都建立了一个后置条件。当设计 for 或 while 语句时,我们需要明确语句末尾应该成立的条件。理想情况下,后置条件是像 text[position] in ’=:’这样的简单条件。然而,在给定文本中没有=或:的情况下,过于简单的后置条件可能不成立。
在 for 语句的末尾,以下两种情况之一是真实的:
-
要么是位置索引的字符是:或=
-
或者所有字符都已检查,并且没有字符是:或=
我们的应用程序代码需要处理这两种情况。
-
写出明显的后置条件。我们有时称这为“快乐路径”条件,因为它是在没有发生任何异常时为真的条件:
assert text[position] in ’=:’ # We found a = or : -
通过添加边缘情况的条件来创建整体的后置条件。在这个例子中,我们有两个额外的条件:
-
没有等号(=)或冒号(:)。
-
没有字符。这意味着 len()是零,for 语句实际上从未执行过。这也意味着位置变量永远不会被创建。
因此,在这个例子中,我们已经发现了总共三个条件:
-
len(text) == 0
-
not(’=’ in text or ’:’ in text),这可以用多种方式表达。not(text[position] == ’:’ or text[position] == ’=’)可能最清晰。
-
text[position] in ’=:’
-
-
当一个 while 语句可以被重新设计以在 while 子句中包含完整的后置条件集时,这可以消除使用 break 语句的需要。仍然需要正确初始化变量。
-
当使用 for 语句时,需要正确初始化变量。在 for 语句的主体之后添加 if 语句,以处理各种终止条件。以下是结果 for 语句和复杂的 if 语句,用于检查所有可能的后置条件:
>>> position = -1 >>> for position in range(len(sample_2)): ... if sample_2[position] in ’=:’: ... break ... >>> if position == -1: ... print(f"name=None value=None") ... elif not(sample_2[position] == ’:’ or sample_2[position] == ’=’): ... print(f"name={sample_2!r} value=None") ... else: ... print(f"name={sample_2[:position]!r}", ... f"value={sample_2[position+1:]!r}") name=’name_only’ value=None
在 for 语句之后的语句中,我们已经明确地列举了所有终止条件。
2.7.3 它是如何工作的...
这种方法迫使我们仔细地确定后置条件,以确保我们绝对知道 for 或 while 语句结束的所有原因。
这里的想法是放弃任何假设或直觉。通过一点点的纪律,我们可以确保知道所有后置条件的原因。当语句正常工作时,明确该条件是真实的至关重要。这是我们软件的目标,我们可以通过选择最简单的语句来实现目标条件,从而从目标逆向工作。
2.7.4 更多...
我们还可以在 for 语句上使用 else 子句来确定语句是否正常完成或执行了 break 语句。我们可以使用类似以下的方法:
>>> for position in range(len(sample_2)):
... if sample_2[position] in ’=:’:
... name, value = sample_2[:position], sample_2[position+1:]
... break
... else:
... if len(sample_2) > 0:
... name, value = sample_2, None
... else:
... name, value = None, None
>>> print(f"{name=!r} {value=!r}")
name=’name_only’ value=None
在 for 语句中使用 else 子句有时会令人困惑,我们不推荐这样做。不清楚这个版本是否实质上优于任何其他替代方案。因为它很少使用,所以很容易忘记 else 执行的原因。
2.7.5 参见
- 关于这个主题的经典文章是 David Gries 的《关于开发循环不变量和循环的标准策略的笔记》。见
www.sciencedirect.com/science/article/pii/0167642383900151
2.8 利用异常匹配规则
try 语句让我们能够捕获异常。当引发异常时,我们有许多处理它的选择:
-
忽略它:如果我们什么都不做,程序将停止。我们可以通过两种方式做到这一点——一开始不要使用 try 语句,或者在 try 语句中没有匹配的 except 子句。
-
记录它:我们可以写一条消息并使用 raise 语句在写入日志后让异常传播。预期这将停止程序。
-
从中恢复:我们可以编写一个 except 子句来执行一些恢复操作,以撤销部分完成的 try 子句的任何影响。
-
使其静默:如果我们什么都不做(即使用 pass 语句),那么在 try 语句之后会继续处理。这使异常静默,但不会纠正根本问题,或作为恢复尝试提供替代结果。
-
重新编写它:我们可以引发不同的异常。原始异常成为新引发的异常的上下文。
关于嵌套上下文呢?在这种情况下,一个异常可以被内部 try 语句忽略,但由外部上下文处理。每个 try 上下文的基本选项集是相同的。软件的整体行为取决于嵌套定义。
try 语句的设计取决于 Python 异常如何形成类层次结构。有关详细信息,请参阅 Python 标准库中的异常层次结构部分。例如,ZeroDivisionError 异常也是 ArithmeticError 和 Exception。另一个例子,FileNotFoundError 异常也是 OSError 以及 Exception。
如果我们试图同时处理详细异常和通用异常,这种层次结构可能会导致混淆。
2.8.1 准备工作
假设我们将使用 shutil 模块从一个地方复制文件到另一个地方。大多数可能引发的异常表明问题严重到无法解决。然而,在 FileNotFoundError 异常的具体事件中,我们希望尝试恢复操作。
这是我们想要做的粗略概述:
>>> from pathlib import Path
>>> import shutil
>>> source_dir = Path.cwd()/"data"
>>> target_dir = Path.cwd()/"backup"
>>> for source_path in source_dir.glob(’**/*.csv’):
... source_name = source_path.relative_to(source_dir)
... target_path = target_dir / source_name
... shutil.copy(source_path, target_path)
我们有两个目录路径,source_dir 和 target_dir。我们使用了 glob() 方法来定位 source_dir 下所有具有 *.csv 文件的文件。
表达式 source_path.relative_to(source_dir) 给我们文件名的尾部,即目录之后的部分。我们使用这个来在 target_dir 目录下构建一个新的、类似的路径。这确保了在 source_dir 目录中的文件名为 wc1.csv,在 target_dir 目录中也将有类似的名称。
处理 shutil.copy() 函数引发的异常时会出现问题。我们需要一个 try 语句,以便从某些类型的错误中恢复。如果我们尝试运行以下内容,我们将看到这种错误:
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: ...
(我们用 ... 替换了一些细节,因为它们在您的计算机上会有所不同。)
当备份目录尚未创建时,会引发此异常。当源目录树中存在而目标目录树中不存在的子目录时,也会发生这种情况。我们如何创建一个 try 语句来处理这些异常并创建缺失的目录?
2.8.2 如何实现...
-
将我们想要使用的代码缩进写入 try 块中:
>>> try: ... shutil.copy(source_path, target_path) -
在 except 子句中首先包含最具体的异常类。在这种情况下,我们对特定的 FileNotFoundError 异常有一个有意义的响应。
-
在后面包含任何更通用的异常。在这种情况下,我们将报告遇到的任何通用 OSError 异常。这导致以下结果:
>>> try: ... target = shutil.copy(source_path, target_path) ... except FileNotFoundError: ... target_path.parent.mkdir(exist_ok=True, parents=True) ... target = shutil.copy(source_path, target_path) ... except OSError as ex: ... print(f"Copy {source_path} to {target_path} error {ex}")
我们首先匹配最具体的异常,然后是更通用的异常。
我们通过创建缺失的目录来处理 FileNotFoundError 异常。然后我们再次尝试 copy(),因为我们知道现在它将正常工作。
我们记录了任何其他属于 OSError 类的异常。例如,如果存在权限问题,该错误将被写入日志,然后尝试下一个文件。我们的目标是尝试复制所有文件。任何引起问题的文件将被记录,但整体复制过程将继续。
并且,是的,复制文件的代码在两个不同的上下文中重复。第一次重复是在没有错误的情况下。第二次是在尝试从初始错误中恢复后。在一定程度上,这感觉像是违反了“不要重复自己”的原则。让我们看看替代方案,它似乎并不那么好。
为了满足 DRY 标准,我们可以尝试在 for 循环中嵌套这个操作。如果一切顺利,则使用 break 语句,否则可以尝试多次。for 循环的额外复杂性似乎比重复更糟糕。
一种常见的折衷方案是编写一个单行函数,将重复的内容简化为函数名。这样做的好处是可以在一个地方将其更改为 shutil.copy 函数中的另一个函数。
2.8.3 它是如何工作的...
Python 的异常匹配规则旨在简单:
-
按顺序处理 except 子句。
-
将实际异常与异常类(或异常类的元组)进行匹配。匹配意味着实际异常对象(或异常对象的任何基类)在 except 子句中属于给定的类。
这些规则说明了为什么我们将最具体的异常类放在最前面,而将更一般的异常类放在最后。像 Exception 这样的通用异常类几乎可以匹配任何类型的异常。我们不希望它排在第一位,因为其他子句将不会被检查。
还有一个更通用的类,即 BaseException 类。没有很好的理由去处理这个类的异常。如果我们这样做,我们将捕获 SystemExit 和 KeyboardInterrupt 异常;这会干扰终止不当行为的应用程序的能力。我们只在定义存在于正常异常层次结构之外的新异常类时使用 BaseException 类作为超类。
2.8.4 更多内容...
我们的例子包括一个嵌套的上下文,其中可以引发第二个异常。考虑这个 except 子句片段(从上下文中摘出):
... except FileNotFoundError:
... target_path.parent.mkdir(exist_ok=True, parents=True)
... target = shutil.copy(source_path, target_path)
如果 mkdir()方法或 shutil.copy()函数在处理原始 FileNotFoundError 异常时实际引发异常,则不会被处理。在 except 子句中引发的任何异常都可能使整个程序崩溃。处理这些嵌套异常可能需要嵌套的 try 语句。
我们可以重写 except 子句以包括恢复过程中的嵌套 try:
>>> try:
... target = shutil.copy(source_path, target_path )
... except FileNotFoundError:
... try:
... target_path.parent.mkdir(exist_ok=True, parents=True)
... target = shutil.copy(source_path, target_path)
... except OSError as ex2:
... print(f"{target_path.parent} problem: {ex2}")
... except OSError as ex:
... print(f"Copy {source_path} to {target_path} error {ex}")
在这个例子中,嵌套的上下文为 OSError 异常写入一条消息。在外部上下文中,使用略微不同的错误消息来记录类似错误。在这两种情况下,处理都可以继续。不同的错误消息可以使调试问题稍微容易一些。
2.8.5 参考内容
- 在 Avoiding a potential problem with an except: clause 菜谱中,我们探讨了设计异常处理语句时的一些额外考虑。
2.9 避免使用 except:子句可能带来的潜在问题
在异常处理中存在一些常见的错误。这些错误可能导致程序变得无响应。
我们可能犯的一个错误是使用没有命名异常类的 except:子句进行匹配。如果我们不谨慎地处理异常,我们可能会犯一些其他的错误。
本菜谱将展示一些我们可以避免的常见异常处理错误。
2.9.1 准备工作
当代码可以引发各种异常时,有时会诱使我们尝试匹配尽可能多的异常。匹配过多的异常类可能会干扰停止行为不端的 Python 程序。我们将在本菜谱中扩展“不要做什么”的概念。
2.9.2 如何操作...
我们需要避免使用裸 except:子句。相反,使用 except Exception:来匹配一个应用可以合理处理的最为通用的异常类型。
处理过多的异常类可能会干扰我们停止行为不端的 Python 程序的能力。当我们按下 Ctrl + C,或者通过操作系统的 kill -2 命令发送 SIGINT 信号时,我们通常希望程序停止。我们很少希望程序写一条消息然后继续运行。如果我们使用裸 except:子句,我们可能会意外地静音重要的异常。
我们应该小心避免尝试处理以下几类异常:
-
SystemError
-
RuntimeError
-
MemoryError
通常,这些异常意味着 Python 内部某个地方出了问题。而不是静音这些异常或尝试恢复,我们应该允许程序失败,找到根本原因,并修复它。
此外,如果我们捕获了这些异常,可能会干扰这些内部异常的处理方式:
-
SystemExit
-
KeyboardInterrupt
-
GeneratorExit
尝试处理这些异常可能会在我们需要停止程序的时候导致程序变得无响应。
2.9.3 工作原理...
我们应该避免以下三种技术:
-
不要在 except BaseException:子句中匹配 BaseException 类。
-
不要使用没有异常类的 except:。这会匹配所有异常,包括我们应避免尝试处理的异常。
-
不要匹配那些无法合理恢复的异常。
如果我们处理过多的异常类型,可能会加剧问题,通过错误的异常处理方式将其转化为更大、更神秘的问题。
编写一个永远不会崩溃的程序是一种崇高的愿望。然而,干扰 Python 的一些内部异常并不会创建一个更可靠的程序。相反,它创建了一个清晰的失败被掩盖并变成一个难以理解的问题的程序。
2.9.4 相关内容
- 在 利用异常匹配规则 菜谱中,我们探讨了设计异常处理语句时的考虑因素。
2.10 隐藏异常的根本原因
异常包含一个根本原因。内部引发的异常的默认行为是使用隐式的 context 属性来包含异常的根本原因。在某些情况下,我们可能想淡化根本原因,因为它可能会误导或对调试无帮助。
这种技术几乎总是与定义了唯一异常的应用程序或库配对。想法是显示唯一的异常,而不显示来自应用程序或库外部的无关异常的杂乱。
2.10.1 准备工作
假设我们正在编写一些复杂的字符串处理。我们希望将多种不同类型的详细异常视为一个通用的错误,以便我们的软件用户免受实现细节的影响。我们可以在通用错误中附加详细信息。
2.10.2 如何做...
-
要创建一个新的异常,我们可以这样做:
>>> class MyAppError(Exception): ... pass这将创建一个新的、独特的异常类别,我们的库或应用程序可以使用。
-
当处理异常时,我们可以像这样隐藏根本原因异常:
>>> try: ... None.some_method(42) # Raises an exception ... except AttributeError as exception: ... raise MyAppError("Some Known Problem") from None
在这个例子中,我们引发模块的唯一 MyAppError 异常类的新实例。新的异常将不会与根本原因 AttributeError 异常有任何联系。
2.10.3 它是如何工作的...
Python 异常类都有一个记录异常原因的地方。我们可以使用 raise Visible from RootCause 语句设置此 cause 属性。这是通过异常上下文隐式完成的。
当这个异常被引发时,看起来是这样的:
>>> try:
... None.some_method(42)
... except AttributeError as exception:
... raise MyAppError("Some Known Problem") from None
Traceback (most recent call last):
...
MyAppError: Some Known Problem
根本原因已被隐藏。如果我们省略 raise 语句中的 from None,那么异常将包含两部分,并且会复杂得多。当显示根本原因时,输出看起来更像是这样:
Traceback (most recent call last):
File "<doctest recipe_09.txt[3]>", line 2, in <module>
None.some_method(42)
^^^^^^^^^^^^^^^^
AttributeError: ’NoneType’ object has no attribute ’some_method’
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File ...
exec...
File ...
raise...
MyAppError: Some Known Problem
这显示了底层的 AttributeError 异常。这可能是一个无助于调试的实现细节,最好不将其打印在异常的显示中。
异常更有用的部分(一些细节被省略了...)跟在初始(可能是不相关的)根本原因信息之后。
2.10.4 更多内容...
异常有许多内部属性。这些包括 cause、context、traceback 和 suppress_context。整体的异常上下文在 context 属性中。如果通过 raise from 语句提供,原因在 cause 中。异常的上下文是可用的,但可以抑制其打印。
2.10.5 相关阅读
-
在 利用异常匹配规则 菜谱中,我们探讨了设计异常处理语句时的考虑因素。
-
在避免 except 子句的潜在问题的菜谱中,我们探讨了设计异常处理语句时的一些额外考虑。
2.11 使用 with 语句管理上下文
在许多情况下,我们的脚本将与外部资源纠缠在一起。最常见的情况是磁盘文件和连接到外部主机的网络连接。一个常见的错误是永远保留这些纠缠,无用地占用这些资源。这些有时被称为内存泄漏,因为每次打开一个新文件而不关闭之前使用的文件时,可用的内存都会减少。
我们希望隔离每个纠缠,以确保资源被正确获取和释放。想法是在我们的脚本使用外部资源的环境中创建一个上下文。在上下文结束时,我们的程序不再绑定到资源,我们希望得到保证,资源将被释放。
2.11.1 准备工作
假设我们想要将数据行写入 CSV 格式的文件。完成之后,我们想要确保文件已关闭,并且释放了各种操作系统资源——包括缓冲区和文件句柄。我们可以在上下文管理器中这样做,这保证了文件将被正确关闭。
由于我们将处理 CSV 文件,我们可以使用 csv 模块来处理格式化的细节:
>>> import csv
我们还将使用 pathlib 模块来定位我们将要处理文件:
>>> from pathlib import Path
为了有东西可以写入,我们将使用这个愚蠢的数据源:
>>> some_source = [
... [2,3,5],
... [7,11,13],
... [17,19,23]]
我们还需要一个工作目录。在示例中,我们使用当前工作目录下的数据。我们可以使用终端窗口命令创建此目录,或者我们可以从 Python 内部创建它:
>>> Path.cwd().mkdir("data", exists=ok=True)
这将给我们一个了解 with 语句的上下文。
2.11.2 如何做...
-
通过打开 Path 或使用 urllib.request.urlopen()创建网络连接来创建上下文。其他常见的上下文包括创建归档,如 zip 文件和 tar 文件。以下是打开文件的必要上下文创建:
>>> target_path = Path.cwd() / "data" / "test.csv" >>> with target_path.open(’w’, newline=’’) as target_file: -
包含所有在 with 语句内缩进的加工:
>>> target_path = Path.cwd() / "data" / "test.csv" >>> with target_path.open(’w’, newline=’’) as target_file: ... writer = csv.writer(target_file) ... writer.writerow([’column’, ’data’, ’heading’]) ... writer.writerows(some_source) -
当我们将文件用作上下文管理器时,文件将在缩进的上下文块结束时自动关闭。即使引发异常,文件也会被正确关闭。在上下文完成后和资源释放后,缩进完成后的处理:
>>> target_path = Path.cwd() / "data" / "test.csv" >>> with target_path.open(’w’, newline=’’) as target_file: ... writer = csv.writer(target_file) ... _ = writer.writerow([’column’, ’data’, ’heading’]) ... writer.writerows(some_source) >>> print(f’finished writing {target_path.name}’) finished writing test.csv
with 上下文之外的语句将在上下文关闭后执行。命名资源——由 target_path.open()打开的文件——将被正确关闭。
(我们将 writerow()方法的结果赋值给变量 _。这是一个避免显示此结果的技巧。它是数字 21,告诉我们写了多少个字符。)
即使在 with 上下文中抛出异常,文件仍然会被正确关闭。上下文管理器会通知异常。它可以关闭文件并允许异常传播。
2.11.3 它是如何工作的...
上下文管理器会通知围绕缩进代码块的三项重要事件:
-
进入上下文
-
没有异常的正常退出上下文
-
由于异常而退出上下文
上下文管理器将在所有情况下将我们的程序从外部资源中解耦。文件可以被关闭。网络连接可以被断开。数据库事务可以被提交或回滚。锁可以被释放。
我们可以通过在 with 语句中包含一个手动异常来实验这个。这可以显示文件被正确关闭:
>>> try:
... with target_path.open(’w’, newline=’’) as target_file:
... writer = csv.writer(target_file)
... _ = writer.writerow([’column’, ’data’, ’heading’])
... _ = writer.writerow(some_source[0])
... raise Exception("Testing")
... except Exception as exc:
... print(f"{target_file.closed=}")
... print(f"{exc=}")
target_file.closed=True
exc=Exception(’Testing’)
>>> print(f"finished writing {target_path.name}")
finished writing test.csv
在这个例子中,我们将实际工作封装在 try 语句中。这允许我们在将第一行数据写入 CSV 文件后抛出异常。因为异常处理在 with 上下文之外,所以文件被正确关闭。所有资源都得到释放,所写入的部分可以由其他程序正确访问和使用。
输出确认了预期的文件状态:
target_file.closed=True
exc=Exception(’Testing’)
这表明文件被正确关闭。它还显示了与异常相关的消息,以确认这是我们手动抛出的异常。这种技术允许我们与像数据库连接和网络连接这样的昂贵资源一起工作,并确保这些资源不会“泄漏”。
资源泄漏是在资源没有正确释放回操作系统时常用的一个描述。这就像一个池子慢慢被排空,应用程序停止工作,因为没有更多的可用操作系统网络套接字或文件句柄。with 语句可以用来正确地将我们的 Python 应用程序与操作系统资源解耦。
2.11.4 更多内容...
Python 为我们提供了一系列上下文管理器。我们注意到一个打开的文件是一个上下文,就像由urllib.request.urlopen()创建的打开网络连接一样。
对于所有文件操作和所有网络连接,我们应该始终使用 with 语句作为上下文管理器。很难找到这个规则的例外。
结果表明,十进制模块使用上下文管理器来允许对十进制算术执行方式的本地化更改。我们可以使用decimal.localcontext()函数作为上下文管理器来更改计算中隔离的舍入规则或精度。
我们也可以定义自己的上下文管理器。contextlib模块包含函数和装饰器,可以帮助我们在不明确提供它们的情况下创建资源周围的上下文管理器。
当使用锁时,with 语句上下文管理器是获取和释放锁的理想方式。查看 docs.python.org/3/library/threading.html#with-locks 了解由 threading 模块创建的锁对象与上下文管理器之间的关系。
2.11.5 参考信息
-
查看 PEP-343 了解 with 语句的起源。
-
第九章中的许多食谱将使用这项技术。包括 使用 CSV 模块读取定界文件、使用正则表达式读取复杂格式 和 读取 HTML 文档 等食谱都将使用 with 语句。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第三章:3
函数定义
函数定义是将一个大问题分解成更小问题的方法。数学家们已经这样做了几百年。这是一种将我们的 Python 编程打包成智力上可管理的块的方法。
我们将在这些食谱中查看多种函数定义技术。这包括处理灵活参数的方法以及根据某些高级设计原则组织参数的方法。
我们还将查看 typing 模块以及我们如何为我们的函数创建更正式的类型提示。使用类型提示将使我们的代码准备就绪,以便我们可以使用像 mypy 这样的工具来确认在整个程序中正确地使用了数据类型。类型提示不是必需的,但它们通常可以识别潜在的不一致性,使我们能够编写防止问题的代码。
在本章中,我们将查看以下食谱:
-
函数参数和类型提示
-
设计具有可选参数的函数
-
使用超级灵活的关键字参数
-
使用*分隔符强制关键字参数
-
使用/分隔符定义位置参数
-
根据部分函数选择参数顺序
-
使用 RST 标记编写清晰的文档字符串
-
围绕 Python 的堆限制设计递归函数
-
使用 script-library 开关编写可测试的脚本
3.1 函数参数和类型提示
通过多个 Python 增强提案,类型提示变得更加复杂。mypy 工具是验证这些类型提示的一种方式,以确保提示和代码一致。本书中展示的所有示例都已通过 mypy 工具检查。
这种额外的语法对于提示是可选的。它在运行时用途有限,并且没有性能开销。
3.1.1 准备工作
我们需要下载并安装 mypy 工具。通常,这是通过以下终端命令完成的:
(cookbook3) % python -m pip install mypy
使用 python -m pip 命令确保 pip 命令将与当前活动的虚拟环境相关联。在这个例子中,提示显示了名为 cookbook3 的虚拟环境。
我们还可以使用 pyright 工具来检查类型提示。
例如,为了说明类型提示,我们将查看一些颜色计算。这些计算中的第一个是从在 HTML 页面样式表中常用颜色代码中提取红色、绿色和蓝色值。值的编码方式有很多种,包括字符串、整数和元组。以下是数据类型的一些变体:
-
例如,一个以#开头的前导六位十六进制字符的字符串,如"#C62D42"
-
一个六位十六进制字符的字符串,例如,"C62D42"
-
例如,一个 Python 数值,比如 0xC62D42
-
例如,一个包含 R, G 和 B 整数的三个元组,(198, 45, 66)
对于字符串和数字,我们直接使用类型名称,str 或 int。对于元组,我们使用更复杂的看起来像 tuple[int, int, int]。
目标是三个整数值。从字符串或整数到三个值的转换涉及两个单独的步骤:
-
如果值是字符串,则使用 int() 函数将其转换为单个整数。
-
对于单个整数值,使用 >> 和 & 运算符将整数拆分为三个单独的值。这是将单个整数值 hx_int 转换为三个单独的 r, g, b 值的核心计算:
r, g, b = (hx_int >> 16) & 0xFF, (hx_int >> 8) & 0xFF, hx_int & 0xFF
一个单独的 RGB 整数有三个单独的值,这些值通过位移操作组合在一起。红色值左移了 16 位。为了提取这个组件,使用 >> 运算符将值右移 16 位。& 运算符应用 0xff 作为“掩码”,仅保存可能更大的数字的 8 位。为了提取绿色组件,右移 8 位。蓝色值占据最低的有效 8 位。
3.1.2 如何实现...
对于某些函数,从工作实现开始并添加提示可能最简单。以下是它是如何工作的:
-
不添加任何提示编写函数:
def hex2rgb_1(hx_int): if isinstance(hx_int, str): if hx_int[0] == "#": hx_int = int(hx_int [1:], 16) else: hx_int = int(hx_int, 16) r, g, b = (hx_int >> 16) & 0xff, (hx_int >> 8) & 0xff, hx_int & 0xff return r, g, b -
添加结果提示。它基于返回语句。在这个例子中,返回是一个包含三个整数的元组,tuple[int, int, int]。
-
添加参数提示。在这种情况下,我们有两个参数的替代类型:它可以是字符串或整数。在类型提示的正式语言中,这是两种类型的联合。参数可以描述为 Union[str, int] 或 str | int。如果使用 Union,则必须从 typing 模块导入定义。
将提示合并到一个函数中,得到以下定义:
def hex2rgb(hx_int: int | str) -> tuple[int, int, int]:
if isinstance(hx_int, str):
if hx_int[0] == "#":
hx_int = int(hx_int[1:], 16)
else:
hx_int = int(hx_int, 16)
r, g, b = (hx_int >> 16) & 0xff, (hx_int >> 8) & 0xff, hx_int & 0xff
return r, g, b
3.1.3 它是如何工作的...
这些类型提示在 Python 代码执行时没有影响。提示是为了让人阅读和供外部工具,如 mypy,验证。工具可以确认 hx_int 变量始终用作整数或字符串。
在 r, g, b = 赋值语句中,预期 hx_int 的值是一个整数。mypy 工具可以确认运算符适用于整数值,并且返回类型与计算类型匹配。
我们可以通过在代码中插入 reveal_type(hx_int) 函数来观察 mypy 工具对类型的分析。这个语句具有函数语法;它仅在运行 mypy 工具时使用。我们只有在运行 mypy 时才会看到这个输出,我们必须在尝试对模块进行任何其他操作之前删除这额外的代码行。
当我们在 recipe_01_reveal.py 文件上在 shell 提示符运行 mypy 时,输出看起来是这样的:
(cookbook3) % mypy src/ch03/recipe_01_reveal.py
src/ch03/recipe_01_reveal.py:15: note: Revealed type is "builtins.int"
Success: no issues found in 1 source file
从 reveal_type(hx_int) 行输出的结果告诉我们,mypy 确定在第一个 if 语句完成后,变量将具有整数值。一旦我们看到了揭示的类型信息,我们需要从文件中删除 reveal_type(hx_int) 行。
3.1.4 更多内容...
让我们看看一个相关的计算。这个计算将 RGB 数值转换为色调-饱和度-亮度 (HSL) 值。这些 HSL 值可以用来计算补色。一个额外的算法,将 HSL 转换回 RGB 值,可以帮助为网页编码颜色:
-
RGB 到 HSL:我们将仔细研究这个,因为它有复杂的类型提示。
-
HSL 补色:关于“最佳”补色可能存在多种理论。我们将略过这些细节。
-
HSL 到 RGB:这将是最后一步,但我们将忽略这个计算的细节。
我们不会仔细研究两个实现。它们并不特别复杂,但这些计算细节可能会分散对类型和类型提示的理解。请参阅 www.easyrgb.com/en/math.php。
我们首先通过一个类似这样的存根定义来草拟函数的定义:def function_name() -> return_type:。
def rgb_to_hsl_t(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
...
这可以帮助我们可视化多个相关函数,以确保它们都具有一致的类型。其他两个函数有类似的存根:
def hsl_comp_t(hsl: tuple[float, float, float]) -> tuple[float, float, float]:
...
def hsl_to_rgb_t(hsl: tuple[float, float, float]) -> tuple[int, int, int]:
...
在写下这个初始的存根定义列表之后,我们可以看到一些类型提示在略有不同的上下文中被重复。这表明我们需要创建一个单独的命名类型来避免重复细节。我们将为重复的类型细节提供一个名称:
from typing import TypeAlias
RGB_a: TypeAlias = tuple[int, int, int]
HSL_a: TypeAlias = tuple[float, float, float]
def rgb_to_hsl(color: RGB_a) -> HSL_a:
...
def hsl_complement(color: HSL_a) -> HSL_a:
...
def hsl_to_rgb(color: HSL_a) -> RGB_a:
...
本概述中各种函数的介绍可以帮助确保每个函数使用数据的方式与其他函数保持一致。
RGB_a 和 HSL_a 名称包含一个后缀 _a 以帮助区分这些类型别名与这个食谱中的其他示例。在实际应用中,像 _a 这样的后缀字符串来显示名称是别名将会造成视觉上的杂乱,应该避免。
如第一章中提到的 使用 NamedTuples 简化元组中的项目访问,我们可以为这些元组类型提供一组更具描述性的名称:
from typing import NamedTuple
class RGB(NamedTuple):
red: int
green: int
blue: int
我们定义了一个独特的新 NamedTuple 子类,称为 RGB。使用名称可以帮助阐明代码背后的意图。
3.1.5 参见
-
mypy 项目包含大量信息。有关类型提示如何工作的更多信息,请参阅
mypy.readthedocs.io。 -
pyright 项目是另一个有用的类型提示工具。有关更多信息,请参阅
microsoft.github.io/pyright。
3.2 设计具有可选参数的函数
当我们定义一个函数时,我们通常需要可选参数。这使我们能够编写更加灵活且易于阅读的函数。
我们也可以将其视为创建一组密切相关函数的方法。每个函数都有略微不同的参数集合——称为签名——但所有函数都共享相同的简单名称。这有时被称为“重载”函数。在类型模块中,@overload 装饰器可以帮助在更复杂的情况下创建类型提示。
可选参数的一个例子是内置的 int() 函数。此函数有两个签名:
-
int(str)->int。例如,int('355')的值为 355。可选的基数参数默认值为 10。 -
int(str, base)->int。例如,int('163', 16)的值为 355。在这种情况下,基数参数的值是 16。
3.2.1 准备工作
许多游戏依赖于骰子集合。赌场游戏 Craps 使用两个骰子。像 Zonk(或贪婪或一万)这样的游戏使用六个骰子。有一个可以处理所有这些变化的掷骰子函数是很有用的。
3.2.2 如何操作...
我们有两种设计具有可选参数的函数的方法:
-
从一般到特殊:首先设计最通用的解决方案,并为最常见的情况提供默认值。
-
特殊到一般:首先设计几个相关的函数。然后我们将它们合并成一个通用的函数,该函数涵盖所有情况,并特别指定一个原始函数作为默认行为。
我们首先探讨从特殊到一般的方法,因为这通常更容易从一个具体例子开始。
特殊到一般设计
在整个示例中,我们将使用略有不同的名称,因为函数在演变过程中。这简化了不同版本的单元测试和比较。我们将这样进行:
-
编写一个游戏函数。我们将从 Craps 游戏开始,因为它似乎是最简单的:
import random def die() -> int: return random.randint(1, 6) def craps() -> tuple[int, int]: return (die(), die())我们定义了一个函数,
die(),来封装有关标准骰子的一个基本事实。通常使用五个柏拉图立体,产生四面体、六面体、八面体、十二面体和二十面体骰子。randint()表达式假设是一个六面的立方体。 -
编写下一个游戏函数。我们将继续到 Zonk 游戏:
def zonk() -> tuple[int, ...]: return tuple(die() for x in range(6))我们使用生成器表达式创建了一个包含六个骰子的元组对象。我们将在第九章节中深入探讨生成器表达式。
zonk() 函数体内的生成器表达式有一个变量 x,这是必需的语法,但该值被忽略。这也常见于写作
tuple(die() for _ in range(6))。变量 _ 是一个有效的 Python 变量名,通常在需要变量名但从未使用时使用。 -
定位 craps() 和 zonk() 函数中的共同特征。在这种情况下,我们可以重构 craps() 函数的设计,使其遵循 zonk() 函数的模式。而不是构建 exactly two evaluations of the die() 函数,我们可以引入一个基于 range(2) 的生成器表达式,该表达式将评估 die() 函数两次:
def craps_v2() -> tuple[int, ...]: return tuple(die() for x in range(2))合并两个函数。这通常涉及暴露之前是字面值的变量:
def dice_v2(n: int) -> tuple[int, ...]: return tuple(die() for x in range(n))这提供了一个通用的函数,涵盖了 Craps 和 Zonk 游戏的需求。
-
识别最常见的使用情况,并将此作为任何引入的参数的默认值。如果我们最常见的模拟是 Craps,我们可能会这样做:
def dice_v3(n: int = 2) -> tuple[int, ...]: return tuple(die() for x in range(n))现在,我们可以使用 dice_v3() 来玩 Craps 游戏。在 Zonk 游戏的第一轮中,我们需要使用表达式 dice_v3(6)。
-
检查类型提示以确保它们描述了参数和返回值。在这种情况下,我们有一个整数值的参数,返回值是一个整数元组,描述为 tuple[int, ...]。
在整个示例中,名称从 dice() 发展到 dice_v2(),然后到 dice_v3()。这可以使得在菜谱中更容易看到差异。一旦编写了最终版本,删除其他版本并将这些函数的最终版本重命名为 dice()、craps() 和 zonk() 是有意义的。它们演变的历程可能成为一篇博客文章,但不需要保留在代码中。
从一般到特殊的设计
在遵循从一般到特殊策略时,我们首先确定所有需求。预见所有替代方案可能很困难,这使得这个任务更具挑战性。我们通常会通过向需求中引入变量来完成这项工作:
-
总结掷骰子需求。我们可能从如下列表开始:
-
Craps:两个骰子
-
Zonk 的第一轮:六个骰子
-
Zonk 的后续轮次:一到六个骰子
-
-
用显式参数替换任何字面值重写需求。我们将用参数 n 替换我们所有的数字。这个参数将取值为 2、6 或 1 ≤ n ≤ 6 范围内的值。我们想确保我们已经正确地参数化了每个不同的函数。
-
编写符合一般模式的函数:
def dice_d1(n): return tuple(die() for x in range(n))在第三种情况——Zonk 的后续轮次——中,我们确定了一个由应用程序在玩 Zonk 时施加的约束 1 ≤ n ≤ 6。
-
为最常见的用例提供一个默认值。如果我们最常见的模拟是骰子游戏 Craps,我们可能会这样做:
def dice_d2(n=2): return tuple(die() for x in range(n)) -
添加类型提示。这些将描述参数和返回值。在这种情况下,我们有一个整数值的参数,返回值是一个整数元组,描述为 tuple[int, ...]:
def dice(n: int=2) -> tuple[int, ...]: return tuple(die() for x in range(n))
现在,我们可以使用这个 dice() 函数来玩 Craps。在 Zonk 的第一轮中,我们需要使用 dice(6)。
在这个菜谱中,名称不需要通过多个版本演变。名称演变只在书中对单元测试每个示例有用。
这个版本看起来与之前菜谱中的 dice_v2() 函数完全一样。这不是偶然的——两种设计策略通常会在一个共同解决方案上汇聚。
3.2.3 它是如何工作的...
Python 提供参数值的规则允许确保每个参数都给出了一个参数值。我们可以将这个过程想象成这样:
-
在存在默认值的地方,设置这些参数。默认值使这些参数成为可选的。
-
对于没有名称的参数——例如,dice(2)——参数值按位置分配给参数。
-
对于具有名称的参数——例如,dice(n=2)——参数值按名称分配给参数。
-
如果任何参数仍然缺少值,则引发 TypeError 异常。
规则还允许我们将位置值与命名值混合。这通过提供默认值使一些参数成为可选的。
3.2.4 更多...
写出我们更通用函数的专用版本有助于编写函数。这些函数可以简化应用程序:
def craps_v3():
return dice(2)
def zonk_v3():
return dice(6)
我们的应用程序功能 - craps_v3() 和 zonk_v3() - 依赖于一个通用函数,dice()。
这些形成了依赖层,使我们不必了解太多细节。这种分层抽象的想法有时被称为分块,是一种通过隔离细节来管理复杂性的方法。
3.2.5 参考以下内容
-
我们将在本章后面的基于部分函数选择参数顺序食谱中扩展这些想法。
-
我们已经使用了涉及不可变对象的可选参数。在本食谱中,我们专注于数字。在第四章中,我们将探讨可变对象,它们具有可以更改的内部状态。在避免为函数参数使用可变默认值食谱中,我们将探讨可选值的额外考虑。
3.3 使用超级灵活的关键字参数
一些设计问题涉及在给出足够已知值时求解一个未知数的简单方程。例如,速率、时间和距离有一个简单的线性关系。我们可以求解任何一个,当给出其他两个时。
有三个与 r × t = d 相关的解:
例如,在设计电路时,基于欧姆定律使用一组类似的方程。在这种情况下,方程将电阻、电流和电压联系起来。
在某些情况下,我们希望有一个可以根据已知和未知的内容执行三种不同计算的实施方案。
3.3.1 准备工作
我们将构建一个单一的功能,通过体现所有三种解决方案,可以解决任何两个已知值的速率-时间-距离(RTD)计算。通过微小的变量名更改,这适用于许多现实世界的问题。
我们不一定需要一个单一的值作为答案。我们可以通过创建一个包含三个值的 Python 小字典来稍微泛化这一点;其中两个是已给出的,一个是计算得出的。我们将在第五章中更详细地探讨字典。
当有问题时,我们将使用警告模块而不是引发异常:
import warnings
有时,产生一个可疑的结果比停止处理更有帮助。
3.3.2 如何实现...
-
为每个未知数求解方程。有三个单独的表达式:
-
distance = rate * time
-
rate = distance / time
-
time = distance / rate
-
-
根据其中一个值在未知时为 None 的条件,将每个表达式包裹在一个 if 语句中:
if distance is None: distance = rate * time elif rate is None: rate = distance / time elif time is None: time = distance / rate -
请参考第二章的设计复杂的 if...elif 链配方,以获取设计这些复杂 if...elif 链的指导。包括 Else-Raise 选项的变体:
else: warnings.warning("Nothing to solve for") -
构建最终的字典对象:
return dict(distance=distance, rate=rate, time=time) -
使用具有默认值 None 的关键字参数将所有这些封装成一个函数。这导致参数类型为 Optional[float],通常表示为 float | None。返回类型是一个具有字符串键的字典,总结为 dict[str, float | None]。它看起来像这样:
def rtd( distance: float | None = None, rate: float | None = None, time: float | None = None, ) -> dict[str, float | None]: if distance is None and rate is not None and time is not None: distance = rate * time elif rate is None and distance is not None and time is not None: rate = distance / time elif time is None and distance is not None and rate is not None: time = distance / rate else: warnings.warn("Nothing to solve for") return dict(distance=distance, rate=rate, time=time)
类型提示往往会使函数定义变得非常长,以至于不得不跨越五行代码。这么多可选值的呈现很难总结!
我们可以这样使用生成的函数:
>>> rtd(distance=31.2, rate=6)
{’distance’: 31.2, ’rate’: 6, ’time’: 5.2}
这表明以 6 节的速度航行 31.2 海里需要 5.2 小时。
为了得到格式良好的输出,我们可能这样做:
>>> result = rtd(distance=31.2, rate=6)
>>> (’At {rate}kt, it takes ’
... ’{time}hrs to cover {distance}nm’).format_map(result)
’At 6kt, it takes 5.2hrs to cover 31.2nm’
为了打断长字符串,我们使用了第二章的设计复杂的 if...elif 链配方中的知识。
为了使警告更加明显,可以使用警告模块设置一个过滤器,将警告提升为错误。使用表达式 warnings.simplefilter('error') 将警告转换为可见的异常。
3.3.3 它是如何工作的...
由于我们已经为所有参数提供了默认值,因此我们可以为三个参数中的任意两个提供参数值,然后函数可以求解第三个参数。这使我们不必编写三个单独的函数。
返回字典作为最终结果并不是这个问题的关键。这是一个方便的方式来展示输入和输出。它允许函数返回统一的结果,无论提供了哪些参数值。
3.3.4 更多...
我们有这个问题的另一种公式,它涉及更多的灵活性。Python 函数有一个所有其他关键字参数,前缀为**。
我们可以利用灵活的关键字参数并坚持要求所有参数都作为关键字提供:
def rtd2(**keywords: float) -> dict[str, float | None]:
rate = keywords.get(’rate’)
time = keywords.get(’time’)
distance = keywords.get(’distance’)
# etc.
关键字类型提示表明,这些参数的所有值都将为 float 对象。在极少数情况下,不是所有关键字参数的类型都相同;在这种情况下,一些重新设计可能有助于使类型更清晰。
这个版本使用字典的 get()方法在字典中查找给定的键。如果键不存在,则提供一个默认值 None。
字典的 get()方法允许第二个参数,即默认值,如果键不存在,则可以提供而不是 None。
这种开放式设计具有更大的灵活性,这是一个潜在的优势。一个潜在的缺点是,实际的参数名称难以辨认,因为它们不是函数定义的一部分,而是函数体的一部分。我们可以遵循 Writing better docstrings with RST markup 配方并提供一个好的文档字符串。然而,似乎更好的做法是将参数名称明确地作为 Python 代码的一部分提供,而不是通过文档隐式地提供。
这还有另一个,并且更加深远的缺点。问题在以下不良示例中暴露出来:
>>> rtd2(distnace=31.2, rate=6)
{’distance’: None, ’rate’: 6, ’time’: None}
这不是我们想要的行为。拼写错误的“distance”没有被报告为 TypeError 异常。拼写错误的参数名称在任何地方都没有被报告。为了揭示这些错误,我们需要添加一些编程来从关键字字典中弹出项目,并在移除预期名称后报告剩余名称的错误:
def rtd3(**keywords: float) -> dict[str, float | None]:
rate = keywords.pop("rate", None)
time = keywords.pop("time", None)
distance = keywords.pop("distance", None)
if keywords:
raise TypeError(
f"Invalid keyword parameter: {’’.join(keywords.keys())}")
这种设计将检测拼写错误。额外的处理表明,显式参数名称可能比无限制名称集合的灵活性更好。
3.3.5 参考信息
- 我们查看第二章中 Writing better docstrings with RST markup 配方中函数的文档。
3.4 使用*分隔符强制关键字参数
有一些情况下,一个函数会有大量的位置参数。从实用主义的角度来看,具有三个以上参数的函数可能会让人困惑。大量的传统数学似乎都集中在单参数和双参数函数上。似乎没有太多常见的数学运算符涉及三个或更多操作数。
当难以记住参数的所需顺序时,这表明参数太多。
3.4.1 准备工作
我们将查看一个用于准备风寒表并写入 CSV 格式输出文件的函数。我们需要提供一系列温度、一系列风速以及我们想要创建的文件信息。这有很多参数。
显然温度,即风寒温度,T[wc],的一个公式是:

风寒温度,
,基于空气温度,
,以摄氏度为单位,以及风速,
,以公里每小时为单位。
对于美国人来说,这需要一些转换:
-
将温度,
,从华氏度,
,转换为摄氏度,
:
。 -
将风速,
,从英里每小时,
,转换为公里每小时:
。 -
结果,
,需要从
转换回
:
。
我们不会将这些美国转换折叠到解决方案中。我们将把这留给你作为练习。
计算风寒温度的函数,T_wc(),看起来像这样:
def T_wc(T: float, V: float) -> float:
return 13.12 + 0.6215*T - 11.37*V**0.16 + 0.3965*T*V**0.16
这个函数有一个不寻常的名字,T_wc()。我们匹配了 T[wc] 的正式定义,而不是强制执行 PEP-8 规则,即函数名以小写字母开头。在这种情况下,似乎坚持使用文献中使用的名称,而不是基于语言习惯强加名称会更好。
创建风寒表的 一种方法 是创建如下所示的东西:
import csv
from typing import TextIO
def wind_chill(
start_T: int, stop_T: int, step_T: int,
start_V: int, stop_V: int, step_V: int,
target: TextIO
) -> None:
"""Wind Chill Table."""
writer= csv.writer(target)
heading = [’’]+[str(t) for t in range(start_T, stop_T, step_T)]
writer.writerow(heading)
for V in range(start_V, stop_V, step_V):
row = [float(V)] + [
T_wc(T, V)
for T in range(start_T, stop_T, step_T)
]
writer.writerow(row)
在我们到达设计问题之前,让我们看看基本处理。我们期望使用此函数的人已经使用 with 上下文打开了一个输出文件。这遵循了第二章中 Managing a context using the with statement 的配方。在这个上下文中,我们为 CSV 输出文件创建了一个写入。我们将在第十一章中更深入地探讨这一点。
标题变量的值包括一个列表字面量和构建列表的推导式。我们将在第四章中探讨列表。我们将在第九章中探讨推导式和生成器表达式。
同样,表格的每一行都是由一个表达式构建的,该表达式将单个浮点值与列表推导式相结合。列表由通过 wind-chill 函数,T_wc() 计算的值组成。我们根据表格中的行提供风速,V。我们还根据表格中的列提供温度,T。
wind_chill() 函数的整体定义提出了一个问题:wind_chill() 函数有七个不同的位置参数。当我们尝试使用这个函数时,我们最终得到如下代码:
>>> from pathlib import Path
>>> p = Path(’data/wc1.csv’)
>>> with p.open(’w’,newline=’’) as target:
... wind_chill(0, -45, -5, 0, 20, 2, target)
所有这些数字是什么?我们能做些什么来帮助解释所有这些数字背后的目的?
3.4.2 如何做到...
当我们拥有大量参数时,要求使用关键字参数而不是位置参数是有帮助的。我们可以在两组参数之间使用 * 作为分隔符。
在我们的例子中,结果函数定义具有以下存根定义:
def wind_chill_k(
*,
start_T: int, stop_T: int, step_T: int,
start_V: int, stop_V: int, step_V: int,
target: Path
) -> None:
让我们看看它在实践中如何使用不同类型的参数:
-
当我们尝试使用令人困惑的位置参数时,我们会看到如下情况:
>>> wind_chill_k(0, -45, -5, 0, 20, 2, target) Traceback (most recent call last): ... TypeError: wind_chill_k() takes 0 positional arguments but 7 were given -
我们必须使用具有显式参数名称的函数,如下所示:
>>> p = Path(’data/wc2.csv’) >>> with p.open(’w’, newline=’’) as output_file: ... wind_chill_k(start_T=0, stop_T=-45, step_T=-5, ... start_V=0, stop_V=20, step_V=2, ... target=output_file)
这种强制使用关键字参数的使用迫使我们每次使用这个看似复杂的函数时都要写一个更长但更清晰的语句。
3.4.3 它是如何工作的...
当 * 字符用作参数定义时,它将两个参数集合分开:
-
在 * 之前,我们列出可以按位置或按关键字命名的参数值。在这个例子中,我们没有这些参数。
-
在 * 之后,我们列出必须用关键字给出的参数值。在我们的例子中,这是所有参数。
print() 函数是这一点的示例。它有三个仅关键字参数用于输出文件、字段分隔符字符串和行结束字符串。
3.4.4 更多内容...
当然,我们可以将这种技术与各种参数的默认值结合起来。例如,我们可以对此进行修改,从而引入一个单一的默认值:
import sys
from typing import TextIO
def wind_chill_k2(
*,
start_T: int, stop_T: int, step_T: int,
start_V: int, stop_V: int, step_V: int,
target: TextIO = sys.stdout
) -> None:
...
我们现在可以用两种方式使用这个函数:
-
这里有一种在控制台上打印表格的方法,使用默认的目标:
>>> wind_chill_k2( ... start_T=0, stop_T=-45, step_T=-5, ... start_V=0, stop_V=20, step_V=2) -
这里有一种使用显式目标写入文件的方法:
>>> import pathlib >>> path = pathlib.Path("data/wc3.csv") >>> with path.open(’w’, newline=’’) as output_file: ... wind_chill_k2(target=output_file, ... start_T=0, stop_T=-45, step_T=-5, ... start_V=0, stop_V=20, step_V=2)
我们可以对这些更改更有信心,因为必须按名称提供参数。我们不必仔细检查以确保参数的顺序。
作为一种一般模式,我们建议在函数有三个以上参数时这样做。一两个参数很容易记住。大多数数学运算符是一元或二元。虽然第三个参数仍然可能很容易记住,但第四个(及以后的)参数将变得非常难以回忆。
3.4.5 参考以下内容
- 参考基于部分函数选择参数顺序的选择参数顺序的食谱以了解此技术的另一种应用。
3.5 使用斜杠(/)分隔仅位置参数
我们可以在参数列表中使用斜杠(/)字符将参数分为两组。在斜杠之前,所有参数值以位置方式工作。在斜杠参数之后,参数值可以是位置方式,也可以使用名称。
这应该用于满足以下所有条件的函数:
-
只使用少数几个位置参数(不超过三个)。
-
而且它们都是必需的。
-
而顺序是如此明显,任何改变都可能令人困惑。
这一直是标准库的一个特性。例如,math.sin() 函数只能使用位置参数。正式定义如下:
>>> help(math.sin)
Help on built-in function sin in module math:
sin(x, /)
Return the sine of x (measured in radians).
尽管有一个 x 参数名称,但我们不能使用这个名称。如果我们尝试使用它,我们会看到以下异常:
>>> import math
>>> math.sin(x=0.5)
Traceback (most recent call last):
...
TypeError: math.sin() takes no keyword arguments
x 参数只能以位置方式提供。help() 函数的输出提供了一个如何使用斜杠(/)分隔符来实现这一点的建议。
3.5.1 准备工作
一些内部内置函数使用仅位置参数;设计模式在我们的函数中也可能很有帮助。为了有用,必须有非常少的仅位置参数。由于大多数数学运算符有一个或两个操作数,这表明一个或两个仅位置参数可能很有用。
我们将考虑两个函数,用于将美国使用的华氏温度系统和世界上几乎所有其他地方使用的摄氏温度系统之间的单位转换:
-
将
转换为
:![5(F−32) C = ---9---]()
-
将
转换为
:![F = 32 + 9C- 5]()
这些函数每个只有一个参数,使其成为仅位置参数的合理示例。
3.5.2 如何操作...
-
定义函数:
def F_1(c: float) -> float: return 32 + 9 * c / 5 -
在仅位置参数之后添加/参数分隔符:
def F_2(c: float, /) -> float: return 32 + 9 * c / 5
在这些例子中,我们在函数名称后加上 _1 和 _2 后缀,以便清楚地说明每个步骤对应的定义。这是同一函数的两个版本,它们应该有相同的名称。它们被分开以展示函数编写的历史;这并不是一个实用的命名约定,除非在写书时,一些部分完成的函数有自己的单元测试。
3.5.3 它是如何工作的...
/分隔符将参数名称分为两组。在/之前是必须按位置提供参数值的参数:不能使用命名参数值。在/之后是允许使用名称的参数。
让我们看看温度转换的一个稍微复杂一点的版本:
def C(f: float, /, truncate: bool=False) -> float:
c = 5 * (f - 32) / 9
if truncate:
return round(c, 0)
return c
这个函数有一个名为 f 的仅位置参数。它还有一个截断参数,可以通过名称提供。这导致有三种使用此函数的不同方式,如下面的例子所示:
>>> C(72)
22.22222222222222
>>> C(72, truncate=True)
22.0
>>> C(72, True)
22.0
第一个例子显示了仅位置参数和没有四舍五入的输出。这是一个看起来复杂的价值。
第二个例子使用命名参数样式将非位置参数截断设置为 True。第三个例子提供了两种参数值的位置。
3.5.4 更多内容...
这可以与*分隔符结合,创建非常复杂的函数签名。参数可以分解为三组:
-
/分隔符之前的参数必须按位置提供。这些必须是第一个。
-
/分隔符之后的参数可以通过位置或名称提供。
-
*分隔符之后的参数必须只按名称提供。这些名称是最后提供的,因为它们永远不会按位置匹配。
3.5.5 参考内容
- 有关分隔符的详细信息,请参阅使用分隔符强制关键字参数的配方。
3.6 基于部分函数选择参数顺序
“部分函数”这个术语被广泛用来描述函数的部分应用。一些参数值是固定的,而另一些则变化。我们可能有一个函数,
,其中
和
有固定的值。有了这些固定值,我们就有了一个函数的新版本,
。
当我们查看复杂函数时,我们有时会看到我们使用函数的方式中存在一种模式。例如,我们可能会多次评估一个函数,其中一些参数值由上下文固定,而其他参数值则随着处理细节的变化而变化。有一些固定的参数值暗示了一个部分函数。
创建部分函数可以通过避免重复特定上下文中固定的参数值来简化我们的编程。
3.6.1 准备工作
我们将查看 haversine 公式的版本。这个公式计算地球表面上两点,p[1] = (lon[1],lat[1]) 和 p[2] = (lon[2],lat[2]) 之间的距离:


重要的计算得到两点之间的中心角,c。角度以弧度为单位。我们必须通过乘以地球的平均半径以某些给定的单位来将这个角度转换为距离。如果我们把角度 c 乘以 3,959 英里的半径,我们将把角度转换为英里。
这里是这个函数的实现:
from math import radians, sin, cos, sqrt, asin
MI = 3959
NM = 3440
KM = 6372
def haversine(
lat_1: float, lon_1: float,
lat_2: float, lon_2: float, R: float
) -> float:
"""Distance between points.
R is Earth’s radius.
R=MI computes in miles. Default is nautical miles.
>>> round(haversine(36.12, -86.67, 33.94, -118.40, R=6372.8), 5)
2887.25995
"""
_lat = radians(lat_2) - radians(lat_1)
_lon = radians(lon_2) - radians(lon_1)
lat_1 = radians(lat_1)
lat_2 = radians(lat_2)
a = sqrt(
sin(_lat / 2) ** 2 +
cos(lat_1) * cos(lat_2) * sin(_lon / 2) ** 2
)
return R * 2 * asin(a)
doctest 示例使用了一个地球半径,多了一个未在其他地方使用的额外小数点。这个示例的输出将与网上找到的其他示例相匹配。
我们经常遇到的问题是 R 的值在特定上下文中很少改变。一个上下文可能在整个应用程序中使用公里,而另一个上下文使用海里。我们希望为特定上下文强制一个默认值 R = NM,在给定上下文中得到海里,而无需编辑模块。
我们将查看几种提供一致参数值的方法。
3.6.2 如何实现...
在某些情况下,整体上下文将确定一个参数的单个值。这个值很少改变。以下是为参数提供一致值的三种常见方法:
-
将函数包装在一个新函数中,该函数提供默认值。
-
创建一个带有默认值的偏函数。这有两个进一步的改进:
-
我们可以将默认值作为关键字参数提供。
-
我们可以将默认值作为位置参数提供。
-
我们将在本食谱中分别查看这些不同的变体。
包装一个函数
这里是我们如何稍微修改函数并创建一个包装器的示例:
-
将一些参数设置为位置参数,将一些参数设置为关键字参数。我们希望上下文特征——那些很少改变的——是关键字参数。更频繁改变的参数应保留为位置参数:
def haversine_k( lat_1: float, lon_1: float, lat_2: float, lon_2: float, *, R: float ) -> float: ... # etc.我们可以遵循使用 * 分隔符强制关键字参数食谱。
-
我们可以编写一个包装函数,该函数将应用所有位置参数,不进行修改。它将作为长期上下文的一部分提供额外的关键字参数:
def nm_haversine_1(*args): return haversine_k(*args, R=NM)我们在函数声明中有 *args 构造来接受所有位置参数值作为一个单一的元组,args。我们在评估 haversine() 函数时使用类似外观的 *args 来将元组展开为该函数的所有位置参数值。
在这种情况下,所有类型都是 float。我们可以使用 *args: float 提供合适的提示。这并不总是有效,并且这种处理参数的方式——虽然看起来简单——可能会隐藏问题。
使用关键字参数创建部分函数
定义作为部分函数工作良好的函数的一种方法是通过使用关键字参数:
-
我们可以遵循使用 * 分隔符强制关键字参数的配方来做这件事。我们可能会改变基本的海里函数,使其看起来像这样:
def haversine_k( lat_1: float, lon_1: float, lat_2: float, lon_2: float, *, R: float ) -> float: ... # etc. -
使用关键字参数创建部分函数:
from functools import partial nm_haversine_3 = partial(haversine, R=NM)
partial() 函数从一个现有函数和一个具体的参数值集合中构建一个新的函数。nm_haversine_3() 函数在构建部分函数时为 R 提供了一个特定的值。
我们可以像使用任何其他函数一样使用它:
>>> round(nm_haversine_3(36.12, -86.67, 33.94, -118.40), 2)
1558.53
我们得到海里单位的答案,这使得我们能够轻松地进行与航海相关的计算。将 R=NM 设置为固定值使代码看起来稍微简单一些,并且更加可信。消除了一个计算中 R 值可能不正确的情况。
使用位置参数创建部分函数
如果我们尝试使用 partial() 与位置参数一起,我们被限制在部分定义中提供最左边的参数值。这让我们想到函数的前几个参数可能是被部分函数或包装器隐藏的候选者:
-
我们需要更改基本的海里函数,将 R 参数放在第一位。这使得定义部分函数稍微容易一些。以下是更改后的定义:
def p_haversine( R: float, lat_1: float, lon_1: float, lat_2: float, lon_2: float ) -> float: # etc. -
使用位置参数创建部分函数
from functools import partial nm_haversine_4 = partial(p_haversine, NM)partial()函数从一个现有函数和一个具体的参数值集合中构建一个新的函数。nm_haversine_4()函数在构建部分函数时为第一个参数 R 提供了一个特定的值。
我们可以像使用任何其他函数一样使用它:
>>> round(nm_haversine_4(36.12, -86.67, 33.94, -118.40), 2)
1558.53
我们得到海里单位的答案,这使得我们能够轻松地进行与航海相关的计算,代码可以使用没有重复 R=NM 参数值的烦恼细节的海里函数版本。
3.6.3 它是如何工作的...
部分函数本质上与包装函数相同。我们可以在程序的其他更复杂的部分中自由构建部分函数。请注意,创建部分函数时,在考虑位置参数的顺序时会导致一些额外的考虑:
-
如果我们尝试在包装器中使用
*args,这些必须定义在最后。所有这些参数都变成了匿名参数。这种匿名性意味着像 mypy 这样的工具可能难以确认参数是否被正确使用。文档也不会显示必要的细节。 -
在创建部分函数时,最左边的位置参数最容易提供值。
-
任何在 * 分隔符之后定义的关键字参数也是作为部分定义的一部分提供的好选择。
这些考虑可以让我们将最左边的参数视为一种上下文:这些参数预计很少改变,并且可以通过部分函数定义更容易地提供。
3.6.4 更多...
还有另一种封装函数的方法——我们也可以构建一个 lambda 对象。以下示例也将有效:
nm_haversine_L = lambda *args: haversine_k(*args, R=NM)
这依赖于 haversine_k()函数的定义,其中 R 参数被标记为关键字参数。如果没有这种位置参数和关键字参数值之间的明确分离,这个 lambda 定义将导致 mypy 发出警告。如果我们使用原始的 haversine()函数,警告会告诉我们 R 可能得到多个值。
lambda 对象是一个去除了其名称和主体的函数。函数定义简化为仅包含两个基本要素:
-
在这个例子中,参数列表为*args。
-
一个单一的表达式,即结果,haversine_k(*args, R=NM)。lambda 不能有任何语句。
lambda 方法使得创建类型提示变得困难,这限制了它的实用性。此外,PEP-8建议不应该将 lambda 赋值给变量。
3.6.5 参考信息
-
我们还将进一步探讨这个设计的扩展,在使用 script-library 开关编写可测试脚本的配方中。
-
对于更多函数式编程技术,请参阅《函数式 Python 编程》:
www.packtpub.com/product/functional-python-programming-3rd-edition-third-edition/9781803232577。这本书中有许多使用 lambda 和部分函数的示例。
3.7 使用 RST 标记编写清晰的文档字符串
我们如何清晰地记录函数的功能?我们能提供示例吗?当然可以,我们真的应该这样做。在第二章的包括描述和文档配方和使用 RST 标记编写更好的文档字符串配方中,我们查看了一些基本的文档技术。这些配方介绍了用于模块文档字符串的 ReStructuredText (RST)。
我们将扩展这些技术来编写 RST 格式的函数文档字符串。当我们使用 Sphinx 这样的工具时,我们的函数文档字符串将变成优雅的文档,描述我们的函数功能。
3.7.1 准备工作
在使用*分隔符强制关键字参数的配方中,我们查看了一个根据温度和风速计算风寒的函数。
在这个配方中,我们将展示几个带有名称尾随 _0 的函数的不同版本。从实用主义的角度来看,这种名称更改不是一个好主意。然而,为了使本书中这个函数的演变清晰,给每个新变体一个独特的名称似乎是有帮助的。
我们需要用更完整的文档来注释这个函数。
3.7.2 如何操作...
我们通常为函数描述编写以下内容:
-
概述
-
描述
-
参数
-
返回值
-
异常
-
测试用例
-
任何其他似乎有意义的内容
这是我们将如何为函数创建文档。我们可以将类似的方法应用于类的成员函数,甚至是一个模块。
-
编写概要。不需要适当的主题。不要写 This function computes...;我们可以从 Computes.... 开始。没有必要过度强调上下文:
def T_wc_1(T, V): """Computes the wind chill temperature."""为了帮助阐明本书中此函数 docstring 的演变,我们在名称后附加了后缀 _1。
-
编写描述并提供详细信息:
def T_wc_2(T, V): """Computes the wind chill temperature. The wind-chill, :math:‘T_{wc}‘, is based on air temperature, T, and wind speed, V. """在这种情况下,我们在描述中使用了小块排版数学。:math: 解释文本角色使用 LaTeXmath 排版。像 Sphinx 这样的工具可以使用 MathJax 或 jsMath 来处理数学排版。
-
描述参数。对于位置参数,通常使用 :param name: description. Sphinx 可以容忍多种变体,但这是常见的。对于必须使用关键字参数的情况,通常使用 :key name: 作为描述的前缀。
def T_wc_3(T: float, V: float): """Computes the wind chill temperature The wind-chill, :math:‘T_{wc}‘, is based on air temperature, T, and wind speed, V. :param T: Temperature in C :param V: Wind Speed in kph """ -
使用 :returns:: 描述返回值
def T_wc_4(T: float, V: float) -> float: """Computes the wind chill temperature The wind-chill, :math:‘T_{wc}‘, is based on air temperature, T, and wind speed, V. :param T: Temperature in C :param V: Wind Speed in kph :returns: Wind-Chill temperature in C """ -
识别可能引发的重要异常。使用 :raises exception: 标记来定义异常的原因。有几种可能的变体,但 :raises exception: 似乎很受欢迎:
def T_wc_5(T: float, V: float) -> float: """Computes the wind chill temperature The wind-chill, :math:‘T_{wc}‘, is based on air temperature, T, and wind speed, V. :param T: Temperature in C :param V: Wind Speed in kph :returns: Wind-Chill temperature in C :raises ValueError: for wind speeds under 4.8 kph or T above 10C """ -
如果可能,包括一个 doctest 测试用例:
def T_wc(T: float, V: float) -> float: """Computes the wind chill temperature The wind-chill, :math:‘T_{wc}‘, is based on air temperature, T, and wind speed, V. :param T: Temperature in C :param V: Wind Speed in kph :returns: Wind-Chill temperature in C :raises ValueError: for wind speeds under 4.8 kph or T above 10C >>> round(T_wc(-10, 25), 1) -18.8 -
编写任何附加的注释和有用的信息。我们可以在 docstring 中添加以下内容:
See https://en.wikipedia.org/wiki/Wind_chill .. math:: T_{wc}(T_a, V) = 13.2 + 0.6215 T_a - 11.37 V ^ {0.16} + 0.3965 T_a V ^ {0.16}我们还包含了一个指向总结风冷计算并链接到更多详细信息页面的维基百科页面。
我们还包含了一个 .. math:: 指令,其中包含函数中使用的 LaTeX 公式。这通常会很好地排版,提供代码的可读版本。
3.7.3 它是如何工作的...
关于 docstrings 的更多信息,请参阅第二章的 包括描述和文档 菜单。虽然 Sphinx 很受欢迎,但它不是唯一可以从 docstring 注释创建文档的工具。Python 标准库中的 pydoc 工具也可以从 docstring 注释生成外观良好的文档。
Sphinx 工具依赖于 Docutils 包的核心 RST 处理功能。有关更多信息,请参阅 pypi.python.org/pypi/docutils。
RST 规则相对简单。这个菜谱中的大多数附加功能都利用了 RST 的解释文本角色。我们每个 :param T:, :returns:, 和 :raises ValueError: 构造都是一个文本角色。RST 处理器可以使用这些信息来决定内容的样式和结构。样式通常包括一个独特的字体。上下文可能是 HTML 定义列表格式。
3.7.4 还有更多...
在许多情况下,我们还需要在函数和类之间包含交叉引用。例如,我们可能有一个准备风冷表的函数。这个函数的文档可能包括对 T_wc() 函数的引用。
Sphinx 将使用特殊的:func:文本角色生成这些交叉引用:
def wind_chill_table() -> None:
"""Uses :func:‘T_wc‘ to produce a wind-chill
table for temperatures from -30C to 10C and
wind speeds from 5kph to 50kph.
"""
... # etc.
我们已经使用:func:‘Twc‘在 RST 文档中的一个函数到另一个函数创建了一个引用。Sphinx 将这些转换为适当的超链接。
3.7.5 参见
- 请参阅第二章中的包含描述和文档和使用 RST 标记编写更好的 docstrings 配方,以了解其他展示 RST 如何工作的配方。
3.8 设计围绕 Python 栈限制的递归函数
一些函数可以使用递归公式明确且简洁地定义。这里有这种方法的两个常见例子。
阶乘函数有以下的递归定义:

计算斐波那契数 F[n]的递归规则如下定义:

这些都涉及一个具有简单定义值的案例和一个涉及根据同一函数的其他值计算函数值的案例。
我们遇到的问题是 Python 对这些类型的递归函数评估施加了上限。虽然 Python 的整数可以轻松计算 1000!的值,但栈限制阻止了我们随意计算。
从实用主义的角度来看,文件系统是一个递归数据结构的例子。每个目录都包含子目录。递归函数定义可以用于目录树。具有定义值的案例来自处理非目录文件。
我们通常可以将递归设计重构为消除递归,并用迭代来替换。在进行递归消除时,我们希望尽可能保留原始数学的清晰度。
3.8.1 准备工作
许多递归函数定义遵循阶乘函数的模式。这有时被称为尾递归,因为递归情况可以写在函数体的末尾:
def fact_r(n: int) -> int:
if n == 0:
return 1
return n * fact_r(n - 1)
函数中的最后一个表达式指的是同一个函数,但使用了不同的参数值。
我们可以重新表述这一点,避免 Python 中的递归限制。
3.8.2 如何实现...
尾递归也可以描述为一种缩减。我们将从一个值集合开始,然后将其缩减为一个单一值:
-
展开规则以显示所有细节:n! = n×(n−1)×(n−2)× (n − 3) ×
× 1。这有助于确保我们理解递归规则。 -
编写一个循环或生成器来创建所有值:N = {n,n − 1,n − 2,n − 3,…,1}。在 Python 中,这可以像 range(1, n+1)这样简单。在某些情况下,我们可能需要对基本值应用一些转换函数:N = {f(i)∣1 ≤ i < n + 1}。这是一个列表推导式;请参阅第四章中的构建列表 – 字面量、追加和推导式。
-
结合减少函数。在这种情况下,我们正在使用乘法计算一个大的乘积。我们可以将其总结为∏ [1≤x<n+1]x。
这里是一个 Python 的实现:
def prod_i(int_iter: Iterable[int]) -> int: p = 1 for x in int_iter: p *= x return pmath 模块中有一个等效的函数。我们不必像上面那样写出来,可以使用 from math import prod。
prod_i()函数可以如下使用来计算阶乘值:
>>> prod_i(range(1, 6))
120
>>> fact(5)
120
这工作得很好。我们已经将 prod_i()函数优化成了一个迭代函数。这次修订避免了递归版本可能遇到的栈溢出问题。
注意,range 对象是惰性的;它不会创建一个大的列表对象,从而避免了大量内存的分配。range 对象返回单个值,当它们被 prod_i()函数消耗时。
3.8.3 它是如何工作的...
尾递归定义很方便,因为它简短且易于记忆。数学家喜欢它,因为它可以帮助阐明函数的含义。
许多静态、编译型语言以与我们在此处展示的技术类似的方式创建优化代码。这是通过在虚拟机的字节码——或者实际的机器码——中注入一个特殊指令来实现的,以重新评估函数而不创建新的栈帧。Python 没有这个功能。实际上,这种优化将递归转换成一种 while 语句:
def loop_fact(n: int) -> int:
p = n
while n != 1:
n = n-1
p *= n
return p
特殊字节码指令的注入将导致运行快速、不透露中间修订的代码。然而,生成的指令可能不会与源文本完美匹配,这可能导致定位错误存在困难。
3.8.4 更多...
计算 F[n]斐波那契数涉及一个额外的问题。如果我们不小心,我们会多次计算很多值:
例如,为了计算 F[5] = F[4] + F[3],我们将评估以下内容:

展开 F[3]和 F[2]的定义显示了一些冗余的计算。
斐波那契问题涉及两个递归。如果我们天真地写出它,可能看起来像这样:
def fibo(n: int) -> int:
if n <= 1:
return 1
else:
return fibo(n-1) + fibo(n-2)
将像这个例子这样的东西转换成尾递归是困难的。我们有两种方法可以减少这个计算复杂度:
-
使用记忆化
-
重新表述问题
记忆化技术在 Python 中应用起来很简单。我们可以使用@functools.cache 作为装饰器。它看起来是这样的:
from functools import cache
@cache
def fibo_r(n: int) -> int:
if n < 2:
return 1
else:
return fibo_r(n - 1) + fibo_r(n - 2)
添加这个装饰器将优化更复杂的递归。
重新表述问题意味着从新的角度看待它。在这种情况下,我们可以考虑计算所有斐波那契数,直到并包括所需的 F[n]。我们只想要这个序列中的最后一个值。计算多个中间值可以相对高效。
这里是一个执行此操作的生成器函数:
from collections.abc import Iterator
def fibo_iter() -> Iterator[int]:
a = 1
b = 1
yield a
while True:
yield b
a, b = b, a + b
这个函数是斐波那契数的无限迭代。它使用 Python 的 yield 来以懒加载的方式发出值。当客户端函数使用这个迭代器时,序列中的下一个数字是在消耗每个数字时计算的。
这是一个消耗值并也对无限迭代器施加上限的函数:
def fibo_i(n: int) -> int:
for i, f_i in enumerate(fibo_iter()):
if i == n:
break
return f_i
这个函数消耗来自 fibo_iter() 迭代器的值序列。当达到所需的数字时,break 语句结束 for 语句。
我们已经优化了递归解决方案,并将其转换为避免栈溢出潜在问题的迭代。
3.8.5 参见
- 请参阅第二章中的 避免 break 语句的潜在问题 菜单。
3.9 使用脚本库开关编写可测试的脚本
创建 Python 脚本文件通常非常容易。当我们向 Python 提供脚本文件时,它立即运行。在某些情况下,没有函数或类定义;脚本文件是 Python 语句的序列。
这些脚本文件很难进行测试。此外,它们也很难重用。当我们想要从脚本文件集合中构建更大、更复杂的应用程序时,我们通常被迫将脚本重构成一个或多个函数。
3.9.1 准备工作
假设我们有一个名为 haversine() 的方便的 haversine 距离函数实现,它位于名为 recipe_11.py 的文件中。
该文件包含本章中 基于部分函数选择参数顺序 所示的函数和定义。这包括一个部分函数 nm_haversine(),用于计算海里距离。脚本还包含以下顶级代码:
source_path = Path("data/waypoints.csv")
with source_path.open() as source_file:
reader = csv.DictReader(source_file)
start = next(reader)
for point in reader:
d = nm_haversine(
float(start[’lat’]),
float(start[’lon’]),
float(point[’lat’]),
float(point[’lon’])
)
print(start, point, d)
start = point
这个 Python 脚本打开一个文件,data/wapypoints.csv,并对该文件进行一些处理。虽然这很方便使用,但我们无法轻松测试它。
如果我们尝试在单元测试中导入 haversine() 函数,我们将执行脚本的其它部分。我们如何重构这个模块,以便在不打印 wapypoints.csv 文件中航点之间距离显示的情况下导入有用的函数?
3.9.2 如何做...
编写 Python 脚本可以被称为一种吸引人的麻烦;它看起来简单吸引人,但很难有效地进行测试。以下是我们将脚本转换成可测试和可重用库的方法:
-
识别执行脚本工作的语句。这意味着区分定义和动作。如 import、def 和 class 这样的语句是定义性的——它们创建对象但不直接执行计算或产生输出的动作。几乎所有其他语句都执行某些动作。因为一些赋值语句可能是类型提示定义的一部分,或者可能创建有用的常量,所以这种区分完全是基于意图的。
-
在我们的例子中,有一些赋值语句比行动更像是定义。这些赋值语句类似于
def语句;它们只设置稍后使用的变量。以下是一些通常的定义性语句:from math import radians, sin, cos, sqrt, asin from functools import partial MI = 3959 NM = 3440 KM = 6373 def haversine( lat_1: float, lon_1: float, lat_2: float, lon_2: float, *, R: float) -> float: ... # etc.nm_haversine = partial(haversine, R=NM)模块中的其余语句旨在采取行动以产生打印结果。
-
将行动包装成一个函数。尽量选择一个描述性的名称。如果没有更好的名称,可以使用
main()。在这个例子中,行动计算距离,所以我们将函数命名为 distances()。def distances_draft(): source_path = Path("data/waypoints.csv") with source_path.open() as source_file: reader = csv.DictReader(source_file) start = next(reader) for point in reader: d = nm_haversine( float(start[’lat’]), float(start[’lon’]), float(point[’lat’]), float(point[’lon’]) ) print(start, point, d) start = point在上述例子中,我们将函数命名为 distances_draft() 以确保它与更最终的版本明显不同。实际上,在代码向完成演化的过程中使用这样的不同名称并不必要,除非在编写书籍时,单元测试中间步骤是必要的。
-
在可能的情况下,提取字面量并将它们转换为参数。这通常是将字面量简单移动到具有默认值的参数中的操作。
def distances( source_path: Path = Path("data/waypoints.csv") ) -> None: ... # etc.这使得脚本可重用,因为路径现在是一个参数而不是一个假设。
-
在脚本文件中包含以下 if 语句作为唯一的高级行动语句:
if __name__ == "__main__": distances()
我们已经将脚本的行动包装成一个函数。顶级行动脚本现在被一个 if 语句包裹,这样在导入时就不会执行,而是在直接运行脚本时执行。
3.9.3 它是如何工作的...
对于 Python 来说,一个模块的导入本质上等同于作为脚本运行该模块。文件中的语句按顺序从上到下执行。
当我们导入一个文件时,我们通常对执行 def 和 class 语句感兴趣。我们可能对一些定义有用全局变量的赋值语句感兴趣。有时,我们可能对执行主程序不感兴趣。
当 Python 运行脚本时,它会设置一些内置的特殊变量。其中之一是 __name__。这个变量的值取决于文件被执行的上下文:
-
从命令行执行的最高级脚本:在这种情况下,内置特殊名称
__name__的值被设置为 "main"。 -
由于导入语句而正在执行的文件:在这种情况下,
__name__的值是从读取文件并执行 Python 语句创建的模块的名称。
"main" 的标准名称一开始可能看起来有点奇怪。为什么在所有情况下不使用文件名?这个特殊名称被分配是因为 Python 脚本可以从许多来源读取。它可以是一个文件。Python 也可以从 stdin 管道读取,或者可以通过使用 -c 选项在 Python 命令行中提供。
3.9.4 更多...
我们现在可以围绕可重用库构建有用的工作。我们可能会创建一个看起来像这样的应用程序脚本文件:
from pathlib import Path
from ch03.recipe_11 import distances
if __name__ == "__main__":
for trip in ’trip_1.csv’, ’trip_2.csv’:
distances(Path(’data’) / trip)
目标是将实际解决方案分解为两个功能集合:
-
类和函数的定义
-
一个非常小的以动作为导向的脚本,使用定义来完成有用的工作
我们通常从一个将这两组功能合并在一起的脚本开始。这种脚本可以被视为一个峰值解决方案。一旦我们确定它可行,我们的峰值解决方案就可以向更精细的解决方案进化。峰值或锤子是登山装备的一部分,使我们能够安全地攀登。
在开始使用峰值(spike)之后,我们可以提升我们的设计,并将代码重构为定义和动作。然后测试可以导入模块来测试各种定义,而不执行可能会覆盖重要文件的行动。
3.9.5 参见
-
在第七章中,我们探讨了类定义。这些是除了函数定义之外,另一种广泛使用的定义性语句。
-
我们在第十一章中讨论的 使用 CSV 模块读取定界文件 的配方也解决了 CSV 文件读取的问题。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第四章:4
内置数据结构第一部分:列表和集合
Python 拥有丰富的内置数据结构。这些数据结构有时被称为“容器”或“集合”,因为它们包含一系列单独的项目。这些结构涵盖了广泛的常见编程场景。
我们将概述内置的各种集合以及它们解决的问题。概述之后,我们将详细探讨列表和集合。
内置的元组和字符串类型是第一章数字、字符串和元组的一部分。这些结构是序列,因此在许多方面与列表集合相似。然而,字符串和元组似乎与不可变的数字有更多的共同点。
下一章,第五章,将探讨字典,以及一些与列表和集合相关的高级主题。特别是,它将探讨 Python 如何处理可变集合对象的引用。这在需要将列表或集合作为参数的函数定义方式上有影响。
在本章中,我们将探讨以下配方,所有这些都与 Python 的内置数据结构相关:
-
选择数据结构
-
构建列表 - 字面量、追加和推导式
-
切片和切块列表
-
缩小列表 - 删除、移除和弹出
-
编写与列表相关的类型提示
-
反转列表的副本
-
构建集合 - 字面量、添加、推导式和运算符
-
缩小集合 - remove()、pop()和差集
-
编写与集合相关的类型提示
4.1 选择数据结构
Python 提供了一些内置数据结构来帮助我们处理数据集合。将数据结构功能与我们要解决的问题相匹配可能会令人困惑。
我们如何选择使用哪种结构?
4.1.1 准备工作
在我们将数据放入集合之前,我们需要考虑我们将如何收集数据,以及我们拥有集合后我们将做什么。一个重要的问题是如何在集合中识别特定的项目。Python 提供了多种选择。
4.1.2 如何做...
-
编程是否关注值的存不存在?一个例子是验证输入值。当用户输入集合中的内容时,他们的输入是有效的;否则,输入无效。简单的成员资格测试建议使用集合:
def confirm() -> bool: yes = {"yes", "y"} no = {"no", "n"} while (answer := input("Confirm: ")).lower() not in (yes | no): print("Please respond with yes or no") return answer in yes集合以无特定顺序的方式存储项目。如果顺序很重要,那么列表更合适。
-
我们是否将通过在集合中的位置来识别项?一个例子包括输入文件中的行——行号是其在集合中的位置。当我们使用索引或位置来识别项时,我们必须使用列表:
>>> month_name_list = ["Jan", "Feb", "Mar", "Apr", ... "May", "Jun", "Jul", "Aug", ... "Sep", "Oct", "Nov", "Dec"] >>> month_name_list[8] ’Sep’ >>> month_name_list.index("Feb") 1我们已经创建了一个名为
month_name_list的列表,其中包含 12 个字符串项,并按照特定顺序排列。我们可以通过提供索引位置来选择一个项。我们还可以使用index()方法来返回列表中项的索引位置。Python 中的列表索引值始终从零开始。虽然列表具有简单的成员资格测试,但对于非常大的列表,这种测试可能会很慢,如果需要执行许多此类测试,那么集合可能是一个更好的选择。如果集合中的项数是固定的——例如,RGB 颜色有三个值——这表明元组而不是列表是一个更好的选择。如果项的数量将增长和变化,那么列表集合比元组集合是一个更好的选择。
-
我们是否将通过与项的索引不同的键值来识别集合中的项?一个例子可能包括字符字符串(例如单词)与表示这些单词频率的整数之间的映射。另一个例子可能是一个颜色名称与该颜色的 RGB 元组之间的映射。我们将在第五章“内置数据结构第二部分:字典”中查看映射和字典。重要的区别是映射不像列表那样通过数值索引位置来定位项。
-
考虑集合中项的可变性(以及字典中的键)。集合中的每个项都必须是不可变对象。数字、字符串和元组都是不可变的,可以收集到集合中。由于列表、字典和集合对象是可变的,因此不能用作集合中的项。例如,不可能构建一个由列表对象组成的集合。
我们可以选择将每个列表项转换为一个不可变的元组对象,而不是创建一组列表项。同样,字典键也必须是不可变的。我们可以使用数字、字符串或元组作为字典键。我们不能使用列表、集合或任何其他可变对象作为字典键。
4.1.3 它是如何工作的...
每个 Python 内置的集合都提供了一组独特的功能。集合还提供了大量重叠的功能。对于 Python 新手程序员来说,挑战在于将每个集合的独特功能映射到他们试图解决的问题。
collections.abc模块提供了一种通过内置容器类的路线图。该模块定义了我们使用的具体类背后的抽象基类(ABC)。我们将使用这些定义中的名称来指导我们了解其功能。
从 ABC 中,我们可以看到有三类总共有六种实现选择的集合:
-
集合:它的独特之处在于项要么是成员,要么不是。这意味着重复项被忽略:
-
可变集合:内置的 set 集合
-
不可变集合:内置的 frozenset 集合
-
-
序列:其独特之处在于项目提供了索引位置:
-
可变序列:内置的列表集合
-
不可变序列:内置的 tuple 集合。这是第一章中一些菜谱的主题。
-
-
映射:其独特之处在于每个项目都有一个键,该键指向一个值:
-
可变映射:内置的 dict 集合。这是第五章的主题。
-
不可变映射:有趣的是,没有内置的 frozen 映射。
-
Python 的库提供了这些核心集合类型的额外实现。collections 模块包括:
-
namedtuple:一个元组,为元组中的每个项目提供名称。使用 rgb_color.red 比使用 rgb_color[0] 更清晰。
-
deque:一个双端队列。它是一个可变序列,对从两端推入和弹出进行了优化。我们可以用列表做类似的事情,但当需要两端的变化时,deque 更有效率。
-
defaultdict:一个 dict,可以为缺失的键提供一个默认值。
-
Counter:一个 dict,设计用来计数键的出现的次数。这有时被称为多重集合或包。
-
ChainMap:一个 dict,可以将多个字典组合成一个映射。
此外,还有一个较旧的 OrderedDict 类。这个类保留了键的创建顺序。从 Python 3.7 开始,普通字典的键保留了创建顺序,使得 OrderedDict 类变得冗余。
4.1.4 更多...
Python 标准库中还有更多。我们还可以使用 heapq 模块,它定义了一种列表,充当高性能优先队列。bisect 模块包括快速搜索排序列表的方法。这使得我们可以创建一个列表对象,其性能可以接近字典的非常快速的查找。
我们可以在总结网页上找到数据结构的描述,如这个:thealgorist.com。我们将快速查看四个额外的数据结构家族:
-
数组:Python 的 array 模块支持密集填充的值数组。numpy 模块也提供了非常复杂化的数组处理。
-
树:通常,树结构可以用来创建集合、顺序列表或键值映射。我们可以将树视为构建集合或字典的实现技术。我们通常使用对象和类定义来构建树结构。
-
哈希:Python 使用哈希来实现字典和集合。这导致速度良好,但可能消耗大量内存。
-
图:Python 没有内置的图数据结构。然而,我们可以通过一个字典轻松地表示图结构,其中每个节点都有一个相邻节点的列表。像 NetworkX、Pyoxigraph 和 RDFLib 这样的外部库支持复杂的图数据库。
通过一点巧妙的方法,我们可以在 Python 中实现几乎任何类型的数据结构。虽然内置结构通常具有基本功能,但我们可能能够找到可以投入使用的内置结构。我们将在第五章内置数据结构第二部分:字典中探讨映射和字典。
4.1.5 参见
-
对于高性能数组处理,请参阅
numpy.org。 -
对于高级图分析,请参阅
networkx.github.io。 -
对于图操作和存储,请参阅
pyoxigraph.readthedocs.io/en/stable/。 -
对于图操作,请参阅
rdflib.readthedocs.io/en/stable/。
4.2 构建列表 – 字面量、追加和推导式
如果我们决定根据容器中每个项目的位置创建一个集合——列表——我们有几种构建这种结构的方法。我们将探讨从单个项目组装列表对象的各种方法。
在某些情况下,我们需要一个列表,因为它允许重复的值,与集合不同。这在统计工作中很常见。另一种称为多重集的结构,对于允许重复的统计导向集合也很有用。这个集合在标准库中作为 collections.Counter 提供。
4.2.1 准备工作
假设我们需要对一些文件大小进行统计分析。以下是一个简短的脚本,它将为我们提供一些文件的大小:
>>> from pathlib import Path
>>> home = Path.cwd() / "data"
>>> for path in sorted(home.glob(’*.csv’)):
... print(path.stat().st_size, path.name)
260 binned.csv
250 ch14_r03.csv
2060 ch14_r04.csv
45 craps.csv
225 fuel.csv
156 fuel2.csv
28 output.csv
19760 output_0.csv
19860 output_1.csv
19645 output_2.csv
19971 output_3.csv
19588 output_4.csv
...
我们已经使用 pathlib.Path 对象来表示文件系统中的目录。glob()方法扩展所有与给定模式匹配的名称。
我们希望累积一个包含各种文件大小的列表对象。从该列表中,我们可以计算总大小和平均大小。
4.2.2 如何做...
我们有许多创建列表对象的方法:
-
字面量:我们可以使用方括号[]字符包围的值序列创建列表的字面量显示。例如,[1, 2, 3]。Python 需要匹配一个开方括号[和一个闭方括号]来看到一条完整的逻辑行,因此字面量可以跨越物理行。有关更多信息,请参阅第二章编写长行代码食谱。
-
转换函数:我们可以使用 list()函数将其他数据集合转换为列表。
-
追加方法:我们有允许我们逐个构建列表的方法。这些方法包括 append()、extend()和 insert()。我们将在本食谱的使用 append()方法构建列表部分探讨 append()方法。
-
理解:理解是一种特殊的生成器表达式,它从源对象计算出一个列表。我们将在本食谱的编写列表理解部分详细探讨这一点。
创建列表的前两种方法是单个 Python 表达式。最后两种更复杂,我们将展示每种方法的配方。
使用 append()方法构建列表
-
使用字面语法[]或 list()函数创建一个空列表:
>>> file_sizes = [] -
通过 append()方法遍历一些数据源。将项目添加到列表中:
>>> home = Path.cwd() / "data" >>> for path in sorted(home.glob(’*.csv’)): ... file_sizes.append(path.stat().st_size) >>> print(file_sizes) [260, 250, 2060, 45, 225, 156, 28, 19760, 19860, 19645, 19971, 19588, 19999, 20000, 20035, 19739, 19941, 215, 412, 28, 166, 0, 1810, 0, 0, 16437, 20295] >>> print(sum(file_sizes)) 240925
当我们打印列表时,Python 会以字面符号显示它。如果我们需要将列表复制并粘贴到另一个脚本中,这很方便。
非常重要的是要注意,append()方法不返回任何值。append()方法会修改列表对象,并且不返回任何内容。
编写列表推导式
列表推导式的目标是创建一个对象,它占据字面表达式的语法角色:
-
写出包围要构建的列表对象的[]括号。
-
写出数据源。这将包括目标变量。请注意,for 子句的末尾没有冒号,因为我们不是在写一个完整的语句:
[... for path in home.glob(’*.csv’)] -
在 for 子句前加上一个表达式来评估,以创建每个值,这些值来自目标变量的值。再次强调,由于这只是一个单一的表达式,我们在这里不能使用复杂语句:
[path.stat().st_size for path in home.glob(’*.csv’)]
这是一个列表对象构建的例子:
>>> [path.stat().st_size
... for path in sorted(home.glob(’*.csv’))]
[260, 250, 2060, 45, 225, 156, 28, 19760, 19860, 19645, 19971, 19588, 19999, 20000, 20035, 19739, 19941, 215, 412, 28, 166, 0, 1810, 0, 0, 16437, 20295]
现在我们已经创建了一个列表对象,我们可以将其分配给一个变量,并对数据进行其他计算和总结。
列表推导式是围绕一个中心生成器表达式构建的,在语言手册中称为推导式。推导式有两个部分:数据表达式子句和 for 子句。数据表达式子句会被反复评估,由 for 子句中分配的变量驱动。
我们可以用 list()函数替换包围要构建的列表对象的[]括号。当我们考虑数据结构可能发生变化的可能性时,使用显式的 list()函数有一个优点。我们可以轻松地将 list()替换为 set()或 Counter(),以利用核心生成器,同时创建一个不同的集合类型。
4.2.3 它是如何工作的...
Python 列表对象具有动态大小。当项目被追加或插入,或者列表通过另一个序列的项目扩展时,大小会调整。同样,当项目被弹出或删除时,大小会缩小。
在罕见的情况下,我们可能想要创建一个具有给定初始大小的列表,然后分别设置项目的值。我们可以使用列表推导式来完成这个操作,如下所示:
>>> sieve = [True for i in range(100)]
这将创建一个包含 100 个初始项的列表,每个项都是 True。我们可能需要这种初始化来实现埃拉托斯特尼筛法(Sieve of Eratosthenes)算法:
>>> sieve[0] = sieve[1] = False
>>> for p in range(100):
... if sieve[p]:
... for n in range(p*2, 100, p):
... sieve[n] = False
>>> prime = [p for p in range(100) if sieve[p]]
筛选集合包含一系列 True 和 False 值。每个 True 的索引位置是一个素数。从 p² 开始的每个素数 p 的倍数被设置为 False。素数集合是一个值序列,p 是 sieve[p] 表达式为 True 的素数。
4.2.4 更多内容...
创建列表对象的一个常见目标是能够总结它。我们可以使用各种 Python 函数来做这件事。以下是一些示例:
>>> sizes = list(path.stat().st_size
... for path in home.glob(’*.csv’))
>>> sum(sizes)
240925
>>> max(sizes)
20295
>>> min(sizes)
0
>>> from statistics import mean
>>> round(mean(sizes), 3)
8923.148
我们使用了内置的 sum()、min() 和 max() 方法来生成这些文档大小的描述性统计信息。哪个索引文件是最小的?我们想知道值列表中最小值的索引位置。我们可以使用 index() 方法来做这件事:
>>> sizes.index(min(sizes))
1
我们找到了最小值,然后使用 index() 方法定位那个最小值的索引位置。
扩展列表的其他方法
我们可以扩展列表对象,也可以将其插入到列表的中间或开头。我们有两种扩展列表的方法:我们可以使用 + 运算符,或者我们可以使用 extend() 方法。以下是一个使用 + 运算符将两个列表合并的示例:
>>> home = Path.cwd() / "src"
>>> ch3 = list(path.stat().st_size
... for path in home.glob(’ch03/*.py’))
>>> ch4 = list(path.stat().st_size
... for path in home.glob(’ch04/*.py’))
>>> len(ch3)
16
>>> len(ch4)
6
>>> final = ch3 + ch4
>>> len(final)
22
>>> sum(final)
34853
我们创建了一个包含文档大小的列表,文档名称类似于 Chapter_03/.py。然后我们创建了一个包含文档大小的第二个列表,其名称模式略有不同,Chapter_04/.py。然后我们将这两个列表合并成一个最终的列表。
我们可以在列表中的任何特定位置之前插入一个值。insert() 方法接受一个项目的位置;新值将在给定位置之前:
>>> p = [3, 5, 11, 13]
>>> p.insert(0, 2)
>>> p
[2, 3, 5, 11, 13]
>>> p.insert(3, 7)
>>> p
[2, 3, 5, 7, 11, 13]
我们已经向列表对象中插入了两个新值。与 append() 和 extend() 方法一样,insert() 方法不返回任何值。它修改了列表对象。
4.2.5 参考信息
-
参考关于切片和切块列表的配方,了解复制列表和从列表中挑选子列表的方法。
-
参考关于缩小列表 - 删除、移除和弹出的配方,了解从列表中移除项目的其他方法。
-
在反转列表副本的配方中,我们将查看如何反转列表。
-
本文提供了一些关于 Python 集合内部工作方式的见解:
wiki.python.org/moin/TimeComplexity。在查看表格时,重要的是要注意表达式 O(1) 表示成本基本上是常数。表达式 O(n) 表示成本随着集合大小的增长而增长。
4.3 切片和切块列表
有很多次我们想要从列表中挑选项目。最常见的一种处理方式是将列表的第一个项目视为特殊情况。这导致了一种头尾处理方式,其中我们对待列表的头与对待列表尾的项目不同。
我们可以使用这些技术来复制列表。
4.3.1 准备工作
我们有一个用于记录大型帆船燃油消耗的电子表格。它的行看起来像这样:
|
|
|
|
|
| 日期 | 引擎开启 | 燃料高度 | |
|---|---|---|---|
| 引擎关闭 | 燃料高度 | ||
| 其他备注 |
|
|
|
|
|
| 10/25/2013 | 08:24:00 AM | 29 | |
|---|---|---|---|
| 01:15:00 PM | 27 | ||
| 平静大海 – 锚定所罗门岛 |
|
|
|
|
|
| 10/26/2013 | 09:12:00 AM | 27 | |
|---|---|---|---|
| 06:25:00 PM | 22 | ||
| choppy – 锚定在杰克逊溪 |
|
|
|
|
|
表 4.1:帆船燃油使用示例
在这个数据集中,燃料是通过高度来测量的。这是因为使用了深度为英寸的视距计,校准。对于所有实际目的,油箱是矩形的,所以显示的深度可以转换成体积,因为我们知道 31 英寸的深度大约是 75 加仑。
这个电子表格数据的例子没有正确规范化。理想情况下,所有行都遵循数据的第一范式:一行应该有相同的内容,每个单元格应该只有原子值。在这个数据中,有三种子类型的行:
-
三行组的第一行包含引擎开启日期、时间和一个测量值。
-
一组中的第二行包含引擎关闭时间和一个测量值。
-
第三行有一些不太有用的备注。
这种非规范化数据包括以下两个问题:
-
.csv 文件有四行标题。(第四行是一个空白行,在此格式良好的书中未显示。)这是 csv 模块无法直接处理的事情。
-
每天的旅行分布在三行中。这些行必须合并起来,以便更容易计算经过的时间和使用的燃料英寸数。
我们可以使用如下定义的函数来读取数据:
import csv
from pathlib import Path
def get_fuel_use(path: Path) -> list[list[str]]:
with path.open() as source_file:
reader = csv.reader(source_file)
log_rows = list(reader)
return log_rows
我们已经使用 csv 模块读取了日志细节。csv.reader()函数返回的对象是可迭代的。为了将项目收集到一个单独的列表中,我们对可迭代对象应用了 list()函数;这从读取器创建了一个列表对象。
原始 CSV 文件的每一行都是一个列表。以下是第一行和最后一行的样子:
>>> log_rows[0]
[’date’, ’engine on’, ’fuel height’]
>>> log_rows[-1]
[’’, "choppy -- anchor in jackson’s creek", ’’]
对于这个食谱,我们将使用列表索引表达式的扩展来从行列表中切片项。切片,就像索引表达式一样,跟在列表对象后面的[]字符中。Python 提供了几种切片表达式的变体,以便我们可以从行列表中提取有用的子集。
4.3.2 如何操作...
-
我们需要做的第一件事是从行列表中删除四行标题。我们将使用两个部分切片表达式来通过第四行分割列表:
>>> head, tail = log_rows[:4], log_rows[4:] >>> head[0] [’date’, ’engine on’, ’fuel height’] >>> head[-1] [’’, ’’, ’’] >>> tail[0] [’10/25/13’, ’08:24:00 AM’, ’29’] >>> tail[-1] [’’, "choppy -- anchor in jackson’s creek", ’’]我们使用 log_rows[:4]和 log_rows[4:]将列表切分成两部分。第一个切片表达式选择前四行;这被分配给 head 变量。第二个切片表达式选择从第 4 行到列表末尾的行。这被分配给 tail 变量。这是我们关心的表格行。
-
我们将使用带有步骤的切片来选择有趣的行。切片的 start:stop:step 版本将根据步骤值分组选择行。在我们的例子中,我们将取两个切片。一个切片从行零开始——“引擎开启”行——另一个切片从行一开始——“引擎关闭”行。
这里是每第三行的切片,从行零开始:
>>> pprint(tail[0::3], width=64) [[’10/25/13’, ’08:24:00 AM’, ’29’], [’10/26/13’, ’09:12:00 AM’, ’27’]]我们已经使用了来自 pprint 模块的 pprint() 函数来使输出更容易阅读。
从行一开始,每第三行有额外的数据:
>>> pprint(tail[1::3], width=48) [[’’, ’01:15:00 PM’, ’27’], [’’, ’06:25:00 PM’, ’22’]] -
这两个切片然后可以组合在一起以创建一对对的列表:
>>> paired_rows = list(zip(tail[0::3], tail[1::3])) >>> pprint(paired_rows) [([’10/25/13’, ’08:24:00 AM’, ’29’], [’’, ’01:15:00 PM’, ’27’]), ([’10/26/13’, ’09:12:00 AM’, ’27’], [’’, ’06:25:00 PM’, ’22’])]这给我们一个由三个元组的对组成的序列。这非常接近我们可以处理的东西。
-
展平结果:
>>> paired_rows = list(zip(tail[0::3], tail[1::3])) >>> combined = [a+b for a, b in paired_rows] >>> pprint(combined) [[’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’], [’10/26/13’, ’09:12:00 AM’, ’27’, ’’, ’06:25:00 PM’, ’22’]]我们已经使用了一个列表推导式从 构建列表 – 字面量、追加和推导式 章节中结合每对行的两个元素以创建一个单行。这有更规范化的数据描述每段航程。
从结果列表中,我们现在可以计算时间差以得到船的运行时间。我们可以计算高度差以估计每段旅程中消耗的燃料。这个包含五个有用项——日期、时间、高度、时间、高度——的单行列表包含了所需的所有数据。它还有一个通常包含空字符串的列。
4.3.3 它是如何工作的...
切片操作符有几种不同的形式:
-
[:]: 开始和结束是隐含的。表达式 S[:] 将创建序列 S 的一个副本。
-
[start:stop]: 这将选择一个子列表,从起始索引开始,到停止索引之前结束。Python 使用半开区间。起始索引包含在内,而停止索引不包含。
-
[::step]: 开始和结束是隐含的,包括整个序列。步骤——通常不等于一——意味着我们将使用步骤从开始跳过列表。对于给定的步骤,s,和大小为 |L| 的列表,索引值是 i ∈{s×n∣n ∈ℕ and 0 ≤ s×n < |L|}。
-
[start::step]: 起始是给定的,但停止是隐含的。想法是起始是一个偏移量,步骤应用于该偏移量。对于给定的起始,a,步骤,s,和大小为 |L| 的列表,索引值是 i ∈{s × n + a∣n ∈ℕ and 0 ≤ s × n + a < |L|}。
切片技术适用于列表、元组、字符串以及任何其他类型的序列。切片不会导致集合被修改;相反,切片将序列的一部分制作成副本。现在,源集合中的项在集合之间共享。
4.3.4 更多...
在反转列表副本的菜谱中,我们将探讨切片表达式的一种更复杂的使用方法。
序列的副本被称为浅拷贝,因为将会有两个集合,每个集合都包含对相同底层对象的引用。我们将在制作浅拷贝和深拷贝的对象的菜谱中详细探讨这一点。
对于这个特定的例子,我们还有另一种将多行数据重新结构化为单行数据的方法:我们可以使用生成器函数。我们将在第九章中在线查看函数式编程技术。
4.3.5 参考信息
-
请参考构建列表 - 字面量、追加和推导的菜谱,了解创建列表的方法。
-
请参考缩小列表 - 删除、移除和弹出的菜谱,了解从列表中删除项的其他方法。
-
在反转列表副本的菜谱中,我们将探讨如何反转列表。
-
pandas 包提供了一些与 CSV 文件一起工作的额外方法。
4.4 缩小列表 - 删除、移除和弹出
有很多次我们想要从列表集合中删除项。我们可能从列表中删除项,然后处理剩下的项。
移除不需要的项与使用 filter() 创建只包含所需项的副本的效果相似。区别在于,过滤后的副本将比从列表中删除项使用更多的内存。我们将展示两种从可变列表中删除不需要项的技术。
4.4.1 准备工作
我们有一个用于记录大型帆船燃油消耗的电子表格。请参阅表 4.1 中的数据。
关于此数据的更多背景信息,请参考本章前面的切片和切块列表菜谱。get_fuel_use() 函数将收集原始数据。需要注意的是,此数据的结构——每个事实分布在三个单独的行中——非常糟糕,需要相当多的注意来重建更有用的东西。
原始 CSV 文件的每一行都是一个列表。这些列表中的每一个都包含三个项。删除包含标题和无用数据的某些行是至关重要的。
4.4.2 如何操作...
我们将探讨几种从列表中删除项的方法:
-
del 语句。
-
remove() 方法。
-
pop() 方法。
-
我们还可以使用切片赋值来替换列表中的项。
del 语句
我们可以使用 del 语句从列表中删除项目。我们可以提供一个对象和一个切片来从列表对象中删除一组行。以下是 del 语句的示例:
>>> del log_rows[:4]
>>> log_rows[0]
[’10/25/13’, ’08:24:00 AM’, ’29’]
>>> log_rows[-1]
[’’, "choppy -- anchor in jackson’s creek", ’’]
del 语句删除了前四行,留下了我们真正需要处理的行。然后我们可以将这些行组合起来,使用 Slicing and dicing a list 配方进行总结。
remove()方法
我们可以使用 remove()方法从列表中删除项目。给定一个特定的值,它会从列表中删除匹配的项目。
我们可能有一个看起来像这样的列表:
>>> row = [’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’]
我们可以删除列表中的无用“”项目:
>>> row.remove(’’)
>>> row
[’10/25/13’, ’08:24:00 AM’, ’29’, ’01:15:00 PM’, ’27’]
注意到 remove()方法不返回值。它会在原地修改列表。
如构建列表 – 字面量、追加和推导配方中所述,以下代码是不正确的:
a = [’some’, ’data’]
a = a.remove(’data’)
这绝对是错误的。这将使 a 变为 None。
pop()方法
我们可以使用 pop()方法从列表中删除项目。这是基于它们的索引从列表中删除项目。
我们可能有一个看起来像这样的列表:
>>> row = [’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’]
这包含了一个无用的“”字符串。我们可以找到要弹出项目的索引,然后删除它。以下示例将此代码分解为几个独立的步骤:
>>> target_position = row.index(’’)
>>> target_position
3
>>> row.pop(target_position)
’’
>>> row
[’10/25/13’, ’08:24:00 AM’, ’29’, ’01:15:00 PM’, ’27’]
注意到 pop()方法做了两件事:
-
它通过修改列表对象来删除一个项目。
-
它还返回了被删除的项目。
这种修改和返回值的组合很少见,使得这种方法独特。
切片赋值
我们可以通过在赋值语句的左侧使用切片表达式来替换列表中的项目。这让我们可以替换列表中的项目。当替换的大小不同时,它允许我们扩展或收缩列表。这导致了一种使用切片赋值从列表中删除项目的技术。
我们将从位置 3 有一个空值的行开始。这看起来像这样:
>>> row = [’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’]
>>> target_position = row.index(’’)
>>> target_position
3
我们可以将一个空列表赋给从索引位置 3 开始到索引位置 4 之前的切片。这将用一个零项切片替换一个单项切片,从而从列表中删除该项目:
>>> row[3:4] = []
>>> row
[’10/25/13’, ’08:24:00 AM’, ’29’, ’01:15:00 PM’, ’27’]
del 语句和 remove()、pop()等方法似乎清楚地表明了从集合中删除项目的意图。切片赋值可能不太明确,因为它没有明显的函数名。然而,它确实可以很好地用于删除可以用切片表达式描述的一组项目。
4.4.3 它是如何工作的...
因为列表是一个可变对象,所以我们可以从列表中删除项目。这种技术不适用于元组或字符串,因为它们是不可变的。
我们只能删除列表中存在的索引的项目。如果我们尝试删除索引超出允许范围的项,我们将得到 IndexError 异常。
以下示例尝试从一个索引值为零、一和二的列表中删除索引为三的项目:
>>> row = [’’, ’06:25:00 PM’, ’22’]
>>> del row[3]
Traceback (most recent call last):
...
IndexError: list assignment index out of range
4.4.4 更多内容...
在 Python 的几个地方,从列表对象中删除可能会变得复杂。如果我们在一个 for 语句中使用列表对象,我们无法从列表中删除项。这样做会导致迭代控制与底层对象的内部状态之间出现意外的冲突。
假设我们想要从列表中删除所有偶数项。以下是一个不正确工作的示例:
>>> data_items = [1, 1, 2, 3, 5, 8, 10,
... 13, 21, 34, 36, 55]
>>> for f in data_items:
... if f % 2 == 0:
... data_items.remove(f)
>>> data_items
[1, 1, 3, 5, 10, 13, 21, 36, 55]
源列表有几个偶数值。结果是明显不正确的;10 和 36 的值仍然在列表中。为什么有些偶数值项被留在列表中?
让我们看看处理 data_items[5]时会发生什么;它的值为 8。当 remove(8)方法被评估时,该值将被移除,列表中的所有后续值都将向前滑动一个位置。10 值将移动到 5 的位置,即 8 值之前的位置。迭代控制值将前进到下一个位置,该位置将包含 13。10 值将不会被处理。
我们有几种方法可以避免删除时跳过的问题:
-
制作列表的副本:
>>> for f in data_items[:]: ... if f % 2 == 0: ... data_items.remove(f) -
使用 while 语句并显式维护索引值:
>>> position = 0 >>> while position != len(data_items): ... f = data_items[position] ... if f % 2 == 0: ... data_items.remove(f) ... else: ... position += 1我们设计了一个 while 语句,只有当 data_items[position]的值为奇数时才增加位置变量。如果值为偶数,则该值将被移除,这也意味着列表中的其他项将向前移动一个位置;保持位置变量的值不变是至关重要的。
-
我们也可以以相反的顺序遍历列表。表达式 range(len(row)-1, -1, -1)将产生从-1 开始的递减索引。这是因为负索引值从列表的末尾向前工作。row[-1]是最后一个项。
4.4.5 参见
-
请参阅构建列表 - 字面量、追加和推导配方,了解创建列表的方法。
-
请参阅切片和切块列表配方,了解复制列表和从列表中选取子列表的方法。
-
在反转列表副本配方中,我们将查看如何反转列表。
4.5 编写与列表相关的类型提示
typing 模块提供了一些描述列表对象内容的必要类型定义。主要类型定义是 list,我们可以用列表中项的类型来参数化它。它通常看起来像 list[int]。
4.5.1 准备工作
我们将查看一个包含两种类型的元组的列表。一些元组是简单的 RGB 颜色。其他元组是某些计算结果的 RGB 颜色。这些是从浮点值而不是整数构建的。我们可能有一个类似这样的异构列表结构:
(’ Brick_Red’, (198, 45, 66)),
(’ color1’, (198.00, 100.50, 45.00)),
(’ color2’, (198.00, 45.00, 142.50)),
]
列表中的每一项都是一个包含颜色名称和 RGB 值元组的二元组。RGB 值表示为整数或浮点值的三个元组。这可能会难以用类型提示来描述。
我们有两个与此数据相关的函数。第一个从 RGB 值创建颜色代码。
重要的规则是将每个组件(红色、绿色或蓝色)视为一个 8 位数字,一个介于 0 到 255 之间的值。这三个值通过将红色值左移 16 位和绿色值左移 8 位来组合。Python 的<<运算符执行必要的位移动。|运算符执行“或”操作,将移动的位组合成一个新的整数值。
这个函数的类型提示并不复杂:
def hexify(r: float, g: float, b: float) -> str:
return f’#{int(r) << 16 | int(g) << 8 | int(b):06X}’
:06X 格式规范产生一个 6 位的十六进制值。
另一种方法是使用表达式 f"#{int(r):02X}{int(g):02X}{int(b):02X}"将每个颜色视为一个单独的十六进制数字对。这使用了三个:02X 格式规范来为每个颜色组件产生 2 位的十六进制值。
当我们使用这个函数从 RGB 数字创建颜色字符串时,它看起来像这样:
>>> hexify(198, 45, 66)
’#C62D42’
然而,另一个函数可能有些令人困惑。这个函数将一个复杂的颜色列表转换成另一个包含十六进制颜色代码的列表:
def source_to_hex_0(src):
return [
(n, hexify(*color)) for n, color in src
]
我们需要添加类型提示以确保这个函数正确地将颜色列表从数字形式转换为字符串代码形式。
我们在函数名上添加了一个 _0 后缀来区分它和随后的示例。这并不是最佳实践,但我们发现它有助于澄清像这本书中展示的代码:
4.5.2 如何做...
我们首先通过添加类型提示来描述输入列表的各个项目,例如之前显示的 scheme 变量:
-
首先定义结果类型。通常,关注结果并从源数据回溯到产生预期结果所需的数据是有帮助的。在这种情况下,结果是包含颜色名称和颜色十六进制代码的两个元组的列表。我们可以将其描述为 list[tuple[str, str]],但这种总结隐藏了一些重要的细节。我们更喜欢以下方式暴露这些细节:
ColorCode = tuple[str, str] ColorCodeList = list[ColorCode]这个列表可以看作是同质的;每个项目都将匹配 ColorCode 类型定义。
-
定义源类型。在这种情况下,我们有两种稍微不同的颜色定义类型。虽然它们往往重叠,但它们的起源不同,处理历史有时有助于作为类型提示的一部分:
from typing import Union RGB_I = tuple[int, int, int] RGB_F = tuple[float, float, float] ColorRGB = tuple[str, Union[RGB_I, RGB_F]] ColorRGBList = list[ColorRGB]我们定义了基于整数的 RGB 三元组为 RGB_I,基于浮点数的 RGB 三元组为 RGB_F。这两种替代类型组合成 ColorRGB 元组定义。这是一个二元组;第二个元素可以是 RGB_I 类型或 RGB_F 类型的实例。存在联合类型意味着这个列表是异构的。
我们也可以使用 RGB_I | RGB_F 代替 Union[RGB_I, RGB_F]。
-
更新函数以包含类型提示。输入将是一个类似于之前显示的模式对象的列表。结果将是一个与 ColorCodeList 类型描述相匹配的列表:
def source_to_hex(src: ColorRGBList) -> ColorCodeList: return [ (n, hexify(*color)) for n, color in src ]
4.5.3 它是如何工作的...
list[T] 类型提示需要一个单一值 T 来描述可以成为此列表一部分的所有对象类型。对于同质列表,类型是直接声明的。对于异构列表,必须使用 Union 来定义可能存在的各种类型。
我们采取的方法将类型提示分解为两层:
-
一个描述集合中单个项目的“基础”层。我们定义了三种原始项目类型:RGB_I 和 RGB_F 类型,以及由此产生的 ColorCode 类型。
-
多个“组合”层,将基础类型组合成复合对象的描述。在这种情况下,ColorRGB、ColorRGBList 和 ColorCodeList 都是复合类型定义。
一旦命名了类型,然后使用定义函数、类和方法使用这些名称。
在阶段定义类型很重要,以避免长而复杂的类型提示,这些类型提示不会提供任何关于正在处理的对象的有用见解。避免像这样的类型描述是好的:
list[tuple[str, Union[tuple[int, int, int], tuple[float, float, float]]]]
虽然这在技术上正确,但由于其复杂性,难以理解。将复杂类型分解成有用的组件描述有助于理解。
4.5.4 更多...
类型提示假设列表中的每个项目都是单一类型。语法 list[T] 表示所有项目都是类型 T。
在异构列表的情况下,具有多个不同类型,我们需要定义类型的联合。我们可以从 typing 模块导入 Union 类型。或者我们可以使用 | 来为列表提供替代类型。
使用类似 list[RGB_I | RGB_F] 的结构描述了一个包含混合类型项目的列表。
4.5.5 参见
-
在第一章,使用 NamedTuples 简化元组中的项目访问的配方提供了一些澄清元组类型提示的替代方法。
-
编写与集合相关的类型提示的配方从集合类型的视角涵盖了这一点。
4.6 反转列表的副本
一些算法以相反的顺序产生结果。通常会在列表中收集输出,然后反转列表。作为一个例子,我们将查看将数字转换为特定基数时通常是从最低有效位到最高有效位生成的。我们通常希望以最高有效位首先显示值。这导致需要反转列表中数字的顺序。
4.6.1 准备工作
假设我们在进行基数之间的转换。我们将查看一个数字在基数中的表示方式,以及如何从数字计算该表示。
任何值 v 都可以定义为给定基数 b 中各种数字 d[n] 的多项式函数。一个四位数将具有 ⟨d[3],d[2],d[1],d[0]⟩ 作为数字序列。
注意,我们在这里使用的顺序与 Python 列表中项目的通常顺序相反。
这个由数字组成的序列的值 v 由以下多项式给出:

例如,十六进制数 0xBEEF 有以下数字:⟨B = 11,E = 14,E = 14,F = 15⟩,基数 b = 16:

有许多情况下基数不是某个数的连续幂。例如,ISO 日期格式有一个混合基数,涉及每周 7 天,每天 24 小时,每小时 60 分钟,每分钟 60 秒。
而不是 b⁴, b³, b², b¹ = b, 和 b⁰ = 1,我们有 7 × 24 × 60 × 60, 24 × 60 × 60, 60 × 60, 和 60 作为计算多项式的各种值。
给定一个周数,一周中的某一天,一个小时,一个分钟和一个秒,我们可以在给定的年份内计算秒数时间戳 t[s]:

例如:
>>> week = 13
>>> day = 2
>>> hour = 7
>>> minute = 53
>>> second = 19
>>> t_s = (((week*7+day)*24+hour)*60+minute)*60+second
>>> t_s
8063599
这显示了如何将给定的时间点转换为时间戳。我们如何反转这个计算?我们如何从整体时间戳中获取各个字段?
我们需要使用 divmod 风格的除法。有关背景信息,请参阅在真除法和取整除法之间选择配方。
将秒数时间戳 t[s] 转换为单独的周、天和时间字段的算法如下:
| t[m]; s | = ⌊ ⌋; t[s] mod 60 |
||
|---|---|---|---|
| t[h]; m | = ⌊ ⌋; t[m] mod 60 |
||
| t[d]; h | = ⌊ ⌋; t[h] mod 24 |
||
| w; d | = ⌊ ⌋; t[d] mod 7 |
这有一个实用的模式,导致实现。它产生值的顺序是相反的:
>>> t_s = 8063599
>>> fields = []
>>> for base in 60, 60, 24, 7:
... t_s, f = divmod(t_s, base)
... fields.append(f)
>>> fields.append(t_s)
>>> fields
[19, 53, 7, 2, 13]
我们已经四次应用了 divmod() 函数,从以秒为单位的时间戳中提取秒、分钟、小时、天和周。它们的顺序是错误的。我们如何将它们反转?
4.6.2 如何做到这一点...
我们有三种方法:我们可以使用 reverse() 方法,我们可以使用 [::-1] 切片表达式,或者我们可以使用 reversed() 内置函数。以下是 reverse() 方法:
>>> fields_copy1 = fields.copy()
>>> fields_copy1.reverse()
>>> fields_copy1
[13, 2, 7, 53, 19]
我们创建了原始列表的副本,这样我们就可以保留一个未修改的副本以与修改后的副本进行比较。这使得跟踪示例变得更容易。我们应用了 reverse() 方法来反转列表的副本。
这将修改列表。与其他修改方法一样,它不会返回一个有用的值。使用像 a = b.reverse(); 这样的语句是不正确的,a 的值始终是 None。
这是一个带有负步长的切片表达式:
>>> fields_copy2 = fields[::-1]
>>> fields_copy2
[13, 2, 7, 53, 19]
在这个例子中,我们创建了一个使用隐含的起始和结束位置以及步长为 -1 的切片 [::-1]。这以相反的顺序选择列表中的所有项以创建一个新的列表。
原始列表在切片操作中明确没有被修改。这创建了一个副本。检查 fields 变量的值以确认它没有改变。
这就是如何使用 reversed() 函数来创建一个值的列表的反向副本:
>>> fields_copy3 = list(reversed(fields))
>>> fields_copy3
[13, 2, 7, 53, 19]
在这个例子中,使用 list()函数很重要。reversed()函数是一个生成器,我们需要消耗生成器中的项来创建一个新的列表。
4.6.3 工作原理...
正如我们在切片和切块列表配方中提到的,切片符号相当复杂。使用具有负步长的切片将按从右到左的顺序创建一个副本(或子集),而不是默认的从左到右的顺序。
很重要要区分这三种方法:
-
reverse()方法修改列表对象本身。与 append()和 remove()等方法一样,此方法没有返回值。因为它改变了列表,所以它不返回值。
-
[::-1]切片表达式创建了一个新的列表。这是原始列表的浅拷贝,顺序被反转。
-
reversed()函数是一个生成器,它以相反的顺序产生值。当值被 list()函数消耗时,它创建列表的副本。
4.6.4 相关内容
-
请参考制作浅拷贝和深拷贝的对象配方以获取有关浅拷贝是什么以及为什么我们可能想要制作深拷贝的更多信息。
-
请参考构建列表 - 字面量、追加和推导式配方了解创建列表的方法。
-
请参考切片和切块列表配方了解复制列表和从列表中选取子列表的方法。
-
请参考缩小列表 - 删除、移除和弹出配方了解从列表中删除项的其他方法。
4.7 构建集合 - 字面量、添加、推导式和运算符
如果我们决定仅基于一个项的存在来创建一个集合(即集合),我们有几种构建这种结构的方法。由于集合的窄焦点,项目没有顺序——没有相对位置——并且项目不能重复。我们将探讨从单个项目的来源组装集合集合的多种方法。
集合运算符与集合论数学中定义的运算符平行。这些运算符对于在集合之间进行大量比较很有帮助。我们将探讨这些运算符,以及集合类的方法。
集合有一个重要的约束:它们只包含不可变对象。非正式地说,不可变对象没有内部状态可以改变。数字是不可变的,字符串也是如此,不可变对象的元组也是。正式地说,不可变对象有一个内部哈希值,hash()函数将显示这个值。
下面是如何在实际中做到这一点:
>>> a = "string"
>>> hash(a)
... # doctest: +SKIP
4964286962312962439
>>> b = ["list", "of", "strings"]
>>> hash(b)
Traceback (most recent call last):
...
TypeError: unhashable type: ’list’
a 变量的值是一个不可变的字符串,它有一个哈希值。另一方面,b 变量是一个可变列表,没有哈希值。我们可以创建包含字符串等不可变对象的集合,但如果尝试将可变对象放入集合中,将会引发 TypeError 异常。
4.7.1 准备工作
假设我们需要对一个复杂应用程序中模块之间的依赖关系进行分析。以下是可用数据的一部分:
>>> import_details = [
... (’Chapter_12.ch12_r01’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r02’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r03’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r04’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r05’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r06’, [’typing’, ’textwrap’, ’pathlib’]),
... (’Chapter_12.ch12_r07’, [’typing’, ’Chapter_12.ch12_r06’, ’Chapter_12.ch12_r05’, ’concurrent’]),
... (’Chapter_12.ch12_r08’, [’typing’, ’argparse’, ’pathlib’]),
... (’Chapter_12.ch12_r09’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r10’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r11’, [’typing’, ’pathlib’]),
... (’Chapter_12.ch12_r12’, [’typing’, ’argparse’])
此列表中的每个项目命名了一个模块及其导入的模块列表。我们可以对这个模块之间的关系集合提出许多问题。我们希望计算一个简短的依赖列表,从而从列表中删除重复项。
4.7.2 如何实现...
我们有许多创建集合对象的方法:
-
字面量:我们可以使用一系列由字符包围的值来创建集合的文本显示。它看起来像这样:{value, ...}。Python 需要匹配字面量开头的{和结尾的},以看到完整的逻辑行,因此字面量可以跨越物理行。有关更多信息,请参阅第二章中编写长行代码的配方。
注意,我们无法使用{}创建一个空集合;这是一个空字典。我们必须使用 set()来创建一个空集合。
-
转换函数:我们可以使用 set()函数将其他数据集合转换为集合。我们可以转换不可变项的列表,或字典的键,或不可变项的元组。
-
添加方法:set 方法中的 add()会将一个项目添加到集合中。此外,集合可以通过 union()方法或|运算符创建。
-
推导式:推导式是一种特殊的生成器表达式,它使用一个表达式来定义成员资格,从而描述集合中的项目。我们将在本配方编写集合推导式部分中详细探讨这一点。
创建集合的前两种方法是单个 Python 表达式。最后两种更复杂,我们将为每种方法提供配方。
使用 add 方法构建集合
我们的数据源集合是一个包含子列表的列表。我们希望总结每个子列表中的项目:
-
创建一个空集合,可以添加项目。与列表不同,没有空集合的缩写语法,因此我们必须使用 set()函数:
>>> all_imports = set() -
编写一个 for 语句来遍历 import_details 集合中的每个二元组。这需要一个嵌套的 for 语句来遍历每对中导入列表中的每个名称。使用 all_imports 集合的 add()方法创建一个包含重复项的完整集合:
>>> for item, import_list in import_details: ... for name in import_list: ... all_imports.add(name) >>> all_imports == {’Chapter_12.ch12_r06’, ’textwrap’, ... ’Chapter_12.ch12_r05’, ’pathlib’, ’concurrent’, ... ’argparse’, ’typing’} True
此结果总结了多行细节,显示了导入的不同项目的集合。请注意,这里的顺序是任意的,并且每次执行示例时都可能不同。
任意的顺序意味着用于确认此代码正确性的 doctest 示例不能简单地显示预期的结果。请参阅第十五章中处理常见的 doctest 问题的配方,以获取有关使用 doctest 的更多建议。
编写集合推导式
集合推导式的目标是创建一个占据语法角色的对象,类似于集合字面量:
-
编写包围要构建的集合对象的括号:
>>> {} {} -
编写数据源。这将包括目标变量。我们有两个嵌套列表,因此我们需要使用两个 for 子句。注意,for 子句的末尾没有冒号,因为我们没有写一个完整的语句:
>>> {... ... for item, import_list in import_details ... for name in import_list ... } {Ellipsis}现在,我们将表达式的结果写成了特殊的省略号对象。一旦我们完成这个表达式,我们将用更有用的东西替换它。
-
在 for 子句前加上创建目标集合每个值的表达式。在这种情况下,我们只想从整体导入详情列表中的每一对项目中的导入列表中获取名称:
>>> names = {name ... for item, import_list in import_details ... for name in import_list} >>> names == {’Chapter_12.ch12_r06’, ’Chapter_12.ch12_r05’, ... ’typing’, ’concurrent’, ’argparse’, ’textwrap’, ’pathlib’} True
集合推导不能有重复项,所以这总是有不同值的。
就像列表推导一样,集合推导围绕一个中心生成器表达式构建。这个推导的核心生成器表达式有一个数据表达式子句和一个 for 子句。
我们可以用 set() 函数替换包围的 { 和 } 语法。当我们考虑更改数据结构的可能性时,使用显式的 set() 函数有优势。我们可以轻松地将 set() 替换为 frozenset()、list() 或 Counter()。
4.7.3 它是如何工作的...
集合是不可变对象的集合。每个不可变的 Python 对象都有一个哈希值,这些数字哈希码用于优化在集合中定位项目。我们可以想象实现依赖于一个桶的数组,数字哈希值将我们引导到桶中,以查看项目是否存在于该桶中。
哈希值不一定唯一。哈希桶的数组是有限的,这意味着可能发生哈希冲突。当两个不同的对象都具有相同的哈希值时,就会发生冲突。这导致了一些处理任何冲突的开销。
我们可以创建两个将产生哈希冲突的整数:
>>> import sys
>>> v1 = 7
>>> v2 = 7+sys.hash_info.modulus
>>> v1
7
>>> v2
2305843009213693958
>>> hash(v1)
7
>>> hash(v2)
7
尽管这两个对象具有相同的哈希值,但哈希冲突处理将保持这两个对象在集合中相互分离。
4.7.4 更多内容...
我们有几种方法可以向集合中添加项目:
-
示例使用了 add() 方法。这对于单个项目是有效的。
-
我们可以使用 union() 方法。这个方法就像一个运算符——它创建一个新的结果集。它不会修改操作数集合中的任何一个。
-
我们可以使用 update() 方法用另一个集合中的项目更新一个集合。这会修改集合,但不返回任何值。
对于这些技术中的大多数,我们需要从我们要添加的项目创建一个单元素集合。以下是一些示例:
>>> collection = {1}
>>> collection
{1}
>>> item = 3
>>> collection.union({item})
{1, 3}
>>> collection
{1}
在前面的例子中,我们已从 item 变量的值创建了一个单元素集合 {item}。然后我们使用了 union() 方法来计算一个新的集合,这是集合集合和 {item} 集合的并集。
注意,union() 创建了一个新对象,并保留了原始集合不变。这里还有一个使用并集运算符 | 的另一种替代方案:
>>> collection = collection | {item}
>>> collection
{1, 3}
我们还可以使用 update() 方法来修改集合:
>>> collection.update({4})
>>> collection
{1, 3, 4}
类似于 update() 和 add() 这样的方法会修改集合对象。因为它们会修改集合,所以它们不会返回值。这与列表集合的方法类似。通常,修改集合的方法不会返回值。这个模式的唯一例外是 pop() 方法,它既会修改集合对象又会返回被弹出的值。
Python 有许多集合运算符。这些是我们可以用于复杂集合表达式的普通运算符符号:
-
| 用于集合并集,通常表示为 A ∪ B
-
& 用于集合交集,通常表示为 A ∩ B
-
^ 用于集合对称差集,通常表示为 A △ B
-
- 用于集合减法,通常表示为 A ∖ B
4.7.5 参考内容
- 在缩小集合 – remove(), pop(), 和 difference 菜谱中,我们将探讨如何通过删除或替换项目来更新集合。
4.8 缩小集合 – remove(), pop(), 和 difference
Python 给我们几种从集合集合中删除项目的方法。我们可以使用 remove() 方法来删除一个特定的项目。我们可以使用 pop() 方法来删除(并返回)一个任意项目。
此外,我们可以使用集合交集、差集和对称差集运算符:&、- 和 ^ 来计算一个新的集合。这些将产生一个新的集合,它是给定输入集合的子集。
4.8.1 准备工作
有时,我们会有包含复杂和多变格式的日志文件。以下是从一个长而复杂的日志中摘取的一小段:
>>> log = """
... [2016-03-05T09:29:31-05:00] INFO: Processing ruby_block[print IP] action run (@recipe_files::/home/slott/ch4/deploy.rb line 9)
... [2016-03-05T09:29:31-05:00] INFO: Installed IP: 111.222.111.222
... [2016-03-05T09:29:31-05:00] INFO: ruby_block[print IP] called
...
... (Skipping some details)
... """
我们需要在这个日志中找到所有类似于 IP: 111.222.111.222 的文本。这些是具有 4 个数字字段的 IPv4 地址。
这是我们如何创建匹配项集合的方法:
>>> import re
>>> pattern = re.compile(r"IP: \d+\.\d+\.\d+\.\d+")
>>> matches = set(pattern.findall(log))
>>> matches
{’IP: 111.222.111.222’}
我们这个日志的问题是有一些无关的匹配项。日志文件中还有一些看起来相似但却是我们需要忽略的占位符或占位值。在完整的日志中,我们还会找到包含类似 IP: 1.2.3.4 这样的文本的行,这是一个占位符,不是一个有意义的地址。结果证明,存在一组不相关的值。
这是在集合交集和集合减法可以非常有帮助的地方。
4.8.2 如何做到...
-
创建一个我们想要忽略的项目集合,作为一个集合字面量:
>>> to_be_ignored = {’IP: 0.0.0.0’, ’IP: 1.2.3.4’} -
收集日志中的所有条目。我们将使用前面展示的 re 模块来完成此操作。我们将看到以下结果:
>>> matches = {’IP: 111.222.111.222’, ’IP: 1.2.3.4’} -
使用集合减法从匹配项集合中删除项目。以下有两个示例:
>>> matches - to_be_ignored {’IP: 111.222.111.222’} >>> matches.difference(to_be_ignored) {’IP: 111.222.111.222’}这两个都是返回新集合作为结果的运算符。这两个都不会修改底层集合对象。
结果表明,difference() 方法可以与任何可迭代的集合一起工作,包括列表和元组。虽然允许混合集合和列表,但这可能会造成混淆,并且为它们编写类型提示可能会很具挑战性。
我们经常在语句中使用这些,例如以下示例:
>>> valid_matches = matches - to_be_ignored
>>> valid_matches
{’IP: 111.222.111.222’}
这将把结果集合分配给一个新的变量,valid_matches,这样我们就可以对这个新集合进行所需的处理。
我们还可以使用 remove() 和 pop() 方法来移除特定项目。当无法移除项目时,remove() 方法会引发异常。我们可以利用这种行为来确认项目是否在集合中,并移除它。
4.8.3 它是如何工作的...
集合对象跟踪项目的成员资格。一个项目要么在集合中,要么不在。我们指定要移除的项目。移除项目不依赖于索引位置或键值。
集合运算符允许复杂的集合计算。我们可以从目标集合中移除一个集合中的任何项目,计算集合差或集合减法。
4.8.4 更多内容...
我们有几种其他方法可以从集合中移除项目:
-
在这个例子中,我们使用了
difference()方法和-操作符。difference()方法的行为类似于操作符,并创建一个新的集合。 -
我们还可以使用
difference_update()方法。这将就地修改一个集合。它不会返回任何值。 -
我们可以使用
remove()方法移除单个项目。 -
我们还可以使用
pop()方法移除任意项目。这在这个例子中不太适用,因为我们无法控制从集合中弹出的项目。
下面是 difference_update() 方法的样子:
>>> valid_matches = matches.copy()
>>> valid_matches.difference_update(to_be_ignored)
>>> valid_matches
{’IP: 111.222.111.222’}
我们应用了 difference_update() 方法来从 valid_matches 集合中移除不希望的项目。由于 valid_matches 集合被修改了,没有返回值。此外,由于这是一个副本,此操作不会修改原始的 matches 集合。
我们可以使用以下示例来使用 remove() 方法。请注意,如果项目不在集合中,remove() 将引发异常:
>>> valid_matches = matches.copy()
>>> for item in to_be_ignored:
... if item in valid_matches:
... valid_matches.remove(item)
>>> valid_matches
{’IP: 111.222.111.222’}
在尝试移除项目之前,我们测试了项目是否在 valid_matches 集合中,以避免引发 KeyError 异常。使用 if 语句是避免引发异常的一种方法。另一种方法是使用 try: 语句来抑制当项目不存在时引发的异常。
我们还可以使用 pop() 方法来移除任意项目。这种方法很特别,因为它既修改了集合,又返回了被移除的项目。然而,我们无法控制哪个项目被弹出,这使得它不适合这个例子。
4.9 编写与集合相关的类型提示
typing 模块提供了一些基本类型定义,用于描述集合对象的内部内容。主要类型定义是 set,我们可以用集合中项目的类型来参数化它。我们将使用 set[int] 来描述由整数组成的集合。这与编写与列表相关的类型提示食谱相平行。
4.9.1 准备工作
类似于 Zonk(也称为 10,000 或 Greed)的掷骰子游戏需要将一组随机骰子分组到“手”中。虽然规则各不相同,但手有几种模式,包括:
-
三张相同的牌。
-
一组五个递增的骰子(1-2-3-4-5 或 2-3-4-5-6 是两种组合)。
-
一组六个递增的骰子(称为“大顺子”)。
-
一个“王牌”手。这至少有一个不是三张相同或顺子的 1 骰子。
-
一组相同的六个骰子。虽然罕见,但并非不可能。
我们将使用以下类和函数定义来创建骰子手牌:
import random
class Die(str, Enum):
d_1 = "\u2680"
d_2 = "\u2681"
d_3 = "\u2682"
d_4 = "\u2683"
d_5 = "\u2684"
d_6 = "\u2685"
def zonk(n: int = 6) -> tuple[Die, ...]:
faces = list(Die)
return tuple(random.choice(faces) for _ in range(n))
Die 类的定义通过提供适当的 Unicode 字符来枚举标准骰子的六个面。
当我们评估 zonk()函数时,它看起来是这样的。
>>> zonk()
(<Die.d_6: ’’>, <Die.d_1: ’’>, <Die.d_1: ’’>,
<Die.d_6: ’’>, <Die.d_3: ’’>, <Die.d_2: ’’>)
这向我们展示了一副包含两个六、两个一、一个二和一个三的手牌。在检查手牌以寻找模式时,我们通常会创建复杂的一组对象。
4.9.2 如何实现...
一个用于分析骰子模式的功能通过从六个骰子实例创建一个 set[Die]对象来工作。这个集合揭示了许多信息:
-
当集合中独特的值只有一个骰子时,那么所有六个骰子都有相同的值。
-
当集合中独特的值有五个骰子时,这可能是小顺子。这需要额外的检查以查看独特的值集合是否为 1-5 或 2-6,这两个是有效的小顺子。
-
当集合中独特的值有六个不同项时,那么这必须是一个大顺子。
-
对于两个独特的骰子值,至少会有一个三个一的情况。可能会有四个或五个一的情况,但这些被计分为三个一,剩余的骰子不计分。
-
对于集合中的三个或四个独特骰子,可能会有三个一的情况。需要更详细地分析集合以查看确切的模式。
我们可以通过查看独特骰子集合的基数来区分许多模式。剩余的区别可以通过查看计数模式来实现。为此,collections.Counter 对象将很有用。
下面是如何编写这种基于集合的分析:
-
定义集合中每个项目的类型。在这个例子中,Die 类是项目类。我们将使用 set[Die]和 Counter[Die]类型。
-
使用 Die 实例的手牌中的独特值创建集合对象。下面是评估函数如何开始:
import collections def eval_zonk_6(hand: tuple[Die, ...]) -> str: assert len(hand) == 6, "Only works for 6-dice zonk." unique: set[Die] = set(hand) -
有两个小顺子的定义:1-5 和 2-6:
faces = list(Die) small_straights = [ set(faces[:-1]), set(faces[1:]) ]我们可以在分析函数的主体中构建这两个集合,以展示它们是如何被使用的。从实用主义的角度来看,small_straights 的值应该只计算一次。
我们不能构建这两个集合实例的集合,因为集合对象是可变的。我们可以通过构建两个 frozenset 对象来代替构建列表。
-
检查简单的情况。集合中不同元素的数量直接识别了几种手牌:
if len(unique) == 6: return "large straight" elif len(unique) == 5 and unique in small_straights: return "small straight" elif len(unique) == 2: return "three of a kind" elif len(unique) == 1: return "six of a kind" -
当集合中有三个或四个独特值时,可以使用计数来总结模式。这种频率计数的模式可以总结为 set[int]:
elif len(unique) in {3, 4}: # 4 unique: wwwxyz (good) or wwxxyz (bad) # 3 unique: xxxxyz, xxxyyz (good) or xxyyzz (bad) frequencies: set[int] = set( collections.Counter(hand).values()) -
对于三个或四个不同的 Die 值的情况,这些可以形成各种模式。如果至少有一个 Die 的频率为三个或四个,那么这就是一个计分组合。如果没有其他匹配,并且有一个骰子显示一,那么这就是最小得分:
if 3 in frequencies or 4 in frequencies: return "three of a kind" elif Die.d_1 in unique: return "ace" -
是否还有剩余的条件?这涵盖了所有可能的骰子基数和频率吗?剩余的情况包括没有“一个”出现的成对和单数集合。在上述 if 语句之后,我们可以提供一个单独的返回语句来收集所有其他情况到一个单一的、不计分的 Zonk 中:
return "Zonk!"
这显示了两种使用集合评估数据项集合模式的方法。第一个集合,set[Die],检查了唯一 Die 值的整体模式。第二个集合,set[int],检查了 Die 值频率的模式。
4.9.3 它是如何工作的...
集合的基本属性是成员资格。当我们从 Die 实例的集合中计算集合时,我们使用 set[Die]类型提示来描述这个结构。
类似地,当我们查看频率分布时,只有几种不同的模式。将计数转换为具有 set[int]类型提示的值的集合,描述了这种额外的结构。
4.9.4 更多内容...
一组没有单一、统一类型的项可能会令人困惑。我们可以使用 set[T1 | T2]来描述一个可以包含 T1 或 T2 类型的项的集合。
计算骰子手的分数取决于哪些骰子是获胜模式的一部分。这意味着当结果是三张牌时,评估函数需要返回一个更复杂的结果。为了确定分数,我们需要考虑三种情况:
-
Dice 类中哪个值出现了三次或六次。这决定了基础分数。通常,1 被赋予 1,000 分,2 到 6 被赋予 200 到 600 分。
-
抛出两个三重组合是可能的;这种模式也需要区分。这通常得分 2,000 点,无论显示的数字是多少。
-
对于顺子和“王牌”手牌,分配了简单的固定分数。小顺子可能是 1,000 分。大顺子 2,000 分。单独的 1 可能只得分 50 分。
我们有两个单独的条件来识别表示三张牌模式的唯一值模式。该函数需要一些重构,以正确识别出现三次或更多次的骰子值以及被忽略的骰子值。我们将这个额外的设计留作读者的练习。
4.9.5 参考以下内容
- 参考本章中的编写列表相关类型提示配方以了解更多关于列表类型提示的信息。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第五章:5
内置数据结构第二部分:字典
从第四章开始,内置数据结构第一部分:列表和集合,我们开始探讨 Python 丰富的内置数据结构集合。这些数据结构有时被称为“容器”或“集合”,因为它们包含一系列单独的项目。
在本章中,我们将介绍字典结构。字典是从键到值的映射,有时也称为关联数组。将映射与两个序列(列表和集合)分开似乎是有道理的。
本章还将探讨一些与 Python 处理可变集合对象引用相关的更高级主题。这会影响函数的定义方式。
在本章中,我们将探讨以下菜谱,所有这些都与 Python 的内置数据结构相关:
-
创建字典 – 插入和更新
-
缩小字典 – pop() 方法和 del 语句
-
编写与字典相关的类型提示
-
理解变量、引用和赋值
-
创建对象的浅拷贝和深拷贝
-
避免为函数参数使用可变默认值
我们将从如何创建一个字典开始。
5.1 创建字典 – 插入和更新
字典是 Python 的一种映射类型。内置类型 dict 提供了许多基础特性。在 collections 模块中定义了一些这些特性的常见变体。
正如我们在第四章开头的 选择数据结构 菜谱中提到的,当我们有一个需要将键映射到给定值的键时,我们将使用字典。例如,我们可能想要将一个单词映射到该单词的复杂定义,或者将某个值映射到该值在数据集中出现的次数。
5.1.1 准备工作
我们将探讨一个用于定位事务处理各个阶段的算法。这依赖于为每个请求分配一个唯一的 ID,并在事务期间包含每个日志记录中该 ID。由于多线程服务器可能同时处理多个请求,每个请求的事务阶段将不可预测地交错。按请求 ID 重新组织日志有助于隔离每个事务。
这里是一个模拟的三个并发请求的日志条目序列:
[2019/11/12:08:09:10,123] INFO #PJQXB^{}eRwnEGG?2%32U path="/openapi.yaml" method=GET
[2019/11/12:08:09:10,234] INFO 9DiC!B^{}nXxnEGG?2%32U path="/items?limit=x" method=GET
[2019/11/12:08:09:10,235] INFO 9DiC!B^{}nXxnEGG?2%32U error="invalid query"
[2019/11/12:08:09:10,345] INFO #PJQXB^{}eRwnEGG?2%32U status="200" bytes="11234"
[2019/11/12:08:09:10,456] INFO 9DiC!B^{}nXxnEGG?2%32U status="404" bytes="987"
[2019/11/12:08:09:10,567] INFO >~UL>~PB_R>&nEGG?2%32U path="/category/42" method=GET
行很长,可能随意换行以适应书籍的边距。每一行都有一个时间戳。示例中显示的每个记录的严重级别都是 INFO。接下来的 20 个字符是一个事务 ID。之后是针对事务中特定步骤的日志信息。
以下正则表达式定义了日志记录:
import re
log_parser = re.compile(r"\[(.*?)\] (\w+) (\S+) (.*)")
此模式捕获每个日志条目的四个字段。有关正则表达式的更多信息,请参阅第一章的 使用正则表达式进行字符串解析 菜谱。
解析这些行将产生一个四元组序列。结果对象看起来像这样:
[(’2019/11/12:08:09:10,123’,
’INFO’,
’#PJQXB^{}eRwnEGG?2%32U’,
’path="/openapi.yaml" method=GET’),
(’2019/11/12:08:09:10,234’,
’INFO’,
’9DiC!B^{}nXxnEGG?2%32U’,
’path="/items?limit=x" method=GET’),
... details omitted ...
(’2019/11/12:08:09:10,567’,
’INFO’,
’>~UL>~PB_R>&nEGG?2%32U’,
’path="/category/42" method=GET’)]
我们需要知道每个唯一路径被请求的频率。这意味着忽略一些日志记录并从其他记录中收集数据。从路径字符串到计数的映射是一种优雅地收集这些数据的方法。我们将详细讨论如何实现。稍后,我们将查看 collections 模块中的一些替代实现。
5.1.2 如何实现...
我们有多种构建字典对象的方法:
-
文字:我们可以通过使用由 {} 字符包围的键值对序列来创建字典的显示。我们在键和关联的值之间使用冒号。文字看起来像这样:{"num": 355, "den": 113}。
-
转换函数:一个由两个元组组成的序列可以转换成这样的字典:dict([(‘num’,355),(‘den’,113)]). 每个元组变成一个键值对。键必须是不可变对象,如字符串、数字或不可变对象的元组。我们也可以这样构建字典:dict(num=355, den=113). 每个参数名称都变成一个键。这限制了字典键为字符串,这些字符串也是有效的 Python 变量名。
-
插入:我们可以使用字典 [key] = value 语法在字典中设置或替换一个值。我们将在本菜谱的后面讨论这个问题。
-
理解:与列表和集合类似,我们可以编写一个字典理解来从某些数据源构建字典。
通过设置项构建字典
我们通过创建一个空字典然后向其中设置项来构建字典:
-
创建一个空字典以映射路径到计数。我们也可以使用 dict() 来创建一个空字典。由于我们将创建一个计数路径使用次数的直方图,我们将它命名为 histogram:
>>> histogram = {}我们也可以使用函数 dict() 而不是字面量值 {} 来创建一个空字典。
-
对于每条日志行,过滤掉那些在索引为 3 的项中没有以 path 开头的值的那些行:
>>> for line in log_lines: ... path_method = line[3] # group(4) of the original match ... if path_method.startswith("path"): -
如果路径不在字典中,我们需要添加它。一旦 path_method 字符串的值在字典中,我们就可以根据数据中的键在字典中增加值。
... if path_method not in histogram: ... histogram[path_method] = 0 ... histogram[path_method] += 1
这种技术将每个新的 path_method 值添加到字典中。一旦确定 path_method 键在字典中,我们就可以增加与该键关联的值。
通过理解构建字典
每条日志行的最后一个字段有一个或两个字段。可能有一个像 path="/openapi.yaml" method=GET 这样的值,包含两个属性 path 和 method,或者一个像 error="invalid query" 这样的值,只有一个属性 error。
我们可以使用以下正则表达式来分解每行的最终字段:
param_parser = re.compile(
r’(\w+)=(".*?"|\w+)’
)
这个正则表达式的 findall()方法将基于匹配文本提供一个包含两个元组的序列。然后我们可以从匹配组的序列构建一个字典:
-
对于每条日志行,应用正则表达式以创建一对序列:
>>> for line in log_lines: ... name_value_pairs = param_parser.findall(line[3]) -
使用字典推导式,将第一个匹配组作为键,第二个匹配组作为值:
... params = {match[0]: match[1] for match in name_value_pairs}
我们可以打印出参数值,我们会看到如下示例中的字典:
{’path’: ’"/openapi.yaml"’, ’method’: ’GET’}
{’path’: ’"/items?limit=x"’, ’method’: ’GET’}
{’error’: ’"invalid query"’}
使用字典作为每个日志记录的最终字段,使得分离重要信息变得更容易。
5.1.3 工作原理...
字典的核心功能是从不可变键到任何类型值对象的映射。在第一个示例中,我们使用了不可变的字符串作为键,整数作为值。我们在类型提示中描述它为 dict[str, int]。
理解+=赋值语句的工作方式很重要。+=的实现基本上是这样的:
histogram[customer] = histogram[customer] + 1
从字典中获取的直方图[客户]值被计算出一个新值,并将结果用于更新字典。
确保字典键对象是不可变的。我们不能使用列表、集合或字典作为字典映射的键。然而,我们可以将列表转换为不可变的元组,或者将集合转换为 frozenset,这样我们就可以使用这些更复杂对象中的一个作为键。在本食谱中显示的示例中,我们使用了不可变的字符串作为每个字典的键。
5.1.4 更多...
我们不必使用 if 语句来添加缺失的键。我们可以使用字典的 setdefault()方法。使用 collections 模块中的类甚至更容易。
这是使用 collections 模块中的 defaultdict 类的版本:
>>> from collections import defaultdict
>>> histogram = defaultdict(int)
>>> for line in log_lines:
... path_method = line[3] # group(4) of the match
... if path_method.startswith("path"):
... histogram[path_method] += 1
我们创建了一个 defaultdict 实例,它将使用 int()函数初始化任何未知的键值。我们将函数对象 int 提供给了 defaultdict 构造函数。defaultdict 实例将评估给定的函数以创建默认值。
这允许我们使用 histogram[path_method] += 1。如果与 path_method 键关联的值之前在字典中,该值将增加并放回字典中。如果 path_method 键不在字典中,则调用 int()函数不带任何参数;这个默认值将被增加并放入字典中。
我们还可以通过创建 Counter 对象来累积频率计数。我们可以如下从原始数据构建 Counter 对象:
>>> from collections import Counter
>>> filtered_paths = (
... line[3]
... for line in log_lines
... if line[3].startswith("path")
... )
>>> histogram = Counter(filtered_paths)
>>> histogram
Counter({’path="/openapi.yaml" method=GET’: 1, ’path="/items?limit=x" method=GET’: 1, ’path="/category/42" method=GET’: 1})
首先,我们使用生成表达式创建了一个过滤路径数据的迭代器;这被分配给了 filtered_paths。然后我们从数据源创建了一个 Counter;Counter 类将扫描数据并计算不同出现的次数。
5.1.5 参见
- 在收缩字典 – pop() 方法和 del 语句的菜谱中,我们将探讨如何通过删除项来修改字典。
5.2 收缩字典 – pop() 方法和 del 语句
字典的一个常见用途是作为关联存储:它保持键和值对象之间的关联。这意味着我们可能对字典中的项执行任何 CRUD 操作:
-
创建一个新的键值对。
-
获取与键关联的值。
-
更新与键关联的值。
-
从字典中删除键(及其对应的值)。
5.2.1 准备工作
大量的处理支持围绕一个(或多个)不同的共同值对项目进行分组的需求。我们将回到本章中创建字典 – 插入和更新菜谱中显示的日志数据。
我们将使用一个迭代器算法,该算法使用交易 ID 作为字典中的键。这个键的值将是交易的步骤序列。在非常长的日志中,我们通常不想在巨大的字典中保存每一笔交易。当我们达到交易序列的终止时,我们可以产生交易日志条目的列表。一个函数可以消费这个迭代器,独立地处理每一批交易。
5.2.2 如何做...
这个菜谱的上下文将需要一个条件为 match := log_parser.match(line) 的 if 语句。这将应用正则表达式,并将结果收集在 match 变量中。给定这个上下文,更新或从字典中删除的处理如下:
-
这个函数使用 defaultdict 类和两个额外的类型提示,可迭代和迭代器:
from collections import defaultdict from collections.abc import Iterable, Iterator -
定义一个 defaultdict 对象来保存交易步骤。键是 20 个字符的字符串。值是日志记录的列表。在这种情况下,每个日志记录都将从源文本解析成单个字符串的元组:
LogRec = tuple[str, ...] def request_iter_t(source: Iterable[str]) -> Iterator[list[LogRec]]: requests: defaultdict[str, list[LogRec]] = defaultdict(list) -
定义每个日志条目组的键:
for line in source: if match := log_parser.match(line): id = match.group(3) -
使用日志记录更新字典项:
requests[id].append(tuple(match.groups())) -
如果这个日志记录完成了一笔交易,作为生成器函数的一部分产生这个组。然后从字典中删除交易,因为它已经完成:
if match.group(4).startswith(’status’): yield requests[id] del requests[id] -
最后,可能会有一个非空的请求字典。这反映了在日志文件切换时正在进行的交易。
5.2.3 它是如何工作的...
因为字典是一个可变对象,所以我们可以从字典中删除键。del 语句将删除与键关联的键和值对象。在这个例子中,当数据表明交易完成时,键被删除。一个处理平均每秒 10 笔交易的繁忙的 Web 服务器在 24 小时内将看到 864,000 笔交易。如果每笔交易平均有 2.5 条日志条目,文件中至少将有 2,160,000 行。
如果我们只想知道每个资源的耗时,我们不想在内存中保留包含 864,000 个事务的整个字典。我们更愿意将日志转换成一个中间摘要文件以供进一步分析。
这种临时数据的概念使我们把解析的日志行累积到一个列表实例中。每行新内容都追加到属于该事务的适当列表中。当找到最后一行时,这些行可以从字典中清除。
5.2.4 更多内容...
在示例中,我们使用了 del 语句。pop()方法也可以使用。如果给定的项目在字典中找不到,del 语句将引发 KeyError 异常。
pop()方法看起来是这样的:
requests.pop(id)
这将就地修改字典,如果存在则删除项目,或者引发 KeyError 异常。
当 pop()方法提供一个默认值时,如果找不到键,它可以返回给定的默认值而不是引发异常。在任何情况下,键将不再存在于字典中。请注意,此方法既修改了集合又返回了一个值。
popitem()方法将从字典中删除一个键值对。这些对以最后进入,最先出来(LIFO)的顺序返回。这意味着字典也是一种栈。
5.2.5 参见
- 在创建字典 – 插入和更新菜谱中,我们探讨了如何创建字典并将它们填充键和值。
5.3 编写与字典相关的类型提示
当我们查看集合和列表时,我们通常期望列表(或集合)中的每个项目都是相同类型。当我们查看面向对象的类设计时,在第七章中,我们将看到如何一个公共超类可以成为紧密相关的对象类型家族的共同类型。虽然在一个列表或集合集合中可以有异构类型,但通常处理起来相当复杂,需要匹配语句来进行适当的类型匹配。然而,字典可以用来创建类型的区分联合。特定的键值可以用来定义字典中存在哪些其他键。这意味着一个简单的 if 语句可以区分异构类型。
5.3.1 准备工作
我们将查看两种类型的字典类型提示,一种用于同质值类型,另一种用于异构值类型。我们将查看最初是这些类型之一的数据字典,但后来被转换成更复杂的类型定义。
我们将从一个以下 CSV 文件开始:
date,engine on,fuel height on,engine off,fuel height off
10/25/13,08:24:00,29,13:15:00,27
10/26/13,09:12:00,27,18:25:00,22
10/28/13,13:21:00,22,06:25:00,14
这描述了乘帆船进行的多日旅行中的三个独立阶段。燃料是通过油箱中的高度来测量的,而不是使用浮子或其他仪表的间接方法。因为油箱大约是矩形的,31 英寸的深度大约是 75 加仑的燃料。
5.3.2 如何做...
csv.DictReader 的初始使用将导致具有同质类型定义的字典:
-
定位字典中键的类型。当读取 CSV 文件时,键是字符串,类型为 str。
-
定位字典中值的类型。当读取 CSV 文件时,值是字符串,类型为 str。
-
使用 dict 类型提示组合类型。这产生 dict[str, str]。
这里是一个从 CSV 文件读取数据的示例函数:
import csv
from pathlib import Path
def get_fuel_use(source_path: Path) -> list[dict[str, str]]:
with source_path.open() as source_file:
rdr = csv.DictReader(source_file)
data: list[dict[str, str]] = list(rdr)
return data
get_fuel_use() 函数产生与源数据匹配的值。在这种情况下,它是一个将字符串列名映射到字符串单元格值的字典。
这份数据本身难以处理。常见的第二步是对源行应用转换以创建更有用的数据类型。我们可以用类型提示来描述结果:
-
确定所需的各种值类型。在这个例子中,有五个字段,三种不同类型,如下所示:
-
日期字段是一个 datetime.date 对象。
-
引擎字段是一个 datetime.time 对象。
-
燃料高度字段是一个整数,但我们知道它将在浮点上下文中使用,因此我们将直接创建一个浮点数。
-
引擎关闭字段是一个 datetime.time 对象。
-
燃料高度字段也是一个浮点值。
-
-
从 typing 模块导入 TypedDict 类型定义。
-
定义具有新异构字典类型的 TypedDict 子类。
import datetime from typing import TypedDict class History(TypedDict): date: datetime.date start_time: datetime.time start_fuel: float end_time: datetime.time end_fuel: float这部分是第七章的预告 7。它展示了一种非常简单的类定义。在这种情况下,类是具有五个特定键的字典,所有这些键都是必需的,并且必须具有给定类型的值。
在这个例子中,我们还重命名了字段,使它们成为有效的 Python 名称。用 _ 替换标点是明显的第一步。我们还更改了一些,因为 CSV 文件中的列名看起来很别扭。
执行转换的函数可能看起来像以下示例:
from collections.abc import Iterable, Iterator
def make_history(source: Iterable[dict[str, str]]) -> Iterator[History]:
for row in source:
yield dict(
date=datetime.datetime.strptime(
row[’date’], "%m/%d/%y").date(),
start_time=datetime.datetime.strptime(
row[’engine on’], ’%H:%M:%S’).time(),
start_fuel=float(row[’fuel height on’]),
end_time=datetime.datetime.strptime(
row[’engine off’], ’%H:%M:%S’).time(),
end_fuel=float(row[’fuel height off’]),
)
这个函数消耗初始 dict[str, str] 字典的实例,并创建由 History 类描述的字典的实例。以下是这两个函数如何一起工作的:
>>> from pprint import pprint
>>> source_path = Path("data/fuel2.csv")
>>> fuel_use = make_history(get_fuel_use(source_path))
>>> for row in fuel_use:
... pprint(row)
{’date’: datetime.date(2013, 10, 25),
’end_fuel’: 27.0,
’end_time’: datetime.time(13, 15),
’start_fuel’: 29.0,
’start_time’: datetime.time(8, 24)}
{’date’: datetime.date(2013, 10, 26),
’end_fuel’: 22.0,
’end_time’: datetime.time(18, 25),
’start_fuel’: 27.0,
’start_time’: datetime.time(9, 12)}
{’date’: datetime.date(2013, 10, 28),
’end_fuel’: 14.0,
’end_time’: datetime.time(6, 25),
’start_fuel’: 22.0,
’start_time’: datetime.time(13, 21)}
这展示了如何通过 make_history() 函数处理 get_fuel_use() 函数的输出,以创建一个字典的可迭代序列。每个结果字典都将源数据转换为更实用的类型。
5.3.3 它是如何工作的...
字典的核心类型提示命名了键类型和值类型,形式为 dict[key, value]。TypedDict 类允许我们更具体地描述字典键与广泛值域之间的绑定。
重要的是要注意,类型提示仅由 mypy 等程序检查。这些提示对运行时没有影响。例如,我们可以编写如下语句:
result: History = {’date’: 42}
这个语句声称结果字典将匹配 History 类型定义中的类型提示。然而,字典字面量在 'date' 字段类型不正确,并且许多其他字段缺失。虽然这会执行,但会从 mypy 引发错误。
运行 mypy 程序会显示如下列表中的错误:
(cookbook3) % python -m mypy src/ch05/recipe_04_bad.py
src/ch05/recipe_04_bad.py:18: error: Missing keys ("start_time", "start_fuel", "end_time", "end_fuel") for TypedDict "History" [typeddict-item]
Found 1 error in 1 file (checked 1 source file)
对于运行时数据验证,像 Pydantic 这样的项目可以非常有帮助。
5.3.4 更多...
字典键异质性的常见情况之一是可选项。类型提示 Optional[str]或 str | None 描述了这一点。在字典中很少需要这样做,因为它可以更简单地省略整个键值对。
假设我们需要 History 类型的两个变体:
-
在此配方中较早展示的变体,其中所有字段都存在。
-
两个“不完整”的记录,一个没有关机时间或结束燃油高度,另一个变体没有开机时间或起始燃油高度。这两个记录可能用于有动力过夜航行。
在这种情况下,我们可能需要使用 NotRequired 注解这些字段。生成的类定义将如下所示:
from typing import TypedDict, NotRequired
class History2(TypedDict):
date: datetime.date
start_time: NotRequired[datetime.time]
start_fuel: NotRequired[float]
end_time: NotRequired[datetime.time]
end_fuel: NotRequired[float]
此记录允许字典值有很大的可变性。它需要使用 if 语句来确定数据中存在的字段组合。此外,它还需要在 make_history()函数中进行一些更复杂的处理,以根据 CSV 文件中的空列创建这些变体记录。
TypedDict 和 NamedTuple 类型定义之间存在一些相似之处。将 TypedDict 更改为 NamedTuple 将创建一个命名元组类而不是类型字典类。
由于 NamedTuple 类有一个 _asdict()方法,因此可以从命名元组生成与 TypedDict 结构匹配的字典。
与 TypedDict 提示匹配的字典是可变的。然而,NamedTuple 的子类是不可变的。这是这两个类型提示之间的一个主要区别。更重要的是,字典使用 row[‘date’]语法通过键‘date’来引用一个项目。命名元组使用 row.date 语法通过名称来引用一个项目。
5.3.5 参见
-
使用 NamedTuples 简化元组中的项目访问的配方提供了关于 NamedTuple 类型提示的更多详细信息。
-
关于列表的类型提示,请参阅第四章中的编写与列表相关的类型提示配方。
-
第四章中的编写与集合相关的类型提示配方从集合类型的角度涵盖了这一点。
-
对于运行时数据验证,像 Pydantic 这样的项目可以非常有帮助。请参阅
docs.pydantic.dev/latest/。
5.4 理解变量、引用和赋值
变量实际上是如何工作的?当我们将可变对象分配给两个变量时会发生什么?当两个变量共享对公共可变对象的引用时,行为可能会令人困惑。
这是核心原则:Python 共享引用;它不会复制数据。
为了了解这个关于引用共享的规则意味着什么,我们将创建两个数据结构:一个是可变的,另一个是不可变的。
5.4.1 准备工作
我们将查看两种类型的序列,尽管我们也可以用两种类型的集合做类似的事情:
>>> mutable = [1, 1, 2, 3, 5, 8]
>>> immutable = (5, 8, 13, 21)
我们将查看当这些对象的引用被共享时会发生什么。
我们可以用类似的方式与集合和 frozenset 进行比较。我们无法轻松地这样做,因为 Python 没有提供方便的不可变映射。
5.4.2 如何实现...
这个配方将展示如何观察当有两个引用到底层可变对象时的“超距作用”。我们将在制作浅拷贝和深拷贝对象配方中查看防止这种情况的方法。以下是查看可变和不可变集合之间差异的步骤:
-
将每个集合分配给一个额外的变量。这将创建对该结构的两个引用:
>>> mutable_b = mutable >>> immutable_b = immutable现在我们有两个引用到列表 [1, 1, 2, 3, 5, 8] 和两个引用到元组 (5, 8, 13, 21)。
-
我们可以使用 is 运算符来确认这一点。这确定两个变量是否引用了同一个底层对象:
>>> mutable_b is mutable True >>> immutable_b is immutable True -
对集合的两个引用之一进行更改。对于列表类型,我们有像 extend() 或 append() 这样的方法。在这个例子中,我们将使用 + 运算符:
>>> mutable += [mutable[-2] + mutable[-1]]我们可以用类似的方法对不可变结构进行操作:
>>> immutable += (immutable[-2] + immutable[-1],) -
看看引用可变结构的另外两个变量。因为这两个变量是同一个底层列表对象的引用,每个变量都显示了当前状态:
>>> mutable_b [1, 1, 2, 3, 5, 8, 13] >>> mutable is mutable_b True -
看看引用不可变结构的两个变量。最初,两个变量共享一个公共对象。当执行赋值语句时,创建了一个新的元组,只有一个变量更改以引用新的元组:
>>> immutable_b (5, 8, 13, 21) >>> immutable (5, 8, 13, 21, 34)
5.4.3 它是如何工作的...
两个变量 mutable 和 mutable_b 仍然引用同一个底层对象。正因为如此,我们可以使用任何一个变量来更改对象,并看到更改反映在另一个变量的值上。
两个变量 immutable_b 和 immutable 最初引用的是同一个对象。因为该对象不能就地修改,对其中一个变量的更改意味着将新对象分配给该变量。另一个变量仍然牢固地附加到原始对象上。
在 Python 中,变量是一个附加到对象的标签。我们可以把它们想象成我们暂时贴在对象上的鲜艳颜色的粘性便签。可以对对象附加多个标签。是赋值语句将变量名放置在对象上。
考虑以下声明:
immutable += (immutable[-2] + immutable[-1],)
这与以下语句具有相同的效果:
immutable = immutable + (immutable[-2] + immutable[-1],)
等号右侧的表达式从不可变元组的上一个值创建一个新的元组。然后赋值语句将标签 immutable 分配给新铸造的对象。
将值赋给变量有两种可能的行为:
-
对于提供适当原地赋值操作符定义的可变对象,如
+=,赋值会被转换为特殊方法;在这种情况下,__iadd__()。特殊方法将修改对象的内部状态。 -
对于不提供如
+=等赋值定义的不可变对象,赋值会被转换为=和+。+操作符将构建一个新的对象,并将变量名附加到这个新对象上。之前引用被替换对象的变量不受影响;它们将继续引用旧对象。
Python 计算一个对象被引用的次数。当引用计数变为零时,该对象在任何地方都不再被使用,可以从内存中移除。
5.4.4 更多...
一些语言除了对象外还有原始类型。在这些语言中,+=语句可以利用硬件指令的特性来调整原始类型的值。
Python 没有这种优化。数字是不可变对象;没有特殊的指令来调整它们的值。考虑以下赋值语句:
a = 355
a += 113
处理过程不会调整对象 355 的内部状态。int类不提供__iadd__()特殊方法。将创建一个新的不可变整数对象。这个新对象被标记为 a。之前分配给 a 的旧值不再需要,存储可以被回收。
5.4.5 参见
-
在制作浅拷贝和深拷贝对象的菜谱中,我们将探讨如何复制可变结构以防止共享引用。
-
此外,请参阅避免为函数参数使用可变默认值以了解 Python 中引用共享方式的另一种后果。
-
对于 CPython 实现,一些对象可以是永生的。有关此实现细节的更多信息,请参阅PEP 683。
5.5 制作浅拷贝和深拷贝对象
在本章中,我们讨论了赋值语句如何共享对象引用。对象通常不会被复制。
考虑以下赋值语句:
a = b
这创建了两个指向相同底层对象的引用。如果 b 变量的值具有可变类型,如列表、集合或字典类型,那么使用 a 或 b 进行更改将更新底层可变对象。更多背景信息,请参阅理解变量、引用和赋值菜谱。
大多数情况下,我们希望这种行为。这对于向函数提供可变对象并在函数中有一个局部变量来修改函数外部创建的对象是理想的。在罕见的情况下,我们希望实际上从单个原始对象创建两个独立的对象。
当两个变量引用相同的底层对象时,有两种方法可以断开这种连接:
-
制作结构的浅拷贝
-
创建结构的深拷贝
5.5.1 准备工作
Python 不会自动复制一个对象。我们已经看到了几种创建副本的语法:
-
序列 - 列表,以及 str、bytes 和 tuple 类型:我们可以使用序列[:] 通过使用空切片表达式来复制序列。这是序列的一个特例。
-
几乎所有集合都有一个 copy() 方法。
-
调用一个类型,以该类型的实例作为唯一参数,返回一个副本。例如,如果 d 是一个字典,dict(d) 将创建 d 的浅拷贝。
重要的是,这些都是浅拷贝。当两个集合是浅拷贝时,它们各自包含对相同底层对象的引用。如果底层对象是不可变的,如元组、数字或字符串,这种区别并不重要。
例如,如果我们有 a = [1, 1, 2, 3],我们就无法对 a[0] 进行任何修改。a[0] 中的数字 1 没有内部状态。我们只能替换对象。
然而,当我们有一个涉及可变对象的集合时,问题就出现了。首先,我们会创建一个对象,然后创建一个副本:
>>> some_dict = {’a’: [1, 1, 2, 3]}
>>> another_dict = some_dict.copy()
这个例子创建了一个字典的浅拷贝。这两个副本看起来很相似,因为它们都包含对相同对象的引用。有一个共享的引用到不可变的字符串 'a' 和一个共享的引用到可变的列表 [1, 1, 2, 3]。我们可以显示 another_dict 的值来看到它看起来像我们开始时的 some_dict 对象:
>>> another_dict
{’a’: [1, 1, 2, 3]}
当我们更新字典副本中的共享列表时,会发生以下情况。我们将更改 some_dict 的值,并看到结果也出现在 another_dict 中:
>>> some_dict[’a’].append(5)
>>> another_dict
{’a’: [1, 1, 2, 3, 5]}
我们可以使用 id() 函数看到项是共享的:
>>> id(some_dict[’a’]) == id(another_dict[’a’])
True
因为两个 id() 值相同,所以它们是相同的底层对象。与键 'a' 关联的值在 some_dict 和 another_dict 中都是相同的可变列表。我们也可以使用 is 操作符来看到它们是相同的对象。
这种对浅拷贝的修改适用于包含所有其他可变对象类型作为项的列表集合:
因为我们不能创建一个包含可变对象的集合,所以我们实际上不必考虑创建共享项的集合的浅拷贝。
一个元组可以包含可变对象。虽然元组是不可变的,但元组内的对象是可变的。
元组的不可变性不会神奇地传播到元组内的项。
如果我们想要完全断开两个副本的连接?我们如何创建深拷贝而不是浅拷贝?
5.5.2 如何实现...
Python 通常通过共享引用来工作。它不情愿地复制对象。默认行为是创建浅拷贝,共享集合中项的引用。以下是创建深拷贝的方法:
-
导入 copy 模块:
>>> import copy -
使用 copy.deepcopy() 函数复制一个对象及其包含的所有可变项:
>>> some_dict = {’a’: [1, 1, 2, 3]} >>> another_dict = copy.deepcopy(some_dict)
这将创建没有共享引用的副本。对一个副本的可变内部项的更改不会对其他任何地方产生任何影响:
>>> some_dict[’a’].append(5)
>>> some_dict
{’a’: [1, 1, 2, 3, 5]}
>>> another_dict
{’a’: [1, 1, 2, 3]}
我们更新了 some_dict 中的某个项,但它对另一个 _dict 中的副本没有影响。我们可以使用 id()函数看到这些对象是不同的:
>>> id(some_dict[’a’]) == id(another_dict[’a’])
False
由于 id 值不同,这些是不同的对象。我们还可以使用 is 运算符来查看它们是不同的对象。
5.5.3 它是如何工作的...
创建浅复制相对简单。我们甚至可以用自己的版本编写算法,使用推导式(包含生成器表达式):
>>> copy_of_list = [item for item in some_list]
>>> copy_of_dict = {key:value for key, value in some_dict.items()}
在列表的情况下,新列表的项是源列表中项的引用。同样,在字典的情况下,键和值是源字典中键和值的引用。
deepcopy()函数使用递归算法来查看每个可变集合中的每个项。
对于具有列表类型的对象,概念算法类似于以下内容:
from typing import Any
def deepcopy_json(some_obj: Any) -> Any:
match some_obj:
case int() | float() | tuple() | str() | bytes() | None:
return some_obj
case list() as some_list:
list_copy: list[Any] = []
for item in some_list:
list_copy.append(deepcopy_json(item))
return list_copy
case dict() as some_dict:
dict_copy: dict[Any, Any] = {}
for key in some_dict:
dict_copy[key] = deepcopy_json(some_dict[key])
return dict_copy
case _:
raise ValueError(f"can’t copy {type(some_obj)}")
这可以用于 JSON 文档中使用的类型集合。对于第一种情况子句中的不可变类型,没有必要进行复制;这些类型之一的对象不能被修改。对于 JSON 文档中使用的两种可变类型,构建空结构,然后插入每个项的副本。处理涉及递归以确保——无论嵌套多深——所有可变项都被复制。
deepcopy()函数的实际实现处理了 JSON 规范之外的额外类型。这个例子旨在展示深度复制函数的一般思想。
5.5.4 参见
- 在理解变量、引用和赋值的配方中,我们探讨了 Python 如何倾向于创建对象的引用。
5.6 避免为函数参数使用可变默认值
在第 3 章中,我们探讨了 Python 函数定义的许多方面。在设计具有可选参数的函数的配方中,我们展示了处理可选参数的配方。当时,我们没有深入探讨提供可变结构引用作为默认值的问题。我们将仔细研究函数参数可变默认值的后果。
5.6.1 准备工作
让我们想象一个函数,它要么创建,要么更新一个可变的 Counter 对象。我们将称之为 gather_stats()。
理想情况下,一个小型数据收集函数可能看起来像这样:
from collections import Counter
from random import randint, seed
def gather_stats_bad(
n: int,
samples: int = 1000,
summary: Counter[int] = Counter()
) -> Counter[int]:
summary.update(
sum(randint(1, 6)
for d in range(n)) for _ in range(samples)
)
return summary
这展示了函数的一个糟糕设计。它有两个场景:
-
第一个场景为摘要参数没有提供任何参数值。当省略此参数时,函数创建并返回一个统计集合。以下是这个故事的例子:
>>> seed(1) >>> s1 = gather_stats_bad(2) >>> s1 Counter({7: 168, 6: 147, 8: 136, 9: 114, 5: 110, 10: 77, 11: 71, 4: 70, 3: 52, 12: 29, 2: 26}) -
第二个场景允许我们为摘要参数提供一个显式的参数值。当提供此参数时,此函数更新给定的对象。以下是这个故事的例子:
>>> seed(1) >>> mc = Counter() >>> gather_stats_bad(2, summary=mc) Counter... >>> mc Counter({7: 168, 6: 147, 8: 136, 9: 114, 5: 110, 10: 77, 11: 71, 4: 70, 3: 52, 12: 29, 2: 26})我们已经设置了随机数种子以确保两个随机值序列是相同的。我们提供了一个 Counter 对象以确认结果是一致的。
问题出现在我们执行上述第一个场景之后的以下操作:
>>> seed(1)
>>> s3b = gather_stats_bad(2)
>>> s3b
Counter({7: 336, 6: 294, 8: 272, 9: 228, 5: 220, 10: 154, 11: 142, 4: 140, 3: 104, 12: 58, 2: 52})
这个例子中的值是不正确的。它们被加倍了。出了点问题。这只有在多次使用默认场景时才会发生。这段代码可以通过一个简单的单元测试套件并看起来是正确的。
正如我们在制作浅拷贝和深拷贝对象的配方中看到的,Python 更喜欢共享引用。共享的一个后果是,由 s1 变量引用的对象和由 s3b 变量引用的对象是同一个对象:
>>> s1 is s3b
True
这意味着当为 s3b 变量的对象创建时,s1 变量引用的对象的值发生了变化。从这个例子中,应该很明显,该函数正在更新一个单一、共享的集合对象,并返回共享集合的引用。
这个 gather_stats_bad() 函数的摘要参数使用的默认值导致结果值由一个单一、共享的对象构建。我们如何避免这种情况?
5.6.2 如何操作...
解决可变默认参数问题的有两种方法:
-
提供一个不可变的默认值。
-
改变设计。
我们首先看看不可变默认值。改变设计通常是一个更好的主意。为了看到为什么改变设计更好,我们将展示一个纯粹的技术解决方案。
当我们为函数提供默认值时,默认对象只创建一次,并且之后永远共享。这里有一个替代方案:
-
将任何可变的默认参数值替换为 None:
def gather_stats_good( n: int, samples: int = 1000, summary: Counter[int] | None = None ) -> Counter[int]:def gather_stats_good( n: int, summary: Counter[int] | None = None, samples: int = 1000, ) -> Counter[int]: -
添加一个 if 语句来检查 None 参数值,并用一个全新的、正确的可变对象替换它:
if summary is None: summary = Counter()这将确保每次函数在没有参数值的情况下评估时,我们都会创建一个全新的、可变的对象。我们将避免反复共享单个可变对象。
5.6.3 它是如何工作的...
如我们之前所提到的,Python 更喜欢共享引用。它很少在没有显式使用 copy 模块或对象的 copy() 方法的情况下创建对象的副本。因此,函数参数值的默认值将是共享对象。Python 不会为默认参数值创建全新的对象。
永远不要为函数参数的默认值使用可变默认值。
而不是使用可变对象(例如,集合、列表或字典)作为默认值,使用 None。
在大多数情况下,我们应该考虑改变设计,根本不提供默认值。相反,定义两个单独的函数。一个函数更新参数值,另一个函数使用这个函数,但提供一个全新的、空的、可变的对象。
对于这个例子,它们可能被称为 create_stats() 和 update_stats(),具有明确的参数:
def update_stats(
n: int,
summary: Counter[int],
samples: int = 1000,
) -> Counter[int]:
summary.update(
sum(randint(1, 6)
for d in range(n)) for _ in range(samples))
return summary
def create_stats(n: int, samples: int = 1000) -> Counter[int]:
return update_stats(n, Counter(), samples)
注意,update_stats()函数的 summary 参数不是可选的。同样,create_stats()函数也没有定义 summary 对象参数。
可选可变参数的想法并不好,因为作为参数默认值的可变对象被重复使用。
5.6.4 更多内容...
在标准库中,有一些示例展示了如何创建新的默认对象的一个酷技术。许多地方使用工厂函数作为参数。这个函数可以用来创建一个全新的可变对象。
为了利用这种设计模式,我们需要修改 update_stats()函数的设计。我们不再在函数中更新现有的 Counter 对象。我们将始终创建一个全新的对象。
这里有一个调用工厂函数来创建有用默认值的函数:
from collections import Counter
from collections.abc import Callable, Iterable, Hashable
from typing import TypeVar, TypeAlias
T = TypeVar(’T’, bound=Hashable)
Summarizer: TypeAlias = Callable[[Iterable[T]], Counter[T]]
def gather_stats_flex(
n: int,
samples: int = 1000,
summary_func: Summarizer[int] = Counter
) -> Counter[int]:
summary = summary_func(
sum(randint(1, 6)
for d in range(n)) for _ in range(samples))
return summary
对于这个版本,我们定义了 Summarizer 类型为一个接受一个参数的函数,该函数将创建一个 Counter 对象。默认值使用 Counter 类作为单参数函数。我们可以用任何单参数函数来覆盖 summary_func 函数,该函数将收集细节而不是总结。
这里是一个使用 list 而不是 collections.Counter 的例子:
>>> seed(1)
>>> gather_stats_flex(2, 12, summary_func=list)
[7, 4, 5, 8, 10, 3, 5, 8, 6, 10, 9, 7]
在这个例子中,我们提供了 list 函数来创建一个包含单个随机样本的列表。
这里是一个不带参数值的例子。每次使用时,它都会创建一个新的 collections.Counter 对象:
>>> seed(1)
>>> gather_stats_flex(2, 12)
Counter({7: 2, 5: 2, 8: 2, 10: 2, 4: 1, 3: 1, 6: 1, 9: 1})
在这个例子中,我们使用默认的 summary_func 值来评估函数,它从随机样本中创建一个 collections.Counter 对象。
5.6.5 参见
- 参见创建字典 – 插入和更新食谱,它展示了 defaultdict 集合的工作方式。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第六章:6
用户输入和输出
软件的关键目的是产生有用的输出。在许多可能的输出中,一种简单的输出类型是显示有用结果的文本。Python 通过 print()函数支持这一点。
input()函数与 print()函数类似。input()函数从控制台读取文本,使我们能够向程序提供数据。使用 print()和 input()在应用程序的输入和输出之间创建了一种优雅的对称性。
向程序提供输入有许多其他常见方式。解析命令行对许多应用程序很有帮助。我们有时需要使用配置文件来提供有用的输入。数据文件和网络连接是提供输入的更多方式。这些方法各不相同,需要单独考虑。在本章中,我们将关注 input()和 print()的基础知识。
在本章中,我们将探讨以下配方:
-
使用 print()函数的特性
-
使用 input()和 getpass()获取用户输入
-
使用 f”{value=}”字符串进行调试
-
使用 argparse 获取命令行输入
-
使用 invoke 获取命令行输入
-
使用 cmd 创建命令行应用程序
-
使用 OS 环境设置
似乎最好从 print()函数开始,并展示它可以做的一些事情。毕竟,应用程序的输出通常最有用。
6.1 使用 print()函数的特性
在许多情况下,print()函数是我们首先了解的函数。第一个脚本通常是以下内容的变体:
>>> print("Hello, world.")
Hello, world.
print()函数可以显示多个值,项目之间有有帮助的空格。
当我们这样写时:
>>> count = 9973
>>> print("Final count", count)
Final count 9973
我们可以看到,为我们包含了空格分隔符。此外,在函数中提供的值之后,通常会打印一个换行符,通常表示为\n 字符。
我们能否控制这种格式?我们能否更改提供的额外字符?
6.1.1 准备工作
考虑这个用于记录大型帆船燃油消耗的电子表格。CSV 文件中的行看起来像这样:
date,engine on,fuel height on,engine off,fuel height off
10/25/13,08:24:00,29,13:15:00,27
10/26/13,09:12:00,27,18:25:00,22
10/28/13,13:21:00,22,06:25:00,14
关于此数据的更多信息,请参阅第四章中的缩小集合 – remove()、pop()和 difference 和切片和切块列表配方。由于油箱内部没有传感器,燃油的深度是通过油箱侧面的玻璃面板观察到的。知道油箱大约是矩形的,深度约为 31 英寸,容量约为 72 加仑,可以将深度转换为体积。
这里是一个使用此 CSV 数据的示例。此函数读取文件并返回由每一行构建的字段列表:
from pathlib import Path
import csv
def get_fuel_use(source_path: Path) -> list[dict[str, str]]:
with source_path.open() as source_file:
rdr = csv.DictReader(source_file)
return list(rdr)
这里是一个从 CSV 文件中读取和打印行的示例:
>>> source_path = Path("data/fuel2.csv")
>>> fuel_use = get_fuel_use(source_path)
>>> for row in fuel_use:
... print(row)
{’date’: ’10/25/13’, ’engine on’: ’08:24:00’, ’fuel height on’: ’29’, ’engine off’: ’13:15:00’, ’fuel height off’: ’27’}
{’date’: ’10/26/13’, ’engine on’: ’09:12:00’, ’fuel height on’: ’27’, ’engine off’: ’18:25:00’, ’fuel height off’: ’22’}
{’date’: ’10/28/13’, ’engine on’: ’13:21:00’, ’fuel height on’: ’22’, ’engine off’: ’06:25:00’, ’fuel height off’: ’14’}
print()函数的输出,如这里所示的长行,使用起来具有挑战性。让我们看看如何使用 print()函数的附加功能来改进这个输出。
6.1.2 如何做...
我们有两种方式来控制 print()函数的输出格式:
-
设置字段分隔符字符串,sep。默认值是一个空格字符。
-
设置行结束字符串,end。默认值是\n 字符。
这个配方将展示几个变体:
-
读取数据:
>>> fuel_use = get_fuel_use(Path("data/fuel2.csv")) -
对于数据中的每一项,进行任何有用的数据转换:
>>> for leg in fuel_use: ... start = float(leg["fuel height on"]) ... finish = float(leg["fuel height off"]) -
以下替代方案展示了不同的包含分隔符的方法:
-
使用 sep 和 end 的默认值打印标签和字段:
... print("On", leg["date"], "from", leg["engine on"], ... "to", leg["engine off"], ... "change", start-finish, "in.") On 10/25/13 from 08:24:00 to 13:15:00 change 2.0 in. On 10/26/13 from 09:12:00 to 18:25:00 change 5.0 in. On 10/28/13 from 13:21:00 to 06:25:00 change 8.0 in.当我们查看输出时,我们可以看到在每一项之间插入了一个空格。
-
在准备数据时,我们可能希望使用类似于 CSV 的格式,可能使用非简单逗号的列分隔符。我们可以使用" | "作为 sep 参数的字符串值来打印标签和字段:
... print(leg["date"], leg["engine on"], ... leg["engine off"], start-finish, sep=" | ") 10/25/13 | 08:24:00 | 13:15:00 | 2.0 10/26/13 | 09:12:00 | 18:25:00 | 5.0 10/28/13 | 13:21:00 | 06:25:00 | 8.0在这种情况下,我们可以看到每一列都有给定的分隔符字符串。由于没有更改结束设置,每个 print()函数都产生了一条独特的输出行。
-
这是我们如何更改默认标点符号以强调字段名称和值的示例。我们可以使用"="作为 sep 参数的字符串值和", "作为 end 参数的值来打印标签和字段:
... print("date", leg["date"], sep="=", end=", ") ... print("on", leg["engine on"], sep="=", end=", ") ... print("off", leg["engine off"], sep="=", end=", ") ... print("change", start-finish, sep="=") date=10/25/13, on=08:24:00, off=13:15:00, change=2.0 date=10/26/13, on=09:12:00, off=18:25:00, change=5.0 date=10/28/13, on=13:21:00, off=06:25:00, change=8.0由于行尾使用的字符串已更改为", ",因此 print()函数的每次使用不再产生单独的行。为了看到正确的行尾,最后的 print()函数有一个默认的 end 值。我们也可以使用 end="\n"的参数值来明确地表示换行符的存在。
-
6.1.3 它是如何工作的...
print()函数的定义包括几个必须以关键字形式提供的参数。其中两个是 sep 和 end 关键字参数,分别具有空格和换行符的默认值。
使用 print()函数的 sep 和 end 参数对于比这些简单示例更复杂的情况可能会变得相当复杂。而不是处理一系列复杂的 print()函数请求,我们可以使用字符串的 format()方法,或者使用 f-string。
6.1.4 更多...
sys 模块定义了两个始终可用的标准输出文件:sys.stdout 和 sys.stderr。通常,print()函数可以被视为 sys.stdout.write()的一个便捷包装器。
我们可以使用 file=关键字参数将内容写入标准错误文件,而不是写入标准输出文件:
>>> import sys
>>> print("Red Alert!", file=sys.stderr)
我们导入了 sys 模块,以便我们可以访问标准错误文件。我们使用它来写入一条不会成为标准输出流一部分的消息。
由于这两个文件始终可用,使用 OS 文件重定向技术通常效果很好。当我们的程序的主要输出写入 sys.stdout 时,它可以在 OS 级别进行重定向。用户可能会输入一个类似这样的 shell 命令行:
% python myapp.py < input.dat > output.dat
这将为 sys.stdin 提供 input.dat 文件作为输入。当这个 Python 程序写入 sys.stdout 时,输出将由 OS 重定向到 output.dat 文件。
在某些情况下,我们需要打开额外的文件。在这种情况下,我们可能会看到这样的编程:
>>> from pathlib import Path
>>> target_path = Path("data")/"extra_detail.log"
>>> with target_path.open(’w’) as target_file:
... print("Some detailed output", file=target_file)
... print("Ordinary log")
Ordinary log
在本例中,我们已为输出打开了一个特定的路径,并使用 with 语句将打开的文件分配给 target_file。然后我们可以将此作为 print()函数中的 file=值来写入此文件。因为文件是一个上下文管理器,所以离开 with 语句意味着文件将被正确关闭;所有 OS 资源都将从应用程序中释放。所有文件操作都应该用 with 语句的上下文包装,以确保资源得到适当的释放。
6.1.5 相关阅读
-
对于更多格式化选项,请参阅使用 f”{value=}"字符串进行调试食谱。
-
关于本例中输入数据的更多信息,请参阅第四章中的收缩集合 – remove(), pop(), 和 difference 和切片和切块列表食谱。
-
关于文件操作的一般信息,请参阅第八章。
6.2 使用 input()和 getpass()获取用户输入
一些 Python 脚本依赖于从用户那里收集输入。有几种方法可以做到这一点。一种流行的技术是使用控制台以交互方式提示用户输入。
有两种相对常见的情况:
-
普通输入:这将提供输入字符的有帮助的回显。
-
安全、无回显输入:这通常用于密码。输入的字符不会显示,提供了一定程度的隐私。我们使用 getpass 模块中的 getpass()函数来完成这项工作。
作为交互式输入的替代方案,我们将在本章后面的使用 argparse 获取命令行输入食谱中探讨一些其他方法。
input()和 getpass()函数只是从控制台读取的两种实现选择。结果是,获取字符字符串只是收集有用数据的第一步。输入还需要进行验证。
6.2.1 准备工作
我们将探讨一种从人那里读取复杂结构的技术。在这种情况下,我们将使用年、月和日作为单独的项目。然后这些项目被组合起来创建一个完整的日期。
这里有一个快速的用户输入示例,省略了所有验证考虑。这是糟糕的设计:
from datetime import date
def get_date1() -> date:
year = int(input("year: "))
month = int(input("month [1-12]: "))
day = int(input("day [1-31]: "))
result = date(year, month, day)
return result
虽然使用 input()函数非常容易,但它缺少许多有用的功能。当用户输入无效日期时,这可能会引发一个可能令人困惑的异常。
我们经常需要将 input()函数与数据验证处理包装起来,使其更有用。日历很复杂,我们不愿意在未警告用户的情况下接受 2 月 31 日,这不是一个正确的日期。
6.2.2 如何实现...
-
如果输入是密码或类似需要编辑的内容,则 input()函数不是最佳选择。如果涉及密码或其他秘密,则使用 getpass.getpass()函数。这意味着当涉及秘密时,我们需要以下导入:
from getpass import getpass否则,当不需要秘密输入时,我们将使用内置的 input()函数,不需要额外的导入。
-
确定将使用哪个提示。在我们的例子中,我们提供了一个字段名称和有关预期数据类型的提示作为 input()或 getpass()函数的提示字符串参数。这有助于将输入与文本到整数的转换分开。这个配方不遵循之前显示的片段;它将操作分解为两个独立的步骤。首先,获取文本值:
year_text = input("year: ") -
确定如何单独验证每个项目。最简单的情况是一个具有涵盖所有内容的单个规则的单个值。在更复杂的情况下——就像这个例子——每个单独的元素都是一个具有范围约束的数字。在后续步骤中,我们将查看验证组合项目:
year = int(year_text)将输入和验证包装成如下所示的 while-try 块:
year = None while year is None: year_text = input("year: ") try: year = int(year_text) except ValueError as ex: print(ex)
此处应用单个验证规则,即 int(year_txt)表达式,以确保输入是整数。while 语句导致输入和转换步骤的重复,直到 year 变量的值为 None。
对于错误输入抛出异常为我们提供了一些灵活性。我们可以通过扩展额外的异常类来满足输入必须满足的其他条件。
此处理过程仅涵盖年份字段。我们还需要获取月份和日期字段的值。这意味着我们需要为复杂日期对象的这三个字段分别编写三个几乎相同的循环。为了避免复制和粘贴几乎相同的代码,我们需要重构此处理过程。
我们将定义一个新的函数 get_integer(),用于通用数字值输入。以下是完整的函数定义:
def get_integer(prompt: str) -> int:
while True:
value_text = input(prompt)
try:
value = int(value_text)
return value
except ValueError as ex:
print(ex)
我们可以将这些组合成一个整体过程,以获取日期的三个整数。这将涉及类似 while-try 设计模式,但应用于组合对象。它看起来像这样:
def get_date2() -> date:
while True:
year = get_integer("year: ")
month = get_integer("month [1-12]: ")
day = get_integer("day [1-31]: ")
try:
result = date(year, month, day)
return result
except ValueError as ex:
print(f"invalid, {ex}")
这使用围绕 get_integer()函数序列的单独 while-try 处理序列来获取构成日期的各个值。然后,它使用 date()构造函数从单个字段创建日期对象。如果由于组件无效,无法构建日期对象——作为一个整体——则必须重新输入年、月和日以创建一个有效的日期。
6.2.3 它是如何工作的...
我们需要将输入问题分解为几个相互独立但密切相关的子问题。为此,想象一个转换步骤的塔。在最底层是与用户的初始交互。我们确定了两种处理这种交互的常见方法:
-
input():此函数提示并从用户那里读取
-
getpass.getpass():此函数提示并读取输入(如密码)而不显示回显
这两个函数提供了基本的控制台交互。如果需要更复杂的交互,还有其他库可以提供。例如,Click 项目有一些有用的提示功能。参见click.palletsprojects.com/en/7.x/。
Rich 项目具有极其复杂的终端交互。参见rich.readthedocs.io/en/latest/。
在基础之上,我们构建了几个验证处理的层级。层级如下:
-
数据类型验证:这使用内置的转换函数,如 int()或 float()。这些函数对无效文本引发 ValueError 异常。
-
域验证:这使用 if 语句来确定值是否符合任何特定应用程序的约束。为了保持一致性,如果数据无效,也应引发 ValueError 异常。
-
组合对象验证:这是特定于应用程序的检查。在我们的例子中,组合对象是 datetime.date 的一个实例。这也倾向于对无效的日期引发 ValueError 异常。
可能对值施加的约束类型有很多。我们使用了有效的日期约束,因为它特别复杂。
6.2.4 更多内容...
我们有几种涉及略微不同方法的用户输入替代方案。我们将详细探讨这两个主题:
-
复杂文本:这将涉及简单使用 input()和更复杂的源文本解析。而不是提示单个字段,可能更好的是接受 yyyy-mm-dd 格式的字符串,并使用 strptime()解析器提取日期。这不会改变设计模式;它用稍微复杂一些的东西替换了 int()或 float()。
-
通过 cmd 模块进行交互:这涉及一个更复杂的类来控制交互。我们将在使用 cmd 创建命令行应用程序的配方中详细探讨这一点。
可以从 JSON 模式定义中提取潜在输入验证规则列表。此类型列表包括布尔值、整数、浮点数和字符串。在 JSON 模式中定义的许多常见字符串格式包括日期时间、时间、日期、电子邮件、主机名、IPv4 和 IPv6 格式的 IP 地址以及 URI。
用户输入验证规则的另一个来源可以在 HTML5 标签的定义中找到。此列表包括颜色、日期、datetime-local、电子邮件、文件、月份、数字、密码、电话号码、时间、URL 和周年的格式。
6.2.5 参见
-
在本章中查看使用 cmd 创建命令行应用程序的配方以了解复杂交互。
-
查看使用使用 argparse 获取命令行输入的配方以从命令行收集用户输入。
-
在 SunOS 操作系统的参考资料中,该系统现在由 Oracle 拥有,其中包含一组提示不同类型用户输入的命令:
docs.oracle.com/cd/E19683-01/816-0210/6m6nb7m5d/index.html
6.3 使用 f”{value=}”字符串进行调试
Python 中可用的重要调试和设计工具之一是 print()函数。在使用 print()函数的功能食谱中显示的两个格式选项提供的灵活性不多。我们有更多的灵活性使用 f"string"格式。我们将基于第一章、数字、字符串和元组中显示的一些食谱。
6.3.1 准备工作
让我们看看一个涉及一些中等复杂计算的多步骤过程。我们将计算一些样本数据的平均值和标准差。给定这些值,我们将定位所有高于平均值一个标准差的项:
>>> import statistics
>>> size = [2353, 2889, 2195, 3094,
... 725, 1099, 690, 1207, 926,
... 758, 615, 521, 1320]
>>> mean_size = statistics.mean(size)
>>> std_size = statistics.stdev(size)
>>> sig1 = round(mean_size + std_size, 1)
>>> [x for x in size if x > sig1]
[2353, 2889, 3094]
这个计算有几个工作变量。最终的列表推导式涉及其他三个变量,mean_size、std_size 和 sig1。使用这么多值来过滤大小列表,很难可视化正在发生的事情。了解计算的步骤通常很有帮助;显示中间变量的值可能非常有帮助。
6.3.2 如何做...
f"{name=}"字符串将同时包含字面字符串 name=和 name 表达式的值。这通常是一个变量,但可以使用任何表达式。使用这个与 print()函数结合的例子如下:
>>> print(
... f"{mean_size=:.2f}, {std_size=:.2f}"
... )
mean_size=1414.77, std_size=901.10
我们可以使用{name=}将任何变量放入 f-string 中并查看其值。上述代码中的这些例子包括格式说明符:.2f 作为后缀,以显示四舍五入到两位小数的值。另一个常见的后缀是!r,用于显示对象的内部表示;我们可能会使用 f"{name=!r}"。
6.3.3 它是如何工作的...
关于格式选项的更多背景信息,请参阅第一章中构建复杂的 f-string 字符串食谱。
这个功能有一个非常实用的扩展。我们可以在 f-string 中的=左侧使用任何表达式。这将显示表达式及其计算出的值,为我们提供更多的调试信息。
6.3.4 更多...
我们可以使用 f-string 的扩展表达式功能包括额外的计算,这些计算不仅仅是局部变量的值:
>>> print(
... f"{mean_size=:.2f}, {std_size=:.2f},"
... f" {mean_size + 2 * std_size=:.2f}"
... )
mean_size=1414.77, std_size=901.10, mean_size + 2 * std_size=3216.97
我们已经计算了一个新值,mean_size+2*std_size,它只出现在格式化输出中。这使得我们可以在不创建额外变量的情况下显示中间计算结果。
6.3.5 参见
-
参考第一章中的“使用 f-strings 构建复杂的字符串”食谱,了解更多可以使用 f-strings 和字符串的 format()方法完成的事情。
-
参考本章前面的“使用 print()函数的特性”食谱,了解其他格式化选项。
6.4 使用 argparse 获取命令行输入
对于某些应用,在没有太多人工交互的情况下从操作系统命令行获取用户输入可能更好。我们更愿意解析命令行参数值,然后执行处理或报告错误。
例如,在操作系统级别,我们可能想运行这样的程序:
% python ch06/distance_app.py -u KM 36.12,-86.67 33.94,-118.40
From 36.12,-86.67 to 33.94,-118.4 in KM = 2886.90
在%的操作系统提示符下,我们输入了一个命令,python ch06/distance_app.py。此命令有一个可选参数,-u KM,以及两个位置参数 36.12,-86.67 和 33.94,-118.40。
如果用户输入错误,交互可能看起来像这样:
% python ch06/distance_app.py -u KM 36.12,-86.67 33.94,-118asd
usage: distance_app.py [-h] [-u {NM,MI,KM}] p1 p2
distance_app.py: error: argument p2: could not convert string to float: ’-118asd’
-118asd 的无效参数值会导致错误消息。用户可以按上箭头键获取之前的命令行,进行更改,然后再次运行程序。交互式用户体验委托给操作系统命令行处理。
6.4.1 准备工作
我们需要做的第一件事是重构我们的代码,创建三个单独的函数:
-
一个从命令行获取参数的函数。
-
一个执行实际工作的函数。目的是定义一个可以在各种环境中重用的函数,其中之一是使用命令行参数。
-
一个主函数,它收集参数并使用适当的参数值调用实际工作函数。
下面是我们的实际工作函数,display():
from ch06.distance_computation import haversine, MI, NM, KM
def display(
lat1: float, lon1: float, lat2: float, lon2: float, r: str
) -> None:
r_float = {"NM": NM, "KM": KM, "MI": MI}[r]
d = haversine(lat1, lon1, lat2, lon2, R=r_float)
print(f"From {lat1},{lon1} to {lat2},{lon2} in {r} = {d:.2f}")
我们已从 ch06.distance_computation 模块导入了核心计算函数 haversine()。这是基于第三章中“基于部分函数选择参数顺序”食谱中显示的计算:
下面是函数在 Python 内部使用时的样子:
>>> display(36.12, -86.67, 33.94, -118.4, ’NM’)
From 36.12,-86.67 to 33.94,-118.4 in NM = 1558.53
此函数有两个重要的设计特点。第一个特点是它避免了引用由参数解析创建的 argparse.Namespace 对象的功能。我们的目标是拥有一个可以在多个不同环境中重用的函数。我们需要将用户界面的输入和输出元素分开。
第二个设计特点是该功能显示另一个函数计算出的值。这是将一个较大的问题分解为两个较小问题的分解。我们将打印输出的用户体验与基本计算分离。(这两个方面都相当小,但分离这两个方面的原则很重要。)
6.4.2 如何实现...
-
定义整体参数解析函数:
def get_options(argv: list[str]) -> argparse.Namespace: -
创建解析器对象:
parser = argparse.ArgumentParser() -
将各种类型的参数添加到解析器对象中。有时,这很困难,因为我们仍在改进用户体验。很难想象人们会如何使用程序以及他们可能提出的所有问题。在我们的例子中,我们有两个必需的位置参数和一个可选参数:
-
第一点:纬度和经度
-
第二点:纬度和经度
-
可选的距离单位;我们将提供海里作为默认值:
parser.add_argument("-u", "--units", action="store", choices=("NM", "MI", "KM"), default="NM") parser.add_argument("p1", action="store", type=point_type) parser.add_argument("p2", action="store", type=point_type)我们添加了可选和必需参数的混合。-u 参数以短横线开头,表示它是可选的。支持较长的双短横线版本 --units 作为替代。
必需的位置参数不带前缀命名。
-
-
评估步骤 2 中创建的解析器对象的 parse_args() 方法:
options = parser.parse_args(argv)
默认情况下,解析器使用 sys.argv 的值,即用户输入的命令行参数值。当我们能够提供明确的参数值时,测试会更加容易。
下面是最终的函数:
def get_options(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-u", "--units",
action="store", choices=("NM", "MI", "KM"), default="NM")
parser.add_argument("p1", action="store", type=point_type)
parser.add_argument("p2", action="store", type=point_type)
options = parser.parse_args(argv)
return options
这依赖于一个 point_type() 函数,该函数既验证字符串,又把字符串转换成(纬度,经度)两个元素的元组。下面是这个函数的定义:
def point_type(text: str) -> tuple[float, float]:
try:
lat_str, lon_str = text.split(",")
lat = float(lat_str)
lon = float(lon_str)
return lat, lon
except ValueError as ex:
raise argparse.ArgumentTypeError(ex)
如果出现任何问题,将引发异常。从这个异常中,我们将引发 ArgumentTypeError 异常。这个异常被 argparse 模块捕获,并导致它向用户报告错误。
这里是结合选项解析器和输出显示功能的主体脚本:
def main(argv: list[str] = sys.argv[1:]) -> None:
options = get_options(argv)
lat_1, lon_1 = options.p1
lat_2, lon_2 = options.p2
display(lat_1, lon_1, lat_2, lon_2, r=options.r)
if __name__ == "__main__":
main()
此主体脚本将用户输入连接到显示的输出。错误消息的详细信息和处理帮助被委托给 argparse 模块。
6.4.3 它是如何工作的...
参数解析器分为三个阶段:
-
通过创建一个 ArgumentParser 类的实例来创建一个解析器对象,从而定义整体上下文。
-
使用 add_argument() 方法添加单个参数。这些参数可以包括可选参数以及必需参数。
-
解析实际的命令行输入,通常基于 sys.argv。
一些简单的程序可能只有几个可选参数。一个更复杂的程序可能有更多可选参数。
通常,文件名作为位置参数。当程序读取一个或多个文件时,文件名可以按如下方式在命令行中提供:
% python some_program.py *.rst
我们使用了 Linux shell 的通配符功能:*.rst 字符串被扩展成匹配命名规则的文件列表。这是 Linux shell 的一个特性,发生在 Python 解释器开始之前。这个文件列表可以使用以下定义的参数进行处理:
parser.add_argument(’file’, type=Path, nargs=’*’)
命令行上所有不以 - 字符开头的参数都是位置参数,并且它们被收集到由解析器构建的对象的 file 值中。
然后,我们可以使用以下方式处理每个给定的文件:
for filename in options.file:
process(filename)
对于 Windows 程序,shell 不会从通配符模式中获取文件名。这意味着应用程序必须处理包含通配符字符(如 "*" 和 "?”)的文件名。Python 的 glob 模块可以帮助处理这个问题。此外,pathlib 模块可以创建 Path 对象,这些对象包括用于在目录中定位匹配文件名的通配符功能。
6.4.4 更多内容...
我们可以处理哪些类型的参数?在常见使用中有很多参数风格。所有这些变体都是使用解析器的 add_argument() 方法定义的:
-
简单选项:形式为 -o 或 --option 的参数通常定义可选功能。这些使用的是 ‘store_true’ 或 ‘store_false’ 动作。
-
带值的选项:我们展示了 -r unit 作为带值的选项。‘store’ 动作是保存值的方式。
-
增加计数的选项:动作 ‘count’ 和默认值 =0 允许重复的选项。例如,详细和非常详细的日志选项 -v 和 -vv。
-
累积列表的选项:动作 ‘append’ 和默认值 [] 可以累积多个选项值。
-
显示版本号:可以使用特殊动作 ‘version’ 创建一个将显示版本号并退出的参数。
-
位置参数在其名称中不带有前导 ‘-’。它们必须按照将使用的顺序定义。
argparse 模块使用 -h 和 --help 将显示帮助信息并退出。除非使用具有 ‘help’ 动作的参数更改,否则这些选项都是可用的。
这涵盖了命令行参数处理的常见情况。通常,当我们编写自己的应用程序时,我们会尝试利用这些常见的参数风格。如果我们努力遵循广泛使用的参数风格,我们的用户更有可能理解我们的应用程序是如何工作的。
6.4.5 参见
-
我们在 使用 input() 和 getpass() 获取用户输入 的菜谱中探讨了如何获取交互式用户输入。
-
我们将在 使用 OS 环境设置 的菜谱中查看如何添加更多灵活性。
6.5 使用 invoke 获取命令行输入
invoke 包不是标准库的一部分。它需要单独安装。通常,这是通过以下终端命令完成的:
(cookbook3) % python -m pip install invoke
使用 python -m pip 命令确保我们将使用与当前活动虚拟环境一起的 pip 命令,显示为 cookbook3。
请参阅本章中的 使用 argparse 获取命令行输入 菜谱。它描述了一个类似以下的工作命令行应用程序:
% RECIPE=7 # invoke
命令始终是 invoke。Python 路径信息用于定位名为 tasks.py 的模块文件,以提供可以调用的命令的定义。剩余的命令行值提供给 tasks 模块中定义的函数。
6.5.1 准备工作
使用 invoke 时,我们通常会创建一个双层设计。这两层是:
-
一个从命令行获取参数、执行所需的验证或转换,并调用函数执行真实工作的函数。这个函数将被装饰为@task。
-
执行真实工作的函数。如果这个函数被设计成不直接引用命令行选项,那么这会有所帮助。目的是定义一个可以在各种环境中重用的函数,其中之一就是使用来自命令行的参数。
在某些情况下,这两个函数可以合并成一个。这种情况通常发生在 Python 被用作包装器,为底层提供简单接口,而底层应用却异常复杂时。在这种应用中,Python 包装器可能只做很少的处理,参数值验证与应用的“真实工作”之间没有有用的区别。
在本章的使用 argparse 获取命令行输入配方中,定义了 display()函数。这个函数执行应用的“真实工作”。当与 invoke 一起工作时,这种设计将继续使用。
6.5.2 如何做...
-
定义一个描述可以调用的任务的函数。通常,提供一些关于各种参数的帮助信息是至关重要的,这可以通过向@task 装饰器提供参数名称和帮助文本的字典来完成:
import sys from invoke.tasks import task from invoke.context import Context@task( help={ ’p1’: ’Lat,Lon’, ’p2’: ’Lat,Lon’, ’u’: ’Unit: KM, MI, NM’}) def distance( context: Context, p1: str, p2: str, u: str = "KM" ) -> None: """Compute distance between two points. """函数的文档字符串成为 invoke distance --help 命令提供的帮助文本。提供一些有助于用户理解各种命令将做什么以及如何使用它们的内容非常重要。
Context 参数是必需的,但在这个例子中不会使用。该对象在调用多个单独的任务时提供一致的环境。它还提供了运行外部应用程序的方法。
-
对各种参数值进行所需的转换。使用清洗后的值评估“真实工作”函数:
try: lat_1, lon_1 = point_type(p1) lat_2, lon_2 = point_type(p2) display(lat_1, lon_1, lat_2, lon_2, r=u) except (ValueError, KeyError) as ex: sys.exit(f"{ex}\nFor help use invoke --help distance")我们已经使用 sys.exit()来生成错误信息。也可以抛出异常,但这会显示长的跟踪信息,可能并不有用。
6.5.3 它是如何工作的...
invoke 包检查给定 Python 函数的参数,并构建必要的命令行解析选项。参数名称成为选项的名称。在示例 distance()函数中,p1、p2 和 u 的参数分别成为命令行选项--p1、--p2 和-u。这使得我们可以在运行应用时灵活地提供参数。值可以是按位置提供,也可以通过使用选项标志提供。
6.5.4 更多...
invoke 最重要的特性是它能够作为其他二进制应用程序的包装器。提供给每个任务的 Context 对象提供了更改当前工作目录和运行任意 OS 命令的方法。这包括更新子进程环境、捕获输出和错误流、提供输入流以及许多其他功能。
我们可以使用 invoke 在单个包装器下组合多个应用程序。这可以通过提供一个统一的接口来简化复杂的应用程序集合,该接口通过单个任务定义模块实现。
例如,我们可以组合一个计算两点之间距离的应用程序,以及一个处理连接一系列点的完整路线的 CSV 文件的应用程序。
整体设计可能看起来像这样:
@task
def distance(context: Context, p1: str, p2: str, u: str) -> None:
... # Shown earlier
@task
def route(context: Context, filename: str) -> None:
if not path(filename).exists():
sys.exit(f"File not found {filename}")
context.run("python some_app.py {filename}", env={"APP_UNITS": "NM"})
context.run()方法将调用任意的 OS 级命令。env 参数值提供了更新环境变量的命令。
6.5.5 参见
-
应用程序集成的附加配方在第十四章应用程序集成:组合中展示。
-
www.pyinvoke.org网页包含了关于 invoke 的所有文档。
6.6 使用 cmd 创建命令行应用程序
有几种方法可以创建交互式应用程序。使用 input()和 getpass()获取用户输入配方探讨了 input()和 getpass.getpass()等函数。使用 argparse 获取命令行输入配方展示了如何使用 argparse 模块创建用户可以从 OS 命令行与之交互的应用程序。
我们还有另一种创建交互式应用程序的方法:使用 cmd 模块。此模块将提示用户输入,然后调用我们提供的类的一个特定方法。
这里是一个交互示例:
] dice 5
Rolling 5 dice
] roll
[5, 6, 6, 1, 5]
]
我们输入了 dice 5 命令来设置骰子的数量。之后,roll 命令显示了掷出五个骰子的结果。help 命令将显示可用的命令。
6.6.1 准备工作
cmd.Cmd 应用程序的核心特性是一个读取-评估-打印循环(REPL)。当存在多个单独的状态变化和许多密切相关用于执行这些状态变化的命令时,这种应用程序运行良好。
我们将使用一个简单的、有状态的骰子游戏。想法是有一把骰子,其中一些可以掷出,而另一些是冻结的。这意味着我们的 Cmd 类定义必须有一些属性来描述一把骰子的当前状态。
命令将包括以下内容:
-
dice 设置骰子的数量
-
roll 掷骰子
-
reroll 重新掷选定的骰子,其他骰子保持不变
6.6.2 如何实现...
-
导入 cmd 模块以使 cmd.Cmd 类定义可用。由于这是一个游戏,还需要随机模块:
import cmd import random -
定义一个扩展到 cmd.Cmd:
class DiceCLI(cmd.Cmd): -
在 preloop() 方法中定义任何所需的初始化:
def preloop(self) -> None: self.n_dice = 6 self.dice: list[int] | None = None # no roll has been made. self.reroll_count = 0此方法在处理开始时评估一次。
初始化也可以在 init() 方法中完成。然而,这样做稍微复杂一些,因为它必须与 Cmd 类的初始化协作。
-
对于每个命令,创建一个 do_command() 方法。方法名将是命令,前面加上 do_ 字符。任何命令之后的用户输入文本将作为方法的参数值提供。方法定义中的文档字符串注释是命令的帮助文本。以下是由 do_roll() 方法定义的 roll 命令:
def do_roll(self, arg: str) -> bool: """Roll the dice. Use the dice command to set the number of dice.""" self.dice = [random.randint(1, 6) for _ in range(self.n_dice)] print(f"{self.dice}") return False -
解析和验证使用它们的命令的参数。用户在命令之后的输入将作为方法第一个位置参数的值提供。以下是由 do_dice() 方法定义的 dice 命令:
def do_dice(self, arg: str) -> bool: """Sets the number of dice to roll.""" try: self.n_dice = int(arg) except ValueError: print(f"{arg!r} is invalid") return False self.dice = None print(f"Rolling {self.n_dice} dice") return False -
编写主脚本。这将创建此类的实例并执行 cmdloop() 方法:
if __name__ == "__main__": game = DiceCLI() game.cmdloop()cmdloop() 方法处理提示、收集输入和根据用户的输入执行适当方法的细节。
6.6.3 它是如何工作的...
Cmd 类包含大量内置功能来显示提示、从用户那里读取输入,然后根据用户的输入定位适当的方法。
例如,当我们输入 dice 5 这样的命令时,Cmd 超类的内置方法将从输入中删除第一个单词 dice,并将其前缀为 do_。然后它将尝试使用行剩余部分的参数值,即 5,来评估该方法。
如果我们输入了一个没有匹配 do_*() 方法的命令,命令处理器将写入一个错误信息。这是自动完成的;我们不需要编写任何代码来处理无效的命令输入。
一些方法,如 do_help(),已经是应用程序的一部分。这些方法将总结其他 do_* 方法。当我们的方法有一个文档字符串时,这将通过内置的帮助功能显示。
Cmd 类依赖于 Python 的内省功能。类的实例可以检查方法名以定位所有以 do_ 开头的方法。内省是一个高级主题,将在第八章中简要介绍。
6.6.4 更多...
Cmd 类有多个可以添加交互功能的地方:
-
我们可以定义特定的 help_*() 方法,使其成为帮助主题的一部分。
-
当任何 do_*() 方法返回非 False 值时,循环将结束。我们可能想要添加一个 do_quit() 方法来返回 True。
-
如果输入流被关闭,将提供一个 EOF 命令。在 Linux 中,使用 ctrl-d 将关闭输入文件。这导致 do_EOF() 方法,它应该使用 return True。
-
我们可能提供一个名为 emptyline() 的方法来响应空白行。
-
当用户的输入与任何 do_*() 方法都不匹配时,将评估 default() 方法。
-
postloop() 方法可以在循环结束后进行一些处理。这是一个写总结的好地方。
此外,我们还可以设置一些属性。这些是与方法定义平级的类级变量:
-
提示属性是要写入的提示字符串。介绍属性是在第一个提示之前要写入的介绍性文本。对于我们的示例,我们可以这样做:
class DiceCLI2(cmd.Cmd): prompt = "] " intro = "A dice rolling tool. ? for help." -
我们可以通过设置 doc_header、undoc_header、misc_header 和 ruler 属性来定制帮助输出。
目标是能够创建一个尽可能直接处理用户交互的整洁类。
6.6.5 相关内容
- 我们将在第七章和第八章中查看类定义。
6.7 使用操作系统环境设置
有几种方式来看待我们软件用户提供的输入:
-
交互式输入:这是根据应用程序的要求由用户提供的。请参阅 使用 input() 和 getpass() 获取用户输入 的配方。
-
命令行参数:这些是在程序启动时提供的。请参阅 使用 argparse 获取命令行输入 和 使用 invoke 获取命令行输入 的配方。
-
环境变量:这些是操作系统级别的设置。有几种方式可以设置它们:
-
在命令行中,当运行应用程序时。
-
在用户选择的 shell 的配置文件中设置。例如,如果使用 zsh,这些文件是 ~/.zshrc 文件和 ~/.profile 文件。也可以有系统范围的文件,如 /etc/zshrc 文件。
-
在 Windows 中,有环境变量的高级设置选项。
-
-
配置文件:这些是特定于应用程序的。它们是第十三章的主题。
环境变量可以通过 os 模块获得。
6.7.1 准备工作
在 使用 argparse 获取命令行输入 的配方中,我们将 haversine() 函数包装在一个简单的应用程序中,该应用程序解析命令行参数。我们创建了一个这样工作的程序:
% python ch06/distance_app.py -u KM 36.12,-86.67 33.94,-118.40
From 36.12,-86.67 to 33.94,-118.4 in KM = 2886.90
"""
在使用这个版本的应用程序一段时间后,我们可能会发现我们经常使用海里来计算从我们船锚定的地方的距离。我们真的希望有一个输入点的默认值以及 -r 参数的默认值。
由于一艘船可以在多个地方锚定,我们需要在不修改实际代码的情况下更改默认设置。一个“缓慢变化”的参数值的概念与操作系统环境变量很好地吻合。它们可以持久存在,但相对容易更改。
我们将使用两个操作系统环境变量:
-
UNITS 将具有默认的距离单位。
-
HOME_PORT 可以有一个锚点。
我们希望能够做到以下几点:
% UNITS=NM
% HOME_PORT=36.842952,-76.300171
% python ch06/distance_app.py 36.12,-86.67
From 36.12,-86.67 to 36.842952,-76.300171 in NM = 502.23
6.7.2 如何实现...
-
导入 os 模块。要解析的默认命令行参数集来自 sys.argv,因此还需要导入 sys 模块。应用程序还将依赖于 argparse 模块:
import os import sys import argparse -
导入应用程序需要的任何其他类或对象:
from ch03.recipe_11 import haversine, MI, NM, KM from ch06.recipe_04 import point_type, display -
定义一个函数,该函数将使用环境值作为可选命令行参数的默认值:
def get_options(argv: list[str] = sys.argv[1:]) -> argparse.Namespace: -
从操作系统环境设置中收集默认值。这包括所需的任何验证:
default_units = os.environ.get("UNITS", "KM") if default_units not in ("KM", "NM", "MI"): sys.exit(f"Invalid UNITS, {default_units!r} not KM, NM, or MI") default_home_port = os.environ.get("HOME_PORT")注意,使用 os.environ.get()允许应用程序在环境变量未设置的情况下包含一个默认值。
-
创建解析器对象。为从环境变量中提取的相关参数提供默认值:
parser = argparse.ArgumentParser() parser.add_argument("-u", "--units", action="store", choices=("NM", "MI", "KM"), default=default_units ) parser.add_argument("p1", action="store", type=point_type) parser.add_argument( "p2", nargs="?", action="store", type=point_type, default=default_home_port ) -
进行任何额外的验证以确保参数设置正确。在这个例子中,可能没有为 HOME_PORT 设置值,也没有为第二个命令行参数提供值。
这需要使用 if 语句和调用 sys.exit():
options = parser.parse_args(argv) if options.p2 is None: sys.exit("Neither HOME_PORT nor p2 argument provided.") -
返回包含有效参数集的最终选项对象:
return options
这将使-u 参数和第二个点成为可选的。如果这些参数从命令行中省略,参数解析器将使用配置信息提供默认值。
sys.exit()提供的错误代码有细微的区别。当应用程序因命令行问题失败时,通常返回状态码 2,但 sys.exit()会将值设置为 1。一个稍微更好的方法是使用 parser.error()方法。这样做需要重构,在获取和验证环境变量值之前创建 ArgumentParser 实例。
6.7.3 它是如何工作的...
我们已经使用操作系统环境变量来创建默认值,这些值可以被命令行参数覆盖。如果环境变量已设置,则该字符串将作为默认值提供给参数定义。如果没有设置环境变量,则应用程序将使用默认值。在 UNITS 变量的情况下,在这个例子中,如果操作系统环境变量未设置,应用程序将使用公里作为默认值。
我们已经使用操作系统环境来设置默认值,这些值可以被命令行参数值覆盖。这支持环境提供可能由多个命令共享的一般上下文的概念。
6.7.4 更多...
使用 argparse 获取命令行输入的配方展示了处理从 sys.argv 中可用的默认命令行参数的略微不同的方法。第一个参数是正在执行的 Python 应用程序的名称,通常与参数解析不相关。
sys.argv 的值将是一个字符串列表:
[’ch06/distance_app.py’, ’-u’, ’NM’, ’36.12,-86.67’]
在处理过程中,我们不得不在某些时候跳过 sys.argv[0] 的初始值。通常,应用程序需要将 sys.argv[1:] 提供给解析器。这可以在 get_options() 函数内部完成。这也可以在 main() 函数评估 get_options() 函数时完成。正如本例所示,这也可以在为 get_options() 函数创建默认参数值时完成。
argparse 模块允许我们为参数定义提供类型信息。提供类型信息可以用于验证参数值。在许多情况下,一个值可能有一组有限的选项,这组允许的选项可以作为参数定义的一部分提供。这样做可以创建更好的错误和帮助信息,提高应用程序运行时的用户体验。
6.7.5 参见
- 我们将在第十三章节中探讨处理配置文件的多种方法。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第七章:7
类和对象的基础
计算的目的在于处理数据。我们通常将处理和数据封装到单个定义中。我们可以将具有共同属性集的对象组织成类,以定义它们的内部状态和共同行为。类的每个实例都是一个具有独特内部状态和行为的独立对象。
这种状态和行为的概念特别适用于游戏的工作方式。当构建类似交互式游戏的东西时,用户的操作会更新游戏状态。玩家的每个可能动作都是一个改变游戏状态的方法。在许多游戏中,这会导致大量的动画来展示状态之间的转换。在单人街机风格的游戏中,敌人或对手通常会是独立的对象,每个对象都有一个基于其他敌人动作和玩家动作而变化的内部状态。
另一方面,如果我们考虑一副牌或掷骰子游戏,可能的状态可能非常少。像 Zonk 这样的游戏涉及玩家掷(并重新掷)骰子,只要他们的分数提高。如果随后的掷骰子未能改善他们的骰子组合,他们的回合就结束了。手牌的状态是由构成得分子集的骰子池,通常推到桌子的一个边上。在一个六骰子的游戏中,将有从一到六个得分骰子作为不同的状态。此外,当所有骰子都是得分骰子时,玩家可以通过重新掷所有骰子来再次开始掷骰子的过程。这导致了一个额外的“超常”状态,玩家也必须记住。
面向对象设计的目的是使用对象的属性来定义当前状态。每个对象都被定义为类似对象的类的一个实例。我们用 Python 编写类定义,并使用这些定义来创建对象。类中定义的方法会在对象上引起状态变化。
在本章中,我们将探讨以下食谱:
-
使用类来封装数据和处理
-
类定义的基本类型提示
-
设计具有大量处理的类
-
使用 typing.NamedTuple 来表示不可变对象
-
使用数据类来表示可变对象
-
使用冻结的数据类来表示不可变对象
-
使用 slots 优化小对象
-
使用更复杂的集合
-
扩展内置集合 – 一个可以进行统计的列表
-
使用属性来表示延迟属性
-
创建上下文和上下文管理器
-
使用多个资源管理多个上下文
面向对象设计的主旨相当广泛。在本章中,我们将介绍一些基本概念。我们将从一些基础概念开始,例如类定义如何封装类的所有实例的状态和处理细节。
7.1 使用类封装数据和处理
类设计受到 SOLID 设计原则的影响。单一责任和接口隔离原则提供了有用的建议。综合考虑,这些原则建议我们,一个类应该有方法,这些方法专注于单一、明确的责任。
考虑类的一种另一种方式是作为一个紧密相关的函数组,这些函数使用共同的数据。我们称这些为处理数据的函数。类定义应该包含处理对象数据的最小方法集合。
我们希望基于狭窄的责任分配创建类定义。我们如何有效地定义责任?设计一个类的好方法是什么?
7.1.1 准备工作
让我们看看一个简单的、有状态的对象——一对骰子。这个背景是一个模拟简单游戏如 Craps 的应用程序。
软件对象可以看作是类似事物——名词。类的行为可以看作是动词。这种与名词和动词的认同给我们提供了如何有效地设计类以有效工作的线索。
这引导我们进入几个准备步骤。我们将通过使用一对骰子进行游戏模拟来提供这些步骤的具体示例。我们按以下步骤进行:
-
写下描述类实例所做事情的简单句子。我们可以称之为问题陈述。专注于单动词句子,只关注名词和动词是至关重要的。以下是一些例子:
-
Craps 游戏有两个标准的骰子。
-
每个骰子有六个面,点数从一到六。
-
玩家掷骰子。虽然作者和编辑更喜欢主动语态版本,“玩家掷骰子”,但骰子通常被其他对象所作用,使得被动语态句子稍微更有用。
-
骰子的总和改变了 Craps 游戏的状态。这些规则与骰子是分开的。
-
如果两个骰子匹配,这个数字被描述为“硬掷”。如果两个骰子不匹配,掷骰子被描述为“易掷”。
-
-
识别句子中的所有名词。在这个例子中,名词包括骰子、面、点数和玩家。名词识别不同类别的对象,可能是合作者,如玩家和游戏。名词也可能识别对象的属性,如面和点数。
-
识别句子中的所有动词。动词通常成为所讨论的类的成员方法。在这个例子中,动词包括 roll 和 match。
这些信息有助于定义对象的状态和行为。拥有这些背景信息将帮助我们编写类定义。
7.1.2 如何做...
由于我们编写的模拟涉及骰子的随机投掷,我们将依赖于 from random import randint 提供有用的 randint() 函数。定义类的步骤如下:
-
使用类声明开始编写类:
class Dice: -
在 init() 方法的主体中初始化对象的属性。我们将使用 faces 属性来模拟骰子的内部状态。需要一个 self 变量来确保我们引用的是类的给定实例的属性。我们将在每个属性上提供类型提示,以确保在整个类定义中正确使用:
def __init__(self) -> None: self.faces: tuple[int, int] = (0, 0) -
根据描述中的动词定义对象的方法。当玩家掷骰子时,roll() 方法可以设置两个骰子面上的值。我们通过设置 self 对象的 faces 属性来实现这一点:
def roll(self) -> None: self.faces = (randint(1,6), randint(1,6))此方法会改变对象的内部状态。我们选择不返回任何值。
-
玩家掷骰子后,total() 方法有助于计算骰子的总和:
def total(self) -> int: return sum(self.faces) -
可以提供额外的方法来回答有关骰子状态的问题。在这种情况下,当两个骰子都匹配时,总和是通过“困难的方式”得到的:
def hardway(self) -> bool: return self.faces[0] == self.faces[1] def easyway(self) -> bool: return self.faces[0] != self.faces[1]
7.1.3 它是如何工作的...
核心思想是使用普通的语法规则——名词、动词和形容词——作为识别类基本特征的一种方式。在我们的例子中,骰子是真实的事物。我们尽量避免使用抽象术语,如随机化器或事件生成器。描述真实事物的可触摸特征更容易,然后定义一个实现来匹配这些可触摸特征。
掷骰子的想法是一个我们可以通过方法定义来模拟的物理动作。这个掷骰子的动作会改变对象的状态。在极少数情况下——36 次中的 1 次——下一个状态会恰好与之前的状态相同。
下面是使用 Dice 类的一个示例:
-
首先,我们将使用一个固定值来初始化随机数生成器,以便我们可以得到一个固定的结果序列:
>>> import random >>> random.seed(1) -
我们将创建一个 Dice 对象,并将其分配给变量 d1。然后我们可以使用 roll() 方法设置其状态。然后我们将查看 total() 方法以查看掷出了什么。我们将通过查看 faces 属性来检查状态:
>>> d1 = Dice() >>> d1.roll() >>> d1.total() 7 >>> d1.faces (2, 5)
7.1.4 更多...
捕获导致状态变化的必要内部状态和方法是良好类设计的第一步。我们可以使用缩写 SOLID 总结一些有用的设计原则:
-
单一职责原则:一个类应该有一个明确定义的责任。
-
开放/封闭原则:一个类应该对扩展开放——通常通过继承——但对修改封闭。我们应该设计我们的类,以便我们不需要调整代码来添加或更改功能。
-
李斯克夫替换原则:我们需要设计继承,使得子类可以替代父类使用。
-
接口隔离原则:在编写问题陈述时,我们希望确保协作类尽可能少地依赖。在许多情况下,这一原则将引导我们将大问题分解成许多小的类定义。
-
依赖倒置原则:一个类直接依赖于其他类并不理想。如果类依赖于抽象,并且用具体实现类替换抽象类,则更好。
目标是创建具有必要行为并遵循设计原则的类,以便它们可以被扩展和重用。
7.1.5 参考内容
-
请参阅使用属性实现懒属性的配方,我们将探讨选择积极属性和懒属性之间的选择。
-
在第八章中,我们将更深入地探讨类设计技术。
-
请参阅第十五章,了解如何为类编写适当的单元测试配方。
7.2 类定义的必要类型提示
类名也是一个类型提示,允许变量直接引用应该定义与变量相关联的对象的类。这种关系使工具如 mypy 能够推理我们的程序,以确保对象引用和方法引用似乎与代码中的类型提示相匹配。
除了类名之外,我们将在类定义中的三个常见位置使用类型提示:
-
在方法定义中,我们将使用类型提示来注释参数和返回类型。
-
在
__init__()方法中,我们可能需要为定义对象状态的实例变量提供提示。 -
在类的整体属性中。这些不是常见的,这里的类型提示也很少。
7.2.1 准备工作
我们将检查一个具有各种类型提示的类。在这个例子中,我们的类将模拟一把骰子。我们将允许重新掷选定的骰子,使类的实例具有状态。
骰子集合可以通过第一次掷骰子来设置,其中所有骰子都被掷出。该类允许掷出骰子子集的后续掷骰。同时也会计算掷骰子的次数。
类型提示将反映骰子集合的性质、整数计数、浮点平均值以及整个手牌的字符串表示。这将展示一系列类型提示及其编写方式。
7.2.2 如何实现...
-
此定义将涉及随机数以及集合和列表的类型提示。我们导入 random 模块:
import random -
定义类。这创建了一个新类型:
class Dice: -
类级别的变量很少需要类型提示。它们几乎总是通过赋值语句创建的,这些语句使类型信息对人类或像 mypy 这样的工具来说很清晰。在这种情况下,我们希望我们的骰子类的所有实例共享一个共同的随机数生成器对象:
RNG = random.Random() -
init()方法创建了定义对象状态的实例变量。在这种情况下,我们将保存一些配置细节和一些内部状态。init()方法还有初始化参数。通常,我们会在这些参数上放置类型提示。其他内部状态变量可能需要类型提示来显示其他类方法将分配哪些类型的值。在这个例子中,faces 属性没有初始值;我们声明当它被设置时,它将是一个 List[int]对象:
def __init__(self, n: int, sides: int = 6) -> None: self.n_dice = n self.sides = sides self.faces: list[int] self.roll_number = 0 -
计算新导出值的方法可以用它们的返回类型信息进行注解。这里有三个例子,用于返回字符串表示、计算总和以及计算骰子的平均值。这些函数的返回类型分别是 str、int 和 float,如下所示:
def __str__(self) -> str: return ", ".join( f"{i}: {f}" for i, f in enumerate(self.faces) ) def total(self) -> int: return sum(self.faces) def average(self) -> float: return sum(self.faces) / self.n_dice -
对于有参数的方法,我们在参数上以及返回类型上包含类型提示。在这种情况下,改变内部状态的方法也会返回值。两个方法的返回值都是骰子面的列表,描述为 list[int]。reroll()方法的参数是要重新掷的骰子集合。这表示为 set[int],需要一组整数。Python 比这要灵活一些,我们将探讨一些替代方案:
def first_roll(self) -> list[int]: self.roll_number = 0 self.faces = [ self.RNG.randint(1, self.sides) for _ in range(self.n_dice) ] return self.faces def reroll(self, positions: set[int]) -> list[int]: self.roll_number += 1 for p in positions: self.faces[p] = self.RNG.randint(1, self.sides) return self.faces
7.2.3 工作原理...
类型提示信息被程序如 mypy 使用,以确保在整个应用程序中正确使用类的实例。
如果我们尝试编写如下函数:
def example_mypy_failure() -> None:
d = Dice(2.5)
d.first_roll()
print(d)
使用浮点值作为 n 参数创建 Dice 类实例的尝试与类型提示发生冲突。Dice 类的 init()方法的提示称参数值应该是整数。mypy 程序报告如下:
src/ch07/recipe_02_bad.py:9: error: Argument 1 to "Dice" has incompatible type "float"; expected "int" [arg-type]
如果我们尝试执行应用程序,它将在另一个地方引发 TypeError 异常。错误将在评估 d.first_roll()方法时显现。异常在这里被引发,因为 init()方法的主体可以很好地处理任何类型的值。提示称期望特定的类型,但在运行时,可以提供任何对象。在执行期间不会检查提示。
类似地,当我们使用其他方法时,mypy 程序会检查我们的方法使用是否与类型提示定义的期望相匹配。这里有一个另一个例子:
r1: list[str] = d.first_roll()
这个赋值语句中,r1 变量的类型提示与 first_roll()方法返回值的类型提示不匹配。这种冲突是由 mypy 检测到的,并报告为“赋值中不兼容的类型”错误。
7.2.4 更多内容...
在这个例子中,有一个类型提示过于具体。用于重新掷骰子的 reroll()函数有一个 positions 参数。positions 参数在 for 语句中使用,这意味着对象必须是某种可迭代对象。
错误在于提供了一个类型提示 set[int],这仅仅是许多可迭代对象中的一种。我们可以通过将类型提示从非常具体的 set[int]切换到更通用的 Iterable[int]来泛化这个定义。
放宽提示意味着任何集合、列表或元组对象都可以作为此参数的有效参数值。所需的唯一其他代码更改是导入 collections.abc 模块中的 Iterable。
for 语句有一个从可迭代集合获取迭代器对象、将值赋给变量和执行缩进体的特定协议。这个协议由 Iterable 类型提示定义。有许多这样的基于协议的类型,它们允许我们提供与 Python 固有的类型灵活性相匹配的类型提示。
7.2.5 参考信息
-
在第三章的函数参数和类型提示配方中,展示了多个类似的概念。
-
在第四章中,编写与列表相关的类型提示和编写与集合相关的类型提示配方解决了额外的详细类型提示。
-
在第五章中,编写与字典相关的类型提示配方也解决了类型提示。
7.3 设计具有大量处理的类
有时,一个对象将包含定义其内部状态的所有数据。然而,有些情况下,一个类不持有数据,而是设计用来对存储在单独容器中的数据进行处理进行整合。
这种设计的典型例子是统计算法,这些算法通常位于被分析的数据之外。数据可能在一个内置的列表或 Counter 对象中;处理定义在数据容器之外的一个类中。
7.3.1 准备工作
在对已经总结成组或箱的数据进行分析是很常见的。例如,我们可能有一个包含大量工业过程测量的巨大数据文件。
为了了解背景,请参阅 NIST 气溶胶粒子尺寸案例研究:www.itl.nist.gov/div898/handbook/pmc/section6/pmc62.htm
与分析大量的原始数据相比,首先总结重要的变量,然后分析总结后的数据通常要快得多。总结数据可以保存在一个 Counter 对象中。数据看起来是这样的:
data = Counter({7: 80,
6: 67,
8: 62,
9: 50,
... Details omitted ...
2: 3,
3: 2,
1: 1})
键(7、6、8、9 等等)是反映粒子大小的代码。实际尺寸从 109 到 119 不等。从 s 代码计算实际尺寸 c,公式为 c = ⌊2(s − 109)⌋。
(NIST 背景信息中没有提供单位。由于大量数据反映了电子芯片晶圆和制造过程,单位可能非常小。)
我们希望在不需要强制与原始大量数据集一起工作的前提下,计算这个 Counter 对象上的某些统计数据。一般来说,设计用于存储和处理数据的类有两种一般的设计策略:
-
扩展存储类定义,在这个例子中是 Counter,以添加统计处理。我们将在扩展内置集合 – 具有统计功能的列表食谱中详细说明。
-
在一个提供所需额外功能的类中包装 Counter 对象。当我们这样做时,我们还有两个选择:
-
暴露底层 Counter 对象。我们将关注这一点。
-
编写特殊方法以使包装器看起来也是一个集合,封装 Counter 对象。我们将在第八章中探讨这一点。
-
对于这个食谱,我们将专注于包装变体,其中我们定义一个统计计算类,该类公开一个 Counter 对象。我们有两种设计这种计算密集型处理的方法:
-
积极实现会在尽可能早的时候计算统计数据。这些值成为简单的属性。我们将关注这个选择。
-
懒惰方法不会在需要通过方法函数或属性获取值之前进行任何计算。我们将在使用属性进行懒惰属性食谱中探讨这一点。
两种设计的基本算法是相同的。唯一的问题是计算工作何时完成。
7.3.2 如何实现...
-
从 collections 模块导入适当的类。计算使用 math.sqrt()。务必添加所需的导入 math:
from collections import Counter import math -
使用描述性的名称定义类:
class CounterStatistics: -
编写 init()方法以包含数据所在的对象。在这种情况下,类型提示是 Counter[int],因为 Counter 对象中使用的键将是整数:
def __init__(self, raw_counter: Counter[int]) -> None: self.raw_counter = raw_counter -
在 init()方法中初始化任何其他可能有用的局部变量。由于我们将积极计算值,最积极的时间是在对象创建时。我们将编写对一些尚未定义的函数的引用:
self.mean = self.compute_mean() self.stddev = self.compute_stddev() -
定义所需的方法以计算各种值。以下是计算平均值的示例:
def compute_mean(self) -> float: total, count = 0.0, 0 for value, frequency in self.raw_counter.items(): total += value * frequency count += frequency return total / count -
这是我们如何计算标准差的方法:
def compute_stddev(self) -> float: total, count = 0.0, 0 for value, frequency in self.raw_counter.items(): total += frequency * (value - self.mean) ** 2 count += frequency return math.sqrt(total / (count - 1))
注意,这个计算需要首先计算平均值,并创建 self.mean 实例变量。从无已知平均值到已知平均值再到已知标准差这种内部状态变化是一个潜在的复杂性,需要清晰的文档说明。
本例的原始数据位于www.itl.nist.gov/div898/handbook//datasets/NEGIZ4.DAT。由于数据前有 50 行标题文本,这个文件的结构显得很复杂。此外,文件不是常见的 CSV 格式。因此,处理汇总数据更容易。
本书代码库中包含一个名为 data/binned.csv 的文件,其中包含分箱的摘要数据。该数据有三个列:size_code、size 和 frequency。我们只对 size_code 和 frequency 感兴趣。
这是我们可以从该文件构建合适的 Counter 对象的方法:
>>> from pathlib import Path
>>> import csv
>>> from collections import Counter
>>> data_path = Path.cwd() / "data" / "binned.csv"
>>> with data_path.open() as data_file:
... reader = csv.DictReader(data_file)
... extract = {
... int(row[’size_code’]): int(row[’frequency’])
... for row in reader
... }
>>> data = Counter(extract)
我们使用字典推导来创建从 size_code 到该代码值频率的映射。然后将其提供给 Counter 类,从现有的摘要构建 Counter 对象 data。我们可以将此数据提供给 CounterStatistics 类,从分箱数据中获得有用的摘要统计信息。这看起来像以下示例:
>>> stats = CounterStatistics(data)
>>> print(f"Mean: {stats.mean:.1f}")
Mean: 10.4
>>> print(f"Standard Deviation: {stats.stddev:.2f}")
Standard Deviation: 4.17
我们提供了数据对象来创建 CounterStatistics 类的实例。创建这个实例也将立即计算摘要统计信息。不需要额外的显式方法评估。这些值作为 stats.mean 和 stats.stddev 属性可用。
计算统计信息的处理成本最初就支付了。正如我们下面将看到的,任何对底层数据的更改都可以关联一个非常小的增量成本。
7.3.3 它是如何工作的...
这个类封装了两个复杂算法,但不包括这些算法的任何数据。数据被单独保存在 Counter 对象中。我们编写了一个高级规范来处理,并将其放置在 init()方法中。然后我们编写了实现指定处理步骤的方法。我们可以设置所需的所有属性,这使得这是一种非常灵活的方法。
这种设计的优点是,属性值可以重复使用。计算平均值和标准差的成本只支付一次;每次使用属性值时,不需要进一步处理。
这种设计的缺点是,对底层 Counter 对象状态的任何更改都将使 CounterStatistics 对象的状态过时且不正确。例如,如果我们添加了数百个更多数据值,平均值和标准差就需要重新计算。当底层 Counter 对象不会改变时,急切计算值的设计是合适的。
7.3.4 更多内容...
如果我们需要对有状态、可变对象进行计算,我们有几种选择:
-
封装 Counter 对象并通过 CounterStatistics 类进行更改。这需要小心地暴露数据收集足够多的方法。我们将把这种设计推迟到第八章(ch012.xhtml#x1-4520008)。
-
使用延迟计算。参见本章中的使用属性进行延迟属性配方。
-
添加一个方法来实现计算平均值和标准差,这样在更改底层 Counter 对象后可以重新计算这些值。这导致重构 init()方法以使用这种新的计算方法。我们将把这个留作读者的练习。
-
编写文档说明每次底层 Counter 对象发生变化时创建新的 CounterStatistics 实例的要求。这不需要代码,只需明确说明对象状态上的约束即可。
7.3.5 参见
-
在扩展内置集合 – 统计列表的菜谱中,我们将探讨一种不同的设计方法,其中这些新的汇总函数被用来扩展类定义。
-
我们将在使用属性创建懒属性的菜谱中探讨不同的方法。这个替代菜谱将使用属性按需计算属性。
-
在第八章中,也探讨了 wrap=extend 设计选择。
7.4 使用 typing.NamedTuple 创建不可变对象
在某些情况下,一个对象是一个相对复杂数据的容器,但实际上并没有对数据进行很多处理。实际上,在许多情况下,我们将定义一个不需要任何独特方法函数的类。这些类是相对被动的数据项容器,没有太多的处理。
在许多情况下,Python 的内置容器类 – 列表、集合或字典 – 可以覆盖你的用例。小问题是,访问字典或列表中项的语法并不像访问对象属性那样优雅。
我们如何创建一个类,使我们能够使用 object.attribute 语法而不是更复杂的 object[‘attribute’] 语法?
7.4.1 准备工作
任何类型的类设计都有两种情况:
-
它是无状态的(或不可变的)吗?它是否包含永远不会改变的值的属性?这是一个 NamedTuple 的好例子。
-
它是有状态的(或可变的)吗?是否会有一个或多个属性的状态变化?这是 Python 类定义的默认情况。一个普通类是有状态的。我们可以使用使用 dataclasses 创建可变对象的菜谱来简化创建有状态对象的过程。
我们将定义一个类来描述具有点数和花色的简单扑克牌。由于牌的点数和花色不会改变,我们将为这个创建一个小型的无状态类。typing.NamedTuple 类是这类类定义的便捷基类。
7.4.2 如何做...
-
我们将定义无状态对象为 typing.NamedTuple 的子类:
from typing import NamedTuple -
将类名称定义为 NamedTuple 的扩展。包括具有各自类型提示的属性:
class Card(NamedTuple): rank: int suit: str
这是我们如何使用这个类定义来创建 Card 对象的方法:
>>> eight_hearts = Card(rank=8, suit=’\N{White Heart Suit}’)
>>> eight_hearts
Card(rank=8, suit=’’)
>>> eight_hearts.rank
8
>> eight_hearts.suit
’’
>>> eight_hearts[0]
我们创建了一个名为 Card 的新类,它有两个属性名称:rank 和 suit。在定义了类之后,我们可以创建类的实例。我们构建了一个单张 Card 对象,eight_hearts,其点数为八,花色为 ♡。
我们可以用其名称或其元组内的位置来引用这个对象的属性。当我们使用 eight_hearts.rank 或 eight_hearts[0]时,我们会看到 rank 属性的值,因为该属性在属性名称序列中定义在第一位。
这种类型的对象是不可变的。以下是一个尝试更改实例属性的示例:
>>> eight_hearts.suit = ’\N{Black Spade Suit}’
Traceback (most recent call last):
...
AttributeError: can’t set attribute
我们尝试更改 eight_hearts 对象的 suit 属性。这引发了一个 AttributeError 异常,表明命名元组的实例是不可变的。
元组可以包含任何类型的对象。
当元组包含可变项,如列表、集合或字典时,这些对象保持可变。
只有顶层包含的元组是不可变的。元组内的列表、集合或字典是可变的。
7.4.3 它是如何工作的...
typing.NamedTuple 类让我们定义一个具有明确定义属性列表的新子类。自动创建了一些方法,以提供最小级别的 Python 行为。我们可以看到一个实例将显示一个可读的文本表示,显示各种属性的值。
对于命名元组子类,其行为基于内置元组实例的工作方式。属性的顺序定义了元组之间的比较。例如,我们的 Card 定义首先列出 rank 属性。这意味着我们可以很容易地按等级排序牌。对于等级相同的两张牌,花色将按顺序排序。因为命名元组也是元组,所以它很好地作为集合的成员或字典的键。
在这个例子中,rank 和 suit 这两个属性作为类定义的一部分命名,但作为实例变量实现。为我们创建了一个元组的 new()方法的变体。该方法有两个参数与实例变量名称匹配。自动创建的方法将在对象创建时将参数值分配给实例变量。
7.4.4 更多...
我们可以向这个类定义添加方法。例如,如果每张牌都有一个点数,我们可能希望扩展类,使其看起来像以下示例:
class CardPoints(NamedTuple):
rank: int
suit: str
def points(self) -> int:
if 1 <= self.rank < 10:
return self.rank
else:
return 10
我们编写了一个 CardsPoints 类,它有一个 points()方法,该方法返回分配给每个等级的点数。这种点规则适用于像克里比奇这样的游戏,而不适用于像黑杰克这样的游戏。
因为这是一个元组,所以方法不能添加新的属性或更改属性。在某些情况下,我们通过其他元组构建复杂的元组。
7.4.5 参见
- 在设计大量处理的类的配方中,我们查看了一个完全处理且几乎没有数据的类。它作为这个类的完全对立面。
7.5 使用 dataclasses 处理可变对象
我们已经记录了 Python 中的两种一般类型的对象:
-
不可变:在设计过程中,我们会询问是否有属性具有永远不会改变的值。如果答案是肯定的,请参阅使用 typing.NamedTuple 为不可变对象菜谱,它提供了一种为不可变对象构建类定义的方法。
-
可变:一个或多个属性会有状态变化吗?在这种情况下,我们可以从头开始构建一个类,或者我们可以利用 @dataclass 装饰器从一些属性和类型提示中创建一个类定义。这个案例是这个菜谱的重点。
我们如何利用 dataclasses 库来帮助设计可变对象?
7.5.1 准备工作
我们将仔细研究一个具有内部状态的可变对象,以表示一副牌。虽然单个卡片是不可变的,但它们可以被插入到一副牌中并从一副牌中移除。在像克里比(Cribbage)这样的游戏中,手牌会有许多状态变化。最初,六张牌被分给两位玩家。玩家将各自放下一对牌来创建克里比。然后,剩下的四张牌交替出牌,以创造得分机会。然后,手牌在隔离状态下计数,得分机会的混合略有所不同。庄家从计数克里比中的牌中获得额外的手牌得分。(是的,最初是不公平的,但发牌轮流进行,所以最终是公平的。)
我们将研究一个简单的集合来存放卡片,并丢弃形成克里比的两张卡片。
7.5.2 如何操作...
-
为了定义数据类,我们将导入 @dataclass 装饰器:
from dataclasses import dataclass -
使用 @dataclass 装饰器定义新的类:
@dataclass class CribbageHand: -
使用适当的类型提示定义各种属性。在这个例子中,我们期望玩家拥有一组由 list[CardPoints] 表示的卡片集合。因为每张卡片都是唯一的,我们也可以使用 set[CardPoints] 类型提示:
cards: list[CardPoints] -
定义任何会改变对象状态的函数:
def to_crib(self, card1: CardPoints, card2: CardPoints) -> None: self.cards.remove(card1)
这是完整的类定义,正确缩进:
@dataclass
class CribbageHand:
cards: list[CardPoints]
def to_crib(self, card1: CardPoints, card2: CardPoints) -> None:
self.cards.remove(card1)
self.cards.remove(card2)
这个定义提供了一个单一的实例变量 self.cards,它可以被任何编写的函数使用。因为我们提供了类型提示,所以 mypy 程序可以检查类以确保它被正确使用。
这是创建这个 CribbageHand 类实例时的样子:
>>> cards = [
... CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=6, suit=’\N{BLACK SPADE SUIT}’),
... CardPoints(rank=7, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=1, suit=’\N{BLACK SPADE SUIT}’),
... CardPoints(rank=6, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=10, suit=’\N{WHITE HEART SUIT}’)]
>>> ch1 = CribbageHand(cards)
>>> from pprint import pprint
>>> pprint(ch1)
CribbageHand(cards=[CardPoints(rank=3, suit=’’),
CardPoints(rank=6, suit=’’),
CardPoints(rank=7, suit=’’),
CardPoints(rank=1, suit=’’),
CardPoints(rank=6, suit=’’),
CardPoints(rank=10, suit=’’)])
>>> [c.points() for c in ch1.cards]
[3, 6, 7, 1, 6, 10]
在以下示例中,玩家决定(可能不明智)将 3♢ 和 A♠ 卡片放进行克里比:
>>> ch1.to_crib(
... CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=1, suit=’\N{BLACK SPADE SUIT}’))
>>> pprint(ch1)
CribbageHand(cards=[CardPoints(rank=6, suit=’’),
CardPoints(rank=7, suit=’’),
CardPoints(rank=6, suit=’’),
CardPoints(rank=10, suit=’’)])
>>> [c.points() for c in ch1.cards]
[6, 7, 6, 10]
在 to_crib() 方法从手中移除两张卡片后,剩余的四张卡片被显示出来。然后创建了一个新的列表推导式,包含剩余四张卡片的点数。
7.5.3 它是如何工作的...
@dataclass 装饰器帮助我们定义一个具有几个有用方法以及从命名变量及其类型提示中抽取的属性列表的类。我们可以看到,一个实例显示了一个可读的文本表示,显示了各种属性的值。
属性作为类定义的一部分命名,但实际上作为实例变量实现。在这个例子中,只有一个属性,cards。为我们创建了一个非常复杂的 init()方法。在这个例子中,它将有一个与每个实例变量名称匹配的参数,并将参数值分配给匹配的实例变量。
@dataclass 装饰器有几个选项可以帮助我们选择我们想要的类特性。以下是我们可以选择的选项和默认设置:
-
init=True:默认情况下,将创建一个 init()方法,其参数与实例变量相匹配。
-
repr=True:默认情况下,将创建一个 repr()方法来返回显示对象状态的字符串。
-
eq=True:默认情况下,提供了 eq()和 ne()方法。这些方法实现了==和!=运算符。
-
order=False:不会自动创建 lt(), le(), gt(), 和 ge()方法。这些方法实现了<, <=, >, 和 >=运算符。
-
unsafe_hash=False:通常,可变对象没有哈希值,不能用作字典的键或集合的元素。可以自动添加 hash()方法,但这很少是可变对象的一个明智选择,这就是为什么这个选项被称为“不安全”的哈希。
-
frozen=False:这创建了一个不可变对象。有关更多详细信息,请参阅本章中的使用冻结数据类创建不可变对象配方。
由于为我们编写了大量的代码,我们可以专注于类定义的属性。我们可以编写真正有特色的函数,避免编写具有明显定义的“样板”方法。
7.5.4 更多...
一副牌需要一种初始化方法来提供 Card 对象的集合。一个默认的 init()方法可以填充这个集合。
考虑创建一副牌,而不是一手牌。初始牌组是一个不需要初始化方法来设置实例变量的数据类的例子。相反,牌组需要一个没有参数的自定义 init()方法;它总是创建相同的 52 个 Card 对象集合。这意味着我们将使用 init=False 在@dataclass 装饰器中定义这个方法,用于 Deck 类定义。
@dataclass 定义的一般模式是提供类级别的名称,这些名称既用于定义实例变量,也用于创建初始化方法 init()。这涵盖了状态对象的一个常见用例。
然而,在某些情况下,我们想要定义一个不用于创建实例变量但将保留为类级别变量的类级别变量。这可以通过 ClassVar 类型提示来完成。ClassVar 类型表示一个不是实例变量或 init()方法部分的类级别变量。
在以下示例中,我们将创建一个具有花色字符串序列的类变量:
import random
from typing import ClassVar
@dataclass(init=False)
class Deck:
SUITS: ClassVar[tuple[str, ...]] = (
’\N{Black Club Suit}’,
’\N{White Diamond Suit}’,
’\N{White Heart Suit}’,
’\N{Black Spade Suit}’
)
cards: list[CardPoints]
def __init__(self) -> None:
self.cards = [
CardPoints(rank=r, suit=s)
for r in range(1, 14)
此示例类定义提供了一个类级变量 SUITS,它是 Deck 类的一部分。此变量是用于定义花色的字符的元组。
cards 变量有一个提示表明它将具有 list[CardPoints] 类型。此信息被 mypy 程序用于确认 __init__() 方法的主体正确初始化了此属性。它还确认此属性被其他类适当地使用。
7.5.5 参考信息
-
查看使用
typing.NamedTuple构建无状态对象 菜单了解如何为无状态对象构建类定义。 -
使用类封装数据和处理 菜单涵盖了构建不使用
@dataclass装饰器创建的额外方法的类技术。
7.6 使用冻结数据类实现不可变对象
在 使用 typing.NamedTuple 构建无状态对象 菜单中,我们看到了如何定义具有固定属性集的类。这些属性可以通过 mypy 程序进行检查,以确保它们被正确使用。在某些情况下,我们可能想使用稍微更灵活的数据类来创建不可变对象。
使用数据类的一个潜在原因是因为它比 NamedTuple 子类具有更复杂的字段定义。另一个潜在原因是能够自定义初始化和创建的哈希函数。由于 NamedTuple 实质上是一个元组,因此在此类中调整实例的行为的能力有限。
7.6.1 准备工作
我们将重新审视定义具有等级和花色的简单扑克牌的想法。等级可以通过介于 1(A)和 13(K)之间的整数来表示。花色可以通过集合 {‘♠’,‘♡’,‘♢’,‘♣’} 中的单个 Unicode 字符来表示。由于牌的等级和花色不会改变,我们将创建一个小的、冻结的数据类来表示这一点。
7.6.2 如何实现...
-
从
dataclasses模块导入dataclass装饰器:from dataclasses import dataclass -
使用
@dataclass装饰器开始类定义,使用frozen=True选项确保对象是不可变的。我们还包含了order=True以定义比较运算符,允许将此类实例按顺序排序:@dataclass(frozen=True, order=True) class Card: -
为此类每个实例的属性提供属性名称和类型提示:
rank: int suit: str
我们可以在代码中如下使用这些对象:
>>> eight_hearts = Card(rank=8, suit=’\N{White Heart Suit}’)
>>> eight_hearts
Card(rank=8, suit=’’)
>>> eight_hearts.rank
8
>>> eight_hearts.suit
’’
我们已经创建了一个具有特定等级和花色属性的 Card 类实例。由于该对象是不可变的,任何尝试更改状态的操作都将导致一个异常,如下面的示例所示:
>>> eight_hearts.suit = ’\N{Black Spade Suit}’
Traceback (most recent call last):
...
dataclasses.FrozenInstanceError: cannot assign to field ’suit’
这显示了尝试更改冻结数据类实例的属性。dataclasses.FrozenInstanceError 异常被抛出以表示此类操作是不允许的。
7.6.3 它是如何工作的...
这个 @dataclass 装饰器向类定义中添加了多个内置方法。正如我们在使用 dataclasses 处理可变对象配方中提到的,有一些特性可以被启用或禁用。每个特性可能会让我们在类定义中包含一个或多个单独的方法。
7.6.4 更多内容...
@dataclass 初始化方法相当复杂。我们将探讨一个有时很有用的特性,用于定义可选属性。
考虑一个可以持有牌手的类。虽然常见用例提供了牌集来初始化手,但我们也可以有在游戏中逐步构建的手,从空集合开始,并在游戏过程中添加牌。
我们可以使用 dataclasses 模块中的 field() 函数定义这种可选属性。field() 函数允许我们提供一个函数来构建默认值,称为 default_factory。我们将在以下示例中这样使用它:
from dataclasses import dataclass, field
@dataclass(frozen=True, order=True)
class Hand:
cards: list[CardPoints] = field(default_factory=list)
Hand dataclass 有一个单一属性,cards,它是一个 CardPoints 对象的列表。field() 函数提供了一个默认工厂:如果没有提供初始值,将执行 list() 函数来创建一个新的空列表。
我们可以使用这个 dataclass 创建两种类型的手。以下是一个传统示例,其中我们处理六张牌:
>>> cards = [
... CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=6, suit=’\N{BLACK SPADE SUIT}’),
... CardPoints(rank=7, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=1, suit=’\N{BLACK SPADE SUIT}’),
... CardPoints(rank=6, suit=’\N{WHITE DIAMOND SUIT}’),
... CardPoints(rank=10, suit=’\N{WHITE HEART SUIT}’)]
>>>
>>> h = Hand(cards)
The Hands() 类型期望一个单一属性,与该类中属性的定义相匹配。这是可选的,我们可以像以下示例中那样构建一个空手:
>>> crib = Hand()
>>> d3 = CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’)
>>> h.cards.remove(d3)
>>> crib.cards.append(d3)
>>> from pprint import pprint
>>> pprint(crib)
Hand(cards=[CardPoints(rank=3, suit=’’)])
在这个例子中,我们创建了一个没有参数值的 Hand() 实例,并将其分配给 crib 变量。由于 cards 属性是用提供了一个 default_factory 的字段定义的,因此将使用 list() 函数为 cards 属性创建一个空列表。
7.6.5 另请参阅
- 使用 dataclasses 处理可变对象配方涵盖了使用 dataclasses 避免编写类定义复杂性的额外主题。
7.7 使用 slots 优化小对象
对象的一般情况允许动态属性集合。对于基于元组类的固定属性集合的对象有一个特殊情况。我们在使用 typing.NamedTuple 处理不可变对象配方中探讨了这两个。
存在一个折衷方案。我们也可以定义一个具有固定数量属性的对象,但属性的值可以更改。通过将类从无限属性集合转换为固定属性集,我们发现我们还可以节省内存和处理时间。
我们如何创建具有固定属性集的优化类?
7.7.1 准备工作
通常,Python 允许向对象添加属性。这可能是不可取的,尤其是在处理大量对象时。大多数类定义使用字典的方式的灵活性在内存使用上是有代价的。使用特定的 slots 名称将类限制在命名属性上,从而节省内存。
例如,Cribbage 纸牌游戏有几个组成部分:
-
一副牌。
-
两名玩家,他们将轮流担任庄家和对手的角色。
这个小领域的事物似乎适合作为类定义的候选。每位玩家都有一手牌和一个分数。玩家的角色是一个有趣的复杂因素。两个角色之间有一些重要差异。
-
作为庄家的玩家将获得 crib 牌。
-
如果起始牌是 JACK,庄家角色将为此获得分数。
-
对手先出第一张牌。
-
对手先计算他们的手牌。
-
庄家从他们的手中出牌,但计算他们的手牌和 crib。
比赛的特定顺序和计分方式很重要,因为第一个通过 120 分的玩家就是赢家,无论游戏处于何种状态。
看起来 Cribbage 游戏包括一副牌和两名玩家。属于庄家的 crib(底牌)可以被视为游戏整体的一个特性。当新一轮游戏开始时,我们将探讨如何切换庄家和对手的角色。
7.7.2 如何实现...
在创建类时,我们将利用 slots 特殊名称:
-
定义一个具有描述性名称的类:
class Cribbage: -
定义属性名称列表。这标识了允许此类实例的唯一两个属性。任何尝试添加另一个属性都将引发 AttributeError 异常:
__slots__ = (’deck’, ’players’, ’crib’, ’dealer’, ’opponent’) -
添加一个初始化方法。这必须为命名槽位创建实例变量:
def __init__( self, deck: Deck, player1: Player, player2: Player ) -> None: self.deck = deck self.players = [player1, player2] random.shuffle(self.players) self.dealer, self.opponent = self.players self.crib = Hand()Deck 类的定义在本章的使用 dataclasses 创建可变对象配方中展示。
-
添加更新集合的方法。在这个例子中,我们定义了一个切换角色的方法。
def new_deal(self) -> None: self.deck.shuffle() self.players = list(reversed(self.players)) self.dealer, self.opponent = self.players self.crib = Hand()
以下是我们可以使用此类构建一手牌的方法。我们需要 Card 类的定义,基于使用 typing.NamedTuple 创建不可变对象配方中的示例:
>>> deck = Deck()
>>> c = Cribbage(deck, Player("1"), Player("2"))
>>> c.dealer
Player(name=’2’)
>>> c.opponent
Player(name=’1’)
>>> c.new_deal()
>>> c.dealer
Player(name=’1’)
>>> c.opponent
Player(name=’2’)
初始的 Cribbage 对象是用 Deck 和两个 Player 实例创建的。这三个对象填充了牌和玩家槽位。然后 init()方法随机化玩家,使其中一名成为庄家,另一名成为对手。crib 被初始化为一个空的 Hand 实例。
new_deal()方法会对 Cribbage 实例的状态进行多项更改。这可以通过检查庄家和对手属性来揭示。
如果我们尝试创建一个新属性,会发生以下情况:
>>> c.some_other_attribute = True
Traceback (most recent call last):
...
AttributeError: ’Cribbage’ object has no attribute ’some_other_attribute’
我们尝试在 Cribbage 对象 c 上创建一个名为 some_other_attribute 的属性。这引发了一个 AttributeError 异常。使用 slots 意味着不能向类的实例添加新属性。
7.7.3 它是如何工作的...
当我们创建一个对象实例时,该过程中的步骤部分由对象的类和内置的 type() 函数定义。隐式地,一个类有一个特殊的 new() 方法,用于处理创建新、空对象所需的内部管理。之后,init() 方法创建并初始化属性。
Python 有三个创建类实例的基本路径:
-
当我们定义一个类而没有做任何不寻常的事情时,默认行为是由内置的 object 和 type() 函数定义的。每个实例都包含一个 dict 属性,用于存储所有其他属性。因为对象的属性保存在字典中,所以我们可以自由地添加、更改和删除属性。这种灵活性需要为每个实例内部的字典对象使用额外的内存。
-
slots 行为避免了创建 dict 属性。因为对象只有 slots 序列中命名的属性,所以我们不能添加或删除属性。我们可以更改定义的属性值。这种缺乏灵活性意味着每个对象使用的内存更少。
-
元组子类的行为定义了不可变对象。创建这些类的一个简单方法是以 typing.NamedTuple 作为父类。一旦构建,实例就是不可变的,不能被更改。虽然可以直接从元组中派生,但 NamedTuple 的额外功能似乎使这成为理想的选择。
一个大型应用程序可能会受到内存使用的限制,将具有最大实例数的类切换到 slots 可以提高性能。
7.7.4 更多...
可以调整 new() 方法的工作方式,用不同类型的字典替换默认的 dict 属性。这是一个高级技术,因为它暴露了类和对象的内部工作原理。
Python 依赖于元类来创建类的实例。默认的元类是 type 类。其思想是元类提供了一些用于创建每个对象的功能。一旦创建了空对象,类的 init() 方法将初始化这个空对象。
通常,元类会提供一个 new() 方法的定义,如果需要定制对象,可能还会提供 prepare()。Python 语言参考文档中有一个广泛使用的例子,它调整了用于创建类的命名空间。
更多详情,请参阅 docs.python.org/3/reference/datamodel.html#metaclass-example。
7.7.5 参见
- 不可变对象或完全灵活对象的更常见情况在 使用 typing.NamedTuple 创建不可变对象 章节中进行了介绍。
7.8 使用更复杂的集合
Python 拥有丰富的内置集合。在第四章中,我们对其进行了详细探讨。在选择数据结构的配方中,我们提供了一个决策树,以帮助从可用的选择中定位适当的数据结构。
当我们考虑标准库中的内置类型和其他数据结构时,我们有更多的选择,需要做出的决定也更多。我们如何为我们的问题选择正确的数据结构?
7.8.1 准备工作
在我们将数据放入集合之前,我们需要考虑我们将如何收集数据,以及一旦我们拥有它,我们将如何处理这个集合。始终存在的一个大问题是我们在集合中如何识别特定的项目。我们将探讨一些关键问题,这些问题需要我们回答,以帮助我们选择适合我们需求的适当集合。
这里是一些替代集合的概述。collections 模块包含许多内置集合的变体。以下是一些包括的内容:
-
deque:一个双端队列。这是一个可变序列,对从两端推入和弹出进行了优化。请注意,类名以小写字母开头;这在 Python 中是不典型的。
-
defaultdict:一种可以为一个缺失的键提供默认值的映射。请注意,类名以小写字母开头;这在 Python 中是不典型的。
-
Counter:一种设计用来计算不同键出现次数的映射。这有时被称为多重集或包。
-
ChainMap:一种将多个字典组合成一个单一映射的映射。
heapq 模块包含一个优先队列实现。这是一个专门化的库,利用内置的列表序列来保持项目排序。
bisect 模块包含搜索排序列表的方法。这在大字典功能和列表功能之间产生了一些重叠。
此外,collections 模块中还有一个 OrderedDict 类。从 Python 3.7 开始,普通字典的键按创建顺序保留,这使得 OrderedDict 类变得冗余。
7.8.2 如何实现...
我们需要回答一些问题来决定是否需要一个库数据集合而不是内置集合:
-
这种结构是生产者和消费者之间的缓冲吗?算法的某个部分产生数据项,而另一个部分消费数据项吗?
-
队列用于先入先出(FIFO)处理。项目在一端插入,从另一端消费。我们可以使用 list.append()和 list.pop(0)来模拟这个过程,尽管 collections.deque 将更高效;我们可以使用 deque.append()和 deque.popleft()。
-
栈用于后进先出(LIFO)处理。项目从同一端插入和消耗。我们可以使用 list.append()和 list.pop()来模拟这一点,尽管 collections.deque 将更高效;我们可以使用 deque.append()和 deque.pop()。
-
优先队列(或堆队列)按某种顺序保持队列排序,这种顺序与到达顺序不同。我们可以通过使用 list.append()、list.sort(key=lambda x:x.priority)和 list.pop(-1)操作来模拟这一点,以保持项目按优先级排序。每次插入后进行排序可能会使其效率低下。使用 heapq 模块可能更高效。heapq 模块有用于创建和更新堆的函数。
-
-
我们应该如何处理字典中的缺失键?
-
抛出异常。这是内置的 dict 类的工作方式。
-
创建一个默认项。这是 collections.defaultdict 的工作方式。我们必须提供一个返回默认值的函数。常见的例子包括 defaultdict(int)和 defaultdict(float)来使用默认值 0 或 0.0。我们还可以使用 defauldict(list)和 defauldict(set)来创建字典-of-list 或字典-of-set 结构。
-
用于创建计数字典的 defaultdict(int)非常常见,以至于 collections.Counter 类正是这样做的。
-
-
我们希望如何处理字典中键的顺序?通常,Python 3.6 以上版本会保持键的插入顺序。如果我们想有不同的顺序,我们将不得不手动排序它们。
-
我们将如何构建字典?
-
我们有一个简单的算法来创建项。在这种情况下,一个内置的 dict 对象可能就足够了。
-
我们有多个需要合并的字典。这可能在读取配置文件时发生。我们可能有一个单独的配置、系统范围的配置以及默认的应用程序配置,所有这些都需要使用 ChainMap 集合合并成一个单一的字典。
-
7.8.3 它是如何工作的...
数据处理有两个主要资源约束:
-
存储
-
时间
我们的所有编程都必须遵守这些约束。在大多数情况下,这两者是相反的:我们为了减少存储使用而做的事情往往会增加处理时间,而我们为了减少处理时间而做的事情会增加存储使用。算法和数据结构设计寻求在约束之间找到一个最佳平衡。
时间方面通过复杂度指标形式化。描述算法复杂性的方法有很多:
-
复杂度 O(1)不随数据量的大小而改变。对于某些集合,实际的长期平均整体几乎接近 O(1),但有少数例外。许多字典操作是 O(1)。向列表中添加元素,以及从列表末尾弹出元素非常快,使得 LIFO 栈非常高效。从列表前面弹出元素是 O(n),这使得由简单列表构建的 FIFO 队列相当昂贵;deque 类和 heapq 模块通过更好的设计来解决这个问题。
-
被描述为 O(log n)的复杂度意味着成本的增长速度低于数据量 n 的增长速度。二分查找模块允许我们通过将列表分成两半来更有效地搜索排序后的列表。请注意,首先对列表进行排序是 O(nlog n),因此需要大量的搜索来分摊排序的成本。
-
被描述为 O(n)的复杂度意味着成本随着数据量 n 的增长而增长。在列表中查找一个项目具有这种复杂度。如果项目在列表的末尾,必须检查所有 n 个项目。集合和映射没有这个问题,并且具有接近 O(1)的复杂度。
-
被描述为 O(nlog n)的复杂度比数据量增长得更快。排序列表通常具有这种复杂度。因此,最小化或消除大量数据的排序是有帮助的。
-
有些情况甚至更糟。一些算法的复杂度为 O(n²)、O(2^n),甚至 O(n!)。我们希望通过巧妙的设计和良好的数据结构选择来避免这些非常昂贵的算法。在实践中,这些算法可能会具有欺骗性。我们可能能够设计出一个 O(2^n)的算法,在 n 为 3 或 4 的小测试用例中似乎表现良好。在这些情况下,组合数只有 8 或 16 种。如果实际数据涉及 70 个项目,组合数将达到 10²²的数量级,一个有 22 位数的数字。
标准库中可用的各种数据结构反映了时间和存储之间的许多权衡。
7.8.4 更多内容...
作为具体和极端的例子,让我们看看搜索一个特定事件序列的 Web 日志文件。我们有两个总体设计策略:
-
使用类似 file.read().splitlines()的方法将所有事件读入一个列表结构中。然后我们可以使用 for 语句遍历列表,寻找事件的组合。虽然初始读取可能需要一些时间,但由于日志全部在内存中,搜索将会非常快。
-
从日志文件中逐个读取和处理事件。当一个日志条目是搜索到的模式的一部分时,只保存这个事件在日志的子集中是有意义的。我们可能使用一个以会话 ID 或客户端 IP 地址作为键,事件列表作为值的 defaultdict。这将花费更长的时间来读取日志,但内存中的结果结构将比所有日志条目的列表小得多。
第一个算法,将所有内容读入内存,可能非常不切实际。在一个大型网络服务器上,日志可能涉及数百 GB 的数据。日志可能太大,无法放入任何计算机的内存中。
第二种方法有几个替代实现:
-
单进程:这里大多数 Python 食谱的一般方法假设我们正在创建一个作为单个进程运行的应用程序。
-
多进程:我们可能会将逐行搜索扩展为多进程应用程序,使用 multiprocessing 或 concurrent.futures 包。这些包让我们创建一组工作进程,每个进程可以处理可用数据的一个子集,并将结果返回给一个消费者,该消费者将结果组合起来。在现代多处理器、多核计算机上,这可以是非常有效的资源利用方式。
-
多主机:极端情况需要多个服务器,每个服务器处理数据的一个子集。这需要在主机之间进行更复杂的协调以共享结果集。通常,使用 Dask 或 Spark 这样的框架来处理这类工作效果很好。虽然 multiprocessing 模块相当复杂,但像 Dask 这样的工具更适合大规模计算。
我们经常将大搜索分解为映射和归约处理。映射阶段对集合中的每个项目应用一些处理或过滤。归约阶段将映射结果组合成摘要或聚合对象。在许多情况下,有一个复杂的 Map-Reduce 阶段层次结构应用于先前 Map-Reduce 操作的结果。
7.8.5 相关阅读
- 请参阅第四章中的选择数据结构配方,以了解选择数据结构的基础决策集。
7.9 扩展内置集合 – 一个能进行统计的列表
在设计具有大量处理的类的配方中,我们查看了一种区分复杂算法和集合的方法。我们展示了如何将算法和数据封装到不同的类中。另一种设计策略是扩展集合以包含有用的算法。
我们如何扩展 Python 的内置集合?我们如何向内置列表添加功能?
7.9.1 准备工作
我们将创建一个复杂的列表类,其中每个实例都可以计算列表中项目的总和和平均值。这需要应用仅将数字放入列表中;否则,将引发 ValueError 异常。
我们将展示一些方法,这些方法明确使用生成器表达式作为可以包含额外处理的地方。我们不会使用 sum(self),而是强调 sum(v for v in self),因为有两个常见的未来扩展:sum(m(v) for v in self) 和 sum(v for v in self if f(v))。这些是映射和过滤的替代方案,其中映射函数 m(v) 应用于每个项目;或者过滤函数 f(v) 应用于通过或拒绝每个项目。例如,计算平方和将映射应用于计算每个值的平方,然后再求和。
7.9.2 如何实现...
-
为列表选择一个同时也能进行简单统计的名字。将类定义为内置列表类的扩展:
class StatsList(list[float]):我们可以坚持使用通用的类型提示 list。这通常太宽泛了。由于结构将包含数字,使用更窄的提示 list[float]更合理。
当处理数值数据时,mypy 将 float 类型视为 float 和 int 的超类,从而节省了我们定义显式 Union[float, int]的需要。
-
将额外的处理定义为方法。self 变量将是一个继承了超类所有属性和方法的对象。在这种情况下,超类是 list[float]。我们在这里使用生成器表达式作为一个可能包含未来更改的地方。以下是一个 sum()方法:
def sum(self) -> float: return sum(v for v in self) -
这里是另一个我们经常应用于列表的方法。它计算项目数量并返回大小。我们使用生成器表达式使其易于添加映射或过滤条件,如果需要的话:
def size(self) -> float: return sum(1 for v in self) -
这里是平均数方法:
def mean(self) -> float: return self.sum() / self.size() -
这里有一些额外的函数。sum2()方法计算列表中值的平方和。这用于计算方差。然后使用方差来计算列表中值的标准差。与之前的 sum()和 count()方法不同,那里没有映射,在这种情况下,生成器表达式包括一个映射转换:
def sum2(self) -> float: return sum(v ** 2 for v in self) def variance(self) -> float: return ( (self.sum2() - self.sum() ** 2 / self.size()) / (self.size() - 1) ) def stddev(self) -> float: return math.sqrt(self.variance())
StatsList 类的定义继承了内置列表对象的所有特性。它通过我们添加的方法进行了扩展。以下是创建此集合中实例的示例:
>>> subset1 = StatsList([10, 8, 13, 9, 11])
>>> data = StatsList([14, 6, 4, 12, 7, 5])
>>> data.extend(subset1)
我们从字面列表对象中创建了两个 StatsList 对象,分别是 subset1 和 data。我们使用了从列表超类继承的 extend()方法来合并这两个对象。以下是结果对象:
>>> data
[14, 6, 4, 12, 7, 5, 10, 8, 13, 9, 11]
这是我们可以使用这个对象上定义的额外方法的方式:
>>> data.mean()
9.0
>>> data.variance()
11.0
我们已经展示了 mean()和 variance()方法的结果。内置列表类的所有特性也存在于我们的扩展中。
7.9.3 它是如何工作的...
类定义的一个基本特征是继承的概念。当我们创建一个超类-子类关系时,子类继承超类的所有特性。这有时被称为泛化-特殊化关系。超类是一个更通用的类;子类更特殊,因为它添加或修改了特性。
所有内置类都可以扩展以添加功能。在这个例子中,我们添加了一些统计处理,创建了一个特殊的数字列表子类。
两种设计策略之间存在重要的紧张关系:
-
扩展:在这种情况下,我们扩展了一个类以添加功能。这些特性与这个单一的数据结构紧密相连,我们无法轻易地将其用于不同类型的序列。
-
包装:在设计具有大量处理功能的类时,我们保持了处理与集合的分离。这导致在处理两个对象时出现一些复杂性。
很难说其中哪一种本质上优于另一种。在许多情况下,我们会发现包装可能具有优势,因为它似乎更适合 SOLID 设计原则。然而,通常会有一些情况,扩展内置集合是合适的。
7.9.4 更多内容...
概化的想法可能导致抽象超类。由于抽象类是不完整的,它需要一个子类来扩展它并提供缺失的实现细节。我们不能创建一个抽象类的实例,因为它会缺少使其有用的功能。
正如我们在第四章的选择数据结构配方中提到的,所有内置集合都有抽象超类。我们不仅可以从一个具体类开始,还可以从一个抽象基类开始我们的设计。
例如,我们可以这样开始一个类定义:
from collections.abc import MutableMapping
class MyFancyMapping(MutableMapping[int, int]):
... # etc.
为了完成这门课程,我们需要为一些特殊方法提供实现:
-
__getitem__ -
__setitem__ -
__delitem__ -
__iter__ -
__len__
这些方法在抽象类中都不存在;在 Mapping 类中没有具体的实现。一旦我们为每个方法提供了可行的实现,我们就可以创建新子类实例。
7.9.5 参考信息
- 在设计大量处理类的配方中,我们采取了不同的方法。在那个配方中,我们将复杂的算法留在了另一个类中。
7.10 使用属性懒加载
在设计大量处理类的配方中,我们定义了一个类,它会急切地计算集合中数据的许多属性。那里的想法是尽可能早地计算值,这样属性就不会有进一步的计算成本。
我们将其描述为急切处理,因为工作尽可能早地完成。另一种方法是懒处理,其中工作尽可能晚地完成。
如果我们有一些很少使用且计算成本很高的值,我们该怎么办?我们如何最小化初始计算,并且只在真正需要时计算值?
7.10.1 准备工作...
对于背景信息,请参阅 NIST 气溶胶粒子尺寸案例研究:www.itl.nist.gov/div898/handbook/pmc/section6/pmc62.htm
在本章中,请参阅设计大量处理类的配方以获取更多关于此数据集的详细信息。与处理原始数据相比,处理包含在 Counter 对象中的摘要信息可能会有所帮助。配方展示了从粒子大小到数量的映射,以及特定大小被测量的次数。
我们想计算这个 Counter 的一些统计数据。我们有两个总体策略来完成这项工作:
-
扩展:我们在扩展内置集合 – 执行统计的列表的菜谱中详细介绍了这一点,我们将在第八章中查看其他扩展类的例子。
-
包装:我们可以将 Counter 对象包装在另一个只提供所需功能的类中。我们将在第八章中探讨这一点。
在包装时的一种常见变体是创建一个与数据收集对象分开的统计计算对象。这种包装的变体通常会导致一个优雅的解决方案。
无论我们选择哪种类架构,我们还有两种设计处理的方式:
-
贪婪:这意味着我们将尽快计算统计值。这是设计具有大量处理的类菜谱中采用的方法。
-
懒惰:这意味着我们不会在需要之前通过方法函数或属性进行任何计算。在扩展内置集合 – 执行统计的列表的菜谱中,我们向集合类添加了方法。这些额外的方法是惰性计算的例子。统计值仅在需要时才进行计算。
两种设计的基本数学是相同的。唯一的问题是何时进行计算。
7.10.2 如何实现...
-
使用描述性的名称定义类:
class LazyCounterStatistics: -
编写初始化方法以包含此对象将要连接的对象。我们定义了一个方法函数,它接受一个 Counter 对象作为参数值。这个 Counter 对象被保存为 Counter_Statistics 实例的一部分:
def __init__(self, raw_counter: Counter[int]) -> None: self.raw_counter = raw_counter -
定义一些有用的辅助方法。这些方法中的每一个都用@property 装饰,使其表现得像简单的属性:
@property def sum(self) -> float: return sum( f * v for v, f in self.raw_counter.items() ) @property def count(self) -> float: return sum( f for v, f in self.raw_counter.items() ) -
定义所需的各种值的必要方法。以下是对平均值的计算。这也用@property 装饰。其他方法可以像属性一样引用,尽管它们是正确的方法函数:
@property def mean(self) -> float: return self.sum / self.count -
这是我们如何计算标准差的方法。注意,我们一直在使用 math.sqrt()。务必在 Python 模块中添加所需的 import math 语句:
@property def sum2(self) -> float: return sum( f * v ** 2 for v, f in self.raw_counter.items() ) @property def variance(self) -> float: return ( (self.sum2 - self.sum ** 2 / self.count) / (self.count - 1) ) @property def stddev(self) -> float: return math.sqrt(self.variance)
为了展示这是如何工作的,我们将这个类的实例应用于一些汇总数据。这本书的代码库包括一个 data/binned.csv 文件,它包含汇总数据的分箱。这些数据有三个列:size_code,size 和 frequency。我们只对 size_code 和 frequency 感兴趣。
这是我们如何从这个文件中构建一个合适的 Counter 对象的方法:
>>> from pathlib import Path
>>> import csv
>>> from collections import Counter
>>> data_path = Path.cwd() / "data" / "binned.csv"
>>> with data_path.open() as data_file:
... reader = csv.DictReader(data_file)
... extract = {
... int(row[’size_code’]): int(row[’frequency’])
... for row in reader
... }
>>> data = Counter(extract)
我们使用字典推导来创建从 size_code 到该代码值频率的映射。然后,我们将它提供给 Counter 类,从现有的汇总中构建一个名为 data 的 Counter 对象。
这是我们如何分析 Counter 对象的方法:
>>> stats = LazyCounterStatistics(data)
>>> print(f"Mean: {stats.mean:.1f}")
Mean: 10.4
>>> print(f"Standard Deviation: {stats.stddev:.2f}")
Standard Deviation: 4.17
我们提供了数据对象来创建 LazyCounterStatistics 类的实例,即 stats 变量。当我们打印 stats.mean 属性和 stats.stddev 属性的值时,将调用方法来进行适当的值计算。
计算成本只有在客户端对象请求 stats.mean 或 stats.stddev 属性值时才会支付。这将引发一系列计算来计算这些值。
当底层数据发生变化时,整个计算将再次执行。在高度动态数据的罕见情况下,这可能很昂贵。在更常见的情况下,分析先前汇总的数据,这相当高效。
7.10.3 它是如何工作的...
当值很少使用时,懒计算的思路效果很好。在这个例子中,计数在计算方差和标准差时被计算了两次。
在值频繁重新计算的情况下,一个简单的懒设计在某些情况下可能不是最优的。这通常是一个容易解决的问题。我们总是可以创建额外的局部变量来缓存中间结果,而不是重新计算。我们将在本食谱的后面讨论这个问题。
为了使这个类看起来像它已经执行了急切计算,我们使用了@property 装饰器。这使得一个方法看起来像是一个属性。这只能适用于没有参数值的方法。
在所有情况下,一个被急切计算的属性可以被一个懒属性所替代。创建急切属性变量的主要原因是优化计算成本。在计算结果可能不会被总是使用的情况下,懒属性可以避免昂贵的计算。
7.10.4 更多内容...
有一些情况下,我们可以进一步优化一个属性,以限制在值变化时所做的额外计算量。这需要仔细分析使用案例,以便理解底层数据的更新模式。
在数据被加载到集合中并执行分析的情况下,我们可以缓存结果以避免第二次计算。我们可能做如下操作:
from typing import cast
class CachingLazyCounterStatistics:
def __init__(self, raw_counter: Counter[int]) -> None:
self.raw_counter = raw_counter
self._sum: float | None = None
self._count: float | None = None
@property
def sum(self) -> float:
if self._sum is None:
self._sum = sum(
f * v
for v, f in self.raw_counter.items()
)
return self._sum
这种技术使用两个属性来保存求和和计数计算的值,self._sum 和 self._count。这些值将只计算一次,并在需要时返回,无需额外的计算成本。
类型提示显示这些属性是可选的。一旦 self._sum 和 self._count 的值被计算出来,这些值就不再是可选的,但将始终存在。我们用 cast()类型提示向像 mypy 这样的工具描述这一点。这个提示告诉类型检查工具将 self._sum 视为一个 float 对象,而不是 float | None 对象。这个函数没有成本,因为它什么也不做;它的目的是注释处理过程,以显示设计意图。
如果 raw_counter 对象的状态从不改变,这种缓存优化是有帮助的。在一个更新底层 Counter 的应用程序中,这个缓存的值会过时。这种类型的应用程序需要在底层 Counter 更新时重置 self._sum 和 self._count 的内部缓存值。
7.10.5 参见...
- 在设计具有大量处理的类的配方中,我们定义了一个类,它急切地计算了许多属性。这代表了一种管理计算成本的不同策略。
7.11 创建上下文和上下文管理器
许多 Python 对象表现得像上下文管理器。其中一些最明显的例子是文件对象。我们通常使用 with path.open() as file:来在一个可以保证资源释放的上下文中处理文件。在第二章中,使用 with 语句管理上下文的配方涵盖了使用基于文件的上下文管理器的基础知识。
我们如何创建自己的类,使其充当上下文管理器?
7.11.1 准备工作
我们将在第三章的基于部分函数选择参数顺序配方中查看一个函数。这个配方介绍了一个函数,haversine(),它有一个上下文类似的参数,用于将答案从无量纲弧度调整到有用的单位,如公里、海里或美国英里。在许多方面,这个距离因子是一种上下文,用于定义所进行的计算类型。
我们希望能够使用 with 语句来描述一个变化不快的对象;实际上,这种变化充当了一种边界,定义了计算的范围。我们可能想要使用如下代码:
>>> with Distance(r=NM) as nm_dist:
... print(f"{nm_dist(p1, p2)=:.2f}")
... print(f"{nm_dist(p2, p3)=:.2f}")
nm_dist(p1, p2)=39.72
nm_dist(p2, p3)=30.74
Distance(r=NM)构造函数提供了上下文的定义,创建了一个新的对象 nm_dist,该对象已配置为以海里为单位执行所需的计算。这只能在 with 语句体中使用。
这个 Distance 类定义可以看作是创建了一个部分函数,nm_dist()。这个函数为使用 haversine()函数的多个后续计算提供了一个固定的单位参数,r。
有许多其他方法可以创建部分函数,包括 lambda 对象、functools.partial()函数和可调用对象。我们在第三章的基于部分函数选择参数顺序配方中探讨了部分函数的替代方案。
7.11.2 如何做...
上下文管理器类有两个特殊方法,我们需要定义:
-
从一个有意义的类名开始:
class Distance: -
定义一个初始化器,创建上下文的任何独特功能。在这种情况下,我们想要设置使用的距离单位:
def __init__(self, r: float) -> None: self.r = r -
定义
__enter__()方法。当with语句块开始时,会调用此方法。语句with Distance(r=NM) as nm_dist执行了两件事。首先,它创建了Distance类的实例,然后调用该对象的__enter__()方法以启动上下文。__enter__()方法的返回值通过as子句分配给一个局部变量。这并不总是必需的。对于简单情况,上下文管理器通常返回自身。如果此方法需要返回同一类别的实例,请注意类尚未完全定义,必须提供类名类型提示作为字符串。对于这个配方,我们将返回一个函数,类型提示基于Callable:def __enter__(self) -> Callable[[Point, Point], float]: return self.distance -
定义
__exit__()方法。当上下文结束时,将调用此方法。这是释放资源和进行清理的地方。在这个例子中,不需要做更多的事情。任何异常的详细信息都提供给此方法;方法可以静默异常或允许其传播。如果__exit__()方法的返回值为True,则异常将被静默。返回值False或None将允许异常在with语句外部可见:def __exit__( self, exc_type: type[Exception] | None, exc_val: Exception | None, exc_tb: TracebackType | None ) -> bool | None: return None -
创建一个类(或定义此类的函数)以在上下文中工作。在这种情况下,该方法将使用第三章中单独定义的
haversine()函数:def distance(self, p1: Point, p2: Point) -> float: return haversine( p1.lat, p1.lon, p2.lat, p2.lon, R=self.r )
大多数上下文管理器类需要相当多的导入:
from collections.abc import Callable
from types import TracebackType
from typing import NamedTuple
此类已定义为与 Point 类的对象一起工作。这可以是 NamedTuple、@dataclass 或其他提供所需两个属性的类。以下是 NamedTuple 的定义:
class Point(NamedTuple):
lat: float
lon: float
此类定义提供了一个名为 Point 的类,具有所需的属性名称。
7.11.3 它是如何工作的...
上下文管理器依赖于 with 语句执行大量操作。
我们将把以下结构放在显微镜下观察:
>>> p1 = Point(38.9784, -76.4922)
>>> p2 = Point(36.8443, -76.2922)
>>> nm_distance = Distance(r=NM)
>>> with nm_distance as nm_calc:
... print(f"{nm_calc(p1, p2)=:.2f}")
nm_calc(p1, p2)=128.48
第一行创建了 Distance 类的一个实例。该实例的 r 参数值等于常数 NM,这使得我们能够在海里进行计算。Distance 实例被分配给 nm_distance 变量。
当 with 语句开始执行时,通过执行 __enter__() 方法来通知上下文管理器对象。在这种情况下,__enter__() 方法返回的值是一个函数,类型为 Callable[[Point, Point], float]。该函数接受两个 Point 对象并返回一个浮点结果。as 子句将此函数对象分配给 nm_calc 名称。
print() 函数使用 nm_calc 对象来完成其工作。该对象是一个函数,将从两个 Point 实例计算距离。
当 with 语句结束时,exit() 方法将被执行。对于更复杂的上下文管理器,这可能涉及关闭文件或释放网络连接。可能需要许多种类的上下文清理。在这种情况下,不需要做任何清理上下文的事情。
这有一个优点,就是定义了一个固定边界,在这个边界内使用部分函数。在某些情况下,上下文管理器内部的计算可能涉及数据库或复杂的网络服务,从而导致更复杂的 exit() 方法。
7.11.4 更多内容…
exit() 方法的操作对于充分利用上下文管理器至关重要。在先前的例子中,我们使用了以下“什么都不做”的 exit() 方法:
def __exit__(
self,
exc_type: type[Exception] | None,
exc_val: Exception | None,
exc_tb: TracebackType | None
) -> bool | None:
# Cleanup goes here.
return None
这里的问题是允许任何异常正常传播。我们经常看到任何清理处理替换了 # Cleanup goes here. 注释。这就是缓冲区被刷新、文件被关闭和错误日志消息被写入的地方。
有时,我们需要处理特定的异常细节。考虑以下交互会话的片段:
>>> p1 = Point(38.9784, -76.4922)
>>> p2 = Point(36.8443, -76.2922)
>>> with Distance(None) as nm_dist:
... print(f"{nm_dist(p1, p2)=:.2f}")
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for *: ’NoneType’ and ’int’
Distance 对象使用 r 参数值设置为 None 进行初始化。虽然这段代码会导致像 mypy 这样的工具发出警告,但从语法上是有效的。然而,TypeError 的 traceback 并不指向 Distance;它指向 haversine() 函数中的一行代码。
我们可能想报告一个 ValueError 而不是这个 TypeError。下面是 Distance 类的一个变体,它隐藏了 TypeError,用 ValueError 替换它:
class Distance_2:
def __init__(self, r: float) -> None:
self.r = r
def __enter__(self) -> Callable[[Point, Point], float]:
return self.distance
def __exit__(
self,
exc_type: type[Exception] | None,
exc_val: Exception | None,
exc_tb: TracebackType | None
) -> bool | None:
if exc_type is TypeError:
raise ValueError(f"Invalid r={self.r!r}")
return None
def distance(self, p1: Point, p2: Point) -> float:
return haversine(p1.lat, p1.lon, p2.lat, p2.lon, R=self.r)
这显示了如何在 exit() 方法中检查异常的详细信息。提供的信息与 sys.exc_info() 函数类似,包括异常的类型、异常对象以及一个具有 types.TracebackType 类型的 traceback 对象。
7.11.5 参见
- 在第二章的使用 with 语句管理上下文 节中,我们介绍了使用基于文件的上下文管理器的基础知识。
7.12 使用多个资源管理多个上下文
我们经常使用上下文管理器与打开的文件一起使用。因为上下文管理器可以保证操作系统资源被释放,这样做可以防止资源泄漏。它可以用来防止在没有将所有缓冲区刷新到持久存储的情况下关闭文件。
当处理多个资源时,通常意味着需要多个上下文管理器。例如,如果我们有三个打开的文件,我们可能需要三个嵌套的 with 语句?我们如何优化或简化多个 with 语句?
7.12.1 准备工作
我们将查看创建包含多个腿的行程计划。我们的起始数据收集是一个定义我们路线的点列表。例如,穿越切萨皮克湾可能涉及从马里兰州安纳波利斯出发,航行到索尔 omon 岛、弗吉尼亚州的 Deltaville,然后到弗吉尼亚州的诺福克。为了规划目的,我们希望将其视为三条腿,而不是四个点。一条腿有距离,需要时间穿越:计算时间、速度和距离是规划问题的本质。
在运行配方之前,我们将先进行一些基础定义。首先是单个点的定义,具有纬度和经度属性:
@dataclass(frozen=True)
class Point:
lat: float
lon: float
可以使用如下语句构建一个点:p = Point(38.9784, -76.4922)。这让我们可以在后续计算中引用 p.lat 和 p.lon。使用属性名称使代码更容易阅读。
一条腿是一对点。我们可以如下定义它:
@dataclass
class Leg:
start: Point
end: Point
distance: float = field(init=False)
我们已将其创建为可变对象。距离属性具有由 dataclasses.field()函数定义的初始值。使用 init=False 表示在初始化对象时不会提供该属性;它必须在初始化后提供。
这是一个上下文管理器,用于从点实例创建 Leg 对象。这与创建上下文和上下文管理器配方中显示的上下文管理器类似。这里有一个微小但重要的区别。init()保存一个值给 self.r 来设置距离单位上下文。默认值是海里:
from types import TracebackType
class LegMaker:
def __init__(self, r: float=NM) -> None:
self.last_point: Point | None = None
self.last_leg: Leg | None = None
self.r = r
def __enter__(self) -> "LegMaker":
return self
def __exit__(
self,
exc_type: type[Exception] | None,
exc_val: Exception | None,
exc_tb: TracebackType | None
) -> bool | None:
return None
重要的方法 waypoint()接受一个航点并创建一个 Leg 对象。第一个航点,即航行的起点,将返回 None。所有后续的点将返回一个 Leg 对象:
def waypoint(self, next_point: Point) -> Leg | None:
leg: Leg | None
if self.last_point is None:
# Special case for the first leg
self.last_point = next_point
leg = None
else:
leg = Leg(self.last_point, next_point)
d = haversine(
leg.start.lat, leg.start.lon,
leg.end.lat, leg.end.lon,
R=self.r
)
leg.distance = round(d)
self.last_point = next_point
return leg
此方法使用缓存的点对象 self.last_point 和下一个点 next_point 来创建一个 Leg 实例,然后更新该实例。
如果我们想在 CSV 格式中创建输出文件,我们需要使用两个上下文管理器:一个用于创建 Leg 对象,另一个用于管理打开的文件。我们将把这个复杂的多上下文处理放入一个单独的函数中。
7.12.2 如何做到这一点...
-
我们将使用 csv 和 pathlib 模块。此外,此配方还将使用 Iterable 类型提示和 dataclasses 模块中的 asdict 函数:
from collections.abc import Iterable import csv from dataclasses import asdict from pathlib import Path -
由于我们将创建 CSV 文件,我们需要定义用于 CSV 输出的标题:
HEADERS = ["start_lat", "start_lon", "end_lat", "end_lon", "distance"] -
定义一个函数,将复杂对象转换为适合写入每一行数据的字典。输入是一个 Leg 对象;输出是一个具有与 HEADERS 列表中列名匹配的键的字典:
def flat_dict(leg: Leg) -> dict[str, float]: struct = asdict(leg) return dict( start_lat=struct["start"]["lat"], start_lon=struct["start"]["lon"], end_lat=struct["end"]["lat"], end_lon=struct["end"]["lon"], distance=struct["distance"], ) -
定义一个具有意义名称的函数。我们将提供两个参数:一个点对象列表和一个 Path 对象,显示 CSV 文件应该创建的位置。我们已使用 Iterable[Point]作为类型提示,因此此函数可以接受任何可迭代的点实例集合:
def make_route_file( points: Iterable[Point], target: Path ) -> None: -
使用单个 with 语句启动两个上下文。这将调用两个 enter()方法来为工作准备两个上下文。这一行可能会很长:
with ( LegMaker(r=NM) as legger, target.open(’w’, newline=’’) as csv_file ): -
一旦上下文准备好工作,我们可以创建一个 CSV 写入器并开始写入行:
writer = csv.DictWriter(csv_file, HEADERS) writer.writeheader() for point in points: leg = legger.waypoint(point) if leg is not None: writer.writerow(flat_dict(leg)) -
在上下文结束时,进行任何最终的汇总处理。这不是在 with 语句体的缩进内;它与 with 关键字本身的缩进级别相同:
print(f"Finished creating {target}")通过将此消息放在 with 上下文之外,它提供了重要的证据,表明文件已正确关闭,所有的计算都已完成。
7.12.3 它是如何工作的...
复合的 with 语句为我们创建了一系列上下文管理器。所有的管理器都将使用它们的 enter()方法来开始处理,并且可选地返回一个可以在上下文中使用的对象。LegMaker 类定义了一个返回 LegMaker 实例的 enter()方法。Path.open()方法返回一个 TextIO 对象;这些也是上下文管理器。
当 with 语句结束时上下文退出,将调用所有上下文管理器的 exit()方法。这允许每个上下文管理器执行任何最终的清理。在 TextIO 对象的情况下,这将关闭外部文件,释放正在使用的任何 OS 资源。
在 LegMaker 对象的情况下,上下文退出时没有进行最终的清理处理。创建了一个 LegMaker 对象;从 enter()方法返回的值是这个对象方法的引用。legger 可调用对象将继续在上下文外部正确运行。这是一个特殊的情况,在没有在 exit()方法中进行清理的情况下发生。如果需要防止进一步使用 legger 可调用对象,那么 exit()方法需要在 LegMaker 对象内部进行显式的状态改变,以便抛出异常。一种方法是在 exit()方法中将 self.r 值设置为 None,这将防止进一步使用 waypoint()方法。
7.12.4 更多内容...
上下文管理器的任务是隔离资源管理的细节。最常见的情况是文件和网络连接。我们已经展示了在算法周围使用上下文管理器来帮助管理带有单个 Point 对象的缓存。
当处理非常大的数据集时,使用压缩通常很有帮助。这可以在处理周围创建不同的上下文。内置的 open()方法通常在 io 模块中分配给 io.open()函数。这意味着我们通常可以用 bz2.open()这样的函数替换 io.open()来处理压缩文件。
我们可以用类似这样的事物替换一个未压缩的文件上下文管理器:
import bz2
def make_route_bz2(points: Iterable[Point], target: Path) -> None:
with (
LegMaker(r=NM) as legger,
bz2.open(target, "wt") as archive
):
writer = csv.DictWriter(archive, HEADERS)
writer.writeheader()
for point in points:
leg = legger.waypoint(point)
if leg is not None:
writer.writerow(flat_dict(leg))
print(f"Finished creating {target}")
我们已经用 bz2.open(path)替换了原始的 path.open()方法。其余的上下文处理保持不变。这种灵活性允许我们在数据量增长时,最初处理文本文件,然后将其转换为压缩文件。
7.12.5 参见
-
在第二章的使用 with 语句管理上下文 菜单中,我们介绍了基于文件上下文管理器的使用基础。
-
创建上下文和上下文管理器 菜单涵盖了创建一个上下文管理器类的核心内容。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第八章:8
更高级的类设计
在第七章中,我们探讨了涵盖类设计基础的一些菜谱。在这一章中,我们将更深入地探讨 Python 类和类设计。
在第七章的 设计大量处理的类 和 使用属性实现懒属性 菜谱中,我们确定了一个面向对象编程的核心设计选择,“封装与扩展”决策。添加功能的一种方式是通过扩展创建一个新的子类。添加功能的另一种技术是封装一个现有的类,使其成为新类的一部分。
除了直接继承之外,Python 中还有一些其他类扩展技术可用。一个 Python 类可以从多个超类继承功能。我们称这种设计模式为混入(mixin)。
在第四章和第五章中,我们探讨了核心内置数据结构。我们可以结合和扩展这些集合定义功能,以创建更复杂的数据结构或具有附加功能的数据结构。
在这一章中,我们将探讨以下菜谱:
-
在继承和组合之间选择 – “是”问题
-
通过多重继承分离关注点
-
利用 Python 的鸭子类型
-
管理全局和单例对象
-
使用更复杂的结构 – 列表映射
-
创建具有可排序对象的类
-
从复杂对象的列表中删除
Python 中有大量的面向对象类设计技术。我们将从一个基础设计概念开始:在从基类继承和使用封装来扩展之间做出设计选择。
8.1 在继承和组合之间选择 – “是”问题
在第六章的 使用 cmd 创建命令行应用程序 菜谱和第七章的 扩展内置集合 – 执行统计的列表 菜谱中,我们探讨了类的扩展。在这两种情况下,菜谱中实现的类都是 Python 内置类的一个子类。
通过继承进行扩展的想法有时被称为泛化-特殊化关系。它也可以称为“是”关系。有一个重要的语义问题:
-
我们的意思是子类的实例也是超类的实例吗?这是一个“是”关系,是继承的一个例子,其中我们扩展了一个类,改变了功能的实现细节。
-
或者我们的意思是不是别的?可能是指组合或关联,有时称为“有”关系。在这种情况下,我们可能包装另一个类,添加或删除功能。
SOLID 设计原则之一,Liskov 替换原则要求任何子类都应该是超类的一个适当替代。我们将探讨创建现有类新功能的继承和组合技术。
8.1.1 准备工作
对于这个食谱,我们将使用扑克牌的模型作为具体的例子。我们将探讨几种设计集合的方法。
两种实现的核心成分是底层的 Card 对象。我们可以使用 NamedTuple 来定义它:
from typing import NamedTuple
class Card(NamedTuple):
rank: int
suit: str
Spades, Hearts, Diamonds, Clubs = (’\u2660’, ’\u2661’, ’\u2662’, ’\u2663’)
在本食谱的其余部分,我们将使用这个 Card 类。重要的是表示一副牌或牌手的各种集合类型;它们在支持的功能类型上都有相当大的重叠。
我们有几个常见的集合模式:
-
聚合:某些对象被绑定到集合中,但对象有适当的独立存在。虽然牌对象可以被聚合到牌手集合中,但当牌手对象被删除时,牌对象仍然存在。
-
组合:集合中的某些对象没有独立的存在。一副牌手不能没有玩家而存在。当一个玩家实例离开游戏时,牌手对象也必须被移除。
-
继承(也称为“是一个”关系):这是指一副牌是一个具有一些额外功能的牌手。我们可以扩展内置的类如列表来实现这一点。
在设计数据库时,区分聚合和组合非常重要,因为此时关注的是对象的持久性。在 Python 中,这种区分是一个小的细微差别。普通的 Python 内存管理会保留由集合或变量仍然引用的对象。我们将两者都视为组合的例子。
一旦理解了关系,就有两条不同的路径:组合或聚合或继承和扩展。
8.1.2 如何实现...
这个食谱有两个独立的迷你食谱:聚合和继承。
组合或聚合
在另一个类的实例变量中包装集合对象有两种常见的变体,有时称为组合和聚合。在 Python 中,这种细微的差别并不重要。以下是使用组合设计集合的方法:
-
定义集合类。
为了区分本书中的类似例子,名称带有“_W”后缀以表明它是一个包装器。这不是一个普遍推荐的做法;它只在这里用来强调本食谱中类定义之间的区别。
这是类的定义:
class Deck_W: -
使用这个类的
__init__()方法作为提供底层集合对象的一种方式。这也会初始化任何有状态的变量。我们可能创建一个用于发牌的迭代器:def __init__(self, cards: list[Card]) -> None: self.cards = cards self.deal_iter = iter(self.cards)这使用类型提示 list[Card]来显示将要包装的源集合。
-
为聚合对象提供适当的方法。shuffle()方法随机化内部列表对象。它还创建了一个迭代器,由 deal()方法用来遍历列表。我们在 deal()上提供了类型提示,以明确它返回 Card 实例:
def shuffle(self) -> None: random.shuffle(self.cards) self.deal_iter = iter(self.cards) def deal(self) -> Card: return next(self.deal_iter)
这是我们如何使用 Deck_W 类的方法。我们将使用 Card 对象列表。在这种情况下,domain 变量是由生成所有 52 种 13 个花色和四种花色的组合的列表推导式创建的:
>>> domain = list(
... Card(r+1,s)
... for r in range(13)
... for s in (Spades, Hearts, Diamonds, Clubs)
... )
>>> len(domain)
52
我们可以使用这个集合中的项目,domain,来创建一个共享相同底层 Card 对象的第二个聚合对象。我们将从 Card 对象列表构建 Deck_W 对象:
>>> d = Deck_W(domain)
>>> import random
>>> random.seed(1)
>>> d.shuffle()
>>> [d.deal() for _ in range(5)]
[Card(rank=13, suit=’’), Card(rank=3, suit=’’), Card(rank=10, suit=’’), Card(rank=6, suit=’’), Card(rank=1, suit=’’)]
继承和扩展
这里是定义扩展内置对象集合的类的做法:
-
首先定义扩展类为内置集合的子类。为了区分本书中的类似示例,名称有一个 _X 后缀。子类关系是一个正式声明——Deck_X 实例也是一种列表。以下是类定义:
class Deck_X(list[Card]): -
初始化实例不需要额外的代码,因为我们将从列表类继承 init()方法。
-
更新牌组不需要额外的代码,因为我们将在 Deck_X 实例中添加、更改或删除项目时使用列表类的其他方法。
-
向扩展对象提供适当的新功能。shuffle()方法随机化整个对象。这里的集合是 self,因为此方法是列表类的一个扩展。deal()对象依赖于 shuffle()方法创建的迭代器来遍历列表,返回 Card 实例:
def shuffle(self) -> None: random.shuffle(self) self.deal_iter = iter(self) def deal(self) -> Card: return next(self.deal_iter)
这是我们如何使用 Deck_X 类的方法。首先,我们将构建一副牌:
>>> dx = Deck_X(
... Card(r+1,s)
... for r in range(13)
... for s in (Spades, Hearts, Diamonds, Clubs)
... )
>>> len(dx)
52
在 Deck_X 实现中仅使用特定的牌组功能看起来与 Deck_W 实现完全相同:
>>> import random
>>> random.seed(1)
>>> dx.shuffle()
>>> [dx.deal() for _ in range(5)]
[Card(rank=13, suit=’’), Card(rank=3, suit=’’), Card(rank=10, suit=’’), Card(rank=6, suit=’’), Card(rank=1, suit=’’)]
正如我们在下面的 There’s more...中将要看到的,因为 Deck_X 是列表,它具有列表对象的所有方法。当为他人设计框架时,这可能是一个坏主意。当设计应用程序时,很容易避免使用额外的功能。
8.1.3 它是如何工作的...
Python 通过一个聪明的搜索算法实现继承的概念,用于查找对象类的(方法)和属性。搜索过程如下:
-
检查对象的类以获取方法或属性名称。
-
如果在直接类中未定义名称,则将在所有父类中搜索该方法或属性。方法解析顺序(MRO)定义了搜索这些类的顺序。
通过遍历父类确保两件事:
-
任何超类中定义的所有方法都对子类可用。
-
任何子类都可以覆盖方法以替换超类方法。super()函数搜索父类以查找被覆盖的定义。
由于这个原因,列表类的子类继承了父类的所有特性。它是内置列表类的一个特殊扩展。
这也意味着所有方法都有可能被子类覆盖。一些语言有锁定方法以防止扩展的方法。由于 Python 没有这个限制,子类可以覆盖任何方法。
super() 函数允许子类通过包装超类方法版本来添加功能。使用它的一个方法如下:
class SomeClass(Parent):
def some_method(self) -> None:
# do something extra
super().some_method()
在这种情况下,类的 some_method() 方法将执行一些额外操作,然后使用该方法超类版本。这为我们提供了一种方便的方式来扩展类的选定方法。我们可以在添加子类特有的功能的同时保留超类的特性。
8.1.4 更多内容...
在 Deck_W 和 Deck_X 这两个定义之间有一些巨大的差异。在包装时,我们得到我们定义的确切方法而没有其他方法。在使用继承时,我们从超类接收大量方法定义。这导致 Deck_X 类中一些可能不受欢迎的额外行为:
-
我们可以使用各种集合作为创建 Deck_X 实例的来源。这是因为列表类具有将 Python 集合转换为列表的许多特性。Deck_W 类仅适用于提供 shuffle() 方法隐式所需方法的序列。此外,list[Card] 的类型提示将导致像 mypy 这样的程序在使用其他来源集合时引发错误。
-
Deck_X 实例可以在 deal() 方法支持的核心理解迭代之外进行切片和索引。
-
由于 Deck_X 类是列表,它也可以直接与 iter() 函数一起使用;它可以在不使用 deal() 方法的情况下作为 Card 对象的可迭代来源。
这些差异也是决定使用哪种技术的重要部分。如果额外的功能是可取的,那么这表明继承是合适的。如果额外的功能造成问题,那么组合可能是一个更好的选择。
8.1.5 参见
-
我们已经在第四章中探讨了内置集合。在第七章中,我们也探讨了如何定义简单的集合。
-
在 设计具有大量处理的类 的配方中,我们探讨了使用一个处理处理细节的单独类来包装一个类。我们可以将其与第七章的 使用属性进行懒加载属性 配方进行对比,在那里我们将复杂的计算作为属性放入类中;这种设计依赖于扩展。
8.2 通过多重继承分离关注点
在本章前面的 在继承和组合之间选择 – “是”问题 配方中,我们探讨了定义一个 Deck 类的想法,该类是玩牌对象的组合。为了这个示例的目的,我们将每个 Card 对象视为仅具有等级和花色属性。这造成了两个小问题:
-
卡片的显示总是显示数字等级。我们没有看到 J、Q 或 K。相反,我们看到 11、12 和 13。同样,A 牌显示为 1 而不是 A。
-
许多游戏,如克里比奇,为每个等级分配点值。通常,面牌有 10 点。剩余的牌的点数与它们的等级相匹配。
Python 的多重继承让我们在保持单个基本 Card 类的同时处理卡牌游戏规则的变化。使用多重继承让我们将特定游戏的规则与通用牌的特性分开。我们可以将基类定义与提供所需功能的混合类组合起来。
Python 的多重继承依赖于一个称为 C3 的巧妙算法,用于将各种父类解析为单个列表,以有用的顺序。当我们组合多个类时,它们将具有共同的父类,现在有多个引用。C3 算法创建了一个尊重所有父子关系的线性列表。
8.2.1 准备工作
对 Card 类的实际扩展需要是两个特性集的混合。Python 允许我们定义一个包含多个父类特性的类。这个模式有两个部分:
-
核心特性:这是等级和花色。这还包括一个方法,以字符串形式优雅地显示 Card 对象的值,使用“J”、“Q”和“K”表示宫廷牌,以及“A”表示 A 牌。
-
混合特性:这些都是不那么重要的、特定于游戏的特性,例如分配给每张特定牌的点数。
工作应用程序依赖于从基本特性和混合特性构建的特性组合。
8.2.2 如何操作...
此配方将创建两个类层次结构,一个用于基本的 Card 类,另一个用于特定游戏的特性,包括克里比奇点值:
-
定义基本类。这是一个通用的 Card 类,适用于 2 到 10 的等级:
@dataclass(frozen=True) class Card: """Superclass for cards""" rank: int suit: str def __str__(self) -> str: return f"{self.rank:2d} {self.suit}" -
定义子类以实现特殊化。我们需要 Card 类的两个子类——AceCard 类和 FaceCard 类,如下面的代码所示:
class AceCard(Card): def __str__(self) -> str: return f" A {self.suit}" class FaceCard(Card): def __str__(self) -> str: names = {11: "J", 12: "Q", 13: "K"} return f" {names[self.rank]} {self.suit}"每个类都覆盖了内置的
__str__()方法以提供不同的行为。 -
定义混合类所需的核心特性。使用
typing.Protocol超类以确保各种实现都提供了所需的功能。协议需要rank属性,它将在基本类中定义。混合类中将定义一个points()方法。以下是它的样子:from typing import Protocol class PointedCard(Protocol): rank: int def points(self) -> int: ...当编写类型提示类时,主体可以是 ... 因为这将由像 mypy 这样的工具忽略。
-
定义混合子类以添加额外的功能。对于 Cribbage 游戏,某些牌的点数等于牌的等级,面牌是 10 点:
class CribbagePoints(PointedCard): def points(self) -> int: return self.rankclass CribbageFacePoints(PointedCard): def points(self) -> int: return 10 -
创建最终的实体类定义以组合基本类和所有所需的混合类:
class CribbageCard(Card, CribbagePoints): pass class CribbageAce(AceCard, CribbagePoints): pass class CribbageFace(FaceCard, CribbageFacePoints): pass注意,CribbagePoints 混合类被用于 Card 和 AceCard 类,这使得我们可以重用代码。
-
定义一个函数(或类)根据输入参数创建适当的对象。这通常被称为工厂函数或工厂类。被创建的对象都将被视为 Card 类的子类,因为它是基类列表中的第一个:
def make_cribbage_card(rank: int, suit: str) -> Card: if rank == 1: return CribbageAce(rank, suit) elif 2 <= rank < 11: return CribbageCard(rank, suit) elif 11 <= rank: return CribbageFace(rank, suit) else: raise ValueError(f"invalid rank {rank}")
我们可以使用 make_cribbage_card() 函数创建一副洗好的牌,如下面的示例交互会话所示:
>>> import random
>>> random.seed(1)
>>> deck = [make_cribbage_card(rank+1, suit) for rank in range(13) for suit in SUITS]
>>> random.shuffle(deck)
>>> len(deck)
52
>>> [str(c) for c in deck[:5]]
[’ K ’, ’ 3 ’, ’10 ’, ’ 6 ’, ’ A ’]
我们可以评估每个 Card 对象的 points() 方法:
>>> sum(c.points() for c in deck[:5])
30
手中有两张面牌,加上 3、6 和 A,所以总分数是 30。
8.2.3 它是如何工作的...
Python 查找对象方法(或属性)的机制如下:
-
在实例中搜索该属性。
-
在类中搜索该方法或属性。
-
如果在直接类中未定义名称,则会在所有父类中搜索该方法或属性。父类是按照称为方法解析顺序(Method Resolution Order,MRO)的顺序搜索的。
我们可以使用类的 mro() 方法显示 MRO。以下是一个示例:
>>> c.__class__.__name__
’CribbageCard’
>>> from pprint import pprint
>>> pprint(c.__class__.mro())
[<class ’recipe_02.CribbageCard’>,
<class ’recipe_02.Card’>,
<class ’recipe_02.CribbagePoints’>,
<class ’recipe_02.PointedCard’>,
<class ’typing.Protocol’>,
<class ’typing.Generic’>,
<class ’object’>]
CribbageCard 类的 mro() 方法显示了用于解析名称的顺序。因为类对象使用内部字典来存储方法定义,所以搜索是极快的基于哈希的属性名称查找。
8.2.4 更多...
我们可以将几种设计关注点以混合类的形式分离出来:
-
持久性和状态表示:一个混合类可以添加方法来管理转换为一致的 CSV 或 JSON 表示法等外部表示。
-
安全性:一个混合类可以添加执行一致授权检查的方法,这些检查适用于多个基类。
-
日志记录:一个混合类可以引入一个具有跨各种类定义一致的 logger。
-
事件信号和变更通知:一个混合类可能会报告对象状态的变化,以便一个或多个 GUI 小部件可以刷新显示。
例如,我们将创建一个混合类来向牌引入日志记录。我们将以必须首先在超类列表中提供的方式定义此类。由于它在 MRO 列表中较早,因此它使用 super() 函数来使用 MRO 列表中后续类定义的方法。
此类将为具有 PointedCard 协议定义的每个对象添加 logger 属性:
import logging
class Logged(Card, PointedCard):
def __init__(self, rank: int, suit: str) -> None:
self.logger = logging.getLogger(self.__class__.__name__)
super().__init__(rank, suit)
def points(self) -> int:
p = super().points() # type: ignore [safe-super]
self.logger.debug("points {0}", p)
return p
注意,我们使用了 super().init()来执行任何其他类定义的 init()方法。这些初始化的顺序来自类 MRO。最简单的方法是有一个定义对象基本特征的类,而所有其他混入则通过向基本对象添加额外方法的形式添加功能。
我们为 points()提供了覆盖定义。这将搜索 MRO 列表中的其他类以查找 points()方法的实现。然后它将记录由另一个混入类中的方法计算的结果。
type: ignore [safe-super]注释是给像 mypy 这样的严格类型检查工具的提示。当我们查看 PointedCard 协议的定义时,没有为这个方法提供定义。从工具对类层次结构的检查来看,调用 super().points()可能是不安全的。我们确信在实际情况中不会发生这种情况,因为混入将始终存在以定义 points()方法。我们将 super()的不安全使用标记为要忽略的错误。
这里有一些包含 Logged 混入功能的类:
class LoggedCribbageAce(Logged, AceCard, CribbagePoints):
pass
class LoggedCribbageCard(Logged, Card, CribbagePoints):
pass
class LoggedCribbageFace(Logged, FaceCard, CribbageFacePoints):
pass
这些类都是基于三个独立的类定义构建的。由于 Logged 类首先提供,我们确保所有类都有一致的日志记录。我们还确保 Logged 中的任何方法都可以使用 super()来定位在类定义中跟随其后的类列表中的实现。
要使用这些类,我们需要定义一个 make_logged_card()函数来使用这些新类。
8.2.5 参见
-
当类创建时计算方法解析顺序。使用的算法称为 C3。这个过程最初是为 Dylan 语言开发的,现在也被 Python 使用。C3 算法确保每个父类只被搜索一次。它还确保了超类之间的相对顺序被保留;子类将在检查任何父类之前被搜索。更多信息请参阅
dl.acm.org/doi/10.1145/236337.236343。 -
在考虑多重继承时,始终需要考虑是否将包装器作为比子类更好的设计。参见在继承和组合之间选择 – “是”问题配方。
8.3 利用 Python 的鸭子类型
当设计涉及继承时,通常从超类到一个或多个子类之间存在明确的关系。在本章的在继承和组合之间选择 – “是”问题配方以及第七章中的扩展内置集合 – 做统计的列表配方中,我们查看涉及适当的子类-超类关系的扩展。
为了有可以相互替换使用的类(“多态类”),某些语言要求有一个共同的超类。在许多情况下,共同类并没有对所有方法进行具体实现;它被称为抽象超类。
Python 不需要共同的超类。标准库提供了 abc 模块,以支持在有助于阐明类之间关系的情况下创建抽象类。
与使用具有共同超类的多态类相比,Python 依赖于鸭子类型来建立等价性。这个名字来源于以下引言:
当我看到一只既像鸭子走路又像鸭子游泳还像鸭子嘎嘎叫的鸟时,我就称那只鸟为鸭子。
(詹姆斯·惠特科姆·赖利)
在 Python 类关系的情况下,如果两个对象具有相同的方法和属性,这些相似之处与具有共同超类具有相同的效果。不需要对共同类进行正式的定义。
这个配方将展示如何利用鸭子类型的概念来创建多态类。这些类的实例可以相互替换,使我们能够拥有更灵活的设计。
8.3.1 准备工作
在某些情况下,为多个松散相关的实现选择定义一个超类可能会有些尴尬。例如,如果一个应用程序分布在几个模块中,可能很难提取一个共同的超类并将其单独放在一个模块中,以便在其他模块中导入。与其提取一个共同的抽象,有时创建将通过“鸭子测试”的类更容易:各种类具有相同的方法和属性;因此,它们实际上是可互换的,是多态类。
8.3.2 如何实现...
我们将定义一对类来展示这是如何工作的。这两个类都将模拟掷两个骰子的过程。我们将创建两个具有足够共同特征的独立实现,这样它们就可以互换使用:
-
从一个具有所需方法和属性的类开始,Dice1。在这个例子中,我们将有一个属性,dice,它保留上一次掷骰子的结果,以及一个方法,roll(),它改变骰子的状态:
import random class Dice1: def __init__(self, seed: int | None = None) -> None: self._rng = random.Random(seed) self.roll() def roll(self) -> tuple[int, ...]: self.dice = ( self._rng.randint(1, 6), self._rng.randint(1, 6)) return self.dice -
定义另一个类,Dice2,具有相同的方法和属性。这里是一个稍微复杂一些的定义,它创建了一个与 Dice1 类具有相同签名的类:
import random class Die: def __init__(self, rng: random.Random) -> None: self._rng = rng def roll(self) -> int: return self._rng.randint(1, 6) class Dice2: def __init__(self, seed: int | None = None) -> None: self._rng = random.Random(seed) self._dice = [Die(self._rng) for _ in range(2)] self.roll() def roll(self) -> tuple[int, ...]: self.dice = tuple(d.roll() for d in self._dice) return self.dice
到目前为止,两个类,Dice1 和 Dice2,可以自由互换。这里是一个接受任一类作为参数的函数,创建一个实例,并产生几个掷骰子的结果:
from collections.abc import Iterator
def roller(
dice_class: type[Dice1 | Dice2],
seed: int | None = None,
*,
samples: int = 10
) -> Iterator[tuple[int, ...]]:
dice = dice_class(seed)
for _ in range(samples):
yield dice.roll()
我们可以使用 Dice1 类或 Dice2 类作为 dice 参数值的函数。type[Dice1 | Dice2]类型提示指定了多个等效类的联合。此函数在 dice 参数中创建给定类的实例,甚至可以提供种子值。使用已知的种子可以创建可重复的结果,这对于单元测试通常是必需的,也用于重现涉及随机选择的统计研究。
以下交互会话显示了 roller()函数被应用于两个类:
>>> list(roller(Dice1, 1, samples=5))
[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)]
>>> list(roller(Dice2, 1, samples=5))
[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)]
由 Dice1 和 Dice2 构建的对象有足够的相似性,以至于它们无法区分。
8.3.3 它是如何工作的...
我们创建了两个具有相同属性和方法集合的类。这是鸭子类型的核心。由于 Python 通过遍历字典序列来搜索匹配的名称,因此类不需要有共同的超类就可以互换。
定义相关类的联合可能会有所帮助。另一个选择是定义一个类都遵守的通用协议。对于每个类来说,虽然不是必须显式地从协议定义中继承,但这样做可以使读者更清晰地理解。像 mypy 这样的工具可以判断一个类是否符合协议,这正是鸭子类型的工作方式。
8.3.4 更多内容...
在 roller()函数的定义中,我们使用了以下类型提示:dice: type[Dice1 | Dice2]。
使用如下代码可以使这种做法更明确:
Dice = Dice1 | Dice2
这可以很容易地扩展,因为当添加新的替代定义时。客户端类可以使用 type[Dice]来引用替代方案的联合。
另一个选择是定义一个协议。协议定义了一个具有仅共享各种实现共同特征的通用类型:
class DiceP(Protocol):
def roll(self) -> tuple[int, ...]:
...
这有助于在开发过程的后期创建类型提示。在创建替代实现之后,很容易定义一个联合各种选择的类型。如果出现更多实现,它们可以被添加到联合中。
协议允许更容易地扩展替代方案。协议仅定义了实现的有关特征。这通常是通过重构客户端方法签名的属性来完成的,使其引用协议类。
8.3.5 参见
-
鸭子类型问题在在继承和组合之间选择 – “是”问题配方中是隐含的;如果我们利用鸭子类型,我们也在声称两个类不是同一件事。当我们绕过继承时,我们隐含地声称 is-a 关系不成立。
-
当查看通过多重继承分离关注点配方时,我们也能够利用鸭子类型来创建可能没有简单继承层次结构的复合类。
8.4 管理全局和单例对象
Python 环境包含许多隐式全局对象。这些对象提供了一种方便的方式来处理其他对象的集合。因为集合是隐式的,所以我们避免了显式初始化代码的烦恼。
这的一个例子是 random 模块中的隐式随机数生成对象。当我们评估 random.random() 时,我们实际上是在使用 random.Random 类的一个实例。
因为一个模块只导入一次,所以模块实现了 Singleton 设计模式。我们可以依赖这项技术来实现这些全局单例。
其他例子包括以下内容:
-
可用的数据编码器和解码器(编解码器)集合。编解码器模块有一个编码器和解码器的注册表。我们可以向这个注册表中添加编码和解码。
-
webbrowser 模块有一个已知浏览器的注册表。
-
numbers 模块有一个数字数据类型的注册表。这允许模块定义一个新的数字类型实现并将其添加到已知类型的混合中。
-
logging 模块维护一个命名记录器的集合。getLogger() 函数试图找到一个现有的记录器;如果需要,它会创建一个新的记录器。
-
re 模块有一个编译正则表达式的缓存。这节省了重新编译在方法或函数内部定义的正则表达式的时间。
本食谱将展示如何处理像用于编解码器、浏览器和数字类的注册表这样的隐式全局对象。
8.4.1 准备工作
一组函数都可以与由模块创建的隐式全局对象一起工作。好处是允许其他模块共享一个公共对象,而无需编写任何显式协调共享的代码。
这可能会让阅读你代码的人感到困惑。
共享全局状态的想法可能会变成一个设计噩梦。进一步使共享对象变得隐式可能会使问题更加复杂。
观察 Python 标准库中的示例,有两个重要的模式。首先,有一个狭窄的焦点。其次,注册表的更新仅限于添加新实例。
例如,我们将定义一个包含全局单例对象的模块。我们将在第十三章中更详细地讨论模块。
我们的全球对象将是一个计数器,我们可以用它来累积来自几个独立模块或对象的集中化数据。我们将使用这个全局对象来计数应用程序中的事件。计数提供了已完成工作的摘要,并检查以确认所有工作都已完成。
目标是能够编写如下内容:
for row in source:
count(’input’)
some_processing()
print(counts())
some_processing() 函数可能会使用类似 count('reject') 来计算被拒绝的输入行数。这个函数可能会调用其他也使用 count() 函数来记录处理证据的函数。
这两个独立函数都引用了一个共享的全局计数器:
-
count(key) 增加一个全局 Counter,并返回给定键的当前值。
-
counts()提供所有的计数器值。
8.4.2 如何实现...
处理全局状态信息有两种常见的方式:
-
使用模块全局变量,因为模块是单例对象。
-
使用类级别的变量(在某些编程语言中称为静态)。在 Python 中,类定义也是一个可以共享的单例对象。
我们将单独介绍这些小技巧,从模块全局变量开始。
模块全局变量
我们可以这样做来创建一个对模块全局的变量:
-
创建一个模块文件。这将是一个包含定义的 .py 文件。我们将它命名为 counter.py。
-
如果需要,定义一个全局单例类的类。在我们的例子中,我们可以使用这个定义来创建一个
collections.Counter对象:from collections import Counter -
定义全局单例对象的唯一实例。我们在名称前使用了前导下划线使其稍微不那么显眼。它——技术上——不是私有的。然而,它被许多 Python 工具和实用程序优雅地忽略:
_global_counter: Counter[str] = Counter()标记全局变量的常见习惯是使用全大写的名称。这似乎对被视为常量的全局变量更重要。在这种情况下,这个变量将被更新,使用全大写名称似乎有些误导。
-
定义两个函数以使用全局对象
_global_counter。这些函数封装了计数器实现的细节:def count(key: str, increment: int = 1) -> None: _global_counter[key] += increment def counts() -> list[tuple[str, int]]: return _global_counter.most_common()
现在,我们可以编写在多个地方使用 count() 函数的应用程序。然而,计数的事件完全集中在一个对象中,该对象作为模块的一部分定义。
我们可能有这样的代码:
>>> from counter import *
>>> from recipe_03 import Dice1
>>> d = Dice1(1)
>>> for _ in range(1000):
... if sum(d.roll()) == 7:
... count(’seven’)
... else:
... count(’other’)
>>> print(counts())
[(’other’, 833), (’seven’, 167)]
我们已从 counter 模块中导入了 count() 和 counts() 函数。我们还导入了 Dice1 类作为一个方便的对象,用于创建事件序列。当我们创建 Dice1 的实例时,我们提供一个初始化来强制特定的随机种子。这给出了可重复的结果。
这种技术的优点是,多个模块都可以在 counter 模块中共享全局对象。所需的一切只是一个导入语句。不需要进一步的协调或开销。
类级别的“静态”变量
我们可以这样做来创建一个对类定义的所有实例全局的变量:
-
在
__init__()方法之外定义一个类的变量。这个变量是类的一部分,而不是实例的一部分。为了清楚地表明这个属性是由类的所有实例共享的,ClassVar类型提示很有帮助。在这个例子中,我们决定使用前导下划线,这样类级别的变量就不会被视为公共接口的一部分:from collections import Counter from typing import ClassVar class EventCounter: _class_counter: ClassVar[Counter[str]] = Counter() -
添加更新和从类级别的
_class_counter属性中提取数据的方法。这些方法将使用@classmethod装饰器来表明它们是直接由类使用的,而不是由实例使用的。不使用self变量;相反,使用cls变量作为方法适用于类的提醒:@classmethod def count(cls, key: str, increment: int = 1) -> None: cls._class_counter[key] += increment @classmethod def counts(cls) -> list[tuple[str, int]]: return cls._class_counter.most_common()
非常重要的是要注意,_class_counter 属性是类的一部分,被称为 cls._class_counter。我们不使用 self 实例变量,因为我们不是指代类的实例;我们是指代类定义中的一部分变量,作为装饰有 @classmethod 的方法的第一个参数提供。
这就是我们可以使用这个类的方法。
>>> from counter import *
>>> EventCounter.count(’input’)
>>> EventCounter.count(’input’)
>>> EventCounter.count(’filter’)
>>> EventCounter.counts()
[(’input’, 2), (’filter’, 1)]
由于所有这些操作都更新 EventCounter 类,每个都会增加共享变量。
共享的全局状态必须谨慎使用。
它们可能与类的实际工作无关。焦点必须狭窄且限于非常有限的状态变化。
当有疑问时,显式共享对象将是一个更好的设计策略,但将涉及更多的代码。
8.4.3 它是如何工作的...
Python 导入机制使用 sys.modules 来跟踪哪些模块被加载。一旦模块在这个映射中,它就不会再次被加载。这意味着模块内定义的任何变量都将是一个单例:只有一个实例。
在一个模块中,只能创建一次类定义。这意味着类的内部状态变化也遵循单例设计模式。
我们如何在这两种机制之间进行选择?选择基于由多个类共享全局状态所造成的混淆程度。正如在类级别的“静态”变量中的前一个示例所示,我们可以有多个变量共享一个共同的 Counter 对象。如果隐式共享的全局状态看起来很令人困惑,那么模块级全局变量是一个更好的选择。在模块级全局变量令人困惑的情况下,应通过普通可见变量显式共享状态。
8.4.4 更多内容...
共享的全局状态可以被称为面向对象编程的反面。面向对象编程的一个理想是封装单个对象中的状态变化。如果过于广泛地使用,全局变量会破坏封装单个对象状态的想法。
在某种意义上,一个模块是一种类似于类的结构。模块是一个命名空间,具有模块级变量来定义状态和类似于方法的模块函数。
需要公共全局状态的一个例子通常出现在尝试为应用程序定义配置参数时。拥有一个单一、统一的配置,并在多个模块中广泛共享,可能会有所帮助。当这些对象用于普遍的功能,如配置、审计、日志记录和安全时,全局变量可以帮助将单个、专注的横切关注点隔离到一个通用的类中,该类与应用程序特定的类分开。
另一个选择是显式创建一个配置对象。然后,可以将此配置对象作为参数提供给应用程序中的其他对象。
8.4.5 参见
- 第十四章介绍了模块和应用设计中的其他主题。
8.5 使用更复杂的结构 – 列表映射
在第四章中,我们探讨了 Python 中可用的基本数据结构。那些菜谱通常单独查看各种结构。
我们将探讨一种常见的组合结构——从单个键到相关值列表的映射。这可以用来累积有关数据库或日志记录的详细信息,这些记录由给定的键标识。这个菜谱将把平铺的细节列表分割成按共享键值组织的列表。
这与类设计相融合,因为我们经常可以利用 Python 的内置类来完成这类工作。这可以减少我们必须编写的独特新代码的量。
8.5.1 准备工作
我们将探讨从一个字符串到我们设计的类的实例列表的映射。我们将从一个应用程序的原始日志条目开始,将每一行分解成单个字段,然后从数据字段创建单个事件对象。一旦我们有了这些对象,我们就可以将它们重新组织和分组到与常见属性值(如模块名称或消息严重性)关联的列表中。
在第五章中,我们探讨了 创建字典 – 插入和更新 菜谱中的日志数据。
第一步将是将日志行转换成更有用的逗号分隔值(CSV)格式。正则表达式可以提取各种语法组。有关解析工作如何进行的更多信息,请参阅第一章的 使用正则表达式进行字符串解析 菜谱。
原始数据看起来如下:
[2016-04-24 11:05:01,462] INFO in module1: Sample Message One
[2016-04-24 11:06:02,624] DEBUG in module2: Debugging
[2016-04-24 11:07:03,246] WARNING in module1: Something might have gone wrong
每一行都可以用正则表达式解析成组件字段。我们将定义一个名为 NamedTuple 的子类,它有一个静态方法 from_line(),用于使用这四个字段创建类的实例。确保属性名称与正则表达式分组名称匹配,我们可以使用以下定义来构建类的实例:
import re
from typing import NamedTuple
class Event(NamedTuple):
timestamp: str
level: str
module: str
message: str
@staticmethod
def from_line(line: str) -> ’Event | None’:
pattern = re.compile(
r"\[(?P<timestamp>.*?)\]\s+"
r"(?P<level>\w+)\s+"
r"in\s+(?P<module>\w+)"
r":\s+(?P<message>.*)"
)
if log_line := pattern.match(line):
return Event(**log_line.groupdict())
else:
return None
我们的目标是按模块名称属性对日志消息进行分组。我们希望看到如下内容:
>>> pprint(summary)
{’module1’: [
Event(’2016-04-24 11:05:01,462’, ’INFO’, ’module1’, ’Sample Message One’),
Event(’2016-04-24 11:07:03,246’, ’WARNING’, ’module1’, ’Something might have gone wrong’)],
’module2’: [
Event(’2016-04-24 11:06:02,624’, ’DEBUG’, ’module2’, ’Debugging’)]
}
8.5.2 如何实现...
我们可以编写一个 summarize() 函数,将日志数据重新结构化为以下形式:
-
导入所需的模块和一些用于各种集合的类型提示:
from collections import defaultdict from collections.abc import Iterabledefaultdict 类型是一个扩展了 MutableMapping 抽象基类的具体类。这与 Iterable 类型提示所在的模块分开,后者是一个抽象基类定义。
-
源数据类型,事件类,在准备就绪部分已经展示。
-
定义我们将要工作的摘要字典的整体类型提示:
from typing import TypeAlias Summary: TypeAlias = defaultdict[str, list[Event]] -
开始定义一个函数,用于总结可迭代的事件实例源,并生成一个 Summary 对象:
def summarize(data: Iterable[Event]) -> Summary: -
将列表函数用作 defaultdict 的默认值。为这个集合创建一个类型提示也很有用:
module_details: Summary = defaultdict(list)列表函数仅提供名称。使用
list()的常见错误是评估函数并创建一个列表对象,该对象不是函数。错误消息如TypeError: first argument must be callable or None提醒我们,参数必须是函数名称。 -
遍历数据,将列表添加到每个键关联的列表中。
defaultdict对象将使用提供的list()函数来构建一个空列表,作为首次遇到每个新键时的值:for event in data: module_details[event.module].append(event) return module_details
summarize() 函数的结果是一个字典,它将模块名称字符串映射到该模块名称的所有日志行列表。数据看起来如下:
>>> pprint(summary)
defaultdict(<class ’list’>,
{’module1’: [Event(timestamp=’2016-04-24 11:05:01,462’, level=’INFO’, module=’module1’, message=’Sample Message One’),
Event(timestamp=’2016-04-24 11:07:03,246’, level=’WARNING’, module=’module1’, message=’Something might have gone wrong’)],
’module2’: [Event(timestamp=’2016-04-24 11:06:02,624’, level=’DEBUG’, module=’module2’, message=’Debugging’)]})
此映射的键是模块名称,映射中的值是该模块名称的行列表。我们现在可以专注于特定模块的分析。这似乎与总结结果的初始预期非常接近。
8.5.3 它是如何工作的...
当找不到键时,映射的行为有两种选择:
-
内置的
dict类在键缺失时抛出异常。这使得累积与预先不知道的键关联的值变得困难。 -
defaultdict类在键缺失时评估一个创建默认值的函数。在许多情况下,该函数是int或float,以创建默认的数值 0 或 0.0。在这种情况下,该函数是list,以创建一个空列表。
我们可以想象使用 set 函数而不是 list 来为缺失的键创建一个空的集合对象。这对于从键到共享该键的不可变对象集合的映射是合适的。
8.5.4 更多...
我们也可以将其作为一个扩展构建为内置的 dict 类:
class ModuleEvents(dict[str, list[Event]]):
def __missing__(self, key: str) -> list[Event]:
self[key] = list()
return self[key]
我们提供了一个特殊 __missing__() 方法的实现。默认行为是抛出一个 KeyError 异常。这将在映射中创建一个新的空列表。
这允许我们使用如下代码:
>>> event_iter = (Event.from_line(l) for l in log_data.splitlines())
>>> module_details = ModuleEvents()
>>> for event in filter(None, event_iter):
用例与 defaultdict 相同,但集合类的定义稍微复杂一些。这允许进一步扩展以向 ModuleEvents 类添加功能。
8.5.5 相关阅读
-
在第四章的 创建字典 – 插入和更新 菜谱中,我们探讨了使用映射的基础。
-
在第四章的 避免为函数参数使用可变默认值 菜谱中,我们探讨了其他使用默认值的地方。
-
在第七章的 使用更复杂的集合 菜谱中,我们探讨了使用
defaultdict类的其他示例。
8.6 创建具有可排序对象的类
我们经常需要能够按顺序排列的对象。以日志记录为例,它们通常按日期和时间排序。我们大多数类定义都没有包括将对象按顺序排序所需的特性。许多配方都保持对象基于由 __hash__() 方法计算的内联哈希值和由 __eq__() 方法定义的相等性测试的映射或集合。
为了保持项目在排序集合中,我们需要实现 <、>、<= 和 >= 的比较方法。这些比较都是基于每个对象的属性值。
当我们扩展 NamedTuple 类时,适用于元组类的比较方法都是可用的。如果我们使用 @dataclass 装饰器定义类,默认情况下不会提供比较方法。我们可以使用 @dataclass(order=True) 来包含排序方法。对于这个配方,我们将查看一个不基于这些辅助工具的类。
8.6.1 准备工作
在 通过多重继承分离关注点 的配方中,我们使用两个类定义来定义扑克牌。Card 类层次定义了每张牌的基本特性。第二组混合类为每张牌提供了特定于游戏的特性。
核心定义,Card,是一个冻结的数据类。它没有 order=True 参数,并且没有正确地将牌按顺序排列。我们需要向这个 Card 定义添加功能,以创建可以正确排序的对象。
我们将假设另一个类定义,PinochlePoints,以遵循为 Pinochle 游戏分配牌点的规则。细节不重要;重要的是这个类实现了 points() 方法。
为了创建一个可排序的牌集合,我们需要向 Card 类定义系列添加另一个特性。我们需要定义用于比较运算符的四个特殊方法。
8.6.2 如何做...
要创建一个可排序的类定义,我们将创建一个比较协议,然后定义一个实现该协议的类,如下所示:
-
我们正在定义一个新的协议,像 mypy 这样的工具可以在比较对象时使用。这将描述混合类将应用于哪些类型的对象。我们称之为
CardLike,因为它适用于具有至少排名和花色这两个属性的任何类:from typing import Protocol, Any class CardLike(Protocol): rank: int suit: str将类似-Something 作为协议名称的使用是 Pythonic 整体方法的一部分,即鸭子类型。而不是坚持类型层次结构,我们定义了作为新协议所需的最少特性。
-
扩展协议,我们可以创建具有比较特性的
SortableCard子类。这个子类可以混合到任何符合协议定义的类中:class SortableCard(CardLike): -
将四个排序比较方法添加到 SortableCard 子类中。在这种情况下,我们使用符合 CardLike 协议的任何类的相关属性到一个元组中,然后使用 Python 的内置元组比较来处理元组中项的比较细节。以下是这些方法:
def __lt__(self: CardLike, other: Any) -> bool: return (self.rank, self.suit) < (other.rank, other.suit) def __le__(self: CardLike, other: Any) -> bool: return (self.rank, self.suit) <= (other.rank, other.suit) def __gt__(self: CardLike, other: Any) -> bool: return (self.rank, self.suit) > (other.rank, other.suit) def __ge__(self: CardLike, other: Any) -> bool: return (self.rank, self.suit) >= (other.rank, other.suit) -
编写由基本 Card 类和两个混合类构建的复合类定义,以提供 Pinochle 和比较功能:
class PinochleAce(AceCard, SortableCard, PinochlePoints): pass class PinochleFace(FaceCard, SortableCard, PinochlePoints): pass class PinochleNumber(Card, SortableCard, PinochlePoints): pass -
这组类没有简单的超类。我们将添加一个类型提示来创建一个公共定义:
from typing import TypeAlias PinochleCard: TypeAlias = PinochleAce | PinochleFace | PinochleNumber -
现在我们可以创建一个函数,从之前定义的类中创建单个 PinochleCard 对象:
def make_pinochle_card(rank: int, suit: str) -> PinochleCard: if rank in (9, 10): return PinochleNumber(rank, suit) elif rank in (11, 12, 13): return PinochleFace(rank, suit) else: return PinochleAce(rank, suit)
Pinochle 的复杂点规则被封装在 PinochlePoints 类中。我们省略了它们,因为点数根本不与六种牌等级平行。将复合类作为 Card 和 PinochlePoints 的基子类构建,可以准确地对牌进行建模,而不会过于复杂。
我们现在可以使用以下交互命令序列来创建对比较运算符做出响应的牌:
>>> c1 = make_pinochle_card(9, ’’)
>>> c2 = make_pinochle_card(10, ’’)
>>> c1 < c2
True
>>> c1 == c1 # Cards match themselves
True
>>> c1 == c2
False
>>> c1 > c2
False
实现等于(==)和不等于(!=)的相等比较是在基类 Card 中定义的。这是一个冻结的数据类。默认情况下,数据类包含相等测试方法。
这是一个构建特殊 48 张牌组的函数。它创建了 24 种不同等级和花色组合的每张牌的两个副本:
def make_pinochle_deck() -> list[PinochleCard]:
return [
make_pinochle_card(r, s)
for _ in range(2)
for r in range(9, 15)
for s in SUITS
]
SUITS 变量的值是四种 Unicode 字符,代表花色。make_deck() 函数内部的生成表达式使用 Pinochle 游戏中的 6 个等级构建每张牌的两个副本。
8.6.3 它是如何工作的...
Python 为许多事物使用特殊方法。语言中几乎每个运算符都是由特殊方法实现的。(少数例外是 is 运算符和 and、or、not。)在这个配方中,我们利用了四个排序运算符。
表达式 c1 <= c2 被评估为如果我们写了 c1.le(c2)。这种转换几乎适用于 Python 的所有运算符。
Python 语言参考将特殊方法组织成几个不同的组。在这个配方中,我们查看用于基本定制类的那些方法。
当我们处理这个类层次结构的实例时,看起来是这样的。第一个例子将创建一个 48 张牌的 Pinochle 牌组:
>>> deck = make_pinochle_deck()
>>> len(deck)
48
>>> import random
>>> random.seed(4)
>>> random.shuffle(deck)
>>> [str(c) for c in sorted(deck[:12])]
[’ 9 ’, ’10 ’, ’ J ’, ’ J ’, ’ J ’, ’ Q ’, ’ Q ’, ’ K ’, ’ K ’, ’ K ’, ’ A ’, ’ A ’]
上面的例子中重要的部分是 sorted() 函数的使用。因为我们已经定义了适当的比较运算符,我们可以对 PinochleCard 实例进行排序,并且它们按照从低等级到高等级的预期顺序呈现。
8.6.4 更多...
一点形式逻辑表明,我们实际上只需要详细实现两种比较。从一个相等方法和一个排序方法,所有其他方法都可以构建。例如,如果我们为小于(lt())和等于(eq())构建操作,我们可以根据以下等价规则计算其他三个比较:
Python 明确不会为我们做这类高级代数运算。我们需要仔细进行代数运算并实现必要的比较方法。
functools 库包括一个装饰器,@total_ordering,它可以生成这些缺失的比较方法。
8.6.5 参见
-
参见通过多重继承分离关注点配方,了解卡片和卡牌游戏规则的必要定义。
-
参见第七章(ch011_split_000.xhtml#x1-3760007)了解更多关于数据类和命名元组类的信息。
8.7 从复杂对象列表中删除
从列表中删除项目有一个有趣的结果。具体来说,当一个项目被删除时,所有后续的项目都会向前移动。规则是这样的:
在删除项目 y 时,items list[y+1:] 取代了 items list[y:]。
这是在删除选定的项目之外发生的副作用。由于列表中的项目可能会移动,这使得一次删除多个项目具有潜在的挑战性。
当列表包含具有 eq() 特殊方法定义的项目时,列表的 remove() 方法可以删除每个项目。当列表项目没有简单的 eq() 测试时,remove() 方法不起作用,这使得从列表中删除多个项目更具挑战性。
8.7.1 准备工作
对于这个例子,我们将使用字典列表,其中删除项目的天真方法不起作用。看到尝试重复搜索列表以删除项目可能会出错是有帮助的。
在这种情况下,我们有一些包含歌曲名称、作家和持续时间的资料。字典对象相当长。数据看起来像这样:
>>> song_list = [
... {’title’: ’Eruption’, ’writer’: [’Emerson’], ’time’: ’2:43’},
... {’title’: ’Stones of Years’, ’writer’: [’Emerson’, ’Lake’], ’time’: ’3:43’},
... {’title’: ’Iconoclast’, ’writer’: [’Emerson’], ’time’: ’1:16’},
... {’title’: ’Mass’, ’writer’: [’Emerson’, ’Lake’], ’time’: ’3:09’},
... {’title’: ’Manticore’, ’writer’: [’Emerson’], ’time’: ’1:49’},
... {’title’: ’Battlefield’, ’writer’: [’Lake’], ’time’: ’3:57’},
... {’title’: ’Aquatarkus’, ’writer’: [’Emerson’], ’time’: ’3:54’}
... ]
可以用以下方式定义这个复杂结构的每一行的类型提示:
from typing import TypedDict
class SongType(TypedDict):
title: str
writer: list[str]
time: str
一个更好的设计会使用 datetime.timedelta 来表示歌曲的时间。我们在配方中省略了这个复杂性。
整个歌曲列表可以描述为 list[SongType]。
这里有一个天真且明显无效的方法:
def naive_delete(data: list[SongType], writer: str) -> None:
for index in range(len(data)):
if ’Lake’ in data[index][’writer’]:
del data[index]
由于项目被移动,计算出的索引值会跳过刚被删除的项目之后的那个项目。此外,由于列表变短,删除后的范围是错误的。这会导致索引错误,如下面的输出所示:
>>> naive_delete(song_list, ’Lake’)
Traceback (most recent call last):
...
IndexError: list index out of range
另一种失败的方法看起来是这样的:
>>> remove = list(filter(lambda x: ’Lake’ in x[’writer’], song_list))
>>> for x in remove:
... song_list.remove(x)
这有一个问题,即每个 remove() 操作都必须从列表的开始搜索。对于非常大的列表,这种方法会很慢。
我们需要以避免多次遍历列表的方式组合搜索和删除操作。
8.7.2 如何做...
为了有效地从列表中删除多个项目,我们需要实现自己的列表索引处理函数,如下所示:
-
定义一个函数,通过删除选定的项目来更新列表对象:
def incremental_delete( data: list[SongType], writer: str ) -> None: -
初始化一个索引值 i,从列表的第一个项目开始为零:
i = 0 -
当 i 变量的值不等于列表的长度时,我们希望对状态进行更改,要么增加 i 值,要么缩小列表:
while i != len(data): -
如果 data[i] 的值是搜索的目标,我们可以删除它,缩小列表。
否则,增加索引值 i,使其更接近列表的长度:
if ’Lake’ in data[i][’writer’]: del data[i] else: i += 1
这导致从列表中删除项目时不会出现索引错误,也不会多次遍历列表项,或者无法删除匹配的项目。
8.7.3 它是如何工作的...
目标是检查每个项目恰好一次,要么删除它,要么跳过它,将其留在原位。while 语句的设计源于观察那些朝着目标前进的语句:增加索引,删除一个项目。这些操作在有限的一组条件下有效:
-
仅当项目不应该被删除时,增加索引才有效。
-
仅当项目是匹配项时,删除项目才有效。
重要的是条件是互斥的。当我们使用 for 语句时,增量处理总是发生,这是一个不希望的特性。while 语句允许我们仅在项目应该留在原位时才进行增量。
8.7.4 更多内容...
相对于删除的开销,另一种方法是创建一个新列表,其中一些项目被拒绝。制作项目浅拷贝比从列表中删除项目快得多,但需要更多的存储空间。这是一个常见的关于时间和内存权衡的例子。
我们可以使用如下列表推导来创建一个只包含所需项目的新的列表:
>>> [item
... for item in song_list
... if ’Lake’ not in item[’writer’]
... ]
这将创建列表中选定项目的浅拷贝。我们不希望保留的项目将被忽略。有关浅拷贝的概念的更多信息,请参阅第四章中的“制作浅拷贝和深拷贝对象”配方。
我们也可以在复制操作中使用一个高阶函数,filter()。考虑以下示例:
>>> list(
... filter(
... lambda item: ’Lake’ not in item[’writer’],
... song_list
... )
... )
filter() 函数有两个参数:一个 lambda 对象和原始数据集。在这种情况下,lambda 表达式用于决定哪些项目要传递。lambda 返回 False 的项目将被拒绝。
filter() 函数是一个生成器。这意味着我们需要收集所有项目以创建一个最终的列表对象。list() 函数是消耗生成器中所有项目的一种方式,将它们存储在它们创建并返回的集合对象中。
8.7.5 参考内容
-
我们已经利用了两个其他配方:制作浅拷贝和深拷贝对象 和 切片和切块列表,它们在第四章中。
-
我们将在第九章节中仔细研究过滤器和生成器表达式。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第九章:9
函数式编程特性
函数式编程的思想是专注于编写小而表达性强的函数,以执行所需的数据转换。函数的组合通常可以创建比长串过程语句或复杂有状态对象的方法定义更简洁、更具有表现力的代码。本章重点介绍 Python 的函数式编程特性,而不是过程式或面向对象编程。
这提供了一种与本书其他地方严格面向对象的方法不同的软件设计途径。将对象与函数的组合允许在组装最佳组件集合时具有灵活性。
传统数学将许多事物定义为函数。多个函数可以组合起来,从之前的转换构建复杂的结果。当我们把数学运算符视为函数时,表达式 p = f(n,g(n))也可以写成两个单独的函数。我们可能会将其视为 p = f(n,b),其中 b = g(n)。
理想情况下,我们还可以从这两个函数创建一个复合函数:

定义一个新的复合函数 g ∘ f,而不是嵌套函数,可以帮助阐明设计背后的意图。这种对组件的重构可以让我们将许多小的细节组合成更大的知识块,体现设计背后的概念。
由于编程通常与数据集合一起工作,我们经常会将函数应用到集合的所有项目上。这发生在进行数据库提取和转换以对齐来自不同源应用程序的数据时。这也发生在汇总数据时。将 CSV 文件转换为统计摘要这样常见的事情,就是从文本行到数据行的转换函数的组合,以及从数据行到平均值和标准差的转换。这与集合构造者或集合理解数学思想非常吻合。
应用一个函数到一组数据的三种常见模式:
-
映射:这将对集合的所有元素应用一个函数,{m(x)|x ∈ S}。我们将某个函数 m(x)应用到较大的集合 S 中的每个项目 x 上。
-
过滤:这使用一个函数从集合中选择元素,{x|x ∈ S if f(x)}。我们使用一个函数,f(x),来确定是否将较大的集合 S 中的每个项目 x 通过或拒绝。
-
减少:这总结了集合中的项目。最常见的一种减少是将集合 S 中所有项目求和,表示为 ∑ [x∈S]x。其他常见的减少还包括找到最小项、最大项以及所有项的乘积。
我们经常将这些模式结合起来创建更复杂的复合应用程序。这里重要的是,像 m(x)和 f(x)这样的小函数可以通过内置的高阶函数如 map()、filter()和 reduce()进行组合。itertools 模块包含许多额外的更高阶函数,我们可以使用它们来构建应用程序。当然,我们也可以定义我们自己的高阶函数来组合较小的函数。
这些食谱中的一些将展示可以定义为使用@property 装饰器创建的类定义属性的运算。这是另一种可以限制有状态对象复杂性的设计选择。然而,在本章中,我们将尝试坚持一种函数式方法,即通过转换来创建新对象,而不是使用属性。
在本章中,我们将探讨以下食谱:
-
使用 yield 语句编写生成器函数
-
对集合应用转换
-
使用堆叠的生成器表达式
-
选择子集 – 三种过滤方式
-
总结集合 – 如何减少
-
组合 map 和 reduce 转换
-
实现“存在”处理
-
创建部分函数
-
使用 yield from 语句编写递归生成器函数
我们将从创建函数开始,这些函数将产生一个可迭代的值序列。而不是创建一个完整的列表(或集合,或其他集合),生成器函数根据客户端操作的需求产生集合的各个项目。这节省了内存,并且可能节省时间。
9.1 使用 yield 语句编写生成器函数
生成器函数通常被设计为对集合中的每个项目应用某种类型的转换。生成器也可以创建数据。生成器被称为懒惰的,因为其产生的值必须由客户端消耗;值只有在客户端尝试消耗它们时才会被计算。客户端操作,如 list()函数或 for 语句,是常见的消费者示例。每次像 list()这样的函数需要值时,生成器函数必须使用 yield 语句产生一个值。
相反,一个普通函数可以被调用为急切模式。没有 yield 语句,函数将计算整个结果并通过 return 语句返回。
在我们无法将整个集合放入内存的情况下,一种懒惰的方法非常有帮助。例如,分析巨大的网络日志文件可以通过小批量进行,而不是创建一个庞大的内存集合。
在 Python 的类型提示语言中,我们经常使用 Iterator 泛型来描述生成器。我们需要使用类型来澄清这个泛型,例如 Iterator[str],以表明函数产生字符串对象。
生成器正在消耗的项目通常来自由 Iterable 泛型类型描述的集合。所有 Python 的内置集合都是可迭代的,文件也是如此。例如,字符串值的列表可以被视为 Iterable[str]。
从 collections.abc 模块中可以获得 Iterable 和 Iterator 类型。它们也可以从 typing 模块中导入。
yield 语句将普通函数转换为生成器。它将迭代计算并产生结果。
9.1.1 准备工作
我们将对一些网络日志数据进行生成器应用。我们将设计一个生成器,将原始文本转换为更有用的结构化对象。生成器函数用于隔离转换处理。这允许在初始转换之后灵活地应用过滤或汇总操作。
条目最初是看起来像这样的文本行:
[2016-06-15 17:57:54,715] INFO in ch10_r10: Sample Message One
[2016-06-15 17:57:54,716] DEBUG in ch10_r10: Debugging
[2016-06-15 17:57:54,720] WARNING in ch10_r10: Something might have gone wrong
我们在第八章的使用更复杂结构 - 列表映射食谱中看到了使用这种日志的其他示例。使用第一章的使用正则表达式进行字符串解析食谱中的正则表达式,我们可以将每一行分解成更有用的结构。
在日志的每一行中捕获详细信息,并将其存储在具有不同类型的对象中通常很有帮助。这有助于使代码更加专注,并有助于我们使用 mypy 工具来确认类型被正确使用。以下是一个 NamedTuple 类定义:
from typing import NamedTuple
class RawLog(NamedTuple):
date: str
level: str
module: str
message: str
我们将从将字符串类型的可迭代源代码转换为字段元组的迭代器开始。之后,我们将再次应用该食谱,将日期属性从字符串转换为有用的日期时间对象。
9.1.2 如何做...
生成器函数是一个函数,所以食谱与第三章中显示的类似。我们首先定义函数,如下所示:
-
从 collections.abc 模块导入所需的类型提示。导入 re 模块以解析日志文件的行:
import re from collections.abc import Iterable, Iterator -
定义一个遍历 RawLog 对象的函数。在函数名称中包含 _iter 可以帮助强调结果是迭代器,而不是单个值。参数是日志行的可迭代源:
def parse_line_iter( source: Iterable[str] ) -> Iterator[RawLog]: -
parse_line_iter() 转换函数依赖于正则表达式来分解每一行。我们可以在函数内部定义它,以保持它与处理的其他部分的紧密绑定:
pattern = re.compile( r"\[(?P<date>.*?)\]\s+" r"(?P<level>\w+)\s+" r"in\s+(?P<module>.+?)" r":\s+(?P<message>.+)", re.X ) -
for 语句将消耗可迭代源代码的每一行,使我们能够单独创建并产生每个 RawLog 对象:
for line in source: -
for 语句的主体可以使用匹配组将每个与模式匹配的字符串实例映射到一个新的 RawLog 对象:
if match := pattern.match(line): yield RawLog(*match.groups())不匹配的行将被静默丢弃。在大多数情况下,这似乎是合理的,因为日志可以充满来自各种来源的消息。
没有 yield 语句的函数是“普通”的,并计算一个单一的结果。
这是如何使用这个函数从上面显示的样本数据中发出一系列 RawLog 实例的:
>>> from pprint import pprint
>>> for item in parse_line_iter(log_lines):
... pprint(item)
RawLog(date=’2016-04-24 11:05:01,462’, level=’INFO’, module=’module1’, message=’Sample Message One’)
RawLog(date=’2016-04-24 11:06:02,624’, level=’DEBUG’, module=’module2’, message=’Debugging’)
RawLog(date=’2016-04-24 11:07:03,246’, level=’WARNING’, module=’module1’, message=’Something might have gone wrong’)
我们也可以使用类似的方法将项目收集到一个列表对象中:
>>> details = list(parse_line_iter(log_lines))
在这个例子中,list()函数消耗了 parse_line_iter()函数产生的所有项目。生成器是一个相对被动的结构:直到需要数据,它不会做任何工作。
9.1.3 它是如何工作的...
Python 的每个内置集合类型都实现了一个特殊方法,iter(),用于生成一个迭代器对象。迭代器对象实现了 next()特殊方法,用于返回一个项目并推进迭代器的状态以返回下一个项目。这就是迭代器协议。内置的 next()函数评估迭代器对象的这个方法。
虽然 Python 内置的 collections 可以创建 Iterator 对象,但生成器函数也实现了这个协议。生成器在响应 iter()函数时会返回自身。在响应 next()函数时,生成器在 yield 语句处暂停执行,并提供一个值,该值成为 next()函数的结果。由于函数被暂停,它可以在另一个 next()函数被评估时恢复。
要了解 yield 语句是如何工作的,请看这个小型函数,它产生两个对象:
test_example_4_3 = """
>>> def gen_func():
... print("pre-yield")
... yield 1
... print("post-yield")
当我们在生成器上评估 next()函数时,会发生以下情况:
>>> y = gen_func()
>>> next(y)
pre-yield
1
>>> next(y)
post-yield
第一次评估 next()函数时,第一个 print()函数被评估,然后 yield 语句产生了一个值。
使用 next()函数恢复处理,并在两个 yield 语句之间的语句被评估。
接下来会发生什么?由于函数体中没有更多的 yield 语句,所以我们观察到以下情况:
>>> next(y)
Traceback (most recent call last):
...
在生成器函数的末尾会抛出 StopIteration 异常。这是 for 语句处理过程中预期到的。它被静默地吸收以跳出处理。
如果我们不使用像 list()或 for 语句这样的函数来消耗数据,我们会看到类似以下的内容:
>>> parse_line_iter(data)
<generator object parse_line_iter at ...>
评估 parse_line_iter()函数返回的值是一个生成器。它不是一个项目集合,而是一个将按需从消费者那里产生项目,一次一个的对象。
9.1.4 更多内容...
我们可以将这个方法应用到转换每个 RawLog 对象中的日期属性。每行更精细的数据类型将遵循以下类定义:
import datetime
from typing import NamedTuple
class DatedLog(NamedTuple):
date: datetime.datetime
level: str
module: str
message: str
这有一个更有用的 datetime.datetime 对象作为时间戳。其他字段保持为字符串。
这是一个生成器函数——使用 for 语句和 yield 关键字,使其成为一个迭代器——用于将每个 RawLog 对象精炼成 DatedLog 对象:
def parse_date_iter(
source: Iterable[RawLog]
) -> Iterator[DatedLog]:
for item in source:
date = datetime.datetime.strptime(
item.date, "%Y-%m-%d %H:%M:%S,%f"
)
yield DatedLog(
date, item.level, item.module, item.message
)
将整体处理分解成小的生成器函数提供了几个显著的优势。首先,分解使得每个函数更加简洁,因为它专注于特定的任务。这使得这些函数更容易设计、测试和维护。其次,它使得整体组合更能够表达所做的工作。
我们可以将这两个生成器以以下方式组合:
>>> for item in parse_date_iter(parse_line_iter(log_lines)):
... print(item)
DatedLog(date=datetime.datetime(2016, 4, 24, 11, 5, 1, 462000), level=’INFO’, module=’module1’, message=’Sample Message One’)
DatedLog(date=datetime.datetime(2016, 4, 24, 11, 6, 2, 624000), level=’DEBUG’, module=’module2’, message=’Debugging’)
DatedLog(date=datetime.datetime(2016, 4, 24, 11, 7, 3, 246000), level=’WARNING’, module=’module1’, message=’Something might have gone wrong’)
parse_line_iter()函数将消耗源数据中的行,当消费者需要时创建 RawLog 对象。parse_date_iter()函数是 RawLog 对象的消费者;从这些对象中,当消费者需要时,它创建 DatedLog 对象。外部的 for 语句是最终消费者,需要 DatedLog 对象。
在任何时候,内存中都不会存在大量中间对象。这些函数中的每一个都只处理一个对象,从而限制了内存的使用量。
9.1.5 参见
-
在使用堆叠生成器表达式的菜谱中,我们将结合生成器函数,从简单组件构建复杂处理堆栈。
-
在将转换应用于集合的菜谱中,我们将看到如何使用内置的 map()函数从简单的函数和可迭代的数据源创建复杂处理。
-
在选择子集 - 三种过滤方式的菜谱中,我们将看到如何使用内置的 filter()函数也从简单的函数和可迭代的数据源构建复杂处理。
9.2 将转换应用于集合
我们经常定义生成器函数,目的是将函数应用于数据项的集合。生成器与集合的交互有几种方式。
在本章的使用 yield 语句编写生成器函数的菜谱中,我们创建了一个生成器函数,将数据从字符串转换成更复杂的对象。
生成器函数具有共同的架构,通常看起来是这样的:
def new_item_iter(source: Iterable[X]) -> Iterator[Y]:
for item in source:
new_item: Y = some_transformation(item)
yield new_item
yield 语句意味着结果将迭代生成。函数的类型提示强调它从源集合中消费项目。这个编写生成器函数的模板暴露了一个常见的设计模式。
从数学上讲,我们可以这样总结:

新集合 N 是对源 S 中的每个项目 x 应用转换 m(x)的结果。这强调了转换函数 m(x),将其与消费源和产生结果的细节分开。在之前显示的 Python 示例中,这个函数被调用为 some_transformation()。
这个数学总结表明,for 语句可以被理解为围绕转换函数的一种支架。这种支架可以有两种额外的形式。我们可以写一个生成器表达式,或者我们可以使用内置的 map()函数。这个配方将检查所有三种技术。
9.2.1 准备工作
我们将查看使用 yield 语句编写生成器函数配方中的网络日志数据。这些数据中的日期是以字符串形式表示的,我们希望将其转换为合适的 datetime 对象,以便用于后续的计算。我们将利用之前配方中的 DatedLog 类定义。
使用 yield 语句编写生成器函数的配方使用了如下示例中的生成器函数,将一系列 RawLog 对象转换为一个更有用的 DatedLog 实例迭代器:
import datetime
from recipe_01 import RawLog, DatedLog
def parse_date_iter(
source: Iterable[RawLog]
) -> Iterator[DatedLog]:
for item in source:
date = datetime.datetime.strptime(
item.date, "%Y-%m-%d %H:%M:%S,%f"
)
yield DatedLog(
date, item.level, item.module, item.message
)
这个 parse_date_iter()函数在有趣的功能周围有大量的支架代码。for 和 yield 语句是支架的例子。另一方面,日期解析是函数中独特、有趣的部分。我们需要提取这个独特的处理过程,以便使用更灵活的支架。
9.2.2 如何实现...
为了使用不同的生成器函数应用方法,我们需要首先重构原始的 parse_date_iter()函数。这将提取一个 parse_date()函数,可以以多种方式使用。在此初始步骤之后,我们将展示三个独立的迷你配方,用于使用重构后的代码。
重构迭代器以定义一个函数,该函数可以将源类型的项目转换为结果类型的项目:
def parse_date(item: RawLog) -> DatedLog:
date = datetime.datetime.strptime(
item.date, "%Y-%m-%d %H:%M:%S,%f")
return DatedLog(
date, item.level, item.module, item.message)
这种转换可以通过三种方式应用于数据集合:生成器函数、生成器表达式和通过 map()函数。我们将首先重建原始生成器。
使用 for 和 yield 语句
我们可以使用 for 和 yield 语句将单行 parse_date()转换函数应用于集合中的每个项目。这在前面的使用 yield 语句编写生成器函数配方中已经展示过。下面是这个例子:
def parse_date_iter_y(
source: Iterable[RawLog]
) -> Iterator[DatedLog]:
for item in source:
yield parse_date(item)
使用生成器表达式
我们可以使用生成器表达式将 parse_date()函数应用于集合中的每个项目。生成器表达式包括两个部分——映射函数和一个 for 子句——由括号()包围。这遵循了第四章中构建列表——字面量、追加和推导式配方中的模式:
-
写出包围生成器的括号()。
-
为数据源编写一个 for 子句,将每个项目分配给一个变量,在这个例子中是 item:
(... for item in source) -
在 for 子句前加上映射函数,应用于变量:
(parse_date(item) for item in source) -
该表达式可以是函数的返回值,该函数为源和结果表达式提供合适的类型提示。以下是整个函数,因为它非常小:
def parse_date_iter_g( source: Iterable[RawLog] ) -> Iterator[DatedLog]: return (parse_date(item) for item in source)该函数返回一个生成器表达式,它将 parse_date() 函数应用于源可迭代中的每个项目。
是的,这个函数很小,似乎不需要 def 语句和名称的开销。在某些情况下,类型提示可能会有所帮助,这使得这是一个合理的选择。
使用 map() 函数
我们可以使用 map() 内置函数将 parse_date() 函数应用于集合中的每个项目:
-
使用 map() 函数将转换应用于源数据:
map(parse_date, source) -
该表达式可以是函数的返回值,该函数为源和结果表达式提供合适的类型提示。以下是整个函数,因为它非常小:
def parse_date_iter_m( source: Iterable[RawLog] ) -> Iterator[DatedLog]: return map(parse_date, source)
map() 函数是一个迭代器,它将 parse_date() 函数应用于源可迭代中的每个项目。它产生由 parse_date() 函数创建的对象。
重要的是要注意,parse_date 名称不带 () 是对函数对象的引用。
认为函数必须被评估,并包含额外的、不必要的 () 的使用是一个常见的错误。
这三种技术是等效的。
9.2.3 它是如何工作的...
map() 函数替换了一些常见的代码,这些代码在处理周围充当脚手架。它执行 for 语句的工作。它将给定的函数应用于源可迭代中的每个项目。
我们可以如下定义我们自己的 map() 版本:
def my_map2(f: Callable[[P], Q], source: Iterable[P]) -> Iterator[Q]:
return (f(item) for item in source)
正如我们所见,它们的行为是相同的。不同的代码受众可能有不同的偏好。我们提供的指导是选择使代码的意义和意图对阅读代码的受众最清晰的风格。
9.2.4 更多...
在这个例子中,我们使用了 map() 函数将一个函数应用于单个可迭代集合中的每个项目。结果证明,map() 函数可以做得更多。map() 函数可以处理多个序列。
考虑这个函数和这两个数据源:
>>> def mul(a, b):
... return a * b
>>> list_1 = [2, 3, 5, 7]
>>> list_2 = [11, 13, 17, 23]
我们可以将 mul() 函数应用于从每个数据源中抽取的配对序列:
>>> list(map(mul, list_1, list_2))
[22, 39, 85, 161]
这使我们能够使用从序列中提取的参数值的不同类型的操作合并几个值序列。
9.2.5 参见
- 在本章后面的 使用堆叠的生成器表达式 菜单中,我们将查看堆叠生成器。我们将从多个单个映射操作构建一个复合函数,这些操作以各种类型的生成器函数的形式编写。
9.3 使用堆叠的生成器表达式
在本章前面的使用 yield 语句编写生成器函数配方中,我们创建了一个简单的生成器函数,它对数据执行单个转换。作为一个实际问题,我们经常希望对传入的数据应用几个函数。
我们如何堆叠或组合多个生成器函数以创建一个复合函数?
9.3.1 准备工作
此配方将对源数据应用几种不同的转换。将有三行重组以合并为单行,数据转换将源字符串转换为有用的数字或日期时间戳,以及过滤掉无用的行。
我们有一个电子表格,用于记录大型帆船的燃料消耗。
有关此数据的详细信息,请参阅第四章中的切片和切块列表配方。我们将在第十一章中的使用 CSV 模块读取分隔文件配方中更详细地查看解析。
我们希望对列表-of-lists-of-strings 对象的每一行级别的列表应用多个转换:
-
排除数据中存在的三行标题(以及任何空白行)。
-
将三个物理文本行合并为一个逻辑数据行。
-
将分隔的日期和时间字符串转换为日期时间对象。
-
将燃料高度从字符串转换为浮点数,理想情况下以加仑(或升)为单位,而不是英寸。
我们的目标是创建一组生成器函数。假设我们已经将生成器函数的结果分配给一个变量,datetime_gen,这些转换允许我们拥有如下所示的软件:
>>> total_time = datetime.timedelta(0)
>>> total_fuel = 0
>>> for row in datetime_gen:
... total_time += row.engine_off - row.engine_on
... total_fuel += (
... float(row.engine_on_fuel_height) -
... float(row.engine_off_fuel_height)
... )
>>> print(
... f"{total_time.total_seconds()/60/60 = :.2f}, "
... f"{total_fuel = :.2f}")
我们需要设计一个复合函数来创建这个 datetime_gen 生成器。
9.3.2 如何实现...
我们将将其分解为三个独立的迷你配方:
-
重新排列行。
-
排除标题行。
-
创建更有用的行对象。
我们将从将三个物理行重组为一个逻辑行开始。
重新排列行
我们将首先创建一个 row_merge()函数来重新排列数据:
-
我们将使用命名元组来定义组合逻辑行的类型:
from typing import NamedTuple class CombinedRow(NamedTuple): # Line 1 date: str engine_on_time: str engine_on_fuel_height: str # Line 2 filler_1: str engine_off_time: str engine_off_fuel_height: str # Line 3 filler_2: str other_notes: str filler_3: str原始数据有空单元格;我们称它们为 filler_1、filler_2 和 filler_3。保留这些垃圾列可以更容易地调试问题。
-
CSV 读取器创建的源行将具有 list[str]类型;我们将为此类型提供一个别名,RawRow。函数的定义将接受 RawRow 实例的可迭代对象。它是一个 CombinedRow 对象的迭代器:
from typing import TypeAlias from collections.abc import Iterable, Iterator RawRow: TypeAlias = list[str] def row_merge( source: Iterable[RawRow] ) -> Iterator[CombinedRow]: -
函数的主体将消耗源迭代器中的行,跳过空行,构建一个定义 CombinedRow 对象的簇。当第一列非空时,任何之前的簇就完成了,它被产出,然后开始一个新的簇。最后一个簇也需要被产出:
cluster: RawRow = [] for row in source: if all(len(col) == 0 for col in row): continue elif len(row[0]) != 0: # Non-empty column 1: line 1 if len(cluster) == 9: yield CombinedRow(*cluster) cluster = row.copy() else: # Empty column 1: line 2 or line 3 cluster.extend(row) if len(cluster) == 9: yield CombinedRow(*cluster)
这个初始转换可以用来将 CSV 单元格值的行序列转换为 CombinedRow 对象,其中每个来自三个不同行的字段值都有它们自己的独特属性:
这个转换的第一个输出行将是标题行。下一部分是一个函数来删除这一行:
排除标题行
源 CSV 文件的前三行文本将创建一个不太有用的 CombinedRow 对象。我们将排除一个带有标签而不是数据的行:
-
定义一个函数来处理一个由 CombinedRow 对象组成的可迭代集合,创建一个 CombinedRow 对象的迭代器:
def skip_header_date( source: Iterable[CombinedRow] ) -> Iterator[CombinedRow]: -
函数体消费源数据中的每一行,并产出好的行。它使用 continue 语句来拒绝不想要的行:
for row in source: if row.date == "date": continue yield row
这可以与之前配方中显示的 row_merge()函数结合使用,以提供良好数据的迭代器:
要使合并的数据真正有用,需要几个转换步骤。接下来,我们将查看其中之一,创建正确的 datetime.datetime 对象:
创建更有用的行对象
每行中的日期和时间作为单独的字符串并不很有用。我们将编写的函数可以比这个配方中前两个步骤有稍微不同的形式,因为它适用于每个单独的行。单行转换看起来像这样:
-
定义一个新的 NamedTuple 类,指定一个对时间值更有用的类型:
import datetime from typing import NamedTuple class DatetimeRow(NamedTuple): date: datetime.date engine_on: datetime.datetime engine_on_fuel_height: str engine_off: datetime.datetime engine_off_fuel_height: str other_notes: str -
定义一个映射函数,将一个 CombinedRow 实例转换为一个单一的 DatetimeRow 实例:
def convert_datetime(row: CombinedRow) -> DatetimeRow: -
这个函数的主体将执行多个日期时间计算并创建一个新的 DatetimeRow 实例:
travel_date = datetime.datetime.strptime( row.date, "%m/%d/%y").date() start_time = datetime.datetime.strptime( row.engine_on_time, "%I:%M:%S %p").time() start_datetime = datetime.datetime.combine( travel_date, start_time) end_time = datetime.datetime.strptime( row.engine_off_time, "%I:%M:%S %p").time() end_datetime = datetime.datetime.combine( travel_date, end_time) return DatetimeRow( date=travel_date, engine_on=start_datetime, engine_off=end_datetime, engine_on_fuel_height=row.engine_on_fuel_height, engine_off_fuel_height=row.engine_off_fuel_height, other_notes=row.other_notes )
现在我们可以堆叠转换函数来合并行、排除标题行并执行日期时间转换。处理过程如下:
>>> row_gen = row_merge(log_rows)
>>> tail_gen = skip_header_date(row_gen)
>>> datetime_gen = (convert_datetime(row) for row in tail_gen)
我们已经将重新格式化、过滤和转换问题分解为三个单独的函数。这三个步骤中的每一个都只完成整体工作的一个小部分。我们可以单独测试这三个函数。比能够测试更重要的是能够修复或修改一个步骤,而不会完全破坏整个转换堆栈:
9.3.3 它是如何工作的...
当我们编写一个生成器函数时,参数值可以是一个项目集合,或者它可以是一个任何其他类型的可迭代项目源。由于生成器函数是迭代器,因此可以通过堆叠它们来创建一个生成器函数的管道。一个生成器的结果成为堆叠中下一个生成器的输入:
由这个配方创建的 datetime_gen 对象是由三个独立的生成器组成的组合。一个 for 语句可以从 datetime_gen 生成器表达式收集值。该语句的主体可以打印详细信息并计算正在生成的对象的摘要:
此设计强调每个阶段的微小、增量操作。管道的某些阶段将消耗多个源行以生成单个结果行,在处理过程中重新构建数据。其他阶段则消耗并转换单行,这使得它们可以通过生成器表达式来描述。
整个管道由客户端的需求驱动。请注意,此处理中没有并发。每个函数在 yield 语句处“暂停”,直到客户端通过内置的 next()函数要求更多数据。
最重要的是,可以单独调试和测试各个转换步骤。这种分解有助于创建更健壮和可靠的软件。
9.3.4 更多内容...
为了使这些数据变得有用,需要进行一些其他转换。我们希望将开始和结束时间戳转换为持续时间。我们还需要将燃油高度值转换为浮点数,而不是字符串。
我们有几种方法来处理这些派生数据计算:
-
我们可以在我们的生成器函数堆栈中创建额外的转换步骤。这反映了急切计算方法。
-
我们还可以在类定义中添加@property 方法。这是一种惰性计算;只有在需要属性值时才会执行。
为了急切地计算额外的燃油高度和体积值,我们可以再次应用设计模式。首先,定义具有所需字段的额外命名元组类。然后,定义一个转换函数,将高度从字符串转换为浮点数。还要定义一个将高度从英寸转换为加仑的转换。这些额外的函数将是小型且易于测试的。
我们现在有一个复杂的计算,它由多个小型(几乎)完全独立的块定义。每个函数只执行创建一行所需的工作,将开销保持在最低。我们可以修改一个部分,而不必深入思考其他部分的工作方式。
9.3.5 参见
-
请参阅使用 yield 语句编写生成器函数的说明以了解生成器函数的简介。
-
请参阅第四章中的切片和切块列表说明,以获取有关燃油消耗数据集的更多信息。
-
请参阅组合 map 和 reduce 转换说明,了解另一种组合操作的方法。
-
选择子集的三种过滤方法说明详细介绍了过滤函数。
9.4 选择子集 – 三种过滤方法
选择相关行的子集可以称为过滤数据集合。我们可以将过滤视为拒绝不良行或包含期望行。有几种方法可以将过滤函数应用于数据项集合。
在使用堆叠生成器表达式的配方中,我们编写了 skip_header_date()生成器函数来排除一组数据中的某些行。skip_header_date()函数结合了两个元素:一个用于通过或拒绝项的规则,以及数据源。这个生成器函数有一个通用的模式,如下所示:
from collections.abc import Iterable, Iterator
from typing import TypeVar
T = TypeVar("T")
def data_filter_iter(
source: Iterable[T]
) -> Iterator[T]:
for item in source:
if should_be_passed(item):
yield item
这个 data_filter_iter()函数的类型提示强调它是一个可迭代的,从可迭代的源集合中消费类型为 T 的项。对每个项应用一些表达式以确定其是否有效。这个表达式可以定义为单独的函数。我们可以定义相当复杂的过滤器。
设计模式可以总结如下:

新集合 N 是源 S 中的每个项 x,其中过滤器函数 f(x)为真。这个总结强调了过滤器函数 f(x),将其与消耗源和产生结果的技术细节区分开来。
这个数学总结表明 for 语句几乎只是脚手架代码。因为它不如过滤规则重要,所以它可以帮助重构生成器函数并从其他处理中提取过滤功能。
将 for 语句视为脚手架,我们还能如何将过滤器应用于集合中的每个项?我们可以使用两种额外的技术:
-
我们可以编写一个生成器表达式。
-
我们可以使用内置的 filter()函数。
这两个都需要重构生成器函数——skip_header_date(),如前所述,在使用堆叠生成器表达式的配方中——以提取决策表达式,使其与周围的 for 和 if 脚手架分离。从这个函数中,我们然后可以转向创建生成器表达式,并使用 filter()函数。
9.4.1 准备工作
在这个配方中,我们将查看本章使用堆叠生成器表达式配方中的燃料消耗数据。关于这些数据的详细信息,请参阅第四章中的切片和切块列表配方。
我们使用了两个生成器函数。第一个,row_merge(),将物理行重新组织成逻辑行。使用命名元组 CombinedRow 为行数据提供了更有用的结构。第二个生成器函数 skip_header_date()拒绝了数据表中的标题行,传递了有用的数据行。
我们将重写 skip_header_date()函数来展示三种不同的提取有用数据的方法。
9.4.2 如何做到...
本配方的第一部分将把“良好数据”规则从生成器函数中重构出来,使其更具有通用性。
-
从以下大纲开始编写生成器函数的草稿版本:
def skip_header_date( source: Iterable[CombinedRow] ) -> Iterator[CombinedRow]: for row in source: if row.date == "date": continue yield row -
if 语句中的表达式可以被重构为一个函数,该函数可以应用于数据的一行,产生一个 bool 值:
def pass_non_date(row: CombinedRow) -> bool: return row.date != "date" -
原始的生成器函数现在可以简化:
def skip_header_date_iter( source: Iterable[CombinedRow] ) -> Iterator[CombinedRow]: for item in source: if pass_non_date(item): yield item
pass_non_date()函数可以用三种方式使用。如所示,它可以由一个生成器函数使用。它也可以用在生成器表达式中,以及与 filter()函数一起使用。接下来,我们将看看如何编写一个表达式。
在生成器表达式中使用 filter
生成器表达式包括三个部分——项目、一个 for 子句和一个 if 子句——所有这些都包含在括号()中。
-
从一个 for 子句开始,将对象分配给一个变量。这个源来自某个可迭代的集合,在这个例子中称为 source:
(... for item in source) -
因为这是一个过滤器,结果表达式应该是 for 子句中的变量:
(item for item in source) -
使用 filter 规则函数 pass_non_date()编写一个 if 子句。
(item for item in source if pass_non_date(source)) -
这个生成器表达式可以是一个函数的返回值,该函数为源表达式和结果表达式提供了合适的类型提示。以下是整个函数,因为它非常小:
def skip_header_gen( source: Iterable[CombinedRow] ) -> Iterator[CombinedRow]: return ( item for item in source if pass_non_date(item) )这个函数返回生成器表达式的结果。这个函数并没有做很多,但它确实给表达式应用了一个名称和一组类型提示。
skip_header_gen()函数使用一个生成器表达式,该表达式将 pass_non_date()函数应用于源集合中的每个项目,以确定它是否通过并保留,或者是否被拒绝。
结果与上面显示的原始 skip_header_date()函数相同。
使用 filter()函数
使用 filter()函数包括两个部分——决策函数和数据来源——作为参数:
-
使用 filter()函数将函数应用于源数据:
filter(pass_non_date, source)
filter()函数是一个迭代器,它将给定的函数 pass_non_date()作为规则应用于给定的可迭代对象 data 中的每个项目,以决定是否通过或拒绝。它产生那些 pass_non_date()函数返回 True 的行。
重要的是要注意,没有括号的 pass_non_date 名称是对一个函数对象的引用。
常见的错误是认为函数必须被评估,并包含额外的、不必要的括号使用。
9.4.3 它是如何工作的...
生成器表达式必须包含一个 for 子句以提供数据项的来源。可选的 if 子句可以应用一个条件,保留一些项同时拒绝其他项。在 if 子句中放置一个过滤器条件可以使表达式清晰并表达算法。
生成器表达式有一个重要的限制。作为表达式,它们不能使用 Python 的面向语句的特性。try-except 语句,用于处理异常数据条件,通常很有帮助。
9.4.4 更多内容...
有时,很难编写一个简单的规则来定义有效数据或拒绝无效数据。在许多情况下,可能无法使用简单的字符串比较来识别要拒绝的行。当文件中充满了无关信息时,这种情况就会发生;手动准备的电子表格就存在这个问题。在某些情况下,没有简单的正则表达式可以帮助描述有效数据。
我们经常遇到数据,其中确定有效性的最简单方法就是尝试转换,并将异常的存在或不存在转换为布尔条件。
考虑以下函数,以确定数据行是否具有有效的日期:
import datetime
def row_has_date(row: CombinedRow) -> bool:
try:
datetime.datetime.strptime(row.date, "%m/%d/%y")
return True
except ValueError as ex:
return False
这将尝试转换日期。它将拒绝不符合基本格式规则的无效字符字符串。它还将拒绝 2/31/24;虽然数字字符串是有效的,但这不是一个真实日期。
9.4.5 参见
- 在本章前面的使用堆叠生成器表达式配方中,我们将类似这样的函数放入生成器堆栈中。我们通过将多个作为生成器函数编写的单个映射和过滤操作组合起来,构建了一个复合函数。
9.5 汇总集合 – 如何 reduce
Reduction 是计算集合的总和或最大值等摘要的通用概念。计算均值或方差等统计度量也是 reduce。在本配方中,我们将探讨几种摘要或 reduce 技术。
在本章的介绍中,我们提到 Python 支持三种优雅的处理模式:map、filter 和 reduce。我们在应用于集合的转换配方中看到了映射的示例,在选择子集 – 三种过滤方式配方中看到了过滤的示例。
第三种常见的模式是 reduce。在设计大量处理类的配方和扩展内置集合 – 执行统计的列表配方中,我们看到了计算多个统计值的类定义。这些定义几乎完全依赖于内置的 sum()函数。这是更常见的 reduce 操作之一。
在本配方中,我们将探讨一种泛化求和的方法,从而可以编写多种不同类型的类似 reduce。泛化 reduce 的概念将使我们能够在一个可靠的基础上构建更复杂的算法。
9.5.1 准备工作
最常见的 reduce 操作包括求和、最小值、最大值。这些操作非常常见,因此它们是内置的。另一方面,平均值和方差是在统计模块中定义的 reduce。数学模块有一个 sum 的变体,即 fsum(),它特别适用于浮点数值集合。
求和是财务报告的骨架。它是自使用笔和纸进行财务报告以来电子表格所使用的本质。
求和的数学帮助我们了解运算符是如何将值集合转换为单个值的。下面是使用运算符 + 应用到集合 C = {c[0],c[1],c[2],…,c[n]} 中的值来思考求和函数的数学定义的一种方法:

我们通过将加法运算符 + 拼接到 C 中的值序列来扩展 sum 的定义。
拼接涉及两个项目:一个二元运算符和一个基本值。对于 sum,运算符是 +,基本值是零。对于 product,运算符是 ×,基本值是一。基本值需要是给定运算符的单位元素。
我们可以将这个概念应用到许多算法中,可能简化定义。在这个配方中,我们将定义一个乘积函数。这是
运算符,类似于
运算符。
9.5.2 如何操作...
下面是如何定义一个实现数字集合乘积的减少操作:
-
从 functools 模块导入 reduce() 函数:
from functools import reduce -
选择运算符。对于 sum,它是 +。对于 product,它将是 ×。这些可以用各种方式定义。下面是长版本。稍后还会展示定义必要的二元运算符的其他方式:
def mul(a: int, b: int) -> int: return a * b -
选择所需的基本值。对于 sum 的加法单位值是零。对于 product 的乘法单位值是一:
def prod(values: Iterable[float]) -> float: return reduce(mul, values, 1)def prod(values: Iterable[int]) -> int: return reduce(mul, values, 1)
我们可以使用这个 prod() 函数来定义其他函数。一个例子是阶乘函数。它看起来是这样的:
def factorial(n: int) -> int:
return prod(range(1, n+1))
有多少种六张牌的克里比奇牌手是可能的?二项式计算使用阶乘函数来计算从 52 张牌的牌堆中抽取 6 张牌的方法数:

下面是一个 Python 实现:
>>> factorial(52) // (factorial(6) * factorial(52 - 6))
20358520
对于任何给定的洗牌,大约有 2000 万种不同的克里比奇牌手可能出现。
9.5.3 它是如何工作的...
reduce() 函数的行为好像它有如下定义:
T = TypeVar("T")
def my_reduce(
fn: Callable[[T, T], T],
source: Iterable[T],
initial: T | None = None
) -> T:
类型提示显示必须有一个统一的类型 T,它适用于折叠的运算符和折叠的初始值。给定的函数 fn() 必须组合两个类型为 T 的值并返回另一个类型为 T 的值。reduce() 函数的结果也将是这个类型。
此外,在 Python 中,reduce 操作将从左到右遍历值。它将在源集合的前一个结果和下一个项目之间应用给定的二元函数 fn()。当考虑非交换运算符(如减法或除法)时,这个额外细节很重要。
9.5.4 更多...
我们将探讨三个额外的主题。首先,定义操作的方法。之后,我们将探讨在逻辑简化:任意和所有中将 reduce 应用于布尔值。最后,在恒等元素中,我们将探讨各种运算符使用的恒等元素。
操作定义
当为 reduce()函数设计新应用时,我们需要提供一个二元运算符。有三种方法来定义必要的二元运算符。首先,我们可以使用完整的函数定义,如上所示在配方中。还有两种选择。我们可以使用 lambda 对象而不是完整的函数:
from collections.abc import Callable
lmul: Callable[[int, int], int] = lambda a, b: a * b
lambda 对象是一个匿名函数,简化为仅包含两个基本元素:参数和返回表达式。lambda 内部没有语句,只有一个表达式。
我们将 lambda 对象赋值给变量 lmul,这样我们就可以使用表达式 lmul(2, 3)来将 lambda 对象应用于参数值。
当操作是 Python 的内置运算符之一时,我们还有另一个选择——从 operator 模块导入定义:
from itertools import takewhile
这对于所有内置的算术运算符都适用。
考虑正在使用的运算符的复杂性是至关重要的。执行 reduce 操作会将运算符的复杂性增加 n 倍。当应用于集合中的 n 个元素时,O(1)的操作变为 O(n)。对于我们所展示的运算符,如加法和乘法,这符合我们的预期。比 O(1)更复杂的运算符可能会变成性能噩梦。
在下一节中,我们将探讨逻辑简化函数。
逻辑简化:任意和所有
从概念上讲,我们似乎应该能够使用布尔运算符 and 和 or 来进行 reduce()操作。实际上,这涉及到一些额外的考虑。
Python 的布尔运算符具有短路特性:当我们评估表达式 False and 3 / 0 时,结果仅为 False。and 运算符右侧的表达式 3 / 0 永远不会被评估。or 运算符类似:当左侧为 True 时,右侧永远不会被评估。
如果我们想确保一个布尔值序列全部为真,自己构建 reduce()将会做太多工作。一旦看到初始的 False,就没有必要处理剩余的项目。and 和 or 的短路特性与 reduce()函数不匹配。
内置函数 any()和 all()另一方面,是使用逻辑运算符的简化。实际上,any()函数相当于使用 or 运算符的 reduce()。同样,all()函数的行为就像是一个使用 and 运算符的 reduce()。
恒等元素
通常,用于归约的运算符必须有一个恒等元素。这作为初始值提供给 reduce() 函数。当它们应用于空序列时,恒等元素也将是结果。以下是一些常见示例:
-
sum([]) 是零。
-
math.prod([]) 是一个例子。
-
any([]) 是 False。
-
all([]) 是 True。
给定操作的恒等值是定义问题。
在任何() 和 all() 的特定情况下,可以考虑基本折叠操作。恒等元素总是可以折叠而不改变结果。以下是 all() 以显式折叠和运算符的形式看起来是怎样的:

如果 b[0],b[1],b[2],...,b[n] 中的所有值都是 True,那么额外的 True 不会改变值。如果 b[0],b[1],b[2],...,b[n] 中的任何值是 False,同样地,额外的 True 也不会产生影响。
当集合中没有值时,all() 的值是恒等元素,即 True。
9.5.5 参见
- 在本章中,查看 使用堆叠的生成表达式 菜单,了解在何种情况下可以将 sum() 函数应用于计算总小时数和总燃料量。
9.6 map 和 reduce 转换的结合
在本章的其他菜谱中,我们一直在查看 map、filter 和 reduce 操作。我们已经单独查看过这些函数:
-
对集合应用转换 菜单展示了 map() 函数。
-
选择子集 – 三种过滤方式 菜单展示了 filter() 函数。
-
总结集合 – 如何归约 菜单展示了 reduce() 函数。
许多算法将涉及创建组合函数,这些函数结合了更基本的操作。此外,我们还需要考虑使用迭代器和生成函数的一个深刻限制。
这里有一个这个限制的例子:
>>> typical_iterator = iter([0, 1, 2, 3, 4])
>>> sum(typical_iterator)
10
>>> sum(typical_iterator)
0
我们通过手动将 iter() 函数应用于一个字面量列表对象来创建一个值序列的迭代器。第一次 sum() 函数从 typical_iterator 消费值时,它消费了所有五个值。下一次我们尝试将任何函数应用于 typical_iterator 时,将没有更多的值可以消费;迭代器将看起来是空的。根据定义,恒等值(对于求和来说是 0)是结果。
迭代器只能产生一次值。
在值被消费后,迭代器看起来像是一个空集合。
这个一次性约束将迫使我们缓存中间结果,当我们需要对数据进行多次减少时。创建中间集合对象将消耗内存,因此在处理非常大的数据集时需要仔细设计。(处理大量数据集是困难的。Python 提供了一些创建可行解决方案的方法;它并不能神奇地使问题消失。)
要对一个集合应用复杂的转换,我们通常会发现可以单独实现的 map、filter 和 reduce 操作的实例。然后,这些操作可以组合成复杂的复合操作。
9.6.1 准备工作
在本章前面使用堆叠生成器表达式的配方中,我们查看了一些帆船数据。电子表格组织得很差,需要多个步骤来对数据进行更有用的结构化。
在那个配方中,我们查看了一个用于记录大型帆船燃油消耗的电子表格。关于这些数据的详细信息,请参阅第四章中的切片和切块列表配方。我们将在第十一章的使用 CSV 模块读取定界文件配方中更详细地查看解析。
在使用堆叠生成器表达式的配方中,初始处理创建了一系列操作以改变数据的组织结构,过滤掉标题,并计算一些有用的值。我们需要补充两个额外的减少步骤来获取一些平均值和方差信息。这些统计数据将帮助我们更全面地理解数据。我们将在此基础上进行一些额外的步骤。
9.6.2 如何实现...
我们将从目标代码行作为设计目标开始。在这种情况下,我们希望有一个函数来计算每小时的燃油使用量。这遵循了一个常见的三步处理模式。首先,我们使用 row_merge()对数据进行归一化。其次,我们使用映射和过滤通过 clean_data_iter()创建更实用的对象。
第三步应该看起来像以下内容:
>>> round(
... total_fuel(clean_data_iter(row_merge(log_rows))),
... 3
... )
7.0
我们的目标函数 total_fuel()被设计成与几个用于清理和整理原始数据的函数一起工作。我们将从归一化开始,然后定义最终的汇总函数,如下所示:
-
从前面的配方中导入函数以重用初始准备:
from recipe_03 import row_merge, CombinedRow -
定义由清理和丰富步骤创建的目标数据结构。在这个例子中,我们将使用可变的数据类。来自归一化 CombinedRow 对象的字段可以直接初始化。其他五个字段将通过几个单独的函数积极计算。在 init()方法中没有计算的字段必须提供一个初始值 field(init=False):
import datetime from dataclasses import dataclass, field @dataclass class Leg: date: str start_time: str start_fuel_height: str end_time: str end_fuel_height: str other_notes: str start_timestamp: datetime.datetime = field(init=False) end_timestamp: datetime.datetime = field(init=False) travel_hours: float = field(init=False) fuel_change: float = field(init=False) fuel_per_hour: float = field(init=False) -
定义整体数据清洗和丰富数据函数。这将从源 CombinedRow 对象构建丰富的 Leg 对象。我们将从七个更简单的函数构建它。实现是一个 map()和 filter()操作的堆栈,它将从源字段推导数据:
from collections.abc import Iterable, Iterator def clean_data_iter( source: Iterable[CombinedRow] ) -> Iterator[Leg]: leg_iter = map(make_Leg, source) fitered_source = filter(reject_date_header, leg_iter) start_iter = map(start_datetime, fitered_source) end_iter = map(end_datetime, start_iter) delta_iter = map(duration, end_iter) fuel_iter = map(fuel_use, delta_iter) per_hour_iter = map(fuel_per_hour, fuel_iter) return per_hour_iter每个语句都使用前一个语句产生的迭代器。
-
编写 make_Leg()函数,从 CombinedRow 实例创建 Leg 实例:
def make_Leg(row: CombinedRow) -> Leg: return Leg( date=row.date, start_time=row.engine_on_time, start_fuel_height=row.engine_on_fuel_height, end_time=row.engine_off_time, end_fuel_height=row.engine_off_fuel_height, other_notes=row.other_notes, ) -
编写 reject_date_header()函数,用于 filter()移除标题行:
def reject_date_header(row: Leg) -> bool: return not (row.date == "date") -
编写数据转换函数。我们将从两个日期和时间字符串开始,它们需要变成一个单一的 datetime 对象:
def timestamp( date_text: str, time_text: str ) -> datetime.datetime: date = datetime.datetime.strptime( date_text, "%m/%d/%y").date() time = datetime.datetime.strptime( time_text, "%I:%M:%S %p").time() timestamp = datetime.datetime.combine( date, time) return timestamp -
使用额外的值修改 Leg 实例:
def start_datetime(row: Leg) -> Leg: row.start_timestamp = timestamp( row.date, row.start_time) return row def end_datetime(row: Leg) -> Leg: row.end_timestamp = timestamp( row.date, row.end_time) return row这种原地更新方法是一种优化,以避免创建中间对象。
-
从时间戳计算派生持续时间:
def duration(row: Leg) -> Leg: travel_time = row.end_timestamp - row.start_timestamp row.travel_hours = round( travel_time.total_seconds() / 60 / 60, 1 ) return row -
计算分析所需的其他任何指标:
def fuel_use(row: Leg) -> Leg: end_height = float(row.end_fuel_height) start_height = float(row.start_fuel_height) row.fuel_change = start_height - end_height return row def fuel_per_hour(row: Leg) -> Leg: row.fuel_per_hour = row.fuel_change / row.travel_hours return row
最终的 fuel_per_hour()函数的计算依赖于整个前面的计算堆栈。每个这些计算都是单独进行的,以阐明和隔离计算细节。这种方法允许对隔离的计算进行更改。最重要的是,它允许将每个计算作为一个单独的单元进行测试。
9.6.3 它是如何工作的...
核心概念是从一系列小步骤构建一个复合转换。由于每个步骤在概念上是不同的,这使得理解组合变得相对容易。
在这个菜谱中,我们使用了三种类型的转换:
-
结构变化。一个初始生成函数将物理行分组到逻辑行中。
-
过滤器。一个生成函数拒绝无效的行。
-
丰富。正如我们所见,有两种设计方法可以丰富数据:懒加载和急加载。懒加载方法可能涉及仅在需要时计算的方法或属性。这种设计显示了急加载计算,其中许多字段值是由处理管道构建的。
各种丰富方法通过更新状态化的 Leg 对象、设置计算列值来实现。使用这种状态化对象需要严格按照顺序执行各种丰富转换,因为其中一些(如 duration())依赖于其他转换先执行。
我们现在可以设计目标计算函数:
from statistics import *
def avg_fuel_per_hour(source: Iterable[Leg]) -> float:
return mean(row.fuel_per_hour for row in source)
def stdev_fuel_per_hour(source: Iterable[Leg]) -> float:
return stdev(row.fuel_per_hour for row in source)
这符合我们的设计目标,即能够在原始数据上执行有意义的计算。
9.6.4 更多内容...
正如我们所提到的,我们只能对可迭代数据源中的项目进行一次迭代。如果我们想计算多个平均值,或者平均值以及方差,我们需要使用稍微不同的设计模式。
为了计算多个数据摘要,通常最好创建某种类型的具体对象,该对象可以被反复总结:
def summary(raw_data: Iterable[list[str]]) -> None:
data = tuple(clean_data_iter(row_merge(raw_data)))
m = avg_fuel_per_hour(data)
s = 2 * stdev_fuel_per_hour(data)
print(f"Fuel use {m:.2f} {s:.2f}")
在这里,我们从清洗和丰富后的数据创建了一个非常大的元组。从这个元组中,我们可以产生任意数量的迭代器。这使我们能够计算任意数量的不同摘要。
我们还可以使用 itertools 模块中的 tee()函数进行此类处理。由于克隆迭代器的实例保持其内部状态的方式,这可能导致效率低下的处理。通常,创建一个中间结构(如列表或元组)比使用 itertools.tee()更好。
设计模式将多个转换应用于源数据。我们使用单独的映射、过滤和减少操作堆叠构建了它。
9.6.5 参考信息
-
在本章中查看使用堆叠生成器表达式的配方,了解 sum 函数可以应用于计算总小时数和总燃料量的上下文。
-
在本章中查看总结集合 – 如何减少的配方,了解 reduce()函数的一些背景信息。
-
有关分布式 map-reduce 处理的更多信息,请参阅Python High Performance。
-
我们在使用属性进行懒属性的配方中查看懒性属性,在第七章。此外,这个配方还探讨了 map-reduce 处理的某些重要变体。
9.7 实现“存在”处理
我们一直在研究的处理模式都可以用全称量化符∀来概括,意味着对于所有。这一直是所有处理定义的隐含部分:
-
映射:对于源中的所有项,S,应用映射函数,m(x)。我们可以使用全称量化符:∀[x∈S]m(x)。
-
过滤:这也意味着对于源中的所有项,S,传递那些过滤函数,f(x),为真的项。在这里,我们也可以使用全称量化符:∀[x∈S]x if f(x)。
-
减少:对于源中的所有项,使用给定的运算符和基数值来计算摘要。在运算符∑[x∈S]x 和∏[x∈S]x 的定义中隐含了全称量化。
将这些通用函数与仅对我们定位单个项感兴趣的情况进行对比。我们经常将这些情况描述为搜索,以表明至少存在一个项满足条件。这可以用存在量化符∃来描述,意味着存在。
我们需要使用 Python 的一些附加功能来创建生成器函数,当第一个值匹配某个谓词时停止。我们希望模拟内置的 any()和 all()函数的短路功能。
9.7.1 准备工作
以一个存在性测试的例子为例,考虑一个确定一个数是素数还是合数的函数。素数没有除了 1 和它自己之外的因子。具有多个因子的数被称为合数。数字 42 是合数,因为它有 2、3 和 7 作为素数因子。
判断一个数是否为素数等同于证明它不是合数。对于任何合数(或非素数)数,n,规则是这样的:

如果存在一个值 i,介于 2 和该数本身之间,可以整除该数,则一个数 n 不是素数。为了测试一个数是否是素数,我们不需要知道所有的因子。单个因子的存在表明该数是合数。
整体思路是遍历候选数字的范围,在找到因子时退出迭代。在 Python 中,这种从 for 语句中的提前退出是通过 break 语句完成的,将语义从“对所有”转换为“存在”。因为 break 是一个语句,所以我们不能轻易使用生成器表达式;我们被迫编写生成器函数。
(费马测试通常比我们在这些例子中使用的更有效,但它不涉及简单地搜索因子的存在。我们使用它作为搜索的示例,而不是作为良好素性测试的示例。)
9.7.2 如何做到...
为了构建这种搜索函数,我们需要创建一个生成器函数,当它找到第一个匹配项时将完成处理。一种方法是使用 break 语句,如下所示:
-
定义一个生成器函数以跳过项,直到通过测试。生成器可以产生通过谓词测试的第一个值。生成器通过将谓词函数 fn()应用于某些类型 T 的项序列中的项来工作:
from collections.abc import Callable, Iterable, Iterator from typing import TypeVar T = TypeVar("T") def find_first( fn: Callable[[T], bool], source: Iterable[T] ) -> Iterator[T]: for item in source: if fn(item): yield item break -
为此应用定义特定的谓词函数。由于我们正在测试是否为素数,我们正在寻找任何可以整除目标数 n 的值。以下是所需的表达式类型:
lambda i: n % i == 0 -
使用给定的值范围和谓词应用 find_first()搜索函数。如果因子可迭代有项,则 n 是合数。否则,因子可迭代中没有值,这意味着 n 是素数:
import math def prime(n: int) -> bool: factors = find_first( lambda i: n % i == 0, range(2, int(math.sqrt(n) + 1)) ) return len(list(factors)) == 0
实际上,我们不需要测试介于两个数和 n 之间的每个数来查看 n 是否为素数。只需要测试满足 2 ≤ i < ⌊
⌋的值 i。
9.7.3 它是如何工作的...
在 find_first()函数中,我们引入了 break 语句来停止处理源可迭代。当 for 语句停止时,生成器将到达函数的末尾并正常返回。
消费此生成器值的客户端函数将收到 StopIteration 异常。find_first()函数可以引发异常,但这不是错误;它是可迭代已处理输入值的信号。
在这种情况下,StopIteration 异常意味着以下两种情况之一:
-
如果之前已经产生了值,则该值是 n 的因子。
-
如果没有产生值,则 n 是素数。
这种提前从 for 语句中退出的微小变化在生成器函数的意义上产生了巨大的差异。find_first()生成器将停止处理,而不是处理所有源值。
9.7.4 更多...
在 itertools 模块中,find_first() 函数有一个替代方案。takewhile() 函数使用谓词函数从输入中取值,只要谓词函数为真。当谓词变为假时,函数停止消费和产生值。
要使用 takewhile() 函数,我们需要反转我们的因子测试。我们需要消费非因子值,直到我们找到第一个因子。这导致 lambda 表达式从 lambda i: n % i == 0 变为 lambda i: n % i != 0。
让我们看看一个测试,看看 47 是否为质数。我们需要检查 2 到
= 7 范围内的数字:
>>> from itertools import takewhile
>>> n = 47
>>> list(takewhile(lambda i: n % i != 0, range(2, 8)))
[2, 3, 4, 5, 6, 7]
对于像 47 这样的质数,没有任何测试值是因子。所有这些非因子测试值都通过了 takewhile() 谓词,因为它是始终为真的。结果列表与原始测试值集合相同。
对于一个合数,非因子测试值将是测试值的一个子集。由于找到了一个因子,一些值已被排除。
itertools 模块中还有许多其他函数可以用来简化复杂的 map-reduce 应用。我们鼓励您仔细研究这个模块。
9.7.5 参见
-
在本章前面的 使用堆叠的生成表达式 菜谱中,我们广泛使用了不可变类定义。
-
有关小于 200 万的质数相关的一个具有挑战性的问题,请参阅
projecteuler.net/problem=10。问题的某些部分看起来很明显。然而,测试所有这些数字是否为质数可能很困难。 -
itertools 模块提供了许多函数,可以简化函数设计。
-
在标准库之外,像 Pyrsistent 这样的包提供了函数式编程组件。
9.8 创建一个部分函数
当我们查看 reduce()、sorted()、min() 和 max() 等函数时,我们会看到我们经常会遇到一些参数值几乎不会改变,或者在特定上下文中实际上是固定的。例如,我们可能会在几个地方需要编写类似以下内容:
reduce(operator.mul, ..., 1)
在 reduce() 的三个参数值中,只有一个——要处理的可迭代对象——实际上会改变。操作符和初始值参数值基本上固定在 operator.mul 和 1。
显然,我们可以为这个定义一个新的函数:
from collections.abc import Iterable
from functools import reduce
import operator
def prod(iterable: Iterable[float]) -> float:
return reduce(operator.mul, iterable, 1)
Python 有几种方法可以简化这个模式,这样我们就不必重复编写样板 def 和 return 语句。
这个菜谱的目标与提供一般默认值不同。部分函数不提供覆盖默认值的方法。部分函数在定义时绑定特定的值。想法是能够创建许多部分函数,每个函数在预先绑定特定的参数值。这有时也被称为闭包,但应用于一些参数。参见第三章中的基于部分函数选择参数顺序以获取更多部分函数定义的示例。
9.8.1 准备工作
一些统计建模使用标准化值进行,有时称为 z 分数。想法是将原始测量值标准化到一个可以轻松与正态分布比较的值,并且可以轻松与可能以不同单位测量的相关数字比较。
计算过程如下:

在这里,x 是一个原始值,μ是总体均值,σ是总体标准差。z 的值将具有 0 的均值和 1 的标准差,提供标准化值。我们可以使用这个值来发现异常值——那些可疑地远离均值的值。我们预计(大约)99.7%的 z 值将在-3 和+3 之间。
我们可以定义一个函数来计算标准分数,如下所示:
def standardize(mean: float, stdev: float, x: float) -> float:
return (x - mean) / stdev
这个 standardize()函数将从原始分数 x 计算 z 分数。当我们在这个实际应用中使用这个函数时,我们会看到参数有两种类型的值:
-
均值和 stdev 参数的值基本上是固定的。一旦我们计算了总体值,我们就必须反复将相同的两个值提供给 standardize()函数。
-
每次评估 standardize()函数时,x 参数的值都会变化。
让我们处理一个包含两个变量 x 和 y 的数据样本集合。这些对由 DataPair 类定义:
from dataclasses import dataclass
@dataclass
class DataPair:
x: float
y: float
例如,我们将计算 x 属性的标准化值。这意味着计算 x 值的平均值和标准差。然后,我们需要将平均值和标准差值应用于标准化我们收集中的数据。计算过程如下:
>>> import statistics
>>> mean_x = statistics.mean(item.x for item in data_1)
>>> stdev_x = statistics.stdev(item.x for item in data_1)
>>> for DataPair in data_1:
... z_x = standardize(mean_x, stdev_x, DataPair.x)
... print(DataPair, z_x)
每次评估 standardize()函数时提供 mean_x 和 stdev_x 值可能会使算法充斥着不重要的细节。
我们可以使用部分函数简化使用具有两个固定参数值和一个可变参数的 standardize()的使用。
9.8.2 如何实现...
为了简化使用具有多个固定参数值的函数,我们可以创建一个部分函数。这个菜谱将展示两种创建部分函数的方法,作为独立的迷你菜谱:
-
使用 functools 模块中的 partial()函数从完整的 standardize()函数构建一个新的函数
-
创建一个 lambda 对象来提供不变的参数值
使用 functools.partial()
-
从 functools 模块导入 partial() 函数:
from functools import partial -
使用 partial() 创建一个新的函数。我们提供基本函数,以及需要包含的位置参数。当定义 partial 时未提供的任何参数必须在评估 partial 时提供:[firstline=79,lastline=79,gobble=4][python]src/ch09/recipe˙08.py
我们已经为 standardize() 函数的前两个参数,即平均值和标准差,提供了固定值。现在我们可以使用 z() 函数的单个值,z(a),它将评估表达式 standardize(mean_x, stdev_x, a)。
创建 lambda 对象
-
定义一个绑定固定参数的 lambda 对象:[firstline=105,lastline=105,gobble=8][python]src/ch09/recipe˙08.py
-
将此 lambda 分配给变量以创建一个可调用的对象,z():[firstline=105,lastline=105,gobble=4][python]src/ch09/recipe˙08.py
这为 standardize() 函数的前两个参数,即平均值和标准差,提供了固定值。现在我们可以使用 z() lambda 对象的单个值,z(a),它将评估表达式 standardize(mean_x, stdev_x, a)。
9.8.3 工作原理...
这两种技术都创建了一个可调用的对象——一个函数——名为 z(),它已经将 mean_x 和 stdev_x 的值绑定到前两个位置参数。使用这两种方法中的任何一种,我们现在都可以进行如下处理:
[firstline=107,lastline=108,gobble=4][python]src/ch09/recipe˙08.py
我们已经将 z() 函数应用于每一组数据。因为 z() 是一个部分函数并且已经应用了一些参数,所以它的使用被简化了。
创建 z() 函数的两种技术之间有一个显著的区别:
-
partial() 函数绑定参数的实际值。任何后续对这些变量所做的更改都不会改变创建的部分函数的定义。
-
lambda 对象绑定变量名,而不是值。任何后续对变量值的更改都会改变 lambda 的行为方式。
我们可以稍微修改 lambda 以绑定特定值而不是名称:
[firstline=131,lastline=131,gobble=4][python]src/ch09/recipe˙08.py
这将提取当前 mean_x 和 stdev_x 的值以创建 lambda 对象参数的默认值。mean_x 和 stdev_x 的值现在与 lambda 对象 z() 的正确操作无关。
9.8.4 更多内容...
在创建部分函数时,我们可以提供关键字参数值以及位置参数值。虽然这在一般情况下工作得很好,但还有一些情况它不起作用。
我们开始这个配方时查看 reduce() 函数。有趣的是,这个函数是函数不能轻易转换为部分函数的一个例子。参数的顺序不是创建部分函数的理想顺序,并且它不允许通过名称提供参数值。
reduce() 函数似乎是这样定义的:
def reduce(function, iterable, initializer=None)
如果这是实际的定义,我们可以这样做:
prod = partial(reduce(mul, initializer=1))
实际上,前面的例子会引发一个 TypeError。它不起作用,因为 reduce() 的定义不接受关键字参数值。因此,我们无法轻松地创建使用它的部分函数。
这意味着我们被迫使用以下 lambda 技术:
>>> from operator import mul
>>> from functools import reduce
>>> prod = lambda x: reduce(mul, x, 1)
在 Python 中,函数是一个对象。我们已经看到了许多函数可以作为其他函数的参数的方式。接受或返回另一个函数作为参数的函数有时被称为高阶函数。
同样,函数也可以返回一个函数对象作为结果。这意味着我们可以创建一个像这样的函数:
from collections.abc import Sequence, Callable
import statistics
def prepare_z(data: Sequence[DataPair]) -> Callable[[float], float]:
mean_x = statistics.mean(item.x for item in data_1)
stdev_x = statistics.stdev(item.x for item in data_1)
return partial(standardize, mean_x, stdev_x)
这里,我们定义了一个在类型为 DataPair 的序列上的函数,这些是 (x,y) 样本。我们计算了每个样本的 x 属性的平均值和标准差。然后我们创建了一个部分函数,可以根据计算出的统计数据标准化分数。这个函数的结果是一个我们可以用于数据分析的函数。
以下示例显示了如何使用这个新创建的函数:
>>> z = prepare_z(data_1)
>>> for DataPair in data_1:
... print(DataPair, z(DataPair.x))
prepare_z() 函数的结果是一个可调用对象,它将根据计算出的平均值和标准差标准化分数。
9.8.5 参考阅读
- 参见第三章中的 Picking an order for parameters based on partial functions 以获取部分函数定义的更多示例。
9.9 使用 yield from 语句编写递归生成器函数
许多算法可以简洁地表示为递归。在围绕 Python 的堆限制设计递归函数的配方中,我们查看了一些可以优化以减少函数调用的递归函数。
当我们查看某些数据结构时,我们发现它们涉及递归。特别是,JSON 文档(以及 XML 和 HTML 文档)可以具有递归结构。JSON 文档是一个复杂对象,它可以包含其内的其他复杂对象。
在许多情况下,使用生成器处理这些结构具有优势。在这个配方中,我们将查看处理递归数据结构的方法。
9.9.1 准备工作
在这个配方中,我们将查看一种在复杂、递归数据结构中搜索所有匹配值的方法。当处理复杂的 JSON 文档时,它们通常包含 dict-of-dict、dict-of-list、list-of-dict 和 list-of-list 结构。当然,JSON 文档不仅限于两层;dict-of-dict 实际上可以意味着 dict-of-dict-of...。同样,dict-of-list 可以意味着 dict-of-list-of...。搜索算法必须遍历整个结构以查找特定的键或值。
一个具有复杂结构的文档可能看起来像这样:
document = {
"field": "value1",
"field2": "value",
"array": [
{"array_item_key1": "value"},
{"array_item_key2": "array_item_value2"}
],
"object": {
"attribute1": "value",
"attribute2": "value2"
},
}
值 "value" 可以在三个地方找到:
-
["array", 0, "array_item_key1"]:此路径从顶级字段名为 array 开始,然后访问列表中的第 0 个项目,然后是名为 array_item_key1 的字段。
-
["field2"]:此路径只有一个字段名,其中找到了值。
-
["object", "attribute1"]:此路径以顶级字段 object 开头,然后是该字段的子字段 attribute1。
find_value()函数应在搜索整个文档以查找目标值时产生所有这些路径。这个算法的核心是深度优先搜索。此函数的输出必须是一个路径列表,用于标识目标值。每个路径将是一个字段名序列或字段名与索引位置的混合。
9.9.2 如何操作...
我们将从概述深度优先算法开始,以访问 JSON 文档中的所有节点。
-
从处理整体数据结构中每个替代结构的函数草图开始。以下是导入和一些类型提示:
from collections.abc import Iterator from typing import Any, TypeAlias JSON_DOC: TypeAlias = ( None | str | int | float | bool | dict[str, Any] | list[Any] ) Node_Id: TypeAlias = Any这里是函数的草图:
def find_value_sketch( value: Any, node: JSON_DOC, path: list[Node_Id] | None = None ) -> Iterator[list[Node_Id]]: if path is None: path = [] match node: case dict() as dnode: pass # apply find_value to each key in dnode case list() as lnode: pass # apply find_value to each item in lnode case _ as pnode: # str, int, float, bool, None if pnode == value: yield path -
这里是一个起始版本,用于查看字典的每个键。这替换了前面代码中的# apply find_value to each key in dnode 行。测试以确保递归正常工作:[firstline=58,lastline=60,gobble=8][python]src/ch09/recipe˙10.py
-
将内部的 for 循环替换为 yield from 语句:[firstline=98,lastline=100,gobble=8][python]src/ch09/recipe˙10.py
-
这也必须应用于列表情况。开始检查列表中的每个项目:[firstline=62,lastline=64,gobble=8][python]src/ch09/recipe˙10.py
-
将内部的 for 循环替换为 yield from 语句:[firstline=102,lastline=104,gobble=8][python]src/ch09/recipe˙10.py
当完整时,完整的深度优先 find_value()搜索函数将看起来像这样:
def find_value(
value: Any,
node: JSON_DOC,
path: list[Node_Id] | None = None
) -> Iterator[list[Node_Id]]:
if path is None:
path = []
match node:
case dict() as dnode:
for key in sorted(dnode.keys()):
yield from find_value(
value, node[key], path + [key])
case list() as lnode:
for index, item in enumerate(lnode):
yield from find_value(
value, item, path + [index])
case _ as pnode:
# str, int, float, bool, None
if pnode == value:
yield path
当我们使用 find_value()函数时,它看起来像这样:
>>> places = list(find_value(’value’, document))
>>> places
[[’array’, 0, ’array_item_key1’], [’field2’], [’object’, ’attribute1’]]
结果列表有三个项目。这些项目中的每一个都是一个键列表,这些键构成了一条到具有“value”目标值的项的路径。
9.9.3 它是如何工作的...
关于背景信息,请参阅本章中的使用 yield 语句编写生成器函数配方。
yield from 语句是以下内容的简写:
[firstline=135,lastline=136,gobble=8][python]src/ch09/recipe˙10.py
yield from 语句让我们能够编写简洁的递归算法,该算法将表现得像一个迭代器,并正确地产生多个值。它节省了样板 for 语句的开销。
这也可以用于不涉及递归函数的上下文中。在任何涉及可迭代结果的上下文中使用 yield from 语句是完全合理的。对于递归函数来说,这是一个方便的简化,因为它保留了清晰的递归结构。
9.9.4 还有更多...
另一种常见的定义方式是使用 append 操作来组装项目列表。我们可以将其重写为一个迭代器,以避免构建和修改列表对象的开销。
在分解一个数字时,我们可以定义一个数字 x 的质因数集合,如下所示:

如果值 x 是质数,它在质因子集合中只有它自己。否则,必须有一些质数 n,它是 x 的最小因子。我们可以从这个数字 n 开始组装一个因子集合,然后追加
的所有因子。为了确保只找到质因子,n 必须是质数。如果我们从 2 开始搜索 n 的递增值,我们将在找到合数因子之前找到质数因子。
一种急切的方法是构建一个完整的因子列表。一种懒惰的方法可以是生成器,为消费者提供因子。以下是一个急切构建列表的函数:
import math
def factor_list(x: int) -> list[int]:
limit = int(math.sqrt(x) + 1)
for n in range(2, limit):
q, r = divmod(x, n)
if r == 0:
return [n] + factor_list(q)
return [x]
这个 factor_list() 函数将构建一个列表对象。如果找到一个因子 n,它将以该因子开始一个列表。然后,它将使用 x // n 的值构建的因子扩展列表。如果没有 x 的因子,那么这个值是质数,并且它将返回一个只包含 x 值的列表。
(这有一个效率低下的原因,因为它以这种方式搜索合数和质数。例如,在测试 2 和 3 之后,它还将测试 4 和 6,即使它们是合数,并且它们的所有因子都已经测试过了。这个例子集中在列表构建上,而不是高效地分解数字。)
我们可以通过用 yield from 语句替换递归调用将此重写为迭代器。函数将看起来像这样:
def factor_iter(x: int) -> Iterator[int]:
limit = int(math.sqrt(x) + 1)
for n in range(2, limit):
q, r = divmod(x, n)
if r == 0:
yield n
yield from factor_iter(q)
return
yield x
当找到一个因子时,该函数将产生因子 n,然后通过递归调用 factor_iter() 找到所有其他因子。如果没有找到因子,该函数将产生质数 x,不再产生其他内容。
使用迭代器允许此函数的客户从因子构建任何类型的集合。我们不仅可以创建列表对象,还可以使用 collections.Counter 类创建多重集。它看起来像这样:
>>> from collections import Counter
>>> Counter(factor_iter(384))
Counter({2: 7, 3: 1})
这表明:

在某些情况下,这种多重集可能比简单的因子列表更容易处理。
重要的是,这个多重集是直接从 factor_iter() 迭代器创建的,而没有创建任何中间列表对象。这种优化让我们能够构建不强制消耗大量内存的复杂算法。
9.9.5 参见
-
在本章前面的围绕 Python 栈限制设计递归函数配方中,我们介绍了递归函数的核心设计模式。这个配方提供了一种创建结果的替代方法。
-
关于背景,请参阅本章中的使用 yield 语句编写生成器函数配方。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第十章:10
使用类型匹配和注释进行工作
本章将探讨我们如何与具有各种数据类型的数据结构一起工作。这通常意味着检查属性的类型、元组的一个元素或字典中的一个值。
在前面的章节中,我们避免过多地关注数据验证的考虑。在本章中,我们将仔细检查输入值,以确保它们符合预期的数据类型和值范围。
这种数据验证是一种类型检查。它验证的值域比整数或字符串等非常广泛的类要窄。应用程序必须检查对象的值,以确保它们适用于预期的目的。
一些数据结构,如 JSON 或 XML 文档,可以包含各种数据类型的对象。一个常见的情况可以总结为第一范式(1NF),其中集合中的每个项目都是同一类型。然而,这并不普遍。当解析复杂的文件,如编程语言语句时,我们会看到一系列不同的数据类型。不同类型的存在意味着应用程序软件不能简单地假设一个单一、一致的类型,而必须处理可用的数据。
在本章中,我们将探讨与类型和类型匹配相关的多个配方:
-
使用类型提示进行设计
-
使用内置的类型匹配函数
-
使用 match 语句
-
处理类型转换
-
使用 Pydantic 实现更严格的类型检查
-
包含运行时有效值检查
10.1 使用类型提示进行设计
函数定义中的注释在 2006 年引入到语言语法中,没有正式的语义。注释的想法附带了一系列潜在的使用案例,其中之一是类型检查。在 2014 年,类型提示的想法得到了巩固和正式化,成为了一个类型模块和一些相关工具,包括 mypy 工具。
几年前,注释是一种通用的语法,类型提示是注释的一个特定用例。到 2017 年,注释的其他用途已被弃用,并且注释语法明确专注于类型提示。虽然注释和类型提示之间曾经存在细微的差别,但这种区别已经消失,留下了两个同义词。
使用类型提示有三个重要方面:
-
类型提示是可选的。我们可以不使用类型提示来编写 Python 代码。
-
类型提示可以逐步应用。应用程序的一部分可以有提示,而另一部分则没有。像 mypy 这样的工具可以容忍带有和不带有提示的代码的混合。
-
类型提示在运行时不会被使用,并且没有性能开销。
在整本书中,我们将提示视为良好软件设计的必要条件。它们与单元测试和连贯的文档一样重要,这两者也是技术上的可选项,但对于可信赖的软件来说是必不可少的。我们发现它们通过强制执行一定程度的严谨性和正式性来帮助防止问题。
Python 的处理依赖于鸭子类型规则。有关更多背景信息,请参阅第八章,特别是 Leveraging Python’s duck typing 食谱。我们有两种广泛的设计模式可供选择:
-
具有共同超类的一严格层次结构。
-
利用鸭子类型,一组类可以具有共同特性,通常定义为指定相关特性的协议。
在这个食谱中,我们将探讨两种设计包含类型提示且可由 mypy 等工具检查的代码的方法。
10.1.1 准备工作
我们将研究一个涉及处理在源文件中混合在一起的两个不同类型数据的问题。在这种情况下,我们将使用具有大量数据文件的数据目录的内容进行分类。此外,我们还有一个 src 目录,其中包含大量包含应用程序程序和脚本的子目录。我们想要创建一组数据结构来表示两种不同的数据文件类:
-
未被任何应用程序程序或脚本命名的数据文件
-
被一个或多个应用程序程序引用的数据文件
10.1.2 如何做...
设计此类程序有两种广泛的方法:
-
首先概述数据类型和转换,然后编写代码以适应这些类型。
-
首先编写代码,然后为工作代码添加类型提示。
都不能说是最好的。在许多情况下,两者是并行发展的。
我们将在本食谱中分别查看这些内容的各种变体。
首先类型提示设计
我们将处理各种类的对象。在这个变体中,我们将首先定义类型提示,然后填写所需的处理。以下是从类定义开始定义分类器和相关类的方法:
-
定义两个子类。在这个例子中,我们将它们称为未引用文件和引用文件。对于每个类,写一句话来描述每个类实例的独特用途。这些将作为类定义的起点。
-
选择合适的可用类。这可能是一个具有可变属性的普通类、一个 NamedTuple 或一个@dataclass。通常从@dataclass 开始可以提供最大的灵活性。在命名元组、dataclasses 和冻结数据 classes 之间切换涉及最小的语法变化:
from pathlib import Path from dataclasses import dataclass @dataclass class Referenced: """Defines a data file and applications that reference it."""未引用类定义将与适当的文档字符串相似。
-
添加定义每个实例状态的属性和值。对于 Referenced 类,这是 Path 以及每个有引用的源文件的 Path 对象集合。这两个属性定义看起来是这样的:
datafile: Path recipes: list[Path]对于 Unreferenced 类,实际上并没有很多其他属性,除了路径。这提出了一个有趣的问题:这值得一个单独的类声明,还是它可以简单地是一个 Path 对象?
由于 Python 允许类型别名和类型联合,实际上不需要 Unreferenced 类;现有的 Path 就足够了。提供这个类型别名是有帮助的:
from typing import TypeAlias Unreferenced: TypeAlias = Path -
正式化这些不同类的联合。
ContentType: TypeAlias = Unreferenced | Referenced
现在我们有了类型定义,我们可以编写一个函数,该函数是 ContentType 类联合的迭代器。这个函数将产生一系列 Unreferenced 和 Referenced 对象,每个数据文件一个。
函数可能看起来是这样的:
def datafile_iter(base: Path) -> Iterator[ContentType]:
data = (base / "data")
code = (base / "src")
for path in sorted(data.glob("*.*")):
if not path.is_file():
continue
used_by = [
chap_recipe.relative_to(code)
for chap_recipe in code.glob("**/*.py")
if (
chap_recipe.is_file()
and "__pycache__" not in chap_recipe.parts
and ".venv" not in chap_recipe.parts
and "ch10" not in chap_recipe.parts
and path.name in chap_recipe.read_text()
)
]
if used_by:
yield Referenced(path.relative_to(data), used_by)
else:
yield path.relative_to(data)
datafile_iter()函数会跳过数据目录中的任何非文件名。它还会跳过一些源代码目录、pycache 和.venv。此外,我们必须忽略第十章中的一些文件,因为这些文件将包含数据文件名称的测试用例,从而产生令人困惑的结果。
如果数据文件名出现在源文件中,引用将被保存在 used_by 集合中。具有非空 used_by 集合的文件将创建一个 Referenced 实例。其余的文件是 Path 对象;由于 TypeAlias,这些也被识别为 Unreferenced 实例。我们不需要正式地将 Path 对象转换为 Unreferenced 类型。像 mypy 这样的工具将使用 TypeAlias 来查看等价性,而无需任何额外的代码。
结果迭代器提供了一系列不同类型的对象。在使用 match 语句的配方中,我们将探讨处理不同类型对象的便捷方法。
首先进行代码设计
我们将处理多种类的对象。在这个变体中,我们将首先定义处理程序,然后加入类型提示以阐明我们的意图。以下是从函数定义开始定义分类器和相关类的方法:
-
从提供所需参数的函数定义开始:
def datafile_iter(base): data = (base / "data") code = (base / "src") -
编写处理程序以累积所需的数据值。在这种情况下,我们需要遍历数据文件名称。对于每个数据文件,我们需要在所有源文件中查找引用。
for path in sorted(data.glob("*.*")): if not path.is_file(): continue used_by = [ chap_recipe.relative_to(code) for chap_recipe in code.glob("**/*.py") if ( chap_recipe.is_file() and "__pycache__" not in chap_recipe.parts and ".venv" not in chap_recipe.parts and "ch10" not in chap_recipe.parts and path.name in chap_recipe.read_text() ) ] -
决定函数的各种输出需要什么。在某些情况下,我们可以产生包含各种可用值的元组对象。
if used_by: yield (path.relative_to(data), used_by) else: yield path.relative_to(data)对于源中没有引用的路径,我们产生 Path 对象。对于源中有引用的路径,我们可以产生数据 Path 和源 Path 实例的列表。
-
对于具有更复杂内部状态的对象,考虑引入类定义以正确封装状态。对于这个例子,引入一个具有引用的数据文件类型是有意义的。这将导致用以下类似的 NamedTuple 替换一个简单、匿名的元组:
from typing import NamedTuple class Referenced(NamedTuple): datafile: Path recipes: list[Path]这反过来又导致对 Referenced 实例的 yield 语句进行修订。
yield Referenced(path.relative_to(data), used_by) -
回顾函数定义以添加类型提示。
def datafile_iter_2(base: Path) -> Iterator[Path | Referenced]:
调方程序的两个变体中的处理几乎相同。差异在于如何最好地呈现结果的选择。在前一个例子中,创建了一个显式的联合类型 Content_Type。对于这个版本,联合类型是隐式的。
10.1.3 它是如何工作的...
Python 的鸭子类型允许在设计中有很大的灵活性。我们可以从类型定义开始,也可以从代码开始并添加类型提示。最终的代码往往会相似,因为它对相同的数据执行相同的处理。
代码优先或类型优先的选择可能会导致对性能或优化的洞察。每个选择都强调最终代码的特定属性。代码优先的方法可能强调简单的处理,而类型优先可能强调正在处理的对象的统一性。选择方法也可能源于作者对 Python 类型的舒适度。
在某些情况下,编写类型提示的过程可能会暗示算法或优化。这可能导致对已编写代码的有益重构。
需要注意的是,类型提示的存在与否对性能没有影响。任何性能的提升(或损失)都是普通的设计问题,使用类型提示可能会使这些问题更加明显。
10.1.4 更多内容...
当将一个大问题分解成更小的部分时,小部分之间的接口是设计过程中必须早期做出的关键设计决策。对数据结构的早期决策通常会导致整体上采用类型优先的设计过程。面向外部的组件必须具有明确定义接口。支持这些外部组件的函数或方法可以设计得更加自由,约束更少。
这导致在复杂软件的整体架构中首先考虑类型,当在更详细的层次上工作时,保留类型优先或代码优先的设计选择。当我们考虑分布式应用程序——如网络服务——其中服务器和客户端位于不同的机器上时,我们发现类型优先是必不可少的。
随着代码量的增加,类型提示的重要性也在增加。很难将大量细节记住在脑海中。有一个类型提示来总结更复杂的数据结构可以减少代码周围的细节杂乱。
在分布式计算环境中,我们经常需要考虑某些组件可能不是 Python 程序。在这些情况下,我们无法共享 Python 类型提示。这意味着我们被迫使用存在于 Python 之外的模式定义,但它提供了对 Python 类型的所需映射。
跨越语言的这类正式定义的例子包括 JSON Schema、Protocol Buffers、AVRO 以及许多其他。JSON Schema 方法是典型的,并且被许多 Python 工具支持。在本章后面,我们将探讨使用 Pydantic,它支持使用 JSON Schema 定义数据。
10.1.5 参见
-
在第十一章的阅读 JSON 和 YAML 文档配方中,我们将重新使用 JSON 文档来处理复杂数据。
-
在本章后面的使用 match 语句配方中,我们将探讨如何使用 match 语句处理各种类型的数据。这使得处理类型联合相对容易。
-
在本章后面的使用 Pydantic 实现更严格的类型检查配方中,我们将探讨使用 pydantic 包进行更强的类型定义。
10.2 使用内置类型匹配函数
当我们有一个混合类型的对象集合时,我们通常需要区分这些类型。当我们使用自己定义的类时,我们可以定义正确多态的类。这通常不是使用 Python 的内部对象或处理涉及我们定义的类和 Python 内置类的数据集合的情况。
当我们完全使用自己的类时,我们可以设计它们具有共同的方法和属性,但根据涉及哪个子类提供不同的行为。这种设计符合 S.O.L.I.D 设计原则中的“L”原则:Liskov 替换原则。任何子类都可以替换基类使用,因为它们都有一个共同的方法定义集。有关更多信息,请参阅第八章。
这种以抽象驱动的设 计在 Python 中并不总是需要的。由于 Python 的鸭子类型,设计不需要一个共同的基类。在某些情况下,这甚至不切实际:我们可能有多种类型而没有统一的抽象。与内置类以及我们自己的类定义中的对象混合工作是非常常见的。我们不能对内置类施加多态性。
我们如何利用内置函数来编写对类型灵活的函数和方法?对于这个配方,我们将重用本章前面使用类型提示进行设计配方中的处理。
10.2.1 准备工作
在使用类型提示进行设计的配方中,我们定义了一个名为 datafile_iter()的函数,该函数发出两种不同的对象:Path 对象和 Referenced 对象。一个 Referenced 对象是一组 Path 实例的集合,显示一个被一个或多个应用程序使用的数据文件。一个独立的 Path 对象是一个未被任何应用程序使用的数据文件。这些未引用的路径是移除以减少杂乱的对象。
我们需要以不同的方式处理这两类对象。它们由单个生成函数 datafile_iter()创建。此函数发出一系列未引用和引用实例。这种混合意味着应用程序必须通过类型过滤对象。
应用程序将与一系列对象一起工作。这些对象将由以下定义的函数创建:
from collections.abc import Iterator
DataFileIter: TypeAlias = Iterator[Unreferenced | Referenced]
def datafile_iter(base: Path) -> DataFileIter:
datafile_iter()函数将生成一系列未引用和引用的对象。这将反映给定目录中文件的状态。一些将在源代码中有引用;其他则没有任何引用。请参阅使用类型提示进行设计配方中的此函数。
10.2.2 如何操作...
执行分析的应用程序函数将消费各种类型的对象。该函数设计如下:
-
从以下定义开始,该定义显示了消耗的类型:
from collections.abc import Iterable def analysis(source: Iterable[Unreferenced | Referenced]) -> None: -
创建一个空列表,该列表将保存具有引用的数据文件。编写 for 语句以从源可迭代对象中消费对象,并填充该列表:
good_files: list[Referenced] = [] for file in source: -
为了通过类型区分对象,我们可以使用 isinstance()函数来查看一个对象是否是给定类型的类。
要区分类,请使用 isinstance()函数:
if isinstance(file, Unreferenced): print(f"delete {file}") elif isinstance(file, Referenced): good_files.append(file) -
虽然技术上是不必要的,但似乎总是明智地包括一个 else 条件,在不太可能的情况下,如果 datafile_iter 函数以某种惊人的方式更改,则引发异常:
else: raise ValueError(f"unexpected type {type(file)}")关于此设计模式的更多信息,请参阅第二章中的设计复杂的 if...elif 链配方。
-
编写最终的总结:
print(f"Keep {len(good_files)} files")
10.2.3 它是如何工作的...
isinstance()函数检查一个对象属于哪些类。第二个参数可以是单个类或替代类的元组。
重要的是要注意,一个对象通常有许多父类,形成一个从类对象起源的晶格。如果使用多重继承,可以通过超级类定义有大量的路径。isinstance()函数检查所有替代父类。
isinstance()函数不仅了解在应用程序中导入和定义的类,还了解 TypeAlias 名称。这为我们提供了很大的灵活性,可以在类型提示中使用有意义的名称。
在 Python 3.12 中,TypeAlias 构造可以替换为新的 type 语句:
type Unreferenced = Path
有关 mypy 工具对类型语句的支持的更多信息,请参阅Mypy Issue #15238。
在这个问题得到解决之前,我们选择在这本书中使用 TypeAlias。
10.2.4 更多内容...
isinstance()函数是与 filter()高阶函数配合得很好的布尔函数。有关更多信息,请参阅第九章(ch013_split_000.xhtml#x1-5270004)中的选择子集 – 三种过滤方式配方。
除了内置的 isinstance()函数用于查询对象外,还有一个 iscsubclass()函数允许应用程序检查类型定义。区分类的实例和类对象很重要;iscsubclass()函数用于检查类型定义。iscsubclass()函数通常用于元编程:关注软件本身而不是应用程序数据的软件。当设计处理对象类型的函数而不是对象时,iscsubclass()函数是必要的。
在检查对象类型时,match 语句通常比 isinstance()函数更好。原因是 match 语句的 case 子句具有非常复杂的类型模式匹配,而 isinstance()函数仅限于确保对象在其父类中具有给定的类(或类元组中的类)。
10.2.5 另请参阅
-
有关替代方案,请参阅使用 match 语句配方。
-
有关与玩牌和它们所涉及的有趣类层次结构相关的多个配方,请参阅第七章(ch011_split_000.xhtml#x1-3760007)和第八章(ch012.xhtml#x1-4520008)。
10.3 使用 match 语句
定义一组紧密相关的类型的一个重要原因是为了区分应用于对象的处理方式。提供不同行为的一种技术是通过使用多态设计:多个子类提供了对公共函数的不同实现。当我们完全使用自己的类时,我们可以设计它们具有共同的方法和属性,但根据涉及哪个子类提供不同的行为。这一点在第八章(ch012.xhtml#x1-4520008)中有详细说明。
当与 Python 的内部对象一起工作,或者与涉及我们定义的类和 Python 内部部分内置类的数据集合一起工作时,通常不可能实现这一点。在这些情况下,简单地依赖类型匹配来实现不同的行为会更简单。本章中使用内置类型匹配函数配方中展示了一种方法。
我们还可以使用 match 语句来编写灵活的函数和方法,这些函数和方法可以处理各种类型的参数值。对于这个配方,我们将重用本章前面提到的使用类型提示进行设计和使用内置类型匹配函数配方中的处理过程。
10.3.1 准备工作
在使用类型提示进行设计配方中,我们定义了一个 datafile_iter()函数,该函数发出两种不同的对象类型:Path 对象和 Referenced 对象。
我们需要以不同的方式处理这两种类型的对象。这种混合意味着应用程序必须通过它们的类型来过滤它们。
10.3.2 如何做...
应用程序将处理一系列不同类型的对象。函数设计如下:
-
从以下定义开始,显示消耗的类型:
from collections.abc import Iterable def analysis(source: Iterable[Unreferenced | Referenced]) -> None:这个函数将消耗一个对象的可迭代序列。这个函数将计算具有引用的对象数量。它将建议删除没有引用的文件。
-
创建一个空列表,用于存储具有引用的数据文件。编写 for 语句以从源可迭代中消耗对象:
good_files: list[Referenced] = [] for file in source: -
使用文件变量开始编写 match 语句:
match file: -
要处理各种类别的文件,创建显示必须匹配的对象类型的 case 语句。这些 case 语句在 match 语句内缩进:
case Unreferenced() as unref: print(f"delete {unref}") case Referenced() as ref: good_files.append(file) -
虽然技术上不是必需的,但似乎总是明智地包括一个 case : condition。 将匹配任何内容。在这个子句的主体中,如果 datafile_iter 函数以某种惊人的方式被更改,可能会抛出异常:
case _: raise ValueError(f"unexpected type {type(file)}")更多关于这种设计模式的信息,请参阅第二章中的设计复杂的 if...elif 链配方。
-
编写最终的总结:
print(f"Keep {len(good_files)} files")
10.3.3 它是如何工作的...
match 语句使用一系列 case 子句来建立与给定对象匹配的类。虽然存在许多不同的 case 子句,但一个常见的 case 是 case class() as name: variant,称为类模式。在括号内,我们可以提供子模式来匹配具有特定参数类型的对象。
对于这个例子,我们不需要更复杂的匹配模式。我们可以提供一个看起来像实例的东西——由类名和()组成——以表明 case 子句将匹配类的实例。不需要有关实例结构的任何额外细节。
case Unreferenced()的使用几乎看起来像表达式 Unreferenced()将创建 Unreferenced 类的一个实例。这里的意图不是创建一个对象,而是编写一个看起来非常像对象创建的表达式。这种语法有助于阐明使用 case 来匹配命名类中任何对象的意图。
其他模式允许匹配简单的字面值、序列和映射,以及类。此外,还有方法提供替代方案组,甚至可以通过与模式匹配一起使用的守卫条件应用额外的过滤。
case _ 子句是一个通配符子句。它将匹配在匹配语句中提供的任何内容。_ 变量名在这里有特殊意义,并且只能使用这个变量。
这个设计的关键是 case 定义的清晰性。这些比一系列 elif 子句中的 isinstance()函数评估更易于阅读。
10.3.4 更多内容...
我们将扩展这个配方,展示这些 case 子句中一些复杂的类型匹配。考虑我们想要从只包含一个引用它的应用程序列表中的引用文件中分离出来的情况。
我们正在寻找看起来像这个具体示例的对象:
single use: Referenced(datafile=PosixPath(’race_result.json’), recipes=[PosixPath(’ch11/recipe_06.py’)])
这种情况可以总结为 Referenced(_, [Path()])。我们想要匹配一个 Referenced 类的实例,其中第二个参数是一个包含单个 Path 实例的列表。
这变成了一个新的 case 子句。以下是新的、更具体的 case 子句,后面跟着更一般的 case 子句:
case Referenced(_, [Path()]) as single:
print(f"single use: {single}")
good_files.append(single)
case Referenced() as multiple:
good_files.append(multiple)
匹配语句按顺序处理情况。更具体的情况必须先于更不具体的情况。如果我们颠倒这两个情况的顺序,case Referenced()将先匹配,而 case Referenced(_, [Path()])甚至不会被检查。最一般的情况,case _:,必须是最后的。
10.3.5 参考信息
-
请参阅使用内置类型匹配函数的配方,了解使用内置 isinstance()函数的替代方法。
-
请参阅第八章,了解与多态类设计相关的几个配方。有时,这可以减少对类型匹配的需求。
10.4 处理类型转换
Python 的一个有用特性是“数值塔”概念。请参阅 Python 标准库文档中的数值塔。这个概念是指数值可以从整数移动到有理数,再到实数,最后到复数,沿着塔“向上”移动。
数值转换基于这样的想法,即存在几个重叠的数值域。这些包括ℤ整数、ℚ有理数、ℙ无理数、ℝ实数和ℂ复数。这些形成了一个嵌套的集合系列:ℤ ⊂ℚ ⊂ℝ ⊂ℂ。此外,ℚ ∪ℙ = ℝ:实数包括有理数和无理数。
这些内置的数值类型遵循抽象概念:
-
ℂ通过复数类型实现。任何低于此类型的类型都可以转换为复数值。
-
ℝ由 float 类型支持。需要注意的是,float 涉及近似,并不完全符合实数的数学理想。当这个类中的运算符遇到 int 或分数值时,它将创建等效的 float 值。
-
ℚ使用 fractions 模块中的 Fraction 类。当 Fraction 类中的算术运算符遇到 int 时,它将静默地创建一个与整数具有相同值的 Fraction。
= z。 -
ℤ是 int 类。
通常,Python 语言避免过多地转换为其他类型。例如,字符串不会自动解析以创建数值。需要使用显式的内置函数如 int()或 float()来处理包含数字的字符串。
我们经常希望自己的类型共享这种行为。我们希望我们的函数是灵活的,并在需要时将对象转换为其他类型。例如,我们可能希望允许纬度-经度点的多种表示。这些替代方案可能包括:
-
一个包含两个浮点数值的元组
-
一对字符串,每个字符串代表一个浮点值
-
一个包含两个由逗号字符分隔的数值的单个字符串
与数值塔一样,我们自己的类定义需要将其他类型转换为所需的目标类型。
10.4.1 准备工作
我们将考虑一个计算地球上表面两点之间距离的函数。这涉及到一些巧妙的球面三角学。更多信息,请参阅第三章,特别是基于部分函数选择参数顺序配方。还可以参阅第七章中的创建上下文和上下文管理器配方。
函数定义如下:
def haversine(
lat_1: float, lon_1: float,
lat_2: float, lon_2: float, *, R: float) -> float:
... # etc.
这个定义需要将源数据转换为单个浮点值。在集成来自多个源的数据的应用中,这些转换非常常见,因此最好将它们集中到一个封装基本 haversine()计算的函数中。
我们需要一个这样的函数:
def distance(
*args: str | float | tuple[float, float],
R: float = NM
) -> float:
此函数将计算定义为各种数据类型的点之间的距离。*args 参数意味着所有位置参数值将组合成一个单一的元组。必须应用一系列验证规则来理解这个元组。以下是我们将开始的规则:四个浮点值:直接使用这些值。例如:distance(36.12, -86.67, 33.94, -118.40, R=6372.8)。四个字符串:将这些字符串转换为浮点值。例如:distance("36.12", "-86.67", "33.94", "-118.40", R=6372.8)。两个字符串:解析每个字符串,以逗号分隔。每个字符串应包含两个浮点值。例如:distance("36.12,-86.67", "33.94,-118.40", R=6372.8)。两个元组:解包每个元组以确保它包含两个浮点值。例如:distance((36.12, -86.67), (33.94, -118.40), R=6372.8)。
理想情况下,也许支持这些组合也不错。我们将设计一个执行所需类型转换的函数。
10.4.2 如何实现...
包含类型转换的函数通常与底层处理分开构建。如果将这些处理方面的两个部分——转换和计算——分开,这有助于测试和调试:
-
导入所需的
literal_eval()函数以转换预期为 Python 字面量的字符串:from ast import literal_eval使用这个函数,我们可以评估
literal_eval("2,3")来得到一个正确的元组结果,(2, 3)。我们不需要使用正则表达式来分解字符串以查看文本的模式。 -
定义执行转换的距离函数:
def distance( *args: str | float | tuple[float, float], R: float = NM ) -> float: -
开始匹配各种参数模式的匹配语句。
match args: -
编写单独的情况,从更具体到更不具体。从四个不同的浮点值开始,因为不需要进行转换。浮点值的元组具有更复杂的类型结构,但不需要任何转换。
case [float(lat_1), float(lon_1), float(lat_2), float(lon_2)]: pass case ( [[float(lat_1), float(lon_1)], [float(lat_2), float(lon_2)]] ): pass我们提供了
lat_1、lon_1、lat_2和lon_2变量,以便将args结构中的值绑定到变量名。这使我们免去了编写解包参数元组的赋值语句。使用pass语句占位符是因为不需要进行除解包数据结构之外的其他处理。 -
编写涉及提供的值转换的情况:
case [str(s1), str(s2), str(s3), str(s4)]: lat_1, lon_1, lat_2, lon_2 = ( float(s1), float(s2), float(s3), float(s4) ) case [str(ll1), str(ll2)]: lat_1, lon_1 = literal_eval(ll1) lat_2, lon_2 = literal_eval(ll2)当参数值是四个字符串时,我们提供了四个变量来解包这四个字符串。
当参数模式是两个字符串时,我们提供了两个变量,ll1 和 ll2,每个变量都需要被转换为两个数字元组然后解包。
-
编写一个默认情况,它会匹配任何其他情况并引发异常:
case _: raise ValueError(f"unexpected types in {args!r}") -
现在参数已经被正确解包并且应用了任何转换,使用
haversine()函数来计算所需的结果:return haversine(lat_1, lon_1, lat_2, lon_2, R=R)
10.4.3 它是如何工作的...
类型转换的基本功能是使用匹配语句为支持的类型提供适当的转换。在这个例子中,我们容忍了可以转换和解包的字符串和元组的混合,以定位所需的四个参数值。匹配语句有许多聪明的类型匹配规则。例如,表达式 ((float(f1), float(f2)), (float(f3), float(f4))) 将匹配两个元组,每个元组包含两个浮点值。此外,它从元组中解包值并将它们分配给四个变量。
转换值的机制也基于内置功能。float() 函数将数字字符串转换为浮点值或引发 ValueError 异常。
ast.literal_eval() 函数对于评估字符串形式的 Python 字面量非常方便。该函数由于仅限于字面值和一些简单数据结构(由字面值构建的元组、列表、字典和集合)而安全,因此不会评估危险的表达式。它允许我们直接将字符串 "36.12,-86.67" 解析为 (36.12, -86.67)。
10.4.4 更多...
使用独立的 case 子句使得添加额外的类型转换相对容易。例如,我们可能想要处理看起来像 {"lat": 36.12, "lon": -86.67} 的两个字典结构的元组。这可以与以下 case 匹配:
case (
{"lat": float(lat_1), "lon": float(lon_1)},
{"lat": float(lat_2), "lon": float(lon_2)}
):
pass
参数元组模式周围有括号(()),这使得它很容易被拆分成多行。从字典中提取的四个值将被绑定到四个目标变量上。
如果我们想要允许更多的灵活性,我们可以考虑这种情况:我们有两个类型模式的混合参数值。例如,distance("36.12,-86.67", (33.94, -118.40), R=6372.8)。这有两种不同的格式:一个字符串和一个包含一对浮点值的元组。
而不是列举所有可能的组合,我们可以将一对值的解析分解成一个单独的函数,parse(),它将对两个参数值应用相同的转换:
case [p_1, p_2]:
lat_1, lon_1 = parse(p_1)
lat_2, lon_2 = parse(p_2)
这个新的 parse() 函数必须处理提供经纬度值的所有情况。这包括字符串、元组和映射。它看起来是这样的:
def parse(item: Point | float) -> tuple[float, float]:
match item:
case [float(lat), float(lon)]:
pass
case {"lat": float(lat), "lon": float(lon)}:
pass
case str(sll):
lat, lon = literal_eval(sll)
case _:
raise ValueError(f"unexpected types in {item!r}")
return lat, lon
这将稍微简化 distance 函数中的 match 语句。重构后的语句只处理四种情况:
match args:
case [float(lat_1), float(lon_1), float(lat_2), float(lon_2)]:
pass
case [str(s1), str(s2), str(s3), str(s4)]:
lat_1, lon_1, lat_2, lon_2 = float(s1), float(s2), float(s3), float(s4)
case [p_1, p_2]:
lat_1, lon_1 = parse(p_1)
lat_2, lon_2 = parse(p_2)
case _:
raise ValueError(f"unexpected types in {args!r}")
前两种情况处理提供了四个参数值的情况。第三种情况查看一对值,这些值可以是任何一对格式。
我们明确避免提供三个参数值的情况。这需要更多的注意来解释,因为三个参数值中的一个必须是经纬度对。其他两个值必须是分开的经纬度值。逻辑并不特别复杂,但细节偏离了这个配方的核心思想。
虽然这个配方侧重于内置类型,包括 str 和 float,但任何类型都可以使用。例如,可以很容易地在 case 子句中添加一个自定义的 Leg 类型,它具有起始和结束位置。
10.4.5 参见
-
关于数字和转换的更多信息,请参阅第一章的 选择 float、decimal 和 fraction 之间的区别 配方。这提供了一些关于 float 近似限制的更多信息。
-
关于 haversine() 函数的更多信息,请参阅第三章的 基于部分函数选择参数顺序 配方。还可以参阅第七章的 创建上下文和上下文管理器 配方。
10.5 使用 Pydantic 实现更严格的类型检查
对于大多数情况,Python 的内部处理将正确地处理许多简单的有效性检查。如果我们编写了一个将字符串转换为浮点数的函数,该函数将处理浮点值和字符串值。如果我们尝试将 float() 函数应用于 Path 对象,它将引发 ValueError 异常。
为了使类型提示可选,运行时类型检查是确保某些处理可以继续的最小检查级别。这与 mypy 等工具执行的严格检查截然不同。
类型提示不执行运行时处理。
Python(不使用任何附加包)在运行时不会进行数据类型检查或值范围检查。当操作符遇到它无法处理的类型时,会引发异常,而不考虑类型提示。
这意味着 Python 可能能够处理被提示排除的类型。可以编写一个窄提示,如 list[str]。具有给定函数体的 set[str] 对象也可能与 Pydantic 包一起工作。
在某些应用中,我们希望在运行时进行更强的检查。这些通常在需要使用扩展或插件的程序中很有帮助,我们希望确保额外的插件代码表现良好。
提供运行时类型检查的一种方法是通过使用 Pydantic 包。此模块允许我们定义带有运行时类型检查的复杂对象,以及可以广泛共享的模式定义管理。
在第五章,在 创建字典 – 插入和更新 食谱中,我们查看了一个需要解析成更有用结构的日志文件。在第九章,在 使用 yield 语句编写生成器函数 食谱中,我们查看了一个编写生成器函数的例子,该函数将解析并生成解析后的对象。我们将这些生成的对象称为 RawLog,没有类型检查或类型转换。我们应用了一个简单的转换来创建一个带有日期时间戳从文本转换为 datetime.datetime 对象的 DatedLog 实例。
Pydantic 包可以处理一些转换为 DatedLog 实例的转换,从而节省我们一些编程工作。此外,由于模式可以自动生成,我们可以构建 JSON Schema 定义并执行 JSON 序列化,而无需进行大量复杂的工作。
必须下载并安装 Pydantic 包。通常,这可以通过以下终端命令完成:
(cookbook3) % python -m pip install pydantic
使用 python -m pip 命令确保我们将使用与当前活动虚拟环境一起的 pip 命令,在示例中显示为 cookbook3。
10.5.1 准备工作
日志数据中的日期时间戳以字符串值表示。我们需要解析这些值以创建适当的 datetime 对象。为了使本食谱中的内容更集中,我们将使用 Flask 编写的 Web 服务器生成的简化日志。
条目最初是类似以下的文本行:
[2016-06-15 17:57:54,715] INFO in ch10_r10: Sample Message One
[2016-06-15 17:57:54,716] DEBUG in ch10_r10: Debugging
[2016-06-15 17:57:54,720] WARNING in ch10_r10: Something might have gone wrong
我们在第八章的使用更复杂结构 - 列表映射配方中看到了其他处理这种日志的例子。使用第一章中的使用正则表达式进行字符串解析配方中的 RE,我们可以将每一行分解成更有用的结构。
观察其他配方,用于解析的正则表达式具有一个重要的特性。在 (?P
我们需要定义一个类,以有用的形式捕获每条日志行的基本内容。我们将使用 Pydantic 包来定义和填充这个类。
10.5.2 如何操作...
-
为了创建这个类定义,我们需要以下导入:
import datetime from enum import StrEnum from typing import Annotated from pydantic import BaseModel, Field -
为了正确验证具有多个值的字符串,需要一个 Enum 类。我们将定义 StrEnum 的子类来列出有效的字符串值。每个类级别变量提供了一个名称和用于名称序列化的字符串字面量:
class LevelClass(StrEnum): DEBUG = "DEBUG" INFO = "INFO" WARNING = "WARNING" ERROR = "ERROR"在这个类中,Python 属性名称和字符串字面量相匹配。这不是一个要求。对于这个枚举字符串值的集合来说,这碰巧很方便。
-
这个类将是 pydantic 包中的 BaseModel 类的子类:
class LogData(BaseModel):BaseModel 类必须是任何使用 pydantic 特性的模型的超类。
-
我们将定义每个字段,字段名称与解析字段使用的正则表达式中的组名称相匹配。这不是一个要求,但它使得从正则表达式匹配对象的部分组字典构建 LogData 类的实例变得非常容易:
date: datetime.datetime level: LevelClass module: Annotated[str, Field(pattern=r’^\w+$’)] message: str日期被定义为 datetime.datetime 实例。从 BaseModel 类继承的方法将处理这个转换。级别是 LevelClass 的实例。同样,BaseModel 的特性将为我们处理这个转换。我们使用了 Annotated 类型来提供类型,str,以及一个注解参数,Field(...)。这将由 BaseModel 的方法用于验证字段的内容。
这是一个生成器函数,用于读取和解析日志记录:
from typing import Iterable, Iterator
def logdata_iter(source: Iterable[str]) -> Iterator[LogData]:
for row in source:
if match := pattern.match(row):
l = LogData.model_validate(match.groupdict())
yield l
这将使用正则表达式模式,pattern 来解析每条记录。组字典,match.groupdict() 将包含组名称和解析后的文本。BaseModel 的 model_validate() 方法将从编译的正则表达式创建的字典构建 LogData 类的实例。
当我们使用这个 logdata_iter 函数来创建 LogData 类的实例时,它看起来像以下示例:
>>> from pprint import pprint
>>> pprint(list(logdata_iter(data.splitlines())))
[LogData(date=datetime.datetime(2016, 6, 15, 17, 57, 54, 715000), level=<LevelClass.INFO: ’INFO’>, module=’ch10_r10’, message=’Sample Message One’),
LogData(date=datetime.datetime(2016, 6, 15, 17, 57, 54, 716000), level=<LevelClass.DEBUG: ’DEBUG’>, module=’ch10_r10’, message=’Debugging’),
LogData(date=datetime.datetime(2016, 6, 15, 17, 57, 54, 720000), level=<LevelClass.WARNING: ’WARNING’>, module=’ch10_r10’, message=’Something might have gone wrong’)]
此函数已将文本行转换为填充了适当的 Python 对象(datetime.datetime 实例和来自 LevelClass 的枚举值)的 LogData 对象:进一步,它验证了模块名称以确保它们匹配特定的正则表达式模式。
10.5.3 它是如何工作的...
Pydantic 包包括许多用于数据验证和类定义的工具。Python 的类型使用,以及更详细的 Annotated 类型,提供了帮助我们定义类成员的语法,包括数据转换和数据验证。在这个例子中,转换是隐含的;类提供了目标类型,从 BaseModel 类继承的方法确保源数据被正确转换为所需的目标类型。
这个小的类定义有三个不同类型的类型提示:
-
日期和级别字段涉及转换为目标类型。
-
模块字段使用注解类型为属性提供了 Pydantic Field 定义。正则表达式模式将检查每个字符串值以确保它匹配所需的模式。
-
提供的消息字段提供了一个简单的类型,该类型将与源数据类型匹配。对于此字段不会执行任何额外的验证。
@dataclass 和 BaseModel 子类的工作方式之间有一些相似之处。Pydantic 包提供了比 dataclass 定义更为复杂的定义。例如,@dataclass 不执行类型检查或任何自动数据转换。在定义 dataclass 时提供的类型信息主要对像 mypy 这样的工具感兴趣。相比之下,BaseModel 的子类执行了更多的自动化转换和运行时类型检查。
DataModel 的子类附带了许多方法。
model_dump_json() 和 model_validate_json() 方法对于网络服务特别有帮助,在这些服务中,应用程序通常与以 JSON 表示法表示的对象状态的 RESTful 传输一起工作。这些可以序列化为换行符分隔的文件,以便将多个复杂对象收集到以标准化物理格式存储的文件中。
Pydantic 包通常非常快。当前版本涉及编译为提供非常高性能的 Python 扩展。显然,缺少许多 Pydantic 功能的数据类将更快,但功能更少。然而,额外的数据验证通常值得额外的开销。
10.5.4 更多...
与 Pydantic 一起工作的一个好处是自动支持 JSON Schema 定义和 JSON 序列化。
这显示了我们可以如何获取模型的 JSON Schema:
>>> import json
>>> print(json.dumps(LogData.model_json_schema(), indent=2))
JSON Schema 的细节很长,与 Python 类的定义相匹配。我们省略了输出。
我们可以将这些 LogData 实例以 JSON 表示法进行序列化。以下是它的样子:
>>> for record in logdata_iter(data.splitlines()):
... print(record.model_dump_json())
{"date":"2016-06-15T17:57:54.715000","level":"INFO","module":"ch10_r10","message":"Sample Message One"}
{"date":"2016-06-15T17:57:54.716000","level":"DEBUG","module":"ch10_r10","message":"Debugging"}
{"date":"2016-06-15T17:57:54.720000","level":"WARNING","module":"ch10_r10","message":"Something might have gone wrong"}
我们已经使用 model_dump_json()方法将对象序列化为 JSON 文档。这使我们能够将来自各种来源的文档转换为通用格式。这使得围绕通用格式创建分析处理变得容易,将解析、合并和验证与分析和分析处理的有趣结果分开。
10.5.5 参考信息
-
有关一些可能的附加验证规则,请参阅包含运行时有效值检查配方。
-
有关数据类的更多信息,请参阅第七章中的使用数据类处理可变对象配方。Pydantic 对数据类的变体通常比数据类模块更有用。
-
有关以 JSON 格式读取数据的更多信息,请参阅第十一章中的读取 JSON 和 YAML 文档配方。
10.6 包含运行时有效值检查
数据分析通常涉及大量的“数据处理”:处理无效数据或异常数据。源应用程序软件的变化很常见,导致数据文件的新格式,当解析这些文件时,可能会给下游分析应用带来问题。企业流程或政策的变更可能会导致新的数据类型或新的编码值,这可能会干扰分析处理。
类似地,当与机器和机器人(有时称为物联网)一起工作时,设备在启动时或无法正常工作时提供无效数据是很常见的。在某些情况下,当不良数据到达时,可能需要发出警报。在其他情况下,超出范围的数据需要被悄悄忽略。
Pydantic 包提供了非常复杂的验证函数,允许我们有两个选择:
-
将数据从非标准格式转换为 Python 对象。
-
对于无法转换或未能通过更具体领域检查的数据,抛出异常。
在某些情况下,我们还需要验证生成的对象在内部是否一致。这通常意味着必须检查几个字段是否相互一致。这被称为模型验证,它与单独字段验证是不同的。
验证的概念可以扩展。它可以包括拒绝无效数据,以及过滤掉对于特定应用来说有效但无趣的数据。
10.6.1 准备工作
我们正在查看美国国家海洋和大气管理局(NOAA)关于海岸潮汐的数据。移动一艘大型帆船意味着确保有足够的水让它浮起来。这个约束条件要求检查已知浅且难以通过的地区的潮汐高度预测。
特别是,一个名为 El Jobean 的地方,位于 Myakka 河上,有一个浅滩,在穿越时需要小心。我们可以从 NOAA 潮汐和流速网站获取潮汐预测。这个网页允许输入日期范围并下载给定日期范围的潮汐预测文本文件。
生成的文本文件看起来如下所示:
NOAA/NOS/CO-OPS
Disclaimer: These data are based upon the latest information available as of the date of your request, and may differ from the published tide tables.
Daily Tide Predictions
StationName: EL JOBEAN, MYAKKA RIVER
State: FL
Stationid: 8725769
...
Date Day Time Pred High/Low
2024/04/01 Mon 04:30 -0.19 L
2024/04/01 Mon 20:07 1.91 H
...
这些数据几乎符合 CSV 格式,但一些怪癖使其难以处理。以下是一些复杂因素:
-
在有用的列标题行之前,文件有 19 行数据。
-
列使用制表符(\t)作为分隔符,而不是逗号。
-
相关数据的标题行中隐藏了一些多余的空格。
以下函数将提供干净的 CSV 行以供进一步处理:
import csv
from collections.abc import Iterator
from typing import TextIO
def tide_table_reader(source: TextIO) -> Iterator[dict[str, str]]:
line_iter = iter(source)
for line in line_iter:
if len(line.rstrip()) == 0:
break
header = next(line_iter).rstrip().split(’\t’)
del header[1] # Extra tab in the header
reader = csv.DictReader(line_iter, fieldnames=header, delimiter=’\t’)
yield from reader
标题注释中的额外制表符用于处理标题,其中包含一个额外的空格字符。这个标题行在日期和日期列名称之间有两个制表符(\t):
’Date \t\tDay\tTime\tPred\tHigh/Low\n’
有关从列表中删除项的技术,请参阅第四章中的切片和切块列表配方。
这个列名称列表可以用来构建一个 DictReader 实例以消费其余的数据。(有关 CSV 文件,请参阅第十一章中的使用 CSV 模块读取分隔文件配方。)
我们可以使用 Pydantic 验证功能将每个字典转换为类实例。
10.6.2 如何做到这一点...
核心数据模型将验证数据行,创建一个类的实例。我们可以向这个类添加功能以处理特定于应用程序的处理。以下是构建这个类的方法:
-
从每行中的数据类型导入开始,加上 BaseModel 类和一些相关类:
import datetime from enum import StrEnum from typing import Annotated from pydantic import BaseModel, Field, PlainValidator -
定义高/低列的值域。这两个代码作为 Enum 子类的枚举:
class HighLow(StrEnum): high = "H" low = "L" -
由于日期文本不是 Pydantic 使用的默认格式,我们需要定义一个验证函数,该函数将从给定的字符串生成日期对象:
def validate_date(v: str | datetime.date) -> datetime.date: match v: case datetime.date(): return v case str(): return datetime.datetime.strptime(v, "%Y/%m/%d").date() case _: raise TypeError("can’t validate {v!r} of type {type(v)}")Pydantic 验证器可以用于内部 Python 对象以及来自源 CSV 文件或 JSON 文档的字符串。当应用于 datetime.date 对象时,不需要额外的转换。
-
定义模型。字段定义的 validation_alias 参数将从字典中的源字段中提取数据,该字段与类中目标属性名称不完全相同:
class TideTable(BaseModel): date: Annotated[ datetime.date, Field(validation_alias=’Date ’), PlainValidator(validate_date)] day: Annotated[ str, Field(validation_alias=’Day’)] time: Annotated[ datetime.time, Field(validation_alias=’Time’)] prediction: Annotated[ float, Field(validation_alias=’Pred’)] high_low: Annotated[ HighLow, Field(validation_alias=’High/Low’)]每个字段使用 Annotated 类型来定义基本类型,以及验证字符串和将它们转换为该类型所需的其他详细信息。
天字段(包含星期几)实际上并不有用。它是从日期派生出来的数据。出于调试目的,这个数据被保留。
给定这个类,我们可以用它来验证来自一系列字典实例的模型实例。它看起来是这样的:
>>> tides = [TideTable.model_validate(row) for row in dict_rows]
>>> tides[0]
TideTable(date=datetime.date(2024, 4, 1), day=’Mon’, time=datetime.time(4, 30), prediction=-0.19, high_low=<HighLow.low: ’L’>)
>>> tides[-1]
TideTable(date=datetime.date(2024, 4, 30), day=’Tue’, time=datetime.time(19, 57), prediction=1.98, high_low=<HighLow.high: ’H’>)
这个对象序列包含太多数据。我们可以使用 Pydantic 来过滤数据,并只传递有用的行。我们将通过修改这个类定义并创建一个包含要传递的数据规则的替代方案来实现这一点。
10.6.3 它是如何工作的...
BaseModel 类包括一些与类属性注释类型提示一起工作的操作。考虑这个类型提示:
date: Annotated[
datetime.date,
Field(validation_alias=’Date ’),
PlainValidator(validate_date)]
这提供了一个基础类型 datetime.date。它提供了一个 Field 对象,可以从字典中提取名为 'Date' 的字段,并对其应用验证规则。最后,PlainValidator 对象提供了一个一步验证规则,应用于源数据。validate_date() 函数被编写为接受已验证的日期对象,并将字符串对象转换为日期对象。这允许验证用于原始数据以及 Python 对象。
在这个例子中,我们的应用程序涉及对数据域的某些缩小。有三个重要标准:
-
我们只对高潮预测感兴趣。
-
我们希望潮高至少比基准线高 1.5 英尺(45 厘米)。
-
我们需要这个操作在 10:00 之后和 17:00 之前发生。
我们可以利用 Pydantic 来执行额外的验证,以缩小数据域。这些额外的验证可以拒绝低于 1.5 英尺的最小潮高。
10.6.4 更多内容...
我们可以将此模型扩展以添加验证规则,将有效行的域缩小到符合我们基于一天中的时间和潮高选择标准的那些行。我们将在任何数据转换之后应用这些更窄的数据验证规则。这些规则将引发 ValidationError 异常。这扩展了从 pydantic 包中的导入。
我们将定义一系列额外的验证函数。以下是一个为低潮数据引发异常的验证器:
BaseModel, Field, PlainValidator, AfterValidator, ValidationError
)
def pass_high_tide(hl: HighLow) -> HighLow:
assert hl == HighLow.high, f"rejected low tide"
return hl
assert 语句对于这个任务来说非常简洁。这也可以用 if 和 raise 来完成。
类似的验证器可以引发异常,对于超出可接受时间窗口的数据:
def pass_daylight(time: datetime.time) -> datetime.time:
assert datetime.time(10, 0) <= time <= datetime.time(17, 0)
return time
最后,我们可以将这些额外的验证器组合到注释类型定义中:
class HighTideTable(BaseModel):
date: Annotated[
datetime.date,
Field(validation_alias=’Date ’),
PlainValidator(validate_date)]
time: Annotated[
datetime.time,
Field(validation_alias=’Time’),
AfterValidator(pass_daylight)] # Range check
prediction: Annotated[
float,
Field(validation_alias=’Pred’, ge=1.5)] # Minimum check
high_low: Annotated[
HighLow,
Field(validation_alias=’High/Low’),
AfterValidator(pass_high_tide)] # Required value check
额外的验证器将拒绝不符合我们狭窄要求的标准的数据。输出将只包含高潮,潮高大于 1.5 英尺,并且在白天。
这些数据形成了一个高潮表实例的序列,如下所示:
>>> from pathlib import Path
>>> data = Path("data") / "tide-table-2024.txt"
>>> with open(data) as tide_file:
... for ht in high_tide_iter(tide_table_reader(tide_file)):
... print(repr(ht))
HighTideTable(date=datetime.date(2024, 4, 7), time=datetime.time(15, 42), prediction=1.55, high_low=<HighLow.high: ’H’>)
...
HighTideTable(date=datetime.date(2024, 4, 10), time=datetime.time(16, 42), prediction=2.1, high_low=<HighLow.high: ’H’>)
...
HighTideTable(date=datetime.date(2024, 4, 26), time=datetime.time(16, 41), prediction=2.19, high_low=<HighLow.high: ’H’>)
我们省略了一些行,只显示第一行、中间的一行和最后一行。这些是具有 Python 对象属性的高潮表对象,适合进一步分析和处理。
Pydantic 设计的一般方法意味着组合原始数据字段、转换数据和过滤数据的规则都是分开的。我们可以放心地更改这些规则之一,而不用担心破坏应用程序的其他部分。
这个配方包括三种检查方法:
-
范围检查以确保连续值在允许的范围内。AfterValidator 用于确保字符串被转换为时间。
-
确保连续值超过限制的最小检查。对于数字,这可以通过字段定义直接完成。
-
必要值检查以确保离散值具有所需的其中一个值。AfterValidator 用于确保字符串被转换为枚举类型。
这些类型的检查在基本类型匹配之后执行,并用于应用更窄的验证规则。
10.6.5 参见
-
在第十一章中,我们将更深入地探讨读取数据文件。
-
参见 使用 Pydantic 实现更严格的类型检查 菜单以获取使用 Pydantic 的更多示例。Pydantic 使用编译的 Python 扩展来应用验证规则,几乎没有开销。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第十一章:11
输入/输出、物理格式和逻辑布局
计算通常与持久数据一起工作。可能有源数据需要分析,或者使用 Python 的输入和输出操作创建输出。在游戏中探索的地牢图是游戏应用程序将输入的数据。图像、声音和电影是某些应用程序输出的数据,由其他应用程序输入。甚至通过网络发送的请求也会涉及输入和输出操作。所有这些的共同之处在于数据文件的概念。术语“文件”具有许多含义:
-
操作系统(OS)使用文件作为在设备上组织数据字节的方式。解析字节是应用程序软件的责任。两种常见的设备在操作系统文件的功能方面提供了不同的变体:
-
块设备,如磁盘或固态驱动器(SSD):这类设备上的文件可以定位任何特定的字节,这使得它们特别适合数据库,因为任何行都可以在任何时候进行处理。
-
字符设备,如网络连接、键盘或 GPS 天线。这类设备上的文件被视为传输中的单个字节流。无法向前或向后查找;字节必须被捕获在缓冲区中,并按到达顺序进行处理。
-
-
“文件”这个词还定义了 Python 运行时使用的数据结构。统一的 Python 文件抽象封装了各种操作系统文件实现。当我们打开 Python 文件时,Python 抽象、操作系统实现以及块设备上的底层字节集合或字符设备的字节流之间存在绑定。
Python 为我们提供了两种常见模式来处理文件内容:
-
在“b”(二进制)模式下,我们的应用程序看到的是字节,而不进行进一步解释。这有助于处理具有复杂编码的媒体数据,如图像、音频和电影。我们通常会导入像 pillow 这样的库来处理图像文件编码为字节以及从字节解码的细节。
-
在“t”(文本)模式下,文件的字节是字符串值的编码。Python 字符串由 Unicode 字符组成,有多种方案用于将字节解码为文本以及将文本编码为字节。通常,操作系统有一个首选的编码,Python 会尊重这一点。UTF-8 编码很受欢迎。从实用主义的角度来看,文件可以具有任何可用的 Unicode 编码,并且可能不清楚使用了哪种编码来创建文件。
此外,Python 模块如 shelve 和 pickle 有独特的方式来表示比简单字符串更复杂的 Python 对象。有几种 pickle 协议可用;它们都基于二进制模式文件操作。
在本章中,我们将讨论 Python 对象的序列化。序列化创建了一系列字节,以表示 Python 对象的状态。反序列化是相反的过程:它从文件的字节中恢复 Python 对象的状态。保存和传输对象状态的表示是 REST 网络服务背后的基本概念。
当我们处理来自文件的数据时,我们有两个常见的问题:
-
数据的物理格式:我们需要知道如何解释文件上的字节来重建 Python 对象。字节可能代表 JPEG 编码的图像或 MPEG 编码的电影。一个非常常见的例子是表示 Unicode 文本的文件字节,组织成行。通常,物理格式问题由 Python 库如 csv、json 和 pickle 等处理。
-
数据的逻辑布局:给定的数据集合可能有灵活的位置来存储数据项。CSV 列或 JSON 字段的排列可以不同。在数据包含标签的情况下,逻辑布局是清晰的。如果没有标签,布局是位置性的,需要一些额外的模式信息来识别哪些数据项占据了各种位置。
物理格式解码和逻辑布局模式对于解释文件上的数据都是必不可少的。我们将探讨处理不同物理格式的多个食谱。我们还将探讨将我们的程序与逻辑布局的一些方面分离的方法。
在本章中,我们将探讨以下食谱:
-
使用 pathlib 处理文件名
-
替换文件同时保留旧版本
-
使用 CSV 模块读取分隔文件
-
使用 dataclasses 简化处理 CSV 文件
-
使用正则表达式读取复杂格式
-
读取 JSON 和 YAML 文档
-
读取 XML 文档
-
读取 HTML 文档
为了处理文件,我们将从帮助控制操作系统文件系统的对象开始。Python 的 pathlib 模块描述了文件和设备的目录结构的常见特性。此模块在多个操作系统上具有一致的行为,使得 Python 程序在 Linux、macOS 和 Windows 上可以以类似的方式工作。
11.1 使用 pathlib 处理文件名
大多数操作系统使用包含文件的目录树结构。从根目录到特定文件的路径通常表示为一个字符串。以下是一个示例路径:
/Users/slott/Documents/Writing/Python Cookbook/src/ch11/recipe_01.py
这个完整路径名列出了包含在(未命名的)根目录中的七个命名目录。最后的名称有一个 recipe_01 的前缀和 .py 的后缀。
我们可以将其表示为一个字符串,并解析该字符串以定位目录名称、文件名和后缀字符串。这样做在 macOS 和 Linux 操作系统之间不可移植,它们使用"/"作为分隔符,而 Windows 使用""作为分隔符。此外,Windows 文件可能还有设备名称作为路径的前缀。
处理文件名中的“/”或目录名中的“.”等边缘情况会使字符串处理变得不必要地困难。我们可以通过使用 pathlib.Path 对象而不是字符串来简化解析和许多文件系统操作。
11.1.1 准备工作
重要的是要区分三个概念:
-
一个标识文件的路径,包括文件名
-
文件元数据,如创建时间戳和所有权,存储在目录树中
-
文件内容
文件内容与目录信息无关。多个目录条目链接到相同内容是很常见的。这可以通过硬链接来完成,其中目录信息在多个路径之间共享,以及软链接,其中一种特殊类型的文件包含对另一个文件的引用。
通常,文件名有一个后缀(或扩展名),用作关于物理格式的提示。以.csv 结尾的文件名很可能是一个可以解释为行和列数据的文本文件。这种名称与物理格式之间的绑定不是绝对的。文件后缀只是一个提示,可能会出错。
在 Python 中,pathlib 模块处理所有与路径相关的处理。该模块在路径之间做出几个区分:
-
可能或可能不指向实际文件的纯路径
-
解析的具体路径;这些指的是一个实际文件
这种区分使我们能够为应用可能创建或引用的文件创建纯路径。我们也可以为实际存在于操作系统上的文件创建具体路径。应用程序通常将纯路径解析为具体路径。
虽然 pathlib 模块可以在 Linux 路径对象和 Windows 路径对象之间做出区分,但这种区分很少需要。使用 pathlib 的一个重要原因是,我们希望处理与底层操作系统的细节隔离。
本节中的所有迷你食谱都将利用以下内容:
>>> from pathlib import Path
我们还假设使用 argparse 模块来收集文件或目录名称。有关 argparse 的更多信息,请参阅第六章中的使用 argparse 获取命令行输入食谱。我们将使用一个 options 变量作为命名空间,该命名空间包含食谱处理的输入文件名或目录名。作为一个例子,我们将使用以下 Namespace 对象:
>>> from argparse import Namespace
>>> options = Namespace(
... input=’/path/to/some/file.csv’,
... file1=’data/ch11_file1.yaml’,
... file2=’data/ch11_file2.yaml’,
... )
通常,我们会定义 argparse 选项使用 type=Path,以便参数解析为我们创建 Path 对象。为了展示 Path 对象的工作方式,路径信息以字符串值的形式提供。
11.1.2 如何操作...
在以下迷你食谱中,我们将展示一些常见的路径名操作:
-
通过更改输入文件名后缀来创建输出文件名
-
创建具有不同名称的多个同级输出文件
-
比较文件日期以查看哪个更新
-
查找所有匹配给定模式的文件
前两个反映了处理目录到文件路径的技术;使用 Path 对象比进行复杂的字符串操作要容易得多。最后两个收集有关计算机上具体路径和相关文件的信息。
通过更改输入文件名后缀来创建输出文件名
通过更改输入名称的后缀来创建输出文件名的以下步骤:
-
从输入文件名字符串创建一个 Path 对象:
>>> input_path = Path(options.input) >>> input_path PosixPath(’/path/to/some/file.csv’)显示 PosixPath 类,因为作者正在使用 macOS。在 Windows 机器上,该类将是 WindowsPath。
-
使用 with_suffix()方法创建输出 Path 对象:
>>> output_path = input_path.with_suffix(’.out’) >>> output_path PosixPath(’/path/to/some/file.out’)
所有文件名解析都由 Path 类无缝处理。这不会创建具体的输出文件;它只是为它创建了一个新的 Path 对象。
创建具有不同名称的多个同级输出文件
通过更改输入名称的后缀来创建具有不同名称的多个同级输出文件:
-
从输入文件名字符串创建一个 Path 对象:
>>> input_path = Path(options.input) -
从文件名中提取父目录和基本名称。基本名称是没有后缀的名称:
>>> input_directory = input_path.parent >>> input_stem = input_path.stem -
构建所需的输出名称。对于此示例,我们将追加 _pass 到基本名称并构建完整的 Path 对象:
>>> output_stem_pass = f"{input_stem}_pass" >>> output_stem_pass ’file_pass’>>> output_path = ( ... input_directory / output_stem_pass ... ).with_suffix(’.csv’) >>> output_path PosixPath(’/path/to/some/file_pass.csv’)
/运算符从 Path 组件组装一个新的 Path。我们需要将/运算放在括号中,以确保它首先执行,在更改后缀之前创建一个新的 Path 对象。
比较文件日期以查看哪个更新
以下是比较文件日期以查看哪个更新的步骤:
-
从输入文件名字符串创建 Path 对象。Path 类将正确解析字符串以确定路径的元素:
>>> file1_path = Path(options.file1) >>> file2_path = Path(options.file2)在探索此示例时,请确保 options 对象中的名称是实际文件。
-
使用每个 Path 对象的 stat()方法获取文件的戳记。在 stat 对象中,st_mtime 属性提供了文件的最新修改时间:
>>> file1_path.stat().st_mtime 1572806032.0 >>> file2_path.stat().st_mtime 1572806131.0
这些值是以秒为单位的时间戳。您的值将取决于您系统上的文件。如果我们想要一个对大多数人来说都合理的时间戳,我们可以使用 datetime 模块从这个值创建一个更有用的对象:
>>> import datetime
>>> mtime_1 = file1_path.stat().st_mtime
>>> datetime.datetime.fromtimestamp(mtime_1)
datetime.datetime(2019, 11, 3, 13, 33, 52)
我们可以使用多种方法来格式化 datetime 对象。
查找所有匹配给定模式的文件
以下是要查找所有匹配给定模式的文件的步骤:
-
从输入目录名创建 Path 对象:
>>> directory_path = Path(options.file1).parent >>> directory_path PosixPath(’data’) -
使用 Path 对象的 glob() 方法定位此目录中所有匹配给定模式的文件。对于不存在的目录,迭代器将为空。在模式中使用 ** 将递归遍历目录树:
>>> from pprint import pprint >>> pprint(sorted(directory_path.glob("*.csv"))) PosixPath(’data/binned.csv’),我们省略了结果中的许多文件。
glob() 方法是一个迭代器,我们使用了 sorted() 函数来消费这个迭代器的值,并从它们创建一个单独的列表对象。
11.1.3 它是如何工作的...
在操作系统中,查找文件的目录序列是通过文件系统路径实现的。在某些情况下,可以使用简单的字符串表示来总结路径。然而,字符串表示使得许多路径操作变成了复杂的字符串解析问题。字符串对于操作操作系统路径来说是一个无用的不透明抽象。
Path 类定义简化了路径操作。Path 实例上的这些属性、方法和运算符包括以下示例:
-
.parent 提取父目录。
-
.parents 列出所有封装的目录。
-
.name 是最终名称。
-
.stem 是最终名称的基名(不带任何后缀)。
-
.suffix 是最终的后缀。
-
.suffixes 是后缀值序列,用于与 file.tag.gz 类型的名称一起使用。
-
.with_suffix() 方法用新的后缀替换文件的后缀。
-
.with_name() 方法用新的名称替换路径中的名称。
-
/ 操作符从 Path 和字符串组件构建 Path 对象。
具体路径代表实际的文件系统资源。对于具体的路径对象,我们可以对目录信息进行一系列额外的操作:
-
确定这种目录条目是什么类型;即普通文件、目录、链接、套接字、命名管道(或 FIFO)、块设备或字符设备。
-
获取目录详细信息,包括时间戳、权限、所有权、大小等信息。
-
解除链接(即删除)目录条目。请注意,解除普通文件的链接与删除空目录是不同的。我们将在本食谱的 There’s more... 部分中探讨这一点。
-
将文件重命名以将其放置在新的路径中。我们也会在本食谱的 There’s more... 部分中探讨这一点。
几乎我们可以对文件目录执行的所有操作都可以使用 pathlib 模块来完成。少数例外是 os 模块的一部分,因为它们通常是特定于操作系统的。
11.1.4 更多内容...
除了操作路径和收集有关文件的信息外,我们还可以对文件系统进行一些更改。两个常见的操作是重命名文件和解除链接(或删除)文件。我们可以使用多种方法来更改文件系统:
-
.unlink() 方法删除普通文件。它不会删除目录。
-
.rmdir() 方法删除空目录。删除包含文件的目录需要两步操作:首先解除目录中所有文件的联系,然后删除目录。
-
.rename() 方法将文件重命名为新路径。
-
.replace() 方法在目标已存在的情况下不会引发异常来替换文件。
-
.symlink_to() 方法创建一个指向现有文件的软链接文件。
-
.hardlink_to() 方法创建一个操作系统硬链接;现在两个不同的目录条目将拥有底层文件内容。
我们可以通过内置的 open() 函数或 open() 方法来打开一个路径。有些人喜欢看到 open(some_path),而有些人则更喜欢 some_path.open()。两者都做同样的事情:创建一个打开的文件对象。
我们可以使用 mkdir() 方法创建目录。此方法有两个关键字参数:
-
exist_ok=False 是默认值;如果目录已存在,将引发异常。将此更改为 True 使代码对现有目录具有容错性。
-
parents=False 是默认值;不会创建父目录,只有路径中的最底层目录。将此更改为 True 将创建整个路径,包括父目录和子目录。
我们还可以以大字符串或字节对象的形式读取和写入文件:
-
.read_text() 方法将文件作为单个字符串读取。
-
.write_text() 方法使用给定的字符串创建或替换文件。
-
.read_bytes() 方法将文件作为单个字节实例读取。
-
.write_bytes() 方法使用给定的字节创建或替换文件。
还有更多文件系统操作,如更改所有权或更改权限。这些操作在 os 模块中可用。
11.1.5 参见
-
在本章后面的 [在保留先前版本的同时替换文件 菜谱中,我们将探讨如何利用路径对象的功能来创建一个临时文件,然后将临时文件重命名为替换原始文件。
-
在第六章的 使用 argparse 获取命令行输入 菜谱中,我们查看了一种非常常见的方法,即使用字符串来创建路径对象。
-
os 模块提供了一些比 pathlib 提供的更不常用的文件系统操作。
11.2 在保留先前版本的同时替换文件
我们可以利用 pathlib 模块的功能来支持各种文件名操作。在第 使用 pathlib 处理文件名 菜谱中,我们查看了一些管理目录、文件名和文件后缀的最常见技术。
一个常见的文件处理需求是以安全的方式创建输出文件;也就是说,无论应用程序如何或在哪里失败,应用程序都应该保留任何先前的输出文件。
考虑以下场景:
-
在时间 T[0] 时,有一个来自 long_complex.py 应用程序先前运行的 valid output.csv 文件。
-
在时间 T[1] 时,我们开始使用新数据运行 long_complex.py 应用程序。它开始覆盖 output.csv 文件。直到程序完成,字节将不可用。
-
在时间 T[2] 时,应用程序崩溃。output.csv 文件的局部内容是无用的。更糟糕的是,时间 T[0] 的有效文件也不再可用,因为它被覆盖了。
在这个菜谱中,我们将探讨在失败情况下创建输出文件的一种安全方法。
11.2.1 准备工作
对于不跨越物理设备的文件,安全文件输出通常意味着使用临时名称创建文件的新副本。如果新文件可以成功创建,则应使用单个原子重命名操作替换旧文件。
我们希望有以下功能:
-
重要的输出文件必须始终以有效状态保存。
-
应用程序写入文件的临时版本。命名此文件有许多约定。有时,在文件名上放置额外的字符,如 ~ 或 #,以指示它是临时的工作文件;例如,output.csv~。我们将使用更长的后缀,.new;例如,output.csv.new。
-
文件的先前版本也被保留。有时,先前版本有一个 .bak 后缀,表示“备份”。我们将使用更长的后缀,称为 output.csv.old。这也意味着任何先前的 .old 文件都必须作为最终输出的一部分被删除;只保留一个版本。
为了创建一个具体的例子,我们将使用一个包含非常小但珍贵的部分数据的文件:一系列商对象。以下是商类的定义:
from dataclasses import dataclass, asdict, fields
@dataclass
class Quotient:
numerator: int
denominator: int
以下函数将对象写入 CSV 格式的文件:
import csv
from collections.abc import Iterable
from pathlib import Path
def save_data(
output_path: Path, data: Iterable[Quotient]
) -> None:
with output_path.open("w", newline="") as output_file:
headers = [f.name for f in fields(Quotient)]
writer = csv.DictWriter(output_file, headers)
writer.writeheader()
for q in data:
writer.writerow(asdict(q))
如果在将数据对象写入文件时出现问题,我们可能会留下一个损坏的、不可用的文件。我们将用另一个函数包装此函数,以提供可靠的写入。
11.2.2 如何操作...
我们通过导入所需的类开始创建一个包装函数:
-
定义一个函数来封装 save_data() 函数以及一些额外功能。函数签名与 save_data() 函数相同:
-
保存原始后缀,并在后缀末尾创建一个带有 .new 的新名称。这是一个临时文件。如果它正确写入,没有异常,那么我们可以重命名它,使其成为目标文件:
ext = output_path.suffix output_new_path = output_path.with_suffix(f’{ext}.new’) save_data(output_new_path, data)save_data() 函数是封装在此函数中的创建新文件的原始过程。
-
在用新文件替换旧文件之前,删除任何先前的备份副本。如果存在,我们将解除 .old 文件的链接:
output_old_path = output_path.with_suffix(f’{ext}.old’) output_old_path.unlink(missing_ok=True) -
现在,我们可以保留任何先前的良好文件,其名称为 .old:
try: output_path.rename(output_old_path) except FileNotFoundError as ex: # No previous file. That’s okay. pass -
最后一步是将临时的 .new 文件变为官方输出:
try: output_new_path.rename(output_path) except IOError as ex: # Possible recovery... output_old_path.rename(output_path)
这个多步骤过程使用两个重命名操作:
-
将先前的版本重命名为带有后缀 .old 的备份版本。
-
将带有 .new 后缀的新版本重命名为文件的当前版本。
|
11.2.3 它是如何工作的...
此过程涉及三个独立的操作系统操作:一个解除链接和两个重命名。这是为了确保保留一个 .old 文件,并可以使用它来恢复之前良好的状态。
这里是一个显示各种文件状态的时序表。我们将内容标记为版本 0(一些旧数据)、版本 1(当前有效数据)和版本 2(新创建的数据):
由于这些操作是串行应用的,因此在保留旧文件和重命名新文件之间存在一个非常小的时间间隔,应用程序失败将无法替换新文件。我们将在下一节中探讨这一点。
|
| T[0] | | 版本 0 | 版本 1 | |
|
| * * * |
|---|
|
|
|
|
| T[5] | 将 .csv.new 重命名为 .csv 后 | 版本 1 | 版本 2 | |
如果有 .csv 文件,它是当前的有效文件。
| T[2] | 创建后,关闭 | 版本 0 | 版本 1 | 版本 2 |
|
|
|
|
|
|
| * * * |
|---|
|
|
|
|
|
|
一个路径对象有一个 replace() 方法。这总是覆盖目标文件,如果覆盖现有文件则没有警告。rename() 和 replace() 之间的选择取决于我们的应用程序如何处理在文件系统中可能留下旧版本文件的情况。在这个菜谱中,我们使用了 rename() 来尝试避免在多个问题的情况下覆盖文件。
|
| * * * |
|---|
|
虽然有几次失败的机会,但关于哪个文件是有效的没有歧义:
由于这些操作实际上并不涉及复制文件,因此这些操作都非常快且可靠。然而,它们并不保证一定能工作。文件系统的状态可以被任何具有正确权限的用户更改,因此在创建替换旧文件的新的文件时需要小心。
|
|
| T[3] | 解除链接 .csv.old 后 | 版本 1 | 版本 2 |
|---|
|
|
|
|
为了确保输出文件有效,一些应用程序会采取额外步骤,在文件中写入一个最终的校验和行,以提供明确的证据,表明文件是完整且一致的。
|
|
|
| T[4] | 将 .csv 重命名为 .csv.old 后 | 版本 1 | 版本 2 |
|---|
|
|
|
|
|
|
| T[1] | 创建中 | 版本 0 | 版本 1 | 如果使用将出现损坏 |
|
| * * * |
|---|
|
|
| 时间 | 操作 | .csv.old | .csv | .csv.new |
|
|
表 11.1:文件操作时间线
|
|
|
| * * * |
|---|
|
| | | | | |
-
|
-
如果没有 .csv 文件,那么 .csv.old 文件是一个有效的备份副本,应用于恢复。参见 T[4] 时刻,了解这种情况。
|
|
11.2.4 更多内容...
在某些企业应用中,输出文件被组织成基于时间戳命名的目录。这些操作可以通过 pathlib 模块优雅地处理。例如,我们可能有一个用于旧文件的存档目录。这个目录包含带有日期戳的子目录,用于存储临时或工作文件。
然后,我们可以执行以下操作来定义一个工作目录:
[firstline=58,lastline=58,gobble=4][python]src/ch11/recipe˙02.py [firstline=60,lastline=65,gobble=4][python]src/ch11/recipe˙02.py
mkdir() 方法将创建预期的目录。通过包含 parents=True 参数,任何需要的父目录也将被创建。这可以在应用程序第一次执行时创建 archive_path 非常方便。exists_ok=True 将避免在存档目录已存在时引发异常。
对于某些应用,使用 tempfile 模块创建临时文件可能是合适的。此模块可以创建保证唯一的文件名。这允许复杂的服务器进程在无需考虑文件名冲突的情况下创建临时文件。
11.2.5 参见
-
在本章前面的使用 pathlib 处理文件名配方中,我们探讨了 Path 类的基本原理。
-
在第十五章,我们将探讨一些编写单元测试的技术,以确保本配方示例代码的部分行为正确。
-
在第六章,创建上下文和上下文管理器配方展示了有关使用 with 语句确保文件操作正确完成以及释放所有 OS 资源的更多细节。
-
shutil 模块提供了一系列用于复制文件和包含文件的目录的方法。这个包反映了 Linux shell 程序(如 cp)以及 Windows 程序(如 copy 和 xcopy)的功能。
11.3 使用 CSV 模块读取分隔符文件
一种常用的数据格式是逗号分隔值(CSV)。我们可以将逗号字符视为众多候选分隔符之一。例如,CSV 文件可以使用 | 字符作为数据列之间的分隔符。这种对非逗号分隔符的泛化使得 CSV 文件特别强大。
我们如何处理各种 CSV 格式的数据?
11.3.1 准备工作
文件内容的摘要称为模式。区分模式的两个方面是至关重要的。
CSV 文件的字节物理格式编码文本行。对于 CSV 文件,文本使用行分隔符字符(或字符序列)和列分隔符字符组织成行和列。许多电子表格产品将使用 ,(逗号)作为列分隔符,将 \r\n 字符序列作为行分隔符。使用的标点符号字符的具体组合称为 CSV 语法。
此外,当列数据包含分隔符之一时,可以引用列数据。最常见的引用规则是用"字符包围列值。为了在列数据中包含引号字符,引号字符被加倍。例如,"He said, ""Thanks."""。
文件中数据的逻辑布局是一系列存在的数据列。在 CSV 文件中处理逻辑布局有几种常见情况:
-
文件可能有一行标题。这与 csv 模块的工作方式很好地吻合。如果标题也是合适的 Python 变量名,那么这会更有帮助。模式在文件的第一行中明确声明。
-
文件没有标题,但列位置是固定的。在这种情况下,我们可以在打开文件时在文件上施加标题。从实用主义的角度来看,这涉及一些风险,因为很难确认数据符合施加的模式。
-
如果文件没有标题且列位置不固定。在这种情况下,需要额外的外部模式信息来解释数据列。
当然,任何数据都可能出现的某些常见复杂问题。有些文件不是第一范式(1NF)。在 1NF 中,每一行都是独立于所有其他行的。当一个文件不处于这种范式时,我们需要添加一个生成器函数来重新排列数据以形成 1NF 行。请参阅第四章中的 Slicing and dicing a list 配方,以及第九章中的 Using stacked generator expressions 配方,以了解其他显示如何规范化数据结构的配方。
我们将查看一个包含从帆船日志中记录的一些实时数据的 CSV 文件。这是 waypoints.csv 文件。数据如下所示:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
这些数据包含文件第一行中命名的四个列:lat、lon、date 和 time。这些描述了一个航点,需要重新格式化以创建更有用的信息。
11.3.2 如何实现...
在开始编写任何代码之前,检查数据文件以确认以下功能:
-
列分隔符字符是’,’,这是默认值。
-
行分隔符字符是’\r\n’,在 Windows 和 Linux 中都广泛使用。
-
有一个单行标题。如果不存在,当创建读取器对象时,应单独提供标题。
一旦格式得到确认,我们就可以开始创建所需的函数,如下所示:
-
导入 csv 模块和 Path 类:
import csv from pathlib import Path -
定义一个 raw()函数,从指向文件的 Path 对象中读取原始数据:
def raw(data_path: Path) -> None: -
使用 Path 对象在 with 语句中打开文件。从打开的文件构建读取器:
with data_path.open() as data_file: data_reader = csv.DictReader(data_file) -
消费(并处理)可迭代读取器的数据行。这正确地缩进在 with 语句内:
for row in data_reader: print(row)
raw()函数的输出是一系列看起来如下所示的字典:
{’lat’: ’32.8321666666667’, ’lon’: ’-79.9338333333333’, ’date’: ’2012-11-27’, ’time’: ’09:15:00’}
我们现在可以通过将列作为字典项来处理数据,使用例如 row[’date’]这样的语法。使用列名比通过位置引用列更具有描述性;例如,row[0]难以理解。
为了确保我们正确地使用了列名,可以使用 typing.TypedDict 类型提示来提供预期的列名。
11.3.3 它是如何工作的...
csv 模块处理解析物理格式的工作。这使行彼此分离,并在每行内分离列。默认规则确保每条输入行被视为单独的行,并且列由逗号分隔。
当我们需要将列分隔符字符作为数据的一部分使用时会发生什么?我们可能会有这样的数据:
lan,lon,date,time,notes
32.832,-79.934,2012-11-27,09:15:00,"breezy, rainy"
31.671,-80.933,2012-11-28,00:00:00,"blowing ""like stink"""
注释列在第一行有数据,包括逗号分隔符字符。CSV 的规则允许列的值被引号包围。默认情况下,引号字符是"。在这些引号字符内,列和行分隔符字符被忽略。
为了在引号字符串中嵌入引号字符,该字符被加倍。第二个示例行显示了如何通过将引号字符加倍来编码值"like stink",当它们是列值的一部分时。
CSV 文件中的值始终是字符串。像 7331 这样的字符串值可能看起来像数字,但它在 csv 模块处理时始终是文本。这使得处理简单且统一,但可能对我们的 Python 应用程序来说有些尴尬。
当从手动准备的电子表格中保存数据时,数据可能会揭示桌面软件内部数据显示规则的古怪之处。在桌面软件中显示为日期的数据在 CSV 文件中存储为浮点数。
日期作为数字的问题有两个解决方案。一个是向源电子表格中添加一个列,以正确格式化日期数据为字符串。理想情况下,这是使用 ISO 规则完成的,以便日期以 YYYY-MM-DD 格式表示。另一个解决方案是将电子表格中的日期识别为从某个纪元日期以来的秒数。纪元日期随着各种工具的版本略有不同,但通常是 1900 年 1 月 1 日。(一些电子表格应用程序使用 1904 年 1 月 1 日。)
11.3.4 更多内容...
正如我们在第九章的结合 map 和 reduce 转换食谱中看到的,通常有一个包括清理和转换源数据的处理流程。这种堆叠生成器函数的想法让 Python 程序能够处理大量数据。一次读取一行可以避免将所有数据读入一个庞大的内存列表。在这个特定例子中,没有需要消除的额外行。然而,每个列都需要转换成更有用的形式。
在第十章中,许多配方使用 Pydantic 来执行这些类型的数据转换。参见 使用 Pydantic 实现更严格的类型检查 配方,了解这种替代方法的示例。
为了将数据转换成更有用的形式,我们将定义一个行级清洗函数。一个函数可以将此清洗函数应用于源数据中的每一行。
在这个例子中,我们将创建一个字典对象,并插入从输入数据派生出的额外值。这个 Waypoint 字典的核心类型提示如下:
import datetime
from typing import TypeAlias, Any
Raw: TypeAlias = dict[str, Any]
Waypoint: TypeAlias = dict[str, Any]
基于此对 Waypoint 类型的定义,clean_row() 函数可能看起来像这样:
def clean_row(
source_row: Raw
) -> Waypoint:
ts_date = datetime.datetime.strptime(
source_row["date"], "%Y-%m-%d").date()
ts_time = datetime.datetime.strptime(
source_row["time"], "%H:%M:%S").time()
return dict(
date=source_row["date"],
time=source_row["time"],
lat=source_row["lat"],
lon=source_row["lon"],
lat_lon=(
float(source_row["lat"]),
float(source_row["lon"])
),
ts_date=ts_date,
ts_time=ts_time,
timestamp = datetime.datetime.combine(
ts_date, ts_time
)
)
clean_row() 函数从原始字符串数据中创建几个新的列值。名为 lat_lon 的列包含一个包含正确浮点值的二元组,而不是字符串。我们还解析了日期和时间值,分别创建了 datetime.date 和 datetime.time 对象。我们将日期和时间合并成一个单一的有用值,即时间戳列的值。
一旦我们有一个用于清洗和丰富我们数据的行级函数,我们就可以将此函数映射到源数据中的每一行。我们可以使用 map(clean_row, reader) 或者我们可以编写一个体现此处理循环的函数:
def cleanse(reader: csv.DictReader[str]) -> Iterator[Waypoint]:
for row in reader:
yield clean_row(row)
这可以用来从每一行提供更有用的数据:
def display_clean(data_path: Path) -> None:
with data_path.open() as data_file:
data_reader = csv.DictReader(data_file)
clean_data_reader = cleanse(data_reader)
for row in clean_data_reader:
pprint(row)
这些清洗和丰富的行看起来如下:
>>> data = Path("data") / "waypoints.csv"
>>> display_clean(data)
{’date’: ’2012-11-27’,
’lat’: ’32.8321666666667’,
’lat_lon’: (32.8321666666667, -79.9338333333333),
’lon’: ’-79.9338333333333’,
’time’: ’09:15:00’,
’timestamp’: datetime.datetime(2012, 11, 27, 9, 15),
’ts_date’: datetime.date(2012, 11, 27),
’ts_time’: datetime.time(9, 15)}
...
新的列,如 lat_lon,包含正确的数值而不是字符串。时间戳值包含完整的日期时间值,可用于计算航点之间经过的时间的简单计算。
11.3.5 参见
-
参见第九章中的 结合 map 和 reduce 转换 配方,以获取有关处理流程或堆栈的想法的更多信息。
-
参见第四章中的 切片和切块列表 配方,以及第九章中的 使用堆叠生成表达式 配方,以获取有关处理不正确 1NF 的 CSV 文件的更多信息。
-
关于 with 语句的更多信息,请参阅第七章中的 创建上下文和上下文管理器 配方。
-
在第十章中,许多配方使用 Pydantic 来执行这些类型的数据转换。参见 使用 Pydantic 实现更严格的类型检查 配方,了解这种替代方法的示例。
-
查看
www.packtpub.com/product/learning-pandas-second-edition/9781787123137学习 pandas,了解使用 pandas 框架处理 CSV 文件的方法。
11.4 使用 dataclasses 简化 CSV 文件的工作
一种常用的数据格式称为逗号分隔值 (CSV)。Python 的 csv 模块有一个非常方便的 DictReader 类定义。当一个文件包含一行标题时,标题行的值成为用于所有后续行的键。这为数据的逻辑布局提供了很大的灵活性。例如,列顺序并不重要,因为每个列的数据都由标题行中的一个名称标识。
使用字典迫使我们编写,例如,row[’lat’] 或 row[’date’] 来引用特定列中的数据。内置的 dict 类没有提供派生数据。如果我们切换到数据类,我们将获得许多好处:
-
更好的属性语法,如 row.lat 或 row.date。
-
派生值可以是延迟属性。
-
冻结的数据类是不可变的,对象可以作为字典的键和集合的成员。
我们如何使用数据类改进数据访问和处理?
11.4.1 准备工作
我们将查看一个包含从帆船日志中记录的实时数据的 CSV 文件。此文件是 waypoints.csv 文件。有关更多信息,请参阅本章中的 使用 CSV 模块读取定界文件 烹饪配方。数据如下所示:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
第一行包含一个标题,命名了四个列,lat、lon、date 和 time。数据可以通过 csv.DictReader 对象读取。我们希望进行更复杂的工作,因此我们将创建一个 @dataclass 类定义,封装数据和我们需要执行的处理。
11.4.2 如何操作...
我们需要从一个反映可用数据的数据类开始,然后我们可以使用这个数据类与字典读取器一起使用:
-
导入所需的各种库的定义:
from dataclasses import dataclass, field import datetime from collections.abc import Iterator -
定义一个专注于输入的数据类,精确地像源文件中那样出现。我们称这个类为 RawRow。在一个复杂的应用程序中,一个比 RawRow 更有描述性的名称会更合适。这个属性定义可能会随着源文件组织的变化而变化:
@dataclass class RawRow: date: str time: str lat: str lon: str实际上,企业文件格式很可能在引入新软件版本时发生变化。在发生变化时,将文件模式正式化为类定义通常有助于单元测试和问题解决。
-
定义第二个数据类,其中对象由源数据类的属性构建。这个第二类专注于应用程序的实际工作。在这个例子中,源数据在一个名为 raw 的单个属性中。从这个源数据计算的字段都使用 field(init=False) 初始化,因为它们将在初始化之后计算:
@dataclass class Waypoint: raw: RawRow lat_lon: tuple[float, float] = field(init=False) ts_date: datetime.date = field(init=False) ts_time: datetime.time = field(init=False) timestamp: datetime.datetime = field(init=False) -
将
__post_init__()方法添加到急切初始化所有派生字段:def __post_init__(self) -> None: self.ts_date = datetime.datetime.strptime( self.raw.date, "%Y-%m-%d" ).date() self.ts_time = datetime.datetime.strptime( self.raw.time, "%H:%M:%S" ).time() self.lat_lon = ( float(self.raw.lat), float(self.raw.lon) ) self.timestamp = datetime.datetime.combine( self.ts_date, self.ts_time ) -
给定这两个数据类定义,我们可以创建一个迭代器,它将接受来自
csv.DictReader对象的单独字典并创建所需的Waypoint对象。中间表示RawRow是一个便利,这样我们就可以将属性名称分配给源数据列:def waypoint_iter(reader: csv.DictReader[str]) -> Iterator[Waypoint]: for row in reader: raw = RawRow(**row) yield Waypoint(raw)
waypoint_iter() 函数从输入字典创建 RawRow 对象,然后从 RawRow 实例创建最终的 Waypoint 对象。这个两步过程有助于隔离对源或处理的代码变更。
我们可以使用以下函数来读取和显示 CSV 数据:
def display(data_path: Path) -> None:
with data_path.open() as data_file:
data_reader = csv.DictReader(data_file)
for waypoint in waypoint_iter(data_reader):
pprint(waypoint)
11.4.3 它是如何工作的...
在这个例子中,源数据类 RawRow 类被设计成与输入文档相匹配。字段名称和类型与 CSV 输入类型相匹配。由于名称匹配,RawRow(**row) 表达式将从 DictReader 字典创建 RawRow 类的实例。
从这个初始的或原始的数据中,我们可以推导出更有用的数据,如 Waypoint 类定义所示。__post_init__() 方法将 self.raw 属性中的初始值转换成多个更有用的属性值。
这种分离使我们能够管理应用软件的以下两种常见变更:
-
由于电子表格是手动调整的,源数据可能会发生变化。这是常见的:一个人可能会更改列名或更改列的顺序。
-
随着应用程序焦点的扩展或转移,所需的计算可能会发生变化。可能会添加更多派生列,或者算法可能会改变。
将程序的各种方面解开,以便我们可以让它们独立演变,这是很有帮助的。收集、清理和过滤源数据是这种关注点分离的一个方面。由此产生的计算是一个独立的方面,与源数据的格式无关。
11.4.4 更多...
在许多情况下,源 CSV 文件将具有不直接映射到有效 Python 属性名称的标题。在这些情况下,源字典中存在的键必须映射到列名。这可以通过扩展 RawRow 类定义以包括一个构建 RawRow 数据类对象的 @classmethod 来管理。
以下示例定义了一个名为 RawRow_HeaderV2 的类。这个定义反映了具有不同列名标题的变体电子表格:
@dataclass
class RawRow_HeaderV2:
date: str
time: str
lat: str
lon: str
@classmethod
def from_csv(cls, csv_row: dict[str, str]) -> "RawRow_HeaderV2":
return RawRow_HeaderV2(
date = csv_row[’Date of Travel (YYYY-MM-DD)’],
time = csv_row[’Arrival Time (HH:MM:SS)’],
lat = csv_row[’Latitude (degrees N)’],
lon = csv_row[’Logitude (degrees W)’],
RawRow_HeaderV2 类的实例是通过表达式 RawRow_HeaderV2.from_csv(row) 构建的。这些对象与 RawRow 类兼容。这两个类中的任何一个对象也可以转换成 Waypoint 实例。
对于与各种数据源一起工作的应用程序,这类“原始数据转换”dataclasses 可以方便地将逻辑布局中的细微变化映射到一致的内部结构,以便进一步处理。随着输入转换类的数量增加,需要额外的类型提示。例如,以下类型提示为输入格式的变化提供了一个通用名称:
Raw: TypeAlias = RawRow | RawRow_HeaderV2
这种类型提示有助于统一原始的 RawRow 和替代的 RawRow_HeaderV2 类型,它们是具有兼容功能的替代定义。最重要的功能是使用生成器逐行处理数据,以避免创建包含所有数据的庞大列表对象。
11.4.5 参考信息
-
本章前面的使用 CSV 模块读取分隔符文件配方也涵盖了 CSV 文件读取。
-
在第六章的使用 dataclasses 处理可变对象配方中,也介绍了使用 Python 的 dataclasses 的方法。
11.5 使用正则表达式读取复杂格式
许多文件格式缺乏 CSV 文件那种优雅的规律性。一个相当难以解析的常见文件格式是 Web 服务器日志文件。这些文件往往具有复杂的数据,没有单一的、统一的分隔符字符或一致的引号规则。
当我们在第九章的使用 yield 语句编写生成器函数配方中查看简化的日志文件时,我们看到行如下所示:
[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One
[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging
[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong
在这个文件中使用了各种标点符号。csv 模块无法解析这种复杂性。
我们希望编写出像 CSV 处理一样优雅简单的程序。这意味着我们需要封装日志文件解析的复杂性,并将这一方面与分析和汇总处理分开。
11.5.1 准备工作
解析具有复杂结构的文件通常涉及编写一个函数,该函数的行为类似于 csv 模块中的 reader()函数。在某些情况下,创建一个类似于 DictReader 类的简单类可能更容易。
读取复杂文件的核心功能是将一行文本转换成字典或单个字段值的元组。这项工作的部分通常可以通过 re 包来完成。
在我们开始之前,我们需要开发(并调试)一个正则表达式,以便正确解析输入文件的每一行。有关此信息,请参阅第一章中的使用正则表达式进行字符串解析配方。
对于这个例子,我们将使用以下代码。我们将定义一个模式字符串,其中包含一系列用于行中各种元素的正则表达式:
import re
pattern_text = (
r"\[(?P<date>.*?)]\s+"
r"(?P<level>\w+)\s+"
r"in\s+(?P<module>\S+?)"
r":\s+(?P<message>.+)"
)
pattern = re.compile(pattern_text, re.X)
我们使用了 re.X 选项,这样我们可以在正则表达式中包含额外的空白。这可以通过分隔前缀和后缀字符来帮助使其更易于阅读。
当我们编写正则表达式时,我们将要捕获的有兴趣的子字符串用 () 括起来。在执行 match() 或 search() 操作后,生成的 Match 对象将包含匹配的子字符串的捕获文本。Match 对象的 groups() 方法和 Match 对象的 groupdict() 方法将提供捕获的字符串。
这是此模式的工作方式:
>>> sample_data = ’[2016-05-08 11:08:18,651] INFO in ch10_r09: Sample Message One’
>>> match = pattern.match(sample_data)
>>> match.groups()
(’2016-05-08 11:08:18,651’, ’INFO’, ’ch10_r09’, ’Sample Message One’)
>>> match.groupdict()
{’date’: ’2016-05-08 11:08:18,651’, ’level’: ’INFO’, ’module’: ’ch10_r09’, ’message’: ’Sample Message One’}
我们在 sample_data 变量中提供了一行样本数据。生成的 Match 对象有一个 groups() 方法,它返回每个有趣的字段。match 对象的 groupdict() 方法的值是一个字典,其中包含在正则表达式中括号内的 ?P
11.5.2 如何做到...
此配方分为两个小配方。第一部分定义了一个 log_parser() 函数来解析单行,而第二部分则将 log_parser() 函数应用于输入的每一行。
定义解析函数
执行以下步骤以定义 log_parser() 函数:
-
定义编译后的正则表达式对象:
import re pattern_text = ( r"\[(?P<date>.*?)]\s+" r"(?P<level>\w+)\s+" r"in\s+(?P<module>\S+?)" r":\s+(?P<message>.+)" ) pattern = re.compile(pattern_text, re.X) -
定义一个类来模拟生成的复杂数据对象。这可以具有额外的派生属性或其他复杂计算。最小化地,NamedTuple 必须定义解析器提取的字段。字段名称应与正则表达式捕获名称在 (?P
...) 前缀中匹配: from typing import NamedTuple class LogLine(NamedTuple): date: str level: str module: str message: str -
定义一个接受一行文本作为参数并生成解析后的 LogLine 实例的函数:
def log_parser(source_line: str) -> LogLine: -
将正则表达式应用于创建一个匹配对象。我们将其分配给 match 变量,并检查它是否不为 None:
if match := pattern.match(source_line): -
当 match 的值为非 None 时,返回一个包含此输入行各种数据的有用数据结构:
data = match.groupdict() return LogLine(**data) -
当匹配为 None 时,记录问题或引发异常以停止处理:
raise ValueError(f"Unexpected input {source_line=}")
使用 log_parser() 函数
此部分的配方将应用 log_parser() 函数到输入文件的每一行:
-
从 pathlib 模块中导入有用的类和函数定义:
>>> from pathlib import Path >>> from pprint import pprint -
创建标识文件的 Path 对象:
>>> data_path = Path("data") / "sample.log" -
使用 Path 对象以 with 语句打开文件。从打开的文件对象 data_file 创建日志文件读取器。在这种情况下,我们将使用内置的 map() 函数将 log_parser() 函数应用于源文件的每一行:
>>> with data_path.open() as data_file: ... data_reader = map(log_parser, data_file) -
读取(并处理)各种数据行。对于此示例,我们将打印每一行:
... for row in data_reader: ... pprint(row)
输出是一系列看起来如下所示的 LogLine 元组:
LogLine(date=’2016-06-15 17:57:54,715’, level=’INFO’, module=’ch09_r10’, message=’Sample Message One’)
LogLine(date=’2016-06-15 17:57:54,715’, level=’DEBUG’, module=’ch09_r10’, message=’Debugging’)
LogLine(date=’2016-06-15 17:57:54,715’, level=’WARNING’, module=’ch09_r10’, message=’Something might have gone wrong’)
我们可以对这些元组实例进行比原始文本行更有意义的处理。这允许我们通过严重程度级别过滤数据,或根据提供消息的模块创建计数器。
11.5.3 它是如何工作的...
此日志文件处于第一范式(1NF):数据组织成代表独立实体或事件的行。每一行都有一致的属性或列数,每一列都有原子数据或无法进一步有意义的分解。然而,与 CSV 文件不同的是,这种特定格式需要复杂的正则表达式来解析。
在我们的日志文件示例中,时间戳包含多个单独的元素——年、月、日、小时、分钟、秒和毫秒——但进一步分解时间戳的价值不大。将其用作单个日期时间对象并从中提取详细信息(如一天中的小时)比将单个字段组装成新的复合数据更有帮助。
在复杂的日志处理应用程序中,可能有几种不同类型的消息字段。可能需要使用不同的模式来解析这些消息类型。当我们需要这样做时,我们发现日志中的各种行在格式和属性数量方面并不一致,这违反了 1NF 假设之一。
我们通常遵循使用 CSV 模块读取定界文件配方中的设计模式,这样读取复杂的日志文件几乎与读取简单的 CSV 文件相同。实际上,我们可以看到主要区别在于一行代码:
[firstline=93,lastline=93,gobble=8][python]src/ch11/recipe˙05.py
与以下内容比较:
[firstline=95,lastline=95,gobble=8][python]src/ch11/recipe˙05.py
这种并行结构允许我们在许多输入文件格式之间重用分析函数。这使我们能够创建一个库,该库可以用于多个数据源。它可以帮助使分析应用程序在数据源更改时具有弹性。
11.5.4 更多...
在读取非常复杂的文件时,最常见的操作之一是将它们重写为更容易处理的格式。我们通常会想将数据保存为 CSV 格式以供以后处理。
其中一些与第七章中的使用多个资源管理多个上下文配方类似。这个配方展示了多个打开的文件处理上下文。我们将从一个文件中读取并写入到另一个文件中。
文件写入过程如下:
import csv
def copy(data_path: Path) -> None:
target_path = data_path.with_suffix(".csv")
with target_path.open("w", newline="") as target_file:
writer = csv.DictWriter(target_file, LogLine._fields)
writer.writeheader()
with data_path.open() as data_file:
reader = map(log_parser, data_file)
writer.writerows(row._asdict() for row in reader)
该脚本的第一个部分定义了一个用于目标文件的 CSV 写入器。输出文件的路径 target_path 基于输入名称 data_path。后缀被更改为.csv。
使用 newline=’’选项关闭了换行符,以打开目标文件。这允许 csv.DictWriter 类插入适合所需 CSV 方言的换行符。
创建一个 DictWriter 对象以写入指定的文件。列标题的序列由 LogLines 类定义提供。这确保输出 CSV 文件将包含正确、一致的列名。
writeheader() 方法将列名写入输出文件的第一行。这使得读取文件稍微容易一些,因为提供了列名。CSV 文件的第一行可以包含显式的模式定义,显示存在哪些数据。
源文件已按前一个菜谱所示打开。由于 csv 模块编写器的工作方式,我们可以将读取生成器表达式提供给 writer 的 writerows() 方法。writerows() 方法将消耗读取生成器产生的所有数据。这将反过来消耗由打开的文件产生的所有行。
我们不需要编写任何显式的 for 语句来确保处理所有输入行。writerows() 函数为我们保证了这一点。
输出文件如下所示:
date,level,module,message
"2016-06-15 17:57:54,715",INFO,ch09_r10,Sample Message One
"2016-06-15 17:57:54,715",DEBUG,ch09_r10,Debugging
"2016-06-15 17:57:54,715",WARNING,ch09_r10,Something might have gone wrong
该文件已从相对复杂的输入格式转换为更简单的 CSV 格式,适合进一步分析和处理。
11.5.5 参考内容
-
有关 with 语句的更多信息,请参阅第七章的 创建上下文和上下文管理器 菜单。
-
第九章的 使用 yield 语句编写生成器函数 菜谱展示了这种日志格式的其他处理方式。
-
在本章前面的 使用 CSV 模块读取分隔文件 菜谱中,我们探讨了这种通用设计模式的其它应用。
-
在本章前面的 使用数据类简化 CSV 文件处理 菜谱中,我们探讨了其他复杂的 CSV 处理技术。
11.6 读取 JSON 和 YAML 文档
JavaScript 对象表示法 (JSON) 通常用于序列化数据。有关详细信息,请参阅 json.org。Python 包含 json 模块,以便使用这种表示法序列化和反序列化数据。
JSON 文档被广泛应用于网络应用程序。在 RESTful 网络客户端和服务器之间使用 JSON 表示法的文档交换数据是很常见的。这两个应用堆栈的层级通过通过 HTTP 协议发送的 JSON 文档进行通信。
YAML 格式是 JSON 表示法的更复杂和灵活的扩展。有关详细信息,请参阅 yaml.org。任何 JSON 文档都是有效的 YAML 文档。反之则不然:YAML 语法更复杂,包括一些在 JSON 中无效的构造。
要使用 YAML,必须安装一个额外的模块:
(cookbook3) % python -m pip install pyyaml
PyYAML 项目提供了一个流行且功能良好的 yaml 模块。请参阅 pypi.org/project/PyYAML/.
在本菜谱中,我们将使用 json 模块来解析 Python 中的 JSON 格式数据。
11.6.1 准备工作
我们在 race_result.json 文件中收集了一些帆船比赛结果。这个文件包含了关于队伍、比赛段以及各个队伍完成每个单独比赛段顺序的信息。JSON 优雅地处理了这些复杂的数据。
总分可以通过计算每个比赛段的完成位置来得出:得分最低的是总冠军。在某些情况下,当一艘船未参赛、未完成比赛或被取消比赛资格时,会有 null 值。
在计算队伍的总分时,null 值被分配一个比比赛船只数量多一个的分数。如果有七艘船,那么队伍因未能完成比赛而得到八分,这是一个相当大的惩罚。
数据具有以下架构。在整个文档中有两个字段:
-
legs:一个字符串数组,显示起点和终点。
-
teams:一个包含每个队伍详细信息的对象数组。在每个 teams 对象中,有几个数据字段:
-
name:字符串形式的队伍名称。
-
position:一个包含整数和 nulls 的数组,表示位置。这个数组中项目的顺序与 legs 数组中项目的顺序相匹配。
-
数据看起来如下:
{
"teams": [
{
"name": "Abu Dhabi Ocean Racing",
"position": [
1,
3,
2,
2,
1,
2,
5,
3,
5
]
},
...
],
"legs": [
"ALICANTE - CAPE TOWN",
"CAPE TOWN - ABU DHABI",
"ABU DHABI - SANYA",
"SANYA - AUCKLAND",
"AUCKLAND - ITAJA\u00cd",
"ITAJA\u00cd - NEWPORT",
"NEWPORT - LISBON",
"LISBON - LORIENT",
"LORIENT - GOTHENBURG"
]
}
我们只展示了第一支队伍的详细信息。在这场特定的比赛中,总共有七支队伍。每个队伍由一个 Python 字典表示,其中包含队伍的名称和他们在每个比赛段的完成位置历史。对于这里展示的队伍,阿布扎比海洋赛车队,他们在第一段比赛中获得第一名,然后在下一段比赛中获得第三名。他们的最差表现是在第七段和第九段比赛中获得第五名,这两段比赛是从美国罗德岛纽波特到葡萄牙里斯本,以及从法国洛里昂到瑞典哥德堡。
JSON 格式的数据看起来像包含列表的 Python 字典。这种 Python 语法和 JSON 语法的重叠可以被视为一种愉快的巧合:它使得从 JSON 源文档构建的 Python 数据结构更容易可视化。
JSON 有一组小的数据结构:null、布尔值、数字、字符串、列表和对象。这些直接映射到 Python 类型中的对象。json 模块为我们将这些源文本转换为 Python 对象。
其中一个字符串包含一个 Unicode 转义序列,\u00cd,而不是实际的 Unicode 字符Í。这是一种常见的用于编码超出 128 个 ASCII 字符的字符的技术。json 模块中的解析器为我们处理了这一点。
在这个例子中,我们将编写一个函数来解开这个文档,并显示每个比赛段的队伍完成情况。
11.6.2 如何实现...
这个食谱将首先导入必要的模块。然后我们将使用这些模块将文件内容转换为有用的 Python 对象:
-
我们需要 json 模块来解析文本。我们还需要一个 Path 对象来引用文件:
import json from pathlib import Path -
定义一个 race_summary() 函数来从给定的 Path 实例读取 JSON 文档:
def race_summary(source_path: Path) -> None: -
通过解析 JSON 文档创建一个 Python 对象。通常,使用 source_path.read_text() 读取由 Path 对象命名的文件是最简单的。我们将此字符串提供给 json.loads() 函数进行解析。对于非常大的文件,可以将打开的文件传递给 json.load() 函数:
document = json.loads(source_path.read_text()) -
显示数据:文档对象包含一个包含两个键的字典,teams 和 legs。以下是遍历每个 legs 的方法,显示队伍在 legs 中的位置:
for n, leg in enumerate(document[’legs’]): print(leg) for team_finishes in document[’teams’]: print( team_finishes[’name’], team_finishes[’position’][n])
每个队伍的数据将是一个包含两个键的字典:name 和 position。我们可以深入到队伍的详细信息中,以获取第一个队伍的名称:
>>> document[’teams’][6][’name’]
’Team Vestas Wind’
我们可以查看 legs 字段,以查看每个赛段的名称:
>>> document[’legs’][5]
’ITAJA - NEWPORT’
11.6.3 它是如何工作的...
JSON 文档是 JavaScript 对象表示法中的数据结构。JavaScript 程序可以轻松解析文档。其他语言必须做更多工作来将 JSON 转换为本地数据结构。
JSON 文档包含三种结构:
-
映射到 Python 字典的对象:JSON 的语法与 Python 类似:{"key": "value", ...}。
-
映射到 Python 列表的数组:JSON 语法使用 [item, ...],这也与 Python 类似。
-
原始值:有五种值类别:字符串、数字、true、false 和 null。字符串用 " 和 " 包围,并使用各种 \ 转义序列,这与 Python 的类似。数字遵循浮点值规则。其他三个值是简单的字面量;这些与 Python 的 True、False 和 None 字面量平行。
作为特殊情况,没有小数点的数字成为 Python int 对象。这是 JSON 标准的扩展。
没有提供其他数据类型的支持。这意味着 Python 程序必须将复杂的 Python 对象转换为更简单的表示,以便它们可以用 JSON 语法进行序列化。
相反,我们经常应用额外的转换来从简化的 JSON 表示中重建复杂的 Python 对象。json 模块有一些地方可以应用额外的处理来创建更复杂的 Python 对象。
11.6.4 更多...
通常,一个文件包含一个单一的 JSON 文档。JSON 标准没有提供一种简单的方法来将多个文档编码到单个文件中。如果我们想分析一个网络日志,例如,原始的 JSON 标准可能不是保存大量信息的最佳表示法。
有一些常见扩展,如换行符分隔的 JSON(ndjson.org)和 JSON Lines,jsonlines.org,用于定义将多个 JSON 文档编码到单个文件中的方式。
当这些方法处理文档集合时,还有一个额外的问题我们需要解决:序列化(和反序列化)复杂对象,例如 datetime 对象。
当我们将 Python 对象的状态表示为文本字符的字符串时,我们已经序列化了对象的状态。许多 Python 对象需要被保存到文件或传输到另一个进程。这类传输需要对象状态的表示。我们将分别查看序列化和反序列化。
序列化复杂的数据结构
如果我们创建的 Python 对象仅限于内置类型 dict、list、str、int、float、bool 和 None 的值,那么序列化到 JSON 的效果最好。这个 Python 类型子集可以用来构建 json 模块可以序列化的对象,并且可以由多种不同语言编写的许多程序广泛使用。
一种常用的、不易序列化的数据结构是 datetime.datetime 对象。
避免在尝试序列化不寻常的 Python 对象时引发 TypeError 异常,可以通过两种方式之一实现。我们可以在构建文档之前将数据转换为 JSON 友好的结构,或者我们可以在 JSON 序列化过程中添加一个默认类型处理器,这样我们就可以提供一个可序列化的数据版本。
在将 datetime 对象序列化为 JSON 之前转换为字符串,我们需要对底层数据进行更改。由于序列化问题而篡改数据或 Python 的数据类型似乎有些尴尬。
序列化复杂数据的另一种技术是提供一个在序列化过程中由 json 模块使用的函数。此函数必须将复杂对象转换为可以安全序列化的东西。在下面的示例中,我们将 datetime 对象转换为简单的字符串值:
def default_date(object: Any) -> Any:
match object:
case datetime.datetime():
return {"$date$": object.isoformat()}
return object
我们定义了一个函数,default_date(),它将对 datetime 对象应用特殊的转换规则。任何 datetime.datetime 实例都将被替换为一个具有明显键 – "\(date\)" – 和字符串值的字典。这个字典可以通过 json 模块进行序列化。
我们将这个序列化辅助函数提供给 json.dumps() 函数。这是通过将 default_date() 函数分配给默认参数来完成的,如下所示:
>>> example_date = datetime.datetime(2014, 6, 7, 8, 9, 10)
>>> document = {’date’: example_date}
>>> print(
... json.dumps(document, default=default_date, indent=2)
... )
{
"date": {
"$date$": "2014-06-07T08:09:10"
}
}
当 json 模块无法序列化一个对象时,它将对象传递给给定的默认函数 default_date()。在任何给定的应用程序中,我们需要扩展这个函数以处理我们可能想要在 JSON 表示法中序列化的多种 Python 对象类型。如果没有提供默认函数,当对象无法序列化时将引发异常。
反序列化复杂的数据结构
当反序列化 JSON 以创建 Python 对象时,有一个钩子可以用来将数据从 JSON 字典转换为更复杂的 Python 对象。这被称为 object_hook,它在 json.loads() 函数的处理过程中被使用。此钩子用于检查每个 JSON 字典,以查看是否应该从字典实例创建其他内容。
我们提供的函数将创建一个更复杂的 Python 对象,或者简单地返回未修改的原始字典对象:
def as_date(object: dict[str, Any]) -> Any:
if {’$date$’} == set(object.keys()):
return datetime.datetime.fromisoformat(object[’$date$’])
return object
此函数将检查每个解码的对象,以查看对象是否只有一个字段,并且该单个字段是否命名为"\(date\)"。如果是这种情况,整个对象的价值将替换为 datetime.datetime 对象。返回类型是 Any 和 dict[str, Any]的联合,以反映两种可能的结果:要么是某个对象,要么是原始字典。
我们通过 json.loads()函数使用 object_hook 参数提供了一个函数,如下所示:
>>> source = ’’’{"date": {"$date$": "2014-06-07T08:09:10"}}’’’
>>> json.loads(source, object_hook=as_date)
{’date’: datetime.datetime(2014, 6, 7, 8, 9, 10)}
这解析了一个非常小的 JSON 文档。所有对象都提供给 as_date()对象钩子。在这些对象中,有一个字典符合包含日期的标准。从 JSON 序列化中找到的字符串值构建了一个 Python 对象。
Pydantic 包提供了一系列序列化功能。关于如何使用此包的配方在第十章中有展示。
11.6.5 参见
-
本章后面的读取 HTML 文档配方将展示我们如何从 HTML 源准备这些数据。
-
第十章中的使用 Pydantic 实现更严格的类型检查配方涵盖了 Pydantic 包的一些功能。
11.7 读取 XML 文档
XML 标记语言广泛用于以序列化形式表示对象的状态。有关详细信息,请参阅www.w3.org/TR/REC-xml/。Python 包含多个用于解析 XML 文档的库。
XML 被称为标记语言,因为感兴趣的内容被标记为标签,使用起始
由于标记与文本交织在一起,因此必须使用一些额外的语法规则来区分标记和文本。文档必须使用<代替<,>代替>,以及&代替&在文本中。此外,"也用于在属性值中嵌入一个"字符。在大多数情况下,XML 解析器在消费 XML 时会处理这种转换。
因此,示例文档将包含以下项目:
<team><name>Team SCA</name><position>...</position></team>
整个文档形成一个大型、嵌套的容器集合。我们可以将文档视为一个树,其根标签包含所有其他标签及其嵌入的内容。在标签之间,可以有额外的内容。在某些应用中,标签结束之间的额外内容完全是空白。
下面是我们将要查看的文档的开始部分:
<?xml version="1.0"?>
<results>
<teams>
<team>
<name>
Abu Dhabi Ocean Racing
</name>
<position>
<leg n="1">
1
</leg>
...
</position>
...
</team>
...
</teams>
<legs>
<leg n="1">
ALICANTE - CAPE TOWN
</leg>
...
</legs>
</results>
最高层容器是
使用正则表达式解析 XML 非常困难。正则表达式不擅长处理 XML 中存在的递归和重复类型。我们需要更复杂的解析器来处理嵌套标签的语法。
有两个二进制库,分别是 xml.sax 和 xml.parsers.expat 模块的一部分,用于解析 XML。这些库的优点是速度非常快。
此外,xml.etree 包中还有一个非常复杂的工具集。我们将专注于使用此包中的 ElementTree 类来解析和分析 XML 文档。这具有提供大量有用功能的优势,如 XPath 搜索,以在复杂文档中查找标签。
11.7.1 准备工作
我们已经收集了一些帆船赛成绩,保存在 race_result.xml 文件中。该文件包含有关团队、赛段以及各个团队完成每个赛段顺序的信息。有关此数据的更多信息,请参阅本章中的阅读 JSON 和 YAML 文档配方。
此数据的根标签是一个
-
标签包含单个 标签,命名每条赛道的名称。每个 标签将包含一个起始港和一个结束港的文本。 -
标签包含多个 标签,包含每个团队的详细信息。每个团队的数据都使用内部标签进行结构化: -
标签包含团队名称。 -
标签包含多个 标签,表示给定腿的完成位置。每个腿都有编号,编号与 标签中的腿定义相匹配。
-
在 XML 表示法中,应用程序数据出现在两种地方。第一种是在起始标签和结束标签之间——例如,
此外,数据还将作为标签的属性出现;例如,在
XML 允许混合内容模型。这反映了 XML 与文本混合的情况,其中文本位于 XML 标签内外。以下是一个混合内容的示例:
<p>
This has <strong>mixed</strong> content.
</p>
标签的内容是文本和标签的混合。我们在本食谱中处理的数据不依赖于这种混合内容模型,这意味着所有数据都在单个标签或标签的属性中。标签之间的空白可以忽略。
11.7.2 如何实现...
我们将定义一个函数,将 XML 文档转换为包含腿描述和团队结果的字典:
-
我们需要 xml.etree 模块来解析 XML 文本。我们还需要一个 Path 对象来引用文件。我们将 ElementTree 类的较短名称分配给了 XML:
import xml.etree.ElementTree as XML from pathlib import Path from typing import castcast() 函数是必需的,用于强制工具如 mypy 将结果视为给定类型。这使得我们可以忽略 None 结果的可能性。
-
定义一个函数来从给定的 Path 实例读取 XML 文档:
def race_summary(source_path: Path) -> None: -
通过解析 XML 文本创建一个 Python ElementTree 对象。通常,使用 source_path.read_text() 读取由路径指定的文件是最简单的。我们提供了这个字符串给 XML.fromstring() 方法进行解析。对于非常大的文件,增量解析器有时更有帮助。以下是针对较小文件的版本:
source_text = source_path.read_text(encoding=’UTF-8’) document = XML.fromstring(source_text) -
显示数据。XML 元素对象有两个用于导航 XML 结构的有用方法,即 find() 和 findall() 方法,分别用于定位标签的第一个实例和所有实例。使用这些方法,我们可以创建一个包含两个键的字典,"teams" 和 "legs":
legs = cast(XML.Element, document.find(’legs’)) teams = cast(XML.Element, document.find(’teams’)) for leg in legs.findall(’leg’): print(cast(str, leg.text).strip()) n = leg.attrib[’n’] for team in teams.findall(’team’): position_leg = cast(XML.Element, team.find(f"position/leg[@n=’{n}’]")) name = cast(XML.Element, team.find(’name’)) print( cast(str, name.text).strip(), cast(str, position_leg.text).strip() )在
标签内,有许多单独的 标签。每个标签都具有以下结构: <leg n="1">ALICANTE - CAPE TOWN</leg>Python 表达式 leg.attrib[’n’] 从给定元素中提取名为 n 的属性值。表达式 leg.text.strip() 将找到
标签内的所有文本,并去除额外的空白。 元素的 find() 和 findall() 方法使用 XPath 语法来定位标签。我们将在本食谱的 There’s more... 部分中详细检查这些功能。
重要的是要注意,find() 函数的结果具有 XML.Element | None 的类型提示。对于 None 结果的可能性,我们有两种处理方法:
-
使用 if 语句来处理结果为 None 的情况。
-
使用 cast(XML.Element, tag.find(...)) 来声明结果永远不会是 None。如果标签缺失,引发的异常将有助于诊断源文档与我们的消费者应用程序处理期望之间的不匹配。
对于比赛的每一腿,我们需要打印完成位置,这些位置包含在
由于 XML 支持混合内容模型,内容中的所有 \n、\t 和空格字符在解析操作中都被完美保留。我们很少希望保留这些空白字符,因此在使用 strip() 方法在有意义的内容前后删除任何多余的字符是有意义的。
11.7.3 它是如何工作的...
XML 解析模块将 XML 文档转换为基于标准化的文档对象模型(DOM)的相当复杂的树结构。在 xml.etree 模块的情况下,文档将由 Element 对象构建,这些对象通常代表标签和文本。
XML 还可以包含处理指令和注释。在这里,我们将忽略它们,专注于文档结构和内容。
每个元素实例都有标签的文本、标签内的文本、标签的部分属性和尾部。标签是
文本包含在标签的开始和结束之间。因此,一个如 <name>Team SCA</name> 的标签具有 "Team SCA" 作为代表 <name> 标签的元素的文本属性值。
注意,标签还有一个尾部属性。考虑以下两个标签的序列:
<name>Team SCA</name>
<position>...</position>
在 标签关闭后和
11.7.4 更多内容...
由于我们不能简单地将在 XML 文档转换为 Python 字典,我们需要一种方便的方法来搜索文档的内容。ElementTree 类提供了一种搜索技术,这是 XML 路径语言(XPath)的部分实现,用于指定 XML 文档中的位置。XPath 表示法为我们提供了相当大的灵活性。
XPath 查询与 find() 和 findall() 方法一起使用。以下是如何找到所有团队名称的方法:
>>> for tag in document.findall(’teams/team/name’):
... print(tag.text.strip())
Abu Dhabi Ocean Racing
Team Brunel
Dongfeng Race Team
MAPFRE
Team Alvimedica
Team SCA
Team Vestas Wind
XPath 查询查找顶级
11.7.5 参见
-
与 XML 文档相关的安全问题有很多。有关更多信息,请参阅 OWASP XML 安全速查表。
-
lxml 库扩展了元素树库的核心功能,提供了额外的功能。
-
本章后面的 阅读 HTML 文档 菜谱展示了我们如何从 HTML 源准备这些数据。
11.8 阅读 HTML 文档
网络上大量的内容都是使用 HTML 呈现的。浏览器将数据渲染得非常漂亮。我们可以编写应用程序从 HTML 页面中提取内容。
解析 HTML 涉及两个复杂因素:
-
与现代 XML 不同的古老 HTML 方言
-
可以容忍不正确 HTML 并创建正确显示的浏览器
第一个复杂因素是 HTML 和 XML 的历史。现代 HTML 是 XML 的一个特定文档类型。历史上,HTML 从自己的独特文档类型定义开始,基于较老的 SGML。这些原始 SGML/HTML 概念被修订和扩展,以创建一种新的语言,XML。在从遗留 HTML 到基于 XML 的 HTML 的过渡期间,网络服务器使用各种过渡文档类型定义提供内容。大多数现代网络服务器使用
解析 HTML 的另一个复杂因素是浏览器的设计。浏览器有义务渲染网页,即使 HTML 结构不佳甚至完全无效。设计目标是向用户提供反映内容的东西——而不是显示错误消息,指出内容无效。
HTML 页面可能充满了问题,但在浏览器中仍然可以显示一个看起来不错的页面。
我们可以使用标准库的 html.parser 模块,但它并不像我们希望的那样有帮助。Beautiful Soup 包提供了更多有用的方法来解析 HTML 页面到有用的数据结构。这个包可以在 Python 包索引(PyPI)上找到。请参阅pypi.python.org/pypi/beautifulsoup4。
这必须使用以下终端命令下载和安装:
(cookbook3) % python -m pip install beautifulsoup4
11.8.1 准备工作
我们已经收集了一些历史帆船赛结果,保存在 Volvo Ocean Race.html 文件中。这个文件包含有关团队、航段以及各个团队完成每个航段的顺序的信息。它已被从沃尔沃海洋赛网站抓取,并在浏览器中打开时看起来很棒。有关此数据的更多信息,请参阅本章中的阅读 JSON 和 YAML 文档配方。
虽然 Python 的标准库有 urllib 包来获取文档,但通常使用 Requests 包来读取网页。
通常,一个 HTML 页面具有以下整体结构:
<html>
<head>...</head>
<body>...</body>
</html>
在标签内,将有元数据、指向 JavaScript 库的链接以及指向层叠样式表(CSS)文档的链接。内容位于标签中。
在这种情况下,比赛结果位于标签内的 HTML
标签中。该表格具有以下结构:<table>
<thead>
...
</thead>
<tbody>
...
</tbody>
</table>
标签定义了表格的列标题。有一个单独的行标签,其中包含表格标题标签标签包含每个团队和比赛的成果行。每个表格行标签包含带有团队名称及其结果的行:
<tr class="ranking-item">
<td class="ranking-position">3</td>
<td class="ranking-avatar"><img src="img/..."></td>
<td class="ranking-team"> Dongfeng Race Team</td>
<td class="ranking-number">2</td>
<td class="ranking-number">2</td>
<td class="ranking-number">1</td>
<td class="ranking-number">3</td>
<td class="ranking-number" tooltipster
data="<center><strong>RETIRED</strong><br> Click for more info</center>" data-theme="tooltipster-3"
data-position="bottom" data-htmlcontent="true">
<a href="/en/news/8674_Dongfeng-Race-Team-breaks-mast-crew-safe.html"
target="_blank">8</a>
<div class="status-dot dot-3"></div></td>
... more columns ...
</tr>
标签有一个 class 属性,它定义了此行的 CSS 样式。这个 class 属性可以帮助我们的数据收集应用程序定位相关内容。
| ,这些标签包含列标题。对于示例数据,每个 | 标签看起来像这样:
重要的显示是每个赛段的标识符;在这个例子中是 LEG 1。这是 | 标签的文本内容。还有一个由 JavaScript 函数使用的属性值,data。这个属性值是腿的名称,当鼠标悬停在列标题上时显示。 | |||
|---|---|---|---|---|---|
| 表格数据标签。以下是从 HTML 中典型的 | |||||
| 标签也有 class 属性。对于这个精心设计的数据,class 属性阐明了 | 单元格的内容。并不是所有的 CSS 类名都像这些定义得那么好。
其中一个单元格——带有 tooltipster 属性——没有文本内容。相反,这个单元格有一个标签和一个空的 标签。这个单元格还包含几个属性,包括 data 等。这些属性由 JavaScript 函数用于在单元格中显示更多信息。
这里还有一个复杂性,即 data 属性包含实际上是 HTML 内容的文本。解析这部分文本需要创建一个单独的 BeautifulSoup 解析器。 11.8.2 如何实现...我们将定义一个函数,将 HTML 转换为包含腿描述和团队结果的字典:
使用一个特殊的注释来抑制 mypy 警告。# type: ignore [union-attr]是必需的,因为每个标签属性的类型提示为 Tag | None。对于某些应用程序,可以使用额外的 if 语句来确认存在的标签的预期组合。 我们必须从每一行的每个
|



的缩放因子进行分数数学。
。我们可以使用 Decimal 对象来计算这个值:
⌋,a mod b)。


,从华氏度,
,转换为摄氏度,
:
。
,从英里每小时,
,转换为公里每小时:
。
,需要从
转换回
:
。
转换为
:
转换为
:
× 1。这有助于确保我们理解递归规则。
⌋; t[s] mod 60
⌋; t[m] mod 60
⌋; t[h] mod 24
⌋; t[d] mod 7


= z。







![ϕ (n) = 1[1 + erf√n-] 2 2](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/mdn-py-cb-3e/img/file82.png)





浙公网安备 33010602011771号