Python-函数式编程第三版-全-
Python 函数式编程第三版(全)
原文:
zh.annas-archive.org/md5/360b5b79562417b069d240105455e25a译者:飞龙
前言
函数式编程提供了一系列创建简洁和表达性软件的技术。虽然 Python 不是一个纯粹的函数式编程语言,但我们可以在 Python 中进行大量的函数式编程。
Python 拥有一组核心的函数式编程特性。这使得我们可以从其他函数式语言中借用许多设计模式和技巧。这些借用的概念可以引导我们创建优雅的程序。特别是 Python 的生成器表达式,它否定了创建大型内存数据结构的需要,导致程序可能执行得更快,因为它们使用的资源更少。
在 Python 中,我们无法轻松地创建纯函数式程序。Python 缺乏实现这一目标所需的一些特性。例如,我们没有无限的递归,我们没有对所有表达式的惰性求值,我们也没有优化编译器。
Python 中提供了函数式编程语言的一些关键特性。其中之一是函数作为一等对象的理念。Python 还提供了一些高阶函数。内置的 map()、filter() 和 functools.reduce() 函数在此角色中广泛使用,而像 sorted()、min() 和 max() 这样的函数则不那么明显。
在某些情况下,对问题的函数式方法也会导致极高的性能算法。Python 使得创建大型中间数据结构变得过于容易,这会占用内存(和处理时间)。使用函数式编程设计模式,我们通常可以用占用内存更少且运行速度更快的生成器表达式来替换大型列表。
我们将从 Python 视角来探讨函数式编程的核心特性。我们的目标是借鉴函数式编程语言中的好思想,并使用这些思想在 Python 中创建表达性和简洁的应用程序。
本书面向对象
本书面向那些希望通过从函数式编程语言中借用技术和设计模式来创建简洁、表达性强的 Python 程序的更有经验的程序员。某些算法可以用函数式风格优雅地表达;我们可以——并且应该——将这些思想应用到使 Python 程序更易于阅读和维护。
这本书并不是作为 Python 教程编写的。本书假设读者对语言和标准库有一定的了解。对于 Python 的基础介绍,可以考虑学习《Python 编程学习指南》第三版:www.packtpub.com/product/learn-python-programming-third-edition/9781801815093。
虽然我们涵盖了函数式编程的基础,但这并不是对各种函数式编程技术的全面回顾。在另一种语言中接触过函数式编程可能会有所帮助。
本书涵盖内容
我们可以将这本书分解为两大类主题:
-
Python 中函数式编程的精华。这是第一章到第七章的内容。
-
帮助创建函数式程序的库模块。这是本书剩余章节的主题。第十二章包括基本语言和库主题。
第一章,理解函数式编程,介绍了表征函数式编程的一些技术。我们将确定一些将这些特性映射到 Python 的方法。我们还将讨论使用这些设计模式构建 Python 应用程序时,函数式编程的好处是如何积累的。
第二章,介绍核心函数式编程概念,深入探讨了函数式编程范式的核心特性。我们将逐一详细探讨它们在 Python 中的实现方式。我们还将指出一些不适用于 Python 的函数式语言特性。特别是,许多函数式语言都有复杂的类型匹配规则,这些规则是支持编译和优化的必要条件。
第三章,函数、迭代器和生成器,将展示如何利用不可变 Python 对象,以及如何将生成器表达式适应 Python 语言中的函数式编程概念。我们将探讨一些内置的 Python 集合,以及我们如何在不偏离函数式编程概念太远的情况下利用它们。
第四章,使用集合,展示了如何使用多个内置 Python 函数来操作数据集合。本章将重点介绍一些相对简单的函数,如any()和all(),这些函数可以将值集合缩减为单个结果。
第五章,高阶函数,探讨了常用的诸如map()和filter()等高阶函数。它还展示了一些其他的高阶函数,以及我们如何创建与函数一起工作或返回函数的自定义函数。
第六章,递归和归约,教授如何设计使用递归的算法,并将其优化为高性能的for语句。我们还将探讨一些其他广泛使用的归约方法,包括collections.Counter()。
第七章, 复杂无状态对象, 展示了我们可以使用不可变元组、typing.NamedTuple 和冻结的 @dataclass 来替代有状态对象的一些方法。我们还将探讨 pyrsistent 模块作为创建不可变对象的一种方式。不可变对象比有状态对象具有更简单的接口:我们永远不必担心滥用属性并将对象设置到某种不一致或无效的状态。
第八章, 迭代工具模块,检查了 itertools 标准库模块中的多个函数。这个函数集合简化了处理集合或生成器函数的程序编写。
第九章, 组合学中的迭代工具 – 排列和组合, 讲述了 itertools 模块中的组合学函数。这些函数比上一章中的函数更专业。本章包括一些示例,说明了这些函数使用不当以及组合爆炸的后果。
第十章, Functools 模块,专注于如何使用 functools 模块中的某些函数进行函数式编程。该模块中的一些函数更适合构建装饰器,它们被留到 第十二章, 装饰器设计技巧 中讨论。
第十一章, Toolz 包,涵盖了 toolz 包,这是一系列紧密相关的模块,帮助我们用 Python 编写函数式程序。toolz 模块与内置的 itertools 和 functools 模块并行,提供了一些更复杂且更好地利用柯里化函数的替代方案。
第十二章, 装饰器设计技巧,介绍了我们可以将装饰器视为构建复合函数的一种方式。虽然这里有很大的灵活性,但也存在一些概念上的限制:我们将探讨过于复杂的装饰器可能会变得令人困惑而不是有帮助的方法。
第十三章, PyMonad 库,探讨了 PyMonad 库的一些特性。这提供了一些额外的函数式编程特性。它还提供了一种学习更多关于单子的方法。在某些函数式语言中,单子是强制操作以特定顺序执行的重要方式,这些操作可能会被优化成不希望执行的顺序。由于 Python 已经具有严格的表达式和语句顺序,因此单子特性更具有教育意义而不是实用性。
第十四章,Multiprocessing、Threading 和 Concurrent.Futures 模块,指出良好函数式设计的一个重要后果:我们可以分配处理工作负载。使用不可变对象意味着我们不会因为同步写入操作不当而损坏对象。
第十五章,面向 Web 服务的函数式方法,展示了我们可以如何将 Web 服务视为一个嵌套的函数集合,这些函数将请求转换为响应。我们将看到如何利用函数式编程的概念来构建响应式、动态的 Web 内容。
第十六章,卡方案例研究,是一个额外的、仅在网络上提供的案例研究,它将多种函数式编程技术应用于特定的探索性数据分析问题。我们将对一些复杂数据进行χ²统计测试,以查看结果是否显示普通变异性,或者是否表明需要更深入分析的情况。你可以在这里找到案例研究:github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition/blob/main/Bonus_Content/Chapter_16.pdf。
为了充分利用这本书
本书假设读者对 Python 3 和应用程序开发的一般概念有所了解。我们不会深入研究 Python 的微妙或复杂特性;我们将避免过多地考虑语言的内部结构。
一些示例使用探索性数据分析(EDA)作为问题领域来展示函数式编程的价值。对基本概率和统计学的了解将有助于理解这一点。只有少数示例涉及到更深入的数据科学。
需要 Python 3.10。示例也已在 Python 3.11 上进行了测试,并且运行正确。出于数据科学的目的,通常建议从 conda 工具开始创建和管理虚拟环境。然而,这并非必需,读者应该能够使用任何可用的 Python。
通常使用 pip 安装额外的包。命令看起来像这样:
% python -m pip install toolz pymonad pyrsistent beautifulsoup4
完成练习
每章都包含一些练习,帮助读者将章节中的概念应用到实际代码中。大多数练习基于 GitHub 上本书的存储库中的代码:github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 存储库中已提供的单元测试用例相同。读者应将书籍的示例函数名替换为自己的解决方案,以确认其工作正常。
在某些情况下,练习建议编写一个响应文档来比较和对比多个解决方案。找到一位导师或专家,通过审查这些小文档的清晰度和完整性来帮助读者。一个好的设计方法比较将包括使用timeit模块进行性能测量,以展示一个设计相对于另一个设计的性能优势。
下载示例代码文件
书籍的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/OV1CB。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“Python 有其他语句,如global或nonlocal,它们会修改特定命名空间中变量的规则。”
粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中。例如:“基本案例说明零长度序列的和是 0。递归案例说明序列的和是第一个值加上序列其余部分的和。”
代码块设置如下:
print("Hello, World!")
任何命令行输入或输出都写作如下:
% conda create -n functional3 python=3.10
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过电子邮件 feedback@packtpub.com 发送反馈,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过电子邮件 questions@packtpub.com 联系我们。
错误更正:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问subscription.packtpub.com/help,点击提交错误按钮,搜索您的书籍,并输入详细信息。
侵权:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Python 函数式编程》第三版,我们非常乐意听到您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?您的电子书购买是否与您选择的设备不兼容?
不用担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取优惠:
-
扫描下面的二维码或访问下面的链接
![PIC]()
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:1
理解函数式编程
函数式编程使用表达式和评估来定义计算;通常,它们封装在函数定义中。它弱化或避免状态变化和可变对象的复杂性。这往往会产生更简洁、更易于表达的程序。在本章中,我们将介绍一些表征函数式编程的技术。我们将确定一些将这些特性映射到 Python 的方法。最后,我们还将讨论使用这些设计模式构建 Python 应用程序时,函数式编程的好处是如何积累的。
这本书不包含 Python 语言的教程介绍。我们假设读者已经了解一些 Python。在许多情况下,如果读者了解函数式编程语言,那么这些知识可以通过本书中的示例应用到 Python 上。有关 Python 的背景信息,请参阅《Python 速查手册》第 4 版或 Packt Publishing 出版的任何 Python 入门书籍。
Python 拥有广泛的编程特性,包括许多支持函数式编程的方法。正如我们将在本书中看到的那样,Python 不是一种纯粹的函数式编程语言;相反,它依赖于一系列特性的混合。我们将看到,该语言提供了足够正确的特性来提供函数式编程的好处。它还保留了命令式编程语言的全部优化能力。此外,我们可以混合面向对象和函数式特性,以利用两种范例的最佳方面。
我们还将探讨一个我们将用于本书许多示例的问题域。我们将尽量紧密地遵循探索性数据分析(EDA)。有关更多信息,请参阅www.itl.nist.gov/div898/handbook/eda/eda.htm。"探索"这一概念意味着在数据收集之后进行数据分析,目标是推断出描述数据的适当模型。这是一个有用的领域,因为许多算法都是函数式编程的良好示例。此外,在探索数据以定位趋势和关系时,函数式编程的好处会迅速积累。
我们的目标是确立函数式编程的一些基本原理。更严肃的 Python 代码将从第二章,介绍基本函数式概念开始。
在本章中,我们将关注以下主题:
-
比较和对比函数范式与其他软件设计方法。我们将探讨 Python 的方法如何被称为函数式编程和面向对象编程之间的“混合”。
-
我们将深入探讨一个从函数式编程文献中提取的具体示例。
-
我们将以 EDA 的概述和为什么这个学科似乎提供了许多函数式编程的示例作为结束。
本书将重点关注 Python 3.10 的特性。这包括新的match语句。
在本书的整个过程中,我们将在示例中包含 Python 3 类型提示。类型提示可以帮助读者可视化函数定义背后的基本目的。类型提示通过 mypy 工具进行分析。与单元测试一样,mypy 可以是工具链的一部分,以产生高质量的软件。
1.1 编程的函数式风格
我们将通过一系列示例来定义函数式编程。这些示例之间的区别特征是状态的概念,特别是计算的状态。
Python 的强命令式特性意味着计算的状状态由各个命名空间中变量的值定义。某些类型的语句通过添加、更改或删除变量来对状态进行明确的更改。我们称之为命令式,因为特定类型的语句会改变状态。
在 Python 中,赋值语句是改变状态的主要方式。Python 还有其他语句,如global或nonlocal,它们修改特定命名空间中变量的规则。def、class和import等语句改变处理上下文。剩余的大部分语句提供了选择哪些赋值语句被执行的方法。然而,所有这些不同类型的语句的焦点都是改变变量的状态。
在函数式语言中,我们用评估函数的更简单概念来替换状态——变量的变化值。每次函数评估都从现有对象创建新的对象或对象。由于函数式程序是函数的组合,我们可以设计易于理解的底层函数,然后创建函数的组合,这些组合比复杂的语句序列更容易可视化。
函数评估更接近数学形式化。正因为如此,我们通常可以使用简单的代数来设计一个算法,该算法可以清楚地处理边缘情况和边界条件。这使得我们更有信心函数是有效的。这也使得定位形式化单元测试的测试用例变得容易。
重要的是要注意,与命令式(面向对象或过程式)程序相比,函数式程序通常相对简洁、表达性强且效率高。这种好处不是自动的;它需要仔细的设计。对于函数式编程的设计工作通常比过程式编程小。一些有命令式和面向对象风格经验的开发者可能发现,从有状态的设计转向函数式设计是一个挑战。
1.2 比较和对比过程式和函数式风格
我们将使用一个小型示例程序来展示非函数式,或过程式,的编程风格。此示例计算一系列数字的总和。每个数字都有特定的属性,使其成为序列的一部分。
def sum_numeric(limit: int = 10) -> int:
s = 0
for n in range(1, limit):
if n % 3 == 0 or n % 5 == 0:
s += n
return s
这个函数计算的总和只包括 3 或 5 的倍数。我们使这个程序严格遵循过程式,避免任何显式使用 Python 的对象特性。函数的状态由变量s和n的值定义。变量n取值范围为 1 ≤ n < 10。由于迭代涉及对n变量值的有序探索,我们可以证明当n的值等于limit的值时,迭代将终止。
有两个显式赋值语句,都用于设置s变量的值。这些状态变化是可见的。n的值由for语句隐式设置。s变量中的状态变化是计算状态的一个基本元素。
现在,让我们从纯粹的功能角度再次审视这个问题。然后,我们将从更 Pythonic 的角度来审视,它保留了函数式方法的核心,同时利用了 Python 的一些特性。
1.2.1 使用函数式范式
在函数式意义上,3 和 5 的倍数之和可以分解为两部分:
-
数字序列的总和
-
一系列通过简单测试条件的值,例如,是 3 和 5 的倍数
为了非常正式,我们可以使用更简单的语言组件定义总和作为一个函数。序列的总和有一个递归定义:
from collections.abc import Sequence
def sumr(seq : Sequence[int]) -> int:
if len(seq) == 0:
return 0
return seq[0] + sumr(seq[1:])
我们在两种情况下定义了总和。基本案例指出,零长度序列的总和是 0。递归案例指出,序列的总和是第一个值加上序列剩余部分的总和。由于递归定义依赖于较短的序列,我们可以确信它最终会退化到基本案例。
下面是此函数工作的一些示例:
>>> sumr([7, 11])
18
>>> sumr([11])
11
>>> sumr([])
0
第一个例子计算了一个包含多个项目的列表的总和。第二个例子通过将第一个项目seq[0]加到剩余项目的总和sumr(seq[1:])来展示递归规则的工作方式。最终,结果的计算涉及到一个空列表的总和,它被定义为 0。
sumr函数最后一行的+运算符和基本案例中的初始值 0 将方程定义为总和。考虑如果我们把运算符改为*并将初始值改为 1 会发生什么:这个新表达式将计算乘积。我们将在接下来的章节中回到这个简单的泛化思想。
类似地,生成具有给定属性的值序列可以有一个递归定义,如下所示:
from collections.abc import Sequence, Callable
def until(
limit: int,
filter_func: Callable[[int], bool],
v: int
) -> list[int]:
if v == limit:
return []
elif filter_func(v):
return [v] + until(limit, filter_func, v + 1)
else:
return until(limit, filter_func, v + 1)
在这个函数中,我们比较了给定的值v与上限limit。如果v达到了上限,那么结果列表必须是空的。这是给定递归的基本案例。
有两个更多的情况是由一个外部定义的filter_func()函数定义的。v的值是通过filter_func()函数传递的;如果这个函数返回一个非常短的列表,包含一个元素,这个元素可以与until()函数计算出的任何剩余值连接起来。
如果v的值被filter_func()函数拒绝,这个值将被忽略,结果简单地由until()函数计算出的任何剩余值定义。
我们可以看到,v的值将从初始值增加到limit,这确保我们将达到基本情况。
在我们能够看到如何使用until()函数之前,我们将定义一个小函数来过滤出 3 或 5 的倍数:
def mult_3_5(x: int) -> bool:
return x % 3 == 0 or x % 5 == 0
我们也可以将其定义为 lambda 对象,以强调简单函数的简洁定义。任何超过一行表达式的复杂度都需要def语句。
这个函数可以与until()函数结合生成一个值序列,这些值是 3 和 5 的倍数。以下是一个示例:
>>> until(10, mult_3_5, 0)
[0, 3, 5, 6, 9]
回顾本节顶部的分解,我们现在有了一种计算总和的方法,也有了一种计算值序列的方法。
我们可以将sumr()和until()函数结合起来计算值的总和。以下是生成的代码:
def sum_functional(limit: int = 10) -> int:
return sumr(until(limit, mult_3_5, 0))
这个用于计算总和的小程序没有使用赋值语句来设置变量的值。这是一个纯粹的函数式、递归定义,与数学抽象相匹配,这使得推理更容易。我们可以确信每个部分都能单独工作,从而对整体有信心。
作为实际操作,我们将使用许多 Python 特性来简化创建函数式程序。我们将在本例的下一个版本中查看这些优化的一些例子。
1.2.2 使用函数式混合
我们将继续使用前一个例子的一个主要函数式版本来计算 3 和 5 的倍数的和。我们的混合函数式版本可能看起来像以下这样:
def sum_hybrid(limit: int = 10) -> int:
return sum(
n for n in range(1, limit)
if n % 3 == 0 or n % 5 == 0
)
我们已经使用生成器表达式遍历值集合并计算这些值的总和。range(1, 10)对象是一个可迭代对象;它生成一个值序列{n∣1 ≤ n < 10},通常总结为“n 的值,其中 1 小于或等于 n 且 n 小于 10。”更复杂的表达式n for n in range(1, 10) if n % 3 == 0 or n % 5 == 0也是一个生成器。它生成一组值,{n∣1 ≤ n < 10 ∧ (n ≡ 0 mod 3 ∨ n ≡ 0 mod 5)};我们可以描述为“n 的值,其中 1 小于或等于 n 且 n 小于 10,且 n 与 3 同余 0 或 n 与 5 同余 0。”这些是从 1 到 10 的集合中取出的 3 和 5 的倍数。变量n依次绑定到range对象提供的每个值。sum()函数消耗可迭代的值,创建一个最终对象,23。
绑定的变量n在生成器表达式之外不存在。变量n在程序的其他地方是不可见的。
在这个示例中,变量n与前面两个命令式示例中的变量n并不直接可比。一个for语句(在生成器表达式之外)在局部命名空间中创建了一个正确的变量。生成器表达式不会以与for语句相同的方式创建变量:
>>> sum(
... n for n in range(1, 10)
... if n % 3 == 0 or n % 5 == 0
... )
23
>>> n
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name ’n’ is not defined
生成器表达式不会像变量n那样污染命名空间,因为n在表达式的非常狭窄的上下文中之外没有相关性。这是一个令人愉快的特性,确保我们不会因为那些在单个表达式之外没有意义的变量的值而感到困惑。
1.2.3 乌龟堆栈
当我们使用 Python 进行函数式编程时,我们将踏上一条涉及非严格函数式混合的道路。Python 不是 Haskell、OCaml 或 Erlang。就底层处理器硬件而言,它也不是函数式的;它甚至不是严格面向对象的,因为 CPU 通常是过程式的。
所有编程语言都建立在抽象、库、框架和虚拟机之上。这些抽象反过来可能依赖于其他抽象、库、框架和虚拟机。最恰当的隐喻是这样的:世界是建立在一只巨大的乌龟背上的。这只乌龟站在另一只巨大的乌龟背上。而这只乌龟,又站在另一只乌龟的背上.
一切皆由乌龟组成。
—— 匿名
层层抽象没有实际的上限。即使是像电路和电子这样的具体事物,也可能是为了帮助设计者总结量子电动力学的细节而进行的抽象。
更重要的是,抽象和虚拟机的存在并没有实质性地改变我们设计软件以利用 Python 函数式编程特性的方法。
即使在函数式编程社区内部,也存在更纯粹和不太纯粹的函数式编程语言。有些语言广泛使用单子来处理诸如文件系统输入输出这样的有状态事物。其他语言则依赖于类似于我们使用 Python 的混合环境。在 Python 中,软件可以一般是函数式的,同时精心选择过程式异常。
我们的函数式 Python 程序将依赖于以下三个抽象堆栈:
-
我们的应用程序将一直是以函数的形式存在,直到我们触及到对象;
-
支持我们函数式编程的底层 Python 运行时环境也是以对象的形式存在,直到我们触及到库;
-
支持 Python 的库就像是 Python 站立在上的乌龟。
操作系统和硬件形成它们自己的乌龟堆栈。这些细节与我们即将解决的问题无关。
1.3 函数式编程的经典示例
作为我们介绍的一部分,我们将查看一个函数式编程的经典示例。这是基于 John Hughes 的论文《Why Functional Programming Matters》。这篇文章发表在由 D. Turner 编辑的《Research Topics in Functional Programming》论文集中,由 Addison-Wesley 于 1990 年出版。
这里有一个链接到《Research Topics in Functional Programming》中的一篇论文,“Why Functional Programming Matters”:www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf
这篇论文对函数式编程进行了深刻的讨论。给出了几个示例。我们将只看其中一个:用于寻找函数任何根的牛顿-拉夫森算法。在这种情况下,我们将定义一个计算数字平方根的函数。
这很重要,因为许多这个算法的版本依赖于通过循环显式管理的状态。确实,休斯论文提供了一个 Fortran 代码片段,强调了有状态、命令式的处理。
这个近似的骨架是从当前近似计算下一个近似值。next_()函数接受x,即sqrt(n)值的近似,并计算一个包围正确根的下一个值。请看以下示例:
def next_(n: float, x: float) -> float:
return (x + n / x) / 2
此函数计算一系列值,这些值将迅速收敛到某个值 x,使得 x =
,这意味着 x =
。
注意,next()这个名字会与内置函数冲突。将其命名为next_()让我们尽可能接近原始演示,使用 Pythonic 的名字。
这里是如何在 Python 的交互式 REPL 中使用该函数的示例:
>>> n = 2
>>> f = lambda x: next_(n, x)
>>> a0 = 1.0
>>> [round(x, 4)
... for x in (a0, f(a0), f(f(a0)), f(f(f(a0))),)
... ]
[1.0, 1.5, 1.4167, 1.4142]
我们将f()函数定义为 lambda,它将收敛到
(其中 n = 2)。我们以 1.0 作为 a[0]的初始值。然后我们评估了一系列递归评估:a[1] = f(a[0]),a[2] = f(f(a[0])),依此类推。我们使用生成器表达式评估这些函数,以便将每个值四舍五入到四位小数。这使得输出更容易阅读,并且更容易与doctest一起使用。这个序列似乎迅速收敛到
。为了得到更精确的答案,我们必须继续执行上述四个步骤之后的步骤。
我们可以编写一个函数,理论上可以生成一个无限序列的 a[i]值。这个序列将收敛到正确的平方根:
from collections.abc import Iterator, Callable
def repeat(
f: Callable[[float], float],
a: float
) -> Iterator[float]:
yield a
yield from repeat(f, f(a))
此函数将使用函数f()和一个初始值a生成一系列近似值。如果我们提供前面定义的next_()函数,我们将得到一系列对n参数平方根的近似值。
repeat()函数期望f()函数有一个参数;然而,我们的next_()函数有两个参数。我们使用 lambda 对象lambda x: next_(n, x)创建了一个部分版本的next_()函数,其中一个变量被绑定。
Python 的生成器函数不能简单地递归;它们必须显式地迭代递归结果,并单独产生它们。
尝试使用简单的return repeat(f, f(a))将结束迭代,返回一个生成器表达式而不是产生值。
有两种方法可以返回所有值而不是返回一个生成器表达式,如下所示:
-
我们可以编写一个显式的
for语句来产生值,如下所示:for x in some_iter: yield x -
我们可以使用以下方式使用
yield from表达式:yield from some_iter
这两种产生递归生成函数值的技巧将会有类似的结果。我们将尝试强调yield from。
结果表明yield和yield from比我们在这里展示的要复杂一些。为了我们的目的,我们将限制自己处理递归结果。有关yield和yield from的完整功能集的更多信息,请参阅 PEP 342 和 PEP 380:peps.python.org/pep-0342/ 和 peps.python.org/pep-0380/。
当然,我们不想创建由repeat()函数生成的整个无限序列。当我们找到所需的平方根时,停止生成值是至关重要的。我们可以考虑的“足够接近”的极限的常见符号是希腊字母 epsilon,𝜖。
在 Python 中,当我们一次从无限序列中取出一个元素时,我们必须稍微聪明一点。使用一个简单的接口函数来包装稍微复杂一些的递归是一个很好的解决方案。请看以下代码片段:
from collections.abc import Iterator
def within(
𝜖: float,
iterable: Iterator[float]
) -> float:
def head_tail(
𝜖: float,
a: float,
iterable: Iterator[float]
) -> float:
b = next(iterable)
if abs(a-b) <= 𝜖:
return b
return head_tail(𝜖, b, iterable)
return head_tail(𝜖, next(iterable), iterable)
我们定义了一个内部函数head_tail(),它接受容差值𝜖、可迭代序列中的一个元素a以及可迭代序列的其余部分iterable。使用next()函数从可迭代序列中提取的第一个元素绑定到名称b。如果|a − b|≤ 𝜖,则a和b的值足够接近,可以将b的值称为平方根;差异小于或等于非常小的值𝜖。否则,我们使用b值在head_tail()函数的递归调用中检查下一对值。
我们的within()函数正确地初始化了内部head_tail()函数,使用iterable参数的第一个值。
我们可以使用三个函数next_()、repeat()和within()来创建一个平方根函数,如下所示:
def sqrt(n: float) -> float:
return within(
𝜖=0.0001,
iterable=repeat(
lambda x: next_(n, x),
1.0
)
)
我们使用repeat()函数根据next_(n,x)函数生成一个(可能)无限值的序列。我们的within()函数将在找到两个差异小于𝜖的值时停止生成序列中的值。
这个 sqrt() 函数的定义为底层的 within() 函数提供了有用的默认值。它提供了一个 𝜖 值为 0.0001 和一个初始 a[0] 值为 1.0。
更高级的版本可以使用默认参数值来使更改成为可能。作为一个练习,sqrt() 的定义可以被重写,以便像 sqrt(1.0, 0.000_01, 3) 这样的表达式将以 1.0 的近似值开始,并计算
的值,精确到 0.00001。对于大多数应用,初始 a[0] 值可以是 1.0。然而,它越接近实际的平方根,这个算法的收敛速度就越快。
这个近似算法的原始示例是在 Miranda 语言中展示的。很容易看出 Miranda 和 Python 之间存在一些深刻的差异。尽管有差异,但相似之处让我们有信心认为许多类型的函数式编程可以很容易地在 Python 中实现。
这里展示的 within 函数是按照原始文章的函数定义编写的。Python 的 itertools 库提供了一个 takewhile() 函数,可能比这里的 within() 函数更适合这个应用。同样,math.isclose() 函数可能比这里使用的 abs(a-b) <= 𝜖 表达式更好。Python 提供了大量的预构建函数式编程特性;我们将在第八章(Chapter 08.xhtml#x1-1700008)、The Itertools Module 和第九章(Chapter_09.xhtml#x1-1990009)、Itertools for Combinatorics – Permutations and Combinations 中仔细研究这些函数。
1.4 探索性数据分析
在本书的后面部分,我们将使用探索性数据分析领域作为函数式编程具体示例的来源。这个领域充满了处理复杂数据集的算法和方法;函数式编程通常在问题域和自动化解决方案之间非常匹配。
虽然细节因作者而异,但 EDA(探索性数据分析)有几个被广泛接受的阶段。这些包括以下内容:
- 数据准备:这可能涉及源应用程序的提取和转换。它可能涉及解析源数据格式,并进行某种数据清理以删除不可用或无效的数据。这是功能设计技术的优秀应用。
David Mertz 的杰出著作《为高效数据科学清理数据》(www.packtpub.com/product/cleaning-data-for-effective-data-science/9781801071291)提供了有关数据清理的更多信息。这对于所有数据科学和分析工作都是一个关键主题。
- 数据探索:这是对可用数据的描述。这通常涉及基本统计函数。这是探索函数式编程的另一个绝佳场所。我们可以将我们的重点描述为单变量和双变量统计,但这听起来过于令人畏惧和复杂。这实际上意味着我们将关注均值、中位数、众数和其他相关描述性统计。数据探索还可能涉及数据可视化。我们将绕过这个问题,因为它不涉及很多函数式编程。
有关 Python 可视化的更多信息,请参阅《Python 交互式数据可视化》,www.packtpub.com/product/interactive-data-visualization-with-python-second-edition/9781800200944。有关一些额外的可视化库,请参阅www.projectpro.io/article/python-data-visualization-libraries/543。
-
数据建模和机器学习:这通常具有规范性,因为它涉及将模型扩展到新数据。我们将绕过这个问题,因为一些模型可能会变得数学上复杂。如果我们在这上面花费太多时间,我们就无法专注于函数式编程。
-
评估和比较:当存在替代模型时,每个模型都必须被评估以确定哪个更适合现有数据。这可能涉及模型输出的普通描述性统计,这些统计可以受益于功能设计技术。
EDA 的一个目标通常是创建一个可以作为决策支持应用程序部署的模型。在许多情况下,模型可能是一个简单的函数。函数式编程方法可以将模型应用于新数据,并显示供人类消费的结果。
1.5 摘要
在本章中,我们着眼于区分函数式范式和命令式范式,以探讨编程范式。就我们的目的而言,面向对象编程是一种命令式编程;它依赖于显式的状态变化。本书的目标是探索 Python 的函数式编程特性。我们注意到 Python 的一些部分不允许纯函数式编程;我们将使用一些混合技术,这些技术将简洁、表达性强的函数式编程的优点与 Python 中的一些高性能优化相结合。
在下一章中,我们将详细探讨五种特定的函数式编程技术。这些技术将构成我们 Python 混合函数式编程的基础。
1.6 练习
本书中的练习基于 GitHub 上 Packt Publishing 提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。这些作为提示,允许读者探索其他解决方案。
在许多情况下,练习将需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已提供的单元测试用例相同。读者需要将书籍中的示例函数名替换为自己的解决方案以确认其工作。
1.6.1 将命令式算法转换为函数式代码
以下算法被表述为命令式赋值语句和 while 构造来指示迭代处理。
算法 1:命令式迭代
这看起来是在计算什么?给定像 sum 这样的 Python 内置函数,这能简化吗?
将其用 Python 编写并重构代码以确保生成正确答案。
一个测试用例如下:

m 的计算值大约为 7.5。
1.6.2 将逐步计算转换为函数式代码
以下算法被表述为一系列的单个赋值语句。rad(x) 函数将度转换为弧度,rad(d) = π × 。请参阅 math 模块以获取实现。
算法 2:命令式计算
这段代码是否容易理解?你能将这个计算总结为一个简短的类似数学公式的表达式吗?
将其分解为部分,第 1 到 8 行似乎专注于一些转换、差异和中点计算。第 9 到 12 行计算两个值,x 和 y。这些能否总结或简化?最后的四行进行相对直接的 d 的计算。这些能否总结或简化?作为一个提示,看看 math.hypot() 函数,这个函数可能适用于这种情况。
将其用 Python 编写并重构代码会有所帮助。
一个测试用例如下:
lat[1] ← 32.82950
lon[1] ←−79.93021
lat[2] ← 32.74412
lon[2] ←−79.85226
d 的计算值大约为 6.4577。
重构代码可以帮助你确认你的理解。
1.6.3 修改 sqrt() 函数
在“经典函数式编程示例”中定义的 sqrt() 函数只有一个参数值,n。重写它以创建一个更高级的版本,使用默认参数值来使更改成为可能。例如,表达式 sqrt(1.0, `` 0.000_01, `` 3) 将从 1.0 的近似值开始,并计算到 0.00001 的精度。最后一个参数值 3 是 n 的值,我们需要计算其平方根的数字。
1.6.4 数据清洗步骤
源数据文件包含各种格式的美国 ZIP 码。当使用电子表格软件收集或转换数据时,这个问题经常出现。
-
一些 ZIP 代码被处理为数字。这对于新英格兰地区来说并不理想,因为那里的 ZIP 代码以零开头。例如,新罕布什尔州朴次茅斯的代码应该是
03801。在源文件中,它是3801。大多数情况下,这些数字将具有五位或九位数字,但新英格兰的一些代码在去掉单个前导零后将是四位或八位数字。对于波多黎各,可能会有两个前导零。 -
一些 ZIP 代码以字符串形式存储,例如 12345−0100,其中附加了一个四位邮政信箱扩展到基本五位代码。
CSV 格式的文件只有文本值。然而,当文件中的数据经过电子表格处理时,可能会出现问题。因为 ZIP 代码只有数字,它可以被视为数值数据。这意味着原始数据值已经被转换为数字,然后再转换回文本表示。这些转换将删除前导零。在各种电子表格应用程序中存在许多解决方案来防止这个问题。如果不使用它们,数据可能会出现异常值,这些异常值可以被清理以恢复原始表示。
练习的目标是计算源数据文件中最受欢迎的 ZIP 代码的直方图。数据必须经过清理,以具有以下两种 ZIP 格式:
-
没有邮政信箱的五位字符,例如
03801 -
例如,带有连字符的十个字符,例如
03899-9876
基本的直方图可以使用collections.Counter对象完成如下。
from collections import Counter
import csv
from pathlib import Path
DEFAULT_PATH = Path.cwd() / "address.csv"
def main(source_path: Path = DEFAULT_PATH) -> None:
frequency: Counter[str] = Counter()
with source_path.open() as source:
rdr = csv.DictReader(source)
for row in rdr:
if "-" in row[’ZIP’]:
text_zip = row[’ZIP’]
missing_zeroes = 10 - len(text_zip)
if missing_zeroes:
text_zip = missing_zeroes*’0’ + text_zip
else:
text_zip = row[’ZIP’]
if 5 < len(row[’ZIP’]) < 9:
missing_zeroes = 9 - len(text_zip)
else:
missing_zeroes = 5 - len(text_zip)
if missing_zeroes:
text_zip = missing_zeroes*’0’ + text_zip
frequency[text_zip] += 1
print(frequency)
if __name__ == "__main__":
main()
这利用了命令式处理功能来读取文件。使用for语句处理文件行,这种整体设计是 Python 的一个基本特性,我们可以保留它。
另一方面,通过一系列状态变化处理text_zip和missing_zeroes变量似乎是一个潜在的混淆来源。
这可以通过几次重写进行重构:
-
将
main()函数分解为两部分。应该编写一个新的zip_histogram()函数来包含大部分处理细节。这个函数将处理打开的文件,并返回一个Counter对象。建议的签名如下:def zip_histogram( reader: csv.DictReader[str]) -> Counter[str]: passmain()函数负责打开文件,创建csv.DictReader实例,评估zip_histogram(),并打印直方图。 -
一旦定义了
zip_histogram()函数,ZIP属性的清理可以重构为一个单独的函数,例如命名为zip_cleanse()。这个函数而不是设置text_zip变量的值,可以返回清理后的结果。这可以单独测试以确保各种情况都能优雅地处理。 -
带有连字符和不带连字符的长 ZIP 码之间的区别是应该修复的问题。一旦
zip_cleanse()在一般情况下工作,添加一个新函数来在只有数字的 ZIP 码中注入连字符。这应该将38011234转换为03801-1234。注意,短的五位 ZIP 码不需要添加连字符;这种额外的转换仅适用于九位代码,使其成为十位字符串。
最终的 zip_histogram() 函数应该看起来像以下这样:
def zip_histogram(
reader: csv.DictReader[str]) -> Counter[str]:
return Counter(
zip_cleanse(
row[’ZIP’]
) for row in reader
)
这为在给定列中进行专注的数据清理提供了一个框架。它使我们能够区分 CSV 和文件处理功能,以及如何清理特定列数据的细节。
1.6.5(高级)优化此功能代码
以下算法被表述为一个单独的“步骤”,该步骤已被分解为三个独立的公式。这种分解更多的是为了满足将表达式放入印刷页面限制的需要,而不是一种有用的优化。rad(x) 函数将度转换为弧度,rad(d) = π ×
。
算法 3:冗余表达式
存在许多冗余表达式,例如 rad(lat[1]) 和 rad(lat[2])。如果这些被分配给局部变量,表达式能否简化?
d 的最终计算结果与计算斜边传统的理解不符,
。代码应该重构以匹配 math.hypot 中的定义吗?
从用 Python 编写这个开始,然后重构代码是一个好方法。
以下是一个测试用例:
lat[1] ← 32.82950
lon[1] ←−79.93021
lat[2] ← 32.74412
lon[2] ←−79.85226
d 的计算值大约为 6.4577。
代码重构可以帮助你确认你对这段代码真正功能的理解。
加入我们的 Discord 社区空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第二章:2
介绍基本函数式概念
函数式编程的大多数特性已经包含在 Python 语言中。我们编写函数式 Python 的目标是尽可能地将我们的关注点从命令式(过程式或面向对象)技术转移到函数式编程上。
我们将探讨以下函数式编程主题:
-
在 Python 中,函数是一等对象。
-
我们可以使用和创建高阶函数。
-
我们可以非常容易地创建纯函数。
-
我们可以处理不可变数据。
-
在一定程度上,我们可以创建具有非严格子表达式评估的函数。Python 通常严格评估表达式。正如我们稍后将要看到的,一些运算符是非严格的。
-
我们可以设计利用贪婪与惰性评估的函数。
-
我们可以用递归代替显式的循环状态。
-
我们有一个类型系统,可以应用于函数和对象。
这部分内容是对第一章概念的扩展:首先,纯函数式编程避免了通过变量赋值来维护显式状态的复杂性;其次,Python 不是一个纯函数式语言。
由于 Python 不是一个纯函数式语言,我们将关注那些在函数式编程中无可争议的重要特性。我们将从查看函数作为具有自己属性和方法的独立 Python 对象开始。
2.1 函数作为一等对象
函数式编程通常简洁且表达力强。实现这一目标的一种方法是通过将函数作为其他函数的参数和返回值。我们将探讨许多操作函数的示例。
为了实现这一点,函数必须在运行时环境中是一等对象。在像 C 这样的编程语言中,函数不是运行时对象;因为编译的 C 代码通常缺乏内部属性和方法,所以对函数的运行时内省很少。然而,在 Python 中,函数是由 def 语句创建的对象(通常),并且可以被其他 Python 函数操作。我们还可以通过将 lambda 对象赋给变量来创建一个可调用的函数对象。
函数定义如何创建具有属性的对象的示例如下:
>>> def example(a, b, **kw):
... return a*b
...
>>> type(example)
<class ’function’>
>>> example.__code__.co_varnames
(’a’, ’b’, ’kw’)
>>> example.__code__.co_argcount
2
我们创建了一个对象,名为 example,它属于 function 类。这个对象有许多属性。函数对象的 __code__ 属性也有自己的属性。实现细节并不重要。重要的是函数是一等对象,可以像所有其他对象一样被操作。以下示例展示了函数对象许多属性中的两个。
2.1.1 纯函数
一个不受副作用混淆影响的函数通常比在应用程序的其他地方更新状态的函数更具表达性。使用纯函数还可以通过改变评估顺序进行一些优化。然而,主要的优势来自于纯函数在概念上更简单,并且更容易测试。
要在 Python 中编写纯函数,我们必须编写局部代码。这意味着我们必须避免使用 global 语句。我们需要避免与具有隐藏状态的对象纠缠;通常,这意味着避免输入和输出操作。我们还需要仔细检查任何对 nonlocal 的使用。虽然将值赋给非局部变量是一个副作用,但状态变化仅限于嵌套函数定义。避免全局变量和文件操作是一个容易达到的标准。纯函数是 Python 程序的常见特性。
没有内置的工具可以保证 Python 函数没有副作用。对于对细节感兴趣的人,可以使用 mr-proper 工具,pypi.org/project/mr-proper/,来确认函数是纯的。
Python 中的 lambda 经常用于创建一个非常小的、纯函数。lambda 对象执行输入或输出或使用不纯函数是可能的。一些代码检查仍然有助于消除任何疑虑。
这里是一个通过将 lambda 对象赋值给变量创建的函数:
>>> mersenne = lambda x: 2 ** x - 1
>>> mersenne(17)
131071
我们使用 lambda 创建了一个纯函数,并将其赋值给变量 mersenne。这是一个具有单个参数 x 的可调用对象,返回单个值。
以下是一个作为 lambda 对象定义的不纯函数的示例:
>>> default_zip = lambda row: row.setdefault(’ZIP’, ’00000’)
此函数有更新字典的潜力,如果键 'ZIP' 不存在。以下示例中有两种情况:
>>> r_0 = {’CITY’: ’Vaca Key’}
>>> default_zip(r_0)
’00000’
>>> r_0
{’CITY’: ’Vaca Key’, ’ZIP’: ’00000’}
>>> r_1 = {’CITY’: ’Asheville’, ’ZIP’: 27891}
>>> default_zip(r_1)
27891
在第一种情况下,字典对象 r_0 没有键 'ZIP'。字典对象被 lambda 对象更新。这是使用字典的 setdefault() 方法的后果。
在第二种情况下,r_1 对象包含键 'ZIP'。没有更新字典。副作用取决于函数之前对象的状态,这使得函数可能更难以理解。
2.1.2 高阶函数
我们可以使用高阶函数来实现表达性、简洁的程序。这些函数可以接受一个函数作为参数或返回一个函数作为值。我们可以使用高阶函数作为从简单函数创建复合函数的一种方式。
考虑 Python 的 max() 函数。我们可以提供一个函数作为参数,并修改 max() 函数的行为。
这里有一些我们可能想要处理的数据:
>>> year_cheese = [(2000, 29.87), (2001, 30.12),
... (2002, 30.6), (2003, 30.66), (2004, 31.33),
... (2005, 32.62), (2006, 32.73), (2007, 33.5),
... (2008, 32.84), (2009, 33.02), (2010, 32.92)]
我们可以像下面这样应用 max() 函数:
>>> max(year_cheese)
(2010, 32.92)
默认行为是简单地比较序列中的每个元组。这将返回每个元组中位置零上具有最大值的元组。
由于 max() 函数是一个高阶函数,我们可以提供一个函数作为参数。在这种情况下,我们将使用 lambda 作为函数;这被 max() 函数使用,如下所示:
>>> max(year_cheese, key=lambda yc: yc[1])
(2007, 33.5)
在这个例子中,max() 函数应用提供的 lambda 并返回每个元组中位置一的最大值的元组。
Python 提供了丰富的集合高阶函数。我们将在后面的章节中看到 Python 每个高阶函数的示例,主要在第五章,高阶函数。我们还将看到我们如何轻松编写我们自己的高阶函数。
2.2 不可变数据
由于我们不使用变量来跟踪计算的状态,我们的焦点需要保持在不可变对象上。我们可以大量使用元组、typing.NamedTuples 和冻结的 @dataclass 来提供更复杂且不可变的数据结构。我们将在第七章,复杂无状态对象中详细查看这些类定义。
不可变对象的概念对 Python 来说并不陌生。字符串和元组是两种广泛使用的不可变对象。使用不可变元组而不是更复杂的可变对象可能会有性能优势。在某些情况下,好处来自于重新思考算法以避免对象修改的成本。
例如,这里有一个与不可变对象配合得很好的常见设计模式:wrapper() 函数。元组列表是一种相当常见的数据结构。我们通常会以以下两种方式之一处理这个元组列表:
-
使用高阶函数:如前所述,我们向
max()函数提供了一个 lambda 作为参数:max(year_cheese, key=lambda yc: yc[1])。 -
使用封装-处理-解封装模式:在函数式上下文中,我们可以使用遵循
unwrap(process(wrap(structure)))模式的代码来实现这一点。
例如,看看以下命令片段:
>>> max(map(lambda yc: (yc[1], yc), year_cheese))[1]
(2007, 33.5)
这符合封装数据结构、找到封装结构中的最大值以及然后解封装的三部分模式。
表达式 map(lambda yc: (yc[1], yc), year_cheese) 将每个项目转换为一个包含键和原始项目的两个元组的元组。在这个例子中,比较键值是表达式 yc[1]。
处理是通过 max() 函数完成的。由于源数据中的每一部分都已简化为一个新的两个元组,因此 max() 函数的高阶函数特性不是必需的。为了使这可行,比较值是从源记录的位置一取出的,并首先放入两个元组中。max() 函数的默认行为使用每个两个元组中的第一个项目来定位最大值。
最后,我们使用下标表达式 [1] 来解封装。这将选择 max() 函数选择的两个元组的第二个元素。
这种包装和解包的方式非常常见,以至于一些语言有名为 fst() 和 snd() 的特殊函数,我们可以用它们作为函数前缀而不是 [0] 或 [1] 的语法后缀。我们可以用这个想法来修改我们的包装-处理-解包示例,如下所示:
>>> snd = lambda x: x[1]
>>> snd(max(map(lambda yc: (yc[1], yc), year_cheese)))
(2007, 33.5)
在这里,使用 lambda 定义了 snd() 函数来从元组中选取第二个元素。这提供了一个更易于阅读的 unwrap(process(wrap())) 版本。与前面的例子一样,map(lambda...`` ,`` year_cheese) 表达式用于包装我们的原始数据项,而 max() 函数进行处理。最后,snd() 函数从元组中提取第二个元素。
这可以通过使用 typing.NamedTuple 或 @dataclass 来简化。在第七章 复杂无状态对象中,我们将探讨这两种替代方案。
我们将——作为一个一般的设计原则——避免使用类定义。在面向对象编程(OOP)语言中避免对象可能看起来是一种禁忌,但我们注意到函数式编程不依赖于有状态的对象。当我们使用类定义时,我们将避免更新属性值的设计。
使用不可变对象有多个很好的理由。例如,我们可以将对象用作属性值的命名集合。此外,可调用对象可以提供一些优化,如计算结果的缓存。缓存很重要,因为 Python 没有优化编译器。使用类定义的另一个原因是提供一个命名空间,用于紧密相关的函数。
2.3 严格和非严格评估
函数式编程的效率部分源于能够将计算推迟到需要时。有两个类似的概念用于避免计算。这些是:
-
严格性:Python 运算符通常是严格的,并从左到右评估所有子表达式。这意味着表达式
f(a)+f(b)+f(c)的评估方式就像它是(f(a)+f(b))+f(c)。优化编译器可能会避免严格的顺序以提高性能。Python 不进行优化,代码大多是严格的。我们将在下面探讨 Python 不严格的情况。 -
热切与懒惰:Python 运算符通常是热切的,并评估所有子表达式以计算最终答案。这意味着
(3-3)`` *`` f(d)被完全评估,即使乘法的第一部分——(3-3)子表达式——始终为零,这意味着结果始终为零,无论表达式f(d)计算出什么值。生成器表达式是 Python 进行懒惰评估的一个例子。我们将在下一节,懒惰和热切评估中探讨这个例子。
在 Python 中,逻辑表达式运算符 and、or 和 if-else 都是非严格的。我们有时称它们为短路运算符,因为它们不需要评估所有参数来确定结果值。
以下命令片段显示了 and 运算符的非严格特性:
>>> 0 and print("right")
0
>>> True and print("right")
right
当我们执行前面命令片段中的第一个时,and 运算符的左侧等效于 False;右侧没有评估。在第二个例子中,当左侧等效于 True 时,右侧被评估。
Python 的其他部分是严格的。在逻辑运算符之外,表达式严格从左到右评估。一系列语句行也严格按顺序评估。字面列表和元组需要严格评估。在创建类时,方法按严格顺序定义。
2.4 惰性评估和急切评估
Python 的生成器表达式和生成器函数是惰性的。这些表达式不会立即创建所有可能的结果。如果不显式记录计算的详细信息,很难看到这一点。以下是一个具有副作用显示其创建的数字的 range() 函数版本:
from collections.abc import Iterator
def numbers(stop: int) -> Iterator[int]:
for i in range(stop):
print(f"{i=}")
yield i
为了提供一些调试提示,此函数在值产生时打印每个值。如果此函数是急切的,评估 numbers(1024) 将需要创建所有 1,024 个数字所需的时间(和存储空间)。由于 numbers() 函数是惰性的,它仅在请求时创建一个数字。
我们可以使用这个嘈杂的 numbers() 函数以显示惰性评估的方式。我们将编写一个函数,它评估一些值,但不评估所有值从这个迭代器:
def sum_to(limit: int) -> int:
sum: int = 0
for i in numbers(1_024):
if i == limit: break
sum += i
return sum
sum_to() 函数有类型提示,表明它应该接受一个整数值作为 n 参数,并返回一个整数结果。此函数不会评估由 numbers() 函数产生的值的整个结果。它将在仅消耗 numbers() 函数中的一些值后停止。我们可以在以下日志中看到这种值的消耗:
>>> sum_to(5)
i=0
i=1
i=2
i=3
i=4
i=5
10
正如我们稍后将要看到的,Python 生成器函数有一些特性,使得它们对于简单的函数式编程来说有些笨拙。具体来说,Python 中的生成器只能使用一次。我们必须谨慎地使用惰性的 Python 生成器表达式。
2.5 使用递归而不是显式循环状态
函数式程序不依赖于循环及其跟踪循环状态的关联开销。相反,函数式程序试图依赖于更简单的递归函数方法。在某些语言中,程序以递归的形式编写,但编译器中的尾调用优化(TCO)将它们转换为循环。我们将在本章介绍一些递归,并在 第六章,递归和归约 中对其进行详细检查。
我们将研究一个迭代来测试一个数是否为素数。以下是从mathworld.wolfram.com/PrimeNumber.html的一个定义:“素数...是一个大于 1 的正整数 p,它除了 1 和它本身外没有其他正整数除数。”我们可以创建一个简单且性能不佳的算法来确定一个数是否有介于 2 和该数之间的任何因子。这被称为试除法算法。它具有简单性的优点;对于解决一些欧拉计划问题来说,它的工作是可接受的。阅读有关 Miller-Rabin 素性测试的更多信息,以获得更好的算法。
我们将使用“互质”这个术语来表示两个数只有 1 作为它们的公因数。例如,2 和 3 是互质的。然而,6 和 9 不是互质的,因为它们有 3 作为公因数。
如果我们要知道一个数 n 是否为素数,我们实际上会问这个问题:数 n 是否与所有小于 n 的平方的素数 p 互质?我们可以通过使用所有整数 i,使得 2 ≤ i² < n,来简化这个问题。这种简化做了更多的工作,但实现起来要容易得多。
有时候,将这个问题形式化如下会很有帮助:
![prime(n) = ∀x[2 ≤ x < √n-+ 1 ∧ n ≠ 0 mod x ]](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file16.jpg)
这个表达式在 Python 中可能看起来如下:
not any(
n % p == 0
for p in range(2, int(math.sqrt(n))+1)
)
从数学形式主义到 Python 的另一种转换将使用all(n % p != 0, ...). 当all()函数找到第一个False值时,它将停止。not any()将在找到第一个True值时停止。虽然结果相同,但性能取决于p是否为素数。
这个表达式内部有一个for循环:它不是一个纯粹的无状态函数式编程的例子。我们可以将其重构为一个与值集合一起工作的函数。我们可以询问数 n 是否在半开区间 2, 
机器只能在 800 到 2,500 RPM 的范围内运行。直到转速表被替换为正确校准的转速表之前,我们需要一个从观察到的 RPM 到实际 RPM 的值表。这个值表可以打印出来,然后覆膜,并放在机器附近,以帮助测量燃油消耗和工作量。
因为转速表只能读到最近的 100 RPM,所以表格只需要显示像 800、900、1000、1100、...、2500 这样的值。
输出应该是以下类似的内容:
Observed Actual
800 630
900 720
etc.
为了提供一个灵活的解决方案,创建以下两个独立的函数很有帮助:
-
一个实现模型的函数,该函数从观察值计算实际值
-
一个用于显示从模型函数结果生成的值表的函数
这两个独立的函数将作为该设备重新校准工作的一部分被使用。
模型的测试用例可以与值表的测试用例分离,允许在收集更多数据时使用新的模型。
虽然这是下一章的主题,并且在这里只被提及,但鼓励使用 map(),但不是必需的。
2.10.2 函数与 lambda 设计问题
在 将 map() 应用到值序列 问题中的模型是一个小函数,只有大约一行代码。这里有三种不同的方式可以编写它:
-
作为正确的
def函数。 -
作为 lambda 对象。
-
作为实现
__call__()方法的类定义。
创建所有三种实现。比较和对比它们在理解上的难易程度。通过(a)提供一些软件质量标准,以及(b)展示实现如何满足这些标准,来捍卫其中一种实现是理想的。
2.10.3 优化递归
参见本章前面的 递归而不是显式循环状态。
作为对读者的练习,这个递归可以被重新定义为向下计数而不是向上,在第二种情况下使用 [a,b − 1)。实现这个更改以查看是否需要任何更改。测量性能以查看是否有任何性能影响。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第三章:3
函数、迭代器和生成器
函数式编程的核心是使用纯函数将输入域的值映射到输出范围。避免副作用可以减少对变量赋值以维护计算状态的任何依赖。我们无法从 Python 语言中删除赋值语句,但我们可以减少对有状态对象的依赖。这意味着在可用的 Python 内置函数和数据结构中选择那些不需要有状态操作的选择。
本章将从函数式视角介绍几个 Python 特性,如下所示:
-
纯函数,无副作用
-
函数作为可以作为参数传递或作为结果返回的对象
-
使用 Python 的面向对象后缀和前缀表示法
-
使用元组作为创建不可变对象的方法,从而避免状态变化的混淆
-
使用可迭代集合作为我们进行函数式编程的主要设计工具
我们将探讨生成器和生成器表达式,因为这些都是处理对象集合的方法。正如我们在第二章,介绍基本函数式概念中提到的,在尝试用递归替换所有生成器表达式时,存在一些边界问题。Python 强制执行递归限制,并且不会自动处理尾调用优化(TCO):我们必须使用生成器表达式手动优化递归。
我们将编写生成器表达式,以执行以下任务:
-
转换
-
重组
-
复杂计算
我们将快速浏览许多内置的 Python 集合以及如何在追求函数式范式的同时处理集合。这可能会改变我们处理列表、字典和集合的方法。编写函数式 Python 鼓励我们关注元组和不可变集合。在下一章中,我们将强调更多与特定类型集合一起工作的函数式方法。
3.1 编写纯函数
在第二章,介绍基本函数式概念中,我们探讨了纯函数。在本节中,我们将探讨非函数式编程中常见的一个问题:一个引用全局变量的函数。当全局变量被赋值时,将使用global语句。然而,当全局变量被读取时,这被称为自由变量,Python 代码中没有任何明显的标记。
任何对 Python 全局命名空间中值的引用(使用自由变量)都可以重新设计为一个合适的参数。在大多数情况下,这相当简单。以下是一个依赖于自由变量的示例:
global_adjustment: float
def some_function(a: float, b: float, t: float) -> float:
return a+b*t+global_adjustment
在重构函数后,我们需要更改对这个函数的每个引用。这可能会在复杂的应用程序中产生连锁反应。我们将重构作为练习留给读者。
Python 中有许多内部对象是具有状态的。用于输入和输出的对象通常被称为文件对象或类似文件的对象;这些是常用状态对象的例子。有关文件对象的更多信息,请参阅io模块。我们观察到 Python 中一些常用的状态对象通常表现为上下文管理器。在少数情况下,状态对象并没有完全实现上下文管理器接口;在这些情况下,通常有一个close()方法。我们可以使用contextlib.closing()函数为这些对象提供适当的上下文管理器接口。
上下文管理器提供了一种在代码块进入和退出时执行操作的方法。with语句使用上下文管理器执行进入操作,执行缩进的代码块,并执行退出操作。需要注意的是,退出操作总是执行,即使缩进的代码块中抛出了异常。这为执行状态改变操作提供了一种整洁的方式,使代码更容易推理。在实践中,它看起来像以下示例:
from pathlib import Path
def write_file(some_path: Path) -> None:
result = "Hello, world!"
with some_path.open(’w’) as output_file:
output_file.write(result + "\n")
文件仅在with语句内部打开用于写入。这使得更容易看到状态改变操作在哪里执行。
我们不能轻易消除所有具有状态的 Python 对象。因此,我们必须在管理状态的同时,仍然利用函数式设计的优势。为此,我们应该始终使用with语句将具有状态的文件对象封装到定义良好的作用域中。
总是在with上下文中使用文件对象。这定义了一个在此上下文中将执行状态改变操作的环境。
我们应该始终避免使用全局文件对象、全局数据库连接以及相关的状态对象问题。全局文件对象是处理打开文件或数据库的常见模式。我们可能有一个如下所示的函数:
from typing import TextIO
ifile: TextIO
ofile: TextIO
def open_files(iname: str, oname: str) -> None:
"""A bad idea..."""
global ifile, ofile
ifile = open(iname, "r")
ofile = open(oname, "w")
这个函数创建了一对容易被忽视的全局变量。其他函数可以使用ifile和ofile变量,希望它们正确地引用了全局文件,这些文件被保留打开状态,并将经历一系列难以理解的状态变化。
这不是一个很好的函数式设计,我们需要避免它。文件应该是函数的适当参数,打开的文件应该嵌套在with语句中,以确保它们的状态行为得到适当处理。将变量从全局变量更改为正式参数是一个重要的重写:这使得文件操作更易于可见。
重写将涉及定位每个使用ifile或ofile作为自由变量的函数。例如,我们可能有一个如下所示的函数:
def next_line_with(prefix: str) -> str | None:
"""Also a bad idea..."""
line = ifile.readline()
while (line is not None and not line.startswith(prefix)):
line = ifile.readline()
return line
我们需要将ifile全局变量引用转换为该函数的参数。这将给调用next_line_with()的函数带来一系列的变化。这可能需要对识别和定位状态变化进行广泛的修改。这可能导致重新思考设计,以替换像next_line_with()这样的函数。
此上下文管理器设计模式也适用于数据库。数据库连接对象通常应作为正式参数提供给应用程序的函数。这与一些流行的 Web 框架的工作方式相反:一些框架依赖于全局数据库连接,试图使数据库成为应用程序的一个透明特性。这种透明性掩盖了 Web 操作与数据库之间的依赖关系;它可以使单元测试比必要的更复杂。此外,多线程 Web 服务器可能不会从共享单个数据库连接中受益:连接池通常更好。这表明,使用功能设计结合少量隔离的有状态特性的混合方法有一些好处。
3.2 函数作为一等对象
在第二章,介绍基本功能概念中,我们探讨了 Python 函数作为一等对象的方式。在 Python 中,函数对象具有许多属性。参考手册列出了适用于函数的许多特殊成员名称。由于函数是具有属性的对象,我们可以使用特殊属性如__doc__或__name__来提取函数的文档字符串或名称。我们还可以通过__code__属性提取函数体。在编译型语言中,这种内省可能是无法实现的,或者相当复杂。
此外,可调用对象帮助我们创建函数。我们可以将可调用类定义视为高阶函数。我们确实需要在如何使用可调用对象的__init__()方法上谨慎行事;我们应该避免设置有状态的类变量。一个常见应用是使用__init__()方法创建符合策略设计模式的对象。
遵循策略设计模式的类依赖于其他对象来提供算法或算法的一部分。这允许我们在运行时注入算法细节,而不是将细节编译到类中。
为了专注于整体设计原则,我们将查看一个执行微小计算的函数。这个函数计算梅森素数中的一个。有关此主题的持续研究,请参阅www.mersenne.org/primes/。
下面是一个具有嵌入策略对象的可调用对象类的定义示例:
from collections.abc import Callable
class Mersenne1:
def __init__(
self,
algorithm : Callable[[int], int]
) -> None:
self.pow2 = algorithm
def __call__(self, arg: int) -> int:
return self.pow2(arg) - 1
此类使用__init__()来保存对另一个函数的引用,即algorithm作为self.pow2。我们不是创建任何有状态的实例变量;self.pow2的值预计不会改变。使用像_pow2这样的名称是常见的做法,以表明这个属性不应该被此类客户端使用。algorithm参数的类型提示为Callable[[int], int],它描述了一个接受整数参数并返回整数值的函数。
我们使用了collections.abc模块中的Callable类型提示,其中它被定义。在typing模块中有一个别名,但由于 PEP 585 的实施,typing.Callable的使用已被弃用。我们将在本章中使用collections.abc模块中的多个泛型类型。
给定的作为策略对象的函数必须将 2 提升到指定的幂。我们可以插入任何执行此计算的函数。可以插入此类的三个候选对象如下:
def shifty(b: int) -> int:
return 1 << b
def multy(b: int) -> int:
if b == 0: return 1
return 2 * multy(b - 1)
def faster(b: int) -> int:
if b == 0: return 1
if b % 2 == 1: return 2 * faster(b-1)
t = faster(b // 2)
return t * t
shifty()函数通过位左移来将 2 提升到所需的幂。multy()函数使用简单的递归乘法。faster()函数使用分治策略,将执行 log 2 次乘法而不是 b 次乘法。
这三个函数具有相同的函数签名。每个都可以总结为Callable[[int], int],这与Mersenne1.__init__()方法的参数algorithm相匹配。
我们可以用嵌入的策略算法创建Mersenne1类的实例,如下所示:
m1s = Mersenne1(shifty)
m1m = Mersenne1(multy)
m1f = Mersenne1(faster)
结果中的每个函数,m1s()、m1m()和m1f(),都是由另一个函数构建的。函数shifty()、multy()和faster()被纳入结果函数中。这展示了我们可以定义产生相同结果但使用不同算法的替代函数。
由此类创建的可调用对象表现得像普通的 Python 函数,如下例所示:
>>> m1s(17)
131071
>>> m1f(89)
618970019642690137449562111
Python 允许我们计算 M[89] = 2⁸⁹ − 1,因为这个值甚至接近 Python 的递归限制。这是一个相当大的质数,因为它有 27 位。为了超过multy()函数的限制,我们需要请求 M[1,279]的值,这是一个有 386 位的数字。
3.3 使用字符串
由于 Python 字符串是不可变的,它们是函数式编程对象的绝佳例子。Python 的str对象有多个方法,所有这些方法都会产生一个新的字符串作为结果。这些方法是纯函数,没有副作用。
方法的语法是后缀的,而大多数函数是前缀的。这种语法风格的混合意味着当与常规函数混合时,复杂的字符串操作可能难以阅读。例如,在这个表达式 len(variable.title()) 中,title() 方法是后缀表示法,而 len() 函数是前缀表示法。(我们在第二章,介绍基本功能概念,熟悉领域部分中提到了这一点。)
当从网页抓取数据时,我们可能有一个用于清理数据的函数。这可能将一系列转换应用于字符串以清理标点,并返回一个 Decimal 对象供应用程序的其他部分使用。这将涉及前缀和后缀语法的混合使用。
它可能看起来像以下代码片段:
from decimal import Decimal
def clean_decimal(text: str | None) -> Decimal | None:
if text is None: return None
return Decimal(
text.replace("$", "").replace(",", "")
)
此函数对字符串进行两次替换以移除 $ 和 , 字符串值。得到的字符串用作 Decimal 类构造函数的参数,该构造函数返回所需的对象。如果输入值为 None,则将其保留;这就是为什么使用 str | None 类型提示的原因。
为了使语法看起来更一致,我们可以考虑为字符串方法定义我们自己的前缀函数,如下所示:
def replace(text: str, a: str, b: str) -> str:
return text.replace(a, b)
这可以让我们使用 Decimal(replace(replace(text, "$", ""), ",", "")),具有一致的看起来像前缀语法的样式。这种一致性是否比混合前缀和后缀表示法有显著改进还不清楚。这可能是愚蠢的一致性的一个例子。
一种稍微更好的方法可能是定义一个更有意义的用于去除标点的函数,如下面的代码片段所示:
def remove(str: str, chars: str) -> str:
if chars:
return remove(
str.replace(chars[0], ""),
chars[1:]
)
return str
此函数将递归地移除 chars 变量中的每个字符。我们可以使用它作为 Decimal(remove(text, "$,")),以使我们的字符串清理意图更清晰。
3.4 使用元组和命名元组
由于 Python 元组是不可变对象,它们是适合函数式编程的对象的另一个极好例子。Python 元组方法很少,所以几乎所有的事情都是使用前缀语法完成的。元组有许多用途,尤其是在处理列表-元组、元组-元组和元组生成器结构时。
typing.NamedTuple 类为元组添加了一个基本特性:使用名称而不是神秘的索引数字。我们可以利用命名元组来创建数据累积的对象。这允许我们编写基于无状态对象的纯函数,同时将数据绑定到整洁的对象包中。collections.namedtuple() 也可以用来定义不可变对象类。这缺少提供类型提示的机制,使其不如 typing.NamedTuple 类受欢迎。
使用元组或typing.NamedTuple对象的决定完全是出于方便。例如,考虑将颜色值序列作为一个形式为(number, number, number)的三重元组处理。这些是否按红、绿、蓝的顺序排列并不明确。我们有几种方法可以使元组结构更明确。
一种纯粹函数式的方法来暴露三元结构是通过创建函数来分解三重元组,如下面的代码片段所示:
from collections.abc import Callable
from typing import TypeAlias
Extractor: TypeAlias = Callable[[tuple[int, int, int, str]], int]
red: Extractor = lambda color: color[0]
green: Extractor = lambda color: color[1]
blue: Extractor = lambda color: color[2]
给定一个元组item,我们可以使用red(item)来选择具有红色成分的项。这种风格在许多纯粹函数式语言中使用;它具有与数学抽象相匹配的结构。
在 Python 中,有时为每个变量提供更正式的类型提示可能会有所帮助,如下所示:
from collections.abc import Callable
from typing import TypeAlias
RGB: TypeAlias = tuple[int, int, int, str]
redt: Callable[[RGB], int] = lambda color: color[0]
这定义了一个新的类型别名RGB,作为一个四重元组。redt()函数提供了一个类型提示Callable[[RGB], int],表示它应该被视为一个接受RGB类参数值并产生整数结果的函数。这遵循了其他函数式编程风格,并添加了可以被 mypy 检查的类型提示。
一种稍微更好的技术是使用 Python 的typing.NamedTuple类。它使用类定义而不是函数定义,看起来如下所示:
from typing import NamedTuple
class Color(NamedTuple):
"""An RGB color."""
red: int
green: int
blue: int
name: str
Color类定义了一个具有特定名称和类型提示的元组,这些提示针对元组中的每个位置。这保留了性能和不可变性的优势。它还增加了 mypy 程序确认元组被正确使用的功能。
这也意味着我们将使用color.red而不是red(color)。使用属性名来访问元组的成员似乎增加了清晰度。
在处理不可变元组方面,还有一些额外的处理方法。我们将在第七章,复杂无状态对象中查看所有这些不可变类技术。
3.5 使用生成器表达式
我们已经在第二章,介绍基本函数式概念的懒加载和急加载评估部分展示了生成器表达式的几个例子。在本章中我们还将展示更多。在本节中,我们将介绍一些更多的生成器技术。
Python 集合被描述为可迭代的。我们可以使用for语句遍历值。关键机制是集合能够创建一个迭代器对象,该对象由for语句使用。这个概念可以推广到包括一个作为值迭代器的函数。我们称这些为生成器函数。我们也可以编写生成器表达式。
常常看到生成器表达式用于通过列表推导式或字典推导式语法创建list或dict字面量。这是一个列表推导式示例,[x**2 for x in range(10)],一种列表展示。列表推导式是 Python 中使用生成器表达式的好几个地方之一。在这个例子中,列表字面量[]字符包围了生成器表达式x**2 for x in range(10)。这个列表推导式从包含的生成器表达式创建了一个列表对象。
基础的x**2 for x in range(10)表达式产生一系列值。这些值必须由客户端函数消费。list()函数可以消费这些值。这意味着我们有两种方式从生成器表达式创建列表对象,如下面的示例所示:
>>> list(x**2 for x in range(10)) == [x**2 for x in range(10)]
True
还有其他类型的推导式可以创建字典和集合。当包围字符是{}时,这是一个集合推导式。当包围字符是{},并且有:来分隔键和值时,这是一个字典推导式。在本节中,我们将专注于生成器表达式,而不管它们可能创建的具体集合对象类型。
集合对象和生成器表达式有一些相似的行为,因为两者都是可迭代的。但它们并不相同,正如我们将在下面的代码中看到的那样。使用显示对象的一个缺点是会创建一个(可能很大的)对象集合。生成器表达式是懒惰的,并且仅在需要时创建对象;这可以提高性能。
我们必须对生成器表达式提供以下两个重要注意事项:
-
生成器有一些与列表相同的方法。这意味着我们可以将
sorted()和iter()等函数应用于生成器或列表。一个例外是len()函数,它需要知道集合的大小,因此对生成器不起作用。 -
生成器只能使用一次。之后,它们看起来是空的。
生成器函数是一个包含yield表达式的函数。这使得函数表现得像一个迭代器。每个单独的yield值必须由客户端函数单独消费。有关教程介绍,请参阅wiki.python.org/moin/Generators。
还请参阅docs.python.org/3/howto/functional.html#generator-expressions-and-list-comprehensions。
我们可以使用类似以下的方法来创建可能的素数序列:
from collections.abc import Iterator
def candidates() -> Iterator[int]:
for i in range(2, 1024):
yield m1f(i)
这个函数遍历 1,024 个结果值。然而,它不会急于计算它们。它是懒惰的,只在需要时计算值。内置的next()函数是消费值的一种方式。以下是从生成器函数中消费值的示例:
>>> c = candidates()
>>> next(c)
3
>>> next(c)
7
>>> next(c)
15
>>> next(c)
31
当 candidates() 函数被评估时,它创建了一个生成器对象,该对象被保存在变量 c 中。每次我们使用 next(c),生成器函数计算一个额外的值并产生它。在这个例子中,它将从 range 对象中获取一个新的值,并评估 m1f() 函数来计算一个新的值。
yield 表达式扩展了 yield 表达式。这将消耗来自某个迭代器的值,并为它消耗的每个值产生。作为一个小的例子,考虑以下函数:
from collections.abc import Iterator
def bunch_of_numbers() -> Iterator[int]:
for i in range(5):
yield from range(i)
每次请求一个值时,它将由嵌套在 for 语句中的 yield 产生。这将产生 i 个不同的值,每个请求一个。由于 i 是由包含的 for 语句设置的,这将用于产生越来越长的数字序列。
这就是结果看起来像什么:
>>> list(bunch_of_numbers())
[0, 0, 1, 0, 1, 2, 0, 1, 2, 3]
这里是一个我们将用于更多示例的生成器函数:
from collections.abc import Iterator
import math
def pfactorsl(x: int) -> Iterator[int]:
if x % 2 == 0:
yield 2
if x // 2 > 1:
yield from pfactorsl(x // 2)
return
for i in range(3, int(math.sqrt(x) + .5) + 1, 2):
if x % i == 0:
yield i
if x // i > 1:
yield from pfactorsl(x // i)
return
yield x
我们正在寻找一个数字的质因子。如果数字 x 是偶数,我们将产生 2,然后递归地产生 x 除以 2 的所有质因子。
对于奇数,我们将遍历大于或等于 3 的奇数值以找到该数的候选因子。当我们找到因子 i 时,我们将产生该因子,然后递归地产生 x 除以 i 的所有质因子。
如果我们找不到因子,那么数字 x 必须是质数,因此我们可以产生该数字。
我们将 2 视为一个特殊情况以减少迭代次数的一半。除了 2 以外的所有质数都是奇数。
我们除了递归之外还使用了一个重要的 for 语句。这是一个优化,也是对 第六章,递归和归约 内容的一个预告。这个优化使我们能够轻松地处理具有多达 1,000 个因子的数字。(例如,2^(1,000),一个有 300 位数的数字,将有 1,000 个因子。)由于 for 变量 i 不会在语句缩进体外部使用,因此如果我们对 for 语句体进行任何更改,i 变量的有状态性质不会导致混淆。
因为整个函数是一个生成器,所以使用 yield 表达式从递归调用中消耗值并将它们产生给调用者。它提供了一个值的可迭代序列作为结果。
在递归生成器函数中,请注意 return 语句。
不要使用以下语句:return recursive_iter(args)。它只返回一个生成器对象;它不会评估 recursive_iter() 函数以返回产生的值。使用以下任何一种替代方案:
-
yield表达式:for result in recursive_iter(args): yield result -
yield表达式:yield from recursive_iter(args)
实现了 Iterator 协议的函数通常被称为生成器函数。还有一个独立的 Generator 协议,它扩展了基本的 Iterator 定义。我们经常发现,函数式 Python 程序可以围绕生成器表达式结构来构建。这往往使设计工作集中在函数和无状态对象上。
3.5.1 探索生成器的限制
我们注意到生成器表达式和生成器函数有一些限制。这些限制可以通过执行以下命令片段来观察到:
>>> pfactorsl(1560)
<generator object pfactorsl at ...>
>>> list(pfactorsl(1560))
[2, 2, 2, 3, 5, 13]
>>> len(pfactorsl(1560))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: object of type ’generator’ has no len()
在第一个例子中,我们看到了生成器函数 pfactors1() 创建了一个生成器。生成器是惰性的,直到我们消耗生成器产生的结果之前,它没有正确的值。本身这并不是一个限制;惰性评估是生成器表达式适合 Python 函数式编程的重要原因之一。
在第二个例子中,我们从生成器函数产生的结果中实例化了一个列表对象。这对于查看输出和编写单元测试用例非常有用。
在第三个例子中,我们看到了生成器函数的一个限制:没有 len()。因为生成器是惰性的,大小只能在所有值都被消耗后才能知道。
生成器对象的其他限制是它们只能使用一次。例如,看看下面的命令片段:
>>> result = pfactorsl(1560)
>>> sum(result)
27
>>> sum(result)
0
sum() 函数的第一次评估执行了对生成器对象 result 的评估,所有值都被消耗了。sum() 函数的第二次评估发现生成器对象现在是空的。我们只能消耗一次生成器对象的值。
生成器函数 pfactorsl() 可以产生不定数量的生成器对象。在许多情况下,我们将定义消耗其他生成器产生的结果的生成器函数。在这些情况下,我们可能不能简单地创建生成器,而必须创建一个完整的生成器管道。
生成器在 Python 中具有有状态的生命周期。虽然它们在函数式编程的一些方面非常出色,但它们并不完美。
我们可以尝试使用 itertools.tee() 函数来克服只能使用一次的限制。我们将在第八章 《迭代工具模块》 中深入探讨这一点。这不是一个好主意,因为它可能会消耗大量的内存。
下面是一个快速使用示例:
import itertools
from typing import Any
from collections.abc import Iterable
def limits(iterable: Iterable[Any]) -> Any:
max_tee, min_tee = itertools.tee(iterable, 2)
return max(max_tee), min(min_tee)
我们创建了参数生成器表达式的两个克隆,max_tee 和 min_tee。我们可以消耗这两个克隆来从可迭代对象中获取最大和最小值。有趣的是,由于这两个克隆是串行使用的,这导致消耗大量内存来缓存项。这个特定的例子通常使用列表对象而不是使用 tee() 来克隆迭代器会更好。
一旦消耗完毕,生成器对象将不再提供任何值。当我们想要计算多种类型的简化——例如,总和和计数,或最小值和最大值——时,我们需要考虑到这个单次遍历的限制。
3.5.2 组合生成器表达式
函数式编程的本质在于我们能够轻松地组合生成器表达式和生成器函数,以创建非常复杂的复合处理序列。当与生成器表达式一起工作时,我们可以以几种方式组合生成器。
将生成器函数组合在一起的一个常见方式是在创建复合函数时。我们可能有一个生成器,它计算(f(x) for x in some_iterable)。如果我们想计算g(f(x)),我们有几种方法可以组合两个生成器。
我们可以调整原始生成器表达式,如下所示:
g_f_x = (g(f(x)) for x in some_iterable)
虽然技术上正确,但这却违背了重用的任何想法。我们不是重用表达式,而是重新编写了它。
我们也可以在另一个表达式中替换一个表达式,如下所示:
g_f_x = (g(y) for y in (f(x) for x in some_iterable))
这有一个优点,即允许我们使用简单的替换。我们可以稍微修改一下,以强调重用,使用以下命令:
f_x = (f(x) for x in some_iterable)
g_f_x = (g(y) for y in f_x)
这有一个优点,即初始表达式(f(x) for x in some_iterable)基本上保持不变。我们所做的只是将表达式赋给一个变量,而没有改变语法。
结果的复合函数也是一个生成器表达式,它也是惰性的。这意味着从g_f_x中提取下一个值将从一个f_x中提取一个值,而f_x将从一个源some_iterable对象中提取一个值。
3.6 使用生成器函数清理原始数据
在探索性数据分析中出现的任务之一是清理原始源数据。这通常是通过将几个标量函数应用于每条输入数据来创建一个可用的数据集的复合操作来完成的。
让我们看看一组简化的数据。这些数据通常用于展示探索性数据分析中的技术。它被称为安斯康姆四重奏,它来自 F. J. 安斯康姆在 1973 年发表在《美国统计学家》上的文章《统计分析中的图表》。以下是从下载的包含此数据集的文件中的前几行:
Anscombe’s quartet
I II III IV
x y x y x y x y
10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
由于数据是正确制表的,我们可以使用csv.reader()函数遍历各种行。遗憾的是,我们无法用csv模块简单地处理这个问题。我们必须进行一些解析以从该文件中提取有用的信息。我们可以定义一个函数来遍历原始数据,如下所示:
import csv
from typing import TextIO
from collections.abc import Iterator, Iterable
def row_iter(source: TextIO) -> Iterator[list[str]]:
return csv.reader(source, delimiter="\t")
我们将一个文件包裹在csv.reader()函数中,以创建对原始数据行的迭代器。typing模块为读取(或写入)字符串值的文件对象提供了一个方便的定义,TextIO。每一行是一个文本值的列表。定义一个额外的类型Row = list[str]可以使这一点更加明确。
我们可以在以下上下文中使用这个row_iter()函数:
>>> from pathlib import Path
>>> source_path = Path("Anscombe.txt")
>>> with source_path.open() as source:
... print(list(row_iter(source)))
虽然这将显示有用的信息,但问题是结果的可迭代对象中的前三个项目不是数据。Anscombe 的四重奏文件以以下标题行开始:
[["Anscombe’s quartet"],
[’I’, ’II’, ’III’, ’IV’],
[’x’, ’y’, ’x’, ’y’, ’x’, ’y’, ’x’, ’y’],
我们需要从可迭代对象中过滤掉这三行非数据行。有几种可能的方法。以下是一个函数,它将移除预期的三个标题行,验证它们是预期的标题,并返回剩余行的迭代器:
from collections.abc import Iterator
def head_split_fixed(
row_iter: Iterator[list[str]]
) -> Iterator[list[str]]:
title = next(row_iter)
assert (len(title) == 1
and title[0] == "Anscombe’s quartet")
heading = next(row_iter)
assert (len(heading) == 4
and heading == [’I’, ’II’, ’III’, ’IV’])
columns = next(row_iter)
assert (len(columns) == 8
and columns == [’x’,’y’, ’x’,’y’, ’x’,’y’, ’x’,’y’])
return row_iter
此函数从源数据中提取三行,一个迭代器。它断言每一行都有一个预期的值。如果文件不符合这些基本预期,这可能是一个信号,表明文件已损坏,或者我们的分析可能集中在错误的文件上。
由于row_iter()和head_split_fixed()函数都期望一个迭代器作为参数值,因此它们可以组合,如下所示:
from pathlib import Path
from collections.abc import Iterator
def get_rows(path: Path) -> Iterator[list[str]]:
with path.open() as source:
yield from head_split_fixed(row_iter(source))
我们已经将一个迭代器应用于另一个迭代器的结果。实际上,这定义了一个复合函数。当然,我们还没有完成;我们仍然需要将字符串值转换为浮点值,并且我们还需要拆分每行中的四个并行数据系列。
最终的转换和数据提取使用高阶函数,如map()和filter(),会更加容易。我们将在第五章,高阶函数中再次回到这些内容。
3.7 将生成器应用于内置集合
我们现在将探讨如何将生成器表达式应用于 Python 的许多内置集合。本节将涵盖以下主题:
-
列表、字典和集合的生成器
-
使用有状态集合
-
使用
bisect模块创建映射 -
使用有状态的集合
这些内容都关注 Python 集合和生成器函数的一些特定案例。特别是,我们将探讨如何生成一个集合,并在后续处理中消费这个集合。
这是为下一章第四章,处理集合的引入,该章节详细介绍了 Python 集合。
3.7.1 列表、字典和集合的生成器
一个 Python 序列对象,如列表,是可迭代的。然而,它还有一些额外的特性。我们可以将列表视为一个具体化的可迭代对象。我们在几个示例中使用了tuple()函数来收集生成器表达式或生成器函数的输出到一个单一的元组对象中。我们可以使用list()函数将序列具体化以创建列表对象。
在 Python 中,列表显示或列表推导提供了简单的语法来具体化生成器:我们添加[]括号。这一点非常普遍,以至于生成器表达式和列表推导之间的区别可能会消失。我们需要将生成器表达式的概念与使用生成器表达式的列表显示区分开来。
以下是一个枚举案例的示例:
>>> range(10)
range(0, 10)
>>> [range(10)]
[range(0, 10)]
>>> [x for x in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
第一个示例是range对象,它是一种生成函数类型。它不产生任何值,因为它懒惰。
第二个示例显示了一个由生成函数的单个实例组成的列表。[]语法创建了一个不消耗迭代器创建的任何值的range()对象的列表字面量。
第三个示例显示了一个由包含生成函数的生成器表达式构建的列表推导式。函数range(10)由生成器表达式x for x in range(10)评估。结果值被收集到一个列表对象中。
我们还可以使用list()函数从可迭代对象或生成器表达式构建列表。这也适用于set()、tuple()和dict()。
list(range(10))函数评估生成器对象。[range(10)]列表字面量不评估range(10)生成器对象。
虽然list、dict和set可以使用[]和{}的简写语法,但元组没有简写语法。为了实现元组,我们必须使用tuple()函数。因此,通常最一致的做法是使用list()、tuple()和set()函数作为首选语法。
在上一节的数据清洗代码中,我们使用一个复合函数创建了一个包含四个元组的列表。该函数看起来如下:
>>> data = list(get_rows(Path("Anscombe.txt")))
>>> data[0]
[’10.0’, ’8.04’, ’10.0’, ’9.14’, ’10.0’, ’7.46’, ’8.0’, ’6.58’]
>>> data[1]
[’8.0’, ’6.95’, ’8.0’, ’8.14’, ’8.0’, ’6.77’, ’8.0’, ’5.76’]
>>> data[-1]
[’5.0’, ’5.68’, ’5.0’, ’4.74’, ’5.0’, ’5.73’, ’8.0’, ’6.89’]
我们将get_rows()复合函数的结果分配给一个名称,data。每一行都是一个包含四个(x,y)对的集合。
要提取一个(x,y)对,我们需要进行一些额外的处理以使其变得有用。首先,我们需要从八元组中挑选出列对。由于这些对总是相邻的,我们可以通过以下形式的切片操作选择一列对:row[2*n:2*n+2]。其思路是,对 n 的配对位于 2×n 和 2×n+1 的位置。切片表达式2*n:2*n+2包括起始元素2*n,并在停止元素2*n+2之前停止。我们可以通过以下定义中的可重用函数来实现这一点:
from typing import cast, TypeVar
from collections.abc import Iterator, Iterable
SrcT = TypeVar("SrcT")
def series(
n: int,
row_iter: Iterable[list[SrcT]]
) -> Iterator[tuple[SrcT, SrcT]]:
for row in row_iter:
yield cast(tuple[SrcT, SrcT], tuple(row[n * 2: n * 2 + 2]))
此函数根据 0 到 3 之间的数字选择两个相邻的列。它从这两列创建一个元组对象。cast()函数是一个类型提示,用于通知 mypy 工具结果将是一个包含两个字符串元素的二元组。这是必需的,因为 mypy 工具很难确定表达式tuple(row[n*2:n*2+2])将正好从行集合中选择两个元素。
此示例使用类型变量SrcT来对转换做出更深入的声明。具体来说,类型变量告诉阅读代码的人(以及像 mypy 这样的工具)输入对象类型将是结果对象类型。例如,如果源是一个包含str列表的可迭代对象,则SrcT = str,输出将是一个包含两个str值的元组的迭代器。
我们可以使用series()函数从源文件中提取集合,如下所示:
>>> from pathlib import Path
>>> source_path = Path("Anscombe.txt")
>>> with source_path.open() as source:
... data = tuple(head_split_fixed(row_iter(source)))
>>> series_I = tuple(series(0, data))
>>> series_II = tuple(series(1, data))
>>> series_III = tuple(series(2, data))
>>> series_IV = tuple(series(3, data))
我们将tuple()函数应用于基于series()、head_split_fixed()和row_iter()函数的复合函数。这些表达式中的每一个都将创建一个我们可以用于其他几个函数的对象。然后我们可以对源数据子集进行分析。
series_I序列如下所示:
>>> series_I
((’10.0’, ’8.04’), (’8.0’, ’6.95’), ... (’5.0’, ’5.68’))
其他三个序列在结构上相似。然而,值却相当不同。
我们需要做的最后一件事是从我们累积的字符串中创建适当的数值,以便我们可以计算一些统计摘要值。我们可以将float()函数转换作为最后一步。float()函数可以应用于许多不同的地方,我们将在第五章,高阶函数中探讨一些选择。
为了减少内存使用并提高性能,我们尽可能多地使用生成器表达式和函数。这些以懒方式遍历集合,仅在需要时计算值。由于迭代器只能使用一次,我们有时被迫将集合实体化为元组(或列表)对象。实体化集合会消耗内存和时间,所以我们很不情愿地这样做。
熟悉 Clojure 的程序员可以将 Python 的懒生成器与lazy-seq和lazy-cat函数相匹配。其思想是我们可以指定一个可能无限长的序列,但只在其需要时从中取值。
3.7.2 使用状态映射
Python 提供了几个状态集合;各种映射包括dict类和定义在collections模块中的许多相关映射。我们需要强调这些映射的状态性质,并谨慎使用它们。
对于我们的目的,学习 Python 中的函数式编程技术,映射有两种用例:一个累积映射的状态字典和一个无法更新的冻结字典。Python 没有提供易于使用的不可变映射定义。我们可以使用来自collections.abc模块的抽象基类Mapping。我们还可以从一个可变映射创建不可变的MappingProxyType对象。更多信息,请参阅types模块。
状态字典可以进一步分解为以下两个典型用例:
-
一次性构建且从未更新的字典。在这种情况下,我们想利用
dict类的哈希键特性来优化性能。我们可以使用表达式dict(sequence)从任何(key, value)二元组可迭序列创建字典。 -
逐步构建的字典。这是一种我们可以用来避免实例化和排序列表对象的优化。我们将在第六章,递归和归约 中探讨,那里我们将探讨
collections.Counter类作为一个复杂归约。逐步构建对于记忆化特别有帮助。我们将把记忆化推迟到第十章,Functools 模块。
第一个例子,一次性构建字典,源于一个有三个操作阶段的应用程序:收集一些输入,创建一个 dict 对象,然后根据字典中的映射处理输入。作为这类应用的例子,我们可能在进行一些图像处理,并有一个特定的调色板颜色,由名称和 (R, G, B) 元组表示。如果我们使用 GNU 图像处理程序 (GIMP) 文件格式,调色板可能看起来像以下命令片段:
GIMP Palette
Name: Small
Columns: 3
#
0 0 0 Black
255 255 255 White
238 32 77 Red
28 172 120 Green
31 117 254 Blue
解析此文件的详细信息是第六章,递归和归约 的主题。重要的是解析的结果。
首先,我们将定义一个 typing.NamedTuple 类 Color,如下所示:
from typing import NamedTuple
class Color(NamedTuple):
red: int
green: int
blue: int
name: str
第二,我们假设我们有一个生成 Color 对象的可迭代序列的解析器。如果我们将其实例化为一个元组,它将看起来像以下这样:
>>> palette = [
... Color(red=239, green=222, blue=205, name=’Almond’),
... Color(red=205, green=149, blue=117, name=’Antique Brass’),
... Color(red=253, green=217, blue=181, name=’Apricot’),
... Color(red=197, green=227, blue=132, name=’Yellow Green’),
... Color(red=255, green=174, blue=66, name=’Yellow Orange’)
... ]
为了快速定位给定的颜色名称,我们将从这个序列创建一个冻结字典。这不是通过名称快速获取颜色查找的唯一方法。我们将在稍后探讨另一个选项。
要从一个元组的可迭代序列创建映射,我们将使用 process(wrap(iterable)) 设计模式。以下命令显示了我们可以如何创建颜色名称映射:
>>> name_map = dict((c.name, c) for c in palette)
设计模式有三个部分:
-
源可迭代序列是
palette。我们可以用提示Iterable[Color]来正式化这一点。 -
Wrap 是
(c.name, c)表达式,用于将Color对象转换为tuple[str, Color]对。 -
该过程是使用
dict()函数创建映射。
结果字典看起来如下:
>>> name_map[’Antique Brass’]
Color(red=205, green=149, blue=117, name=’Antique Brass’)
>>> name_map[’Yellow Orange’]
Color(red=255, green=174, blue=66, name=’Yellow Orange’)
这也可以通过字典推导来完成。我们将这个作为练习留给读者。
现在我们已经实例化了映射,我们可以使用这个 dict() 对象在后续处理中进行重复的颜色名称到 (R, G, B) 颜色数字的转换。查找将非常快,因为字典会快速从键到哈希值进行转换,然后在该字典中进行查找。
3.7.3 使用 bisect 模块创建映射
在前面的例子中,我们创建了一个dict对象来实现从颜色名称到Color对象的快速映射。这并不是唯一的选择;我们可以使用bisect模块。使用bisect模块意味着我们必须创建一个排序序列,然后我们可以搜索它。为了与字典实现完全兼容,我们可以使用collections.Mapping作为基类。
dict类使用哈希计算来几乎立即定位项。然而,这需要分配相当大的内存块。bisect映射执行搜索,不需要那么多的内存,但性能不能描述为立即。性能从 O(1)下降到 O(log n)。虽然这很显著,但内存节省对于处理大量数据集合可能是关键的。
一个静态映射类看起来像以下命令片段:
import bisect
from collections.abc import Mapping, Iterable
from typing import Any
class StaticMapping(Mapping[str, Color]):
def __init__(self,
iterable: Iterable[tuple[str, Color]]
) -> None:
self._data: tuple[tuple[str, Color], ...] = tuple(iterable)
self._keys: tuple[str, ...] = tuple(sorted(key for key, _ in self._data))
def __getitem__(self, key: str) -> Color:
ix = bisect.bisect_left(self._keys, key)
if (ix != len(self._keys) and self._keys[ix] == key):
return self._data[ix][1]
raise ValueError(f"{key!r} not found")
def __iter__(self) -> Iterator[str]:
return iter(self._keys)
def __len__(self) -> int:
return len(self._keys)
这个类扩展了抽象超类collections.Mapping。它提供了初始化和三个抽象定义中缺失的函数的实现。tuple[str, Color]的类型定义了这个映射期望的特定类型的两个元组。
__getitem__()方法使用bisect.bisect_left()函数在键的集合中搜索。如果找到键,则返回相应的值。__iter__()方法返回一个迭代器,这是超类所要求的。同样,__len__()方法提供了集合所需的大小。
这个类可能看起来没有体现太多的函数式编程原则。我们的目标是支持一个更大的应用程序,该应用程序最小化了有状态变量的使用。这个类保存了一个静态的键值对集合。作为一个优化,它实现了两个对象。
应用程序将创建此类的一个实例以执行与键相关联的值的相对快速查找。超类不支持对象的更新。整个集合是无状态的。它不如内置的dict类快,但占用的内存更少,并且通过作为Mapping类的子类,我们可以确保这个对象不会被用来包含处理状态。
3.7.4 使用有状态的集合
Python 提供了几个有状态的集合,包括set集合。对于我们的目的,集合有两个用例:
-
一个累积项目的有状态
set -
可以用来优化搜索项的
frozenset
我们可以通过与从可迭代对象创建元组对象相同的方式从可迭代对象创建frozenset,即通过frozenset(some_iterable)表达式;这将创建一个具有非常快速的in操作符优势的结构。这可以在一个收集数据、创建集合,然后使用frozenset来匹配其他数据项与集合的应用中使用。
我们可能有一组颜色,我们将将其用作一种色键:我们将使用这种颜色来创建一个掩码,用于组合两个图像。从实用主义的角度来看,单个颜色并不合适,但一小组非常相似的颜色效果最好。在这种情况下,我们可以检查图像文件中的每个像素,看像素是否在色键集中。对于这种处理,色键颜色可以在处理目标图像之前加载到frozenset中。集合查找非常快。
就像映射一样——特别是Counter类——有一些算法可以从一个记忆化的值集中受益。一些函数从记忆化中受益,因为函数是域值和值域之间的映射,这是一个映射工作得很好的工作。一些算法从记忆化的集合中受益,这个集合是有状态的,并且随着数据处理而增长。
我们将在第十章《Functools 模块》中再次回到记忆化。
3.8 摘要
在本章中,我们再次探讨了编写无副作用的纯函数。我们探讨了生成器函数以及我们如何将它们用作函数式编程处理项目集合的骨干。我们还检查了几个内置的集合类,以展示它们在函数式范式中的使用。虽然函数式编程背后的总体思想是限制有状态变量的使用,但集合对象具有有状态实现。对于许多算法,它们通常是必不可少的。我们的目标是谨慎使用 Python 的非函数式特性。
在接下来的两章中,我们将探讨用于处理集合的函数。之后,我们将仔细研究高阶函数:接受函数作为参数并返回函数的函数。在后面的章节中,我们将探讨定义我们自己的高阶函数的技术。我们还将探讨itertools和functools模块及其高阶函数。
3.9 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习将需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 存储库中已经提供的单元测试用例相同。读者应将书中的示例函数名称替换为自己的解决方案,以确认它是否有效。
3.9.1 重写 some_function()函数
在编写纯函数部分,展示了一个依赖于全局变量的函数。
创建一个小应用程序,设置全局变量并调用函数。该应用程序可以基于以下示例进行扩展:
def some_function ...
def main():
"""
>>> main()
some_function(2, 3, 5)=30
some_function(2, 3, 5)=34
"""
global global_adjustment
global_adjustment = 13
print(f"{some_function(2, 3, 5)=}")
global_adjustment = 17
print(f"{some_function(2, 3, 5)=}")
if __name__ == "__main__":
main()
首先,为some_function()和main()创建一个测试套件。示例中展示了嵌入在文档字符串中的doctest套件。
其次,将some_function()重写为将global_adjustment作为一个参数。这将导致修改main()和所有测试用例。
3.9.2 替代梅森类定义
函数作为一等对象部分的示例显示了一个Mersenne1类,该类接受一个函数作为__init__()方法的参数。
另一个选择是在类定义中提供作为插件策略函数。
这将允许以下类型的对象定义:
>>> class ShiftyMersenne(Mersenne2):
... pow2 = staticmethod(shifty)
>>> m2s = ShiftyMersenne()
>>> m2s(17)
131071
使用staticmethod()是必不可少的,因为shifty()函数在评估时不需要self参数。确保这个函数被理解为一个“静态”函数——也就是说,不使用self参数。
3.9.3 替代算法实现
考虑以下算法:
算法 4:命令式迭代
如本章所见,在 Python 中有三种方式来编写这个:
-
作为更新状态变量的
for语句 -
作为生成器表达式
-
作为应用函数的
map()操作
用 Python 编写所有三个版本。
测试用例如下数据:

以及以下缩放函数:

m 的值大约为零。
3.9.4 map()和filter()
内置的map()和filter()函数始终有一个等效的生成器表达式。为了使代码看起来一致,项目团队正在努力坚持所有代码都使用生成器表达式,避免使用内置的map()和filter()函数。
-
仅使用生成器表达式,并给出为什么这样做有优势的原因。
-
仅使用内置的
map()和filter()函数,并给出为什么这种替代方案可能具有优势的原因。 -
在审视这个练习的前两部分的原因时,是否有明确阐述的关于哪种方法更好的决定?如果没有,为什么?如果有,团队应该使用什么规则?
3.9.5 字典推导式
在使用状态映射部分,我们构建了一个从两个元组列表到映射。我们还可以使用字典推导式来构建映射。将表达式dict((c.name,`` c)`` for`` c`` in`` palette)重写为字典推导式。
3.9.6 原始数据清理
一个文件,Anscombe.txt,几乎是一个有效的 CSV 格式文件。问题是文件开头有三行无用的文本。这些行很容易识别,因为将这些标题行中的值应用于float()函数将引发ValueError异常。
一些团队成员建议使用正则表达式来检查值是否有效数字。这可以称为“三思而后行”(LBYL):
其他团队成员建议使用更简单的 try: 语句来揭示无效的非数字标题并丢弃它们。这可以称为“请求原谅比请求许可更容易”(EAFP):
两个算法都有效。将每个算法用 Python 实现,以便进行比较是有教育意义的。以下是比较算法的几个起点:
-
LBYL 变体可以完全依赖于生成器表达式。然而,它需要编写一个能够识别所有可能的浮点值的正则表达式。这是否应该是这个应用程序的一部分责任?
-
EAFP 变体需要一个单独的函数来实现
try:语句的处理。否则,它似乎也可以通过生成器表达式或map()函数来编写。
在构建了两种变体之后,哪一种似乎更能表达过滤和获取数据的目的?
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第四章:4
与集合一起工作
Python 提供了一些处理整个集合的函数。它们可以应用于序列(列表或元组)、集合、映射以及生成器表达式的可迭代结果。我们将从函数式编程的角度探讨 Python 的集合处理功能。
我们将首先查看可迭代对象和一些与可迭代对象一起工作的简单函数。我们将探讨一些设计模式来处理可迭代对象和序列,包括递归函数以及显式的for语句。我们还将探讨如何使用生成器表达式将标量函数应用于数据集合。
在本章中,我们将展示如何使用以下函数与集合一起使用:
-
any()和all() -
len()、sum()以及与这些函数相关的一些高级统计处理 -
使用
zip()和相关技术来结构化和展平数据列表 -
使用
sorted()和reversed()对集合施加排序 -
enumerate()
前四个函数可以被称作归约函数:它们将一个集合归约为一个单一值。其他三个函数,zip()、reversed()和enumerate(),是映射函数;它们从现有的集合中产生新的集合。在下一章中,我们将探讨一些使用额外函数作为参数来自定义处理的更多映射和归约函数。
在本章中,我们将首先探讨使用生成器表达式处理数据的方法。然后,我们将应用不同类型的集合级函数来展示它们如何简化迭代处理的语法。我们还将探讨一些不同的数据重构方式。
在下一章中,我们将专注于使用高阶集合函数来完成类似类型的处理。
4.1 函数种类概述
我们需要区分两种广泛的功能种类,如下所示:
-
标量函数:这些应用于单个值并计算一个单独的结果。例如
abs()、pow()以及整个math模块都是标量函数的例子。 -
集合函数:这些与可迭代集合一起工作。
我们可以将这些集合函数进一步细分为三种亚种:
-
归约:这使用一个函数将集合中的值折叠在一起,结果是一个单一最终值。例如,如果我们把加法操作折叠到一系列整数中,这将计算总和。这也可以被称为聚合函数,因为它为输入集合产生一个单一的聚合值。像
sum()和len()这样的函数是归约集合到单一值的例子。 -
映射:这将对集合中的每个单独项目应用一个标量函数;结果是同样大小的集合。内置的
map()函数就是这样做的;像enumerate()这样的函数可以被看作是从项目到值对的映射。 -
过滤器:这将对集合中的所有项目应用标量函数以拒绝某些项目并传递其他项目。结果是输入的子集。内置的
filter()函数就是这样做的。
一些函数,例如sorted()和reversed(),并不能简单地、整洁地适应这个框架。因为这两个“重新排序”函数并不是从现有值计算出新值,所以将它们放在一边似乎是有道理的。
我们将使用这个概念框架来描述我们使用内置集合函数的方式。
4.2 与可迭代对象一起工作
如前几章所述,Python 的for语句与可迭代对象一起工作,包括 Python 丰富的集合类型。当处理如元组、列表、映射和集合等具体化的集合时,for语句涉及显式地管理状态。
虽然这偏离了纯函数式编程,但它反映了 Python 必要的优化。状态管理被局部化到一个迭代器对象,该对象是作为for语句评估的一部分创建的;我们可以利用这个特性,而不会偏离纯函数式编程太远。例如,如果我们使用for语句的变量在语句缩进体之外,我们就通过利用这个状态控制变量偏离了纯函数式编程。
我们将在第六章,递归和归约中回到这个话题。这是一个重要的话题,我们将在本节中通过一个处理生成器的快速示例来略过表面。
使用for语句进行可迭代处理的一个常见应用是unwrap(process(wrap(iterable)))设计模式。一个wrap()函数首先将可迭代中的每个项目转换为一个包含派生排序键和原始项目的二元组。然后我们可以将这些二元组项目作为一个单一、包装的值进行处理。最后,我们将使用一个unwrap()函数来丢弃用于包装的值,从而恢复原始项目。
在函数式编程的上下文中,这种情况经常发生,以至于有两个函数被大量用于此;它们如下:
from collections.abc import Callable, Sequence
from typing import Any, TypeAlias
Extractor: TypeAlias = Callable[[Sequence[Any]], Any]
fst: Extractor = lambda x: x[0]
snd: Extractor = lambda x: x[1]
这两个函数从二元组中选取第一个和第二个值,并且它们在process()和unwrap()处理阶段都很有用。
另一个常见的模式是wrap3(wrap2(wrap1()))。在这种情况下,我们从一个简单的元组开始,然后通过添加额外的结果来包装它们,从而构建更大、更复杂的元组。我们在第二章,介绍基本函数式概念,不可变数据部分中看到了一个例子。这个主题的一个常见变体是从源对象构建新的、更复杂的命名元组实例。我们可能称之为累积设计模式——一个累积派生值的项。
例如,考虑使用累积模式来处理一系列纬度和经度值。第一步将路径上表示为(lat, lon)对的简单点转换为(begin, end)对的序列。结果中的每一对将表示为((lat, lon), (lat, lon))。fst(item)的值是起始位置;snd(item)的值是每个集合中每个项目的结束位置。我们将通过一系列示例来展示这种设计。
在接下来的章节中,我们将向您展示如何创建一个生成器函数,该函数将遍历源文件的内容。这个可迭代对象将包含我们将要处理的原始输入数据。一旦我们有了原始数据,后面的章节将展示如何在每个腿上装饰水平圆周距离。wrap(wrap(iterable()))设计的最终结果将是一个包含三个元组的序列:((lat, lon), (lat, lon), distance)。然后我们可以分析结果,以找到最长和最短距离、边界矩形和其他摘要。
水平圆周公式相对较长,但可以计算两个点在球面上之间的距离:


第一部分,a,是两点之间的角度。距离 d 是通过角度计算的,使用球体的半径 R,以所需的单位。对于海里距离,我们可以使用 R =
≈ 3437.7。对于千米距离,我们可以使用 R = 6371。
4.2.1 解析 XML 文件
我们首先通过解析可扩展标记语言(XML)文件来获取原始的纬度和经度对。这将展示我们如何封装 Python 的一些不太实用的功能来创建一个可迭代的值序列。
我们将使用xml.etree模块。在解析后,生成的ElementTree对象有一个iterfind()方法,它将遍历可用的值。
我们将寻找如下 XML 示例的结构:
<Placemark><Point>
<coordinates>-76.33029518659048, 37.54901619777347,0</coordinates>
</Point></Placemark>
文件将包含多个<Placemark>标签,每个标签内部都有一个点和坐标结构。坐标标签的值是东西经度、南北纬度和平均海平面以上的高度。这意味着有两个解析级别:XML 级别和每个坐标的详细信息。这是典型的包含地理信息的 Keyhole Markup Language (KML)文件。(更多信息请参阅developers.google.com/kml/documentation。)
从 XML 文件中提取数据可以在两个抽象级别上进行:
-
在较低级别,我们需要在 XML 文件中定位各种标签、属性值和内容。
-
在较高级别,我们希望从文本和属性值中创建有用的对象。
可以以下方式处理更底层的处理:
from collections.abc import Iterable
from typing import TextIO
import xml.etree.ElementTree as XML
def row_iter_kml(file_obj: TextIO) -> Iterable[list[str]]:
ns_map = {
"ns0": "http://www.opengis.net/kml/2.2",
"ns1": "http://www.google.com/kml/ext/2.2"
}
path_to_points = (
"./ns0:Document/ns0:Folder/ns0:Placemark/"
"ns0:Point/ns0:coordinates"
)
doc = XML.parse(file_obj)
text_blocks = (
coordinates.text
for coordinates in doc.iterfind(path_to_points, ns_map)
)
return (
comma_split(text)
for text in text_blocks
if text is not None
)
这个函数需要文本;通常这将从通过with语句打开的文件中获取。这个函数的结果是一个生成器,它从纬度/经度对创建列表对象。作为 XML 处理的一部分,这个函数使用一个简单的静态dict对象ns_map,它提供了正在解析的 XML 标签的命名空间映射信息。这个字典将由ElementTree.iterfind()方法使用,以在 XML 源文档中仅定位<coordinates>标签。
解析的本质是一个生成器函数,它使用doc.iterfind()找到的标签序列。然后,这个标签序列通过comma_split()函数被处理,将文本值分解成逗号分隔的各个部分。
path_to_points对象是一个字符串,它定义了如何通过 XML 结构进行导航。它描述了文档中<coordinates>标签相对于其他标签的位置。使用这个路径意味着生成器表达式将避免其他无关标签的值。
if text is not None子句反映了元素树标签的text属性的定义。如果没有标签体,文本值将是None。虽然看到空的<coordinates/>标签的可能性极低,但类型提示要求我们处理这种情况。
comma_split()函数比字符串的split()方法有更函数式的语法。这个函数定义如下:
def comma_split(text: str) -> list[str]:
return text.split(",")
我们使用了一个包装器来强调稍微更统一的语法。我们还添加了显式的类型提示,以明确指出字符串被转换为str值的列表。没有类型提示,split()方法可能有两种潜在的用法。实际上,这种方法适用于bytes和str。我们使用了str类型名来缩小类型域。
row_iter_kml()函数的结果是一个可迭代的行数据序列。每一行将是一个包含三个字符串的列表:路径上某个航点的纬度、经度和高度。这目前还没有直接用处。我们还需要进行一些额外的处理,以获取纬度和经度,并将这两个字符串转换为有用的浮点值。
可迭代的元组(或列表)序列的想法允许我们以简单统一的方式处理某些类型的数据文件。在第三章,函数、迭代器和生成器中,我们探讨了如何轻松地将逗号分隔值(CSV)文件作为元组的行处理。在第六章,递归和归约中,我们将重新审视解析思想,以比较这些不同的示例。
row_iter_kml()函数的输出可以通过list()函数收集。以下交互式示例将读取文件并提取详细信息。list()函数将创建一个列表,每个<coordinate>标签对应一个列表。累积的结果对象如下所示:
>>> from pprint import pprint
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... v1 = list(row_iter_kml(source))
>>> pprint(v1)
[[’-76.33029518659048’, ’37.54901619777347’, ’0’],
[’-76.27383399999999’, ’37.840832’, ’0’],
[’-76.459503’, ’38.331501’, ’0’],
...
[’-76.47350299999999’, ’38.976334’, ’0’]]
这些都是字符串值。为了更有用,对函数的输出应用一些额外的函数来创建数据的一个可用子集非常重要。
4.2.2 在更高层次解析文件
在将低级语法解析为将 XML 转换为 Python 之后,我们可以将原始数据重新结构化为我们 Python 程序中可用的形式。这种结构化适用于 XML、JavaScript 对象表示法(JSON)、CSV、YAML、TOML 以及数据序列化的各种物理格式。
我们的目标是编写一组小的生成器函数,将解析后的数据转换成我们应用程序可以使用的形式。生成器函数包括对由row_iter_kml()函数找到的文本进行的一些简单转换,具体如下:
-
丢弃海拔,也可以说成只保留纬度和经度
-
将顺序从
(longitude, latitude)改为(latitude, longitude)
我们可以通过定义一个实用函数来使这两个转换具有更多的语法一致性,如下所示:
def pick_lat_lon(
lon: str, lat: str, alt: str
) -> tuple[str, str]:
return lat, lon
我们创建了一个函数,接受三个参数值并从其中两个创建一个元组。类型提示比函数本身更复杂。将源数据转换为可用数据通常涉及选择字段子集以及从字符串到数字的转换。我们分离了这两个问题,因为这些方面通常独立发展。
我们可以使用此函数如下:
from collections.abc import Iterable
from typing import TypeAlias
Rows: TypeAlias = Iterable[list[str]]
LL_Text: TypeAlias = tuple[str, str]
def lat_lon_kml(row_iter: Rows) -> Iterable[LL_Text]:
return (pick_lat_lon(*row) for row in row_iter)
此函数将pick_lat_lon()函数应用于源迭代器中的每一行。我们使用了*row来将行的三个元组中的每个元素分配给pick_lat_lon()函数的单独参数。然后,该函数可以提取并重新排序每个三个元组中的两个相关值。
为了简化函数定义,我们定义了两个类型别名:Rows和LL_Text。这些类型别名可以简化函数定义。它们也可以被重用来确保几个相关函数都在使用相同类型的对象。这种功能设计允许我们自由地替换任何函数及其等效函数,这使得重构风险降低。
这些函数可以组合起来解析文件并构建我们可以使用的结构。以下是一些可用于此目的的代码示例:
>>> import urllib
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... v1 = tuple(lat_lon_kml(row_iter_kml(source)))
>>> v1[0]
(’37.54901619777347’, ’-76.33029518659048’)
>>> v1[-1]
(’38.976334’, ’-76.47350299999999’)
此脚本使用request.urlopen()函数打开一个源。在这种情况下,它是一个本地文件。然而,我们也可以在远程服务器上打开一个 KML 文件。我们使用这种文件打开方式的目标是确保无论数据源是什么,我们的处理都是统一的。
脚本围绕两个低级解析 KML 源的功能构建。row_iter_kml(source)表达式生成一系列文本列。lat_lon_kml()函数将提取并重新排序纬度和经度值。这创建了一个中间结果,为后续处理奠定了基础。后续处理可以设计为独立于原始格式。
最终函数使用几乎完全函数化的方法从复杂的 XML 文件中提供纬度和经度值。由于结果是可迭代的,我们可以继续使用函数式编程技术来处理我们从文件中检索到的每个点。
纯粹主义者有时会争论说,使用for语句引入了一个非函数性元素。为了保持纯粹,迭代应该通过递归定义。由于递归并不是 Python 语言特性的良好使用,我们宁愿牺牲一些纯粹性,以更 Python 化的方法为代价。
此设计明确地将低级 XML 解析与高级数据重组分开。XML 解析生成了一个通用的字符串结构元组。这与其他文件格式的解析器兼容。例如,结果值与 CSV 解析器的输出兼容。当与 SQL 数据库一起工作时,使用类似的元组结构迭代器可能会有所帮助。这允许设计一个可以处理来自各种数据源的高级处理方案。
我们将向您展示一系列转换,以重新排列这些数据,从字符串集合到路线上的航点集合。这将涉及许多转换。我们需要重构数据,以及将字符串转换为浮点值。我们还将探讨几种简化并澄清后续处理步骤的方法。我们将在后续章节中使用这个数据集,因为它相当复杂。
4.2.3 从序列中配对项目
常见的重构需求是将序列中的点转换为起始-结束对。给定一个序列,S = {s[0],s[1],s[2],...,s[n]},我们还想创建一个配对序列,S = {(s[0],s[1]),(s[1],s[2]),(s[2],s[3]),...,(s[n−1],s[n])}。第一和第二项形成一个对。第二和第三项形成下一个对。请注意,这些对是重叠的;每个点(除了第一个或最后一个)将是其中一个对的结束和下一个对的开始。
这些重叠的对用于通过应用哈夫曼函数来计算点与点之间的距离。这种技术也用于将点的路径转换为图形应用程序中的一系列线段。
为什么需要配对项目?为什么不在这个函数中插入几行额外的代码:
begin = next(iterable)
for end in iterable:
compute_something(begin, end)
begin = end
这个代码片段将处理数据中的每一段作为begin, end配对。然而,处理函数和重构数据的for语句紧密绑定,使得复用比必要的更复杂。当它是更复杂compute_something()函数的一部分时,配对算法很难单独测试。
创建一个组合函数也限制了我们的应用重构能力。没有简单的方法可以注入compute_something()函数的替代实现。此外,我们还有一个显式状态的部分,即begin变量,这可能会使生活变得复杂。如果我们尝试向for语句的主体添加功能,我们很容易在iterable源中的项被过滤出处理时未能正确设置begin变量。
通过将这个配对函数与其他处理分离,我们实现了更好的复用。从长远来看,简化是我们的目标之一。如果我们构建了一个包含此类配对函数在内的有用原语库,我们就可以更快、更有信心地解决更大的问题。
事实上,itertools 库(第八章,Itertools 模块的主题)中包含了一个pairwise()函数,我们也可以使用这个函数来从源迭代器中执行这种值的配对。虽然我们可以使用这个函数,但我们也会探讨如何设计我们自己的。
有许多方法可以将路线上的点配对,为每一段创建起始和终止信息。我们在这里将探讨几种方法,然后在第五章,高阶函数和再次在第八章,Itertools 模块中回顾这个问题。创建配对可以通过递归的纯函数方式完成:
![( |{ [] if |l| ≤ 1 pairs(l) = |( [(l0,l1)]+ pairs(l[1:]) if |l| > 1](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file37.jpg)
虽然数学形式似乎很简单,但它没有考虑到项 l[1]既是第一个配对的一部分,也是 l[[1:]]中剩余项的头部。
函数理想是避免将此值分配给变量。变量——以及由此产生的有状态代码——当我们试图进行“小”更改并误用变量的值时,可能会变成一个问题。
另一个选择是 somehow “窥视”可迭代数据源中的下一个项。这在 Python 中效果不佳。一旦我们使用next()来检查值,它就不能再放回迭代器中。这使得创建重叠配对的递归、函数版本变得过于复杂,以至于没有实际价值。
我们执行尾调用优化的策略是将数学形式中的递归替换为for语句。在某些情况下,我们可以进一步将其优化为生成器表达式。因为它使用显式变量跟踪计算状态,所以它更适合 Python,同时它的函数式程度较低。
以下代码是一个优化版本的函数,用于将沿路线的点配对:
from collections.abc import Iterator, Iterable
from typing import Any, TypeVar
LL_Type = TypeVar(’LL_Type’)
def legs(lat_lon_iter: Iterator[LL_Type]) -> Iterator[tuple[LL_Type, LL_Type]]:
begin = next(lat_lon_iter)
for end in lat_lon_iter:
yield begin, end
begin = end
这个版本更简单,相当快,并且不受递归定义的栈限制。它不依赖于任何特定的序列类型,因为它将配对序列生成器发出的任何内容。由于循环中没有处理函数,我们可以根据需要重复使用legs()函数。我们还可以稍微重新设计这个函数,使其接受一个处理函数作为参数值,并将给定的函数应用于创建的每个(begin, end)对。
类型变量LL_Type用于明确说明legs()函数如何重构数据。提示说明输入类型在输出时被保留。输入类型是某种任意类型的Iterator,即LL_Type;输出将包括相同类型的元组,即LL_Type。该函数没有隐含其他转换。
begin和end变量维护计算状态。使用有状态的变量不符合使用不可变对象进行函数式编程的理想。然而,这种优化在 Python 中很重要。它对函数的用户来说是不可见的,使其成为一种 Pythonic-functional 混合体。
注意,此函数需要一个可迭代的单个值来源。这可以是一个可迭代的集合或一个生成器。
我们可以将这个函数视为产生以下类型的对序列:
[items[0:2], items[1:3], items[2:4], ..., items[-2:]]
使用内置的zip()函数查看这个函数的另一种方式如下:
list(zip(items, items[1:]))
虽然这个基于zip()的示例很有信息量,但它仅适用于序列对象。前面展示的pairs()函数适用于任何可迭代对象,包括序列对象。legs()函数仅适用于Iterator对象作为数据源。好消息是,我们可以使用内置的iter()函数将可迭代集合转换为迭代器对象。
4.2.4 显式使用 iter()函数
从纯粹的功能角度来看,我们所有的可迭代对象都可以通过递归函数进行处理,其中状态由递归调用堆栈管理。在实践中,在 Python 中处理可迭代对象通常涉及for语句的评估。有两种常见的情况:集合对象和可迭代对象。当与集合对象一起工作时,for语句创建一个Iterator对象。当与生成器函数一起工作时,生成器函数是一个迭代器,并保持其自己的内部状态。通常,从 Python 编程的角度来看,它们是等效的。在罕见的情况下——通常是我们必须显式使用next()函数的情况——这两个不会完全等效。
之前显示的legs()函数有一个显式的next()评估,用于从可迭代对象中获取第一个值。这对于生成器函数、表达式和其他可迭代对象工作得非常好。它不适用于序列对象,如元组或列表。
以下代码包含三个示例,用于阐明next()和iter()函数的使用:
# Iterator as input:
>>> list(legs(x for x in range(3)))
[(0, 1), (1, 2)]
# List object as input:
>>> list(legs([0, 1, 2]))
Traceback (most recent call last):
...
TypeError: ’list’ object is not an iterator
# Explicit iterator created from list object:
>>> list(legs(iter([0,1,2])))
[(0, 1), (1, 2)]
在第一种情况下,我们将legs()函数应用于一个可迭代对象。在这种情况下,可迭代对象是一个生成器表达式。这是基于本章前面示例中我们期望的行为。项目被正确配对,从三个航点中创建出两条腿。
在第二种情况下,我们尝试将legs()函数应用于一个序列。这导致了一个错误。虽然列表对象和可迭代对象在for语句中使用时是等价的,但它们并不在所有地方都等价。序列不是一个迭代器;序列没有实现允许它被next()函数使用的__next__()特殊方法。然而,for语句通过自动从序列创建迭代器来优雅地处理这种情况。
要使第二种情况工作,我们需要从列表对象中显式创建一个迭代器。这允许legs()函数从列表项的迭代器中获取第一个项。iter()函数将从列表创建一个迭代器。
4.2.5 扩展迭代
我们可以将两种类型的扩展因素化到一个for语句中,该语句处理可迭代数据。我们首先将查看一个过滤器扩展。在这种情况下,我们可能会拒绝进一步考虑的值。它们可能是数据异常值,或者可能是格式不正确的源数据。然后,我们将查看通过执行简单转换来创建新对象,从而将源数据映射到源数据。在我们的情况下,我们将把字符串转换为浮点数。然而,将映射扩展到简单for语句的想法适用于许多情况。我们将查看重构上述legs()函数。如果我们需要调整点序列以丢弃一个值怎么办?这将引入一个过滤器扩展,拒绝一些数据值。
我们正在设计的迭代过程返回成对的数据,而不执行任何额外的应用相关处理——复杂性最小。简单意味着我们不太可能混淆处理状态。
向此设计添加一个过滤器扩展可能看起来像以下代码片段:
from collections.abc import Iterator, Iterable, Callable
from typing import TypeAlias
Waypoint: TypeAlias = tuple[float, float]
Pairs_Iter: TypeAlias = Iterator[Waypoint]
Leg: TypeAlias = tuple[Waypoint, Waypoint]
Leg_Iter: TypeAlias = Iterable[Leg]
def legs_filter(
lat_lon_iter: Pairs_Iter,
rejection_rule: Callable[[Waypoint, Waypoint], bool]) -> Leg_Iter:
begin = next(lat_lon_iter)
for end in lat_lon_iter:
if rejection_rule(begin, end):
pass
else:
yield begin, end
begin = end
我们已经插入了一个处理规则来拒绝某些值。由于 for 语句保持简洁和表达性强,我们相信处理将会被正确执行。另一方面,我们用一个相对简单的函数和两个单独的特征集合搞乱了代码。这种混乱并不是功能设计的理想方法。
我们并没有提供很多关于 rejection_rule() 函数的信息。这需要是一种适用于 Leg 元组的条件,以拒绝进一步考虑的点。例如,它可能会拒绝 begin == end 以避免零长度的腿。rejection_rule 的一个方便的默认值是 lambda s, e: False。这将保留所有的腿。
下一个重构将引入额外的映射到迭代中。在设计演变时添加映射是常见的。在我们的例子中,我们有一系列字符串值。我们需要将这些转换为浮点值以供以后使用。
以下是通过一个包装生成器函数的生成器表达式来处理这种数据映射的一种方法:
>>> import urllib
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... trip = list(
... legs(
... (float(lat), float(lon))
... for lat, lon in lat_lon_kml(row_iter_kml(source))
... )
... )
我们已经将 legs() 函数应用于一个生成器表达式,该表达式从 lat_lon_kml() 函数的输出中创建浮点值。我们也可以按从内到外的顺序读取。lat_lon_kml() 函数的输出被转换成一对浮点值,然后转换成一系列腿。
这开始变得复杂了。这里有很多嵌套的函数。我们正在将 float()、legs() 和 list() 应用于一个数据生成器。重构复杂表达式的常见方法是将生成器表达式与任何具体化的集合分离。我们可以这样做来简化表达式:
>>> import urllib
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... ll_iter = (
... (float(lat), float(lon))
... for lat, lon in lat_lon_kml(row_iter_kml(source))
... )
... trip = list(
... legs(ll_iter)
... )
我们已经将生成器函数分配给一个名为 ll_iter 的变量。这个变量不是一个集合对象;它是一个包含项两元组的生成器。我们没有使用列表推导来创建一个对象。我们只是将生成器表达式分配给一个变量名。然后我们在后续表达式中使用了 ll_iter 变量。
list() 函数的评估实际上导致了一个正确的对象被构建,这样我们就可以打印输出。ll_iter 变量的项仅按需创建。
我们还可能想要进行另一种重构。一般来说,数据源是我们经常想要更改的东西。在我们的例子中,lat_lon_kml() 函数与其他表达式的绑定非常紧密。当我们有不同数据源时,这使重用变得困难。
在float()操作是我们希望参数化以便可以重用的情况下,我们可以在生成器表达式周围定义一个函数。我们将一些处理提取到一个单独的函数中,仅仅是为了分组操作。在我们的情况下,字符串对到浮点对的转换是特定源数据独有的。我们可以将复杂的从字符串到浮点数的表达式重写为一个更简单的函数,例如:
from collections.abc import Iterator, Iterable
from typing import TypeAlias
Text_Iter: TypeAlias = Iterable[tuple[str, str]]
LL_Iter: TypeAlias = Iterable[tuple[float, float]]
def floats_from_pair(lat_lon_iter: Text_Iter) -> LL_Iter:
return (
(float(lat), float(lon))
for lat, lon in lat_lon_iter
)
floats_from_pair()函数将float()函数应用于可迭代中每个项目的第一个和第二个值,从而从输入值创建一个包含两个浮点数的元组。我们依赖于 Python 的for语句来分解这个元组。
类型提示详细说明了从tuple[str, str]项的可迭代序列到tuple[float, float]项的转换。然后,LL_Iter类型别名可以在复杂函数定义的其它地方使用,以展示浮点对是如何处理的。
我们可以在以下上下文中使用此函数:
>>> import urllib
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... trip = list(
... legs(
... floats_from_pair(
... lat_lon_kml(
... row_iter_kml(source))))
... )
我们将创建由来自 KML 文件的浮点值构建的腿。可视化处理过程相当容易,因为过程中的每个阶段都是一个前缀函数。每个函数的输入是嵌套处理步骤中下一个函数的输出。这似乎是表达处理管道的自然方式。
在解析时,我们经常有字符串值的序列。对于数值应用,我们需要将字符串转换为浮点数、整数或Decimal值。这通常涉及到将floats_from_pair()函数等函数插入到清理源数据的表达式序列中。
我们之前的输出都是字符串;它看起来像以下代码片段:
((’37.54901619777347’, ’-76.33029518659048’),
(’37.840832’, ’-76.27383399999999’),
...
(’38.976334’, ’-76.47350299999999’))
我们希望得到如下代码片段所示的数据,其中包含浮点数:
(((37.54901619777347, -76.33029518659048),
(37.840832, -76.273834)), ((37.840832, -76.273834),
...
((38.330166, -76.458504), (38.976334, -76.473503)))
在构建了这个处理管道之后,有一些简化可用。我们将在第五章和高阶函数中查看一些重构。我们将在第六章和递归与归约中重新审视这个问题,看看如何将这些简化应用到文件解析问题中。
4.2.6 将生成器表达式应用于标量函数
我们将探讨一种更复杂的生成器表达式,用于将一种数据类型的值映射到另一种数据类型。在这种情况下,我们将对一个由生成器创建的单独数据值应用一个相当复杂的函数。
我们将这些非生成器函数称为标量,因为它们与简单的原子值一起工作。要处理数据集合,标量函数将嵌入到生成器表达式中。
为了继续之前开始的例子,我们将提供一个 haversine 函数来计算纬度和经度值之间的距离。技术上,这些是角度,并且需要一些球面三角学来将角度转换为球面上的距离。我们可以使用生成器表达式将标量 haversine() 函数应用于从我们的 KML 文件中提取的一对序列。
haversine() 函数的重要部分是计算两个点之间的距离,遵循地球的正确球面几何。这可能涉及一些看起来很复杂的数学,但我们在这里提供了整个定义。我们还在 使用可迭代对象 部分的开头提到了这个函数。
haversine() 函数通过以下代码实现:
from math import radians, sin, cos, sqrt, asin
from typing import TypeAlias
MI = 3959
NM = 3440
KM = 6371
Point: TypeAlias = tuple[float, float]
def haversine(p1: Point, p2: Point, R: float=NM) -> float:
lat_1, lon_1 = p1
lat_2, lon_2 = p2
Δ_lat = radians(lat_2 - lat_1)
Δ_lon = radians(lon_2 - 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
)
c = 2 * asin(a)
return R * c
起点和终点 p1 和 p2 有类型提示以显示其结构。返回值也提供了提示。显式使用 Point 的类型别名使得 mypy 工具能够确认此函数被正确使用。
对于沿海水手覆盖的短距离,等距圆柱距离计算更有用:



其中 R 是地球的平均半径,R =
海里。ϕ 值是南北纬度,λ 值是东西经度。这意味着 (ϕ[0],λ[0]) 和 (ϕ[1],λ[1]) 是我们正在导航的两个点。
更多信息请参阅 edwilliams.org/avform147.htm。
以下代码展示了我们如何使用我们的函数集合来检查一些 KML 数据并生成一系列距离:
>>> import urllib
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... trip = (
... (start, end, round(haversine(start, end), 4))
... for start,end in
... legs(
... floats_from_pair(
... lat_lon_kml(row_iter_kml(source))
... )
... )
... )
... for start, end, dist in trip:
... print(f"({start} to {end} is {dist:.1f}")
处理的本质是将生成器表达式分配给 trip 变量。我们已经组装了包含起点、终点和从起点到终点的距离的三元组。起点和终点对来自 legs() 函数。legs() 函数与从 KML 文件中提取的纬度-经度对构建的浮点数据一起工作。
输出看起来像以下命令片段:
((37.54901619777347, -76.33029518659048) to (37.840832, -76.273834) is 17.7
((37.840832, -76.273834) to (38.331501, -76.459503) is 30.7
((38.331501, -76.459503) to (38.845501, -76.537331) is 31.1
((38.845501, -76.537331) to (38.992832, -76.451332) is 9.7
...
每个处理步骤都已被简洁地定义。概览同样可以简洁地表达为函数和生成器表达式的组合。
显然,我们可能希望对这个数据应用几个进一步的加工步骤。首先,当然是要使用字符串的 format() 方法来生成更美观的输出。
更重要的是,我们希望从这个数据中提取一些聚合值。我们将这些值称为可用数据的缩减。我们希望将数据缩减到获取最大和最小纬度,例如,以显示这条路线的极端南北端。我们还想将数据缩减到获取单段的最大距离以及所有段的总距离。
我们在使用 Python 时会遇到的问题是,trip 变量的输出生成器只能使用一次。我们无法轻松地对这些详细数据进行多次缩减。虽然我们可以使用 itertools.tee() 函数多次处理可迭代对象,但它需要相当多的内存。每次缩减都读取和解析 KML 文件也可能造成浪费。我们可以通过将中间结果物化为列表对象来提高我们的处理效率。
在下一节中,我们将探讨两种特定的缩减类型,它们从一组布尔值计算出一个单一的布尔结果。
4.3 使用 any() 和 all() 作为缩减
any() 和 all() 函数提供了布尔缩减功能。这两个函数将一组值缩减为一个单一的 True 或 False。all() 函数确保所有项都具有真值;any() 函数确保至少有一个项具有真值。在这两种情况下,这些函数依赖于 Python 的概念“truish”,或“truthy”:这些值在内置的 bool() 函数返回 true。一般来说,“falsish”值包括 False 和 None,以及零、空字符串和空集合。非假值即为真值。
这些函数与用于表达数学逻辑的全称量词和存在量词密切相关。例如,我们可能想要断言给定集合中的所有元素都具有某个属性。这种形式之一可能如下所示:

我们可以这样读它:对于 S 中的所有 x,Prime(x) 函数为真。我们在逻辑表达式前使用了全称量词“对于所有”,即 ∀。
在 Python 中,我们稍微调整了项的顺序,将逻辑表达式转录如下:
all(isprime(x) for x in someset)
all() 函数将评估 isprime(x) 函数对每个不同的 x 值,并将值集合缩减为一个单一的 True 或 False。
any() 函数与存在量词相关。如果我们想要断言集合中没有值是素数,我们可以使用以下两个等价表达式之一:

左边表述的是 S 中所有元素都不是素数的情况。右边断言 S 中存在一个不是素数的元素。这两个是等价的;也就是说,如果所有元素都不是素数,那么必须有一个元素是非素数。
这个等价规则被称为德摩根定律。它可以一般地表述为 ∀xP(x) ≡ ¬∃x¬P(x)。如果某个命题 P(x) 对所有 x 都为真,那么不存在 x 使得 P(x) 为假。
在 Python 中,我们可以交换项的顺序,并将这些转换为以下两种形式中的任意一种有效代码:
not_p_1 = not all(isprime(x) for x in someset)
not_p_2 = any(not isprime(x) for x in someset)
由于这两行是等价的,选择其中一行而舍弃另一行的两个常见原因:性能和清晰度。性能几乎相同,所以归结为清晰度。哪个表述的条件最为清晰?
all() 函数可以被描述为一系列值的“与”归约。结果是类似于在给定的值序列之间折叠 and 操作符。类似地,any() 函数可以被描述为“或”归约。当我们查看第十章 The Functools Module 中的 reduce() 函数时,我们会回到这种通用归约。这里没有最佳答案;这是一个关于什么对目标读者来说最易读的问题。
我们还需要考虑这些函数的退化情况。如果序列没有元素,all(()) 或 all([]) 的值是什么?
考虑一个列表 [1, 2, 3]。表达式 [] + [1, 2, 3] == [1, 2, 3] 是正确的,因为空列表是列表连接的恒等值。这也适用于 sum(()) 函数:sum([]) + sum([1, 2, 3]) == sum([1, 2, 3])。空列表的总和必须是加法的恒等值,即零。
and 的恒等值是 True。这是因为 True and whatever == whatever。同样,or 的恒等值是 False。以下代码演示了 Python 遵循这些规则:
>>> all(())
True
>>> any(())
False
Python 给我们一些非常棒的工具来执行涉及逻辑的处理。我们有内置的 and、or 和 not 操作符。然而,我们还有这些集合导向的 any() 和 all() 函数。
4.4 使用 len() 和 sum() 对集合进行操作
len() 和 sum() 函数提供了两种简单的归约——序列中元素的计数和元素的总和。这两个函数在数学上是相似的,但它们的 Python 实现却相当不同。
从数学上观察,我们可以看到这种酷炫的并行性:
-
len()函数返回集合中每个值的“1”的总和,X:∑ [x∈X]1 = ∑ [x∈X]x⁰。 -
sum()函数返回集合中每个值的总和,X:∑ [x∈X]x = ∑ [x∈X]x¹。
sum() 函数适用于任何可迭代对象。len() 函数不适用于可迭代对象;它只适用于序列。这些函数在实现上的这种微小不对称性在统计算法的边缘有点尴尬。
如上所述,对于空序列,这两个函数都返回适当的加法恒等值零:
>>> sum(())
0
>>> len(())
0
虽然 sum(()) 返回整数零,但在处理浮点值时这不是问题。当使用其他数值类型时,可以与可用数据类型的值一起使用整数零。Python 的数值类型通常有与其他数值类型进行操作的规则。
4.4.1 使用总和和计数进行统计
在本节中,我们将实现一些对统计有用的函数。目的是展示函数式编程如何应用于统计函数中常见的处理类型。
几个常见的函数被描述为“集中趋势的度量”。像算术平均值或标准差这样的函数提供了一组值的总结。一种称为“归一化”的转换将值移动和缩放到总体平均值和标准差周围。我们还将探讨如何计算相关系数,以显示两组数据相互关联的程度。
读者可能想查看towardsdatascience.com/descriptive-statistics-f2beeaf7a8df,以获取更多关于描述性统计的信息。
算术平均值似乎基于sum()和len()有一个吸引人的简单定义。看起来以下可能可行:
def mean(items):
return sum(items) / len(items)
这个看起来简单的函数对Iterable对象不起作用。这个定义只适用于支持len()函数的集合。当尝试编写正确的类型注解时,这一点很容易发现。mean(items: Iterable[float]) -> float的定义将不起作用,因为更通用的Iterable[float]类型不支持len()。
事实上,我们很难根据可迭代对象执行像标准差这样的计算。在 Python 中,我们必须要么实例化一个序列对象,要么求助于相对复杂的处理,这种处理在单次遍历数据时计算多个总和。要使用更简单的函数,意味着使用list()创建一个具体的序列,该序列可以被多次处理。
为了通过 mypy 的审查,定义需要看起来像这样:
from collections.abc import Sequence
def mean(items: Sequence[float]) -> float:
return sum(items)/len(items)
这包括适当的类型提示,以确保sum()和len()都能为预期的数据类型工作。mypy 工具了解算术类型匹配规则:任何可以被视为浮点数的值都将被视为有效。这意味着mean([1, 2, 3])将由于值都是整数而被 mypy 工具接受。
在以下定义中,我们有关于平均值和标准差的替代和优雅的表达式:
import math
from collections.abc import Sequence
def stdev(data: Sequence[float]) -> float:
s0 = len(data) # sum(1 for x in data)
s1 = sum(data) # sum(x for x in data)
s2 = sum(x**2 for x in data)
mean = s1 / s0
stdev = math.sqrt(s2 / s0 - mean ** 2)
return stdev
这三个总和s0、s1和s2具有整洁的并行结构。我们可以轻松地从两个总和计算平均值。标准差稍微复杂一些,但它基于三个可用的总和。
这种令人愉悦的对称性也适用于更复杂的统计函数,例如相关性和最小二乘线性回归。
两个样本集之间的相关矩可以从它们的标准化值计算得出。以下是一个计算标准化值的函数:
def z(x: float, m_x: float, s_x: float) -> float:
return (x - m_x) / s_x
这种计算从每个样本 x 中减去平均值μ[x],然后除以标准差σ[x]。这给我们一个以 sigma,σ为单位的值。对于正态分布的数据,大约三分之二的时间会期望一个±1σ的值。更极端的值应该更不常见。一个在±3σ之外的价值应该发生不到百分之一的时间。
我们可以这样使用这个标量函数:
>>> d = [2, 4, 4, 4, 5, 5, 7, 9]
>>> list(z(x, mean(d), stdev(d)) for x in d)
[-1.5, -0.5, -0.5, -0.5, 0.0, 0.0, 1.0, 2.0]
我们构建了一个列表,其中包含基于变量 d 中一些原始数据的归一化分数。我们使用生成器表达式将标量函数 z() 应用到序列对象上。
mean() 和 stdev() 函数基于之前展示的示例:
from math import sqrt
from collections.abc import Sequence
def mean(samples: Sequence[float]) -> float:
return s1(samples)/s0(samples)
def stdev(samples: Sequence[float]) -> float:
N = s0(samples)
return sqrt((s2(samples) / N) - (s1(samples) / N) ** 2)
同样,三个求和函数可以定义为以下代码所示:
def s0(samples: Sequence[float]) -> float:
return sum(1 for x in samples) # or len(data)
def s1(samples: Sequence[float]) -> float:
return sum(x for x in samples) # or sum(data)
def s2(samples: Sequence[float]) -> float:
return sum(x*x for x in samples)
虽然这非常简洁且表达力强,但有一点令人沮丧,因为我们不能在这里使用可迭代对象。例如,在评估 mean() 函数时,需要可迭代对象的和以及计数。对于标准差,需要两个和以及可迭代对象的计数。对于这种类型的统计处理,我们必须实际化一个序列对象(换句话说,创建一个 list),这样我们才能多次检查数据。
以下代码展示了我们如何计算两组样本之间的相关性:
def corr(samples1: Sequence[float], samples2: Sequence[float]) -> float:
m_1, s_1 = mean(samples1), stdev(samples1)
m_2, s_2 = mean(samples2), stdev(samples2)
z_1 = (z( x, m_1, s_1 ) for x in samples1)
z_2 = (z( x, m_2, s_2 ) for x in samples2)
r = (
sum(zx1 * zx2 for zx1, zx2 in zip(z_1, z_2))
/ len(samples1)
)
return r
这个相关性函数 corr() 收集两组样本的基本统计摘要:平均值和标准差。有了这些摘要,我们定义了两个生成器函数,它们将为每组样本创建归一化值。然后我们可以使用 zip() 函数(参见下一个示例)将两个归一化值序列中的项目配对,并计算这两个归一化值的乘积。归一化分数乘积的平均值就是相关性。
以下代码是收集两组样本之间相关性的示例:
>>> xi = [1.47, 1.50, 1.52, 1.55, 1.57, 1.60, 1.63, 1.65,
... 1.68, 1.70, 1.73, 1.75, 1.78, 1.80, 1.83,]
>>> yi = [52.21,53.12,54.48,55.84,57.20,58.57,59.93,61.29,
... 63.11, 64.47, 66.28, 68.10, 69.92, 72.19, 74.46,]
>>> round(corr(xi, yi), 5)
0.99458
我们展示了两个数据点的序列,xi 和 yi。相关系数超过 0.99,这表明这两个序列之间存在非常强的关系。
这展示了函数式编程的一个优势。我们使用六个定义单一表达式的函数创建了一个方便的统计模块。有趣的是,corr() 函数不能轻易地简化为一个单一的表达式。(它可以简化为一个非常长的单一表达式,但阅读起来会非常困难。)在这个函数实现中的每个内部变量只使用一次。这表明 corr() 函数具有函数式设计,尽管它被写成六行独立的 Python 代码。
4.5 使用 zip() 结构化和展平序列
zip() 函数将来自几个迭代器或序列的值交织在一起。它将从每个 n 个输入迭代器或序列中的值创建 n 个元组。我们在上一节中使用它来交织两组样本的数据点,创建成对的元组。
zip() 函数是一个生成器。它不会实际生成一个结果集合。
以下是一个展示 zip() 函数如何工作的代码示例:
>>> xi = [1.47, 1.50, 1.52, 1.55, 1.57, 1.60, 1.63, 1.65,
... 1.68, 1.70, 1.73, 1.75, 1.78, 1.80, 1.83,]
>>> yi = [52.21, 53.12, 54.48, 55.84, 57.20, 58.57, 59.93, 61.29,
... 63.11, 64.47, 66.28, 68.10, 69.92, 72.19, 74.46,]
>>> zip(xi, yi)
<zip object at ...>
>>> pairs = list(zip(xi, yi))
>>> pairs[:3]
[(1.47, 52.21), (1.5, 53.12), (1.52, 54.48)]
>>> pairs[-3:]
[(1.78, 69.92), (1.8, 72.19), (1.83, 74.46)]
zip() 函数有一些边缘情况。我们必须询问以下关于其行为的问题:
-
当没有任何参数时会发生什么?
-
当只有一个参数时会发生什么?
-
当序列长度不同时会发生什么?
与其他函数一样,例如 any()、all()、len() 和 sum(),当我们对空序列应用归约时,我们希望得到一个身份值作为结果。例如,sum(()) 应该是零。这个概念告诉我们 zip() 的身份值应该是什么。
显然,这些边缘情况必须产生某种可迭代输出。以下是一些澄清行为的代码示例。首先,空参数列表:
>>> list(zip())
[]
空列表的生产符合列表身份值 [] 的概念。接下来,我们将尝试一个单一的迭代器:
>>> list(zip((1,2,3)))
[(1,), (2,), (3,)]
在这种情况下,zip() 函数从每个输入值中发出一个元组。这也很有道理。
最后,我们将探讨 zip() 函数使用的不同长度的列表方法:
>>> list(zip((1, 2, 3), (’a’, ’b’)))
[(1, ’a’), (2, ’b’)]
这个结果是有争议的。为什么截断较长的列表?为什么不使用 None 值填充较短的列表?这个 zip() 函数的替代定义在 itertools 模块中作为 zip_longest() 函数可用。我们将在第八章《迭代工具模块》中探讨这个问题。
4.5.1 解包 zipped 序列
我们可以使用 zip() 函数来创建一系列元组。我们还需要查看几种将一系列元组解包到单独集合中的方法。
我们不能完全解包一个元组可迭代对象,因为我们可能想要多次遍历数据。根据我们的需求,我们可能需要将可迭代对象实体化以提取多个值。
解包元组的第一种方法是我们多次见过的:我们可以使用生成器函数来解包一系列元组。例如,假设以下对是一个包含两元组的序列对象:
>>> p0 = list(x[0] for x in pairs)
>>> p0[:3]
[1.47, 1.5, 1.52]
>>> p1 = list(x[1] for x in pairs)
>>> p1[:3]
[52.21, 53.12, 54.48]
这个片段创建了两个序列。p0 序列包含每个两元组的第一个元素;p1 序列包含每个两元组的第二个元素。
在某些情况下,我们可以使用 for 语句的多重赋值来分解元组。以下是一个计算乘积之和的例子:
>>> round(sum(p0*p1 for p0, p1 in pairs), 3)
1548.245
我们使用了 for 语句来分解每个两元组到 p0 和 p1。
4.5.2 展平序列
有时,我们需要展平已经 zipped 的数据。也就是说,我们需要将子序列序列转换成一个单一的列表。例如,我们的输入可能是一个包含列数据的行文件的。它看起来像这样:
2 3 5 7 11 13 17 19 23 29
31 37 41 43 47 53 59 61 67 71
...
我们可以使用 (line.split() for line in file) 来从源文件的行中创建一个序列。该序列中的每个项目都将是从单行上的值组成的嵌套 10 项元组。
这将在 10 个值的块中创建数据。它看起来如下:
>>> blocked = list(line.split() for line in file)
>>> from pprint import pprint
>>> pprint(blocked)
[[’2’, ’3’, ’5’, ’7’, ’11’, ’13’, ’17’, ’19’, ’23’, ’29’],
[’31’, ’37’, ’41’, ’43’, ’47’, ’53’, ’59’, ’61’, ’67’, ’71’],
...
[’179’, ’181’, ’191’, ’193’, ’197’, ’199’, ’211’, ’223’, ’227’, ’229’]]
这是一个开始,但并不完整。我们希望将数字放入一个单一的、扁平的序列中。输入中的每个项目都是一个 10 元组;我们宁愿一次也不分解这个项目。
我们可以使用两层生成器表达式,如以下代码片段所示,来进行这种展平:
>>> len(blocked)
5
>>> (x for line in blocked for x in line)
<generator object <genexpr> at ...>
>>> flat = list(x for line in blocked for x in line)
>>> len(flat)
50
>>> flat[:10]
[’2’, ’3’, ’5’, ’7’, ’11’, ’13’, ’17’, ’19’, ’23’, ’29’]
第一个for循环将阻塞列表中的每个项目——一个包含 10 个值的列表——分配给line变量。第二个for循环将line变量中的每个单独的字符串分配给x变量。最后的生成器是这个分配给x变量的值序列。
我们可以通过以下重写来理解这一点:
from collections.abc import Iterable
from typing import Any
def flatten(data: Iterable[Iterable[Any]]) -> Iterable[Any]:
for line in data:
for x in line:
yield x
这种转换显示了生成器表达式的工作原理。第一个for循环(for line in data)遍历数据中的每个 10 元组。第二个for循环(for x in line)遍历第一个for循环中的每个项目。
这个表达式将序列序列结构扁平化为单个序列。更普遍地说,它将任何包含可迭代对象的可迭代对象扁平化为单个扁平可迭代对象。它适用于列表中的列表,以及列表中的集合或其他任何嵌套可迭代对象的组合。
4.5.3 结构化扁平序列
有时,我们会有原始数据,它是一个我们希望将其组合成子组的扁平值列表。在本章前面的从序列中配对项目部分,我们查看重叠对。在本节中,我们查看非重叠对。
一种方法是使用itertools模块的groupby()函数来实现这一点。这需要等到第八章,迭代器模块。
假设我们有一个扁平列表,如下所示:
>>> flat = [’2’, ’3’, ’5’, ’7’, ’11’, ’13’, ’17’, ’19’, ’23’, ’29’,
... ’31’, ’37’, ’41’, ’43’, ’47’, ’53’, ’59’, ’61’, ’67’, ’71’,
... ]
我们可以编写嵌套生成器函数,从扁平数据构建序列序列结构。为此,我们需要一个可以多次使用的单个迭代器。表达式看起来像以下代码片段:
>>> flat_iter = iter(flat)
>>> (tuple(next(flat_iter) for i in range(5))
... for row in range(len(flat) // 5)
... )
<generator object <genexpr> at ...>
>>> grouped = list(_)
>>> from pprint import pprint
>>> pprint(grouped)
[(’2’, ’3’, ’5’, ’7’, ’11’),
(’13’, ’17’, ’19’, ’23’, ’29’),
(’31’, ’37’, ’41’, ’43’, ’47’),
(’53’, ’59’, ’61’, ’67’, ’71’)]
首先,我们创建一个迭代器,它存在于我们用来创建序列序列的两个循环之外的任何地方。生成器表达式使用tuple(next(flat_iter) for i in range(5))从flat_iter变量中的可迭代值创建五个元素的元组。这个表达式嵌套在另一个生成器中,该生成器重复内部循环适当的次数以创建所需的值序列。
这仅在扁平列表均匀分割时才有效。如果最后一行有部分元素,我们需要单独处理它们。
我们可以使用这种类型的函数将数据分组为相同大小的元组,末尾有一个奇数大小的元组,使用以下定义:
from collections.abc import Sequence
from typing import TypeVar
ItemType = TypeVar("ItemType")
# Flat = Sequence[ItemType]
# Grouped = list[tuple[ItemType, ...]]
def group_by_seq(n: int, sequence: Sequence[ItemType]) -> list[tuple[ItemType,...]]:
flat_iter = iter(sequence)
full_sized_items = list(
tuple(next(flat_iter) for i in range(n))
for row in range(len(sequence) // n)
)
trailer = tuple(flat_iter)
if trailer:
return full_sized_items + [trailer]
else:
return full_sized_items
在group_by_seq()函数中,构建一个初始列表并分配给变量full_sized_items。这个列表中的每个元组大小为n。如果有剩余项,则使用尾随项构建一个非零长度的元组,并将其追加到完整大小的项目列表中。如果trailer元组长度为零,则可以安全忽略。
类型提示包括对 ItemType 的泛型定义,作为一个类型变量。类型变量的意图是表明无论什么类型的输入传递给这个函数,都会从这个函数返回。字符串序列或浮点数序列都可以正常工作。
输入被总结为一个包含项目的 Sequence。输出是一个包含项目元组的 List。所有项目都是同一种类型,用 ItemType 类型变量描述。
这并不像我们之前看到的其他算法那样简单和功能性强。我们可以将其重构为一个更简单的生成器函数,该函数产生一个可迭代对象而不是列表。
以下代码使用 while 语句作为尾部递归优化的部分:
from collections.abc import Iterator
from typing import TypeVar
ItemT = TypeVar("ItemT")
def group_by_iter(n: int, iterable: Iterator[ItemT]) -> Iterator[tuple[ItemT, ...]]:
def group(n: int, iterable: Iterator[ItemT]) -> Iterator[ItemT]:
for i in range(n):
try:
yield next(iterable)
except StopIteration:
return
while row := tuple(group(n, iterable)):
yield row
我们已经从输入的可迭代对象中创建了一个所需长度的行。在输入可迭代对象的末尾,tuple(next(iterable) for i in range(n)) 的值将是一个空元组。这可以是递归定义的基本情况。这被手动优化为 while 语句的终止条件。
狐狸操作符 := 用于将 tuple(group(n, iterable)) 表达式的结果赋值给变量 row。如果这是一个非空元组,它将是 yield 语句的输出。如果这是一个空元组,循环将终止。
类型提示已被修改以反映这种方式与迭代器的结合。这些迭代处理技术不仅限于序列。因为内部 group() 函数明确使用 next(),所以必须像这样使用:group_by_iter(7, iter(flat))。必须使用 iter() 函数从集合中创建迭代器。
我们可以作为一个替代方案,在 group() 函数内部使用 iter() 函数。当提供一个集合时,这将创建一个全新的迭代器。当提供一个迭代器时,它将不执行任何操作。这使得函数更容易使用。
4.5.4 结构化扁平序列 - 一种替代方法
假设我们有一个简单的扁平列表,并希望从这个列表中创建非重叠的成对。以下是我们的数据:
>>> flat = [’2’, ’3’, ’5’, ’7’, ’11’, ’13’, ’17’, ’19’, ’23’, ’29’,
... ’31’, ’37’, ’41’, ’43’, ’47’, ’53’, ’59’, ’61’, ’67’, ’71’,
... ]
我们可以使用列表切片来创建成对,如下所示:
>>> pairs = list(zip(flat[0::2], flat[1::2]))
>>> pairs[:3]
[(’2’, ’3’), (’5’, ’7’), (’11’, ’13’)]
>>> pairs[-3:]
[(’47’, ’53’), (’59’, ’61’), (’67’, ’71’)]
切片 flat[0::2] 包含所有偶数位置。切片 flat[1::2] 包含所有奇数位置。如果我们将这些一起压缩,我们将得到一个二元组。索引 [0] 的项目是第一个偶数位置的价值,然后索引 [1] 的项目是第一个奇数位置的价值。如果元素的数量是偶数,这将产生很好的成对。如果项目总数是奇数,最后一个项目将被丢弃。这是一个有便捷解决方案的问题。
list(zip(...)) 表达式具有相当简洁的优点。我们可以遵循上一节中的方法,并定义我们自己的函数来解决相同的问题。
我们也可以使用 Python 的内置功能构建一个解决方案。具体来说,是使用*(args)方法来生成必须一起 zipped 的序列-of-sequences。它看起来像以下这样:
>>> n = 2
>>> pairs = list(
... zip(*(flat [i::n] for i in range(n)))
... )
>>> pairs[:5]
[(’2’, ’3’), (’5’, ’7’), (’11’, ’13’), (’17’, ’19’), (’23’, ’29’)]
这将生成 n 个切片:flat[0::n], flat[1::n], flat[2::n],以此类推,以及flat[n-1::n]。这个切片集合成为zip()`函数的参数,然后它将每个切片中的值交错排列。
回想一下,zip()会在最短列表处截断序列。这意味着如果列表不是分组因子n的偶数倍,则将丢弃n个项。当列表的长度len(flat)不是n的倍数时,我们会看到len(flat) % n不为零;这将最终切片的大小。
如果我们切换到使用itertools.zip_longest()函数,那么我们会看到最终的元组将用足够的None值填充,使其长度为n。
我们有两种方法来将列表结构化为组。我们需要根据如果列表长度不是组大小的话将采取什么措施来选择方法。我们可以使用zip()来截断,或者使用zip_longest()来添加一个“填充”常数,使最终组达到期望的大小。
列表切片方法用于分组数据是解决将扁平数据序列结构化成块的问题的另一种方法。由于它是一个通用解决方案,它似乎不提供太多比上一节中的函数更多的优势。作为一个专门用于从扁平列表中制作双元组的解决方案,它简单而优雅。
4.6 使用 sorted()和 reversed()来改变顺序
Python 的sorted()函数通过重新排列列表中项的顺序来创建一个新的列表。这与list.sort()方法改变列表顺序的方式相似。
这里是sorted(aList)和aList.sort()之间的重要区别:
-
aList.sort()方法修改了aList对象。它只能有意义的应用于list对象。 -
sorted(aList)函数从一个现有的项目集合中创建一个新的列表。源对象不会被改变。此外,各种集合都可以排序。一个set或一个dict的键可以按顺序排列。
有时候我们需要一个反转的序列。Python 为我们提供了两种方法来实现这一点:reversed()函数和带有反转索引的切片。
例如,考虑执行基转换到十六进制或二进制。以下是一个简单的转换函数:
from collections.abc import Iterator
def digits(x: int, base: int) -> Iterator[int]:
if x == 0: return
yield x % base
yield from digits(x // base, base)
这个函数使用递归从最低有效位到最高有效位生成数字。x % base将是x在基base中的最低有效位。
我们可以将其形式化为以下内容:
![(| {[] if x = 0 digits(x,b) = | x ([x mod b]+ digits(⌊b⌋,b) if x > 0](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file44.jpg)
在 Python 中,我们可以使用像base这样的长名称。这在传统数学中是不常见的,所以通常使用单个字母,如 b。
在某些情况下,我们希望数字以相反的顺序产生;最重要的数字先产生。我们可以用 reversed() 函数包装这个函数来交换数字的顺序:
def to_base(x: int, base: int) -> Iterator[int]:
return reversed(tuple(digits(x, base)))
reversed() 函数产生一个可迭代对象,但参数值必须是一个集合对象。然后该函数以相反的顺序从这个对象中产生项目。虽然字典可以被反转,但这个操作是字典键的迭代器。
我们可以用类似的方法使用切片,例如 tuple(digits(x, base))[::-1]。然而,切片不是一个迭代器。切片是由另一个已存在的对象构建的实体化对象。在这种情况下,对于如此小的值集合,为切片分配额外的内存是微不足道的。由于 reversed() 函数使用的内存比创建切片少,因此在处理更大的集合时可能更有优势。
“火星笑脸”,[:],是切片的一个边缘情况。表达式 some_list[:] 是通过取一个包含所有项目的切片来制作的列表的副本。
4.7 使用 enumerate() 包含序列号
Python 提供了 enumerate() 函数,可以将索引信息应用于序列或可迭代对象中的值。它执行一种特殊的包装,可以用作 unwrap(process(wrap(data))) 设计模式的一部分。
以下代码片段看起来如下:
>>> xi[:3]
[1.47, 1.5, 1.52]
>>> len(xi)
15
>>> id_values = list(enumerate(xi))
>>> id_values[:3]
[(0, 1.47), (1, 1.5), (2, 1.52)]
>>> len(id_values)
15
enumerate() 函数将每个输入项转换成一个包含序列号和原始项的配对。它类似于以下内容:
zip(range(len(source)), source)
enumerate() 的一个重要特性是结果是可迭代的,并且它与任何可迭代的输入一起工作。
当查看统计处理时,例如,enumerate() 函数在将单个值序列转换为一个更合适的时间序列时很有用,通过在每个样本前加上一个数字。
4.8 摘要
在本章中,我们看到了使用许多内置归约的详细方法。
我们使用了 any() 和 all() 来进行基本的逻辑处理。这些是使用简单运算符(如 or 或 and)进行归约的整洁示例。我们还查看了一些数值归约,如 len() 和 sum()。我们将这些函数应用于创建一些高级统计处理。我们将在第六章,递归和归约中回到这些归约。
我们还查看了一些内置映射。zip() 函数合并多个序列。这使我们开始考虑在结构化和展平更复杂的数据结构时使用它。正如我们将在后续章节的示例中看到的那样,嵌套数据在某些情况下很有帮助,而扁平数据在其他情况下很有帮助。enumerate() 函数将可迭代对象映射到一系列二元组。每个二元组在索引 [0] 处有序列号,在索引 [1] 处有原始值。
reversed()函数遍历序列对象中的项,并反转它们的原始顺序。某些算法在产生结果时更有效率,但我们希望以相反的顺序展示这些结果。sorted()函数基于对象的直接比较或使用键函数来比较每个对象的导出值来指定顺序。
在下一章中,我们将探讨使用附加函数作为参数来自定义其处理的映射和归约函数。接受函数作为参数的函数是我们更高阶函数的第一个例子。我们还将简要介绍返回函数作为结果的函数。
4.9 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包括一些练习的部分解决方案。这些作为提示,允许读者探索其他解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中提供的单元测试用例相同。读者应将书中的示例函数名替换为自己的解决方案以确认其有效性。
4.9.1 回文数
请参阅 Project Euler 问题编号 4,projecteuler.net/problem=4。这里的想法是找到一个具有特定属性的数字。在这个练习中,我们想要探讨一个数字是否是(或不是)回文数。
处理这个问题的一种方法是将数字分解为一系列十进制数字。然后我们可以检查这些十进制数字序列是否构成一个正确的回文。
请参阅使用 sorted()和 reversed()改变顺序部分,以获取从给定数字中提取十进制数字的代码片段。我们需要按照最显著数字在前的传统顺序来排列这些数字吗?如果生成的数字是反向顺序的,这有关系吗?
我们可以通过两种方式利用这个函数来检查回文:
-
比较数字序列中的位置,
d[0] == d[-1]。我们只需要比较数字的前半部分与后半部分。确保你的算法正确处理奇数个数字。 -
使用
reversed()创建第二个数字序列并比较这两个序列。这会浪费时间和内存,但可能更容易理解。
实现这两种替代方案,并比较产生的代码的清晰度和表达性。
4.9.2 牌手的双手
给定五张牌,有几种方式可以将这五张牌分成组。像扑克这样的游戏的完整牌组相当复杂。然而,一个简化的牌组提供了一种判断数据是否随机的工具。以下是我们要关注的牌型:
-
所有五张牌都匹配。
-
五张牌中有四张匹配。
-
五张牌中有三张匹配。与扑克不同,我们将忽略其他两张牌是否匹配。
-
有两个独立的匹配对。
-
两张牌匹配,形成一个单一的对。
-
没有牌匹配。
对于真正随机的数据,可以使用一些巧妙的数学计算概率。一个好的随机数生成器允许我们构建一个提供期望值的模拟。
要开始,我们需要一个函数来区分在 1 到 13(包含)的域中由五个随机值表示的手牌类型。输入是一个包含五个值的列表。输出应该是找到一个六种手牌中哪一种的数值代码。
这个函数的大致轮廓如下:
提示:对于更通用的扑克手牌识别,将数值排序成升序可能会有所帮助。对于这个简化的算法,将列表转换为Counter对象并检查各种牌面的频率会有所帮助。Counter类在collections模块中定义,包含许多其他有用的集合类。
每种手牌都可以通过以下形式的函数来识别:
def hand_flavor(cards: Sequence[int]) -> bool:
examine the cards
这让我们可以单独编写每个手牌检测算法。然后我们可以单独测试它们。这让我们有信心整体的手牌分类器将工作。这意味着你需要为单个分类器编写测试用例,以确保它们正常工作。
4.9.3 用 pairwise() 替换 legs()
在从序列中配对项目部分,我们探讨了设计一个legs()函数,从一系列航点中创建腿对的方案。
这个函数可以用itertools.pairwise()替换。在做出这个更改后,确定哪种实现更快。
4.9.4 将 legs() 扩展以包含处理
在从序列中配对项目部分,我们探讨了设计一个legs()函数,从一系列航点中创建腿对的方案。
一种设计替代方案是将一个函数纳入legs()的处理中,对创建的每个对进行计算。
该函数可能看起来如下:
RT = TypeVar("RT")
def legs(transform: Callable[[LL_Type, LL_Type], RT], lat_lon_iter: Iterator[LL_Type]) -> Iterator[RT]:
begin = next(lat_lon_iter)
for end in lat_lon_iter:
yield transform(begin, end)
begin = end
这改变了后续示例的设计。通过后续示例中的设计变更,看看这是否会引导出更简单、更容易理解的 Python 函数定义。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第五章:5
高阶函数
函数式编程范式的一个非常重要的特性是高阶函数。我们将探讨这三种高阶函数的类型:
-
接受函数作为其一个(或多个)参数的函数
-
返回函数的函数
-
接受一个函数并返回一个函数的函数,这是前两个特征的组合
我们将在本章中探讨内置的高阶函数。除了这些函数之外,我们将在介绍这些概念之后,在后续章节中探讨几个提供高阶函数的库模块。
接受函数并创建函数的函数包括复杂的可调用类以及函数装饰器。我们将推迟到第十二章《装饰器设计技术》讨论装饰器。
在本章中,我们将探讨以下函数:
-
max()和min() -
map() -
filter() -
iter() -
sorted()
此外,我们还将探讨operator模块中的itemgetter()函数。这个函数对于从序列中提取一个元素非常有用。
我们还将探讨我们可以用来简化使用高阶函数的 lambda 表达式。
max()和min()函数是归约函数;它们从集合中创建一个单一值。其他函数是映射函数。它们不会将输入归约为一个单一值。
max()、min()和sorted()函数既有默认行为,也有高阶函数行为。如果需要,可以通过key=参数提供一个函数。这些函数有有意义的默认行为。
map()和filter()函数将函数作为第一个位置参数。在这里,由于没有默认行为,所以需要函数。
itertools模块中有许多高阶函数。我们将在第八章《itertools 模块》、第九章《组合学中的 itertools – 排列和组合》中探讨这个模块。
此外,functools模块提供了一个通用的reduce()函数。我们将在第十章《functools 模块》中探讨这个函数,因为它需要更多的注意来使用。我们需要避免将一个低效的算法转变为过度处理的噩梦。
5.1 使用 max()和 min()查找极值
max()和min()函数各有两种生活。它们是应用于集合的简单函数,也是高阶函数。我们可以如下看到它们的默认行为:
>>> max(1, 2, 3)
3
>>> max((1,2,3,4))
4
两个函数都将接受一个不确定数量的参数。这些函数设计为也可以接受一个序列或可迭代对象作为唯一参数,并找到该可迭代对象的极大值(或极小值)。当应用于映射集合时,它们将找到最大(或最小)键值。
它们还做了更复杂的事情。假设我们有从第四章,处理集合中的示例中得到的行程数据。我们有一个函数可以生成一系列看起来如下所示的元组序列:
[
((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246),
((37.840832, -76.273834), (38.331501, -76.459503), 30.7382),
((38.331501, -76.459503), (38.845501, -76.537331), 31.0756),
((36.843334, -76.298668), (37.549, -76.331169), 42.3962),
((37.549, -76.331169), (38.330166, -76.458504), 47.2866),
((38.330166, -76.458504), (38.976334, -76.473503), 38.8019)
]
这个集合中的每个元组包含三个值:一个起始位置,一个结束位置,以及一个距离。位置以纬度和经度对给出。东经纬度是正数;这些是沿着美国东海岸的点,大约在 76°西。点之间的距离以海里为单位。
我们有三种方法从这些值序列中获取最大和最小距离。具体如下:
-
使用生成器函数提取距离。这将只给我们距离,因为我们已经丢弃了每个部分的另外两个属性。如果我们有任何基于纬度或经度的额外处理需求,这不会很好。
-
使用
unwrap(process(wrap()))模式。这将给我们最长和最短距离的路段。从这些路段中,我们可以根据需要提取距离或点。 -
使用
max()和min()函数作为高阶函数,插入一个用于提取重要距离值的函数。这将保留所有原始对象及其属性。
为了提供上下文,以下脚本构建了整个行程:
>>> from Chapter04.ch04_ex1 import (
... floats_from_pair, float_lat_lon, row_iter_kml, haversine, legs
... )
>>> import urllib.request
>>> data = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(data) as source:
... path = floats_from_pair(float_lat_lon(row_iter_kml(source)))
... trip = list(
... (start, end, round(haversine(start, end), 4))
... for start, end in legs(path)
... )
结果的 trip 对象是一个列表对象,包含单个路段。每个路段是一个包含起点、终点和距离的三元组,使用 haversine() 函数计算得出。leg() 函数从原始 KML 文件中点的整体路径创建起点-终点对。list() 函数从惰性生成器中消耗值,以具体化路段列表。
一旦我们有了 trip 对象,我们可以提取距离并计算这些距离的最大值和最小值。使用生成器函数执行此操作的代码如下:
>>> longest = max(dist for start, end, dist in trip)
>>> shortest = min(dist for start, end, dist in trip)
我们使用生成器函数从行程元组的每个部分中提取相关项目。我们必须重复生成器表达式,因为表达式 dist for start, end, dist in trip 只能被消耗一次。
下面是基于比之前展示的数据集更大的数据集的结果:
>>> longest
129.7748
>>> shortest
0.1731
可以参考第二章,介绍基本功能概念,了解 wrap-process-unwrap 设计模式的示例。
下面是将 unwrap(process(wrap())) 模式应用于这些数据的一个版本:
from collections.abc import Iterator, Iterable
from typing import Any
def wrap(leg_iter: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
return ((leg[2], leg) for leg in leg_iter)
def unwrap(dist_leg: tuple[Any, Any]) -> Any:
distance, leg = dist_leg
return leg
我们可以使用这些函数如下:
>>> longest = unwrap(max(wrap(trip)))
>>> longest
((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
>>> short = unwrap(min(wrap(trip)))
>>> short
((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
最后且最重要的形式使用了 max() 和 min() 函数的高阶函数特性。我们首先定义一个辅助函数,然后使用它通过执行以下代码片段来将腿的集合缩减到所需的摘要:
def by_dist(leg: tuple[Any, Any, Any]) -> Any:
lat, lon, dist = leg
return dist
我们可以将此函数用作内置 max() 函数的 key= 参数值。它看起来像这样:
>>> longest = max(trip, key=by_dist)
>>> longest
((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
>>> short = min(trip, key=by_dist)
>>> short
((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
by_dist() 函数将每个腿元组中的三个项目分开,并返回距离项。我们将使用它与 max() 和 min() 函数一起使用。
max() 和 min() 函数都接受一个可迭代的和一个函数作为参数。关键字参数 key= 被许多 Python 的高阶函数用来提供一个函数,该函数将被用来提取必要的键值。
5.1.1 使用 Python lambda 形式
在许多情况下,辅助函数的定义似乎需要太多的代码。通常,我们可以将 key= 函数简化为一个单一的表达式。写两个 def 和 return 语句来包装一个单一表达式可能看起来有些浪费。
Python 提供了 lambda 形式作为一种简化使用高阶函数的方法。lambda 形式允许我们定义一个小的、匿名函数。函数体仅限于一个表达式。
以下是一个使用简单的 lambda 表达式作为 key= 函数的示例:
>>> longest = max(trip, key=lambda leg: leg[2])
>>> shortest = min(trip, key=lambda leg: leg[2])
我们使用的 lambda 将会接收到序列中的一个项目;在这种情况下,每个三元组腿将传递给 lambda。lambda 参数变量 leg 被分配,表达式 leg[2] 被评估,从三元组中提取距离。
在 lambda 正好使用一次的情况下,这种形式是理想的。当重用 lambda 时,避免复制和粘贴很重要。在上面的例子中,lambda 被重复,这可能导致潜在的软件维护噩梦。那么替代方案是什么?
我们可以通过这样做将 lambda 分配给变量:by_dist() 函数将每个腿元组中的三个项目分开,并返回距离项。我们将使用它与 max() 和 min() 函数一起使用。
start = lambda x: x[0]
end = lambda x: x[1]
dist = lambda x: x[2]
每个这些 lambda 形式都是一个可调用的对象,类似于定义的函数。它们可以像函数一样使用。
以下是在交互提示符中的示例:
>>> longest = ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
>>> dist(longest)
129.7748
避免这种技术的两个原因如下:
-
PEP 8,Python 代码的风格指南,建议不要将 lambda 对象分配给变量。有关更多信息,请参阅
peps.python.org/pep-0008/。 -
operator模块提供了一个通用的项获取器,itemgetter()。这是一个高阶函数,它返回一个我们可以用来替代 lambda 对象的函数。
为了扩展这个例子,我们将看看如何获取起点或终点的纬度或经度值。
以下是一个交互会话的延续:
>>> from operator import itemgetter
>>> start = itemgetter(0)
>>> start(longest)
(27.154167, -80.195663)
>>> lat = itemgetter(0)
>>> lon = itemgetter(1)
>>> lat(start(longest))
27.154167
我们已从 operator 模块中导入了 itemgetter() 函数。该函数返回的值是一个函数,它将从序列中抓取所需的项目。在示例的第一部分,start() 函数将从序列中提取项目 0。
类似地,lat() 和 lon() 函数是由 itemgetter() 函数创建的。请注意,数据结构中嵌套元组的复杂性必须与 itemgetter() 函数仔细对应。
使用 lambda 对象或 itemgetter() 函数作为提取字段的方法,与定义 typing.NamedTuple 类或数据类相比,没有明显的优势。使用 lambda(或者更好的是 itemgetter() 函数)确实允许代码依赖于前缀函数符号,这在函数式编程环境中可能更容易阅读。我们可以通过使用 operator.attrgetter 函数从 typing.NamedTuple 类或数据类中提取特定属性来获得类似的优势。使用 attrgetter 会复制一个名称。例如,具有 lat 属性的 typing.NamedTuple 类也可以使用 attrgetter('lat');这可能会在重构时稍微难以找到所有对属性的引用。
5.1.2 Lambda 表达式和 lambda 演算
如果 Python 是一种纯粹的函数式编程语言,那么就有必要解释 Church 的 lambda 演算,以及 Haskell Curry 发明的我们称之为柯里化的技术。然而,Python 并没有严格遵循 lambda 演算。函数不会被柯里化以简化为单参数 lambda 形式。
Python 的 lambda 表达式不仅限于单参数函数。它们可以有任意数量的参数。然而,它们被限制为单个表达式。
我们可以使用 functools.partial 函数来实现柯里化。我们将这个内容留到第十章《Functools 模块》中讲解。
5.2 使用 map() 函数将函数应用于集合
标量函数将域中的值映射到值域中。以 math.sqrt() 函数为例,我们正在查看一个从浮点值 x 映射到另一个浮点值 y = sqrt(x) 的映射,使得 y² = x。域被限制为 math 模块中的非负值。当使用 cmath 模块时,任何数字都可以使用,结果可以是复数。
map() 函数表达了一个类似的概念;它将一个集合中的值映射到另一个集合中。它确保给定的函数被用来将域集合中的每个单独的项目映射到值集合中——这是将内置函数应用于数据集合的理想方式。
我们的第一个例子涉及解析一段文本以获取一系列数字。假设我们有以下文本块:
>>> text= """\
... 2 3 5 7 11 13 17 19 23 29
... 31 37 41 43 47 53 59 61 67 71
... 73 79 83 89 97 101 103 107 109 113
... 127 131 137 139 149 151 157 163 167 173
... 179 181 191 193 197 199 211 223 227 229
... """
我们可以使用以下生成器函数重新结构化这段文本:
>>> data = list(
... v
... for line in text.splitlines()
... for v in line.split()
... )
这将把文本拆分成行。对于每一行,它将行拆分为空格分隔的单词,并遍历每个生成的字符串。结果如下所示:
[’2’, ’3’, ’5’, ’7’, ’11’, ’13’, ’17’, ’19’, ’23’, ’29’,
’31’, ’37’, ’41’, ’43’, ’47’, ’53’, ’59’, ’61’, ’67’, ’71’,
’73’, ’79’, ’83’, ’89’, ’97’, ’101’, ’103’, ’107’, ’109’, ’113’,
’127’, ’131’, ’137’, ’139’, ’149’, ’151’, ’157’, ’163’, ’167’,
’173’, ’179’, ’181’, ’191’, ’193’, ’197’, ’199’, ’211’, ’223’,
’227’, ’229’]
我们仍然需要将 int() 函数应用于每个字符串值。这正是 map() 函数大显身手的地方。看看下面的代码片段:
>>> list(map(int, data))
[2, 3, 5, 7, 11, 13, 17, 19, ..., 229]
map()函数将int()函数应用于集合中的每个值。结果是数字序列而不是字符串序列。
map()函数的结果是可迭代的。map()函数可以处理任何类型的可迭代对象。
这里提出的想法是,任何 Python 函数都可以通过map()函数应用于集合中的项。在这个 map 处理上下文中,有很多内置函数可以使用。
5.2.1 使用 lambda 形式和 map()
假设我们想要将旅行的距离从海里转换为英里。我们想要将每段的距离乘以6076.12/5280,即1.150780。
我们将依赖多个itemgetter函数从数据结构中提取数据。我们可以将提取与计算新值相结合。我们可以使用以下方式通过map()函数进行此计算:
>>> from operator import itemgetter
>>> start = itemgetter(0)
>>> end = itemgetter(1)
>>> dist = itemgetter(2)
>>> sm_trip = map(
... lambda x: (start(x), end(x), dist(x) * 6076.12 / 5280),
... trip
... )
我们定义了一个 lambda 表达式,该表达式将由map()函数应用于旅行的每一段。这个 lambda 表达式将使用itemgetter函数从每一段的元组中分离出起点、终点和距离值。它将计算一个修订后的距离,并从起点、终点和英里距离中组装一个新的段元组。
这正好像以下生成器表达式:
>>> sm_trip = (
... (start(x), end(x), dist(x) * 6076.12 / 5280)
... for x in trip
... )
我们对生成器表达式中的每个项目都进行了相同的处理。
使用内置的map()函数或生成器表达式将产生相同的结果,并且几乎具有相同的性能。使用 lambda、命名元组、定义的函数、operator.itemgetter()函数或生成器表达式的选择完全是关于如何使结果应用程序简洁且易于理解。
5.2.2 使用 map()与多个序列
有时,我们会有两个需要相互并行对齐的数据集合。在第四章,处理集合中,我们看到了zip()函数如何将两个序列交织在一起以创建一对序列。在许多情况下,我们实际上试图做的是以下这样:
map(function, zip(one_iterable, another_iterable))
我们正在从两个(或更多)并行可迭代对象中创建参数元组,并将函数应用于参数元组。这可能有些尴尬,因为给定函数function()的参数将是一个单一的二元组;参数值将不会应用于每个参数。
因此,我们可以考虑使用以下技术将元组分解为两个单独的参数:
(
function(x, y)
for x, y in zip(one_iterable, another_iterable)
)
在这里,我们用等价的生成器表达式替换了map()函数。for x, y将二元组分解,以便我们可以将它们应用于函数的每个参数。
有一种更好的方法已经可供我们使用。让我们看看这种替代方法的具体例子。
在第四章,处理集合中,我们查看从 XML 文件中提取的航迹数据,作为一系列航点。我们需要从这个航点列表中创建表示每段起止点的航段。
下面的简化版本使用了应用于序列两个切片的zip()函数:
>>> waypoints = range(4)
>>> zip(waypoints, waypoints[1:])
<zip object at ...>
>>> list(zip(waypoints, waypoints[1:]))
[(0, 1), (1, 2), (2, 3)]
我们创建了一个从单个扁平列表中抽取的成对序列。每一对将包含两个相邻的值。zip()函数在较短的列表耗尽时停止。这种zip(x, x[1:])模式仅适用于已物化的序列和由range()函数创建的可迭代对象。它不适用于可迭代对象,因为切片操作未实现。
我们创建了成对,以便我们可以将haversine()函数应用于每一对,以计算路径上两点之间的距离。以下是如何在一系列步骤中看起来:
>>> from Chapter04.ch04_ex1 import (
... floats_from_pair, float_lat_lon, row_iter_kml, haversine
... )
>>> import urllib.request
>>> data = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(data) as source:
... path_gen = floats_from_pair(
... float_lat_lon(row_iter_kml(source)))
... path = list(path_gen)
>>> distances_1 = map(
... lambda s_e: (s_e[0], s_e[1], haversine(*s_e)),
... zip(path, path[1:])
... )
我们已经构建了一个航点列表,并用path变量标记。这是一个有序的纬度-经度对序列。由于我们将使用zip(path, path[1:])设计模式,我们必须有一个已物化的序列,而不是一个可迭代对象。
zip()函数的结果将是具有起始和结束位置的成对元素。我们希望我们的输出是一个包含起始、结束和距离的三元组。我们使用的 lambda 表达式将分解原始的起始-结束二元组,并从起始、结束和距离创建一个新的三元组。
我们可以通过使用map()函数的巧妙特性来简化这一点,如下所示:
>>> distances_2 = map(
... lambda s, e: (s, e, haversine(s, e)),
... path, path[1:]
... )
注意,我们已经向map()函数提供了一个 lambda 对象和两个可迭代对象。map()函数将从每个可迭代对象中获取下一个项目,并将这两个值作为给定函数的参数。在这种情况下,给定函数是一个 lambda,它从起始、结束和距离创建所需的三个元素的三元组。
map()函数的正式定义表明它将对不定数量的可迭代对象进行星图处理。它将从每个可迭代对象中获取项目,为给定函数创建一个参数值的元组。这使我们免去了添加zip函数来组合序列的需要。
5.3 使用 filter()函数传递或拒绝数据
filter()函数的职责是使用并应用一个称为谓词的决策函数到集合中的每个值。当谓词函数的结果为真时,值被传递;否则,值被拒绝。itertools模块包括filterfalse()作为这一主题的变体。请参阅第八章,itertools 模块,以了解itertools模块的filterfalse()函数的使用。
我们可能将此应用于我们的航迹数据,以创建超过 50 海里长的航段子集,如下所示:
>>> long_legs = list(
... filter(lambda leg: dist(leg) >= 50, trip)
... )
对于长腿,谓词 lambda 将是True,将被通过。短腿将被拒绝。输出包含通过这个距离测试的 14 条腿。
这种处理明显将 filter 规则(lambda leg: dist(leg) >= 50)与其他任何创建 trip 对象或分析长腿的处理分离。
对于另一个简单的例子,看看以下代码片段:
>>> filter(lambda x: x % 3 == 0 or x % 5 == 0, range(10))
<filter object at ...>
>>> sum(_)
23
我们定义了一个小的 lambda 函数来检查一个数是否是 3 的倍数或 5 的倍数。我们将该函数应用于可迭代对象range(10)。结果是满足决策规则的数字的可迭代序列。
对于 lambda 为True的数字,它们是[0, 3, 5, 6, 9],因此这些值被通过。由于 lambda 对于所有其他数字都是False,它们被拒绝。
_变量是 Python 的 REPL 的一个特殊功能。它隐式地设置为表达式的结果。在上一个例子中,filter(...)的结果被分配给_。在下一行,sum(_)消耗了filter(...)的结果。
这仅在 REPL 中可用,并且存在是为了在我们交互式探索复杂函数时节省我们一些输入。
这也可以通过执行以下代码使用生成器表达式来完成:
>>> list(x for x in range(10) if x % 3 == 0 or x % 5 == 0)
[0, 3, 5, 6, 9]
我们可以使用以下集合推导表示法来形式化这一点:

这意味着我们正在构建一个包含 x 值的集合,其中 x 在range(10)中,且x % 3 == 0或x % 5 == 0。filter()函数和形式化的数学集合推导之间有一个非常优雅的对称性。
我们经常希望使用定义好的函数而不是 lambda 形式来使用filter()函数。以下是一个重用之前定义的谓词的例子:
>>> from Chapter02.ch02_ex1 import isprimeg
>>> list(filter(isprimeg, range(100)))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
在这个例子中,我们从一个名为isprimeg()的其他模块中导入了一个函数。然后我们将此函数应用于一组值,以通过素数并从集合中拒绝任何非素数。
这可能是一种生成素数表的非常低效的方法。这种表面的简单性是律师所说的吸引人的诱惑。它看起来可能很有趣,但根本无法扩展。isprimeg()函数为每个新值重复所有的测试工作。某种缓存对于提供素性测试的重做是必不可少的。一个更好的算法是埃拉托斯特尼筛法;这个算法保留之前找到的素数,并使用它们来防止重新计算。
关于素性检验的更多信息,以及寻找小素数的此算法,请参阅 primes.utm.edu/prove/prove2_1.html。
5.3.1 使用 filter()识别异常值
在上一章中,我们定义了一些有用的统计函数来计算平均值和标准差以及归一化一个值。我们可以使用这些函数来定位旅行数据中的异常值。我们可以做的是将mean()和stdev()函数应用于旅行每一段的距离值,以得到种群的平均值和标准差。
然后,我们可以使用z()函数计算每个部分的归一化值。如果归一化值大于 3,数据可能远离平均值。如果我们拒绝这些异常值,我们将得到一个更均匀的数据集,这种数据集不太可能包含报告或测量错误。
以下是我们如何处理这种情况的方法:
>>> from Chapter04.ch04_ex3 import mean, stdev, z
>>> dist_data = list(map(dist, trip))
>>> μ_d = mean(dist_data)
>>> σ_d = stdev(dist_data)
>>> outlier = lambda leg: abs(z(dist(leg), μ_d, σ_d)) > 3
>>> list(filter(outlier, trip))
我们已经将距离函数映射到trip集合中的每个部分。dist()函数是由itemgetter(2)创建的函数。由于我们将对结果进行多项操作,我们必须创建一个list对象。我们不能依赖于迭代器,因为在这个步骤序列中的第一个函数将消耗迭代器的所有值。然后我们可以使用这个提取来计算种群统计量μ_d和σ_d,即平均值和标准差。
给定平均值和标准差值,我们使用了outlier lambda 来过滤我们的数据。如果归一化值过大,数据就是一个异常值。“远离平均值”的阈值可以根据分布类型而变化。对于正态分布,一个值在平均值三个标准差范围内的概率是 0.997。
list(filter(outlier, trip))的结果是一个列表,其中两条腿的长度与种群中其他腿相比非常长。平均距离约为 34 nm,标准差为 24 nm。
我们能够将一个相当复杂的问题分解成若干个独立的函数,每个函数都可以单独轻松测试。我们的处理过程是简单函数的组合。这可以导致简洁、表达力强的函数式编程。
5.4 带有哨兵值的 iter()函数
内置的iter()函数创建了一个集合类对象的迭代器。list、dict和set类都使用iter()函数为底层集合中的项提供一个迭代器对象。在大多数情况下,我们将允许for语句隐式地完成这项工作。然而,在少数情况下,我们需要显式地创建一个迭代器。一个例子是将集合的头和尾分开。
iter() 函数的其他用途包括构建迭代器以消耗由可调用对象(例如,函数)创建的值,直到找到哨兵值。这个特性有时与文件的 read() 方法一起使用,以消耗项目,直到找到某些行尾或文件结束哨兵值。表达式 iter(file.read, '\n') 将评估给定的函数,直到找到哨兵值 '\n'。这必须谨慎使用:如果未找到哨兵值,它可能会无限期地读取空字符串。
向 iter() 提供一个可调用的函数可能有点挑战性,因为我们提供的函数必须内部维护一些状态。这在函数式程序中通常被视为不希望的。
然而,隐藏状态是打开文件的一个特性:每个 read() 或 readline() 方法都会将文件的内部状态推进到下一个字符或下一行。
显式迭代的另一个例子是可变集合对象的 pop() 方法如何对集合对象进行状态性的更改。以下是一个使用 pop() 方法的示例:
>>> source = [1, 2, 3, None, 4, 5, 6]
>>> tail = iter(source.pop, None)
>>> list(tail)
[6, 5, 4]
tail 变量被设置为对列表 [1, 2, 3, None, 4, 5, 6] 的迭代器进行迭代,该迭代器将由 pop() 函数遍历。pop() 的默认行为是 pop(-1);也就是说,元素以相反的顺序弹出。这会对列表对象进行状态性更改:每次调用 pop() 时,都会移除项目,并修改列表。当找到哨兵值时,迭代器停止返回值。如果未找到哨兵值,这将引发 IndexError 异常。
这种内部状态管理是我们希望避免的。因此,我们不会试图为这个特性编造用途。
5.5 使用 sorted() 对数据进行排序
当我们需要以定义的顺序产生结果时,Python 给我们提供了两个选择。我们可以创建一个列表对象,并使用 list.sort() 方法对项目进行排序。另一种选择是使用 sorted() 函数。这个函数可以与任何可迭代对象一起使用,但在排序操作中它创建了一个最终的 list 对象。
sorted() 函数可以以两种方式使用。它可以简单地应用于集合。它也可以用作高阶函数,使用 key= 参数。
假设我们有了来自第四章,处理集合的示例中的旅行数据。我们有一个函数,它将生成一系列包含旅行每一段的起始位置、结束位置和距离的元组。数据如下所示:
[
((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246),
((37.840832, -76.273834), (38.331501, -76.459503), 30.7382),
((38.331501, -76.459503), (38.845501, -76.537331), 31.0756),
((36.843334, -76.298668), (37.549, -76.331169), 42.3962),
((37.549, -76.331169), (38.330166, -76.458504), 47.2866),
((38.330166, -76.458504), (38.976334, -76.473503), 38.8019)
]
我们可以通过以下交互查看 sorted() 函数的默认行为:
>>> sorted(dist(x) for x in trip)
[0.1731, 0.1898, 1.4235, 4.3155, ... 86.2095, 115.1751, 129.7748]
我们使用生成器表达式 (dist(x) for x in trip) 从旅行数据中提取距离。dist() 函数是由 itemgetter(2) 创建的函数。然后我们对这个可迭代数字集合进行排序,以获取从 0.17 nm 到 129.77 nm 的距离。
如果我们想在原始的三元组中保留腿和距离,我们可以让sorted()函数应用一个key=函数来确定如何对元组进行排序,如下面的代码片段所示:
>>> sorted(trip, key=dist)
[((35.505665, -76.653664), (35.508335, -76.654999), 0.1731), ...
我们已经对旅行数据进行了排序,使用dist()函数从每个元组中提取距离。前面展示的dist()函数是由itemgetter()函数创建的,如下所示:
>>> from operator import itemgetter
>>> dist = itemgetter(2)
作为替代,我们也可以使用lambda leg: leg[2]从元组中选择特定的值。提供一个名称dist可以使它更清楚地表明正在从元组中选择哪个项。
5.6 高阶函数编写概述
我们将探讨设计我们自己的高阶函数。在深入研究一些更复杂的设计模式之前,我们将总结一些过程。我们将从查看常见的数据转换开始,如下所示:
-
将对象包装以创建更复杂的对象
-
将复杂对象解包为其组件
-
展平一个结构
-
结构化扁平序列
这些模式有助于可视化 Python 中高阶函数的设计方式。
这也有助于回忆起一个Callable类定义是一个返回可调用对象的函数。我们将把它视为一种将配置参数注入灵活函数的方法。
我们将推迟对装饰器的深入考虑,直到第十二章,装饰器设计技术。装饰器也是一个高阶函数,但它消耗一个函数并返回另一个,这使得它比本章中的示例更复杂。我们将从开发高度定制的map()和filter()版本开始。
5.7 编写高阶映射和过滤器
Python 的两个内置高阶函数map()和filter()通常可以处理我们可能想要投掷给它们的几乎所有内容。在一般情况下很难优化它们以实现更高的性能。我们将在第十四章,多进程、多线程和 concurrent.futures 模块中查看类似函数,如imap()。
我们有三种主要等效的方式来表达映射。假设我们有一个函数f(x)和一个对象集合C。我们可以计算从集合 C 中的域值到范围值的映射的方法如下:
-
map()函数:map(f, C) -
生成器表达式:
(f(x) for x in C) -
带有
yield语句的生成器函数:from collections.abc import Callable, Iterable, Iterator from typing import Any def mymap(f: Callable[[Any], Any], C: Iterable[Any]) -> Iterator[Any]: for x in C: yield f(x)这个
mymap()函数可以用作一个表达式,其中包含要应用的功能和可迭代的数据源:mymap(f, C)
同样,我们有三种方法可以将过滤器函数应用于集合,它们都是等效的:
-
filter()函数:filter(f, C) -
生成器表达式:
(x for x in C if f(x)) -
带有
yield语句的生成器函数:from collections.abc import Callable, Iterable, Iterator from typing import Any def myfilter(f: Callable[[Any], bool], C: Iterable[Any]) -> Iterator[Any]: for x in C: if f(x): yield x这个
myfilter()函数可以用作一个表达式,其中包含要应用的功能和可迭代的数据源:myfilter(f, C)
存在一些微小的性能差异;通常map()和filter()函数是最快的。更重要的是,有不同类型的扩展适合这些映射和过滤设计,如下所示:
-
如果我们需要修改处理过程,我们可以创建一个更复杂的函数
g(x),该函数应用于每个元素。这是最通用的方法,适用于所有三种设计。这是我们功能设计投入的大部分精力所在。我们可以在现有的f(x)周围定义我们的新函数,或者我们发现我们需要重构原始函数。在所有情况下,这种设计努力似乎能带来最大的好处。 -
我们可以调整生成器表达式或生成器函数内部的
for循环。一个明显的调整是将映射和过滤合并为一个操作,通过在生成器表达式中扩展一个if子句来实现。我们还可以合并mymap()和myfilter()函数以合并映射和过滤。这需要小心,以确保生成的函数不是功能杂糅。
随着软件的演变和成熟,经常发生改变处理循环中数据结构的深刻变化。我们有许多设计模式,包括包装、解包(或提取)、展平和结构化。我们在前面的章节中查看了一些这些技术。
在接下来的章节中,我们将探讨设计我们自己的高阶函数的方法。我们将从在应用映射函数的同时解包复杂数据开始。对于每个示例,重要的是要看看复杂性从何而来,并决定生成的代码是否真的简洁且具有表现力。
5.7.1 在映射时解包数据
当我们使用像(f(x) for x, y in C)这样的结构时,我们使用for语句的多个赋值特性来解包一个多值元组,然后应用一个函数。整个表达式是一个映射。这是 Python 中常见的优化,用于改变结构并应用函数。
我们将使用来自第四章,处理集合的行程数据。以下是一个在映射时解包的具体示例:
from collections.abc import Callable, Iterable, Iterator
from typing import Any, TypeAlias
Conv_F: TypeAlias = Callable[[float], float]
Leg: TypeAlias = tuple[Any, Any, float]
def convert(
conversion: Conv_F,
trip: Iterable[Leg]) -> Iterator[float]:
return (
conversion(distance)
for start, end, distance in trip
)
这个高阶函数将由我们可以应用于原始数据的转换函数支持,如下所示:
from collections.abc import Callable
from typing import TypeAlias
Conversion: TypeAlias = Callable[[float], float]
to_miles: Conversion = lambda nm: nm * 6076.12 / 5280
to_km: Conversion = lambda nm: nm * 1.852
to_nm: Conversion = lambda nm: nm
这些已经被定义为 lambda 表达式并分配给变量。一些静态分析工具可能会对此提出异议,因为 PEP-8 不赞成这样做。
以下展示了我们如何提取距离并应用一个转换函数:
>>> convert(to_miles, trip)
<generator object ...>
>>> miles = list(convert(to_miles, trip))
>>> trip[0]
((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246)
>>> miles[0]
20.397120559090908
>>> trip[-1]
((38.330166, -76.458504), (38.976334, -76.473503), 38.8019)
>>> miles[-1]
44.652462240151515
在我们解包的过程中,结果将是一系列浮点值。结果如下:
[20.397120559090908, 35.37291511060606, ..., 44.652462240151515]
这个convert()函数非常特定于我们的起点-终点-距离行程数据结构,因为for语句分解了一个特定的三元组。
我们可以为这种解包-映射设计模式构建一个更通用的解决方案。它稍微复杂一些。首先,我们需要通用分解函数,如下面的代码片段所示:
from collections.abc import Callable
from operator import itemgetter
from typing import TypeAlias
Selector: TypeAlias = Callable[[tuple[Any, ...]], Any]
fst: Selector = itemgetter(0)
snd: Selector = itemgetter(1)
sel2: Selector = itemgetter(2)
我们希望能够表达f(sel2(s_e_d))对于s_e_d在trip中的情况。这涉及到函数组合;我们正在组合一个函数,例如to_miles(),和一个选择器,例如sel2()。
更具描述性的名称通常比通用名称更有用。我们将把重命名留给读者作为练习。我们可以使用另一个 lambda 在 Python 中表达函数组合,如下所示:
from collections.abc import Callable
to_miles_sel2: Callable[[tuple[Any, Any, float]], float] = (
lambda s_e_d: to_miles(sel2(s_e_d))
)
这给我们提供了一个更长但更专业的解包和映射版本,如下所示:
>>> miles2 = list(
... to_miles_sel2(s_e_d) for s_e_d in trip
... )
我们可以将高阶convert()函数与这个生成器表达式进行比较。两者都应用了一系列转换。convert()函数“隐藏”了一个处理细节——将元组作为起始点、结束点和距离的组合——通过一个分解元组的for语句。这个表达式通过将sel2()函数包含在复合函数定义中,暴露了这种分解。
两者在任何一个衡量标准上都不是“更好”。它们代表了两种展示或隐藏细节的方法。在特定的应用开发环境中,展示(或隐藏)可能更受欢迎。
同样的设计原则也适用于创建混合过滤器以及映射。我们将在返回的生成器表达式的if子句中应用过滤器。
我们可以将映射和过滤结合起来创建更复杂的函数。虽然创建更复杂的函数很有吸引力,但并不总是有价值的。一个复杂的函数可能无法超越嵌套使用map()和filter()函数的性能。通常,我们只想创建更复杂的函数,如果它能封装一个概念并使软件更容易理解。
5.7.2 在映射时包装额外数据
当我们使用((f(x), x) for x in C)这样的结构时,我们已经使用了包装来创建一个多值元组,同时应用了转换映射。这是一种常见的技巧,通过创建更大的结构来保存派生结果。这有避免重新计算的好处,同时避免了具有内部状态变化的复杂对象的责任。在这种情况下,状态变化是结构性的,并且非常明显。
这部分内容是第四章,使用集合中展示的示例的一部分,用于从点的路径创建行程数据。代码看起来是这样的:
>>> from Chapter04.ch04_ex1 import (
... floats_from_pair, float_lat_lon, row_iter_kml, haversine, legs
... )
>>> import urllib.request
>>> data = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(data) as source:
... path = floats_from_pair(float_lat_lon(row_iter_kml(source)))
... trip = tuple(
... (start, end, round(haversine(start, end), 4))
... for start, end in legs(path)
... )
我们可以稍作修改,创建一个将包装与其他函数分离的高阶函数。我们可以重构这个设计,创建一个构造包含原始元组和距离的新元组的函数。这个函数可以定义如下:
from collections.abc import Callable, Iterable, Iterator
from typing import TypeAlias
Point: TypeAlias = tuple[float, float]
Leg_Raw: TypeAlias = tuple[Point, Point]
Point_Func: TypeAlias = Callable[[Point, Point], float]
Leg_D: TypeAlias = tuple[Point, Point, float]
def cons_distance(
distance: Point_Func,
legs_iter: Iterable[Leg_Raw]) -> Iterator[Leg_D]:
return (
(start, end, round(distance(start,end), 4))
for start, end in legs_iter
)
这个函数将每条腿分解成两个变量,start 和 end。这些变量将是 Point 实例,定义为两个浮点值的元组。这些将用于给定的 distance() 函数来计算两点之间的距离。该函数是一个可调用的对象,接受两个 Point 对象并返回一个浮点结果。结果将构建一个包含原始两个 Point 对象以及计算出的浮点结果的三个元组。
然后,我们可以重新编写我们的行程分配,应用 haversine() 函数来计算距离,如下所示:
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... path = floats_from_pair(
... float_lat_lon(row_iter_kml(source))
... )
... trip2 = tuple(
... cons_distance(haversine, legs(iter(path)))
... )
我们用一个高阶函数 cons_distance() 替换了生成器表达式。该函数不仅接受一个函数作为参数,还返回一个生成器表达式。在某些应用中,这个更大、更复杂的处理步骤是一种省略不必要细节的有帮助的方法。
在 第十章,Functools 模块 中,我们将展示如何使用 partial() 函数为 haversine() 函数的 R 参数设置一个值,这改变了距离计算的计量单位。
5.7.3 在映射时扁平化数据
在 第四章,处理集合 中,我们研究了将嵌套元组结构扁平化为单个可迭代对象的算法。当时我们的目标仅仅是重新结构化一些数据而不进行任何实际的处理。我们可以创建混合解决方案,将函数与扁平化操作相结合。
假设我们有一段文本,我们想将其转换为扁平的数字序列。文本如下所示:
>>> text = """2 3 5 7 11 13 17 19 23 29
... 31 37 41 43 47 53 59 61 67 71
... 73 79 83 89 97 101 103 107 109 113
... 127 131 137 139 149 151 157 163 167 173
... 179 181 191 193 197 199 211 223 227 229
... """
每一行是 10 个数字的一块。我们需要解开行,以创建一个扁平的数字序列。
这是通过一个两部分的生成器函数完成的,如下所示:
>>> data = list(
... v
... for line in text.splitlines()
... for v in line.split()
... )
这将把文本分割成行,并遍历每一行。它将每一行分割成单词,并遍历每个单词。这个输出的结果是字符串列表,如下所示:
[’2’, ’3’, ’5’, ’7’, ’11’, ’13’, ’17’, ’19’, ’23’, ’29’, ’31’, ’37’,
’41’, ’43’, ’47’, ’53’, ’59’, ’61’, ’67’, ’71’, ’73’, ’79’, ’83’,
’89’, ’97’, ’101’, ’103’, ’107’, ’109’, ’113’, ’127’, ’131’, ’137’,
’139’, ’149’, ’151’, ’157’, ’163’, ’167’, ’173’, ’179’, ’181’, ’191’,
’193’, ’197’, ’199’, ’211’, ’223’, ’227’, ’229’]
对于这个特定的文本,有一个优化,我们将把它留给读者作为练习。
为了将字符串转换为数字,我们必须应用一个转换函数,并使用以下代码片段解开从原始格式中阻塞的结构:
from collections.abc import Callable, Iterator
from typing import TypeAlias
Num_Conv: TypeAlias = Callable[[str], float]
def numbers_from_rows(
conversion: Num_Conv,
text: str) -> Iterator[float]:
return (
conversion(value)
for line in text.splitlines()
for value in line.split()
)
这个函数有一个 conversion 参数,它是一个应用于将要发出的每个值的函数。这些值是通过使用之前显示的算法进行扁平化创建的。
我们可以在以下类型的表达式中使用这个 numbers_from_rows() 函数:
>>> list(numbers_from_rows(float, text))
在这里,我们使用了内置的 float() 函数从文本块中创建一个浮点数值列表。
我们有许多使用高阶函数和生成器表达式的混合替代方案。例如,我们可能这样表达:
>>> text = (value
... for line in text.splitlines()
... for value in line.split()
... )
>>> numbers = map(float, text)
>>> list(numbers)
这可能有助于我们理解算法的整体结构。这个原则被称为分块:我们用一个有意义的名字总结函数的细节。有了这个总结,细节被抽象化,我们可以在更大的上下文中将函数作为一个小的概念来工作。虽然我们经常使用高阶函数,但有时生成器表达式可能更清晰。
5.7.4 在过滤时结构化数据
前三个示例将额外的处理与映射相结合。将处理与过滤相结合似乎不如与映射相结合那样具有表现力。我们将详细查看一个示例以表明,尽管它很有用,但它似乎没有与映射和处理相结合那样有说服力的用例。
在 第四章,处理集合 中,我们探讨了结构化算法。我们可以轻松地将过滤与结构化算法组合成一个单一、复杂的函数。以下是我们首选的将可迭代对象的输出分组的功能版本:
from collections.abc import Iterator
from typing import TypeVar
ItemT = TypeVar("ItemT")
def group_by_iter(
n: int,
iterable: Iterator[ItemT]
) -> Iterator[tuple[ItemT, ...]]:
def group(n: int, iterable: Iterator[ItemT]) -> Iterator[ItemT]:
for i in range(n):
try:
yield next(iterable)
except StopIteration:
return
while row := tuple(group(n, iterable)):
yield row
这将尝试从可迭代对象中组装一个包含 n 个项目的元组。如果元组中有任何项目,它们将作为结果可迭代对象的一部分产生。原则上,函数随后递归地对原始可迭代对象中的剩余项目进行操作。由于 Python 中的递归有限制,我们将尾调用结构优化为显式的 while 语句。
group_by_iter() 函数的结果是一个 n-元组的序列。在以下示例中,我们将使用过滤函数创建一个数字序列,然后将它们分组为 7-元组:
>>> from pprint import pprint
>>> data = list(
... filter(lambda x: x % 3 == 0 or x % 5 == 0, range(1, 50))
... )
>>> data
[3, 5, 6, 9, 10, ..., 48]
>>> grouped = list(group_by_iter(7, iter(data)))
>>> pprint(grouped)
[(3, 5, 6, 9, 10, 12, 15),
(18, 20, 21, 24, 25, 27, 30),
(33, 35, 36, 39, 40, 42, 45),
(48,)]
我们可以将分组和过滤合并成一个函数,该函数在单个函数体中执行这两个操作。group_by_iter() 的修改如下:
from collections.abc import Callable, Iterator, Iterable
from typing import Any, TypeAlias
ItemFilterPredicate: TypeAlias = Callable[[Any], bool]
def group_filter_iter(
n: int,
predicate: ItemFilterPredicate,
items: Iterator[ItemT]
) -> Iterator[tuple[ItemT, ...]]:
def group(n: int, iterable: Iterator[ItemT]) -> Iterator[ItemT]:
for i in range(n):
try:
yield next(iterable)
except StopIteration:
return
subset = filter(predicate, items)
# ^-- Added this to apply the filter
while row := tuple(group(n, subset)):
# ^-- Changed to use the filter
yield row
我们在 group_by_iter() 函数中添加了一行。这个 filter() 函数的应用创建了一个子集。我们将 while row := tuple(group(n, subset)): 行改为使用子集而不是原始的项目集合。
这个 group_filter_iter() 函数将过滤谓词函数应用于作为 items 参数提供的源可迭代对象。由于过滤输出本身也是一个非严格可迭代对象,子集值不是预先计算的;值是在需要时创建的。这个函数的大部分内容与之前展示的版本相同。
我们可以稍微简化我们使用此函数的上下文。我们可以比较显式使用 filter() 和隐式 filter() 的组合函数。比较如下示例:
>>> rule: ItemFilterPredicate = lambda x: x % 3 == 0 or x % 5 == 0
>>> groups_explicit = list(
... group_by_iter(7, filter(rule, range(1, 50)))
... )
>>> groups = list(
... group_filter_iter(7, rule, iter(range(1, 50)))
... )
在这里,我们应用了过滤谓词并在单个函数调用中将结果分组。对于 filter() 函数来说,与其他处理相结合应用过滤通常没有明显的优势。似乎一个单独的、可见的 filter() 函数比一个组合函数更有帮助。
5.8 使用可调用对象构建高阶函数
我们可以将高阶函数定义为可调用类。这建立在编写生成器函数的想法之上;我们将编写可调用对象,因为我们需要 Python 的状态特性,如实例变量。除了使用语句外,我们还可以在创建高阶函数时应用静态配置。特别是策略设计模式非常适合改变可调用对象的功能。
可调用类定义的重要之处在于,由 class 语句创建的类对象定义了一个发出函数的函数。通常,我们会使用可调用对象来创建一个复合函数,将函数组合成相对复杂的东西。
为了强调这一点,考虑以下类:
from collections.abc import Callable
from typing import Any
class NullAware:
def __init__(self, some_func: Callable[[Any], Any]) -> None:
self.some_func = some_func
def __call__(self, arg: Any) -> Any:
return None if arg is None else self.some_func(arg)
这个类用于创建一个能够处理空值的新的函数。当创建这个类的实例时,提供了一个函数 some_func。唯一指定的限制是 some_func 必须是 Callable[[Any], Any]。这意味着参数接受一个参数并产生一个结果。结果对象是可调用的。期望一个单一的、可选的参数。__call__() 方法的实现处理了将 None 对象作为参数的使用。这个方法的效果是使结果对象成为 Callable[[Optional[Any]], Any]]。
例如,评估 NullAware(math.log) 表达式将创建一个新的函数,该函数可以应用于参数值。__init__() 方法将保存给定的函数到结果对象中。这个对象是一个可以用来处理数据的函数。
常见的做法是创建一个新的函数,并通过给它命名来保存它以供将来使用,如下所示:
import math
null_log_scale = NullAware(math.log)
null_round_4 = NullAware(lambda x: round(x, 4))
第一个示例创建了一个新的函数,并将其命名为 null_log_scale()。第二个示例创建了一个空值感知函数 null_round_4,该函数使用 lambda 对象作为函数的内部值,如果参数不是 None,则应用该函数。然后我们可以在另一个上下文中使用这个函数。请看以下示例:
>>> some_data = [10, 100, None, 50, 60]
>>> scaled = map(null_log_scale, some_data)
>>> [null_round_4(v) for v in scaled]
[2.3026, 4.6052, None, 3.912, 4.0943]
这个示例的 __call__() 方法完全依赖于表达式评估。这是一种优雅且整洁的方式来定义由底层组件函数构建的复合函数。
5.8.1 确保良好的函数式设计
无状态函数式编程的想法在使用 Python 对象时需要小心。对象通常是具有状态的。确实,有人可以争论,面向对象编程的整个目的就是将状态变化封装到类定义中。正因为如此,当我们使用 Python 类定义来处理集合时,我们发现自己处于函数式编程和命令式编程之间的对立方向。
使用可调用对象创建组合函数的好处是,当使用结果组合函数时,我们可以获得稍微简单的语法。当我们开始处理可迭代映射或归约时,我们必须意识到我们如何以及为什么引入有状态的对象。
我们将转向一个相当复杂的函数,它具有以下特性:
-
它对一个项目源应用过滤器。
-
它将对通过过滤器的项目应用映射。
-
它计算映射值的总和。
我们可以尝试将其定义为一个简单的更高阶函数,但如果有三个独立的参数值,使用起来会显得繁琐。相反,我们将创建一个可调用的对象,该对象由过滤器和映射函数配置。
使用对象来配置对象是面向对象编程中使用的策略设计模式。以下是一个需要过滤器和映射函数才能创建可调用对象的类定义:
from collections.abc import Callable, Iterable
class Sum_Filter:
__slots__ = ["filter", "function"]
def __init__(self,
filter: Callable[[float], bool],
func: Callable[[float], float]) -> None:
self.filter = filter
self.function = func
def __call__(self, iterable: Iterable[float]) -> float:
return sum(
self.function(x)
for x in iterable
if self.filter(x)
)
这个类在每个对象中有两个槽位;这在我们将函数用作有状态对象的能力上施加了一些限制。它不会阻止对结果对象的全部修改,但它限制我们只能有两个属性。尝试添加属性将导致异常。
初始化方法 __init__() 将两个函数对象 filter 和 func 存储在对象的实例变量中。__call__() 方法返回一个基于生成器表达式的值,该表达式使用两个内部函数定义。self.filter() 函数用于通过或拒绝项目。self.function() 函数用于转换通过 filter() 函数传递的对象。
这个类的实例是一个包含两个策略函数的函数。我们创建实例的方式如下:
count_not_none = Sum_Filter(
lambda x: x is not None,
lambda x: 1
)
我们构建了一个名为 count_not_none() 的函数,该函数计算序列中的非 None 值。它是通过使用 lambda 传递非 None 值和一个使用常数 1 而不是实际值的函数来完成的。
通常,这个 count_not_none() 对象将表现得像任何其他 Python 函数一样。我们可以如下使用 count_not_None() 函数:
>>> some_data = [10, 100, None, 50, 60]
>>> count_not_none(some_data)
4
这展示了使用 Python 的一些面向对象编程特性来创建可调用对象的技术,这些对象用于以函数式方法设计和构建软件。我们可以将一些复杂性委托给创建复杂函数。拥有一个具有多个功能的单一函数可以简化对函数使用上下文的理解。
5.9 一些设计模式的回顾
max()、min() 和 sorted() 函数在没有 key= 函数的情况下具有默认行为。可以通过提供一个定义如何从可用数据计算键的函数来自定义它们。在我们的许多示例中,key= 函数已经是一个简单的可用数据的提取。这不是必需的;key= 函数可以执行任何操作。
想象以下方法:max(trip, key=random.randint())。通常,我们尽量避免有像这样做一些神秘操作的key=函数。
使用key=函数是一种常见的模式。我们设计的函数可以轻松遵循这个模式。
我们还研究了 lambda 形式如何简化更高阶函数的应用。使用 lambda 形式的一个显著优势是它非常接近函数式范式。当我们编写更传统的函数时,我们可以创建可能会使简洁且富有表现力的函数式设计变得杂乱的命令式程序。
我们已经研究了几种与值集合一起工作的更高阶函数。在之前的章节中,我们提到了几种适用于集合对象和标量对象的更高阶函数的设计模式。以下是一种广泛的分类:
-
返回生成器:一个更高阶函数可以返回一个生成器表达式。我们认为这个函数是更高阶的,因为它没有返回标量值或值的集合。其中一些更高阶函数也接受函数作为参数。
-
作为生成器:一些函数示例使用
yield语句使它们成为一等生成器函数。生成器函数的值是一个可迭代的值集合,这些值是按需评估的。我们建议生成器函数本质上与返回生成器表达式的函数不可区分。两者都是非严格的。两者都可以产生一系列值。因此,我们将生成器函数也视为更高阶。内置函数,如map()和filter(),属于这一类别。 -
实现集合:一些函数必须返回一个具体化的集合对象:列表、元组、集合或映射。如果这些函数的参数中包含一个函数,它们可以成为更高阶的函数。否则,它们是普通的函数,碰巧与集合一起工作。
-
减少集合:一些函数与可迭代对象一起工作以创建标量结果。
len()和sum()函数就是这样的例子。当我们接受一个函数作为参数时,我们可以创建更高阶的减少。我们将在下一章回到这个问题。 -
标量:一些函数作用于单个数据项。如果它们接受另一个函数作为参数,它们可以是更高阶函数。
在我们设计自己的软件时,我们可以从这些既定的设计模式中选择和挑选。
5.10 概述
在本章中,我们看到了两个更高阶函数的减少:max()和min()。我们研究了两个核心更高阶函数map()和filter()。我们还研究了sorted()。
此外,我们还研究了如何使用更高阶函数来转换数据结构。我们可以执行几种常见的转换,包括包装、解包、展平和结构化不同类型的序列。
我们探讨了两种定义我们自己的高阶函数的方法,具体如下:
-
def语句。类似于我们分配给变量的 lambda 形式。 -
将可调用的类定义为一种发出复合函数的函数类型。
我们还可以使用装饰器来发出复合函数。我们将在第十二章装饰器设计技术中回到这一点。
在下一章中,我们将探讨通过递归实现纯函数迭代的概念。我们将使用 Pythonic 结构在纯函数技术上进行一些常见的改进。我们还将探讨从集合到单个值执行归约的相关问题。
5.11 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者可能会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习将需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中提供的单元测试用例相同。读者应将书籍中的示例函数名称替换为自己的解决方案以确认其工作。
5.11.1 状态分类
一个 Web 应用可能拥有多种类型的多个服务器、数据库和已安装的软件组件。负责网站可靠性的某人将想知道事情是否运行得相当顺利。当事情出错时,他们需要详细信息。
作为监控的一部分,健康应用程序可以从各种组件收集状态,并将状态汇总为整体的“健康”值。这个想法是对状态信息执行一种“reduce”操作。
每个单独的服务都有一个可以用于获取状态信息的状态 URL。结果可以是以下四种值之一:
-
完全没有响应。服务未工作。这是不好的。
-
超出健康时间窗口的响应。即使响应是
"working",服务也会降级。 -
"not working"的响应。"not working"的响应几乎与没有响应一样糟糕。它表明存在严重问题,但也意味着监控软件正在工作。 -
"working"的响应。这是理想的响应。
状态形式为包含 3 个元组的集合:("服务", "状态", "响应时间)。服务是一个名称,例如 "primary database" 或 "router" 或任何可以作为分布式 Web 应用一部分的众多其他服务。状态值是一个字符串值,可以是 "working" 或 "not working"。响应时间是响应所需的时间(以毫秒计)。典型数字为 10-50。
摘要可以是以下值之一:
-
Stopped:有一个服务没有响应。 -
Degraded:有一个服务响应时间超出了 50 毫秒或更少的健康时间窗口。或者,有一个服务响应了"not working"。 -
Running:所有服务都在 50 毫秒窗口内正常工作并响应。
以下是有两种可能的实现:
-
编写四个过滤器函数。将过滤器应用于状态值序列,并计算每个过滤器匹配的数量。根据匹配的数量,决定为系统的整体健康提供哪三个响应。
-
编写一个映射来应用严重性数字:2 表示
Stopped的指示,1 表示Degraded的任一指示,或 0 表示所有其他服务状态消息。这个向量的最大值是系统的整体健康。
实现所有变体。比较生成的代码的清晰度和表达性。
5.11.2 状态分类,第二部分
在上一个练习中,服务被描述为报告一个状态值,该值是字符串值"working"或"not working"。
在继续之前,要么完成上一个练习,要么开发一个可行的设计方案来解决上一个练习。
由于技术升级,某些服务的状态值包括第三个值:"degraded"。这具有与服务缓慢响应相同的含义。这可能会改变设计。它肯定会改变实现。
提供一个实现,优雅地处理额外的或不同的状态消息的概念。想法是将状态消息检查隔离到一个可以轻松替换的函数中。例如,我们可能从三个评估状态值的函数开始:is_stopped()、is_degraded()和is_working()。当需要更改时,我们可以编写一个新的版本,is_degraded_2(),它可以替代旧的is_degraded()函数。
目标是创建一个不需要更改任何特定函数实现的程序。相反,添加新函数;这些新函数将重用现有函数以及新函数来完成扩展的目标。
5.11.3 优化文件解析器
在映射时展平数据中,我们使用了以下表达式从带有空格分隔符的文本中提取一个数字序列:
(
v
for line in text.splitlines()
for v in line.split()
)
split()方法的定义包括\n字符,这个字符也被splitlines()方法使用。看起来这可以通过仅使用split()方法来优化。
在使这个工作后,将示例中的源文本更改为:
>>> text = """2,3,5,7,11,13,17,19,23,29
... 31,37,41,43,47,53,59,61,67,71
... 73,79,83,89,97,101,103,107,109,113
... 127,131,137,139,149,151,157,163,167,173
... 179,181,191,193,197,199,211,223,227,229
... """
我们可以使用一次split()方法来解析这个。这需要将一个单一的长序列值重新组织成多行和多列。
这是否比使用splitlines()和split()方法更快?
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第六章:6
递归和归约
许多函数式编程语言编译器会将递归函数优化,将函数尾部的递归调用转换为迭代。这种尾调用优化将显著提高性能。Python 不进行这种自动尾调用优化。一个后果是纯递归受到限制。缺乏自动优化,我们需要手动进行尾调用优化。这意味着重写递归以使用显式迭代。有两种常见的方法来做这件事,我们将在本章中考虑它们。
在前面的章节中,我们探讨了多种相关的处理设计模式;其中一些如下:
-
映射和过滤,它们从集合创建集合
-
从集合创建标量值的归约
这种区别可以通过像 map() 和 filter() 这样的函数来体现,这些函数完成了第一种集合处理。还有一些更专业的归约函数,包括 min()、max()、len() 和 sum()。还有一个通用归约函数,functools.reduce()。
我们还将考虑创建一个 collections.Counter() 对象作为归约操作符的一种。它本身并不产生单个标量值,但它确实创建了一种新的数据组织方式,消除了原始结构的一些部分。本质上,它是一种计数分组操作,与计数归约比与映射有更多的共同点。
在本章中,我们将更详细地探讨归约函数。从纯函数的角度来看,归约可以递归地定义。Python 中可用的尾调用优化技术非常适合归约。
我们将回顾一些内置的归约算法,包括 sum()、count()、max() 和 min()。我们将探讨 collections.Counter() 的创建和相关 itertools.groupby() 归约。我们还将探讨解析(和词法扫描)是如何作为适当的归约的,因为它们将标记序列(或字符序列)转换为具有更复杂属性的更高阶集合。
6.1 简单数值递归
我们可以将所有数值运算都定义为递归。有关更多详细信息,请阅读定义数字基本特征的佩亚诺公理,见www.britannica.com/science/Peano-axioms。
从这些公理中,我们可以看到加法是通过使用更原始的下一个数的概念,即数 n 的后继,S(n) 来递归定义的。
为了简化说明,我们假设我们可以定义一个前驱函数,P(n),使得 n = S(P(n)) = P(S(n)),只要 n≠0。这形式化了这样一个观点:一个数是它前驱数的后继。
两个自然数之间的加法可以递归地定义为如下:

如果我们使用更典型的 n + 1 和 n− 1 的表示法,而不是 S(n)和 P(n),我们可以更容易地看到当 a≠0 时,add(a,b) = add(a − 1,b + 1)的规则是如何工作的。
这在以下函数定义中得到了很好的体现:
def add(a: int, b: int) -> int:
if a == 0:
return b
else:
return add(a - 1, b + 1)
我们已经将抽象的数学符号重新排列成了具体的 Python 代码。
在 Python 中提供我们自己的函数来进行简单的加法没有很好的理由。我们依赖于 Python 的底层实现来正确处理各种算术运算。我们在这里的要点是,基本标量算术可以递归定义,并且定义可以翻译成 Python。
这表明更复杂的递归定义的操作也可以翻译成 Python。这种翻译可以通过手动优化来创建与抽象定义相匹配的运行代码,从而减少关于实现中可能出现的错误的问题。
递归定义必须至少包括两种情况:一个非递归(或基本)情况,其中函数的值直接定义,以及递归情况,其中函数的值是通过递归评估具有不同参数值的函数来计算的。
为了确保递归能够终止,重要的是要看到递归情况是如何计算接近定义的非递归基本情况的值的。从实用角度来看,我们通常省略了函数中的参数值约束。例如,前面命令片段中的add()函数可以扩展以包括assert a>=0 and b>=0,以建立对输入值的两个必要约束。
没有这些限制,从a等于-1 开始,当我们不断从a中减去 1 时,不会接近a == 0的非递归情况。
6.1.1 实现手动尾调用优化
对于某些函数,递归定义是最简洁和表达性最强的。一个常见的例子是factorial()函数。
我们可以从以下公式中看到,这如何被重写为 Python 中的一个简单递归函数:

上述公式可以通过以下函数定义在 Python 中实现:
def fact(n: int) -> int:
if n == 0:
return 1
else:
return n*fact(n-1)
这种实现具有简单性的优势。Python 中的递归限制人为地限制了我们的能力;我们无法进行大于约fact(997)的操作。1000!的值有 2,568 位数字,通常超过了我们的浮点数容量;在某些系统中,浮点数限制接近 10³⁰⁰。从实用角度来看,通常切换到对数伽马函数而不是处理巨大的数字。
有关对数伽马函数的更多信息,请参阅functions.wolfram.com/GammaBetaErf/LogGamma/introductions/Gammas/ShowAll.html。
我们可以将 Python 的调用栈限制扩展到内存的极限。然而,手动优化这些函数以消除递归是更好的选择。
这个函数演示了一个典型的尾递归。函数中的最后一个表达式是对具有新参数值的函数的调用。优化编译器可以用执行非常快的循环来替换函数调用栈管理。
在这个例子中,函数涉及从 n 到 n - 1 的增量变化。这意味着我们在生成一系列数字后,再进行归约以计算它们的乘积。
超出纯粹函数式处理,我们可以定义一个命令式的 facti() 计算如下:
def facti(n: int) -> int:
if n == 0:
return 1
f = 1
for i in range(2, n+1):
f = f * i
return f
这个阶乘函数版本将计算超过 1000!(例如,2000!有 5,736 位)。这个例子并不纯粹是函数式的。我们将尾递归优化为一个依赖于 i 变量的状态 for 语句,以保持计算状态。
通常情况下,我们不得不在 Python 中这样做,因为 Python 无法自动进行尾调用优化。然而,在某些情况下,这种优化实际上并不 helpful。我们将探讨其中的一些情况。
6.1.2 保持递归不变
在某些情况下,递归定义实际上是最佳的。一些递归涉及分治策略,这可以最小化工作量。其中一个例子是平方幂的指数算法。这适用于计算具有正整数指数的值,如 2⁶⁴。我们可以如下形式化地陈述它:

我们将过程分解为三个情况,可以很容易地用 Python 作为递归编写。看看以下函数定义:
def fastexp(a: float, n: int) -> float:
if n == 0:
return 1
elif n % 2 == 1:
return a * fastexp(a, n - 1)
else:
t = fastexp(a, n // 2)
return t * t
对于奇数,fastexp() 方法定义为递归。指数 n 减少了 1。对于这种情况,简单的尾递归优化是可行的。然而,对于偶数情况,则不可行。
对于偶数,fastexp() 递归使用 n // 2,将问题规模减半。由于问题规模减少了 2 倍,这种情况会导致处理速度显著提升。
我们不能简单地重构这种函数为尾调用优化循环。由于它已经是最优的,我们实际上不需要进一步优化它。Python 中的递归限制将导致 n ≤ 2¹⁰⁰⁰,这是一个相当宽松的上限。
6.1.3 处理困难的尾调用优化
我们可以递归地查看斐波那契数的定义。以下是对第 n 个斐波那契数 F[n] 的一个广泛使用的定义:

一个给定的斐波那契数 F[n]定义为前两个数的和,即 F[n−1] + F[n−2]。这是一个多次递归的例子:它不能简单地作为简单的尾递归进行优化。然而,如果我们不将其优化为尾递归,我们会发现它太慢而无法使用。
以下是一个简单的实现:
def fib(n: int) -> int:
if n == 0: return 0
if n == 1: return 1
return fib(n-1) + fib(n-2)
这存在一个可怕的多次递归问题。在计算fib(n)值时,我们必须计算fib(n-1)和fib(n-2)的值。fib(n-1)值的计算涉及到fib(n-2)值的重复计算。fib()函数的两次递归使用将超过重复计算的工作量。
由于 Python 从左到右的评估规则,我们可以评估到大约fib(1000)的值。然而,我们必须有耐心。非常耐心。(尝试使用默认的栈大小找到实际的界限意味着在RecursionError被抛出之前要等待很长时间。)
以下是一个替代方案,它重新表述了整个算法,使用有状态变量而不是简单的递归:
def fibi(n: int) -> int:
if n == 0: return 0
if n == 1: return 1
f_n2, f_n1 = 1, 1
for _ in range(2, n):
f_n2, f_n1 = f_n1, f_n2 + f_n1
return f_n1
我们这个有状态版本的函数从 0 开始计数,与递归从初始值n开始计数不同。这个版本比递归版本快得多。
这里重要的是,我们无法简单地通过明显的重写来优化fib()函数的递归。为了用命令式版本替换递归,我们必须仔细查看算法,以确定需要多少个有状态的中间变量。
作为对读者的练习,尝试使用functools模块中的@cache装饰器。这会产生什么影响?
6.1.4 通过递归处理集合
当处理集合时,我们也可以递归地定义处理。例如,我们可以递归地定义map()函数。形式化可以表述如下:
![( |{ [] if len(C ) = 0 map (f,C ) = | ( map(f,C [:−1]) + [f (C −1)] if len(C ) > 0](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file52.jpg)
我们将函数 f 映射到空集合定义为空序列[]。我们还指定了将函数应用于集合可以通过一个三步表达式递归定义。首先,递归地对函数应用于除最后一个元素之外的所有集合元素进行映射,创建一个序列对象。然后对最后一个元素应用函数。最后,将最后的计算结果追加到之前构建的序列中。
以下是这个map()函数的纯递归函数版本:
from collections.abc import Callable, Sequence
from typing import Any, TypeVar
MapD = TypeVar("MapD")
MapR = TypeVar("MapR")
def mapr(
f: Callable[[MapD], MapR],
collection: Sequence[MapD]
) -> list[MapR]:
if len(collection) == 0: return []
return mapr(f, collection[:-1]) + [f(collection[-1])]
mapr(f,[])方法定义的值是一个空列表对象。对于非空列表的mapr()函数,将应用函数到列表的最后一个元素,并将其追加到由应用于列表头的mapr()函数递归构建的列表中。
我们必须强调,这个mapr()函数实际上创建了一个列表对象。内置的map()函数是一个迭代器;它不会创建列表对象。它按计算顺序产生结果值。此外,工作是在从右到左的顺序中完成的,这不是 Python 通常的工作方式。这只有在使用具有副作用的功能时才会观察到,这是我们希望避免做的事情。
虽然这是一个优雅的形式主义,但它仍然缺乏所需的尾调用优化。优化将使我们能够超过默认的递归限制 1,000,并且比这种原始递归快得多。
使用Callable[[Any], Any]是一种弱类型提示。为了更清楚,可以定义一个域类型变量和一个范围类型变量。我们将在优化示例中包含这个细节。
6.1.5 集合的尾调用优化
我们有两种处理集合的一般方法:我们可以使用返回生成器表达式的高阶函数,或者我们可以创建一个使用for语句处理集合中每个项的函数。这两种模式非常相似。
以下是一个类似于内置map()函数的高阶函数:
from collections.abc import Callable, Iterable, Iterator
from typing import Any, TypeVar
DomT = TypeVar("DomT")
RngT = TypeVar("RngT")
def mapf(
f: Callable[[DomT], RngT],
C: Iterable[DomT]
) -> Iterator[RngT]:
return (f(x) for x in C)
我们返回了一个生成器表达式,它产生了所需的映射。这使用了生成器表达式中的显式for作为尾调用优化的一种形式。
数据源C有一个类型提示Iterable[DomT],以强调某些类型DomT将形成映射的域。转换函数有一个提示Callable[[DomT], RngT],以使其明确地从某个域类型转换到范围类型。例如,float()函数可以将值从字符串域转换为浮点数范围。结果有一个提示Iterator[RngT],以表明它遍历范围类型RngT;可调用函数的结果类型。
以下是一个具有相同签名和结果的生成器函数:
def mapg(
f: Callable[[DomT], RngT],
C: Iterable[DomT]
) -> Iterator[RngT]:
for x in C:
yield f(x)
这使用了完整的for语句进行尾调用优化。结果相同。这个版本稍微慢一些,因为它涉及多个语句。
在这两种情况下,结果是对结果的一个迭代器。我们必须做些别的事情,才能从一个可迭代源中创建一个序列对象。例如,这里使用list()函数从迭代器创建序列:
>>> list(mapg(lambda x: 2 ** x, [0, 1, 2, 3, 4]))
[1, 2, 4, 8, 16]
为了性能和可扩展性,Python 程序中需要这种尾调用优化。这使得代码不如纯函数。然而,好处远远超过了纯度的缺乏。为了获得简洁和表达性强的函数式设计的益处,将这些非纯函数视为适当的递归是有帮助的。
这在实用意义上意味着我们必须避免在集合处理函数中添加额外的状态化处理。即使我们程序的一些元素不是完全函数式的,函数式编程的核心原则仍然有效。
6.1.6 在递归中使用赋值(有时称为“walrus”)运算符
在某些情况下,递归涉及可以使用“walrus”或赋值运算符:=进行优化的条件处理。使用赋值意味着我们正在引入状态变量。如果我们小心这些变量的作用域,那么产生极其复杂算法的可能性就会降低。
我们在保留递归部分回顾了下面的fast_exp()函数。这个函数使用了三个不同的案例来实现分而治之的策略。在将数字a提升到偶数次幂的情况下,我们可以使用t = a^来计算t × t = a^n:
def fastexp_w(a: float, n: int) -> float:
if n == 0:
return 1
else:
q, r = divmod(n, 2)
if r == 1:
return a * fastexp_w(a, n - 1)
else:
return (t := fastexp_w(a, q)) * t
这使用:= walrus 运算符来计算部分答案fastexp_w(a, q)并将其保存到临时变量t中。这将在同一语句的稍后部分用于计算t * t。
对于递归的大部分情况,当我们对递归进行尾调用优化时,for语句的主体将包含普通赋值语句。通常没有必要利用 walrus 运算符。
赋值运算符常用于正则表达式匹配等场景,我们希望保存匹配对象并做出决策。if(match := pattern.match(text)):作为尝试正则表达式匹配、保存结果匹配对象并确认它不是None对象的一种常见方式。
6.2 从多个项目折叠集合到单个项目
我们可以将sum()函数考虑为以下类型的定义。我们可以说,对于空集合,集合的和为 0。对于非空集合,和是第一个元素加上剩余元素的和:
![(| { 0 if n = 0 sum ([c0,c1,c2,...,cn]) = | ( c0 + sum ([c1,c2,...,cn]) if n > 0](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file54.jpg)
我们可以使用一种稍微简化的符号,称为 Bird-Meertens 形式主义。它使用⊕∕[c[0],c[1],...c[n]]来显示某些任意二元运算符⊕如何应用于一系列值。它如下所示,将递归定义总结为更容易处理的东西:
![sum ([c0,c1,c2,...,cn]) = + ∕[c0,c1,c2,...,cn] = 0+ c0 + c1 + ...+ cn](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file55.jpg)
我们有效地将序列中每个项目之间的加法运算符折叠起来。隐式地,处理将按从左到右的顺序进行。这可以称为将集合折叠为单个值的“fold left”方式。我们也可以想象从右到左分组运算符,称之为“fold right”。虽然一些编译型语言会执行这种优化,但 Python 在给定一系列具有相同优先级的运算符时,会严格从左到右工作。
在 Python 中,乘积函数可以递归地定义为以下内容:
from collections.abc import Sequence
def prodrc(collection: Sequence[float]) -> float:
if len(collection) == 0: return 1
return collection[0] * prodrc(collection[1:])
这是对数学符号到 Python 的微小重写。然而,它并不理想,因为所有的切片都会创建大量中间列表对象。它也仅限于与显式集合一起工作;它不能容易地与可迭代对象一起工作。
我们可以稍作修改以适应可迭代对象,这样可以避免创建任何中间集合对象。以下是一个正确递归的乘积函数,它可以与任何迭代器作为数据源一起工作:
from collections.abc import Iterator
def prodri(items: Iterator[float]) -> float:
try:
head = next(items)
except StopIteration:
return 1
return head * prodri(items)
这不适用于可迭代集合。我们无法使用len()函数来查询迭代器有多少元素。我们所能做的就是尝试提取迭代器的头部。如果没有元素在迭代器中,那么任何获取头部尝试都会引发StopIteration异常。如果有元素,那么我们可以将这个元素乘以序列中剩余元素的乘积。
注意,我们必须显式地使用iter()函数从一个具体化的序列对象创建一个迭代器。在其他上下文中,我们可能有一个可迭代的输出结果可以使用。以下是一个示例:
>>> prodri(iter([1,2,3,4,5,6,7]))
5040
这个递归定义不依赖于显式状态或 Python 的其他命令式特性。虽然它更纯粹是函数式的,但它仍然限制在处理小于 1,000 个项目的集合。(虽然我们可以扩展栈的大小,但正确优化这一点要好得多。)从实用主义的角度来看,我们可以使用以下类型的命令式结构来处理归约函数:
from collections.abc import Iterable
def prodi(items: Iterable[float]) -> float:
p: float = 1
for n in items:
p *= n
return p
这避免了任何递归限制。它包括所需的尾调用优化。此外,这将以相同的方式与任何可迭代对象一起工作。这意味着一个Sequence对象,或者一个迭代器。
6.2.1 使用双端队列进行尾调用优化
递归的核心是函数调用的栈。例如,评估fact(5)是5*fact(4)。fact(4)的值是5*fact(3)。直到fact(0)的值为 1,才会有一系列待处理的计算。然后计算栈完成,揭示最终结果。
Python 为我们管理调用栈。它对栈强加了一个任意默认限制,即 1,000 次调用,以防止具有递归错误的程序无限期地运行。
我们也可以手动管理栈。这为我们提供了优化递归的另一种方法。我们可以——明确地——创建一个待处理工作的栈。然后我们可以对待处理工作进行最终总结,从栈中清空项目。
对于像计算阶乘值这样简单的事情,堆栈的压入和弹出可能看起来像是无用的开销。对于更复杂的应用,如检查分层文件系统,将文件处理与将目录放入堆栈以供以后考虑混合起来似乎更合适。
我们需要一个函数来遍历目录层次结构而不使用显式递归。核心概念是目录是一系列条目,每个条目要么是一个文件,要么是一个子目录,或者是我们不想接触的其他文件系统对象(例如,挂载点、符号链接等)。
我们可以说目录树中的一个节点是一系列条目:N = e[0],e[1],e[2],...,e[n]。每个条目要么是另一个目录,e ∈𝔻,要么是一个文件,e ∈𝔽。
我们可以在树中的每个文件上执行映射以处理每个文件的内容。我们可能执行一个过滤操作来创建具有特定属性的文件迭代器。我们还可以执行归约操作来计算具有属性的文件数量。在这个例子中,我们将计算目录树中文件内容中特定子字符串的出现次数。
形式上,我们希望有一个函数 p(f),它将为目录树节点中的“打印”提供计数。它可以定义如下:

这显示了如何将 p(N)函数应用于目录树中的每个元素。当元素是文件,e ∈𝔽,时,我们可以计算“打印”的实例。当元素是目录,e ∈𝔻,时,我们需要递归地应用 p(N)函数到目录中的每个条目,e[x]。虽然目录树可能不够深以打破 Python 的栈大小限制,但这种算法揭示了尾调用优化的替代方案。这是一个使用显式栈的机会。
collections.deque类是构建栈和队列的奇妙方式。这个名字来自“双端队列”,有时拼写为 dequeue。这种数据结构可以用作后进先出(LIFO)栈或先进先出(FIFO)。在这个例子中,我们使用append()和pop()方法,这些方法强制执行 LIFO 栈行为。虽然这很像列表,但在deque实现中还有一些优化,可以使其比通用列表略快。
使用栈数据结构让我们能够在不遇到 Python 内部栈深度限制并引发RecursionError异常的情况下处理不定大小的层次结构。以下函数将遍历文件层次结构,查看 Python 源文件(后缀为.py):
from collections import deque
from pathlib import Path
def all_print(start: Path) -> int:
count = 0
pending: deque[Path] = deque([start])
while pending:
dir_path = pending.pop()
for path in dir_path.iterdir():
if path.is_file():
if path.suffix == ’.py’:
count += path.read_text().count("print")
elif path.is_dir():
if not path.stem.startswith(’.’):
pending.append(path)
else: # Ignore other filesystem objects
pass
return count
我们用初始目录填充了待处理任务的栈。基本算法是从栈中弹出目录并访问目录中的每个条目。对于具有正确后缀的文件条目,执行处理:计算“打印”的出现次数。对于目录条目,将目录作为待处理任务放入栈中。注意,名称中带有点的目录需要被忽略。对于本书中的代码,这些目录包括 mypy、pytest 和 tox 等工具使用的缓存。我们希望跳过这些缓存目录。
对每个文件执行的处理是all_print()函数的一部分。这可以重构为一个单独的函数,作为减少的一部分应用于每个节点。将all_print()函数重写为适当的更高阶函数作为练习留给读者。
这里的想法是我们有两种将形式化递归转换为有用优化函数的策略。我们可以将递归重构成迭代,或者我们可以引入一个显式的栈。
在下一节中,我们将应用减少(以及相关的尾调用优化)的概念来创建项目组并计算组的减少。
6.3 从多个项目到较少项目的分组减少
减少的想法可以以多种方式应用。我们已经看到了减少的基本递归定义,它从值集合中生成单个值。这导致我们优化递归,以便我们能够在没有原始 Python 实现开销的情况下计算摘要。
在 Python 中创建子组并不困难,但了解支持它的形式化方法可能会有所帮助。这种理解可以帮助避免性能极差的实现。
一个非常常见的操作是按某些键或指标对值进行分组。原始数据按某些列的值进行分组,并将减少(有时称为聚合函数)应用于其他列。
在 SQL 中,这通常称为SELECT语句的GROUP BY子句。SQL 聚合函数包括SUM、COUNT、MAX和MIN,以及许多其他函数。
Python 为我们提供了多种在计算分组值的减少之前对数据进行分组的方法。我们将从查看获取分组数据的简单计数方法开始。然后我们将探讨计算分组数据不同摘要的方法。
我们将使用我们在第四章,处理集合中计算的三次数据。这些数据最初是一系列纬度-经度航点。我们将其重构为表示每条腿的起点、终点和距离的三元组。数据看起来如下:
(((37.5490162, -76.330295), (37.840832, -76.273834), 17.7246),
((37.840832, -76.273834), (38.331501, -76.459503), 30.7382),
((38.331501, -76.459503), (38.845501, -76.537331), 31.0756),
...
((38.330166, -76.458504), (38.976334, -76.473503), 38.8019))
我们想知道最常见的距离。由于数据是实值且连续的,每个距离都是一个独特的值。我们需要将这些值从连续域约束到一组离散的距离。例如,将每条腿量化到最接近的五海里倍数。这创建了从 0 到 5 英里,超过 5 到 10 英里等的波段。一旦我们创建了离散的整数值,我们就可以计算每个波段中的腿的数量。
这些量子化的距离可以通过生成器表达式来生成:
quantized = (5 * (dist // 5) for start, stop, dist in trip)
这将把每个距离除以 5——丢弃任何分数——然后将截断的结果乘以 5 来计算一个表示距离向下舍入到最接近 5 海里的数字。
我们没有使用分配给 start 和 stop 变量的值。将它们分配给 _ 变量是一种常见的做法。这可能会导致一些混淆,因为这可能会掩盖三元组的结构。它看起来会是这样:
quantized = (5 * (dist // 5) for _, _, dist in trip)
这种方法对于去除一些视觉杂乱是有帮助的。
6.3.1 使用 Counter 构建映射
类似于 collections.Counter 类的映射是进行按集合中某些值创建计数的归约的优化。以下表达式创建了一个从距离到频率的映射:
# See Chapter 4 for ways to parse "file:./Winter%202012-2013.kml"
# We want to build a trip variable with the sequence of tuples
>>> from collections import Counter
>>> quantized = (5 * (dist // 5) for start, stop, dist in trip)
>>> summary = Counter(quantized)
生成的 summary 对象是状态的;它可以被更新。创建组的表达式 Counter() 看起来像一个函数,这使得它非常适合基于函数编程思想的架构。
如果我们打印 summary.most_common() 的值,我们将看到以下结果:
>>> summary.most_common()
[(30.0, 15), (15.0, 9), ...]
最常见的距离大约是 30 海里。我们还可以应用 min() 和 max() 等函数来找到记录的最短和最长的腿。
注意,你的输出可能与显示的略有不同。most_common() 函数的结果按频率排序;频率相等的桶可能以任何顺序排列。这五个长度不一定总是按显示的顺序排列:
(35.0, 5), (5.0, 5), (10.0, 5), (20.0, 5), (25.0, 5)
这种轻微的变化使得使用 doctest 工具进行测试稍微复杂一些。对于计数器测试的一个有用技巧是使用字典来验证结果;实际值与预期值之间的比较不再依赖于内部哈希计算的随意性。
6.3.2 通过排序构建映射
Counter 的一个替代方案是对原始集合进行排序,然后使用递归循环来识别每个组开始的位置。这涉及到将原始数据实体化,执行一个可能最坏情况下进行 O(nlog n) 操作的排序,然后进行归约以获取每个键的求和或计数。
为了以通用方式与可以排序的 Python 对象一起工作,我们需要定义排序所需的协议。我们将此协议称为 SupportsRichComparisonT,因为我们可以排序任何实现了丰富比较运算符 < 和 > 的对象。这不是一个特定的对象类;这是一个任何数量的类都可能实现的协议。我们使用 typing.Protocol 类型定义正式化类必须支持的协议概念。它也可以被称为一个类必须实现的接口。Python 的灵活性源于拥有相当多的协议,许多不同的类都支持这些协议。
以下是从排序数据创建组的一个常见算法:
from collections.abc import Iterable
from typing import Any, TypeVar, Protocol, TypeAlias
class Comparable(Protocol):
def __lt__(self, __other: Any) -> bool: ...
def __gt__(self, __other: Any) -> bool: ...
SupportsRichComparisonT = TypeVar("SupportsRichComparisonT", bound=Comparable)
Leg: TypeAlias = tuple[Any, Any, float]
def group_sort(trip: Iterable[Leg]) -> dict[int, int]:
def group(
data: Iterable[SupportsRichComparisonT]
) -> Iterable[tuple[SupportsRichComparisonT, int]]:
sorted_data = iter(sorted(data))
previous, count = next(sorted_data), 1
for d in sorted_data:
if d == previous:
count += 1
else:
yield previous, count
previous, count = d, 1
yield previous, count
quantized = (int(5 * (dist // 5)) for beg, end, dist in trip)
return dict(group(quantized))
内部 group() 函数遍历腿的排序序列。如果给定的项目键已经出现过——它与 previous 中的值匹配——则 counter 变量递增。如果给定的项目不匹配前一个值,那么值发生了变化:输出前一个值和计数,并开始为新值积累计数。
group() 函数的定义提供了两个重要的类型提示。源数据是一些类型的可迭代对象,用类型变量 SupportsRichComparisonT 表示。在这个特定的情况下,很明显,使用的值将是 int 类型;然而,算法对任何 Python 类型都适用。group() 函数的结果可迭代对象将保留源数据的类型,并且通过使用相同的类型变量 SupportsRichComparisonT 来明确这一点。
group_sort() 函数的最后一行从分组项创建一个字典。这个字典将与 Counter 字典类似。主要区别是 Counter() 函数将有一个 most_common() 方法函数,而默认字典没有。
我们也可以使用 itertools.groupby() 来做这件事。我们将在第八章,Itertools 模块中详细探讨这个函数。
6.3.3 按键值分组或分区数据
我们可能想要应用于分组数据的归约类型没有限制。我们可能有具有多个独立和依赖变量的数据。我们可以考虑按独立变量分区数据,并计算每个分区中值的最大值、最小值、平均值和标准差等摘要。
进行更复杂归约的基本技巧是将所有数据值收集到每个组中。Counter() 函数仅收集相同项的计数。对于更深入的分析,我们希望创建包含组原始成员的序列。
回顾我们的行程数据,每个五英里区间可能包含该距离的所有腿的整个集合,而不仅仅是腿的数量。我们可以将分区视为递归或作为 defaultdict(list) 对象的状态化应用。我们将探讨 groupby() 函数的递归定义,因为它很容易设计。
显然,对于空集合 [] 的 groupby(C, key) 计算结果是空字典 dict()。或者更有用,空 defaultdict(list) 对象。
对于非空集合,我们需要处理项 C[0],即头部,并递归地处理序列 C[1:],即尾部。我们可以使用切片表达式,或者我们可以使用 head, *tail`` =`` C 语句来解析这个集合,如下所示:
>>> C = [1,2,3,4,5]
>>> head, *tail = C
>>> head
1
>>> tail
[2, 3, 4, 5]
如果我们有一个名为 groups 的 defaultdict 对象,我们需要使用表达式 groups[key(head)].append(head) 将头部元素包含在 groups 字典中。之后,我们需要评估 groupby(tail, key) 表达式来处理剩余的元素。
我们可以创建一个如下所示的函数:
from collections import defaultdict
from collections.abc import Callable, Sequence, Hashable
from typing import TypeVar
SeqItemT = TypeVar("SeqItemT")
ItemKeyT = TypeVar("ItemKeyT", bound=Hashable)
def group_by(
key: Callable[[SeqItemT], ItemKeyT],
data: Sequence[SeqItemT]
) -> dict[ItemKeyT, list[SeqItemT]]:
def group_into(
key: Callable[[SeqItemT], ItemKeyT],
collection: Sequence[SeqItemT],
group_dict: dict[ItemKeyT, list[SeqItemT]]
) -> dict[ItemKeyT, list[SeqItemT]]:
if len(collection) == 0:
return group_dict
head, *tail = collection
group_dict[key(head)].append(head)
return group_into(key, tail, group_dict)
return group_into(key, data, defaultdict(list))
内部函数 group_into() 处理基本的递归定义。对于 collection 的空值返回提供的字典 group_dict。非空集合被分割成头部和尾部。头部用于更新 group_dict 字典。然后递归地使用尾部更新字典中的所有剩余元素。
类型提示在源对象 SeqItemT 的类型和键 ItemKeyT 的类型之间做出了明确的区分。作为 key 参数提供的函数必须是一个可调用的函数,它返回一个键类型 ItemKeyT 的值,给定一个源类型 SeqItemT 的对象。在许多示例中,将展示一个从 Leg 对象中提取距离的函数。这是一个 Callable[[SeqItemT], ItemKeyT],其中源类型 SeqItemT 是 Leg 对象,键类型 ItemKeyT 是浮点值。
bound=Hashable 是一个额外的约束。这定义了可能类型的“上限”,并提醒 mypy 任何可以分配给此类型变量的类型都必须实现 Hashable 协议。基本、不可变的 Python 类型,如数字、字符串和元组都满足这个限制。像字典、集合或列表这样的可变对象将不会满足上限,从而导致 mypy 发出警告。
我们不能轻易使用 Python 的默认值将此合并为一个单一函数。我们明确不能使用以下错误的命令片段:
# Bad use of a mutable default value
def group_by(key, data, dictionary=defaultdict(list)):
如果我们尝试这样做,group_by() 函数的所有使用都共享一个共同的 defaultdict(list) 对象。这不起作用,因为 Python 只构建一次默认值。作为默认值的可变对象很少能做我们想要的事情。常见的做法是提供一个 None 值,并使用显式的 if 语句根据需要创建每个唯一的空 defaultdict(list) 实例。我们已经展示了如何使用包装函数定义来避免 if 语句。
我们可以如下按距离对数据进行分组:
>>> binned_distance = lambda leg: 5 * (leg[2] // 5)
>>> by_distance = group_by(binned_distance, trip)
我们定义了一个可重用的 lambda,将我们的距离放入大小为 5 海里一个的箱子中。然后我们使用提供的 lambda 对数据进行分组。
我们可以如下检查分组后的数据:
>>> import pprint
>>> for distance in sorted(by_distance):
... print(distance)
... pprint.pprint(by_distance[distance])
以下就是输出看起来像什么:
0.0
[((35.505665, -76.653664), (35.508335, -76.654999), 0.1731),
((35.028175, -76.682495), (35.031334, -76.682663), 0.1898),
((25.4095, -77.910164), (25.425833, -77.832664), 4.3155),
((25.0765, -77.308167), (25.080334, -77.334), 1.4235)]
5.0
[((38.845501, -76.537331), (38.992832, -76.451332), 9.7151),
((34.972332, -76.585167), (35.028175, -76.682495), 5.8441),
((30.717167, -81.552498), (30.766333, -81.471832), 5.103),
((25.471333, -78.408165), (25.504833, -78.232834), 9.7128),
((23.9555, -76.31633), (24.099667, -76.401833), 9.844)]
...
125.0
[((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)]
在查看递归定义之后,我们可以转向查看如何通过迭代来构建一个分组算法的尾调用优化。这将适用于更大的数据集,因为它可以超过内部栈大小限制。
我们将从对 group_into() 函数进行尾调用优化开始。我们将将其重命名为 partition(),因为分割是另一种看待分组的方式。
partition()函数可以写成如下迭代形式:
from collections import defaultdict
from collections.abc import Callable, Hashable, Iterable
from typing import TypeVar
SeqT = TypeVar("SeqT")
KeyT = TypeVar("KeyT", bound=Hashable)
def partition(
key: Callable[[SeqT], KeyT],
data: Iterable[SeqT]
) -> dict[KeyT, list[SeqT]]:
group_dict = defaultdict(list)
for head in data:
group_dict[key(head)].append(head)
#---------------------------------
return group_dict
在进行尾调用优化时,命令式版本中的关键代码行将与递归定义匹配。我们在更改的行下面添加了注释,以强调重写是为了达到相同的结果。其余的结构代表了作为绕过 Python 限制的常用方法所采用的尾调用优化。
类型提示强调了源类型SeqT和键类型KeyT之间的区别。源数据可以是任何东西,但键限于具有适当哈希值的类型。
6.3.4 编写更通用的按组减少
一旦我们对原始数据进行分区,我们就可以对每个分区中的数据元素进行各种类型的减少计算。例如,我们可能想要距离 bin 中每一段的北部最远点。
我们将引入一些辅助函数来分解元组,如下所示:
# Legs are (start, end, distance) tuples
start = lambda s, e, d: s
end = lambda s, e, d: e
dist = lambda s, e, d: d
# start and end of a Leg are (lat, lon) tuples
latitude = lambda lat, lon: lat
longitude = lambda lat, lon: lon
这些辅助函数中的每一个都期望提供一个使用*运算符提供的元组对象,将元组的每个元素映射到 lambda 的单独参数。一旦元组展开为s、e和p参数,通过名称返回适当的参数就相当明显了。这比尝试解释tuple_arg[2]值要清晰得多。
以下是我们如何使用这些辅助函数:
>>> point = ((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
>>> start(*point)
(35.505665, -76.653664)
>>> end(*point)
(35.508335, -76.654999)
>>> dist(*point)
0.1731
>>> latitude(*start(*point))
35.505665
我们最初的point对象是一个嵌套的三元组,包含(0)起始位置,(1)结束位置,和(2)距离。我们使用辅助函数提取了各种字段。
给定这些辅助函数,我们可以定位每个 bin 中路段的北部最起始位置:
>>> binned_distance = lambda leg: 5 * (leg[2] // 5)
>>> by_distance = partition(binned_distance, trip)
>>> for distance in sorted(by_distance):
... print(
... distance,
... max(by_distance[distance],
... key=lambda pt: latitude(*start(*pt)))
... )
我们按距离分组的数据包括给定距离的每一段。我们将每个 bin 中的所有段都提供给max()函数。我们提供给max()函数的key函数仅提取路段起点的纬度。
这为我们提供了一个北部最远路段的简短列表,如下所示:
0.0 ((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
5.0 ((38.845501, -76.537331), (38.992832, -76.451332), 9.7151)
10.0 ((36.444168, -76.3265), (36.297501, -76.217834), 10.2537)
...
125.0 ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
6.3.5 编写高阶减少
我们将在这里查看一个高阶减少算法的示例。这将引入一个相当复杂的话题。最简单的减少类型是从值集合中发展出一个单一值。Python 有几个内置的减少,包括any()、all()、max()、min()、sum()和len()。
如我们在第四章中提到的使用集合,如果我们从以下几种减少开始,我们可以进行大量的统计计算:
from collections.abc import Sequence
def sum_x0(data: Sequence[float]) -> float:
return sum(1 for x in data) # or len(data)
def sum_x1(data: Sequence[float]) -> float:
return sum(x for x in data) # or sum(data)
def sum_x2(data: Sequence[float]) -> float:
return sum(x*x for x in data)
这允许我们定义平均值、标准差、归一化值、校正,甚至最小二乘线性回归,基于这些基础减少函数。
我们最后的减少sum_x2()展示了我们如何应用现有的减少来创建高阶函数。我们可能会改变我们的方法,使其更接近以下内容:
from collections.abc import Callable, Iterable
from typing import Any
def sum_f(
function: Callable[[Any], float],
data: Iterable[float]
) -> float:
return sum(function(x) for x in data)
我们添加了一个函数,function(),作为参数;该函数可以转换数据。这个整体函数,sum_f(),计算转换值的总和。
现在,我们可以以三种不同的方式应用此函数来计算三个基本求和如下:
>>> data = [7.46, 6.77, 12.74, 7.11, 7.81,
... 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]
>>> N = sum_f(lambda x: 1, data) # x**0
>>> N
11
>>> S = sum_f(lambda x: x, data) # x**1
>>> round(S, 2)
82.5
>>> S2 = sum_f(lambda x: x*x, data) # x**2
>>> round(S2, 4)
659.9762
我们插入了一个小的 lambda 来计算 ∑ [x∈X]x⁰ = ∑ [x∈X]1,这是计数,∑ [x∈X]x¹ = ∑ [x∈X]x,求和,以及 ∑ [x∈X]x²,平方和,我们可以使用这些来计算标准差。
对此的一个常见扩展包括一个过滤器来拒绝某些方式未知或不合适的原始数据。我们可能使用以下函数来拒绝不良数据:
from collections.abc import Callable, Iterable
def sum_filter_f(
filter_f: Callable[[float], bool],
function: Callable[[float], float],
data: Iterable[float]
) -> float:
return sum(function(x) for x in data if filter_f(x))
以下用于计算平均值的函数定义将以简单的方式拒绝None值:
valid = lambda x: x is not None
def mean_f(predicate: Callable[[Any], bool], data: Sequence[float]) -> float:
count_ = lambda x: 1
sum_ = lambda x: x
N = sum_filter_f(valid, count_, data)
S = sum_filter_f(valid, sum_, data)
return S / N
这显示了我们可以向sum_filter_f()函数提供两种不同的 lambda 组合。过滤器参数是一个拒绝None值的 lambda;我们将其称为valid以强调其含义。函数参数是一个实现计数或求和操作的 lambda。我们可以轻松地添加一个 lambda 来计算平方和。
重复使用一个常见的valid规则确保在应用任何过滤器到源数据时,各种计算都是相同的。这可以与用户选择的过滤器标准相结合,提供一个整洁的插件来计算与用户请求的数据子集相关的多个统计数据。
6.3.6 编写文件解析器
我们通常可以将文件解析器视为一种还原。许多语言有两个级别的定义:语言中的低级标记和由这些标记构建的高级结构。当我们查看 XML 文件时,标签、标签名称和属性名称形成这种低级语法;由 XML 描述的结构形成一个高级语法。
低级词法扫描是一种将单个字符分组为标记的还原过程。这与 Python 的生成器函数设计模式非常契合。我们经常可以编写如下所示的功能:
from collections.abc import Iterator
from enum import Enum
import re
class Token(Enum):
SPACE = 1
PARA = 2
EOF = 3
def lexical_scan(some_source: str) -> Iterator[tuple[Token, str]]:
previous_end = 0
separator_pat = re.compile(r"\n\s*\n", re.M|re.S)
for sep in separator_pat.finditer(some_source):
start, end = sep.span()
yield Token.PARA, some_source[previous_end: start]
yield Token.SPACE, some_source[start: end]
previous_end = end
yield Token.PARA, some_source[previous_end:]
yield Token.EOF, ""
对于众所周知的文件格式,我们将使用现有的文件解析器。对于 CSV、JSON、XML 或 TOML 格式的数据,我们不需要编写文件解析器。这些模块中的大多数都有一个load()方法,该方法生成有用的 Python 对象。
在某些情况下,我们需要将此解析的结果组合成更高层次的对象,这些对象对我们特定的应用是有用的。虽然 CSV 解析器提供单个行,但这些可能需要用于创建NamedTuple实例,或者可能是其他不可变 Python 对象。我们的行程数据示例,从第四章 使用集合开始,通过将航点组合成对的一个算法组合成更高层次的对象,即旅程的段落。当我们引入更复杂的决策时,我们就从重构过渡到解析。
为了首先提供有用的航点,我们需要解析源文件。在这些示例中,输入是一个 KML 文件;KML 是地理信息的 XML 表示。解析器的基本功能看起来类似于以下定义:
from collections.abc import Iterator
from typing import TextIO, cast
def comma_split(text: str) -> list[str]:
return text.split(",")
def row_iter_kml(file_obj: TextIO) -> Iterator[list[str]]:
ns_map = {
"ns0": "http://www.opengis.net/kml/2.2",
"ns1": "http://www.google.com/kml/ext/2.2"}
xpath = (
"./ns0:Document/ns0:Folder/"
"ns0:Placemark/ns0:Point/ns0:coordinates")
doc = XML.parse(file_obj)
return (
comma_split(cast(str, coordinates.text))
for coordinates in doc.findall(xpath, ns_map)
)
row_iter_kml() 函数的主体是 XML 解析,这使得我们可以使用 doc.findall() 函数遍历文档中的 <ns0:coordinates> 标签。我们使用了一个名为 comma_split() 的函数来解析这个标签的文本内容,将其解析为包含三个值的元组。
cast() 函数仅存在以向 mypy 提供证据,表明 coordinates.text 的值是一个 str 对象。文本属性的默认定义是 Union[str, bytes];在此应用中,数据将是 str 独有的。cast() 函数不执行任何运行时处理。
此函数专注于与规范化的 XML 结构一起工作。文档接近数据库设计者对第一范式定义的描述:每个属性都是原子的(单个值),XML 数据中的每一行都具有相同的列,并且数据类型一致。然而,数据值并非完全原子:我们必须在逗号处拆分点,以将经度、纬度和海拔分离成原子的字符串值。然而,这些 XML 标签的文本值在内部是一致的,这使得它与第一范式非常契合。
大量的数据——XML 标签、属性和其他标点符号——被减少到相对较小的体积,包括仅包含浮点纬度和经度值。因此,我们可以将解析器视为一种简化。
我们需要一组高级转换来将文本元组映射到浮点数。此外,我们希望丢弃海拔,并重新排序经度和纬度。这将生成我们需要的特定于应用的元组。我们可以使用以下函数进行此转换:
from collections.abc import Iterator
def pick_lat_lon(
lon: str, lat: str, alt: str
) -> tuple[str, str]:
return lat, lon
def float_lat_lon(
row_iter: Iterator[list[str]]
) -> Iterator[tuple[float, float]]:
lat_lon_iter = (
pick_lat_lon(*row)
for row in row_iter
)
return (
(float(lat), float(lon))
for lat, lon in lat_lon_iter
)
重要的工具是 float_lat_lon() 函数。这是一个高阶函数,它返回一个生成器表达式。生成器使用 map() 函数将 float() 函数转换应用于 pick_lat_lon() 函数的结果,并使用 *row 参数将行元组的每个成员分配给 pick_lat_lon() 函数的不同参数。这仅在每一行是三个元组时才有效。然后 pick_lat_lon() 函数返回一个所需顺序的选定项的两元组。
源文件包含如下所示的 XML:
<Placemark><Point>
<coordinates>-76.33029518659048, 37.54901619777347,0</coordinates>
</Point></Placemark>
我们可以这样使用这个解析器:
>>> import urllib.request
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... flat = list(float_lat_lon(row_iter_kml(source)))
这将构建原始 KML 文件中每个航点的元组表示。结果将是一个看起来像这样的平坦序列对:
>>> from pprint import pprint
>>> pprint(flat) # doctest: +ELLIPSIS
[(37.54901619777347, -76.33029518659048),
...
(38.976334, -76.473503)]
float_lat_lon() 函数使用低级 XML 解析器从原始表示中提取文本数据行。它使用高级解析器将文本项转换为更有用的浮点数值元组,这些值适用于目标应用。
解析 CSV 文件
在第三章,函数、迭代器和生成器中,我们看到了另一个示例,其中我们解析了一个非规范化的 CSV 文件:我们必须丢弃标题行以使其有用。为此,我们使用了一个提取标题并返回剩余行迭代器的函数。
数据如下所示:
Anscombe’s quartet
I II III IV
x y x y x y x y
10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
...
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
列由制表符分隔。此外,还有三行标题,我们可以丢弃。
下面是这个基于 CSV 的解析器的另一个版本。我们将其分解为三个函数。第一个函数是row_iter_csv(),它返回一个制表符分隔的文件中行的迭代器。该函数如下所示:
from collections.abc import Iterator
import csv
from typing import TextIO
def row_iter_csv(source: TextIO) -> Iterator[list[str]]:
rdr = csv.reader(source, delimiter="\t")
return rdr
这是一个围绕 CSV 解析过程的小型包装器。当我们回顾之前的 XML 和纯文本解析器时,这就是那些解析器所缺少的东西。生成行元组的可迭代对象可以是规范化数据解析器的常见功能。
一旦我们有一行元组,我们就可以传递包含可用数据的行,并拒绝包含其他元数据(如标题和列名)的行。我们将介绍一个辅助函数,我们可以用它来进行一些解析,以及一个filter()函数来验证数据行。
下面是转换:
from typing import cast
def float_none(data: str) -> float | None:
try:
data_f = float(data)
return data_f
except ValueError:
return None
此函数处理将单个字符串转换为浮点值,将不良数据转换为None值。float | None的类型提示表达了具有给定类型值或具有与None相同类型的值的想法。这也可以表述为Union[float, None],以显示结果是如何成为不同替代类型的联合。
我们可以将float_none()函数嵌入映射中,以便将行的所有列转换为浮点数或None值。这个 lambda 表达式如下所示:
from collections.abc import Callable
from typing import TypeAlias
R_Float: TypeAlias = list[float | None]
float_row: Callable[[list[str]], R_Float] = \
lambda row: list(map(float_none, row))
在定义float_row()函数时使用了两个类型提示,以使其定义明确。R_Float提示定义了可能包含None值的行的浮点数版本。
下面是一个基于all()函数的行级验证器,用于确保所有值都是float(或者没有值是None):
all_numeric: Callable[[R_Float], bool] = \
lambda row: all(row) and len(row) == 8
这个 lambda 表达式是一种归约,如果所有值都不是“假值”(即,既不是None也不是零)并且恰好有八个值,则将浮点值行转换为布尔值。
简单的all_numeric()函数将零和None混淆。一个更复杂的测试将依赖于类似not any(item is None for item in row)的东西。重写留给读者作为练习。
基本设计是创建基于行的元素,可以组合起来创建解析输入文件的更完整的算法。基础函数遍历文本元组。这些被组合起来以转换和验证转换后的数据。对于文件要么是第一范式(所有行都相同)或者简单验证器可以拒绝额外行的情况,这种设计模式运作得很好。
然而,并非所有解析问题都这么简单。一些文件在标题或尾部行中有重要数据必须保留,即使它不匹配文件其余部分的格式。这些非标准化文件将需要一个更复杂的解析器设计。
解析带有标题的纯文本文件
在 第三章,函数、迭代器和生成器 中,Crayola.GPL 文件被展示出来,但没有显示解析器。这个文件看起来如下:
GIMP Palette
Name: Crayola
Columns: 16
#
239 222 205 Almond
205 149 117 Antique Brass
我们可以使用正则表达式解析文本文件。我们需要使用过滤器来读取(并解析)标题行。我们还希望返回一个数据行的可迭代序列。这个相当复杂的两步解析完全基于两步——头部和尾部——文件结构。
下面是一个低级解析器,它处理标题的四行和长尾:
from collections.abc import Iterator
from typing import TextIO, TypeAlias
Head_Body: TypeAlias = tuple[tuple[str, str], Iterator[list[str]]]
def row_iter_gpl(file_obj: TextIO) -> Head_Body:
header_pat = re.compile(
r"GIMP Palette\nName:\s*(.*?)\nColumns:\s*(.*?)\n#\n",
re.M)
def read_head(file_obj: TextIO) -> tuple[tuple[str, str], TextIO]:
if match := header_pat.match(
"".join(file_obj.readline() for _ in range(4))
):
return (match.group(1), match.group(2)), file_obj
else:
raise ValueError("invalid header")
def read_tail(
headers: tuple[str, str],
file_obj: TextIO) -> Head_Body:
return (
headers,
(next_line.split() for next_line in file_obj)
)
return read_tail(*read_head(file_obj))
Head_Body 类型定义总结了行迭代器的总体目标。结果是两个元组。第一个元素是一个包含文件标题详细信息的两个元组。第二个元素是一个迭代器,提供颜色定义的文本项。这个 Head_Body 类型提示在这个函数定义中使用了两个地方。
header_pat 正则表达式解析标题的所有四行。表达式中存在 () 的情况,用于从标题中提取名称和列信息。
文件解析有两个内部函数用于解析文件的不同部分。read_head() 函数解析标题行,并返回有趣的文本和一个可以用于其余解析的 TextIO 对象。它是通过读取四行并将它们合并成一个长字符串来做到这一点的。然后使用 header_pat 正则表达式进行解析。
从一个函数返回迭代器以在另一个函数中使用是一种将显式状态对象从一个函数传递到另一个函数的模式。确保 read_tail() 函数的所有参数都是 read_head() 函数的结果似乎是有帮助的。
read_tail() 函数解析剩余行的迭代器。这些行仅按空格分割,因为这与 GPL 文件格式的描述相符。
更多信息,请访问以下链接:code.google.com/p/grafx2/issues/detail?id=518。
一旦我们将文件的每一行转换成规范化的字符串元组格式,我们就可以对这份数据应用高级解析。这涉及到转换(如果需要)和验证。
以下是一个高级解析器命令片段:
from collections.abc import Iterator
from typing import NamedTuple
class Color(NamedTuple):
red: int
blue: int
green: int
name: str
def color_palette(
headers: tuple[str, str],
row_iter: Iterator[list[str]]
) -> tuple[str, str, tuple[Color, ...]]:
name, columns = headers
colors = tuple(
Color(int(r), int(g), int(b), " ".join(name))
for r, g, b, *name in row_iter
)
return name, columns, colors
此函数将与低级row_iter_gpl()解析器的输出一起工作:它需要标题和单个行的迭代器。此函数将使用生成器中for子句的多个赋值功能,将颜色数字和剩余的单词分别分配到四个变量r、g、b和name中。使用*name参数确保所有剩余的值都将作为一个元组分配给name变量。然后"`` ".join(name)表达式将单词连接成一个空格分隔的字符串。
以下是如何使用这个双层解析器的说明:
>>> from pathlib import Path
>>> source_path = Path("crayola.gpl")
>>> with source_path.open() as source:
... name, cols, colors = color_palette(
... *row_iter_gpl(source)
... )
>>> name
’Crayola’
>>> cols
’16’
>>> len(colors)
133
我们已经将高级解析器应用于低级解析器的结果。这将返回标题和一个由Color对象序列构建的元组。
6.4 摘要
在本章中,我们探讨了两个重要的函数式编程主题。我们详细研究了递归。许多函数式编程语言编译器会优化递归函数,将函数尾部的调用转换为循环。这有时被称为尾递归消除。更常见的是,它被称为尾调用优化。在 Python 中,我们必须通过使用显式的for语句手动进行尾调用优化,以替换纯函数式递归。
我们还研究了包括sum()、count()、max()和min()函数在内的简化算法。我们研究了collections.Counter()函数和相关groupby()简化。
我们还研究了解析(和词法扫描)如何与简化相似,因为它们将标记序列(或字符序列)转换为具有更复杂属性的更高阶集合。我们检查了一个将解析分解为低级并尝试生成原始字符串元组的模式,以及一个创建更有用应用对象的更高级模式。
在下一章中,我们将探讨适用于处理命名元组和其它不可变数据结构的技巧。我们将探讨使状态对象变得不必要的技巧。虽然状态对象不是纯函数式的,但类层次结构的概念可以用来封装相关的方法定义。
6.5 练习
本章的练习基于 GitHub 上 Packt Publishing 提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。这些解决方案作为提示,允许读者探索替代方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已提供的单元测试用例相同。读者应将书籍中的示例函数名称替换为自己的解决方案以确认其工作。
6.5.1 多重递归和缓存
在处理困难的尾调用优化中,我们查看了一个计算斐波那契数的函数的原始定义,即fib()函数。functools.cache装饰器可以对算法的性能产生深远的影响。
实现两种版本,并描述缓存对计算大斐波那契数所需时间的影响。
6.5.2 重构 all_print()函数
在使用 deque 进行尾调用优化中,我们展示了一个使用collections.deque遍历目录树中所有节点并计算每个正确文件的值的函数。这也可以使用列表以及deque完成,只需进行一些小的代码更改。
此函数嵌入了一个特定的计算。这个计算(查找所有“print”的实例)实际上应该是一个单独的函数。all_print()函数的主体应该重构为两个函数:
-
一个通用的目录遍历,将一个函数应用于具有预期后缀的每个文本文件并汇总结果。
-
一个统计给定 Python 文件中“print”实例数量的函数。
6.5.3 解析 CSV 文件
请参阅本章前面的解析 CSV 文件部分。在那个例子中,简单的all_numeric()函数将零和None混淆。
为此函数创建一个测试用例,以显示它没有正确处理零,将其视为None。一旦测试用例定义好,重新编写all_numeric()函数以区分零和None。
注意,在 Python 中,使用is运算符与None进行比较是一种常见做法。这特别避免了当类有一个不正确处理None作为独立对象的__eq__()实现时可能出现的微妙问题。
6.5.4 状态分类,第三部分
请参阅第五章,高阶函数,状态分类练习。
消费状态详情并总结的第三种方法。
编写一个 reduce 计算。这从运行状态开始。随着每个服务的三元组折叠到结果中,状态与三元组之间进行比较。如果三元组包含一个无响应的服务,状态将前进到停止。如果三元组包含一个慢或无法工作的服务,状态将前进到降级。如果没有发现问题,初始值将成为整个系统的最终健康状态。
策略是提供一个 status_add(previous, this_service) 函数。这个函数可以在 status = reduce(status_add, service_status_sequence, "Running") 的上下文中使用,以计算服务序列的当前状态。
6.5.5 柴油机数据
一台柴油发动机进行了一些维修,这引起了人们对转速表准确性的怀疑。经过一些英勇的努力,收集到了以下表格中的数据,显示了发动机转速表上的观察读数,以及使用发动机上的光学设备测量的实际 RPM 值。
| 样本 | 转速 | 发动机 |
|---|---|---|
| 1 | 1000 | 883 |
| 2 | 1500 | 1242 |
| 3 | 1500 | 1217 |
| 4 | 1600 | 1306 |
| 5 | 1750 | 1534 |
| 6 | 2000 | 1805 |
| 7 | 2000 | 1720 |
如果需要,创建一个包含数据的 CSV 文件。如果您有访问本书 GitHub 仓库的权限,这些数据可以在 engine.csv 文件中找到。
为每个样本创建一个 NamedTuple,并编写一些函数以获取这些数据的有用形式。一旦数据可用,请参阅第四章,使用集合中的使用总和和计数进行统计部分,了解相关函数的定义。
目标是将此相关函数应用于发动机和转速值,以查看它们是否相关。如果它们相关,则表明发动机的仪表可以重新校准。如果不相关,则表明发动机存在其他问题。
注意,第四章,使用集合的相关示例可能对数据类型有一些假设,这些假设不一定适用于之前定义的 NamedTuple。如果需要,请重写类型提示或您的 NamedTuple 定义。请注意,编写完全通用的类型提示可能很困难,通常需要一些工作来解决差异。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于本书的信息:packt.link/dHrHU

第七章:7
复杂的无状态对象
我们所查看的许多示例要么是使用原子(或标量)对象的函数,要么是由小元组构建的相对简单的结构。我们经常可以利用 Python 的不可变 typing.NamedTuple 来构建复杂的数据结构。类样式的语法似乎比旧的 collections.namedtuple 语法更容易阅读。
面向对象编程的一个有益特性是能够增量地创建复杂的数据结构。在某种程度上,一个对象可以被视为函数结果的缓存;这通常与函数式设计模式很好地结合。在其他情况下,对象范式提供了包含从对象属性派生数据的复杂计算的属性方法。使用其他方面不可变的类的属性也适合于函数式设计思想。
在本章中,我们将探讨以下内容:
-
我们如何创建和使用
NamedTuple和冻结的@dataclass定义。 -
使用不可变的
NamedTuple或冻结的@dataclass对象代替有状态对象类的方法。 -
如何使用流行的第三方
pyrsistent包代替有状态对象类。这不是标准库的一部分,需要单独安装。 -
一些在没有任何多态类定义的情况下编写泛型函数的技术。虽然我们可以依赖可调用类来创建多态类层次结构,但在某些情况下,这可能在功能设计中是一个不必要的开销。这将涉及使用
match语句来识别类型或结构。
虽然冻结的数据类和 NamedTuple 子类几乎等价,但冻结的数据类省略了 NamedTuple 包含的序列特征。迭代 NamedTuple 对象的成员是一个令人困惑的特性;数据类不会受到这种潜在问题的困扰。
我们将通过查看使用 NamedTuple 子类来开始我们的旅程。
7.1 使用元组收集数据
在 第三章,函数、迭代器和生成器 中,我们展示了两种处理元组的常见技术。我们还暗示了处理复杂结构的第三种方法。我们可以根据具体情况选择以下任何一种技术:
-
使用 lambda(或使用
def语句创建的函数)根据索引选择一个命名项目 -
使用带多个位置参数的 lambda(或
def函数)与*args相结合,将项目元组分配给参数名称 -
使用
NamedTuple类通过属性名或索引选择一个项目
我们在 第四章,处理集合 中引入的行程数据具有相当复杂的结构。数据最初是一组位置报告的普通时间序列。为了计算覆盖的距离,我们将数据转置成一个包含起始位置、结束位置和距离作为嵌套三元的腿序列。
路段序列中的每个项目看起来如下,作为一个三元组:
>>> some_leg = (
... (37.549016, -76.330295),
... (37.840832, -76.273834),
... 17.7246
... )
前两个项目是起始点和结束点。第三个项目是两点之间的距离。这是一次在切萨皮克湾两点之间的短途旅行。
嵌套元组元组可能相当难以阅读;例如,some_leg[0][0]这样的表达式并不很有信息量。
让我们看看从元组中选择值的三种方法。第一种技术涉及定义一些简单的选择函数,这些函数可以通过索引位置从元组中挑选项目:
>>> start = lambda leg: leg[0]
>>> end = lambda leg: leg[1]
>>> distance = lambda leg: leg[2]
>>> latitude = lambda pt: pt[0]
>>> longitude = lambda pt: pt[1]
使用这些定义,我们可以使用latitude(start(some_leg))来引用特定的数据。代码示例如下:
>>> latitude(start(some_leg))
37.549016
为 lambda 表达式提供类型提示有些尴尬。以下显示了这如何变得复杂:
from collections.abc import Callable
from typing import TypeAlias
Point: TypeAlias = tuple[float, float]
Leg: TypeAlias = tuple[Point, Point, float]
start: Callable[[Leg], Point] = lambda leg: leg[0]
类型提示必须作为赋值语句的一部分提供。这告诉 mypy,名为start的对象是一个可调用的函数,它接受一个类型为Leg的单个参数,并返回一个Point类型的结果。使用def语句创建的函数通常具有更易于阅读的类型提示。
这种收集复杂数据的第一种技术的变体使用*parameter符号来隐藏索引位置的某些细节。以下是一些使用*符号评估的选择函数:
>>> start_s = lambda start, end, distance: start
>>> end_s = lambda start, end, distance: end
>>> distance_s = lambda start, end, distance: distance
>>> latitude_s = lambda lat, lon: lat
>>> longitude_s = lambda lat, lon: lon
使用这些定义,我们可以从元组中提取特定数据。我们使用了_s后缀来强调在评估这些 lambda 表达式时需要使用星号*。代码示例如下:
>>> longitude_s(*start_s(*some_leg))
-76.330295
这在函数定义中提供了一点点更清晰的优点。位置与名称之间的关系由参数名称列表给出。在选区函数的元组参数前看到*运算符可能看起来有点奇怪。这个运算符很有用,因为它将元组中的每个项目映射到函数的参数。
虽然这些功能非常强大,但选择单个属性的语法可能令人困惑。Python 提供了两种面向对象的替代方案,NamedTuple和冻结的@dataclass。
7.2 使用 NamedTuple 收集数据
将数据收集到复杂结构中的第二种技术是typing.NamedTuple。其想法是创建一个不可变的元组类,具有命名属性。有两种变体可用:
-
collections模块中的namedtuple函数。 -
typing模块中的NamedTuple基类。我们将几乎只使用它,因为它允许显式类型提示。
在以下示例中,我们将使用嵌套的NamedTuple类,如下所示:
from typing import NamedTuple
class PointNT(NamedTuple):
latitude: float
longitude: float
class LegNT(NamedTuple):
start: PointNT
end: PointNT
distance: float
这将数据结构从简单的匿名元组更改为具有为每个属性提供类型提示的命名元组。以下是一个示例:
>>> first_leg = LegNT(
... PointNT(29.050501, -80.651169),
... PointNT(27.186001, -80.139503),
... 115.1751)
>>> first_leg.start.latitude
29.050501
first_leg 对象被构建为 NamedTuple 类的 LegNT 子类。此对象包含两个其他命名元组对象和一个浮点值。使用 first_leg.start.latitude 将从元组结构内部获取特定的数据。从前缀函数名称到后缀属性名称的变化可以被视为一种有用的强调。它也可以被视为语法上的混淆变化。
名称中的 NT 后缀不是推荐的做法。
我们在书中加入了后缀,以明确区分定义有用类时类似外观的解决方案。
在实际应用中,我们会选择一个定义,并使用尽可能简单、清晰的名称,避免不必要的后缀,以免像这样的教科书变得杂乱。
用适当的 LegNT() 或 PointNT() 函数调用替换简单的 tuple() 函数很重要。这改变了构建数据结构的过程。它提供了一个具有类型提示的显式命名结构,该结构可以通过 mypy 工具进行检查。
例如,查看以下代码片段以从源数据创建点对:
from collections.abc import Iterable, Iterator
from Chapter04.ch04_ex1 import pick_lat_lon
def float_lat_lon_tuple(
row_iter: Iterable[list[str]]
) -> Iterator[tuple[float, float]]:
lat_lon_iter = (pick_lat_lon(*row) for row in row_iter)
return (
(float(lat), float(lon))
for lat, lon in lat_lon_iter
)
这需要一个可迭代的对象,其单个项是一个字符串列表。CSV 读取器或 KML 读取器可以做到这一点。pick_lat_lon() 函数从行中选取两个值。生成器表达式将 pick_lat_lon() 函数应用于数据源。最终的生成器表达式从两个字符串值创建了一个更有用的二元组。
之前的代码将更改为以下代码片段以创建 Point 对象:
from Chapter04.ch04_ex1 import pick_lat_lon
from typing import Iterable, Iterator
def float_lat_lon(
row_iter: Iterable[list[str]]
) -> Iterator[PointNT]:
#------
lat_lon_iter = (pick_lat_lon(*row) for row in row_iter)
return (
PointNT(float(lat), float(lon))
#------
for lat, lon in lat_lon_iter
)
PointNT() 构造函数已被注入到代码中。返回的数据类型已修订为 Iterator[PointNT]。很明显,这个函数构建的是 Point 对象,而不是匿名浮点坐标二元组。
同样,我们可以引入以下内容来构建完整的 LegNT 对象序列:
from collections.abc import Iterable, Iterator
from typing import cast, TextIO
import urllib.request
from Chapter04.ch04_ex1 import legs, haversine, row_iter_kml
source_url = "file:./Winter%202012-2013.kml"
def get_trip(url: str=source_url) -> list[LegNT]:
with urllib.request.urlopen(url) as source:
path_iter = float_lat_lon(row_iter_kml(source))
pair_iter = legs(path_iter)
trip_iter = (
LegNT(start, end, round(haversine(start, end), 4))
for start, end in pair_iter
)
trip = list(trip_iter)
return trip
处理被定义为一系列生成器表达式,每个表达式都是懒惰的,并且对单个对象进行操作。path_iter 对象使用两个生成器函数 row_iter_kml() 和 float_lat_lon() 来从 KML 文件中读取行,选择字段,并将它们转换为 Point 对象。pair_iter() 对象使用 legs() 生成器函数产生重叠的 Point 对象对,显示每条腿的起点和终点。
trip_iter 生成器表达式从 Point 对象的成对中创建最终的 LegNT 对象。这些生成的对象被 list() 函数消耗,以创建一个单一的腿列表。来自 第四章,处理集合 的 haversine() 函数用于计算距离。
在此函数中应用四舍五入有两个原因。首先,从实际的角度来看,0.0001 海里大约是 20 厘米(7 英寸)。从实用主义的角度来看,将海里四舍五入到 0.001 海里涉及更少的数字,这会给人一种虚假的精确感。其次——更重要的是——如果我们避免查看浮点数的所有数字,这将使单元测试在各个平台上的可靠性更高。
最终的trip对象是一系列LegNT实例。当我们尝试打印它时,它将如下所示:
>>> source_url = "file:./Winter%202012-2013.kml"
>>> trip = get_trip(source_url)
>>> trip[0].start
PointNT(latitude=37.54901619777347, longitude=-76.33029518659048)
>>> trip[0].end
PointNT(latitude=37.840832, longitude=-76.273834)
>>> trip[0].distance
17.7246
重要的是要注意,haversine()函数是为了使用简单的元组而编写的。我们已经使用NamedTuple类实例重用了这个函数。由于我们仔细地保留了参数的顺序,从匿名元组到命名元组的这种表示形式的小变化被 Python 优雅地处理了。
由于这是一个类定义,我们可以轻松地添加方法和属性。将功能添加到NamedTuple的能力使它们特别适用于计算派生值。例如,我们可以更直接地将距离计算作为Point类的一部分来实现,如下面的代码所示:
import math
class PointE(NamedTuple):
latitude: float
longitude: float
def distance(self, other: "PointE", R: float = 360*60/math.tau) -> float:
"""Equirectangular, ’flat-earth’ distance."""
Δϕ = (
math.radians(self.latitude) - math.radians(other.latitude)
)
Δλ = (
math.radians(self.longitude) - math.radians(other.longitude)
)
mid_ϕ = (
(math.radians(self.latitude) - math.radians(other.latitude))
/ 2
)
x = R * Δλ * math.cos(mid_ϕ)
y = R * Δϕ
return math.hypot(x, y)
给定PointE类的这个定义,我们已经封装了处理点和距离的功能。这可能很有帮助,因为它为读者提供了一个查找相关属性和方法的单个位置。
在PointE类的主体中,我们无法轻松地引用类。类名不存在于class语句的主体中。mypy 工具让我们可以使用字符串而不是类名来解析这些罕见的案例,当类需要引用自身时。
我们可以使用以下示例来使用这个类:
>>> start = PointE(latitude=38.330166, longitude=-76.458504)
>>> end = PointE(latitude=38.976334, longitude=-76.473503)
# Apply the distance() method of the start object...
>>> leg = LegNT(start, end, round(start.distance(end), 4))
>>> leg.start == start
True
>>> leg.end == end
True
>>> leg.distance
38.7805
在大多数情况下,NamedTuple类定义增加了清晰度。使用NamedTuple将导致从函数式前缀语法变为对象式后缀语法。
7.3 使用冻结的数据类收集数据
将数据收集到复杂结构中的第三种技术是冻结的@dataclass。其想法是创建一个包含命名属性的不可变集合的类。
沿用上一节的示例,我们可以有如下嵌套数据类:
from dataclasses import dataclass
@dataclass(frozen=True)
class PointDC:
latitude: float
longitude: float
@dataclass(frozen=True)
class LegDC:
start: PointDC
end: PointDC
distance: float
我们在类定义前使用了装饰器@dataclass(frozen=True)来创建一个不可变(称为“冻结”)的数据类。装饰器将为我们添加一些函数,构建一个相当复杂但无需我们提供任何其他内容的类定义。有关装饰器的更多信息,请参阅第十二章,装饰器设计技术。
这也改变了数据结构,从简单的匿名元组变为具有为每个属性提供类型提示的类定义。以下是一个示例:
>>> first_leg = LegDC(
... PointDC(29.050501, -80.651169),
... PointDC(27.186001, -80.139503),
... 115.1751)
>>> first_leg.start.latitude
29.050501
first_leg对象被构建为LegDC实例。此对象包含两个其他PointDC对象和一个浮点值。使用first_leg.start.latitude将检索对象的特定属性。
名称中的DC后缀不是一个推荐的做法。
我们在书中加入了后缀,以明确区分定义有用类时类似外观的解决方案。
在实际应用中,我们会选择一个定义,并使用尽可能简单、清晰的名字,避免不必要的后缀,以免像这样的教科书变得杂乱无章。
将()元组构造替换为适当的LegDC()或PointDC()构造函数,可以构建比匿名元组更复杂的数据结构。它提供了一个具有类型提示的显式命名结构,这些提示可以通过 mypy 工具进行检查。
比较冻结的数据类与NamedTuple实例可能会引发“哪个更好?”的讨论。这里有一些权衡。最值得注意的是,NamedTuple对象极其简单:它占用的内存相对较少,提供的方法也较少。另一方面,数据类可以拥有大量的内置功能,并且可以占用更多的内存。我们可以通过使用@dataclass装饰器的slots=True参数来管理这一点,我们将在本节稍后讨论。
此外,一个NamedTuple对象是一系列值的序列。我们可以使用元组属性的迭代器,这似乎只会造成混淆的处理选项。在不使用名字的情况下迭代值,违背了为元组的成员命名的基本设计概念。
评估内存使用的一个简单方法是创建数百万个类的实例,并查看为 Python 运行时分配了多少内存。这是因为 Python 对象的大小涉及到对所有相关对象的递归遍历,每个对象都有自己的复杂大小计算。通常,我们只关心大量对象的总内存使用,因此直接测量更为有效。
下面是一个类定义,用于支持一个旨在评估 100 万个NamedTuple对象大小的脚本:
from typing import NamedTuple
class LargeNT(NamedTuple):
a: str
b: int
c: float
d: complex
然后,我们可以定义一个函数来创建一百万个对象,将它们分配给变量big_sequence。然后该函数可以报告 Python 运行时分配的内存量。这个函数将涉及一些看起来很奇怪的开销。getallocatedblocks()函数的文档建议我们使用sys._clear_type_cache()函数清除类型缓存,并通过gc.collect()函数强制垃圾回收来清理不再被引用的对象。这两个步骤应该将内存压缩到最小尺寸,并提供关于该对象序列存储使用情况的更可重复的报告。
以下函数创建了一个给定类型的百万个实例,并显示了 Python 运行时分配的内存:
from typing import Type, Any
def sizing(obj_type: Type[Any]) -> None:
big_sequence = [
obj_type(f"Hello, {i}", 42*i, 3.1415926*i, i+2j)
for i in range(1_000_000)
]
sys._clear_type_cache()
gc.collect()
print(f"{obj_type.__name__} {sys.getallocatedblocks()}")
del big_sequence
使用不同的类定义评估此函数将揭示该类 100 万个对象占用的存储空间。我们可以使用sizing(LargeNT)来查看NamedTuple类占用的空间。
当然,我们需要定义替代方案。我们可以定义一个冻结的数据类。此外,我们可以使用@dataclass(frozen=True, slots=True)来查看__slots__的使用对对象大小的影响。类体必须具有相同的属性,并且顺序相同,以简化 sizing()函数构建对象。
实际结果高度依赖于具体实现,但作者在 macOS Python 3.10.0 上的结果显示如下:
|
|
|
| 类别 | 分配的块 |
|---|---|
| LargeNT | 5,035,408 |
| LargeDC | 7,035,404 |
| LargeDC_Slots | 5,035,569 |
| 基准 | 35,425 |
|
|
|
这表明@dataclass将比NamedTuple或带有slots=True的@dataclass使用大约 40%更多的内存。
这还表明,一种根本不同的设计——使用迭代器来避免创建大型内存集合——可以显著减少内存使用。重要的是要有一个正确的解决方案,然后探索替代实现,以查看哪种方法最有效地利用机器资源。
如何实现复杂的初始化是各种类定义方法之间最明显的区别。我们将在下一节中探讨这一点。
7.4 复杂对象初始化和属性计算
当处理无用的数据格式时,通常需要从具有不同结构或不同底层对象类型的源数据中构建 Python 对象。处理对象创建有两种总体方法:
-
这是整个应用程序的一部分。数据应由解析器分解,并重新组合成有用的 Python 对象。这是我们之前示例中采用的方法。
-
这是对象类定义的一部分。源数据应尽可能以原始形式提供,类定义将执行必要的转换。
这种区别从不简单,也不清晰。实用主义考虑将确定为从源数据构建 Python 对象的最佳方法。以下两个例子指出了可用的不同选择:
-
Point类:地理点的语法非常多变。一种常见的方法是简单的浮点度数。然而,一些来源提供度数和分钟。其他可能提供单独的度数、分钟和秒。此外,还有开放位置码,它编码纬度和经度。(更多信息请参阅maps.google.com/pluscodes/。)所有这些不同的解析器都不应成为类的一部分。 -
LegNT(或LegDC)类:腿包括两个点和一段距离。距离可以作为一个简单值来设置。它也可以作为一个属性来计算。第三种选择是使用一个复杂的对象构建器。实际上,我们的get_trip()函数(在使用 NamedTuple 收集数据中定义)已经隐式地包含了一个LegNT对象的构建器。
使用LegNT(start, end, round(haversine(start, end), 4))来创建LegNT实例并没有错,但它做了一些需要挑战的假设。以下是一些假设:
-
应用程序应该始终使用
haversine()来计算距离。 -
应用程序应该始终预先计算距离。这通常是一个优化问题。如果每个腿的距离都会被检查,那么计算一次并保存距离是有帮助的。如果距离不是总是需要的,那么仅在需要时计算距离可能更节省成本。
-
我们总是希望创建
LegNT实例。我们已经看到了可能需要@dataclass实现的情况。在下一节中,我们将查看pyrsistent.PRecord实现。
封装LegNT实例构建的一个通用方法是使用@classmethod来处理复杂的初始化。此外,@dataclass提供了一些额外的初始化技术。
下面是一个更好的定义NamedTuple初始化的例子:
from typing import NamedTuple
class EagerLeg(NamedTuple):
start: Point
end: Point
distance: float
@classmethod
def create(cls, start: Point, end: Point) -> "EagerLeg":
return cls(
start=start,
end=end,
distance=round(haversine(start, end), 4)
)
将上述立即计算距离的定义与以下延迟计算距离的定义进行比较:
from typing import NamedTuple
class LazyLeg(NamedTuple):
start: Point
end: Point
@property
def distance(self) -> float:
return round(haversine(self.start, self.end), 4)
@classmethod
def create(cls, start: Point, end: Point) -> "LazyLeg":
return cls(
start=start,
end=end
)
这两个类定义都有一个相同的create()方法。我们可以使用EagerLeg.create(start, end)或LazyLeg.create(start, end)而不会破坏应用中的其他部分。
最重要的是,决定是否立即计算值或延迟计算值,这个决定可以随时更改。我们可以替换这两个定义,看看哪个更适合我们特定应用的需求。同样,距离计算现在也是这个类的一部分,这使得定义一个子类来更改应用变得更加容易。
数据类提供了一个相对复杂且灵活的对象构建接口:一个__post_init__()方法。此方法在对象的字段值分配后执行,允许对派生值进行立即计算。然而,这对于冻结的数据类是不适用的。__post_init__()方法只能用于非冻结数据类,以便从提供的初始化值中立即计算额外的值。
对于数据类以及NamedTuple类,一个@classmethod创建器是一个好的设计模式,用于涉及立即计算属性值的初始化。
最后关于初始化的说明,创建命名元组对象有三种不同的语法形式。以下是三种选择:
-
我们可以按位置提供值。当参数的顺序明显时,这效果很好。看起来是这样的:
LegNT(start, end, round(haversine(start, end), 4)) -
我们可以使用
*运算符解包一个序列。这也要求参数的顺序明显。例如:PointNT(*map(float, pick_lat_lon(*row))) -
我们可以使用显式的关键字赋值。这有利于使参数名称清晰,并避免了关于排序的隐藏假设。以下是一个示例:
PointNT(longitude=float(row[0]), latitude=float(row[1]))
这些示例展示了封装复杂对象初始化的一种方法。重要的是要避免在这些对象中发生状态变化。复杂的初始化只执行一次,提供了一个单一、集中的地方来理解对象状态是如何建立的。因此,初始化必须能够表达对象的目的,同时也要灵活,以便允许变化。
7.5 使用 pyrsistent 收集数据
除了 Python 的NamedTuple和@dataclass定义之外,我们还可以使用pyrsistent模块创建更复杂的对象实例。pyrsistent模块提供的巨大优势是集合是不可变的。而不是就地更新,集合的更改通过一个通用“进化”对象来完成,该对象创建一个新的具有更改值的不可变对象。实际上,看似状态改变的方法实际上是一个创建新对象的运算符。
以下示例展示了如何导入pyrsistent模块并使用名称和值创建映射结构:
>>> import pyrsistent
>>> v = pyrsistent.pmap({"hello": 42, "world": 3.14159})
>>> v # doctest: +SKIP
pmap({’hello’: 42, ’world’: 3.14159})
>>> v[’hello’]
42
>>> v[’world’]
3.14159
我们不能改变这个对象的值,但我们可以将对象v进化为新的对象v2。这个对象具有相同的起始值,但还包括一个更改的属性值。看起来是这样的:
>>> v2 = v.set("another", 2.71828)
>>> v2 # doctest: +SKIP
pmap({’hello’: 42, ’world’: 3.14159, ’another’: 2.71828})
原始对象v是不可变的,其值没有改变:
>>> v # doctest: +SKIP
pmap({’hello’: 42, ’world’: 3.14159})
有助于将这个操作想象成有两个部分。首先,原始对象被克隆。在克隆之后,应用更改。在上面的例子中,使用了set()方法提供新的键和值。我们可以单独创建进化,并将其应用于对象以创建一个应用了更改的克隆。这似乎非常适合需要更改审计历史记录的应用程序。
注意,我们不得不防止使用两个示例作为单元测试用例。这是因为键的顺序是不固定的。很容易检查键和值是否符合我们的预期,但与字典字面量的简单比较并不总是有效。
PRecord类适用于定义复杂对象。这些对象在某些方面类似于NamedTuple。我们将使用PRecord实例重新审视我们的航点和路段数据模型。定义如下所示:
from pyrsistent import PRecord, field
class PointPR(PRecord): # type: ignore [type-arg]
latitude = field(type=float)
longitude = field(type=float)
class LegPR(PRecord): # type: ignore [type-arg]
start = field(type=PointPR)
end = field(type=PointPR)
distance = field(type=float)
每个字段定义都使用复杂的 field() 函数来构建属性的定义。除了类型序列之外,此函数还可以指定必须对值有效的不可变条件、初始值、字段是否为必需的、构建适当值的工厂函数以及将值序列化为字符串的函数。
名称中的 PR 后缀不是一个推荐的做法。
我们在书中包含了后缀,以明确区分类似的问题定义的有用类的类似解决方案。
在实际应用中,我们会选择一个定义,并使用尽可能简单、清晰的名称,避免不必要的后缀,以免像这样的教科书变得杂乱。
作为这些定义的扩展,我们可以使用序列化函数将点值转换为更有用的格式。这需要一些格式化细节,因为纬度和经度的显示方式略有不同。纬度包括 “N” 或 “S”,经度包括 “E” 或 “W”:
from math import isclose, modf
def to_dm(format: dict[str, str], point: float) -> str:
"""Use {"+": "N", "-": "S"} for latitude; {"+": "E", "-": "W"} for longitude."""
sign = "-" if point < 0 else "+"
ms, d = modf(abs(point))
ms = 60 * ms
# Handle the 59.999 case:
if isclose(ms, 60, rel_tol=1e-5):
ms = 0.0
d += 1
return f"{d:3.0f}{ms:.3f}’{format.get(sign, sign)}"
此函数可以作为 PointPR 类的字段定义的一部分包含;我们必须将函数作为 field() 工厂函数的 serializer= 参数提供:
from pyrsistent import PRecord, field
class PointPR_S(PRecord): # type: ignore[type-arg]
latitude = field(
type=float,
serializer=(
lambda format, value:
to_dm((format or {}) | {"+": "N", "-": "S"}, value)
)
)
longitude = field(
type=float,
serializer=(
lambda format, value:
to_dm((format or {}) | {"+": "E", "-": "W"}, value)
)
)
这让我们能够以优雅的格式打印一个点:
>>> p = PointPR_S(latitude=32.842833333, longitude=-79.929166666)
>>> p.serialize() # doctest: +SKIP
{’latitude’: " 32°50.570’N", ’longitude’: " 79°55.750’W"}
这些定义将提供与之前展示的 NamedTuple 和 @dataclass 示例几乎相同的处理能力。然而,我们可以利用 pyrsistent 包的一些附加功能来创建 PVector 对象,这些对象将是旅行中航点的不可变序列。这需要对先前应用程序进行一些小的更改。
使用 pyrsistent 定义 get_trip() 函数可能看起来像这样:
from collections.abc import Iterable, Iterator
from typing import TextIO
import urllib.request
from Chapter04.ch04_ex1 import legs, haversine, row_iter_kml
from pyrsistent import pvector
from pyrsistent.typing import PVector
source_url = "file:./Winter%202012-2013.kml"
def get_trip_p(url: str=source_url) -> PVector[LegPR]:
with urllib.request.urlopen(url) as source:
path_iter = float_lat_lon(row_iter_kml(source))
pair_iter = legs(path_iter)
trip_iter = (
LegPR(
start=PointPR.create(start._asdict()),
end=PointPR.create(end._asdict()),
distance=round(haversine(start, end), 4))
#--------------------------------------------
for start, end in pair_iter
)
trip = pvector(trip_iter)
#------
return trip
第一个更改相对较大。我们不是将 float_lat_lon() 函数重写为返回 PointPR 对象,而是保留了此函数。我们使用 PRecord.create() 方法将字典转换为 PointPR 实例。给定两个 PointPR 对象和距离,我们可以创建一个 LegPR 对象。
在本章的早期部分,我们展示了 legs() 函数的一个版本,该版本返回包含每条腿上每个点的原始数据的 typing.NamedTuple 实例。NamedTuple 的 _asdict() 方法将元组转换为字典。元组的属性名称将是字典中的键。这种转换可以在以下示例中看到:
>>> p = PointNT(2, 3)
>>> p._asdict()
{’latitude’: 2, ’longitude’: 3}
然后,可以将它提供给 PointPR.create() 方法来创建一个合适的 PointPR 实例,该实例将在应用程序的其余部分中使用。初始的 PointNT 对象可以被丢弃,因为它已经作为输入解析和构建更有用的 Python 对象之间的桥梁。从长远来看,重新审视底层的 legs() 函数并将其重写为与 pyrsistent 记录定义一起工作是一个好主意。
最后,我们不是从迭代器中组装一个list,而是使用pvector()函数组装一个PVector实例。这具有与内置列表类许多相同的属性,但不可变。任何更改都将创建对象的克隆。
这些高性能、不可变的集合是确保应用程序以函数式方式行为的有帮助的方法。这些便于序列化为 JSON 友好格式的类定义非常适合使用 JSON 的应用程序。特别是,Web 服务器可以从使用这些类定义中受益。
7.6 通过使用元组家族避免有状态类
在几个先前的例子中,我们展示了 wrap-unwrap 设计模式的概念,这种模式允许我们处理匿名和命名元组。这种设计的目的在于使用包装其他不可变对象的不可变对象,而不是可变实例变量。
两个数据集之间常见的统计相关度度量是斯皮尔曼秩相关度。这比较了两个变量的排名。我们不是试图比较值,这些值可能具有不同的度量单位,而是比较相对顺序。更多信息,请访问:www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/partraco.htm。
计算斯皮尔曼秩相关度需要为每个观测值分配一个秩值。看起来我们应该能够使用enumerate(sorted())来完成这个任务。给定两组可能相关的数据,我们可以将每组数据转换为一个秩值的序列,并计算相关度度量。
我们将应用 wrap-unwrap 设计模式来完成这个任务。我们将用计算相关系数的目的来包装数据项及其秩。
在第三章,函数、迭代器和生成器中,我们展示了如何解析一个简单的数据集。我们将按照以下方式从该数据集中提取四个样本:
>>> from Chapter03.ch03_ex4 import (
... series, head_map_filter, row_iter)
>>> from pathlib import Path
>>> source_path = Path("Anscombe.txt")
>>> with source_path.open() as source:
... data = list(head_map_filter(row_iter(source)))
结果数据集的每一行都组合了四个不同的数据系列。在第三章,列表、字典和集合的生成器中定义了一个series()函数,用于从整体行中提取给定系列的配对。
定义看起来是这样的:
def series(
n: int,
row_iter: Iterable[list[SrcT]]
) -> Iterator[tuple[SrcT, SrcT]]:
此函数的参数是一个源类型的可迭代对象(通常是一个字符串)。此函数的结果是从源类型生成的两个元组的可迭代序列。当处理 CSV 文件时,期望结果是命名元组。
每对都有一个命名元组:
from typing import NamedTuple
class Pair(NamedTuple):
x: float
y: float
我们将介绍一种转换,将匿名元组转换为命名元组或数据类:
from collections.abc import Callable, Iterable
from typing import TypeAlias
RawPairIter: TypeAlias = Iterable[tuple[float, float]]
pairs: Callable[[RawPairIter], list[Pair]] \
= lambda source: list(Pair(*row) for row in source)
RawPairIter类型定义描述了series()函数的中间输出。这个函数发出一个包含两个元组的可迭代序列。pairslambda 对象是一个可调用的对象,它期望一个可迭代对象,并将产生一个Pair命名元组或数据类实例的列表。
以下展示了如何使用pairs()函数和series()函数从原始数据创建对:
>>> series_I = pairs(series(0, data))
>>> series_II = pairs(series(1, data))
>>> series_III = pairs(series(2, data))
>>> series_IV = pairs(series(3, data))
这些序列中的每一个都是Pair对象的列表。每个Pair对象都有x和y属性。数据看起来如下:
>>> from pprint import pprint
>>> pprint(series_I)
[Pair(x=10.0, y=8.04),
Pair(x=8.0, y=6.95),
...
Pair(x=5.0, y=5.68)]
我们将排名排序问题分为两部分。首先,我们将查看一个通用的高阶函数,我们可以用它来为任何属性分配排名。例如,它可以按Pair对象的x或y属性值对样本进行排名。然后,我们将定义一个围绕Pair对象的包装器,包括各种排名顺序值。
到目前为止,这似乎是一个我们可以包裹每一对,按顺序排序,然后使用像enumerate()这样的函数来分配排名的地方。但结果是,这种方法并不是真正的排名排序算法。
虽然排名排序的本质是能够对样本进行排序,但这还有另一个重要部分。当两个观测值具有相同的值时,它们应该获得相同的排名。一般规则是平均相等观测值的位次。序列[0.8, 1.2, 1.2, 2.3, 18]应该有排名值 1, 2.5, 2.5, 4, 5。排名为 2 和 3 的两个平局值的中点值为 2.5,作为它们的共同排名。
这导致的结果是我们实际上并不需要对所有数据进行排序。相反,我们可以创建一个包含给定属性值及其共享属性值的所有样本的字典。所有这些项目都有一个共同的排名。给定这个字典,键需要按升序处理。对于某些数据集,键的数量可能比原始样本对象要少得多。
排名排序函数分为两个阶段:
-
首先,它构建一个字典,列出具有重复值的样本。我们可以称之为
build_duplicates()阶段。 -
其次,它按排名顺序发出一系列值,对于具有相同值的项,有一个平均排名顺序。我们可以称之为
rank_output()阶段。
以下函数通过两个嵌套函数实现了两阶段排序:
from collections import defaultdict
from collections.abc import Callable, Iterator, Iterable, Hashable
from typing import NamedTuple, TypeVar, Any, Protocol, cast
BaseT = TypeVar("BaseT", int, str, float)
DataT = TypeVar("DataT")
def rank(
data: Iterable[DataT],
key: Callable[[DataT], BaseT]
) -> Iterator[tuple[float, DataT]]:
def build_duplicates(
duplicates: dict[BaseT, list[DataT]],
data_iter: Iterator[DataT],
key: Callable[[DataT], BaseT]
) -> dict[BaseT, list[DataT]]:
for item in data_iter:
duplicates[key(item)].append(item)
return duplicates
def rank_output(
duplicates: dict[BaseT, list[DataT]],
key_iter: Iterator[BaseT],
base: int=0
) -> Iterator[tuple[float, DataT]]:
for k in key_iter:
dups = len(duplicates[k])
for value in duplicates[k]:
yield (base+1+base+dups)/2, value
base += dups
duplicates = build_duplicates(
defaultdict(list), iter(data), key
)
return rank_output(
duplicates,
iter(sorted(duplicates.keys())),
0
)
如我们所见,这个排名排序函数有两个内部函数,用于将样本列表转换为包含分配的排名和原始样本对象的元组列表。
为了保持数据结构类型提示简单,样本元组的基类型定义为BaseT,可以是任何字符串、整数或浮点类型。这里的本质成分是一个简单、可哈希且可比较的对象。
同样,DataT 类型是原始样本的任何类型;声称它将在整个函数中一致使用,并且有两个内部函数。这是一个故意模糊的声明,因为任何类型的 NamedTuple、dataclass 或 PRecord 都可以工作。
build_duplicates() 函数与一个有状态的对象一起工作,构建将键映射到值的字典。这种实现依赖于递归算法的尾调用优化。build_duplicates() 的参数将内部状态作为参数值暴露出来。递归定义的一个基本情况是当 data_iter 为空时。
同样,rank_output() 函数可以被递归定义,以将原始值集合作为带有分配的排名值的元组输出。下面展示的是一个包含两个嵌套 for 语句的优化版本。为了使排名值的计算明确,它包括范围的低端(base+1)、高端(base+dups),并计算这两个值的中间点。如果只有一个重复值,排名值是 (2*base+2)/2,它具有一般解的优势,即使在额外的计算中也会得到 base+1。
重复值的字典类型提示为 dict[BaseT, list[tuple[BaseT, ...]]],因为它将样本属性值 BaseT 映射到原始数据项类型 tuple[BaseT, ...] 的列表。
以下是如何测试以确保它工作的方法。第一个例子对单个值进行排名。第二个例子对一对列表进行排名,使用 lambda 从每个对中选取键值:
>>> from pprint import pprint
>>> data_1 = [(0.8,), (1.2,), (1.2,), (2.3,), (18.,)]
>>> ranked_1 = list(rank(data_1, lambda row: row[0]))
>>> pprint(ranked_1)
[(1.0, (0.8,)), (2.5, (1.2,)), (2.5, (1.2,)), (4.0, (2.3,)), (5.0, (18.0,))]
>>> from random import shuffle
>>> shuffle(data_1)
>>> ranked_1s = list(rank(data_1, lambda row: row[0]))
>>> ranked_1s == ranked_1
True
>>> data_2 = [(2., 0.8), (3., 1.2), (5., 1.2), (7., 2.3), (11., 18.)]
>>> ranked_2 = list(rank(data_2, key=lambda x: x[1],))
>>> pprint(ranked_2)
[(1.0, (2.0, 0.8)),
(2.5, (3.0, 1.2)),
(2.5, (5.0, 1.2)),
(4.0, (7.0, 2.3)),
(5.0, (11.0, 18.0))]
样本数据包含两个相同的值。结果排名将位置 2 和 3 分开,将位置 2.5 分配给两个值。这证实了该函数实现了计算两组值之间斯皮尔曼等级相关系数的常见统计惯例。
rank() 函数涉及到重新排列输入数据,作为发现重复值的一部分。如果我们想在每对中的 x 和 y 值上进行排名,我们需要对数据进行两次重新排序。
7.6.1 计算斯皮尔曼等级相关系数
斯皮尔曼等级相关系数是两个变量等级的比较。它巧妙地绕过了值的幅度,并且当关系不是线性的情况下,它通常可以找到相关性。公式如下:

这个公式表明,我们将对所有观察值对中的排名 r[x] 和 r[y] 的差异进行求和。这需要计算 x 和 y 变量的排名。这意味着将两个排名值合并成一个单一的复合对象,其中排名与原始原始样本结合。
目标类可能看起来如下:
class Ranked_XY(NamedTuple):
r_x: float
r_y: float
raw: Pair
这可以通过首先对一个变量进行排序,然后计算原始数据的第二个排序来实现。对于具有少量变量的简单数据集,这并不糟糕。对于超过几个变量的情况,这变得没有必要地复杂。特别是对于排序的功能定义,它们几乎都是相同的,这表明需要提取出公共代码。
依赖于使用pyrsistent模块来创建和演变字典中的值,效果会好得多,该字典累积排序值。我们可以使用一个包含排名字典和原始数据的PRecord对。排名的字典是一个不可变的PMap。这意味着任何尝试进行更改都将导致演变一个新的实例。
经过演变后,实例是不可变的。我们可以清楚地将状态的累积与处理没有进一步状态更改的对象分开。
这里是我们的包含排名映射和原始、原始数据的PRecord子类:
from pyrsistent import PRecord, field, PMap, pmap
class Ranked_XY(PRecord): # type: ignore [type-arg]
rank = field(type=PMap)
raw = field(type=Pair)
在每个Ranked_XY中,PMap字典提供了从变量名到排名值的映射。原始数据是原始样本。我们希望能够使用sample.rank[attribute_name]来提取特定属性的排名。
我们可以重用通用的rank()函数来构建包含排名和原始数据的基本信息。然后我们可以将每个新的排名合并到一个Ranked_XY实例中。以下函数定义将计算两个属性的排名:
def rank_xy(pairs: Sequence[Pair]) -> Iterator[Ranked_XY]:
data = list(Ranked_XY(rank=pmap(), raw=p) for p in pairs)
for attribute_name in (’x’, ’y’):
ranked = rank(
data,
lambda rxy: cast(float, getattr(rxy.raw, attribute_name))
)
data = list(
original.set(
rank=original.rank.set(attribute_name, r) # type: ignore [arg-type]
)
for r, original in ranked
)
yield from iter(data)
我们已经构建了一个包含空排名字典的Ranked_XY对象的初始列表。对于每个感兴趣的属性,我们将使用先前定义的rank()函数来创建一系列排名值和原始对象。生成器的for子句将排名的双元组分解为r(排名)和original(原始源数据)。
从底层rank()函数的每一对值中,我们对pyrsistent模块的数据结构进行了两项更改。我们使用了以下表达式来创建一个包含先前排名和新排名的新字典:
original.rank.set(attribute_name, r)
这个结果成为original.set(rank=...)表达式的一部分,用于创建一个新的Rank_XY对象,使用新演变的排名和PMap实例。
.set()方法是一个“演变器”:它通过将新状态应用于现有对象来创建一个新的对象。这些通过演变产生的变化很重要,因为它们导致新的、不可变的对象。
需要使用# type: ignore [arg-type]注释来抑制 mypy 警告。pyrsistent模块内部使用的类型信息对 mypy 不可见。
Python 版本的排名相关函数依赖于sum()和len()函数,如下所示:
from collections.abc import Sequence
def rank_corr(pairs: Sequence[Pair]) -> float:
ranked = rank_xy(pairs)
sum_d_2 = sum(
(r.rank[’x’] - r.rank[’y’]) ** 2 # type: ignore[operator, index]
for r in ranked
)
n = len(pairs)
return 1 - 6 * sum_d_2/(n * (n ** 2 - 1))
我们为每个Pair对象创建了Rank_XY对象。鉴于这一点,我们可以从这些对中减去r_x和r_y值来比较它们的差异。然后我们可以对差异进行平方和求和。
在本章前面,请参阅通过使用元组族避免状态类以了解Pair类的定义。
再次强调,我们不得不抑制与pyrsistent模块中缺乏详细内部类型提示相关的 mypy 警告。因为这样工作正常,所以我们有信心关闭这些警告。
一篇好的统计文章将提供关于系数含义的详细指导。数值约为 0 表示两个数据点的数据排名之间没有相关性。散点图显示点随机分布。数值约为+1 或-1 表示两个值之间存在强关系。成对的图形将显示一条清晰的直线或简单的曲线。
以下是基于安斯康姆四重奏系列的示例:
>>> data = [Pair(x=10.0, y=8.04),
... Pair(x=8.0, y=6.95),
... Pair(x=13.0, y=7.58), Pair(x=9.0, y=8.81),
... Pair(x=11.0, y=8.33), Pair(x=14.0, y=9.96),
... Pair(x=6.0, y=7.24), Pair(x=4.0, y=4.26),
... Pair(x=12.0, y=10.84), Pair(x=7.0, y=4.82),
... Pair(x=5.0, y=5.68)]
>>> round(pearson_corr(data), 3)
0.816
对于这个特定数据集,相关性很强。
在第四章中,使用集合,我们展示了如何计算皮尔逊相关系数。我们展示的函数corr()与两个单独的值序列一起工作。我们可以用以下方式使用我们的Pair对象序列:
from collections.abc import Sequence
from Chapter04.ch04_ex4 import corr
def pearson_corr(pairs: Sequence[Pair]) -> float:
X = tuple(p.x for p in pairs)
Y = tuple(p.y for p in pairs)
return corr(X, Y)
我们已经解包了Pair对象,以获取我们可以与现有的corr()函数一起使用的原始值。这提供了不同的相关系数。皮尔逊值基于标准化值在两个序列之间的比较程度。对于许多数据集,皮尔逊和斯皮尔曼相关系数之间的差异相对较小。然而,对于某些数据集,差异可能相当大。
为了看到拥有多个统计工具进行探索性数据分析的重要性,比较安斯康姆四重奏中的四组数据的斯皮尔曼和皮尔逊相关系数。
7.7 多态性和类型模式匹配
一些函数式编程语言提供了一些巧妙的方法来解决与静态类型函数定义一起工作的问题。问题是许多我们想写的函数在数据类型上完全是通用的。例如,我们的大多数统计函数对于int或float数字都是相同的,只要除法返回一个numbers.Real子类的值。Decimal、Fraction和float类型都应该几乎以相同的方式工作。在许多函数式语言中,编译器使用复杂的类型或类型模式匹配规则,允许单个通用定义适用于多个数据类型。
与静态类型函数式语言的(可能)复杂特性不同,Python 在方法上进行了戏剧性的改变。Python 使用基于所使用的数据类型的动态选择操作符最终实现。在 Python 中,我们总是编写通用定义。代码不绑定到任何特定的数据类型。Python 运行时会根据实际使用对象的类型定位适当的操作。语言参考手册中的 6.1.算术转换和 3.3.8.模拟数值类型部分以及标准库中的numbers模块提供了关于这种从操作到特殊方法名映射的详细信息。
在 Python 中,没有编译器来证明我们的函数期望并产生正确的数据类型。我们通常依赖于单元测试和 mypy 工具来进行这种类型检查。
在罕见的情况下,我们可能需要根据数据元素的类型有不同的行为。我们有两种方法来解决这个问题:
-
我们可以使用
match语句来区分不同的案例。这取代了比较参数值与类型的isinstance()函数序列。 -
我们可以创建提供方法替代实现的类层次结构。
在某些情况下,我们实际上需要同时进行操作,以便包括适当的数据类型转换。每个类都负责将参数值强制转换为它可以使用的类型。另一种选择是返回特殊的NotImplemented对象,这会强制 Python 运行时继续搜索实现操作并处理所需数据类型的类。
上一节中的排名示例紧密绑定到将等级排序应用于简单对的概念。它绑定到Pair类定义。虽然这是 Spearman 相关性的定义方式,但多元数据集需要对所有变量进行等级排序相关性。
我们首先需要做的是将我们的等级信息概念进行泛化。以下是一个处理等级和原始数据对象的NamedTuple值:
from typing import NamedTuple, Any
class RankData(NamedTuple):
rank_seq: tuple[float, ...]
raw: Any
我们可以提供一系列排名,每个排名都是相对于原始数据中的不同变量计算的。我们可能有一个数据点,对于’key1’属性值具有 2 的排名,而对于’key2’属性值具有 7 的排名。这种类定义的典型用法在以下示例中显示:
>>> raw_data = {’key1’: 1, ’key2’: 2}
>>> r = RankData((2, 7), raw_data)
>>> r.rank_seq[0]
2
>>> r.raw
{’key1’: 1, ’key2’: 2}
本例中的原始数据行是一个包含两个键的字典,这两个键对应于两个属性名称。在这个整体列表中,这个特定项目有两个排名。应用程序可以获取排名序列以及原始数据项。
我们将在我们的排名函数中添加一些语法糖。在许多之前的示例中,我们要求提供一个可迭代对象或具体的集合。for语句在处理这两个对象时都很优雅。然而,我们并不总是使用for语句,对于某些函数,我们不得不显式地使用iter()将可迭代集合转换为迭代器。(有时我们也被迫使用list()将可迭代对象实体化为具体的集合对象。)
回顾一下在第四章中展示的legs()函数,处理集合,我们看到了以下定义:
from collections.abc import Iterator, Iterable
from typing import Any, TypeVar
LL_Type = TypeVar(’LL_Type’)
def legs(
lat_lon_iter: Iterator[LL_Type]
) -> Iterator[tuple[LL_Type, LL_Type]]:
begin = next(lat_lon_iter)
for end in lat_lon_iter:
yield begin, end
begin = end
这只适用于Iterator对象。如果我们想使用序列,我们被迫插入iter(some_sequence)来从序列创建迭代器。这是令人烦恼且容易出错的。
处理这种情况的传统方式是使用isinstance()检查,如下面的代码片段所示:
from collections.abc import Iterator, Iterable, Sequence
from typing import Any, TypeVar
# Defined earlier
# LL_Type = TypeVar(’LL_Type’)
def legs_g(
lat_lon_src: Iterator[LL_Type] | Sequence[LL_Type]
) -> Iterator[tuple[LL_Type, LL_Type]]:
if isinstance(lat_lon_src, Sequence):
return legs_g(iter(lat_lon_src))
elif isinstance(lat_lon_src, Iterator):
begin = next(lat_lon_src)
for end in lat_lon_src:
yield begin, end
begin = end
else:
raise TypeError("not an Iterator or Sequence")
此示例包含一个类型检查,用于处理Sequence对象和Iterator之间的细微差异。具体来说,当参数值是一个序列时,legs()函数使用iter()将Sequence转换为Iterator,并递归地使用派生值调用自身。
这可以通过类型匹配以一种更优雅和更通用的方式完成。想法是使用match语句处理可变参数类型,并将所需的转换应用于可以处理的统一类型:
from collections.abc import Sequence, Iterator, Iterable
from typing import Any, TypeVar
# Defined earlier
# LL_Type = TypeVar(’LL_Type’)
def legs_m(
lat_lon_src: Iterator[LL_Type] | Sequence[LL_Type]
) -> Iterator[tuple[LL_Type, LL_Type]]:
match lat_lon_src:
case Sequence():
lat_lon_iter = iter(lat_lon_src)
case Iterator() as lat_lon_iter:
pass
case _:
raise TypeError("not an Iterator or Sequence")
begin = next(lat_lon_iter)
for end in lat_lon_iter:
yield begin, end
begin = end
此示例展示了我们如何匹配类型,使其能够使用序列或迭代器。以类似的方式可以实现许多其他类型匹配功能。例如,处理字符串或浮点值时,可以将字符串值强制转换为浮点数可能很有帮助。
结果表明,类型检查并不是解决这个特定问题的唯一方法。iter()函数可以应用于迭代器和具体集合。当iter()函数应用于迭代器时,它不执行任何操作并返回迭代器。当应用于集合时,它从集合中创建一个迭代器。
match语句的目标是避免使用内置的isinstance()函数。match语句提供了更多易于阅读的语法匹配选项。
7.8 摘要
在本章中,我们探讨了使用NamedTuple子类实现更复杂数据结构的多种方法。NamedTuple的基本特征与函数式设计非常契合。它们可以通过创建函数创建,并通过位置和名称访问。
类似地,我们将冻结的数据类视为NamedTuple对象的替代品。使用数据类的效果似乎略优于NamedTuple子类,因为数据类不会像属性值序列那样表现。
我们探讨了如何使用不可变对象代替有状态对象定义。替换状态变化的核心技术是将对象包装在包含派生值的大对象中。
我们还探讨了在 Python 中处理多种数据类型的方法。对于大多数算术运算,Python 的内部方法调度定位适当的实现。然而,要处理集合,我们可能想要使用 match 语句以稍微不同的方式处理迭代器和序列。
在接下来的两章中,我们将探讨 itertools 模块。这个标准库模块提供了一些函数,帮助我们以复杂的方式处理迭代器。这些工具中的许多是高阶函数的例子。它们可以帮助函数式设计保持简洁和表达性。
7.9 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅 github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包括一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已经提供的单元测试用例相同。读者应将书中的示例函数名替换为自己的解决方案,以确认其工作。
7.9.1 冻结字典
带有可选键值的字典可能成为混淆的状态变化管理来源。Python 对象的实现通常依赖于一个名为 __dict__ 的内部字典,以保持对象的属性值。这在应用程序代码中很容易镜像,并可能引起问题。
虽然字典更新可能会令人困惑,但之前描述的使用案例似乎很少见。字典的一个更常见的用途是从源加载映射,然后在后续处理中使用该映射。一个例子是包含从源编码到更有用的数值的映射的字典。它可能使用这样的映射值:{"y":1, "Y":1, "n":0, "N":0}。在这种情况下,字典只创建一次,之后不再改变状态。它实际上是冻结的。
Python 没有内置的冻结字典类。定义此类的一个方法是通过扩展内置的 dict 类,添加一个模式切换。这将有两种模式:“加载”和“查询”。在“加载”模式下,字典可以创建键和值。然而,在“查询”模式下,字典不允许更改。这包括拒绝返回到“加载”模式。这是额外一层有状态的行为,允许或拒绝底层映射行为。
字典类包含一系列特殊方法,如__setitem__()和update(),这些方法会改变内部状态。《Python 语言参考》第 3.3.7 节“模拟容器类型”提供了一个详细的方法列表,这些方法会改变映射的状态。此外,《库参考》中名为“映射类型 – dict”的章节提供了一个内置dict类的方法列表。最后,collections.abc模块也定义了一些映射必须实现的方法。
使用以下示例代码实现必须实现的方法列表:
def some_method(self, *args: Any, **kwargs: Any) -> None:
if self.frozen:
raise RuntimeError("mapping is frozen")
else:
super.some_method(*args, **kwargs)
给定需要这种类型包装器的方法列表,评论拥有一个冻结映射的价值。对比实现这个类所需的工作与状态字典可能引起的混淆。为编写这个类或搁置这个想法并寻找更好的解决方案提供成本效益的理由。回想一下,字典键查找非常快,依赖于哈希计算而不是漫长的搜索。
7.9.2 类似字典的序列
Python 没有内置的冻结字典类。定义这个类的一种方法是通过利用bisect模块构建一个列表。列表保持排序状态,bisect模块可以对排序列表进行相对快速搜索。
对于一个未排序的列表,在 n 个元素的序列中查找特定项的复杂度是 O(n)。对于已排序的列表,bisect模块可以将这个复杂度降低到 O(log [2]n),这对于大型列表来说是一个显著的时间减少。(当然,字典的哈希查找通常是 O(1),这更好。)
字典类包含一系列特殊方法,如__setitem__()和update(),这些方法会改变内部状态。前一个练习提供了一些定位所有与构建字典相关的特殊方法的提示。
一个构建排序列表的函数可以包装bisect.insort_left()。一个查询排序列表的函数可以利用bisect.bisect_left()定位并返回列表中与键关联的值,或者对于列表中不存在的项抛出KeyError异常。
构建一个小型演示应用程序,从源文件创建字典,然后从该字典中进行数千次随机检索。比较使用内置dict和基于bisect的类似字典列表运行演示所需的时间。
使用内置的sys.getallocatedblocks(),比较值列表和值字典使用的内存。为了使这个比较有意义,字典需要几千个键和值。一组随机数和随机生成的字符串可以用于这个比较。
7.9.3 修改 rank_xy()函数以使用原生类型
在计算斯皮尔曼等级相关部分,我们介绍了一个rank_xy()函数,该函数创建了一个包含各种排名位置的pyrsistent.PMap对象。这包含在一个PRecord子类中。
首先,重写函数(以及类型提示)以使用命名元组或数据类而不是PRecord子类。这用一个不可变对象替换了另一个。
接下来,考虑用原生 Python 字典替换PMap对象。由于字典是可变的,在添加新的排名值之前需要创建字典的副本,需要额外的哪些处理?
在将PMap对象修订为字典后,比较pyrsistent对象与原生对象的性能。你能得出什么结论?
7.9.4 修订 rank_corr()函数
在多态和类型模式匹配部分,我们介绍了一种创建包含排名和底层Rank_Data对象(具有原始样本值)的RankedSample对象的方法。
重写rank_corr()函数以计算RankedSample对象rank_seq属性中任何可用值的等级相关性。
7.9.5 修订 legs()函数以使用 pyrsistent
在使用 pyrsistent 收集数据部分,从早期示例中重用了许多函数。特别提到了legs()函数。然而,整个解析管道可以被重写以使用pyrsistent对基本不可变对象类的变体。
在进行修订后,解释使用一个模块一致地用于数据收集的代码中的任何改进。创建一个应用程序,该应用程序多次加载并计算行程的距离。使用各种表示形式并累积计时数据,以查看哪种(如果有的话)更快。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第八章:8
Itertools 模块
函数式编程强调无状态对象。在 Python 中,这让我们转向使用生成器表达式、生成器函数和可迭代对象,而不是大型可变集合对象。在本章中,我们将探讨itertools库的元素。这个库有众多函数帮助我们处理可迭代对象序列以及集合对象。
我们在第三章,函数、迭代器和生成器中介绍了迭代器函数。在本章中,我们将在此基础上进行扩展。我们在第五章,高阶函数中使用了相关函数。
itertools模块中有大量的迭代器函数。我们将在下一章中检查组合函数。在本章中,我们将查看以下三个广泛的迭代器函数分组:
-
与可能无限迭代器一起工作的函数。这些可以应用于任何可迭代对象或任何集合的迭代器。例如,
enumerate()函数不需要可迭代对象中项目数量的上限。 -
与有限迭代器一起工作的函数。通常,这些函数用于创建源数据的简化。例如,将迭代器产生的项目分组可以简化源数据为具有共同键的项目组。
-
tee()迭代器函数可以将一个迭代器克隆成多个副本,每个副本都可以独立使用。这提供了一种克服 Python 迭代器主要限制的方法:它们只能使用一次。然而,这需要大量内存,并且通常需要重新设计。
我们需要强调我们在其他地方提到的重要限制:它们只能使用一次。
可迭代对象只能使用一次。
这可能令人惊讶,因为尝试重复使用已经完全消耗的迭代器不会引发错误异常。一旦耗尽,它们似乎没有元素,并且每次使用时都只会引发StopIteration异常。
迭代器还有一些不涉及如此深刻限制的特性。请注意,许多 Python 函数以及for语句都会使用内置的iter()函数从一个集合对象创建所需的迭代器数量。
迭代器的其他特性包括:
-
迭代器没有
len()函数。 -
迭代器是可迭代子类,可以进行
next()操作,与容器不同。我们通常会使用内置的iter()函数来创建一个具有next()操作的迭代器。 -
for语句通过评估内置的iter()函数,使得容器和其他可迭代对象之间的区别变得不可见。例如,一个容器对象,如列表,会通过产生一个迭代器来响应这个函数。一个不是集合的可迭代对象,例如生成器函数,会返回自身,因为它被设计成遵循Iterator协议。
这些要点将为本章提供一些必要的背景。itertools模块的思路是利用可迭代对象能做什么来创建简洁、表达力强的应用程序,而不需要与可迭代对象管理的细节相关的复杂开销。
8.1 使用无限迭代器
itertools模块提供了一些函数,我们可以使用这些函数来增强或丰富数据源的可迭代性。我们将查看以下三个函数:
-
count(): 这是range()函数的无限制版本。这个序列的消费者必须指定一个上限。 -
cycle(): 这个函数将重复一个值的循环。消费者必须决定何时产生足够多的值。 -
repeat(): 这个函数可以无限次地重复一个单一值。消费者必须结束重复。
我们的目标是理解如何使用这些不同的迭代器函数在生成器表达式中以及与生成器函数一起使用。
8.1.1 使用count()进行计数
内置的range()函数由一个上限定义:下限和步长值是可选的。另一方面,count()函数有一个起始值和可选的步长,但没有上限。
这个函数可以被认为是类似内置的enumerate()函数的原生基础。我们可以用zip()和count()函数定义enumerate()函数,如下所示:
>>> from itertools import count
>>> enumerate = lambda x, start=0: zip(count(start), x)
enumerate()函数的行为就像是一个使用count()函数来生成与某些可迭代对象源相关值的zip()函数。
因此,以下两个表达式是等价的:
>>> list(zip(count(), iter(’word’)))
[(0, ’w’), (1, ’o’), (2, ’r’), (3, ’d’)]
>>> list(enumerate(iter(’word’)))
[(0, ’w’), (1, ’o’), (2, ’r’), (3, ’d’)]
这两个函数都会输出一个由二元组组成的序列。每个元组的第一个元素是一个整数计数器。第二个元素来自迭代器。在这个例子中,迭代器是由一个字符序列构建的。
下面是我们可以用count()函数做的一些用enumerate()函数难以做到的事情:
>>> list(zip(count(1, 3), iter(’word’)))
[(1, ’w’), (4, ’o’), (7, ’r’), (10, ’d’)]
count(b, s)的值是值序列 {b, b + s, b + 2s, b + 3s,...}。在这个例子中,它将提供 1, 4, 7, 10 等值作为枚举器每个值的标识符。enumerate()函数不提供改变步长的方法。
我们当然可以将生成器函数组合起来以实现这个结果。以下是使用enumerate()函数改变步长的方法:
>>> source = iter(’word’)
>>> gen3 = ((1+3*e, x) for e, x in enumerate(source))
>>> list(gen3)
[(1, ’w’), (4, ’o’), (7, ’r’), (10, ’d’)]
这显示了如何从枚举值的源值 e 计算出一个新值 1 + 3e。这表现得像是从 1 开始的序列,每次增加 3。
8.1.2 使用浮点数参数进行计数
count() 函数允许非整数值。我们可以使用 count(0.5,`` 0.1) 这样的表达式来提供浮点值。如果增量值没有精确表示,这将累积误差。通常,使用整数 count() 参数,如 (0.5+x*.1`` for`` x`` in`` count()),以确保表示错误不会累积。
这里有一种检查累积误差的方法。这种对浮点近似的探索展示了有趣的函数式编程技术。
我们将定义一个函数,该函数将评估迭代器中的项,直到满足某个条件。这是一种找到满足由函数定义的某些标准的第一项的方法。以下是如何定义一个 find_first() 函数的示例:
from collections.abc import Callable, Iterator
from typing import TypeVar
T = TypeVar("T")
def find_first(
terminate: Callable[[T], bool],
iterator: Iterator[T]
) -> T:
i = next(iterator)
if terminate(i):
return i
return find_first(terminate, iterator)
这个函数首先从迭代器对象中获取下一个值。没有提供特定类型;类型变量 T 告诉 mypy 源迭代器和目标结果将是相同类型的。如果选定的项通过了测试,即这是期望的值,迭代将停止,返回值将是与类型变量 T 关联的给定类型。否则,我们将递归地评估这个函数以寻找通过测试的后续值。
因为尾调用递归没有被替换为优化的 for 语句,所以这限制在大约 1,000 个项的迭代器上。
如果我们有一个由生成器计算的一系列值,这将消耗迭代器中的项。以下是一个愚蠢的例子。假设我们有一个近似值,它是一系列值的和。一个例子是:

这个序列的项可以通过如下生成器函数创建:
>>> def term_iter():
... d = 1
... sgn = 1
... while True:
... yield Fraction(sgn, d)
... d += 2
... sgn = -1 if sgn == 1 else 1
这将产生类似 Fraction(1,`` 1),Fraction(-1,`` 3),Fraction(1,`` 5),和 Fraction(-1,`` 7) 这样的值。它将产生无限多个这样的值。我们想要直到第一个满足某些标准的值。例如,我们可能想知道第一个将小于
的值(这用铅笔和纸很容易计算出结果):
>>> find_first(lambda v: abs(v) < 1E-2, term_iter())
Fraction(1, 101)
我们的目标是将使用浮点值计数与使用整数值计数进行比较,然后应用缩放因子。我们想要定义一个同时包含这两个序列对的源。作为对这个概念的一个介绍,我们将查看从两个并行源生成对。然后我们将回到上面的计算。
在以下示例中,source 对象是纯浮点值和整型到浮点值对的生成器:
from itertools import count
from collections.abc import Iterator
from typing import NamedTuple, TypeAlias
Pair = NamedTuple(’Pair’, [(’flt_count’, float), (’int_count’, float)])
Pair_Gen: TypeAlias = Iterator[Pair]
source: Pair_Gen = (
Pair(fc, ic) for fc, ic in
zip(count(0, 0.1), (.1*c for c in count()))
)
def not_equal(pair: Pair) -> bool:
return abs(pair.flt_count - pair.int_count) > 1.0E-12
Pair 元组将包含两个浮点值:一个是通过求和浮点值生成的,另一个是通过计数整数并乘以一个浮点缩放因子生成的。
生成器 source 在赋值语句中提供了一个类型提示,以表明它遍历对。
当我们评估find_first(not_equal, source)方法时,我们会反复比较十进制值的浮点近似,直到它们不同。一个是 0.1 值的总和:0.1 × ∑ [x∈ℕ]1. 另一个是整数值的总和,乘以 0.1:∑ [x∈ℕ]0.1. 作为抽象的数学定义,它们之间没有区别。
我们可以将其形式化为以下内容:

然而,当我们对抽象数字进行具体近似时,这两个值将会有所不同。结果如下:
>>> find_first(not_equal, source)
Pair(flt_count=92.799999999999, int_count=92.80000000000001)
经过大约 928 次迭代后,错误位的总和累积到 10^(−12)。这两个值都没有确切的二进制表示。
find_first()函数示例接近 Python 的递归限制。我们需要重写该函数以使用尾调用优化来定位具有更大累积错误值的示例。
我们将此作为练习留给读者。
最小可检测的差异可以计算如下:
>>> source: Pair_Gen = map(Pair, count(0, 0.1), (.1*c for c in count()))
>>> find_first(lambda pair: pair.flt_count != pair.int_count, source)
Pair(flt_count=0.6, int_count=0.6000000000000001)
这使用了一个简单的相等检查而不是错误范围。经过六步后,count(0, 0.1)方法累积了一个微小但可测量的错误 10^(−16)。虽然很小,但这些错误值可能会累积并变得更重要和明显,尤其是在更长的计算中。当查看如何将
表示为二进制值时,需要无限二进制展开。这被截断到大约 10^(−16) ≈ 2^(−53)的概念值。神奇的数字 53 是 IEEE 标准 64 位浮点值中可用的位数。
这就是为什么我们通常用普通整数计数,并应用权重来计算浮点值。
8.1.3 使用 cycle() 重复周期
cycle()函数重复一系列值。这可以在通过在数据集标识符之间循环来划分数据子集时使用。
我们可以想象用它来解决愚蠢的 fizz-buzz 问题。访问rosettacode.org/wiki/FizzBuzz获取一个相当简单编程问题的综合解决方案集。还可以查看projecteuler.net/problem=1了解这个主题的一个有趣变化。
我们可以使用cycle()函数来发出True和False值的序列,如下所示:
>>> from itertools import cycle
>>> m3 = (i == 0 for i in cycle(range(3)))
>>> m5 = (i == 0 for i in cycle(range(5)))
这两个生成器表达式可以产生具有模式[True, False, False, True, False, False, ...]或[True, False, False, False, False, True, False, False, False, False, False, ...]的无限序列。这些是迭代器,只能消费一次。它们倾向于保持其内部状态。如果我们不精确消费 15 个值,即它们周期的最小公倍数,下一次我们消费值时,它们将处于一个意外的中间状态。
如果我们将有限集合的数字和这两个导出值一起压缩,我们将得到一个包含数字、三个真-假条件乘数和一个五个真-假条件乘数的三个元组集合。引入一个有限的迭代器来创建数据生成量的适当上限是很重要的。以下是一系列值及其乘数条件:
>>> multipliers = zip(range(10), m3, m5)
这是一个生成器;我们可以使用 list(multipliers) 来查看结果对象。它看起来像这样:
>>> list(multipliers)
[(0, True, True), (1, False, False), (2, False, False), ..., (9, True,
False)]
现在,我们可以分解三元组,并使用过滤器来传递是倍数的数字,拒绝所有其他数字:
>>> multipliers = zip(range(10), m3, m5)
>>> total = sum(i
... for i, *multipliers in multipliers
... if any(multipliers)
... )
for 子句将每个三元组分解为两部分:值 i 和标志 multipliers。如果任何一个乘数是真实的,则值会被传递;否则,它会被拒绝。
cycle() 函数在探索性数据分析中还有另一个更有价值的用途。
使用 cycle() 进行数据抽样
我们经常需要处理大量数据集的样本。清洗和模型创建的初始阶段最好用小数据集开发,并用越来越大的一组数据集进行测试。我们可以使用 cycle() 函数从更大的数据集中公平地选择行。这与随机选择并信任随机数生成器的公平性是不同的。因为这种方法是可重复的,并且不依赖于随机数生成器,所以它可以应用于由多台计算机处理的大型数据集。
给定一个种群大小,N[p],和所需的样本大小,N[s],这是将产生适当子集的周期大小,c:

我们将假设数据可以用像 csv 模块这样的通用库解析。这导致了一种创建子集的优雅方法。给定 cycle_size 的值和两个打开的文件,source_file 和 target_file,我们可以使用以下函数定义来创建子集:
from collections.abc import Iterable, Iterator
from itertools import cycle
from typing import TypeVar
DT = TypeVar("DT")
def subset_iter(
source: Iterable[DT], cycle_size: int
) -> Iterator[DT]:
chooser = (x == 0 for x in cycle(range(cycle_size)))
yield from (
row
for keep, row in zip(chooser, source)
if keep
)
subset_iter() 函数使用基于选择因子 cycle_size 的 cycle() 函数。例如,我们可能有一个一千万条记录的种群;一个 1000 条记录的子集将通过将 cycle_size 设置为 c =
= 10,000 来构建。我们会保留每万条记录中的一条。
subset_iter() 函数可以被一个从源文件读取并写入目标文件的子集的函数使用。这种处理是以下函数定义的一部分:
import csv
from pathlib import Path
def csv_subset(
source: Path, target: Path, cycle_size: int = 3
) -> None:
with (
source.open() as source_file,
target.open(’w’, newline=’’) as target_file
):
rdr = csv.reader(source_file, delimiter=’\t’)
wtr = csv.writer(target_file)
wtr.writerows(subset_iter(rdr, cycle_size))
我们可以使用这个生成器函数通过 cycle() 函数和从 csv 读取器可用的源数据来过滤数据。由于选择表达式和用于写入行的表达式都不是严格的,这种处理几乎没有内存开销。
我们也可以将这种方法重写为使用 compress()、filter() 和 islice() 函数,正如我们将在本章后面看到的。
这种设计也可以用来将任何非标准的 CSV-like 格式重新格式化为标准化的 CSV 格式。只要我们定义一个解析函数,该函数返回一致定义的字符串元组,并编写将元组写入目标文件的消费者函数,我们就可以用相对简短、清晰的脚本进行大量的清洗和过滤。
8.1.4 使用 repeat() 重复单一值
repeat() 函数看起来像是一个奇特的功能:它反复返回一个单一值。当需要单一值时,它可以作为 cycle() 函数的替代。
使用这个表达式可以表达选择所有数据与选择数据子集之间的差异。表达式 (x==0 for x in cycle(range(size))) 会发出 [True, False, False, ...] 的模式,适合用于选择子集。函数 (x==0 for x in repeat(0)) 会发出 [True, True, True, ...] 的模式,适合选择所有数据。
我们可以思考以下类型的命令:
from itertools import cycle, repeat
def subset_rule_iter(
source: Iterable[DT], rule: Iterator[bool]
) -> Iterator[DT]:
return (
v
for v, keep in zip(source, rule)
if keep
)
all_rows = lambda: repeat(True)
subset = lambda n: (i == 0 for i in cycle(range(n)))
这允许我们通过单次参数更改来选择所有数据或选择数据子集。我们还可以使用 cycle([True]) 而不是 repeat(True);结果相同。
这种模式可以扩展到随机化选择的子集。以下技术增加了一种额外的选择方式:
import random
def randomized(limit: int) -> Iterator[bool]:
while True:
yield random.randrange(limit) == 0
randomized() 函数在给定范围内生成一个可能无限长的随机数序列。这符合 cycle() 和 repeat() 的模式。
这允许代码如下:
>>> import random
>>> random.seed(42)
>>> data = [random.randint(1, 12) for _ in range(12)]
>>> data
[11, 2, 1, 12, 5, 4, 4, 3, 12, 2, 11, 12]
>>> list(subset_rule_iter(data, all_rows()))
[11, 2, 1, 12, 5, 4, 4, 3, 12, 2, 11, 12]
>>> list(subset_rule_iter(data, subset(3)))
[11, 12, 4, 2]
>>> random.seed(42)
>>> list(subset_rule_iter(data, randomized(3)))
[2, 1, 4, 4, 3, 2]
这为我们提供了使用各种技术选择子集的能力。在 all()、subset() 和 randomized() 等可用函数之间进行的小幅改动,让我们能够以简洁和表达清晰的方式改变我们的采样方法。
8.2 使用有限迭代器
itertools 模块提供了一些函数,我们可以使用这些函数来生成有限值的序列。我们将在这个模块中查看 10 个函数,以及一些相关的内置函数:
-
enumerate(): 这个函数实际上是__builtins__包的一部分,但它与迭代器一起工作,并且与itertools模块中的函数非常相似。 -
accumulate(): 这个函数返回输入可迭代对象的序列化简。它是一个高阶函数,可以进行各种巧妙的计算。 -
chain(): 这个函数将多个可迭代对象按顺序组合。 -
groupby(): 这个函数使用一个函数将单个可迭代对象分解成一系列的可迭代对象,这些对象覆盖输入数据的子集。 -
zip_longest(): 这个函数将多个可迭代对象中的元素组合起来。内置的zip()函数会在最短的可迭代对象长度处截断序列。zip_longest()函数会用给定的填充值填充较短的序列。 -
compress(): 这个函数根据第二个布尔值并行可迭代的值过滤一个可迭代对象。 -
islice(): 当应用于可迭代对象时,这个函数等同于序列的切片。 -
dropwhile()和takewhile():这两个函数都使用一个布尔函数来过滤可迭代对象中的项。与filter()或filterfalse()不同,这些函数依赖于单个True或False值来改变它们对所有后续值的过滤行为。 -
filterfalse():这个函数将一个过滤函数应用于可迭代对象。这补充了内置的filter()函数。 -
starmap():这个函数将一个函数映射到一个元组可迭代的序列,将每个可迭代序列作为给定函数的*args参数。map()函数以类似的方式使用多个并行可迭代序列。
我们将从可能被视为用于对Iterator中的项目进行分组或排列的函数开始。之后,我们将查看更适合过滤和映射项目的函数。
8.2.1 使用 enumerate()分配数字
在第四章的使用 enumerate()包含序列号部分,我们在处理集合中使用了enumerate()函数对排序数据进行原始的排名分配。我们可以执行如下操作:将一个值与其在原始序列中的位置配对。
>>> raw_values = [1.2, .8, 1.2, 2.3, 11, 18]
>>> tuple(enumerate(sorted(raw_values)))
((0, 0.8), (1, 1.2), (2, 1.2), (3, 2.3), (4, 11), (5, 18))
这将按顺序对raw_values中的项目进行排序,创建包含递增数字序列的二重元组,并具体化一个我们可以用于进一步计算的对象。
在第七章的复杂无状态对象中,我们实现了一种enumerate()函数的替代形式,即rank()函数,它以更统计上有用的方式处理平局。
对数据行进行枚举是添加到解析器中记录源数据行号的常见功能。在许多情况下,我们将创建某种row_iter()函数从源文件中提取字符串值。这可能遍历 XML 文件的标签中的字符串值或 CSV 文件的列中的字符串值。在某些情况下,我们甚至可能解析使用 Beautiful Soup 解析的 HTML 文件中的数据。
在第四章的处理集合中,我们解析了一个 XML 文件以创建一个简单的位置元组序列。然后我们创建了带有起始点、终点和距离的腿。然而,我们没有分配一个明确的腿号。如果我们对行程集合进行排序,我们将无法确定腿的原始顺序。
在第七章的复杂无状态对象中,我们扩展了基本解析器,为旅行的每一腿创建了命名元组。这个增强解析器的输出如下所示:
>>> from textwrap import wrap
>>> from pprint import pprint
>>> trip[0]
LegNT(start=PointNT(latitude=37.54901619777347, longitude=-76.33029518659048), ...
>>> pprint(wrap(str(trip[0])))
[’LegNT(start=PointNT(latitude=37.54901619777347,’,
’longitude=-76.33029518659048), end=PointNT(latitude=37.840832,’,
’longitude=-76.273834), distance=17.7246)’]
>>> pprint(wrap(str(trip[-1])))
[’LegNT(start=PointNT(latitude=38.330166, longitude=-76.458504),’,
’end=PointNT(latitude=38.976334, longitude=-76.473503),’,
’distance=38.8019)’]
trip[0]的值相当宽,对于这本书来说太宽了。为了保持输出形式适合这本书的页面,我们已将值的字符串表示形式包装起来,并使用pprint来显示单独的行。第一个Leg对象是在切萨皮克湾两点之间的一段短途旅行。
我们可以添加一个函数,该函数将构建一个更复杂的元组,其中输入顺序信息作为元组的一部分。首先,我们将定义 Leg 类的一个稍微复杂一些的版本:
from typing import NamedTuple
class Point(NamedTuple):
latitude: float
longitude: float
class Leg(NamedTuple):
order: int
start: Point
end: Point
distance: float
Leg 的定义与 第七章 中展示的变体类似,复杂无状态对象,特别是 LegNT 的定义。我们将定义一个函数,该函数分解成对并创建 Leg 实例,如下所示:
from typing import Iterator
from Chapter04.ch04_ex1 import haversine
def numbered_leg_iter(
pair_iter: Iterator[tuple[Point, Point]]
) -> Iterator[Leg]:
for order, pair in enumerate(pair_iter):
start, end = pair
yield Leg(
order,
start,
end,
round(haversine(start, end), 4)
)
我们可以使用此函数来枚举每个起点和终点对。我们将分解这对,然后重新组装 order、start 和 end 参数以及 haversine(start,end) 参数的值作为一个单一的 Leg 实例。这个生成器函数将与成对的迭代序列一起工作。
在前面的解释背景下,它被如下使用:
>>> from Chapter06.ch06_ex3 import row_iter_kml
>>> from Chapter04.ch04_ex1 import legs, haversine
>>> import urllib.request
>>> source_url = "file:./Winter%202012-2013.kml"
>>> with urllib.request.urlopen(source_url) as source:
... path_iter = float_lat_lon(row_iter_kml(source))
... pair_iter = legs(path_iter)
... trip_iter = numbered_leg_iter(pair_iter)
... trip = list(trip_iter)
我们已将原始文件解析为路径点,创建了起点-终点对,然后创建了一个由单个 Leg 对象组成的行程。enumerate() 函数确保可迭代序列中的每个项目都被赋予一个唯一的数字,该数字从默认的起始值 0 开始递增。enumerate() 函数的第二个参数值可以提供不同的起始值。
8.2.2 使用 accumulate() 计算累计总和
accumulate() 函数将给定的函数折叠到可迭代对象中,累积一系列的减少。这将遍历另一个迭代器的累计总和;默认函数是 operator.add()。我们可以提供替代函数来改变从总和到乘积的基本行为。Python 库文档显示了一个特别巧妙的使用 max() 函数来创建迄今为止的最大值序列。
累计总和的一个应用是四分位数数据。四分位数是许多位置度量之一。一般方法是乘以样本值乘以一个缩放因子,将其转换为四分位数。如果值范围从 0 ≤ v[i] < N,我们可以通过 ⌈
⌉ 缩放,将任何值 v[i] 转换为 0 到 3 范围内的值,这些值映射到不同的四分位数。math.ceil() 函数用于将缩放分数向上舍入到下一个更高的整数。这将确保没有缩放值会产生缩放结果为 4,这是不可能的第五个四分位数。
如果 v[i] 的最小值不为零,我们需要在乘以缩放因子之前从每个值中减去这个值。
在 使用 enumerate() 分配数字 部分,我们介绍了一系列经纬度坐标,描述了航程上的连续腿。我们可以使用距离作为航点的四分位数的基础。这允许我们确定旅途中的中点。
有关 trip 变量的值,请参阅上一节。该值是一个 Leg 实例的序列。每个 Leg 对象都有一个起点、一个终点和一个距离。四分位数的计算如下代码所示:
>>> from itertools import accumulate
>>> import math
>>> distances = (leg.distance for leg in trip)
>>> distance_accum = list(accumulate(distances))
>>> scale = math.ceil(distance_accum[-1] / 4)
>>> quartiles = list(int(scale*d) for d in distance_accum)
我们提取了距离值并计算了每条腿的累积距离。累积距离的最后一个值是总和。quartiles变量的值如下:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
我们可以使用zip()函数将这个四分位数序列与原始数据点合并。我们还可以使用groupby()等函数来创建每个四分位数的腿的独立集合。
8.2.3 使用chain()函数组合迭代器
可以通过chain()函数将一系列迭代器统一成一个单一的值序列。这有助于将通过groupby()函数分解的数据合并。我们可以使用它来处理多个集合,就像它们是一个单一的集合一样。
Python 的contextlib提供了一个巧妙的类ExitStack(),可以在with语句的末尾执行多个操作。这允许应用程序创建任意数量的子上下文,所有这些上下文都将正确评估__enter__()和__exit__()。这在我们需要打开不定数量的文件时特别有用。
在这个例子中,我们可以将itertools.chain()函数与contextlib.ExitStack对象结合使用,以处理并正确关闭一组文件。此外,所有这些文件的数据将作为一个单独的可迭代值序列进行处理。我们不需要在每个单独的文件操作中包裹with语句,而是可以将所有操作包裹在一个单一的with上下文中。
我们可以像这样为多个文件创建一个单一的上下文:
import csv
from collections.abc import Iterator
from contextlib import ExitStack
from pathlib import Path
from typing import TextIO
def row_iter_csv_tab(*filepaths: Path) -> Iterator[list[str]]:
with ExitStack() as stack:
files: list[TextIO] = [
stack.enter_context(path.open())
for path in filepaths
]
readers = map(
lambda f: csv.reader(f, delimiter=’\t’),
files)
yield from chain(*readers)
我们创建了一个可以包含多个打开的独立上下文的ExitStack对象。当with语句结束时,ExitStack对象中的所有项目都将被正确关闭。在上面的函数中,一系列打开的文件对象被分配给files变量。stack.enter_context()方法将这些对象进入ExitStack对象以进行适当的关闭。
给定files变量中的文件序列,我们在readers变量中创建了一个 CSV 读取器的序列。在这种情况下,我们的所有文件都有共同的制表符分隔格式,这使得我们可以通过简单一致地对文件序列应用函数来愉快地打开它们。
最后,我们使用chain(*readers)将所有读取器链接到一个单一的迭代器中。这被用来产生来自所有文件的行序列。
重要的是要注意,我们不能返回chain(*readers)对象。如果我们这样做,这将退出with语句上下文,关闭所有源文件。相反,我们必须从生成器中产生单个行,以便with语句上下文保持活跃,直到所有行都被消耗。
8.2.4 使用groupby()对迭代器进行分区
我们可以使用groupby()函数将迭代器划分为更小的迭代器。这是通过评估给定可迭代对象中每个项目的给定key函数来实现的。如果键值与上一个项目的键匹配,则这两个项目是同一划分的一部分。如果键不匹配上一个项目的键,则上一个划分结束,并开始一个新的划分。因为匹配是在可迭代对象的相邻项上进行的,所以值必须按键排序。
groupby()函数的输出是一个二元组的序列。每个元组包含组的键值和一个包含组中项目的可迭代器,类似于[(key, iter(group)), (key, iter(group)), ...]。然后可以处理每个组的迭代器以创建一个具体化的集合,或者可能将其缩减为某些汇总值。
在本章前面的使用 accumulate()计算累计总和部分,我们展示了如何计算输入序列的四分位数。我们将扩展这一点,根据距离四分位数创建组。每个组将是一个迭代器,包含适合距离范围的腿。
给定包含原始数据的trip变量和包含四分位数分配的quartile变量,我们可以使用以下命令来分组数据:
>>> from itertools import groupby
>>> from Chapter07.ch07_ex1 import get_trip
>>> source_url = "file:./Winter%202012-2013.kml"
>>> trip = get_trip(source_url)
>>> quartile = quartiles(trip)
>>> group_iter = groupby(zip(quartile, trip), key=lambda q_raw: q_raw[0])
>>> for group_key, group_iter in group_iter:
... print(f"Group {group_key+1}: {len(list(group_iter))} legs")
Group 1: 23 legs
Group 2: 14 legs
Group 3: 19 legs
Group 4: 17 legs
这将首先将四分位数与原始行程数据配对,创建一个包含四分位数和腿的二元组的迭代器。groupby()函数将使用给定的 lambda 对象按四分位数q_raw[0]对每个q_raw元组进行分组。我们使用for语句来检查groupby()函数的结果。这显示了如何获取组键值和每个单独组的成员迭代器。
groupby()函数的输入必须按键值排序。这将确保组内的所有项目都将相邻。对于非常大的数据集,这可能会迫使我们使用操作系统的排序,在文件太大而无法装入内存的罕见情况下。
注意,我们还可以使用defaultdict(list)对象来创建组。这避免了排序步骤,但可以构建一个大型、内存中的列表字典。函数可以定义为以下内容:
from collections import defaultdict
from collections.abc import Iterable, Callable, Hashable
DT = TypeVar("DT")
KT = TypeVar("KT", bound=Hashable)
def groupby_2(
iterable: Iterable[DT],
key: Callable[[DT], KT]
) -> Iterator[tuple[KT, Iterator[DT]]]:
groups: dict[KT, list[DT]] = defaultdict(list)
for item in iterable:
groups[key(item)].append(item)
for g in groups:
yield g, iter(groups[g])
我们创建了一个defaultdict对象,它将使用list()作为与每个新键关联的默认值。类型提示明确了key函数(它发出与类型变量KT相关的一些任意类型的对象)与字典之间的关系,该字典使用相同的类型KT作为键。
每个项目都将应用给定的key()函数以创建键值。项目将附加到具有给定键的defaultdict对象中的列表。
一旦所有项目都被分区,我们就可以返回每个分区作为共享公共键的项目的迭代器。这将保留所有原始值在内存中,并为每个唯一的键值引入一个字典和一个列表。对于非常大的数据集,这可能需要比处理器上可用的更多内存。
类型提示明确指出源是某种任意类型,与变量 DT 关联。结果将是一个包含类型为 DT 的迭代器的迭代器。这强烈表明没有发生转换:范围类型与输入域类型匹配。
8.2.5 使用 zip_longest() 和 zip() 合并可迭代对象
我们在 第四章,处理集合 中看到了 zip() 函数。zip_longest() 函数与 zip() 函数在重要方面有所不同:而 zip() 函数在最短可迭代对象结束时停止,zip_longest() 函数使用给定值填充短可迭代对象,并在最长可迭代对象结束时停止。
fillvalue= 关键字参数允许使用除默认值 None 之外的其他值进行填充。
对于大多数探索性数据分析应用,使用默认值进行填充在统计学上很难证明其合理性。Python 标准库文档包括使用 zip_longest() 函数可以完成的 grouper 菜谱。在不远离我们数据分析重点的情况下很难进一步扩展这一点。
8.2.6 使用 pairwise() 创建成对元素
pairwise() 函数消耗一个源迭代器,以对的形式发出项目。请参阅 第四章,处理集合 中的 legs() 函数,以了解从源可迭代对象创建对的一个示例。
下面是一个将字符序列转换为相邻字符对的简单示例:
>>> from itertools import pairwise
>>> text = "hello world"
>>> list(pairwise(text))
[(’h’, ’e’), (’e’, ’l’), (’l’, ’l’), ...]
这种分析定位字母对,称为“二元组”或“二分图”。在尝试理解简单的字母替换密码时,这可能很有帮助。编码文本中二元组的频率可以暗示可能的解密方法。
在 Python 3.10 中,此函数从菜谱变为正确的 itertools 函数。
8.2.7 使用 compress() 过滤
内置的 filter() 函数使用谓词来确定一个项目是否通过或被拒绝。我们不仅可以使用一个计算值的函数,还可以使用第二个并行可迭代对象来确定哪些项目通过,哪些被拒绝。
在本章的 使用 cycle() 重新迭代循环 部分,我们探讨了使用简单的生成器表达式进行数据选择。其本质如下:
from typing import TypeVar
DataT = TypeVar("DataT")
def subset_gen(
data: Iterable[DataT], rule: Iterable[bool]
) -> Iterator[DataT]:
return (
v
for v, keep in zip(data, rule)
if keep
)
规则可迭代对象的每个值都必须是布尔值。为了选择所有项目,它可以重复 True 值。为了选择一个固定的子集,它可以循环一个 True 值后跟几个 False 值的副本。为了选择四分之一的项目,我们可以使用 cycle([True] + 3*[False])。
列表推导式可以修改为compress(some_source, selectors),使用一个函数作为selectors参数值。如果我们做出这个改变,处理过程将简化:
>>> import random
>>> random.seed(1)
>>> data = [random.randint(1, 12) for _ in range(12)]
>>> from itertools import compress
>>> copy = compress(data, all_rows())
>>> list(copy)
[3, 10, 2, 5, 2, 8, 8, 8, 11, 7, 4, 2]
>>> cycle_subset = compress(data, subset(3))
>>> list(cycle_subset)
[3, 5, 8, 7]
>>> random.seed(1)
>>> random_subset = compress(data, randomized(3))
>>> list(random_subset)
[3, 2, 2, 4, 2]
这些示例依赖于之前展示的替代选择规则all_rows()、subset()和randomized()。subset()和randomized()函数必须使用适当的参数定义,参数值为 c 来从源中选择
行。selectors表达式必须基于某个选择规则函数构建一个包含True和False值的可迭代对象。要保留的行是通过将source可迭代对象应用于行选择可迭代对象来选择的。
由于所有这些都是在惰性评估中完成的,所以只有在需要时才会从源中读取行。这使我们能够高效地处理非常大的数据集。此外,Python 代码的相对简单性意味着我们实际上不需要复杂的配置文件和相关解析器来在选择规则之间做出选择。我们有选择使用这段 Python 代码作为更大数据采样应用程序配置的选项。
我们可以将filter()函数想象成具有以下定义:
from itertools import compress, tee
from collections.abc import Iterable, Iterator, Callable
from typing import TypeVar
SrcT = TypeVar("SrcT")
def filter_concept(
function: Callable[[SrcT], bool],
source: Iterable[SrcT]
) -> Iterator[SrcT]:
i1, i2 = tee(source, 2)
return compress(i1, map(function, i2))
我们使用tee()函数克隆了可迭代对象。我们稍后会详细探讨这个函数。map()函数将生成应用过滤器谓词函数function()到可迭代对象中的每个值的结果,产生一系列True和False值。这个布尔值序列用于压缩原始序列,只传递与True关联的项目。这从compress()函数构建了filter()函数的特征。
函数的提示可以扩展到Callable[[SrcT], Any]。这是因为compress()函数将利用返回值的真值或假值。强调这些值将被理解为布尔值似乎是有帮助的,因此在使用类型提示时使用了bool而不是Any。
8.2.8 使用 islice()选择子集
在第四章,处理集合中,我们探讨了切片符号来从集合中选择子集。我们的例子是将从列表对象中切片的项目配对。以下是一个简单的列表:
>>> from Chapter04.ch04_ex5 import parse_g
>>> with open("1000.txt") as source:
... flat = list(parse_g(source))
>>> flat[:10]
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
>>> flat[-10:]
[7841, 7853, 7867, 7873, 7877, 7879, 7883, 7901, 7907, 7919]
我们可以使用以下方式使用列表切片创建对:
>>> list(zip(flat[0::2], flat[1::2]))
[(2, 3), (5, 7), (11, 13), ...]
islice()函数提供了类似的功能,但没有创建列表对象的开销。这将与任何大小的可迭代对象一起工作。islice()函数接受一个Iterable源,以及定义切片的三个参数:起始、停止和步长值。这意味着islice(source, 1, None, 2)类似于source[1::2]。与使用:的类似切片简写不同,可选参数值被使用;规则与内置的range()函数匹配。重要的区别是source[1::2]仅适用于列表或元组等Sequence对象。islice(source, 1, None, 2)函数适用于任何可迭代对象,包括迭代器对象或生成器表达式。
以下示例将使用islice()函数创建可迭代对象的值对:
>>> flat_iter_1 = iter(flat)
>>> flat_iter_2 = iter(flat)
>>> pairs = list(zip(
... islice(flat_iter_1, 0, None, 2),
... islice(flat_iter_2, 1, None, 2)
... ))
>>> len(pairs)
500
>>> pairs[:3]
[(2, 3), (5, 7), (11, 13)]
>>> pairs[-3:]
[(7877, 7879), (7883, 7901), (7907, 7919)]
我们在flat变量上创建了两个独立的数据点集合的迭代器。这些可以是打开的文件或数据库结果集上的两个独立的迭代器。这两个迭代器需要是独立的,以确保一个islice()源的变化不会干扰另一个islice()源。
这将生成从原始序列中得到的二元组序列:
[(2, 3), (5, 7), (11, 13), (17, 19), (23, 29),
...
(7883, 7901), (7907, 7919)]
由于islice()与可迭代对象一起工作,这种设计可以与极其大量的数据集一起工作。我们可以使用它从更大的数据集中选择子集。除了使用filter()或compress()函数外,我们还可以使用islice(source, 0, None, c)方法从更大的数据集中选择
-大小的子集。
8.2.9 使用 dropwhile()和 takewhile()进行状态性过滤
dropwhile()和takewhile()函数是状态性过滤器函数。它们从一个模式开始;给定的谓词函数是一种翻转开关,用于切换模式。dropwhile()函数从拒绝模式开始;当函数变为False时,它切换到传递模式。takewhile()函数从传递模式开始;当给定的函数变为False时,它切换到拒绝模式。由于这些是过滤器,它们将消耗整个可迭代参数值。
我们可以使用这些方法跳过输入文件中的标题或页脚行。我们使用dropwhile()函数来拒绝标题行并传递剩余的数据。我们使用takewhile()函数来传递数据并拒绝页脚行。我们将回到第三章中展示的简单 GPL 文件格式 Chapter 3,函数、迭代器和生成器。文件有一个如下所示的标题:
GIMP Palette
Name: Crayola
Columns: 16
#
接下来是类似以下示例数据的行:
255 73 108 Radical Red
注意,RGB 颜色三元组和颜色名称之间有一个不可见的制表符字符,\t。为了使其更明显,我们可以将示例排版如下:
255 73 108\tRadical Red
这种微妙的排版技术似乎有点误导,因为在大多数编程编辑器中看起来并不像这样。
我们可以使用基于 dropwhile() 函数的解析器来定位标题的最后一行(即 # 行),如下所示:
>>> import csv
>>> from pathlib import Path
>>> source_path = Path("crayola.gpl")
>>> with source_path.open() as source:
... rdr = csv.reader(source, delimiter=’\\t’)
... row_iter = dropwhile(
... lambda row: row[0] != ’#’, rdr
... )
... color_rows = islice(row_iter, 1, None)
... colors = list(
... (color.split(), name) for color, name in color_rows
... )
我们创建了一个 CSV 读取器来根据制表符字符解析行。这将整洁地将 color 三元组与名称分开。三元组需要进一步解析。这将生成一个以 # 行开始并继续文件其余部分的迭代器。
我们可以使用 islice() 函数丢弃可迭代对象中的第一个元素。islice(rows, 1, None) 表达式类似于请求 rows[1:] 切片:第一个元素被悄悄丢弃。一旦丢弃了最后一行标题行,我们就可以解析颜色三元组并返回更有用的颜色对象。
对于这个特定的文件,我们还可以使用 CSV reader() 函数找到的列数。标题行只有一个列,允许使用 dropwhile(lambda row: len(row) == 1, rdr) 表达式来丢弃标题行。这通常不是一个好的方法,因为定位标题的最后一行通常比尝试定义一些可以区分所有标题(或尾注)行与有意义文件内容的通用模式要容易得多。在这种情况下,标题行可以通过列数来区分;这是一个罕见的情况。
8.2.10 使用 filterfalse() 和 filter() 进行过滤的两种方法
在 第五章,高阶函数 中,我们探讨了内置的 filter() 函数。itertools 模块中的 filterfalse() 函数可以从 filter() 函数定义,如下所示:
filterfalse_concept = (
lambda pred, iterable:
filter(lambda x: not pred(x), iterable)
)
与 filter() 函数一样,谓词函数可以是 None 值。filter(None, iterable) 方法的值是可迭代对象中的所有 True 值。filterfalse(None, iterable) 方法的值是可迭代对象中的所有 False 值:
>>> from itertools import filterfalse
>>> source = [0, False, 1, 2]
>>> list(filter(None, source))
[1, 2]
>>> filterfalse(None, source)
<itertools.filterfalse object at ...>
>>> list(_)
[0, False]
filterfalse() 函数的目的是促进重用。如果我们有一个简洁的函数来做出过滤决策,我们应该能够使用该函数来分区输入,无论是传递还是拒绝组,而无需通过逻辑否定进行繁琐的操作。
理念是执行以下命令:
>>> iter_1, iter_2 = tee(iter(raw_samples), 2)
>>> rule_subset_iter = filter(rule, iter_1)
>>> not_rule_subset_iter = filterfalse(rule, iter_2)
这种将处理分为两个子集的方法将包括源中的所有项。rule() 函数保持不变,我们无法通过不正确地否定此函数引入微妙的逻辑错误。
8.2.11 通过 starmap() 和 map() 将函数应用于数据
内置的 map() 函数是一个高阶函数,它将函数应用于可迭代对象中的项。我们可以将 map() 函数的简单版本想象如下:
map_concept = (
lambda function, arg_iter:
(function(a) for a in arg_iter)
)
当 arg_iter 参数是一个提供单个值的可迭代对象时,这效果很好。实际的 map() 函数比这复杂得多,也可以与多个可迭代对象一起工作。
itertools 模块中的 starmap() 函数是 map() 函数的 *args 版本。我们可以想象其定义如下:
starmap_concept = (
lambda function, arg_iter:
(function(*a) for a in arg_iter)
#^-- Adds this * to decompose tuples
)
这反映了 map() 函数在语义上的微小变化,以正确处理元组可迭代结构。每个元组都被分解并应用于各种位置参数。
当我们查看行程数据时,根据前面的命令,我们可以基于 starmap() 函数重新定义 Leg 对象的构建。
我们可以使用 starmap() 函数来组装 Leg 对象,如下所示:
from Chapter04.ch04_ex1 import legs, haversine
from Chapter06.ch06_ex3 import row_iter_kml
from Chapter07.ch07_ex1 import float_lat_lon, LegNT, PointNT
import urllib.request
from collections.abc import Callable
def get_trip_starmap(url: str) -> List[LegNT]:
make_leg: Callable[[PointNT, PointNT], LegNT] = (
lambda start, end:
LegNT(start, end, haversine(start, end))
)
with urllib.request.urlopen(url) as source:
path_iter = float_lat_lon(
row_iter_kml(source)
)
pair_iter = legs(path_iter)
trip = list(starmap(make_leg, pair_iter))
#-------- Used here
return trip
当我们将这个 get_trip_starmap() 函数应用于读取源数据和遍历创建的 Leg 对象时,它看起来是这样的:
>>> from pprint import pprint
>>> source_url = "file:./Winter%202012-2013.kml"
>>> trip = get_trip_starmap(source_url)
>>> len(trip)
73
>>> pprint(trip[0])
LegNT(start=PointNT(latitude=37.54901619777347, longitude=-76.33029518659048), end=PointNT(latitude=37.840832, longitude=-76.273834), distance=17.724564798884984)
>>> pprint(trip[-1])
LegNT(start=PointNT(latitude=38.330166, longitude=-76.458504), end=PointNT(latitude=38.976334, longitude=-76.473503), distance=38.801864781785845)
make_leg() 函数接受一对 Point 对象,并返回一个包含起点、终点和两点之间距离的 Leg 对象。来自 第四章,处理集合 的 legs() 函数创建反映航程一段起止的 Point 对象对。legs() 创建的这些对作为输入提供给 make_leg(),以创建适当的 Leg 对象。
map() 函数也可以接受多个可迭代对象。当我们使用 map(f, iter1, iter2, ...) 时,它表现得好像迭代器被压缩在一起,并应用了 starmap() 函数。
我们可以将 map(function, iter1, iter2, iter3) 函数视为 starmap(function, zip(iter1, iter2, iter3))。
starmap(function, some_list) 方法的优点是将可能冗长的 (function(*args) for args in some_list) 生成器表达式替换为避免对函数参数值应用可能被忽视的 * 操作符。
8.3 使用 tee() 克隆迭代器
tee() 函数为我们提供了一种绕过使用可迭代对象时的重要 Python 规则的方法。这个规则非常重要,所以我们在这里重复一遍:
迭代器只能使用一次。
tee() 函数允许我们克隆一个迭代器。这似乎让我们免于必须实现一个序列,以便我们可以多次遍历数据。因为 tee() 可能会使用很多内存,所以有时最好实现一个列表并多次处理它,而不是试图使用 tee() 函数的潜在简化。
例如,对于庞大的数据集,简单的平均值可以这样编写:
from collections.abc import Iterable
def mean_t(source: Iterable[float]) -> float:
it_0, it_1 = tee(iter(source), 2)
N = sum(1 for x in it_0)
sum_x = sum(x for x in it_1)
return sum_x/N
这样计算平均值时,看起来并没有在内存中完全实现整个数据集。注意,float 类型的类型提示并不排除整数。mypy 程序了解数值处理规则,这个定义提供了一种灵活的方式来指定 int 或 float 都可以工作。
8.4 itertools 菜谱
在 Python 库文档的 itertools 章节中,有一个名为 Itertools Recipes 的子章节,其中包含使用各种 itertools 函数的杰出示例。由于没有必要重新生成这些示例,我们在这里引用它们。它们应被视为 Python 函数式编程的必读内容。
更多信息,请访问 docs.python.org/3/library/itertools.html#itertools-recipes。
重要的是要注意,这些不是 itertools 模块中的可导入函数。需要阅读和理解这些配方,然后可能复制或修改它们,才能将其包含在应用程序中。
一些配方涉及下一章中展示的一些更高级的技术;它们不在以下表中。我们保留了 Python 文档中项目的顺序,这不是按字母顺序排列的。以下表格总结了显示从 itertools 基础构建的函数式编程设计模式的配方:
| 函数名称 |
take |
tabulate |
consume |
nth |
quantify |
padnone |
ncycles |
dotproduct |
flatten |
repeatfunc |
grouper |
roundrobin |
partition |
unique_everseen |
unique_justseen |
iter_except |
8.5 摘要
在本章中,我们研究了itertools模块中的许多函数。这个库模块帮助我们以复杂的方式处理迭代器。
我们已经研究了无限迭代器;它们会重复而不会终止。它们包括count()、cycle()和repeat()函数。由于它们不会终止,消费函数必须确定何时停止接受值。
我们还研究了多个有限迭代器。其中一些是内置的,还有一些是itertools模块的一部分。它们与源可迭代对象一起工作,因此当该可迭代对象耗尽时它们会终止。这些函数包括enumerate()、accumulate()、chain()、groupby()、zip_longest()、zip()、pairwise()、compress()、islice()、dropwhile()、takewhile()、filterfalse()、filter()、starmap()和map()。这些函数允许我们用看起来更简单的函数替换可能复杂的生成器表达式。
我们已经注意到像tee()这样的函数是可用的,并且可以创建一个有用的简化。它可能需要大量内存,需要仔细考虑。在某些情况下,将列表具体化可能比应用tee()函数更有效。
此外,我们还研究了文档中的配方,这些配方提供了更多我们可以研究和复制到我们自己的应用程序中的函数。配方列表显示了丰富的常见设计模式。
在第九章,组合学中的迭代器 – 排列和组合中,我们将继续研究itertools模块,重点关注排列和组合。这些操作可以产生大量结果。例如,从一副 52 张牌中枚举所有可能的 5 张牌手牌将产生超过 3.12 × 10⁸个排列。然而,对于小域来说,检查所有可能的排列顺序可以帮助我们了解观察到的样本与可能值的域匹配得有多好。
8.6 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。它们作为提示,允许读者探索其他解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已提供的单元测试用例相同。读者应将书中的示例函数名称替换为自己的解决方案,以确认其工作正常。
8.6.1 优化 find_first()函数
在使用浮点数参数计数中,我们定义了一个find_first()函数,用于定位通过给定测试标准的迭代器的第一个配对。在大多数示例中,测试是值之间的比较,以查看值之间的差异是否大于 10^(−12)。
find_first()函数的定义使用了更简单的递归。这限制了可以检查的可迭代对象的大小:在达到栈大小限制之前,只能消耗大约 1,000 个值。
首先,创建一个比较函数,该函数将消耗足够多的值以失败并抛出递归限制异常。
然后,重新编写find_first()函数,用for语句替换尾递归。
使用之前找到的比较函数,证明修改后的函数可以轻松通过 1,000 个元素,寻找第一个符合修改后的标准。
8.6.2 将第四章与 itertools.pairwise()配方进行比较
在第四章,处理集合中,legs()函数从一个源可迭代对象中创建了重叠的配对。比较本书中提供的实现与pairwise()函数的实现。
创建一个非常大的可迭代对象,并比较legs()函数和pairwise()函数的性能。哪个更快?
8.6.3 将第四章与 itertools.tee()配方进行比较
在第四章,处理集合的使用总和和计数进行统计部分,定义了一个mean()函数,该函数的限制是只能与序列一起使用。如果使用itertools.tee(),可以编写一个mean()函数,该函数将适用于所有迭代器,而不仅限于可以产生多个迭代器的集合对象。基于itertools.tee()函数定义一个mean_i()函数,该函数可以与任何迭代器一起工作。哪种均值计算变体更容易理解?
创建一个非常大的可迭代对象,并比较文本中显示的 mean_i() 函数和 mean() 函数的性能。哪个更快?探索需要一些时间,但找到一个既能打破 itertools.tee() 函数又能与具体化列表对象一起工作的集合是件有趣的事情。
8.6.4 用于训练和测试目的的数据集拆分
给定一个样本池,有时有必要将数据分割成用于构建(或“训练”)模型的数据子集,以及用于测试模型预测能力的单独子集。使用源数据的 20%、25% 或甚至 33% 作为测试数据是常见的做法。开发一组函数,将数据分割成测试数据与训练数据比为 1:3、1:4 或 1:5 的子集。
8.6.5 排序排名
在第七章,复杂无状态对象中,我们探讨了在数据集中对项目进行排名的方法。该章节中展示的方法是构建具有相同键值的项的字典。这使得创建一个平均各种项的排名成为可能。例如,序列 [0.8,`` 1.2,`` 1.2,`` 2.3,`` 18] 应该有排名值 1、2.5、2.5、4、5。序列中位置 1 和 2 的两个匹配键值应具有 2.5 的中间值作为它们的共同排名。
这可以通过使用 itertools.groupby() 来计算。每个组将包含一些成员,这些成员由 groupby() 函数提供。具有匹配键的 n 个项目的组排名值序列是 r[0],r[0] + 1,r[0] + 2,...,r[0] + n。r[0] 的值是该组的起始排名。这个序列的平均值是 r[0] +
。这个处理过程需要创建一个临时值序列,以便从具有相同键的值组中按其匹配的排名发出每个项目。
编写这个 rank() 函数,使用 itertools.groupby() 函数。将代码与第七章,复杂无状态对象中的示例进行比较。itertools 变体提供了哪些优势?
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第九章:9
组合学中的 itertools – 排列和组合
函数式编程强调无状态算法。在 Python 中,这使我们倾向于使用生成器表达式、生成器函数和可迭代对象。在本章中,我们将继续研究 itertools 库,其中包含许多函数,帮助我们处理可迭代集合。
在上一章中,我们探讨了三种广泛的迭代器函数分组。它们如下:
-
与无限迭代器一起工作的函数,可以应用于任何可迭代对象或任何集合的迭代器;它们消耗整个源
-
与有限迭代器一起工作的函数,这些函数可以多次累积源数据,或者生成源数据的减少
-
tee()迭代器函数,该函数将迭代器克隆到几个副本中,每个副本都可以独立使用
在本章中,我们将探讨与排列和组合一起工作的 itertools 函数。这包括几个组合函数和一些基于这些函数的食谱。函数如下:
-
product(): 此函数形成与嵌套for语句或嵌套生成器表达式等价的笛卡尔积。 -
permutations(): 此函数从集合 p 中按所有可能的顺序生成长度为 r 的元组;元素不重复。 -
combinations(): 此函数从集合 p 中按顺序生成长度为 r 的元组;元素不重复。 -
combinations_with_replacement(): 此函数从 p 中按顺序生成长度为 r 的元组,允许元素重复。
这些函数体现了可以从少量输入数据创建潜在大型结果集的算法。某些类型的问题基于穷举排列宇宙的精确解决方案。作为一个简单的例子,当试图找出一张牌是否包含顺子(所有数字相邻且按升序排列)时,一种解决方案是计算所有排列并查看是否至少有一种牌的排列是升序的。对于五张牌的硬牌,只有 120 种排列。这些函数使得生成所有排列变得简单;在某些情况下,这种简单的枚举可能不是最优的,甚至是不希望的。
9.1 枚举笛卡尔积
笛卡尔积这个术语指的是从集合的元素中枚举所有可能元素组合的想法。
从数学上讲,我们可以说两个集合 {1,2,3,…,13}×{♣,♢,♡,♠} 的乘积有 52 对,如下所示:

我们可以通过执行以下命令来生成前面的结果:
>>> cards = list(product(range(1, 14), ’♣♢♡♠’))
>>> cards[:4]
[(1, ’♣’), (1, ’♢’), (1, ’♡’), (1, ’♠’)]
>>> cards[4:8]
[(2, ’♣’), (2, ’♢’), (2, ’♡’), (2, ’♠’)]
>>> cards[-4:]
[(13, ’♣’), (13, ’♢’), (13, ’♡’), (13, ’♠’)]
乘积的计算可以扩展到任何数量的可迭代集合。使用大量集合可能导致非常大的结果集。
9.2 减少乘积
在关系数据库理论中,表之间的连接可以被视为一个过滤后的乘积。对于了解 SQL 的人来说,没有WHERE子句的SELECT语句连接表将产生表中行的笛卡尔积。这可以被视为最坏情况的算法——一个没有任何有用过滤来选择所需结果子集的巨大乘积。我们可以使用itertools.product()函数来枚举所有可能的组合,并过滤出那些匹配正确的组合。
我们可以定义一个join()函数来连接两个可迭代集合或生成器,如下面的命令所示:
from collections.abc import Iterable, Iterator, Callable
from itertools import product
from typing import TypeVar
JTL = TypeVar("JTL")
JTR = TypeVar("JTR")
def join(
t1: Iterable[JTL],
t2: Iterable[JTR],
where: Callable[[tuple[JTL, JTR]], bool]
) -> Iterable[tuple[JTL, JTR]]:
return filter(where, product(t1, t2))
计算两个可迭代对象t1和t2的所有组合。filter()函数将应用给定的where()函数,以通过或拒绝匹配正确的两个元组,提示为tuple[JTL, JTR]。where()函数的提示为Callable[[tuple[JTL, JTR]], bool],以表明它返回一个布尔结果。这通常是 SQL 数据库查询在没有任何有用的索引或基数统计信息来建议更好的算法的最坏情况下的典型做法。
虽然这个算法总是有效的,但它可能非常低效。我们通常需要仔细观察问题和可用数据,以找到更有效的算法。
首先,我们将问题稍微泛化一下,通过替换简单的布尔匹配函数。而不是二元结果,我们通常寻找项目之间某些距离的最小值或最大值。在这种情况下,比较产生一个浮点值。
假设我们有一个类来定义一个Color对象表中的实例,如下所示:
from typing import NamedTuple
class Color(NamedTuple):
rgb: tuple[int, int, int]
name: str
下面是一个使用此定义创建一些Color实例的例子:
>>> palette = [Color(rgb=(239, 222, 205), name=’Almond’),
... Color(rgb=(255, 255, 153), name=’Canary’),
... Color(rgb=(28, 172, 120), name=’Green’),
... Color(rgb=(255, 174, 66), name=’Yellow Orange’)
... ]
更多信息,请参阅第六章,递归和归约,其中我们向您展示了如何解析颜色文件以创建NamedTuple对象。在这种情况下,我们将 RGB 颜色保留为tuple[int, int, int],而不是分解每个单独的字段。
一幅图像将包含像素集合,每个像素都是一个 RGB 元组。从概念上讲,图像包含如下数据:
pixels = [(r, g, b), (r, g, b), (r, g, b), ...]
实际上,Python Imaging Library (PIL)包以多种形式呈现像素。其中之一是将(x,y)坐标映射到 RGB 三元组。对于 Pillow 项目文档,请访问pypi.python.org/pypi/Pillow。
给定一个PIL.Image对象,我们可以使用类似以下命令迭代像素集合:
from collections.abc import Iterator
from typing import TypeAlias
from PIL import Image # type: ignore[import]
Point: TypeAlias = tuple[int, int]
RGB: TypeAlias = tuple[int, int, int]
Pixel: TypeAlias = tuple[Point, RGB]
def pixel_iter(img: Image) -> Iterator[Pixel]:
w, h = img.size
return (
(c, img.getpixel(c))
for c in product(range(w), range(h))
)
此函数根据图像大小img.size确定每个坐标的范围。product(range(w), range(h))方法创建所有可能的坐标组合。它的结果与单个表达式中的两个嵌套for语句的结果相同。
这有一个优点,就是为每个像素及其坐标进行枚举。然后我们可以以任意顺序处理像素,并仍然重建图像。这在使用多进程或多线程将工作负载分散到多个核心或处理器时特别有用。concurrent.futures模块提供了一个简单的方法来在核心或处理器之间分配工作。
9.2.1 计算距离
许多决策问题需要我们找到一个足够接近的匹配。我们可能无法使用简单的相等测试。相反,我们必须使用距离度量并定位到目标最近的项目。例如,k-最近邻(k-NN)算法使用数据训练集和距离测量函数。它定位到未知样本的 k 个最近邻,并使用这些邻居中的大多数来分类未知样本。
为了探索枚举所有可能的匹配的概念,我们将使用一个稍微简单一点的例子。然而,尽管表面上更简单,如果我们天真地全面枚举所有潜在的匹配,它可能不会得到好的结果。
在进行颜色匹配时,我们不会有简单的相等测试。在我们的目的中,颜色 C 是一个三元组 ⟨r,g,b⟩。它是在三维空间中的一个点。通常,定义一个最小距离函数来确定两种颜色是否足够接近,而不需要具有相同的三个值 ⟨r[1],g[1],b[1]⟩ = ⟨r[2],g[2],b[2]⟩ 是合理的。我们需要使用颜色空间的红、绿、蓝轴进行多维距离计算。有几种常见的测量距离的方法,包括欧几里得距离、曼哈顿距离以及其他基于视觉偏好的更复杂的加权方法。
这里是欧几里得和曼哈顿距离函数:
import math
def euclidean(pixel: RGB, color: Color) -> float:
return math.sqrt(
sum(map(
lambda x_1, x_2: (x_1 - x_2) ** 2,
pixel,
color.rgb))
)
def manhattan(pixel: RGB, color: Color) -> float:
return sum(map(
lambda x_1, x_2: abs(x_1 - x_2),
pixel,
color.rgb))
欧几里得距离衡量了 RGB 空间中三个点之间直角三角形的斜边。以下是三维空间中的正式定义:

下面是两个点之间欧几里得距离的二维草图:

曼哈顿距离将三个点之间直角三角形的每条边的长度相加。它是以纽约市曼哈顿区的网格布局命名的。为了出行,人们被迫只能在街道和大道上行走。以下是三维空间中的正式定义:

下面是两个点之间曼哈顿距离的二维草图:

欧几里得距离提供精度,而曼哈顿距离提供计算速度。
展望未来,我们希望的结构看起来像这样。对于每个单独的像素,我们可以计算该像素颜色与有限颜色集中的可用颜色之间的距离。对于图像中的一个单独像素的计算结果可能开始如下示例:
[((0, 0),
(92, 139, 195),
Color(rgb=(239, 222, 205), name=’Almond’),
169.10943202553784),
((0, 0),
(92, 139, 195),
Color(rgb=(255, 255, 153), name=’Canary’),
204.42357985320578),
((0, 0),
(92, 139, 195),
Color(rgb=(28, 172, 120), name=’Green’),
103.97114984456024),
((0, 0),
(92, 139, 195),
Color(rgb=(48, 186, 143), name=’Mountain Meadow’),
82.75868534480233),
我们已经展示了一系列元组;每个元组包含四个项目:
-
像素的坐标;例如,(0,0)
-
像素的原始颜色;例如,(92, 139, 195)
-
来自一组七个颜色的
Color对象;例如,Color(rgb=(48, 186, 143)',name='Mountain Meadow'`) -
原始颜色与给定的
Color对象之间的欧几里得距离;例如,82.75868534480233
有助于创建一个 NamedTuple 来封装每个元组中的四个项目。我们可以称它为 X-Y,像素,颜色,距离元组,类似于“XYPCD”。这将使识别 (x,y) 坐标、原始像素的颜色、匹配的颜色以及原始颜色和所选匹配之间的距离稍微容易一些。
最小的欧几里得距离是最接近匹配的颜色。对于这四种示例颜色,Mountain Meadow 是这个像素的最接近匹配。这种减少是通过 min() 函数完成的。如果将 (x,y),像素,颜色和距离的整体四元组分配给一个变量名,choices,像素级别的减少将看起来像这样:
min(choices, key=lambda xypcd: xypcd[3])
这个表达式将选择一个元组作为像素和颜色之间的最佳匹配。它使用 lambda 从元组中选择第 3 项,即距离度量。
9.2.2 获取所有像素和所有颜色
我们如何到达包含所有像素和所有颜色的结构?一个看似简单的答案最终证明并不理想。
将像素映射到颜色的一种方法是通过使用 product() 函数枚举所有像素和所有颜色:
from collections.abc import Iterable
from itertools import groupby
def matching_1(
pixels: Iterable[Pixel],
colors: Iterable[Color]
) -> Iterator[tuple[Point, RGB, Color, float]]:
distances = (
(pixel[0], pixel[1], color, euclidean(pixel[1], color))
for pixel, color in product(pixels, colors)
)
for _, choices in groupby(distances, key=lambda xy_p_c_d: xy_p_c_d[0]):
yield min(choices, key=lambda xypcd: xypcd[3])
这的核心是 product(pixel_iter(img), colors) 表达式,它创建了一个所有像素与所有颜色组合的序列。然后,整体表达式应用 euclidean() 函数来计算像素和 Color 对象之间的距离。结果是包含原始 (x,y) 坐标、原始像素、可用颜色以及原始像素颜色和可用颜色之间距离的四元组对象序列。
最终的颜色选择使用 groupby() 函数和 min(choices, ...) 表达式来定位最接近的匹配。
将 product() 函数应用于像素和颜色创建了一个长而平的迭代器。我们将迭代器分组到更小的集合中,其中坐标匹配。这将把大迭代器分解成仅与单个像素关联的颜色池的小迭代器。然后我们可以为每个像素的每个可用颜色选择最小颜色距离。
在一个 3,648×2,736 像素的图片中,使用了 133 种克雷奥拉颜色,我们有一个包含 3,648 × 2,736 × 133 = 1,327,463,424 个项目的可迭代对象需要评估。这是由这个distances表达式创建的十亿种组合。这个数字并不一定不切实际;它完全在 Python 能处理的范围内。然而,它揭示了在简单使用product()函数时的一个重要缺陷。
在进行一些分析以查看中间数据有多大之前,我们无法简单地执行这种大规模处理。以下是这两个距离函数的timeit数字。这是只进行 1,000,000 次这些计算的总秒数:
-
欧几里得距离:1.761
-
曼哈顿距离:0.857
通过 1,000 倍的比例放大——从 1 百万组合到 10 亿——意味着处理至少需要 1,800 秒;即曼哈顿距离需要大约半小时,欧几里得距离需要 46 分钟来计算。这似乎表明这种简单的批量处理对于大数据集是无效的。
更重要的是,我们做错了。这种宽度 × 高度 × 颜色处理的设计本身就是错误的。在许多情况下,我们可以做得更好。
9.3 性能改进
任何大数据算法的一个关键特性是找到一种执行某种分而治之策略的方法。这在函数式编程设计以及命令式设计中都是正确的。
这里有三个选项可以加快这个处理过程:
-
我们可以尝试使用并行性来同时进行更多的计算。在四核处理器上,时间可以减少到大约 25%。这把曼哈顿距离的计算时间减少到了 8 分钟。
-
我们可以看看缓存中间结果是否会减少冗余计算的数量。问题在于有多少颜色是相同的,有多少颜色是唯一的。
-
我们可以寻找算法的彻底改变。
我们将通过计算源颜色和目标颜色之间所有可能的比较来合并最后两个点。在这种情况下,就像在许多其他情况下一样,我们可以轻松地枚举整个像素和颜色的映射。如果颜色重复,我们将避免进行冗余计算以找到最近的颜色。我们还将把算法从一系列比较改为一系列在映射对象中的查找。
在许多问题域中,源数据是一组浮点值。虽然这些float值很灵活,并且在某些方面与实数的数学抽象相对应,但它们引入了一些额外的成本。浮点运算可能比整数运算慢。更重要的是,浮点值可以包含许多“噪声”位。例如,常见的 RGB 颜色定义使用 256 个不同的值来表示红色、绿色和蓝色组件。这些值使用 8 位精确表示。使用从 0.0 到 1.0 的值的浮点数变体将每个颜色使用完整的 64 位。任何导致浮点截断的算术都会引入噪声。虽然float值看起来很简单,但它们引入了令人烦恼的问题。
以下是一个示例,使用红色r=15:
>>> r = 15
>>> r_f = 15/256
>>> r_f
0.05859375
>>> r_f + 1/100 - 1/100
0.05859374999999999
从代数上讲,r[f] +
−
= r[f]。然而,float定义只是对实数抽象概念的近似。
的值在基于二进制的浮点数中没有确切的表示。使用这样的值会引入截断误差,这些误差会传播到后续的计算中。我们选择使用基于整数的颜色匹配来展示如何最小化由浮点值引起的额外复杂性。
当考虑从源颜色到目标颜色的所有转换的预计算这一想法时,我们需要一个任意图像的整体统计数据。与本书相关的代码包括IMG_2705.jpg。以下是收集指定图像中所有不同颜色元组的简单算法:
from collections import defaultdict, Counter
def gather_colors() -> defaultdict[RGB, list[Point]]:
img = Image.open("IMG_2705.jpg")
palette = defaultdict(list)
for xy, rgb in pixel_iter(img):
palette[rgb].append(xy)
w, h = img.size
print(f"total pixels {w*h}")
print(f"total colors {len(palette)}")
return palette
我们将给定颜色的所有像素收集到一个按颜色组织的列表中。从这些数据中,我们将学习以下事实:
-
像素总数为 9,980,928。这符合 10 兆像素图像的预期。
-
颜色的总数为 210,303。如果我们尝试计算实际颜色和 133 个目标颜色之间的欧几里得距离,我们将进行 27,970,299 次计算,这可能需要大约 76 秒。
-
如果我们使用更不精确的表示,即位数更少的表示,我们可以加快速度。我们将称之为“掩码”,以去除一些不相关的最低有效位。使用 3 位掩码,
0b11100000,实际使用的颜色总数减少到 214 种,而可能的颜色域为 2³ × 2³ × 2³ = 512 种。 -
使用 4 位掩码,
0b11110000,实际上使用了 1,150 种颜色。 -
使用 5 位掩码,
0b11111000,实际上使用了 5,845 种颜色。 -
使用 6 位掩码,
0b11111100,实际上使用了 27,726 种颜色。可能的颜色域增加到 2⁶ × 2⁶ × 2⁶ = 262,144。
这使我们了解如何重新排列数据结构,快速计算匹配的颜色,然后在不进行十亿次比较并避免任何额外的浮点近似复杂性的情况下重建图像。需要做出一些改变以避免不必要的(并引入错误的)计算。
掩码背后的核心思想是保留值的最高有效位并消除最低有效位。考虑一个红色值为 200 的颜色。我们可以使用 Python 的bin()函数来查看该值的二进制表示:
>>> bin(200)
’0b11001000’
>>> 200 & 0b11100000
192
>>> bin(192)
’0b11000000’
计算200 & 0b11100000应用了一个掩码来隐藏最低有效 5 位并保留最高有效 3 位。掩码应用后剩下的值作为红色值为 192。
我们可以使用以下命令将掩码值应用于 RGB 三元组:
masked_color = tuple(map(lambda x: x & 0b11100000, c))
这将通过使用&运算符从整数值中选择特定的位来挑选出颜色元组的红色、绿色和蓝色值的最显著 3 位。如果我们用这个掩码值而不是原始颜色来创建一个Counter对象,我们会看到在应用掩码后,图像只使用了 214 个不同的值。这比理论上的颜色数量少了一半。
9.3.1 重新排列问题
使用product()函数比较所有像素和所有颜色是天真且不恰当的。有 1000 万个像素,但只有 20 万个独特的颜色。在将源颜色映射到目标颜色时,我们只需要在简单的映射中保存 20 万个值。
我们将按以下方式处理:
-
计算源到目标颜色映射。在这种情况下,让我们使用 3 位颜色值作为输出。每个 R、G 和 B 值来自
range(0, 256, 32)表达式中的八个值。我们可以使用这个表达式来枚举所有输出颜色:product(range(0, 256, 32), range(0, 256, 32), range(0, 256, 32)) -
然后,我们可以计算到源调色板中最近颜色的欧几里得距离,只需进行 68,096 次计算。这大约需要 0.14 秒。它只做一次计算,并计算 200,000 个映射。
-
在一次遍历源图像的过程中,我们使用修订后的颜色表构建一个新的图像。在某些情况下,我们可以利用整数值的截断。我们可以使用如
(0b11100000&r, 0b11100000&g, 0b11100000&b)这样的表达式来移除图像颜色的最低有效位。我们将在稍后查看这种额外的计算减少。
这将用 1000 万个字典查找替换十亿次的距离计算,将潜在的 30 分钟计算时间缩短到大约 30 秒。
给定大约有 20 万个颜色的源调色板,我们可以应用快速曼哈顿距离来定位目标调色板(如 Crayola 颜色)中的最近颜色。
我们将加入另一个优化——截断。这将给我们一个更快的算法。
9.3.2 结合两个转换
当结合多个转换时,我们可以从源通过中间目标到结果构建一个更复杂的映射。为了说明这一点,我们将截断颜色并应用映射。
在某些问题背景下,截断可能很困难。在其他情况下,它通常相当简单。例如,将美国邮政编码从九位截断到五位是常见的。邮政编码可以进一步截断到三位,以确定代表更大地理区域的区域设施。
对于颜色,我们可以使用之前显示的位掩码来截断从三个 8 位值(24 位,1600 万种颜色)到三个 3 位值(9 位,512 种颜色)的颜色。
这里是一种构建颜色映射的方法,它结合了给定颜色集的距离和源颜色的截断:
from collections.abc import Sequence
def make_color_map(colors: Sequence[Color]) -> dict[RGB, Color]:
bit3 = range(0, 256, 0b0010_0000)
best_iter = (
min((euclidean(rgb, c), rgb, c) for c in colors)
for rgb in product(bit3, bit3, bit3)
)
color_map = dict((b[1], b[2]) for b in best_iter)
return color_map
我们创建了一个范围对象 bit3,它将遍历所有八个 3 位颜色值。使用二进制值 0b0010_0000 可以帮助可视化位的使用方式。最低的 5 位将被忽略;只使用最高 3 位。
range 对象与普通迭代器不同;它们可以被多次使用。因此,product(bit3, bit3, bit3) 表达式将生成我们用作输出颜色的所有 512 种颜色组合。
对于每个截断的 RGB 颜色,我们创建了一个包含(0)所有蜡笔颜色的距离、(1)RGB 颜色和(2)蜡笔 Color 对象的三元组。当我们请求这个集合的最小值时,我们将得到与截断 RGB 颜色最接近的蜡笔 Color 对象。
我们构建了一个字典,将截断的 RGB 颜色映射到最接近的蜡笔。为了使用这个映射,我们将在查找映射中最接近的蜡笔之前截断源颜色。这种截断与预计算的映射的结合展示了我们可能需要结合映射技术。
以下函数将从一个颜色映射构建新的图像:
def clone_picture(
color_map: dict[RGB, Color],
filename: str = "IMG_2705.jpg"
) -> None:
mask = 0b1110_0000
img = Image.open(filename)
clone = img.copy()
for xy, rgb in pixel_iter(img):
r, g, b = rgb
repl = color_map[(mask & r, mask & g, mask & b)]
clone.putpixel(xy, repl.rgb)
clone.show()
这使用 PIL 的 putpixel() 函数来替换图片中的所有像素。掩码值保留每个颜色的最高三位,将颜色数量减少到子集。
我们看到的是,一些函数式编程工具的简单使用可能导致表达简洁但效率低下的算法。计算复杂度(有时称为 Big-O 分析)的基本工具对于函数式编程来说,与命令式编程一样重要。
问题不在于 product() 函数效率低下。问题在于我们可以使用 product() 函数创建一个效率低下的算法。
9.4 对值集合进行排列
当我们对值集合进行排列时,我们将生成集合中值的所有可能顺序。n 个项目的排列有 n! 种。我们可以使用排列序列作为解决各种优化问题的暴力解决方案。
典型的组合优化问题包括旅行商问题、最小生成树问题和背包问题。这些问题之所以著名,是因为它们涉及到可能的大量排列。为了避免对所有排列进行穷举,需要近似解。使用itertools.permutations()函数仅适用于探索非常小的问题。
9
我们可以创建一个简单的网格,以显示给定代理完成给定任务的能力。对于七个代理和任务的简单问题,将有一个 49 个成本的网格。网格中的每个单元格显示代理 A[0]到 A[6]执行任务 T[0]到 T[6]:
代理
12
25
任务
A[0]
8
A[2]
24
A[4]
A[5]
A[3]
T[3]
T[0]
14
11
A[6]
12
4
30
A[1]
T[1]
15
6
34
4
28
21
20
T[2]
T[5]
22
18
31
15
23
24
5
20
18
9
15
30
18
31
T[4]
这些组合优化问题的一个流行例子是分配问题。我们有 n 个代理和 n 个任务,但每个代理执行特定任务的成本并不相等。想象一下,一些代理在处理某些细节时遇到困难,而其他代理在这些细节上表现出色。如果我们能正确分配任务给代理,我们可以最小化成本。
16
30
4
28
24
4
3
23
22
11
5
10
5
T[6]
13
7
7
7
32
给定这个网格,我们可以列出所有代理和任务的排列组合。然而,这种方法扩展性不好。对于这个问题,有 720 种选择。如果我们有更多的代理,例如 10 个,10!的值是 3,628,800。我们可以使用list(permutations(range(10)))表达式创建整个 300 万项的序列。
我们预计在几秒钟内就能解决这么小的问题。对于 10!,我们可能需要几秒钟。当我们把问题规模加倍到 20!时,我们遇到了一些可扩展性问题:将有 2.433 × 10¹⁸种排列。在一个生成 10!排列需要大约 0.56 秒的计算机上,生成 20!排列的过程将需要大约 12,000 年。
我们可以将穷举搜索最优解的形式化如下:
from itertools import permutations
def assignment(cost: list[tuple[int, ...]]) -> list[tuple[int, ...]]:
n_tasks = len(cost)
perms = permutations(range(n_tasks))
alt = [
(
sum(
cost[task][agent] for agent, task in enumerate(perm)
),
perm
)
for perm in perms
]
m = min(alt)[0]
return [ans for s, ans in alt if s == m]
我们为一系列代理创建所有任务排列,并将其分配给perms。从这些排列中,我们创建了两个元组,表示给定排列的成本矩阵中所有成本的总和。为了定位相关成本,我们枚举一个特定的排列以创建显示代理及其任务分配的两个元组。例如,一个排列是任务(2, 4, 6, 1, 5, 3, 0)。我们可以使用表达式list(enumerate((2, 4, 6, 1, 5, 3, 0)))来分配代理索引值。结果是[(0, 2), (1, 4), (2, 6), (3, 1), (4, 5), (5, 3), (6, 0)],包含所有七个代理索引值及其相关的任务分配。我们可以通过字典查找将索引数字转换为代理名称和任务名称。成本矩阵中值的总和告诉我们这个特定任务分配的成本是多少。
一个最优解可能看起来像上面的分配。它需要将代理名称和任务名称折叠进来,以将任务排列转换为特定的分配列表:
|
|
|
| A[0] | T[2] |
|---|
|
|
|
| A[1] | T[4] |
|---|
|
|
|
| A[2] | T[6] |
|---|
|
|
|
| A[3] | T[1] |
|---|
|
|
|
| A[4] | T[5] |
|---|
|
|
|
| A[5] | T[3] |
|---|
|
|
|
| A[6] | T[0] |
|---|
|
|
|
在某些情况下,可能会有多个最优解;此算法将找到所有这些解。表达式min(alt)[0]选择最小值集中的第一个。
对于小的教科书示例,这似乎是相当快的。有一些线性规划方法可以避免枚举所有排列。Python 线性规划模块 PuLP 可用于解决分配问题。请参阅coin-or.github.io/pulp/。
9.5 生成所有组合
itertools模块也支持计算一组值的所有组合。在考虑组合时,顺序不重要,因此组合的数量远少于排列。组合的数量通常表示为
=
。这是从包含 p 个项目的总体中一次取 r 个项目的组合方式的数量。
例如,有 2,598,960 种五张牌的扑克手牌。实际上,我们可以通过执行以下命令来枚举所有 200 万种手牌:
>>> from itertools import combinations, product
>>> hands = list(
... combinations(
... tuple(
... product(range(13), ’♠♡♢♣’)
... ), 5
... )
... )
更实际的做法是,假设我们有一个包含多个变量的数据集。一种常见的探索技术是确定数据集中所有变量对之间的相关性。如果有 v 个变量,那么我们将执行以下命令来枚举必须比较的所有变量:
>>> combinations(range(v), 2)
简单统计分析的一个有趣数据来源是 Spurious Correlations 网站。这个网站拥有大量具有惊人统计特性的数据集。让我们从 Spurious Correlations 网站,www.tylervigen.com,获取一些样本数据,以展示这是如何工作的。我们将选择三个具有相同时间范围的数据集,编号为 7、43 和 3,890。我们将简单地将数据串联成一个网格。因为源数据重复了year列,我们将从包含重复year列的数据开始。我们最终会移除明显的冗余,但通常最好从所有现有数据开始,以便确认各种数据来源是否正确对齐。
这就是年度数据的第 1 行和剩余行将看起来像这样:
[(’year’, ’Per capita consumption of cheese (US)Pounds (USDA)’,
’Number of people who died by becoming tangled in their
bedsheets Deaths (US) (CDC)’,
’year’, ’Per capita consumption of mozzarella cheese (US)Pounds
(USDA)’, ’Civil engineering doctorates awarded (US) Degrees awarded
(National Science Foundation)’,
’year’, ’US crude oil imports from Venezuela Millions of barrels
(Dept. of Energy)’, ’Per capita consumption of high fructose corn
syrup (US) Pounds (USDA)’),
(2000, 29.8, 327, 2000, 9.3, 480, 2000, 446, 62.6),
(2001, 30.1, 456, 2001, 9.7, 501, 2001, 471, 62.5),
(2002, 30.5, 509, 2002, 9.7, 540, 2002, 438, 62.8),
(2003, 30.6, 497, 2003, 9.7, 552, 2003, 436, 60.9),
(2004, 31.3, 596, 2004, 9.9, 547, 2004, 473, 59.8),
(2005, 31.7, 573, 2005, 10.2, 622, 2005, 449, 59.1),
(2006, 32.6, 661, 2006, 10.5, 655, 2006, 416, 58.2),
(2007, 33.1, 741, 2007, 11, 701, 2007, 420, 56.1),
(2008, 32.7, 809, 2008, 10.6, 712, 2008, 381, 53),
(2009, 32.8, 717, 2009, 10.6, 708, 2009, 352, 50.1)]
这就是我们如何使用combinations()函数产生这个数据集中九个变量的所有组合,每次取两个:
>>> combinations(range(9), 2)
有 36 种可能的组合。我们必须拒绝涉及匹配列year和year的组合。这些组合将与 1.00 的值简单地相关。
这里是一个从我们的数据集中选择数据列的函数:
from typing import TypeVar
from collections.abc import Iterator, Iterable
T = TypeVar("T")
def column(source: Iterable[list[T]], x: int) -> Iterator[T]:
for row in source:
yield row[x]
这允许我们使用第四章,处理集合中的corr()函数来计算两个数据列之间的相关性。
这就是我们如何计算所有相关性的组合:
from collections.abc import Iterator
from itertools import *
from Chapter04.ch04_ex4 import corr
def multi_corr(
source: list[list[float]]
) -> Iterator[tuple[float, float, float]]:
n = len(source[0])
for p, q in combinations(range(n), 2):
header_p, *data_p = list(column(source, p))
header_q, *data_q = list(column(source, q))
if header_p == header_q:
continue
r_pq = corr(data_p, data_q)
yield header_p, header_q, r_pq
对于每一组列的组合,我们已经从我们的数据集中提取了两个数据列。header_p, *data_p =...语句使用多个赋值来分离序列中的第一个项目,即标题,与剩余的行数据。如果标题匹配,我们就是在比较一个变量与自身。这对于从冗余的年份列中产生的三个年份与年份的组合将是True。
给定一个列的组合,我们将计算相关性函数,然后打印出两列的标题以及它们的相关性。我们有意选择了两个显示与第三个数据集有虚假相关性的数据集,该数据集并不紧密遵循相同的模式。尽管如此,相关性却非常高。
结果看起来像这样:
0.96: year vs Per capita consumption of cheese (US) Pounds (USDA)
0.95: year vs Number of people who died by becoming tangled in their
bedsheets Deaths (US) (CDC)
0.92: year vs Per capita consumption of mozzarella cheese (US) Pounds
(USDA)
0.98: year vs Civil engineering doctorates awarded (US) Degrees awarded
(National Science Foundation)
-0.80: year vs US crude oil imports from Venezuela Millions of barrels
(Dept. of Energy)
-0.95: year vs Per capita consumption of high fructose corn syrup (US)
Pounds (USDA)
0.95: Per capita consumption of cheese (US) Pounds (USDA) vs Number of
people who died by becoming tangled in their bedsheets Deaths (US) (CDC)
0.96: Per capita consumption of cheese (US)Pounds (USDA) vs year
0.98: Per capita consumption of cheese (US)Pounds (USDA) vs Per capita
consumption of mozzarella cheese (US)Pounds (USDA)
...
0.88: US crude oil imports from Venezuela Millions of barrels (Dept. of
Energy) vs Per capita consumption of high fructose corn syrup (US)Pounds
(USDA)
这个模式的意义并不明确。为什么这些值会相关?虚假相关性(无显著性)的存在可能会使统计分析变得复杂。我们已经找到了具有奇怪高相关性但没有明显因果因素的数据。
重要的是,一个简单的表达式combinations(range(9), 2)列出了所有可能的数据组合。这种简洁、表达性的技术使得关注数据分析问题而不是组合算法考虑变得更容易。
9.5.1 带替换的组合
itertools 库有两个函数用于生成从某些值集中选择的项的组合。combinations() 函数反映了从一副牌中发牌时的预期:每张牌最多出现一次。combinations_with_replacement() 函数反映了从一副牌中取一张牌,写下它,然后将其洗回牌堆,在再次选择另一张牌之前。这种第二种程序可能产生一个包含五张黑桃 A 的五张牌样本。
我们可以通过以下类型的表达式更清楚地看到这一点:
>>> import itertools
>>> from pprint import pprint
>>> pprint(
... list(itertools.combinations([1,2,3,4,5,6], 2))
... )
[(1, 2),
(1, 3),
(1, 4),
...
(4, 6),
(5, 6)]
>>> pprint(
... list(itertools.combinations_with_replacement([1,2,3,4,5,6], 2))
... )
[(1, 1),
(1, 2),
(1, 3),
...
(5, 5),
(5, 6),
(6, 6)]
从六个事物中每次取两个,有
= 15 种组合。当允许替换时,有 6² = 36 种组合,因为任何值都是结果的可能成员。
9.6 食谱
Python 库文档中的 itertools 章节非常出色。基本定义之后,是一系列非常清晰且有用的食谱。由于没有必要重新生成这些内容,我们在这里引用它们。它们是 Python 函数式编程的必读材料。
Python 标准库中的 itertools 食谱部分是一个极好的资源。访问 docs.python.org/3/library/itertools.html#itertools-recipes 获取更多详情。
这些函数定义不是 itertools 模块中的可导入函数。这些是需要阅读和理解,然后可能复制或修改后包含到应用程序中的想法。
以下表格总结了显示由 itertools 基础构建的函数式编程算法的一些食谱:
| 函数名称 |
powerset |
random_product |
random_permutation |
random_combination |
9.7 概述
在本章中,我们探讨了 itertools 模块中的多个函数。这个标准库模块提供了一系列函数,帮助我们以复杂的方式处理迭代器。
我们探讨了以下这些组合生成函数:
-
product()函数计算从两个或更多集合中选择的元素的所有可能组合。 -
permutations()函数为我们提供了重新排列给定值集的不同方式。 -
combinations()函数返回原始集合的所有可能子集。
我们还探讨了如何天真地使用product()和permutations()函数来创建极其大的结果集。这是一个重要的警告。一个简洁且表达力强的算法也可能涉及大量的计算。我们必须进行基本的复杂度分析,以确保代码能在合理的时间内完成。
在下一章中,我们将探讨functools模块。此模块包含一些工具,用于将函数作为一等对象处理。这基于第二章,介绍基本函数概念,和第五章,高阶函数中展示的一些材料。
9.8 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已提供的单元测试用例相同。读者应将书中的示例函数名称替换为自己的解决方案,以确认其有效性。
9.8.1 替代距离计算方法
参见《距离度量选择对 KNN 分类器性能的影响——综述》。该综述可在arxiv.org/pdf/1708.04321找到。在这篇论文中,考察了数十种距离度量在实现 k-最近邻(k-NN)分类器中的实用性。
其中一些也适用于本章中介绍的色彩匹配算法。我们定义了一个颜色,c,一个三元组,(r,g,b),基于颜色的红、绿、蓝成分。我们可以根据它们的 RGB 成分计算两种颜色之间的距离,D(c[1],c[2]):

我们展示了两种:欧几里得距离和曼哈顿距离。以下是更正式的定义:


一些额外的例子包括这些:
-
切比雪夫距离(CD)是每个颜色差异绝对值的最大值:
![CD = max (|r1 − r2|,|g1 − g2|,|b1 − b2|)]()
-
索尔森距离(SD)是对曼哈顿距离的一种修改,倾向于标准化距离:
![|r1 −-r2|+-|g1 −-g2|+-|b1 −-b2| SD = r1 + r2 + g1 + g2 + b1 + b2]()
重新定义make_color_map()函数,使其成为一个接受距离函数作为参数的高阶函数。所有距离函数都应该有Callable[[RGB, Color], float]类型的提示。一旦make_color_map()函数被修改,就可以使用不同的距离函数创建替代的颜色图。
此函数创建从“掩码”RGB 值到定义的颜色集的映射。使用 3 位掩码定义从 2³ × 2³ × 2³ = 512 个可能的 RGB 值到 133 个颜色域的映射。
函数的定义应该如下所示:
def make_color_map(colors: Sequence[Color], distance: Callable[[RGB, Color], float]) -> dict[RGB, Color]:
距离函数的选择对差异有多大?我们如何描述从大量可能的 RGB 值到有限的颜色子集域的映射?显示有多少不同的 RGB 值映射到子集颜色的直方图是否合理且具有信息性?
9.8.2 像素颜色值的实际域
在创建颜色图时,使用了一个掩码来将可能的颜色域从 2⁸ × 2⁸ × 2⁸ = 16,777,216 减少到更易于管理的 2³ × 2³ × 2³ = 512 个可能值。
使用掩码值扫描原始图像以确定实际可用颜色的域是否有意义?例如,当使用 3 位掩码时,一个给定的图像可能有 210 种不同的颜色。创建此颜色摘要实际上需要多少额外的时间?
颜色摘要能否进一步优化?例如,我们能否排除很少使用的颜色?如果我们排除这些很少使用的颜色,我们如何用更常用的颜色替换像素的颜色?如果我们用大多数相邻像素的颜色替换很少使用的颜色,图像会有什么变化?
考虑一个算法,该算法对图像的像素进行以下两次遍历:
-
创建一个包含每种颜色频率的
Counter。 -
对于少于某个阈值𝜖的颜色,找到相邻像素。在角落处,可能只有三个。在中间,不会超过八个。找到大多数这些像素的颜色并替换异常值。
算法 8:命令式迭代
在开始这个算法之前,考虑任何“边缘”情况是很重要的。特别是,当很少使用的颜色相邻时,可能会出现潜在的复杂情况。
考虑以下情况:
|
|
|
|
| p[(0,0)] | p[(1,0)] | p[(2,0)] |
|---|
|
|
|
|
| p[(0,1)] | p[(1,1)] | p[(2,1)] |
|---|
|
|
|
|
| p[(0,2)] | p[(1,2)] | p[(2,2)] |
|---|
|
|
|
|
如果左上角四个像素 p[(0,0)]、p[(1,0)]、p[(0,1)]和 p[(1,1)]都具有很少使用的颜色,那么很难选择一个多数颜色来替换 p[(0,0)]。
在没有非稀有颜色围绕一个稀有颜色的像素的情况下,算法需要在处理了邻居之后将其排队以供稍后解决。在这个例子中,像素 p[(1,0)]的颜色可以使用不是稀有颜色的邻居来计算。在 p[(0,1)]和 p[(1,1)]也被解决之后,p[(0,0)]可以用三个邻居的大多数颜色来替换。
这个算法的复杂性对于有 1000 万个像素的图片有帮助吗?任意选择一张或几张照片并不算是一个复杂的调查。然而,它可以帮助避免过度思考潜在的问题。
调查一组图片中的颜色。看到单个具有独特颜色的像素有多常见?如果您没有私人图片收藏,请访问kaggle.com寻找可以检查的图像数据集。
9.8.3 克里比奇手牌得分
克里比奇牌戏涉及一个阶段,玩家的手牌被评估。玩家将使用分发给他们的四张牌,加上一张称为起始牌的第五张牌。
为了避免过度使用“点”这个词,我们将考虑每张牌都有一个点数。每张面牌计为 10 点;所有其他牌的点数等于它们的等级。A 计为一点。
得分涉及以下牌的组合:
-
任何总点数为 15 的牌的组合为得分增加 2 分。
-
对子—两张相同等级的牌—为得分增加 2 分。
-
任何三张、四张或五张牌的顺子都会为得分增加 3 分、4 分或 5 分。
-
一手牌中的四张同花色的牌为得分增加 4 分。如果起始牌也是同一花色,那么整个同花顺为 5 分。
-
如果一手牌中的杰克与起始牌的花色相同,这将为得分增加 1 分。
如果一手牌包含三张相同等级的牌,这算作三个单独的对子,总共值 6 分。
一个有趣的手牌包含带有对子的顺子。例如,一张手牌 7C、7D、8H 和 9S,以及一个无关的起始牌王后,有两个顺子—7C、8H、9S 和 7D、8H、9S—以及一对 7。这总共是 8 分。此外,还有两种组合加起来是 15 分:7C、8H 和 7D、8H,将手牌的价值提升到 12 分。
注意,4 张牌的顺子不算作两个重叠的 3 张牌的顺子。它只值 4 分。
另一个有趣的例子是持有 4C、5D、5H、6S,起始牌是 3C。有两个不同的 4 张牌顺子:3C、4C、5D、6S 和 3C、4C、5H、6S,以及一对 5,这个模式总共是 10 分。此外,有两种不同的方式计算 15 点:4C、5D、6S 和 4C、5H、6S,为得分增加 4 分。
对于此问题的一个实用算法是列举几张牌的组合和排列,以定位所有得分。以下规则可以应用:
-
遍历牌的幂集。这是所有子集的集合:所有单张牌、所有对子、所有三张牌等,直到所有五张牌的集合。这些每个都是独特的集合,其中一些的总点数为 15 点。有关生成幂集的更多信息,请参阅 Python 标准库文档中的 Itertools 食谱部分。
-
列出所有牌对以计算任何牌对的分数。
combinations()函数对此非常适用。 -
对于五张牌的集合,如果它们是相邻的、升序的值,则这是一个连牌。如果不是相邻的、升序的值,则列出四张牌连牌的集合,以查看这些连牌中是否有相邻的数字。如果这个测试失败,则列出所有三张牌连牌的集合,以查看这些连牌中是否有相邻的数字。最长的连牌适用于计分,较短的连牌则被忽略。
-
检查手牌和起始牌是否构成五顺子。如果没有五顺子,则只检查手牌是否构成四顺子。这两种组合中只有一种是计分的。
-
此外,检查手牌中是否有与起始牌同花色的杰克。
由于只涉及五张牌,排列和组合的数量相对较小。准备好精确总结五张牌手牌所需的组合和排列数量。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第十章:10
Functools 模块
函数式编程将函数视为一等对象。我们已经看到了几个高阶函数,这些函数接受函数作为参数或返回函数(或生成器表达式)作为结果。在本章中,我们将探讨functools库,它包含一些工具帮助我们实现一些常见的函数式设计模式。
我们将探讨一些高阶函数。这扩展了第五章,高阶函数中的材料。我们还将继续在第十二章,装饰器设计技术中探讨高阶函数技术。
我们将在本模块中探讨以下函数:
-
@cache和@lru_cache:这些装饰器可以极大地提高某些类型应用程序的性能。 -
@total_ordering: 这个装饰器可以帮助创建丰富的比较运算符。此外,它让我们可以探讨面向对象设计与函数式编程相结合的更一般性问题。 -
partial(): 这个函数可以从一个函数和一些参数值绑定中创建一个新的函数。 -
reduce(): 这是一个高阶函数,它泛化了如sum()之类的归约操作。 -
singledispatch(): 这个函数允许我们根据参数类型组装不同的实现。它使我们免于编写match语句来选择实现,从而保持实现干净地分离。
我们将推迟介绍这个库的另外两个成员到第十二章,装饰器设计技术:update_wrapper()和wraps()函数。我们还将更详细地探讨在下一章中编写我们自己的装饰器。
我们将完全忽略cmp_to_key()函数。它的目的是帮助将 Python 2 代码转换为在 Python 3 下运行。由于 Python 2 不再被积极维护,我们可以安全地忽略这个函数。
10.1 函数工具
我们在第五章,高阶函数中探讨了多个高阶函数。这些函数要么接受一个函数作为参数,要么返回一个函数(或生成器表达式)作为结果。所有这些高阶函数都有一个基本的算法,该算法通过注入另一个函数进行定制。例如max()、min()和sorted()函数接受一个key=函数来自定义其行为。例如map()和filter()函数接受一个函数和一个可迭代对象,并将给定的函数应用于参数可迭代对象。在map()函数的情况下,函数的结果简单地产生。在filter()函数的情况下,函数的布尔结果用于从可迭代源产生或拒绝值。
第五章,高阶函数中的所有函数都是 Python 的 __builtins__ 包的一部分,这意味着这些函数无需使用 import 语句即可使用。它们之所以无处不在,是因为它们似乎普遍有用。本章中的函数必须使用 import 语句引入,因为它们并不那么普遍有用。
reduce() 函数跨越了这个界限。它最初是内置的。经过一些讨论后,它从 __builtins__ 包移动到 functools 模块,因为性能可能非常糟糕。在本章的后面部分,我们将看到看似简单的操作可以表现得非常糟糕。
10.2 使用缓存存储先前结果
@cache 和 @lru_cache 装饰器将给定的函数转换为一个可能运行更快的函数。LRU 表示最近最少使用——保留一个有限池的最近使用项。未最近使用的项被丢弃以保持池的大小有限。@cache 没有存储管理,需要稍微考虑以确保它不会消耗所有可用内存。
由于这些是装饰器,我们可以将它们应用于任何可能从缓存先前结果中受益的函数。我们可以如下使用它:
from functools import lru_cache
@lru_cache(128)
def fibc(n: int) -> int:
if n == 0: return 0
if n == 1: return 1
return fibc(n-1) + fibc(n-2)
这是一个基于 第六章,递归和归约 的例子。我们已将 @lru_cache 装饰器应用于天真斐波那契数计算。由于这个装饰,现在每次对 fibc(n) 函数的评估都将与装饰器维护的缓存进行检查。如果参数值 n 在缓存中,则使用之前计算的结果,而不是进行可能昂贵的重新计算。每次新的参数值集合和返回值更新缓存。
我们强调这个例子,因为在这种情况下,天真递归非常昂贵。计算任何给定斐波那契数 F[n] = F[n−1] + F[n−2] 的复杂性不仅涉及计算 F[n−1],还涉及 F[n−2]。这个值树导致复杂度为 O(2^n)。
128 的参数值是缓存的大小。这用于限制缓存使用的内存量。当缓存满时,LRU 项将被替换。
我们可以使用 timeit 模块尝试从经验上确认这些好处。我们可以分别执行这两个实现 1,000 次,以查看时间比较。使用 fib(20) 和 fibc(20) 方法显示了没有缓存的好处,这个计算是多么昂贵。由于天真版本非常慢,timeit 的重复次数减少到只有 1,000 次。以下是结果(以秒为单位):
-
天真:3.23
-
缓存:0.0779
注意,我们不能简单地使用timeit模块在fibc()函数上。缓存值将保持原位;我们只会评估一次完整的fibc(20)计算,这将填充缓存中的值。剩下的 999 次迭代将简单地从缓存中获取值。我们需要在fibc()函数的使用之间实际清除缓存,否则时间几乎会降到零。这是通过装饰器构建的fibc.cache_clear()方法来完成的。
缓存化的概念非常强大。有许多算法可以从结果缓存中受益。因为@cache装饰器适用于函数,这意味着使用函数式编程方法也可以导致高性能软件。具有副作用的功能很少是缓存的好候选;纯函数将工作得最好。
我们将再来看一个缓存好处的例子。这涉及到一个小计算,也有重复的值。从 p 个事物中取 r 个一组组合的数量通常表述如下:

这个二项式函数涉及到计算三个阶乘值。在阶乘函数上使用@cache装饰器可能是有意义的。计算多个二项式值的程序不需要重新计算所有这些阶乘。对于重复计算相似值的情况,速度提升可能会非常显著。对于缓存值很少被重用的情况,维护缓存值的开销可能会超过任何速度提升。
我们省略了实际二项式函数的细节。它只有一行代码。缓存内置函数的方式如下:
from functools import cache
from math import factorial
factorial = cache(factorial)
这将装饰器应用于现有函数。有关这种装饰方法的信息,请参阅第十二章,装饰器设计技术。
当反复评估二项式函数时,我们会看到以下情况:
-
原始阶乘:0.174
-
缓存阶乘:0.046
重要的是要认识到缓存是一个有状态的对象。这种设计将纯函数式编程推向了极致。一个函数式理想是避免状态变化。这种避免有状态变量的概念通过递归函数得到了体现;当前状态包含在参数值中,而不是变量变化的值中。我们已经看到尾调用优化是如何确保这种理想化的递归实际上与可用的处理器硬件和有限的内存预算良好工作的必要性能改进。在 Python 中,我们可以通过将尾递归替换为for循环来手动进行尾调用优化。缓存是一种类似类型的优化;我们必须根据需要手动实现它,知道它不是纯函数式编程。
此外,如果我们的设计以纯函数为中心——没有副作用——那么引入缓存不会有问题。例如,将@cache装饰器应用于具有副作用的函数(例如print()函数),将产生混淆:我们会注意到具有相同参数值的print()评估不会产生任何输出,因为结果值None将是从缓存中检索的。
从原则上讲,对具有缓存的函数的每次调用都有两个结果:预期的结果和可用于函数未来评估的新cache对象。在实践中,在我们的例子中,cache对象封装在fibc()函数的装饰版本中,并且不可用于检查或操作。
缓存并非万能。与浮点值一起工作的应用程序可能不会从记忆化中获得太多好处,因为浮点值通常是近似值。浮点值的最低有效位应被视为随机噪声,这可能会阻止@lru_cache或@cache装饰器中的精确相等性测试。
10.3 定义具有完全排序的类
@total_ordering装饰器对于创建实现丰富比较运算符的新类定义很有帮助。这可能适用于子类numbers.Number的数值类。它也可能适用于半数值类。
作为半数值类的一个例子,考虑一副扑克牌。它有一个数值等级和一个符号花色。例如,花色对某些游戏可能并不重要。像普通整数一样,牌有顺序。我们经常将每张牌的点数相加,使它们像数字一样。然而,牌的乘法,即牌×牌,实际上并没有什么意义;牌并不完全像数字。
我们几乎可以用一个以NamedTuple为基类的PlayingCard来模拟一副扑克牌,如下所示:
from typing import NamedTuple
class Card1(NamedTuple):
rank: int
suit: str
这个模型存在一个深刻的局限性:所有牌的比较都将包括等级和花色。这导致当我们比较黑桃 2 和梅花 2 时出现以下尴尬的行为:
>>> c2s = Card1(2, ’\u2660’)
>>> c2h = Card1(2, ’\u2665’)
>>> c2s
Card1(rank=2, suit=’♠’)
>>> c2h
Card1(rank=2, suit=’♡’)
# The following is undesirable for some games:
>>> c2h == c2s
False
默认比较规则对某些游戏来说很好。对于比较重点在等级而忽略花色的游戏,它不起作用。
对于某些游戏,默认的牌比较最好只基于它们的等级。
以下类定义适用于对花色不是主要关注的游戏:
from functools import total_ordering
from typing import NamedTuple
@total_ordering
class Card2(NamedTuple):
rank: int
suit: str
def __str__(self) -> str:
return f"{self.rank:2d}{self.suit}"
def __eq__(self, other: Any) -> bool:
match other:
case Card2():
return self.rank == other.rank
case int();
return self.rank == other
case _:
return NotImplemented
def __lt__(self, other: Any) -> bool:
match other:
case Card2():
return self.rank < other.rank
case int();
return self.rank < other
case _:
return NotImplemented
这个类扩展了NamedTuple类。我们提供了一个__str__()方法来打印Card2对象的字符串表示形式。
定义了两种比较——一种用于相等性,一种用于排序。可以定义各种比较,@total_ordering装饰器处理剩余比较的构建。在这种情况下,装饰器从这两个定义中创建了__le__()、__gt__()和__ge__()。__ne__()的默认实现使用__eq__();这不需要使用装饰器也能工作。
本课程提供的两种方法允许两种比较类型——Card2对象之间的比较,以及Card2对象与整数的比较。类型提示必须是Any,以保持与__eq__()和__lt__()超类定义的兼容性。很明显,它可以缩小到Union[Card2, int],但这与从超类继承的定义相冲突。
首先,这个类只提供对等级的比较,如下所示:
>>> c2s = Card2(2, ’\u2660’)
>>> c2h = Card2(2, ’\u2665’)
>>> c2h == c2s
True
>>> c2h == 2
True
>>> 2 == c2h
True
我们可以使用这个类进行许多具有简化语法的模拟,以比较牌的等级。此外,装饰器构建了一个丰富的比较操作符集,如下所示:
>>> c2s = Card2(2, ’\u2660’)
>>> c3h = Card2(3, ’\u2665’)
>>> c4c = Card2(4, ’\u2663’)
>>> c2s <= c3h < c4c
True
我们不需要编写所有的比较方法函数;它们是由装饰器生成的。装饰器创建操作符并不完美。在我们的情况下,我们要求与整数以及Card实例之间的比较。这揭示了一些问题。
由于操作符解析的方式,c4c > 3和3 < c4c这样的比较会引发TypeError异常。这暴露了由@total_ordering装饰器创建的方法的局限性。具体来说,生成的方法不会有聪明的类型匹配规则。如果我们需要在所有比较中进行类型匹配,我们需要编写所有的方法。
10.4 使用 partial()应用部分参数
partial()函数导致一种称为部分应用的东西。部分应用函数是从旧函数和所需参数值的一个子集构建的新函数。它与柯里化概念密切相关。由于柯里化并不直接应用于 Python 函数的实现方式,因此大部分理论背景在这里并不相关。然而,这个概念可以引导我们进行一些实用的简化。
我们可以查看如下简单的例子:
>>> exp2 = partial(pow, 2)
>>> exp2(12)
4096
>>> exp2(17)-1
131071
我们创建了函数exp2(y),它是pow(2, y)函数。partial()函数将第一个位置参数绑定到pow()函数。当我们评估新创建的exp2()函数时,我们得到由partial()函数绑定的参数计算出的值,以及提供给exp2()函数的额外参数。
位置参数的绑定按照严格的从左到右的顺序处理。在构建部分应用函数时,也可以提供接受关键字参数的函数。
我们也可以用如下 lambda 形式创建这种部分应用函数:
>>> exp2 = lambda y: pow(2, y)
两者都没有明显的优势。使用partial()可以帮助读者理解设计意图。使用 lambda 可能没有同样的解释力。
部分函数在需要避免向函数重复传递参数值的情况下非常有用。例如,我们可能在计算了平均值和标准差之后对数据进行归一化。这些归一化值有时被称为 Z 分数。虽然我们可以定义一个函数 z(mean: float, stdev: float, score: float) -> float,但这会有很多参数值,而这些参数值在平均值和标准差已知后不会改变。
我们更喜欢以下示例:
>>> m = mean(some_data)
>>> std = stdev(some_data)
>>> z_value = partial(z, m, std)
>>> normalized_some_data = [z_value(x) for x in some_data]
创建 z_value() 部分函数并不是——严格来说——必需的。拥有这个函数可以使创建 normalized_some_data 对象的表达式更加清晰。使用 z_value(x) 比使用 z_value(m, std, x) 稍微更易读。
我们将在第十三章《PyMonad 库》中回到 partial() 函数,并探讨如何使用柯里化实现这种类型的函数定义。
10.5 使用 reduce() 函数减少数据集
sum()、len()、max() 和 min() 函数在某种程度上都是 reduce() 函数表达的一个更通用算法的特殊化。参见第五章《高阶函数》了解更多关于这些函数的信息。reduce() 函数是一个高阶函数,它将二元操作折叠到可迭代对象的每一对元素中。
一个序列对象如下所示:
>>> d = [2, 4, 4, 4, 5, 5, 7, 9]
表达式 reduce(lambda x, y: x+y, d) 将折叠 + 操作符到列表中,就像我们正在评估以下内容:
>>> from functools import reduce
>>> reduce(lambda x, y: x+y, d)
40
>>> 2+4+4+4+5+5+7+9
40
包含 () 可以帮助显示有效的从左到右的分组,如下所示:
>>> ((((((2+4)+4)+4)+5)+5)+7)+9
40
Python 对表达式的标准解释涉及操作符的从左到右评估。因此,左折叠不涉及意义的改变。包括 Haskell 和 OCaml 在内的许多函数式编程语言都提供了折叠右的替代方案。当与递归结合使用时,编译器可以进行许多聪明的优化。这在 Python 中不可用;归约始终是从左到右的。
我们也可以提供初始值,如下所示:
>>> reduce(lambda x, y: x+y**2, d, 0)
232
如果我们不提供初始值,则使用序列的初始值作为初始化。当存在 map() 函数以及 reduce() 函数时,提供初始值是至关重要的。以下是如何使用显式的 0 初始化器计算正确答案的示例:
>>> 0 + 2**2 + 4**2 + 4**2 + 4**2 + 5**2 + 5**2 + 7**2 + 9**2
232
如果我们省略了 0 的初始化,reduce() 函数将使用第一个项目作为初始值。这个值没有应用转换函数,这会导致错误的答案。实际上,没有适当初始值的 reduce() 函数正在计算以下内容:
>>> 2 + 4**2 + 4**2 + 4**2 + 5**2 + 5**2 + 7**2 + 9**2
230
这种错误是为什么 reduce() 必须谨慎使用的原因之一。
我们可以使用 reduce() 高阶函数定义多个常见和内置的归约操作,如下所示:
from collections.abc import Callable
from functools import reduce
from typing import cast, TypeAlias
FloatFT: TypeAlias = Callable[[float, float], float]
sum2 = lambda data: reduce(cast(FloatFT, lambda x, y: x+y**2), data, 0.0)
sum = lambda data: reduce(cast(FloatFT, lambda x, y: x+y), data, 0.0)
count = lambda data: reduce(cast(FloatFT, lambda x, y: x+1), data, 0.0)
min = lambda data: reduce(cast(FloatFT, lambda x, y: x if x < y else y), data)
max = lambda data: reduce(cast(FloatFT, lambda x, y: x if x > y else y), data)
sum2()归约函数是平方和,用于计算样本集的标准差。这个sum()归约函数模仿了内置的sum()函数。count()归约函数类似于len()函数,但它可以在可迭代对象上工作,而len()函数只能在工作集合对象上工作。
cast()函数通知 mypy lambda 对象的预期类型。如果没有这个,lambda 对象的默认类型提示是Any,这不是这些函数的意图。类型提示FloatFT描述了一个接受两个浮点参数值并返回浮点对象的浮点函数。
min()和max()函数模仿内置的归约。因为可迭代的第一个项用于初始化,所以这两个函数将正常工作。如果我们为这些reduce()函数提供了初始值,我们可能会错误地使用一个在原始可迭代中从未出现过的值。
类型提示的复杂性是 lambda 对象没有向工具如 mypy 传达足够信息的建议。虽然 lambda 是有效的 Python,但很难详细检查。这导致了以下提示:
一个好的设计使用小的函数定义。
一个完整的函数定义让我们能够提供默认值、文档和doctest测试用例。
10.5.1 结合 map()和 reduce()
我们可以看到如何围绕这些基础定义构建高阶函数。我们可以定义一个 map-reduce 函数,如下结合map()和reduce()函数:
from collections.abc import Callable, Iterable
from functools import reduce
from typing import TypeVar, cast
ST = TypeVar("ST")
def map_reduce(
map_fun: Callable[[ST], float],
reduce_fun: Callable[[float, float], float],
source: Iterable[ST],
initial: float = 0
) -> float:
return reduce(reduce_fun, map(map_fun, source), initial)
这个定义有几个形式上的类型约束。首先,source迭代器产生一些类型一致的数据。我们将源类型绑定到ST类型变量,以显示在哪里需要一致的类型。其次,提供的map_fun()函数接受一个可以绑定到ST的任何类型的参数,并产生一个浮点对象。第三,提供的reduce_fun()函数将浮点对象归约,返回相同类型的结果。因为 mypy 了解 Python 运算符与整数以及浮点值的工作方式,这既适用于整数上下文也适用于浮点上下文。
我们可以使用map_fun()和reduce_fun()函数分别构建平方和归约,如下所示:
from collections.abc import Iterable
def sum2_mr(source_iter: Iterable[float]) -> float:
return map_reduce(
map_fun=lambda y: y**2,
reduce_fun=lambda x, y: x+y,
source=source_iter,
initial=0)
在这种情况下,我们使用了一个lambda y: y**2参数值作为映射,将每个值平方。归约是lambda x, y: x+y参数值。我们不需要显式提供初始值,因为初始值将是map_fun() lambda 平方后的可迭代中的第一个项。
lambda x, y: x+y 参数值是 + 操作符。Python 在 operator 模块中提供了所有算术运算符作为简短函数。(我们将在第十一章工具包中看到这一点。)以下是如何稍微简化我们的 map-reduce 操作:
from collections.abc import Iterable
import operator
def sum2_mr2(source: Iterable[float]) -> float:
return map_reduce(
lambda y: y**2,
operator.add,
source,
0)
我们使用了 operator.add 函数来求和我们的值,而不是使用较长的 lambda 形式。
下面是我们在可迭代对象中计数值的方法:
def count_mr(source: Iterable[float]) -> float:
return map_reduce(
lambda y: 1,
lambda x, y: x+y,
source,
0)
我们使用了 lambda y: 1 参数将每个值映射到值 1。然后使用 lambda 或 operator.add 函数进行归约计数。
通用 reduce() 函数允许我们从大量数据集中创建任何类型的归约到单个值。然而,在使用 reduce() 函数时,有一些限制。
10.5.2 使用 reduce() 和 partial() 函数
如我们之前所看到的,reduce() 函数有一个初始值的选项。默认的初始值是零。这个初始值是归约的种子,如果源可迭代对象为空,它将是默认值。
在以下示例中,我们提供了一个荒谬的初始值:
>>> import operator
>>> from functools import reduce
>>> d = []
>>> reduce(operator.add, d, "hello world")
’hello world’
传递给 reduce() 函数的初始值是一个字符串。因为数据源 d 为空,没有执行任何操作,所以初始值是最终结果,尽管它荒谬地无效。
当我们尝试使用 reduce() 创建一个部分函数时,我们注意到这里有一个复杂的问题:没有合理的方法来提供初始值。这源于以下根本原因:reduce() 函数没有关键字参数。对于某些归约操作,我们需要为 reduce() 函数的前两个位置参数提供值。
下面是尝试组合 partial() 和 reduce() 的结果。以下部分函数的定义不正确:
from functools import partial, reduce
psum2 = partial(reduce, lambda x, y: x+y**2)
pcount = partial(reduce, lambda x, y: x+1)
psum2() 函数应该计算值源的平方和。正如我们将看到的,这并没有按预期工作。以下是基于 partial() 函数尝试使用这些函数的示例:
>>> d = [2, 4, 4, 4, 5, 5, 7, 9]
>>> sum2(d)
232.0
>>> psum2(d)
230
>>> count(d)
8.0
>>> pcount(d)
9
作为部分函数定义的平方和没有使用适当的值序列初始化。
归约应该从 0 开始。它将对每个值应用 lambda,并计算 0 + 2**2,0 + 2**2 + 4**2 等。实际上,它从第一个值开始,即 2。然后它将对剩余的值应用 lambda,计算 2 + 4**2,2 + 4**2 + 4**2 等。
使用 partial() 没有可行的解决方案。在这些情况下,如果我们想在应用 reduce() 时进行转换,必须使用 lambda 表达式。
部分函数是一种简化特别复杂计算的重要技术。当有众多参数,其中很少改变时,部分函数可能很有帮助。部分函数可以使将复杂计算重构为使用离散部分的替代实现变得更加容易。由于每个离散部分都是一个单独定义的函数,单元测试可以确认结果是否符合预期。
reduce()函数的限制是以下具有两个特性的函数的结果:
-
只有位置参数
-
提供的参数顺序很尴尬
在reduce()的情况下,初始值位于值源之后,这使得通过partial()提供变得困难。
10.5.3 使用 map()和 reduce()函数清洗原始数据
在进行数据清洗时,我们经常会引入各种复杂程度的过滤器来排除无效值。我们还可以在有效但格式不正确的值可以被替换为有效且正确格式的值的情况下,包括一个映射来清洗值。
我们可能会产生以下输出:
from collections.abc import Callable, Iterable
from functools import reduce
def comma_fix(data: str) -> float:
try:
return float(data)
except ValueError:
return float(data.replace(",", ""))
def clean_sum(
cleaner: Callable[[str], float],
data: Iterable[str]
) -> float:
return reduce(operator.add, map(cleaner, data))
我们定义了一个映射,即comma_fix()函数,它将数据从几乎正确的字符串格式转换为可用的浮点数值。这将移除逗号字符。另一种常见的变体可以移除美元符号并转换为decimal.Decimal。我们将这个作为练习留给读者。
我们还定义了一个 map-reduce 操作,它将给定的清洗函数(在这种情况下是comma_fix()函数)应用于reduce()函数之前的数据,使用operator.add方法。
我们可以按照以下方式应用之前描述的函数:
>>> d = (’1,196’, ’1,176’, ’1,269’, ’1,240’, ’1,307’,
... ’1,435’, ’1,601’, ’1,654’, ’1,803’, ’1,734’)
>>> clean_sum(comma_fix, d)
14415.0
我们通过修复逗号以及计算总和来清洗数据。这种语法对于结合这两个操作非常方便。
然而,我们必须小心不要多次使用清洗函数。如果我们还打算计算平方和,我们真的不应该执行以下类型的处理步骤:
>>> sum = clean_sum(comma_fix, d)
>>> comma_fix_squared = lambda x: comma_fix(x)**2
>>> sum_2 = clean_sum(comma_fix_squared, d)
多次使用clean_sum()表达式意味着我们将在源数据上多次执行逗号修复操作。这是一个糟糕的设计。更好的做法是缓存comma_fix()函数的中间数值结果。使用@cache装饰器可以帮助。将清洗的中间值作为临时序列对象实体化会更好。比较不同缓存选项的性能作为练习留给读者。
10.5.4 使用 groupby()和 reduce()函数
一个常见的需求是在将数据分组后总结数据。我们可以使用defaultdict(list)方法来分组数据。然后我们可以分别分析每个分组。在第四章,处理集合中,我们探讨了分组和分区的几种方法。在第八章,Itertools 模块中,我们探讨了其他方法。
以下是我们需要分析的一些样本数据:
>>> data = [(’4’, 6.1), (’1’, 4.0), (’2’, 8.3), (’2’, 6.5),
... (’1’, 4.6), (’2’, 6.8), (’3’, 9.3), (’2’, 7.8),
... (’2’, 9.2), (’4’, 5.6), (’3’, 10.5), (’1’, 5.8),
... (’4’, 3.8), (’3’, 8.1), (’3’, 8.0), (’1’, 6.9),
... (’3’, 6.9), (’4’, 6.2), (’1’, 5.4), (’4’, 5.8)]
我们有一系列原始数据值,每个值都有一个键(一个简短字符串)和每个键的测量值(一个浮点值)。
从这些数据中产生可用的组的一种方法是为每个键到该组成员列表的映射构建一个字典,如下所示:
from collections import defaultdict
from collections.abc import Iterable, Callable, Iterator
from typing import Any, TypeVar, Protocol, cast
DT = TypeVar("DT")
class Comparable(Protocol):
def __lt__(self, __other: Any) -> bool: ...
def __gt__(self, __other: Any) -> bool: ...
def __hash__(self) -> int: ...
KT = TypeVar("KT", bound=Comparable)
def partition(
source: Iterable[DT],
key: Callable[[DT], KT] = cast(Callable[[DT], KT], lambda x: x)
) -> Iterator[tuple[KT, Iterator[DT]]]:
"""Sorting deferred."""
pd: dict[KT, list[DT]] = defaultdict(list)
for item in source:
pd[key(item)].append(item)
for k in sorted(pd):
yield k, iter(pd[k])
这将根据键将可迭代的每个项分离到组中。数据源的可迭代表述使用类型变量 DT,表示每个数据项的类型。key() 函数用于从每个项中提取键值。这个函数产生一个某种键类型的对象,KT,通常与原始数据项类型 DT 不同。在查看样本数据时,每个数据项的类型是元组。键的类型是 str。用于提取键的可调用函数将元组转换为字符串。
从每个数据项中提取的这个键值用于将每个项追加到 pd 字典中的列表中。defaultdict 对象被定义为将每个键 KT 映射到数据项列表 list[DT]。
这个函数的结果与 itertools.groupby() 函数的结果相匹配。它是一个可迭代的 (group key, iterator) 元组序列。组键值将是键函数产生的类型。迭代器将提供原始数据项的序列。
以下是用 itertools.groupby() 函数定义的相同功能:
from itertools import groupby
from collections.abc import Iterable, Callable, Iterator
def partition_s(
source: Iterable[DT],
key: Callable[[DT], KT] = cast(Callable[[DT], KT], lambda x: x)
) -> Iterable[tuple[KT, Iterator[DT]]]:
"""Sort source data"""
return groupby(sorted(source, key=key), key)
每个函数输入的重要区别在于,groupby() 函数版本要求数据按键排序,而
defaultdict 版本不需要排序。对于非常大的数据集,排序可能非常昂贵,无论是从时间还是存储的角度来看。
这是核心分区操作。这可能在过滤出组之前使用,或者在使用每个组的统计数据之前使用:
>>> for key, group_iter in partition(data, key=lambda x: x[0]):
... print(key, tuple(group_iter))
1 ((’1’, 4.0), (’1’, 4.6), (’1’, 5.8), (’1’, 6.9), (’1’, 5.4))
2 ((’2’, 8.3), (’2’, 6.5), (’2’, 6.8), (’2’, 7.8), (’2’, 9.2))
3 ((’3’, 9.3), (’3’, 10.5), (’3’, 8.1), (’3’, 8.0), (’3’, 6.9))
4 ((’4’, 6.1), (’4’, 5.6), (’4’, 3.8), (’4’, 6.2), (’4’, 5.8))
我们可以将这些分组数据总结如下:
from collections.abc import Iterable, Sequence
def summarize(
key: KT,
item_iter: Iterable[tuple[KT, float]]
) -> tuple[KT, float, float]:
# mean = lambda seq: sum(seq) / len(seq)
def mean(seq: Sequence[float]) -> float:
return sum(seq) / len(seq)
# var = lambda mean, seq: sum(...)
def var(mean: float, seq: Sequence[float]) -> float:
return sum((x - mean) ** 2 / (len(seq)-1) for x in seq)
values = tuple(v for k, v in item_iter)
m = mean(values)
return key, m, var(m, values)
partition() 函数的结果将是一个包含 (key, iterator) 双元组的序列。summarize() 函数接受这个双元组,并将其分解为键和原始数据项的迭代器。在这个函数中,数据项被定义为 tuple[KT, float],其中 KT 是某种类型的键,float 是数值。从 item_iter 迭代器中的每个双元组中,我们想要的是值部分,我们使用生成器表达式来创建只包含值的元组。
我们也可以使用表达式 map(snd, item_iter) 来从每个双元组中选取第二个元素。这需要一个 snd = lambda x: x[1] 或 snd = operator.itemgetter(1) 的定义。snd 是 second 的简称。
我们可以使用以下命令将 summarize() 函数应用于每个分区:
>>> from itertools import starmap
>>> partition1 = partition(data, key=lambda x: x[0])
>>> groups1 = starmap(summarize, partition1)
这使用了 itertools 模块中的 starmap() 函数。参见第八章,迭代工具模块。使用 partition_s() 函数的另一种定义如下:
>>> partition2 = partition_s(data, key=lambda x: x[0])
>>> groups2 = starmap(summarize, partition2)
这两种方法都将为我们提供每个组的汇总值。结果分组统计如下:
1 5.34 1.25
2 7.72 1.22
3 8.56 1.9
4 5.5 0.96
方差可以作为 χ²(卡方)测试的一部分来使用,以确定对于这些数据,零假设是否成立。零假设断言没有东西可看:数据中的方差基本上是随机的。我们还可以比较四个组之间的数据,以查看各种均值是否与零假设一致,或者是否存在某种统计上显著的差异。
10.5.5 避免 reduce()的问题
reduce() 函数存在一个阴暗面。我们必须避免以下类似的表达式:
reduce(operator.add, list_of_strings, "")
这确实可行,因为 Python 将在两个操作数之间应用通用的 add 操作符,这两个操作数是字符串。然而,它将计算大量的中间字符串对象,这是一个相对昂贵的操作。一个替代方案是 "".join(list_of_strings) 表达式。通过 timeit 进行一点研究可以揭示,string.join() 方法比通用的 reduce() 版本更有效。我们将数据收集和分析留给读者作为练习。
通常,最好仔细审查由提供的函数创建或修改某种集合的 reduce() 操作。可能有一个表面上看起来很简单但会创建非常大的中间结果的表达式。例如,我们可能会写出 reduce(accumulate_details, some_source, {}),而没有考虑到 accumulate_details() 函数如何更新字典。我们可能更倾向于查看重写底层 accumulate_details() 函数的方法,使其接受一个序列而不是单个项。
10.6 使用 singledispatch 处理多种类型
我们经常会遇到具有相似语义但基于数据类型具有不同实现的函数。我们可能有一个适用于 NamedTuple 的子类或 TypedDict 的函数。处理这些对象的语法是不同的,我们无法使用单个通用的 Python 函数。
我们有以下几种选择来处理不同类型的数据:
-
使用带有每个不同类型的
case子句的match语句。 -
使用
@singledispatch装饰器定义多个密切相关函数。这将为我们创建必要的类型匹配match语句。
当处理美国邮政数据和电子表格时,会出现一个小例子。美国邮政的 ZIP 代码通常会被误解为整数(或浮点)值。例如,马萨诸塞州的安多弗镇有一个邮政编码为 01810。电子表格可能会将其误解为整数 1810,忽略了前面的零。
当处理美国邮政数据时,我们经常需要一个函数来将邮政编码作为字符串值标准化,恢复任何丢失的前导零值。这个函数至少有以下三种情况:
-
整数值需要转换为字符串,并恢复前导零。
-
类似地,浮点值也需要转换为字符串,并恢复前导零。
-
字符串值可能是一个五位数的邮政编码或一个九位数的邮政编码。根据应用,我们可能想要截断邮政编码以确保它们是一致的。
虽然我们可以使用match语句来处理这三种情况,但我们也可以定义几个密切相关函数。@singledispatch装饰器让我们定义一个“默认”函数,用于在无法进行类型匹配时使用。然后,我们可以通过为每个我们想要处理的数据类型添加额外的定义来重载这个函数。
这里是单个zip_format()函数的定义套件。我们将从基础定义开始,这是在没有其他定义可以工作的情况下使用的:
from functools import singledispatch
from typing import Any
@singledispatch
def zip_format(zip: Any) -> str:
raise NotImplementedError(f"unsupported {type(zip)} for zip_format()")
@singledispatch装饰器将创建一个新的装饰器,使用函数的名称zip_format。这个新的@zip_format装饰器可以用来创建替代的重载定义。这些定义隐含了一个match语句,根据类型匹配规则来区分这些替代方案。
这里是替代定义:
@zip_format.register
def _(zip: int) -> str:
return f"{zip:05d}"
@zip_format.register
def _(zip: float) -> str:
return f"{zip:05.0f}"
@zip_format.register
def _(zip: str) -> str:
if "-" in zip:
zip, box = zip.split("-")
return f"{zip:0>5s}"
注意,每个替代函数都使用一个将被忽略的名称_。这些函数将全部组合成一个单一的zip_format()函数,该函数将根据参数值的类型分派适当的实现。
还需要注意的是,这些函数不一定都需要定义在同一个模块中。我们可以提供一个包含基础定义的模块。然后,其他模块可以导入这些基础定义并注册它们独特的实现函数。这允许在模块级别通过添加替代实现来扩展。
10.7 摘要
在本章中,我们探讨了functools模块中的许多函数。这个库模块提供了一些函数,帮助我们创建复杂的函数和类。
我们已经探讨了@cache和@lru_cache装饰器作为提升某些类型应用的方法,这些应用经常需要重新计算相同的值。这两个装饰器对于某些接受整数或字符串参数值的函数非常有价值。它们可以通过实现记忆化来减少处理。@lru_cache有一个内存使用的上限;这对于一个未知大小的域来说是个优点。
我们将@total_ordering函数作为一个装饰器来查看,以帮助我们构建支持丰富排序比较的对象。这处于函数式编程的边缘,但在创建新类型的数字时非常有帮助。
partial()函数创建了一个具有部分应用参数值的新的函数。作为替代,我们也可以构建具有类似特征的 lambda 表达式。这种用例是不明确的。
我们还研究了reduce()函数作为高阶函数。它将像sum()函数这样的归约泛化。我们将在后续章节的几个示例中使用此函数。这与filter()和map()函数作为重要的高阶函数的逻辑相符。
@singledispatch装饰器可以帮助我们创建具有相似语义但不同数据类型参数值的多个函数。这避免了显式match语句的开销。随着软件的发展,我们可以向替代集合中添加定义。
在下一章中,我们将探讨一系列小主题。我们将检查toolz包,它提供了一些内置的itertools和functools模块的替代方案。这个替代方案有几个新特性。它还有一些重叠的特性,从不同的角度考虑,使它们在某些应用中更有用。
我们还将看到operator模块的一些额外用途。此模块使一些 Python 运算符作为函数可用,使我们能够简化自己的函数定义。
我们还将探讨一些设计灵活决策和允许表达式以非严格顺序评估的技术。
10.8 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 存储库中已提供的单元测试用例相同。读者应将书中的示例函数名称替换为自己的解决方案以确认其工作。
10.8.1 比较string.join()和reduce()
在本章的避免 reduce()问题部分,我们注意到我们可以以下两种方式将字符串值列表合并成一个字符串:
-
reduce(operator.add, list_of_strings, "") -
"".join(list_of_strings)
其中之一比另一个效率高得多。使用timeit模块找出哪个更高效。效率提升是显著的,了解这两种方法之间的时间比可能很有帮助。
了解两种方法如何随着字符串集合的增大而扩展也很重要。为此,构建一个小模块,使用字符串集合来练习上述两个表达式。使用大小为 100、200、300、...、900 的集合,以查看工作如何随着连接字符串的数量而扩展。
10.8.2 扩展 comma_fix() 函数
在 使用 map() 和 reduce() 函数清理原始数据 部分,我们定义了一个映射,即 comma_fix() 函数,它将数据从几乎正确的字符串格式转换为可用的浮点值。这将移除逗号字符。
这个函数的名称具有误导性。它实际上是一个可以容忍一些标点的字符串到浮点数的转换。一个更好的名称可能是 tolerant_str_to_float()。
定义并测试一个容忍字符串到十进制的转换函数。这应该移除美元符号以及逗号,并将剩余的字符串转换为 decimal.Decimal。
定义并测试一个容忍字符串到整数的转换函数。这应该与 tolerant_str_to_float() 平行,仅移除逗号字符。
10.8.3 修改 clean_sum() 函数
在 使用 map() 和 reduce() 函数清理原始数据 部分,我们定义了一个 clean_sum() 函数来清理和求和一组原始字符串值。对于像计算均值这样的简单情况,这涉及到对数据进行转换和计算的单次遍历。
对于像方差或标准差这样的更复杂操作,多次遍历可能会很繁琐,因为字符串转换是重复进行的。这表明 clean_sum() 函数是一个糟糕的设计。
第一个要求是一个计算字符串数据的均值、方差和标准差的函数:



一种设计替代方案是缓存 comma_fix() 函数的中间数字结果。使用 @cache 装饰器定义一个 comma_fix() 函数。(这个函数应该重命名为更明确的名字,比如 str_to_float()。)
创建一个非常大的随机数字字符串集合,并查看哪种替代方案更快。
另一种设计替代方案是具体化清理后的中间值。创建一个只包含纯数字值的临时序列对象,然后在这些纯数字列表上计算各种统计度量。
在 第七章,复杂无状态对象 中,我们介绍了一种使用 sys.getallocatedblocks() 来了解 Python 使用了多少内存的方法。这个程序可以应用于此处,以查看哪种缓存替代方案使用的内存最少。
展示结果以显示哪种设计替代方案在性能和内存使用方面最佳。
加入我们的 Discord 社区空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第十一章:11
工具包
GitHub 上 pytoolz 项目提供的 toolz 包包含许多函数式编程特性。具体来说,这些库提供了迭代工具、高阶函数工具,甚至在无状态函数应用中与状态字典一起工作的组件。
toolz 包与标准库的组件之间有一些重叠。toolz 项目分解为三个重要的部分:itertoolz、functoolz 和 dicttoolz。itertoolz 和 functoolz 模块被设计成与标准库模块 itertools 和 functools 相对应。
我们将在本章中查看以下主题列表:
-
我们将从星映射开始,其中使用
f(*args)为映射提供多个参数。 -
我们还将使用
operator模块查看一些额外的functools.reduce()主题。 -
我们将查看
toolz包,它提供了类似于内置的itertools和functools包的功能,但提供了更高层次的函数纯度。 -
我们还将查看
operator模块以及它如何有助于在定义高阶函数时简化并可能澄清某些内容。
我们将从 itertools 和 functools.reduce() 的更多高级用法开始。这两个主题将介绍 toolz 包的使用案例。
11.1 itertools 星映射函数
itertools.starmap() 函数是 map() 高阶函数的一种变体。map() 函数将函数应用于序列中的每个项目。starmap(f, S) 函数假设序列 S 中的每个项目 i 都是一个元组,并使用 f(*i)。每个元组中的项目数必须与给定函数中的参数数相匹配。
这里是一个使用 starmap() 函数多个特性的示例:
>>> from itertools import starmap, zip_longest
>>> d = starmap(pow, zip_longest([], range(4), fillvalue=60))
>>> list(d)
[1, 60, 3600, 216000]
itertools.zip_longest() 函数将创建一个包含对的序列,[(60, 0), (60, 1), (60, 2), (60, 3)]。它这样做是因为我们提供了两个序列:空方括号和 range(4) 参数。当较短的序列数据耗尽时,使用 fillvalue 参数。
当我们使用 starmap() 函数时,每一对都成为给定函数的参数。在这种情况下,我们使用了内置的 pow() 函数,它就是 ** 操作符(我们也可以从 operator() 模块中导入这个函数;定义在两个地方)。这个表达式计算 [60**0, 60**1, 60**2, 60**3] 的值。d 变量的值是 [1, 60, 3600, 216000]。
starmap() 函数期望一个元组的序列。我们在 map(f, x, y) 和 starmap(f, zip(x, y)) 函数之间有一个整洁的等价性。
这里是 itertools.starmap() 函数先前示例的延续:
>>> p = (3, 8, 29, 44)
>>> pi = sum(starmap(truediv, zip(p, d)))
>>> pi
3.1415925925925925
我们将两个四个值的序列组合在一起。d变量的值是在上面使用starmap()计算的。p变量指的是一个简单的字面项列表。我们将这些项组合成对。我们使用starmap()函数与operator.truediv()函数(即/运算符)一起使用,这将计算一个分数序列,我们将对其进行求和。这个和是π ≈
+
+
+
的近似值。
这里有一个稍微简单一点的版本,它使用map(f, x, y)函数而不是starmap(f, zip(x, y))函数:
>>> pi = sum(map(truediv, p, d))
>>> pi
3.1415925925925925
在这个例子中,我们有效地将 60 进制的分数值转换为 10 进制。d变量中的值序列是适当的分母。可以使用本节之前解释的类似技术将其他进制转换。
一些近似涉及可能无限的和(或积)。这些可以使用本节之前解释的类似技术进行评估。我们可以利用itertools模块中的count()函数在近似中生成任意数量的项。然后我们可以使用takewhile()函数只累积对答案有有用精度水平的值。从另一个角度来看,takewhile()产生一系列显著值,并在找到不显著值时停止从流中消耗值。
在我们的下一个例子中,我们将利用第六章,递归和归约中定义的fact()函数。查看实现尾部调用优化部分以获取相关代码。
我们将介绍一个非常类似的功能,半阶乘,也称为双阶乘,用!!符号表示。半阶乘的定义与阶乘的定义类似。重要的区别是它是交替数的乘积,而不是所有数的乘积。例如,看看以下公式:
-
5!! = 5 × 3 × 1
-
7!! = 7 × 5 × 3 × 1
这里是基本函数定义:
def semifact(n: int) -> int:
match n:
case 0 | 1:
return 1
case 2:
return 2
case _:
return semifact(n-2)*n
这里是使用fact()和semifact()函数从一个潜在无限序列的分数中计算总和的例子:
>>> from Chapter06.ch06_ex1 import fact
>>> from itertools import count, takewhile
>>> num = map(fact, count())
>>> den = map(semifact, (2*n+1 for n in count()))
>>> terms = takewhile(
... lambda t: t > 1E-10, map(truediv, num, den))
>>> round(float(2*sum(terms)), 8)
3.14159265
num变量是基于fact()函数的潜在无限序列的分子,count()函数返回从零开始的递增值,并无限期地继续。den变量也是基于半阶乘函数的潜在无限序列的分母。这个den计算也使用count()来创建一个潜在无限值的序列。
为了创建项,我们使用了 map() 函数来应用 operator.truediv() 函数,即 / 运算符,到每一对值。我们用 takewhile() 函数包装这个操作,这样我们只从 map() 输出中取值,直到值大于某个相对较小的值,在这个例子中是 10^(-10)。
这是一个基于此定义的级数展开:

级数展开主题的一个有趣的变化是将 operator.truediv() 函数替换为 fractions.Fraction() 函数。这将创建精确的有理数,不受浮点近似限制。我们将实现留给读者作为练习。
所有内置的 Python 运算符都在 operator 模块中可用。这包括所有位操作符以及比较运算符。在某些情况下,生成器表达式可能比看起来相当复杂的 starmap() 函数和表示运算符的函数更简洁或更易于表达。
operator 模块提供了比 lambda 更简洁的函数。我们可以使用 operator.add 方法而不是 add=lambda a, b: a+b 形式。如果我们有比单个运算符更复杂的表达式,那么 lambda 对象是唯一可以编写它们的途径。
11.2 使用运算符模块函数进行归约
我们将探讨另一种使用 operator 模块定义的方法:我们可以使用它们与内置的 functools.reduce() 函数一起。例如,sum() 函数可以如下实现:
sum = functools.partial(functools.reduce, operator.add)
这创建了一个部分评估的 reduce() 函数版本,其中第一个参数已提供。在这种情况下,它是 + 运算符,通过 operator.add() 函数实现。
如果我们需要一个类似的函数来计算乘积,我们可以这样定义它:
prod = functools.partial(functools.reduce, operator.mul)
这遵循了前面示例中显示的模式。我们有一个部分评估的 reduce() 函数,其第一个参数是 * 运算符,由 operator.mul() 函数实现。
是否可以用太多其他运算符做类似的事情还不清楚。我们可能能够找到 operator.concat() 函数的用途。
and() 和 or() 函数是位运算符 & 和 |。这些函数旨在生成整数结果。
如果我们要使用正确的布尔运算进行归约,我们应该使用 all() 和 any() 函数,而不是尝试使用 reduce() 函数来创建某些内容。
一旦我们有了 prod() 函数,这意味着阶乘可以如下定义:
fact = lambda n: 1 if n < 2 else n * prod(range(1, n))
这具有简洁的优点:它提供了一个单行定义的阶乘。它还有不依赖于递归的优点,避免了任何与栈限制相关的问题。
这并不明显比我们拥有的许多 Python 替代方案有戏剧性的优势。然而,从 partial() 和 reduce() 函数以及 operator 模块等原始组件构建复杂函数的概念非常优雅。这是编写函数式程序的重要设计策略。
一些设计可以通过使用 toolz 包的功能来简化。我们将在下一节中查看一些 toolz 包的内容。
11.3 使用 toolz 包
toolz 包包含与内置的 itertools 和 functools 模块中的一些函数类似的功能。toolz 包添加了一些函数来对字典对象进行复杂的处理。这个包专注于可迭代对象、字典和函数。这与 JSON 和 CSV 文档中可用的数据结构很好地重叠。处理来自文件或数据库的可迭代对象的想法允许 Python 程序在不填充整个对象集合的情况下处理大量数据。
我们将查看 toolz 包各个子节中的几个示例函数。当前版本中包含六十多个单独的函数。此外,还有一个用 Cython 编写的 cytoolz 实现,它比纯 Python 的 toolz 包提供了更高的性能。
11.3.1 一些 itertoolz 函数
我们将探讨一个常见的数据分析问题,即从多个数据集中清理和组织数据。在第九章中,组合学中的迭代工具 – 排列和组合,我们提到了一些可在www.tylervigen.com找到的数据集。
每个相关性都包含一个相关数据的表格。这些表格通常看起来像以下示例:
|
|
|
|
|
|
| 2000 | 2001 | 2002 | ... |
|---|
|
|
|
|
|
|
| 每人奶酪消费量(美国) | 29.8 | 30.1 | 30.5 | ... |
|---|
|
|
|
|
|
|
| 死于床上缠绵的人数 | ||||
|---|---|---|---|---|
| 在床上缠绵而死亡的人数 | 327 | 456 | 509 | ... |
|
|
|
|
|
|
在虚假相关性的每个例子中,通常有三行数据。每行有 10 列数据,一个标题,以及一个用作方便分隔符的空列。一个小的解析函数,使用 Beautiful Soup,可以从中提取必要的数据。这个提取出来的数据并不立即有用;还需要更多的转换。
这是提取 HTML 中相关文本的核心函数:
from bs4 import BeautifulSoup # type: ignore[import]
import urllib.request
from collections.abc import Iterator
def html_data_iter(url: str) -> Iterator[str]:
with urllib.request.urlopen(url) as page:
soup = BeautifulSoup(page.read(), ’html.parser’)
data = soup.html.body.table.table
for subtable in data.table:
for c in subtable.children:
yield c.text
这个html_data_iter()函数使用urllib读取 HTML 页面。它从原始数据创建一个BeautifulSoup实例。soup.html.body.table.table表达式提供了进入 HTML 结构的导航路径。这深入到嵌套的<table>标签中,以定位感兴趣的数据。在嵌套的表格中,将会有其他子表格,包含行和列。由于各种结构可能有些不一致,因此似乎最好分别提取文本并对文本施加有意义的结构。
这个html_data_iter()函数是这样用来从 HTML 页面获取数据的:
>>> s7 = html_data_iter("http://www.tylervigen.com/view_correlation?id=7")
这个表达式的结果是文本字符串序列。许多例子有 37 个单独的字符串。这些字符串可以被分成 3 行,每行 12 个字符串,以及一个包含单个字符串值的第四行。我们可以这样理解这些行:
-
第一行有一个空字符串,十个年份值,以及一个额外的零长度字符串。
-
第二行有第一个数据序列的标题,十个值,以及一个额外的零长度字符串。
-
第三行,就像第二行一样,有第二个数据序列的标题,十个值,以及一个额外的字符串。
-
第四行包含一个字符串,其中包含两个序列之间的相关性值。
这需要一些重新组织,以创建我们可以工作的样本值集。
我们可以使用toolz.itertoolz.partition将值序列分成每组 12 个的组。如果我们使用toolz.itertoolz.interleave交错这三个集合,它将创建一个包含每个三行值的序列:年份、第一序列和第二序列。如果将其分成每组三的组,每个年份和两个样本值将是一个小的三元素元组。我们将默默地丢弃包含相关性值的额外行。
这不是数据的理想形式,但它使我们开始创建有用的对象。从长远来看,toolz框架鼓励我们创建字典来包含样本数据。我们将在稍后讨论字典。现在,我们将从重新排列前 36 个字符串的源数据开始,将其分为 3 组,每组 12 个字符串,然后是 12 组,每组 3 个字符串。这种初始重构看起来是这样的:
>>> from toolz.itertoolz import partition, interleave
>>> data_iter = partition(3, interleave(partition(12, s7)))
>>> data = list(data_iter)
>>> from pprint import pprint
>>> pprint(data)
[(’’,
’Per capita consumption of cheese (US)Pounds (USDA)’,
’Number of people who died by becoming tangled in their bedsheets Deaths (US) ’
’(CDC)’),
(’2000’, ’29.8’, ’327’),
(’2001’, ’30.1’, ’456’),
(’2002’, ’30.5’, ’509’),
(’2003’, ’30.6’, ’497’),
(’2004’, ’31.3’, ’596’),
(’2005’, ’31.7’, ’573’),
(’2006’, ’32.6’, ’661’),
(’2007’, ’33.1’, ’741’),
(’2008’, ’32.7’, ’809’),
(’2009’, ’32.8’, ’717’),
(’’, ’’, ’’)]
第一行尴尬的是,年份列没有标题。因为这是序列中的第一个项目,我们可以使用一对itertoolz函数来删除初始字符串,该字符串始终为"",并将其替换为更有用的内容,即"year"。结果序列将只在每行的末尾有空白单元格,这样我们就可以使用partitionby()将长字符串序列分解为四个单独的行。以下函数定义可以用来将源数据在空字符串上分割成并行序列:
from toolz.itertoolz import cons, drop # type: ignore[import]
from toolz.recipes import partitionby # type: ignore[import]
ROW_COUNT = 0
def row_counter(item: str) -> int:
global ROW_COUNT
rc = ROW_COUNT
if item == "": ROW_COUNT += 1
return rc
row_counter() 函数使用全局变量 ROW_COUNT 来维护行尾字符串的状态计数。一个稍微更好的设计会使用可调用对象将状态信息封装到类定义中。我们将这个变体留给了读者作为练习。在具有 __call__() 方法的类中使用实例变量比全局变量有诸多优势;重新设计这个函数是有帮助的,因为它展示了如何将副作用限制到对象的状态。我们还可以使用类级别变量和 @classmethod 来实现相同类型的隔离。
以下代码片段显示了如何使用此函数对输入进行分区:
>>> year_fixup = cons("year", drop(1, s7))
>>> year, series_1, series_2, extra = list(partitionby(row_counter, year_fixup))
>>> data = list(zip(year, series_1, series_2))
>>> from pprint import pprint
>>> pprint(data)
[(’year’,
’Per capita consumption of cheese (US)Pounds (USDA)’,
’Number of people who died by becoming tangled in their bedsheets Deaths (US) ’
’(CDC)’),
(’2000’, ’29.8’, ’327’),
(’2001’, ’30.1’, ’456’),
(’2002’, ’30.5’, ’509’),
(’2003’, ’30.6’, ’497’),
(’2004’, ’31.3’, ’596’),
(’2005’, ’31.7’, ’573’),
(’2006’, ’32.6’, ’661’),
(’2007’, ’33.1’, ’741’),
(’2008’, ’32.7’, ’809’),
(’2009’, ’32.8’, ’717’),
(’’, ’’, ’’)]
row_counter() 函数对每个单独的字符串进行调用,其中只有少数是行尾。这使得每个行可以通过 partitionby() 函数分割成单独的序列。然后通过 zip() 将这三个序列组合起来,创建一个三个元组的序列。
此结果与前面的示例相同。然而,这个变体不依赖于恰好有三行 12 个值。这种变化依赖于能够检测到每行的末尾单元格。这提供了灵活性。
结果的更有用形式是每个样本的字典,其中包含 year、series_1 和 series_2 的键。我们可以使用生成器表达式将三个元组的序列转换为字典序列。以下示例构建了一个字典序列:
from toolz.itertoolz import cons, drop
from toolz.recipes import partitionby
def make_samples(source: list[str]) -> list[dict[str, float]]:
# Drop the first "" and prepend "year"
year_fixup = cons("year", drop(1, source))
# Restructure to 12 groups of 3
year, series_1, series_2, extra = list(partitionby(row_counter, year_fixup))
# Drop the first and the (empty) last
samples = [
{"year": int(year), "series_1": float(series_1), "series_2": float(series_2)}
for year, series_1, series_2 in drop(1, zip(year, series_1, series_2))
if year
]
return samples
make_samples() 函数创建了一个字典序列。这反过来又允许我们使用其他工具提取可以用来计算两个系列之间相关系数的序列。一些 itertoolz 函数的基本模式与内置的 itertools 相似。
在某些情况下,函数名称之间会发生冲突,其语义也各不相同。例如,itertoolz.count() 和 itertools.count() 具有根本不同的定义。itertoolz 函数类似于 len(),而标准库中的 itertools 函数是 enumerate() 的变体。
在设计应用程序时,同时打开这两个库的参考文档可能会有所帮助。这可以帮助您在 itertoolz 包和标准库 itertools 包之间选择最有用的选项。
注意,在这两个包之间完全自由混合并不容易。一般的方法是选择提供正确功能组合的一个,并始终如一地使用它。
11.3.2 一些 dicttoolz 函数
toolz 模块中的 dicttoolz 模块背后的一个想法是将字典状态更改转换为具有副作用的函数。这使得像 map() 这样的高阶函数可以将多个更新应用到字典中,作为更大表达式的一部分。这使得管理值缓存或累积摘要等操作稍微容易一些。
例如,get_in() 函数使用一系列键值来导航到深层嵌套的字典对象中。当处理复杂的 JSON 文档时,使用 get_in(["k1",`` "k2"]) 可能比编写 ["k1"]["k2"] 表达式更容易。
在之前的例子中,我们创建了一个名为 samples 的样本字典序列。我们可以从每个字典中提取各种系列值,并使用这些值来计算相关系数,如下所示:
>>> from toolz.dicttoolz import get_in
>>> from Chapter04.ch04_ex4 import corr
>>> samples = make_samples(s7)
>>> s_1 = [get_in([’series_1’], s) for s in samples]
>>> s_2 = [get_in([’series_2’], s) for s in samples]
>>> round(corr(s_1, s_2), 6)
0.947091
在这个例子中,我们的相对扁平的文档意味着我们可以使用 s[’series_1’] 而不是 get_in([’series_1’],`` s)。get_in() 函数并没有带来显著的优势。然而,使用 get_in() 允许在样本结构需要更深层次嵌套以反映问题领域变化的情况下,提供未来的灵活性。
数据 s7 在 Some itertoolz functions 中描述。它来自 Spurious Correlations 网站。
我们可以设置一个路径 field`` =`` ["domain",`` "example",`` "series_1"],然后在 get_in(path,`` document) 表达式中使用这个路径。这通过数据结构隔离路径,使得更改更容易管理。这个到相关数据的路径甚至可以成为配置参数,如果数据结构经常变化的话。
11.3.3 一些 functoolz 函数
toolz 的 functoolz 模块包含许多有助于函数式设计的函数。这些函数背后的一个想法是提供一些与 Clojure 语言匹配的名称,以便更容易地在两种语言之间进行转换。
例如,@functoolz.memoize 装饰器本质上与标准库 functools.cache 相同。单词 “memoize” 与 Clojure 语言相匹配,一些程序员认为这很有帮助。
@functoolz 模块的一个显著特性是能够组合多个函数。这可能是在 Python 中处理函数式组合的最灵活方法。
考虑到之前使用表达式 partition``(3, interleave``(``partition``(12, s7``)``)``) 来重构源数据,将 37 个值的序列重新组织成 12 个三元组。最后的字符串被静默地丢弃。
这实际上是由三个函数组成的组合。我们可以将其视为以下抽象公式:

在上述例子中,p(3) 是 partition(3,`` x), i 是 interleave(y), 而 p(12) 是 partition(12,`` z). 这个函数序列被应用到源数据序列 s[7] 上。
我们可以使用functoolz.compose()更直接地实现抽象。在我们能够查看functoolz.compose()解决方案之前,我们需要查看curry()函数。在第十章 The Functools Module 中,我们看到了functools.partial()函数。这与functoolz.curry()函数背后的概念类似,但有一个小的区别。当一个柯里化函数用不完整的参数评估时,它返回一个新的柯里化函数,并提供了更多的参数值。当一个柯里化函数用所有必要的参数评估时,它计算一个结果:
>>> from toolz.functoolz import curry
>>> def some_model(a: float, b: float, x: float) -> float:
... return x**a * b
>>> curried_model = curry(some_model)
>>> cm_a = curried_model(1.0134)
>>> cm_ab = cm_a(0.7724)
>>> expected = cm_ab(1500)
>>> round(expected, 2)
1277.89
初始评估curry(some_model)创建了一个柯里化函数,我们将它赋值给curried_model变量。这个函数需要三个参数值。当我们评估curried_model(1.0134)时,我们提供了其中的一个。这次评估的结果是一个新的柯里化函数,其中a参数有了一个值。评估cm_a(0.7724)提供了第三个参数值;这导致了一个新的函数,其中a和b参数都有值。我们逐步提供参数以展示柯里化函数可以作为一个高阶函数并返回另一个柯里化函数,或者如果所有参数都有值,则计算预期的结果。
我们将在第十三章,PyMonad 库中再次回顾柯里化。这将提供对这个想法的另一个视角,即使用函数和参数值来创建一个新的函数。
常常可以看到像model = curry(some_model, 1.0134, 0.7724)这样的表达式来绑定两个参数。然后表达式model(1500)将提供一个结果,因为所有三个参数都有值。
以下示例展示了如何从三个单独的函数组合出一个更大的函数:
>>> from toolz.itertoolz import interleave, partition, drop
>>> from toolz.functoolz import compose, curry
>>> steps = [
... curry(partition, 3),
... interleave,
... curry(partition, 12),
... ]
>>> xform = compose(*steps)
>>> data = list(xform(s7))
>>> from pprint import pprint
>>> pprint(data) # doctest+ ELLIPSIS
[(’’,
’Per capita consumption of cheese (US) Pounds (USDA)’,
’Number of people who died by becoming tangled in their bedsheets Deaths (US) ’
’(CDC)’),
(’2000’, ’29.8’, ’327’),
...
(’2009’, ’32.8’, ’717’),
(’’, ’’, ’’)]
因为partition()函数需要两个参数,我们使用了curry()函数来绑定一个参数值。另一方面,interleave()函数不需要多个参数,实际上也没有必要柯里化这个函数。虽然柯里化这个函数没有造成伤害,但也没有充分的理由去柯里化它。
整体functoolz.compose()函数将三个单独的步骤组合成一个单一函数,我们将它赋值给变量xform。字符串序列s7被提供给组合函数。这按照从右到左的顺序应用函数,遵循传统的数学规则。表达式(f ∘ g ∘ h)(x)意味着 f(g(h(x)));组合中的最右边的函数首先应用。
有一个functoolz.compose_left()函数,它不遵循数学惯例。此外,还有一个functoolz.pipe()函数,许多人发现它更容易可视化。
下面是使用functoolz.pipe()函数的一个例子:
>>> from toolz.itertoolz import interleave, partition, drop
>>> from toolz.functoolz import pipe, curry
>>> data_iter = pipe(s7, curry(partition, 12), interleave, curry(partition, 3))
>>> data = list(data_iter)
>>> from pprint import pprint
>>> pprint(data) # doctext: +ELLIPSIS
[(’’,
’Per capita consumption of cheese (US Pounds (USDA)’,
’Number of people who died by becoming tangled in their bedsheets Deaths (US) ’
’(CDC)’),
(’2000’, ’29.8’, ’327’),
...
(’2009’, ’32.8’, ’717’),
(’’, ’’, ’’)]
这显示了管道中的处理步骤按从左到右的顺序。首先,partition(12, "s7")被评估。这些结果被呈现给interleave()。交错的结果被呈现给curry(partition(3))。这种管道概念可以是一个非常灵活的方式来使用toolz.itertoolz库转换大量数据。
在本节中,我们看到了toolz包中的许多函数。这些函数提供了广泛的复杂函数式编程支持。它们补充了标准itertools和functools库中的函数。通常,我们会使用这两个库中的函数来构建应用程序。
11.4 概述
我们快速浏览了一些与toolz包重叠的itertools和functools组件功能。许多设计决策涉及选择。了解通过 Python 标准库内置的内容很重要。这有助于看到扩展到另一个包可能带来的好处。
本章的核心主题是查看toolz包。它补充了内置的itertools和functools模块。toolz包使用对其他语言有经验的术语扩展了基本概念,同时也提供了对 JSON 和 CSV 使用的数据结构的关注。
在接下来的章节中,我们将探讨如何使用装饰器构建高阶函数。这些高阶函数可以导致略微简单和清晰的语法。我们可以使用装饰器来定义一个需要将其纳入多个其他函数或类中的独立方面。
11.5 练习
本章的练习基于 GitHub 上 Packt Publishing 提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。这些作为提示,允许读者探索其他解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已经提供的单元测试用例相同。读者应将书籍中的示例函数名称替换为自己的解决方案以确认其是否有效。
11.5.1 用分数替换真除法
在 The itertools star map function 部分,我们计算了使用/真除法运算符计算出的分数之和,该运算符作为operator模块中的operator.truediv()函数提供。
系列展开主题的一个有趣变化是,用operator.truediv()函数——它创建float对象——替换fractions.Fraction()函数,后者将创建Fraction对象。这样做将创建精确的有理数,不会受到浮点近似限制的困扰。
更改此运算符并确保求和仍然近似π。
11.5.2 颜色文件解析
在第三章,函数、迭代器和生成器中,Crayola.GPL文件被展示出来,但没有显示解析器的详细信息。在第八章,Itertools 模块中,展示了一个将一系列转换应用于源文件的解析器。这可以被重写为使用toolz.functoolz.pipe()。
首先,编写并测试新的解析器。
比较这两个解析。特别是,寻找可能的扩展和解析更改。如果一个文件有多个命名的颜色集怎么办?在寻找要解析和提取的相关颜色集合时,是否可以跳过无关的集合?
11.5.3 安斯康姆四重奏解析
该书的 Git 仓库包含一个文件,Anscombe.txt,其中包含四组(x, y)对。每个序列都有相同的知名平均值和标准差。由于每个序列都惊人地不同,需要四个不同的模型来计算给定 x 值的预期 y 值。
数据在一个表格中,如下例所示:
安斯康姆四重奏
I
II
III
IV
x
y
x
y
x
y
x
y
10.0
8.04
10.0
9.14
10.0
7.46
8.0
6.58
8.0
6.95
8.0
8.14
8.0
6.77
8.0
5.76
etc.
第一行是标题。第二行有系列名称。第三行有每个系列的两个列名。剩余的行都有每个系列的 x 和 y 值。
这需要分解为四个独立的序列。每个序列应包含具有"x"和"y"键的两个元素字典。
解析的基础是csv模块。这将把每一行转换为一个字符串序列。然而,每个序列中都有来自四个不同序列的八个样本。
将四个序列分解的剩余解析可以使用toolz.itertoolz或itertools来完成。编写这个解析器以分解来自 Anscombe 数据集的各个序列。确保将值从字符串转换为浮点值,以便为每个序列计算描述性统计。
11.5.4 航点计算
该书的 Git 仓库包含一个文件,Winter`` 2012-2013.kml,其中包含一次长途旅行的多个航点。在第四章,与集合一起工作中,描述了基础的row_iter_kml()函数。这个函数为旅程中的每个航点发出一系列list[str]对象。
为了有用,途径点必须成对处理。toolz.itertoolz.sliding _window()函数是将简单序列分解成对的一种方法。itertools .pairwise()函数是另一个候选方案。
在第七章,复杂无状态对象中,提供了一个distance()函数,该函数计算两个途径点之间足够接近的距离。请注意,该函数被设计为与复杂的NamedTuple对象一起工作。重新设计和重新实现这个距离函数,使其能够与表示为具有“latitude”和“longitude”键的字典的点一起工作。
源数据解析的基础是row_iter_kml()函数,它依赖于底层的xml.etree模块。这个函数将每个途径点转换成一系列字符串。
重新设计源数据解析以使用toolz包。一般处理可以使用tools.functoolz.pipe将源字符串转换成更有用的结果字典。确保将纬度和经度值转换为正确符号的浮点值。
在重新设计后,比较和对比两种实现。哪一个看起来更清晰、简洁?使用timeit模块来比较性能,看看是否提供了特定的性能优势。
11.5.5 途径点地理围栏
途径点计算练习消耗了一个包含多个途径点的文件。这些途径点被连接起来,形成从起点到终点的旅程。
也有必要将途径点作为具有纬度和经度的独立位置样本来检查。给定这些点,可以从最大和最小的纬度以及最大和最小的经度计算出一个简单的边界。
表面上,这描述了一个矩形。实际上,越接近北极,经度位置越接近。面积实际上是一种梯形,靠近极点时越窄。
需要一个类似于途径点计算练习中描述的解析管道。然而,途径点不需要组合成对。在每个轴上定位极值,以定义整个航行的边界框。
如下所述,有几种方法可以括号化航行:
-
给定航行的极边,可以定义四个点,分别对应于边界梯形的四个角。这四个点可以用来定位旅程的中点。
-
给定两个纬度和经度的序列,可以计算出一个平均纬度和一个平均经度。
-
给定两个纬度和经度的序列,可以计算出一个中纬度和一个中经度。
一旦知道了边界和中心选项,就可以使用等距圆距离计算(来自第七章,复杂无状态对象)来定位旅程中离中心最近的点。
11.5.6 row_counter()函数的可调用对象
在本章的 Some itertoolz functions 部分,定义了一个 row_counter() 函数。它使用全局变量来维护结束输入行的源数据项的数量。
一个更好的设计是一个具有内部状态的调用对象。考虑以下类定义作为您解决方案的基类:
class CountEndingItems:
def __init__(self, ending_test_function: Callable[[Any], bool]) -> None:
...
def __call__(self, row: Any) -> int:
...
def reset(self) -> None:
...
策略是创建一个调用对象,row_test = CountEndingItems``(``lambda item``: item == "")。这个调用对象可以与 toolz.itertoolz.partition_by() 一起使用,作为根据匹配某些给定条件行数的输入进行分区的一种方式。
完成这个类定义。使用它与 toolz.itertoolz.partition_by() 解决方案进行分区。对比使用全局变量与使用具有状态的调用对象的使用方法。
第十二章:12
装饰器设计技巧
Python 为我们提供了许多创建高阶函数的方法。在第五章中,高阶函数,我们探讨了两种技术:定义一个接受函数作为参数的函数,以及定义一个Callable的子类,它要么用函数初始化,要么用函数作为参数调用。
装饰函数的一个好处是它可以创建复合函数。这些是包含来自多个来源的功能的单个函数。将装饰语法作为表达复杂处理的方式通常很有帮助。
我们还可以使用装饰器来识别类或函数,通常构建一个注册表——相关定义的集合。在构建注册表时,我们不一定需要创建复合函数。
在本章中,我们将探讨以下主题:
-
使用装饰器基于另一个函数构建函数
-
functools模块中的wraps()函数;这可以帮助我们构建装饰器 -
update_wrapper()函数,在罕见的情况下,当我们想要访问原始函数以及包装函数时可能很有帮助
12.1 装饰器作为高阶函数
装饰器的核心思想是将某个原始函数转换成一个新的函数。这样使用时,装饰器创建了一个基于装饰器和被装饰的原始函数的复合函数。
装饰器可以用以下两种方式之一使用:
-
作为前缀,它创建了一个与基本函数同名的新函数,如下所示:
@decorator def base_function() -> None: pass -
作为一种显式操作,它返回一个新的函数,可能带有新的名称:
def base_function() -> None: pass base_function = decorator(base_function)
这是对同一操作的两种不同语法。前缀表示法具有整洁和简洁的优点。对于某些读者来说,前缀位置也更明显。后缀表示法是明确的,并且稍微灵活一些。
虽然前缀表示法很常见,但使用后缀表示法也有一个原因:我们可能不希望生成的函数替换原始函数。我们可能希望执行以下命令,这允许我们同时使用装饰过的和未装饰过的函数:
new_function = decorator(base_function)
这将从一个原始函数构建一个新的函数,命名为new_function()。当使用@decorator语法时,原始函数就不再可用。实际上,一旦名称被重新分配给一个新的函数对象,原始对象可能就没有剩余的引用,它曾经占用的内存可能符合回收的条件。
装饰器是一个接受函数作为参数并返回函数作为结果的函数。这种基本描述显然是语言的内建特性。表面上,这似乎意味着我们可以更新或调整函数的内部代码结构。
Python 不是通过调整函数的内部结构来工作的。Python 不是通过篡改字节码来工作的,而是使用定义一个新函数来包装原始函数的更干净的方法。这样更容易处理参数值或结果,同时保持原始函数的核心处理不变。
在定义装饰器时,涉及两个阶段的高阶函数;它们如下:
-
在定义时间,装饰器函数将包装器应用于基础函数,并返回新的、包装过的函数。装饰过程可以在构建装饰函数的过程中执行一些一次性评估。例如,可以计算复杂的默认值。
-
在评估时间,包装函数可以(并且通常确实)评估基础函数。包装函数可以预先处理参数值或后处理返回值(或者两者都做)。也有可能包装函数可能避免调用基础函数。例如,在管理缓存的情况下,包装的主要原因是避免对基础函数进行昂贵的调用。
以下是一个装饰器的示例:
from collections.abc import Callable
from functools import wraps
def nullable(function: Callable[[float], float]) -> Callable[[float | None], float | None]:
@wraps(function)
def null_wrapper(value: float | None) -> float | None:
return None if value is None else function(value)
return null_wrapper
我们几乎总是希望在创建自己的装饰器时使用@wraps装饰器,以确保装饰的函数保留了原始函数的属性。例如,复制__name__和__doc__属性,确保生成的装饰函数具有原始函数的名称和文档字符串。
结果的复合函数,定义为装饰器定义中的null_wrapper()函数,也是一种高阶函数,它将原始函数、function可调用对象组合在一个表达式中,同时保留None值。在生成的null_wrapper()函数内部,原始的function可调用对象不是一个显式的参数;它是一个自由变量,其值将从null_wrapper()函数定义的上下文中获取。
@nullable装饰器的返回值是新铸造的函数。它将被分配给原始函数的名称。重要的是装饰器只返回函数,并且它们不尝试处理数据。装饰器使用元编程:创建更多代码的代码。生成的null_wrapper()函数是打算处理应用程序数据的函数。
typing模块使得使用Optional类型定义或|类型运算符来描述 null 感知函数和 null 感知结果变得特别容易。定义float|None或Optional[float]意味着Union[float, None];要么是一个None对象,要么是一个float对象,都符合类型提示的描述。
例如,我们假设我们有一个缩放函数,它将输入数据从海里转换为英里。这可以与在英里中进行计算的地理定位数据一起使用。从海里(n)到英里(s)的基本转换是一个乘法:s = 1.15078 × n。
我们可以将我们的@nullable装饰器应用于创建一个组合函数,如下所示:
import math
@nullable
def st_miles(nm: float) -> float:
return 1.15078 * nm
这将创建一个函数st_miles(),它是小数学运算的空值感知版本。装饰过程返回一个调用原始st_miles()函数的null_wrapper()函数版本。这个结果将被命名为st_miles(),并将具有包装器和原始基本函数的复合行为。
我们可以使用这个组合的st_miles()函数如下所示:
>>> some_data = [8.7, 86.9, None, 43.4, 60]
>>> scaled = map(st_miles, some_data)
>>> list(scaled)
[10.011785999999999, 100.002782, None, 49.94385199999999, 69.04679999999999]
我们已经将函数应用于一组数据值。None值礼貌地导致None结果。没有涉及异常处理。
作为第二个例子,以下是使用相同的装饰器创建一个空值感知的四舍五入函数的方法:
@nullable
def nround4(x: float) -> float:
return round(x, 4)
这个函数是round()函数的部分应用,被包装成空值感知的形式。我们可以使用这个nround4()函数来为我们的st_miles()函数创建一个更好的测试用例,如下所示:
>>> some_data = [8.7, 86.9, None, 43.4, 60]
>>> scaled = map(st_miles, some_data)
>>> [nround4(v) for v in scaled]
[10.0118, 100.0028, None, 49.9439, 69.0468]
这个四舍五入的结果将不受任何平台考虑的影响。这对于doctest测试来说非常方便。
作为另一种实现方式,我们也可以使用以下代码创建这些空值感知函数:
st_miles_2: Callable[[float | None], float | None] = (
nullable(lambda nm: nm * 1.15078)
)
nround4_2: Callable[[float | None], float | None] = (
nullable(lambda x: round(x, 4))
)
我们没有在函数定义def语句前使用@nullable装饰器。相反,我们将nullable()函数应用于另一个定义为 lambda 形式的函数。这些表达式与函数定义前使用装饰器具有相同的效果。
注意到将类型提示应用于 lambda 表达式是有挑战性的。变量nround4_2被赋予了一个Callable类型的提示,其参数列表为float或None,返回类型为float或None。Callable提示仅适用于位置参数。在存在关键字参数或其他复杂情况时,请参阅mypy.readthedocs.io/en/stable/additional_features.html?highlight=callable#extended-callable-types。
@nullable装饰器假设被装饰的函数是一元函数。我们需要重新审视这个设计,以创建一个更通用的空值感知装饰器,它可以与任意参数集合一起工作。
在第十三章,PyMonad 库中,我们将探讨解决容忍None值问题的另一种方法。PyMonad 库定义了一个Maybe类,其对象可能具有适当的值或可能是None值。
12.1.1 使用functools.update_wrapper()函数
@wraps装饰器将update_wrapper()函数应用于保留被包装函数的一些属性。通常,默认情况下它就完成了我们所需的所有事情。这个函数从原始函数复制一系列特定的属性到由装饰器创建的结果函数中。
update_wrapper()函数依赖于在functools模块中定义的全局变量来确定要保留哪些属性。WRAPPER_ASSIGNMENTS变量定义了默认情况下复制的属性。默认值是以下要复制的属性列表:
(’__module__’, ’__name__’, ’__qualname__’, ’__doc__’,
’__annotations__’)
对这个列表进行有意义的修改是困难的。def语句的内部结构不允许简单的修改或更改。这个细节主要作为参考信息是有趣的。
如果我们要创建可调用对象,那么我们可能有一个类,它作为定义的一部分提供一些额外的属性。这可能导致装饰器必须从原始包装的可调用对象复制这些额外的属性到正在创建的包装函数。然而,通过面向对象类设计进行这些类型的更改似乎更简单,而不是利用复杂的装饰器技术。
12.2 横切关注点
装饰器背后的一个基本原则是允许我们通过装饰器和应用装饰器的原始函数来构建一个组合函数。想法是拥有一个常见的装饰器库,它可以提供对常见问题的实现。
我们通常将这些横切关注点称为跨多个函数应用。这些是我们希望通过装饰器一次性设计,并在整个应用程序或框架中的相关类中应用的事情。
通常作为装饰器定义集中化的关注点包括以下内容:
-
日志
-
审计
-
安全性
-
处理不完整数据
例如,一个日志装饰器可能会将标准消息写入应用程序的日志文件。一个审计装饰器可能会写入数据库更新的详细信息。一个安全装饰器可能会检查一些运行时上下文,以确保登录用户具有必要的权限。
我们对函数的空值感知包装器的示例是一个横切关注点。在这种情况下,我们希望有多个函数处理None值,通过返回None值而不是抛出异常。在数据不完整的应用程序中,我们可能需要以简单、统一的方式处理行,而无需编写大量分散注意力的if语句来处理缺失值。
12.3 组合设计
组合函数的常见数学表示如下:

想法是我们可以定义一个新的函数,f ∘g(x),它结合了两个其他函数,f(y)和 g(x)。
Python 可以通过以下代码实现组合函数的多行定义:
@f_deco
def g(x):
something
结果函数可以基本上等同于 f ∘ g(x)。@f_deco装饰器必须通过合并 f(y)的内部定义与提供的基函数 g(x)来定义并返回组合函数。
实现细节显示 Python 实际上提供了一种稍微复杂一点的组合类型。包装器的结构使得将 Python 装饰器组合视为以下内容是有帮助的:

应用到某个应用函数 g(x)的装饰器将包括一个包装函数 w(y),它有两个部分。包装器的一部分,wα,应用于基本函数的参数;另一部分,wβ,应用于基本函数的结果。
这里有一个稍微更具体的概念,以@stringify装饰器定义的形式展示:
def stringify(argument_function: Callable[[int, int], int]) -> Callable[[str], str]:
@wraps(argument_function)
def two_part_wrapper(text: str) -> str:
# The "before" part
arg1, arg2 = map(int, text.split(","))
int_result = argument_function(arg1, arg2)
# The "after" part
return str(int_result)
return two_part_wrapper
这个装饰器插入从字符串到整数的转换,以及从整数回到字符串的转换。在处理 CSV 文件时,内容总是字符串数据,隐藏字符串处理的细节可能是有帮助的。
我们可以将这个装饰器应用于一个函数:
>>> @stringify
... def the_model(m: int, s: int) -> int:
... return m * 45 + s * 3
...
>>> the_model("5,6")
’243’
这显示了在原始函数之前以及之后注入额外处理的两处地方。这强调了函数组合的抽象概念与 Python 实现之间的重要区别:装饰器可以创建 f(g(x))、g(f(x))或更复杂的 f[β]
g(fα)
。装饰语的语法没有描述将创建哪种组合。
装饰器的真正价值在于任何 Python 语句都可以用在包装函数中。装饰器可以使用if或for语句将一个函数转换成条件或迭代使用的函数。在下一节中,示例将利用try:语句执行带有标准错误恢复的操作。在这个通用框架内可以完成很多事情。
大量的函数式编程遵循 f ∘ g(x)的基本设计模式。从两个较小的函数定义一个组合可以帮助总结复杂的处理。在其他情况下,保持两个函数分开可能更有信息量。
创建常见的高阶函数的组合,如map()、filter()和functools.reduce(),很容易。因为这些函数相对简单,组合函数通常很容易描述,并且可以帮助使代码更具表达性。
例如,一个应用程序可能包括map(f, map(g, x))。创建一个组合函数并使用map(f_g, x)表达式来描述将组合应用于集合可能更清晰。我们可以使用f_g = lambda x: f(g(x))来帮助解释一个复杂的应用作为更简单函数的组合。为了确保类型提示正确,我们几乎总是想使用def语句定义单独的函数。
重要的是要注意,这两种技术都没有真正的性能优势。map() 函数是惰性的:使用两个 map() 函数时,一个项目将从源集合 x 中取出,由 g() 函数处理,然后由 f() 函数处理。使用单个 map() 函数时,一个项目将从源集合 x 中取出,然后由 f_g() 合成函数处理;内存使用相同。
在第十三章 《PyMonad 库》中,我们将探讨从单个柯里化函数创建复合函数的替代方法。
12.3.1 坏数据处理预处理
在一些探索性数据分析应用中,一个跨领域的关注点是如何处理缺失或无法解析的数值。我们经常遇到混合了 float、int、datetime.datetime 和 decimal.Decimal 货币值,我们希望以某种一致性对这些值进行处理。
在其他情况下,我们用不可用或不可用的占位符代替数据值;这些不应该干扰计算的主线。允许不可用值在表达式通过而不引发异常通常是很有用的。我们将重点关注三个不良数据转换函数:bd_int()、bd_float() 和 bd_decimal()。我们将 bd_datetime() 留作读者的练习。
我们要添加的复合功能将首先定义。然后我们将使用这个功能来包装内置的转换函数。以下是一个简单的坏数据装饰器:
from collections.abc import Callable
import decimal
from typing import Any, Union, TypeVar, TypeAlias
Number: TypeAlias = Union[decimal.Decimal, float]
NumT = TypeVar("NumT", bound=Number)
def bad_data(
function: Callable[[str], NumT]
) -> Callable[[str], NumT]:
@wraps(function)
def wrap_bad_data(source: str, **kwargs: Any) -> NumT:
try:
return function(source, **kwargs)
except (ValueError, decimal.InvalidOperation):
cleaned = source.replace(",", "")
return function(cleaned, **kwargs)
return wrap_bad_data
装饰器 @bad_data 包装了一个给定的转换函数,参数名为 function,以便在第一次转换失败时尝试第二次转换。ValueError 和 decimal.InvalidOperation 异常通常是数据格式无效的指标:不良数据。在移除 "," 字符后,将尝试第二次转换。这个包装器将 *args 和 **kwargs 参数传递给包装的函数。这确保了包装的函数可以接受额外的参数值。
类型变量 NumT 绑定到被包装的基本函数的原返回类型,即 function 参数的值。装饰器被定义为返回具有相同类型 NumT 的函数。此类型有 float 和 Decimal 类型的并集作为上限。这个边界允许是 float 或 Decimal 的子类的对象。
复杂装饰器设计的类型提示正在迅速演变。特别是,PEP 612 (peps.python.org/pep-0612/) 定义了一些新的结构,这些结构可以允许更加灵活的类型提示。对于不进行任何类型更改的装饰器,我们可以使用泛型参数变量,如 ParamSpec,来捕获被装饰函数的实际参数。这使得我们能够编写泛型装饰器,而无需与被装饰函数的类型提示细节纠缠。我们将指出 PEP 612 的 ParamSpec 和 Concatenate 将在何处有用。在设计泛型装饰器时,务必查看 PEP 612 的示例。
我们可以使用这个包装器创建对不良数据敏感的函数,如下所示:
from decimal import Decimal
bd_int = bad_data(int)
bd_float = bad_data(float)
bd_decimal = bad_data(Decimal)
这将创建一系列函数,可以进行良好数据的转换,以及有限的数据清洗来处理特定类型的不良数据。
为某些类型的可调用对象编写类型提示可能很困难。例如,int() 函数有可选的关键字参数,它们有自己的复杂类型提示。我们的装饰器将这些关键字参数总结为 **kwargs: Any。理想情况下,可以使用 ParamSpec 来捕获被包装函数的参数细节。有关为可调用对象创建复杂类型签名的指导,请参阅 PEP 612 (peps.python.org/pep-0612/)).
下面是使用 bd_int() 函数的一些示例:
>>> bd_int("13")
13
>>> bd_int("1,371")
1371
>>> bd_int("1,371", base=16)
4977
我们已经将 bd_int() 函数应用于转换整洁的字符串以及具有我们所能容忍的特定类型的标点符号的字符串。我们还展示了我们可以为这些转换函数中的每一个提供额外的参数。
我们可能希望有一个更灵活的装饰器。我们可能希望添加的一个特性是处理各种数据清理替代方案的能力。简单的 "," 移除并不总是我们所需要的。我们可能还需要移除 $ 或 ° 符号。我们将在下一节中查看更复杂、参数化的装饰器。
12.4 向装饰器添加参数
一个常见的需求是使用额外的参数自定义装饰器。而不是简单地创建一个复合函数 f ∘ g(x),我们可以做点更复杂的事情。使用参数化装饰器,我们可以创建
f(c) ∘ g
(x)。我们已将参数 c 作为创建包装器 f(c) 的一部分应用。然后,这个参数化复合函数 f(c) ∘ g 可以应用于实际数据 x。
在 Python 语法中,我们可以这样写:
@deco(arg)
def func(x):
base function processing...
这有两个步骤。第一步是将参数应用于一个抽象装饰器以创建一个具体装饰器。然后,将具体的参数化 deco(arg) 函数应用于基础函数定义以创建装饰函数。
其效果如下:
concrete_deco = deco(arg)
def func(x):
base function processing...
func = concrete_deco(func)
参数化装饰器通过以下三个步骤工作:
-
将抽象装饰器
deco()应用于其参数arg,以创建具体的装饰器concrete_deco()。 -
定义了基础函数,
func()。 -
将具体的装饰器
concrete_deco()应用于基础函数以创建函数的装饰版本;实际上,它是deco(arg)(func)。
带参数的装饰器涉及到最终函数的间接构造。我们似乎已经超越了仅仅是一阶函数,进入了一个更加抽象的领域:创建更高阶函数的一阶函数。
我们可以将我们的 bad-data-aware 装饰器扩展以创建一个稍微更灵活的转换。我们将定义一个 @bad_char_remove 装饰器,它可以接受要移除的字符参数。以下是一个参数化的装饰器:
from collections.abc import Callable
import decimal
from typing import Any, TypeVar
T = TypeVar(’T’)
def bad_char_remove(
*bad_chars: str
) -> Callable[[Callable[[str], T]], Callable[[str], T]]:
def cr_decorator(
function: Callable[[str], T]
) -> Callable[[str], T]:
def clean_list(text: str, *, to_replace: tuple[str, ...]) -> str:
if to_replace:
return clean_list(
text.replace(to_replace[0], ""),
to_replace=to_replace[1:]
)
return text
@wraps(function)
def wrap_char_remove(text: str, **kwargs: Any) -> T:
try:
return function(text, **kwargs)
except (ValueError, decimal.InvalidOperation):
cleaned = clean_list(text, to_replace=bad_chars)
return function(cleaned, **kwargs)
return wrap_char_remove
return cr_decorator
参数化装饰器有两个内部函数定义:
-
具体的装饰器;在这个例子中,是
cr_decorator()函数。这将有一个名为bad_chars的自由变量绑定到正在构建的函数上。具体的装饰器随后被返回;它将被应用于基础函数。当应用时,装饰器将返回一个新函数,该函数被wrap_char_remove()函数包装。这个新的wrap_char_remove()函数具有类型提示,其中包含类型变量T,它声称被包装的函数的类型将被新的wrap_char_remove()函数保留。 -
装饰包装器,在这个例子中是
wrap_char_remove()函数,将替换原始函数为包装版本。由于@wraps装饰器,新函数的__name__(和其他属性)将被替换为被包装的基础函数的名称。
整体装饰器,在这个例子中是 @bad_char_remove 函数,其任务是绑定参数 bad_chars 到一个函数,并返回具体的装饰器。类型提示澄清了返回值是一个 Callable 对象,它将一个 Callable 函数转换成另一个 Callable 函数。然后语言规则将具体装饰器应用于以下函数定义。
内部 clean_list() 函数被 @bad_char_remove 装饰器用来移除给定参数值中的所有字符。这被定义为递归以保持其非常简短。如果需要,它可以优化为迭代。我们将这个优化留作读者的练习。
我们可以使用 @bad_char_remove 装饰器创建转换函数,如下所示:
from decimal import Decimal
from typing import Any
@bad_char_remove("$", ",")
def currency(text: str, **kw: Any) -> Decimal:
return Decimal(text, **kw)
我们已经使用我们的 @bad_char_remove 装饰器包装了一个基础 currency() 函数。currency() 函数的基本特征是对 decimal.Decimal 构造函数的引用。
这个 currency() 函数现在将处理一些变体数据格式:
>>> currency("13")
Decimal(’13’)
>>> currency("$3.14")
Decimal(’3.14’)
>>> currency("$1,701.00")
Decimal(’1701.00’)
现在,我们可以使用相对简单的 map(currency, row) 表达式来处理输入数据,将源数据从字符串转换为可用的 Decimal 值。try:/except: 错误处理已被隔离到一个我们用来构建复合转换函数的函数中。
我们可以使用类似的设计来创建容错函数。这些函数将使用类似的 try:/except: 包装器,但会返回 None 值。这种设计变体留给读者作为练习。
此装饰器仅限于应用于单个字符串的转换函数,并且具有类似于 Callable[[str], T] 的类型提示。对于泛型装饰器,参考 PEP-612 中的示例,并使用 ParamSpec 和 Concatenate 类型提示来扩展装饰器的应用范围是有帮助的。因为我们感兴趣的是将内部 clean_list() 函数应用于第一个参数值,所以我们可以将转换函数视为 Callable[Concatenate[str, P], T]。我们将定义第一个参数为字符串,并使用 ParamSpec,P 来表示转换函数的所有其他参数。
12.5 实现更复杂的装饰器
要创建更复杂的组合,Python 允许以下类型的函数定义:
@f_wrap
@g_wrap
def h(x):
return something...
Python 允许堆叠修改其他装饰器结果的装饰器。这有点像 f ∘g ∘h(x)。然而,结果名称将仅仅是 h(x),隐藏了装饰器的堆栈。由于这种潜在的混淆,我们在创建涉及深度嵌套装饰器的函数时需要谨慎。如果我们的意图仅仅是处理一些横切关注点,那么每个装饰器都应该设计为处理一个单独的关注点,同时避免混淆。
虽然装饰器可以做很多事情,但使用装饰器创建清晰、简洁、表达性强的编程是至关重要的。当处理横切关注点时,装饰器的特性通常与被装饰的函数本质上不同。这可能是一种非常好的简化。通过装饰器添加日志记录、调试或安全检查是一种广泛遵循的做法。
过度复杂的设计的一个重要后果是难以提供适当的类型提示。当类型提示退化为简单地使用 Callable[..., Any] 时,设计可能已经变得难以清晰地推理。
12.6 复杂的设计考虑
在我们数据清理的情况下,简单的去除杂散字符可能不足以满足需求。当处理地理位置数据时,我们可能会遇到各种输入格式,包括简单的度数(37.549016197)、度分(37° 32.94097′)和度分秒(37° 32′ 56.46′′)。当然,还可能有更微妙的数据清理问题:一些设备会创建带有 Unicode U+00BA 字符,º,即“男性序数指示符”,而不是类似的外观度数字符,°,它是 U+00B0。
因此,通常有必要提供一个单独的清洗函数,它与转换函数捆绑在一起。这个函数将处理输入所需的更复杂的转换,这些输入在格式上与经纬度一样不一致。
我们如何实现这一点?我们有多种选择。简单的更高阶函数是一个不错的选择。另一方面,装饰器并不奏效。我们将研究基于装饰器的设计,以了解装饰器中哪些是有意义的。
需求有以下两个正交的设计考虑:
-
从
string到int、float或Decimal的输出转换,总结为Callable[str, T] -
输入清洗;移除多余的字符,重新格式化坐标;总结为
Callable[str, str]
理想情况下,可以将这些方面中的一个视为被封装的基本功能,而另一个方面则是通过装饰添加的。本质与封装的选择并不总是明确的。
考虑到之前的例子,这似乎应该被视为一个三部分的复合体:
-
从
string到int、float或decimal的输出转换 -
输入清洗:简单的替换或更复杂的多个字符替换
-
一个整体处理函数,首先尝试转换,然后作为对异常的响应执行任何清洗,然后再次尝试转换
第三部分——尝试转换和重试——实际上是封装器,它也构成了复合函数的一部分。正如我们之前提到的,封装器包含一个参数阶段和一个返回值阶段,我们可以分别称之为 wα 和 wβ。
我们想使用这个封装器来创建两个附加函数的复合体。我们有两个设计选择。我们可以将清洗函数作为转换装饰器的参数包含,如下所示:
@cleanse_before(cleanser)
def convert(text: str) -> int:
# code to convert the text, trusting it was clean
return # an int value
第一种设计声称转换函数是核心的,清洗是一个辅助细节,它将修改行为但保留转换的原始意图。
或者,我们可以将转换函数作为清洗函数的参数包含在装饰器中,如下所示:
@then_convert(converter)
def cleanse(text: str) -> str:
# code to clean the text
return # the str value for later conversion
第二种设计声称清洗是核心的,而转换是一个辅助细节。这有点令人困惑,因为清洗的类型通常是 Callable[[str], str],而转换的类型 Callable[[str], some other type] 是整体封装函数所要求的。
虽然这两种方法都可以创建一个可用的复合函数,但第一个版本有一个重要的优点:conversion() 函数的类型签名也是结果复合函数的类型签名。这突出了装饰器的一般设计模式:被装饰函数的类型注解——签名——是最容易保留的。
当面对定义复合函数的多个选择时,通常最容易保留被装饰函数的类型提示。这有助于识别核心概念。
因此,@cleanse_before(cleaner) 风格的装饰器更受欢迎。装饰器定义看起来如下示例:
from collections.abc import Callable
from typing import Any, TypeVar
# Defined Earlier:
# T = TypeVar(’T’)
def cleanse_before(
cleanse_function: Callable[[str], Any]
) -> Callable[[Callable[[str], T]], Callable[[str], T]]:
def concrete_decorator(converter: Callable[[str], T]) -> Callable[[str], T]:
@wraps(converter)
def cc_wrapper(text: str, **kwargs: Any) -> T:
try:
return converter(text, **kwargs)
except (ValueError, decimal.InvalidOperation):
cleaned = cleanse_function(text)
return converter(cleaned, **kwargs)
return cc_wrapper
return concrete_decorator
我们已经定义了以下多层装饰器:
-
核心是
cc_wrapper()函数,它应用converter()函数。如果这失败了,它将使用给定的cleanse_function()函数,然后再次尝试converter()函数。 -
cc_wrapper()函数是由concrete_decorator()装饰器围绕给定的cleanse_function()和一个converter()函数构建的。converter()函数是被装饰的函数。 -
最外层是
concrete_decorator()函数。这个装饰器将cleanse_function()函数作为一个自由变量。 -
当装饰器接口
cleanse_before()被评估时,创建了具体的装饰器。接口通过提供cleanse_function作为参数值来定制。
类型提示强调了 @cleanse_before 装饰器的角色。它期望一个名为 cleanse_function 的 Callable 函数,并创建一个函数,显示为 Callable[[str], T],它将转换一个函数为一个包装函数。这是一个关于参数化装饰器如何工作的有用提醒。
我们现在可以构建一个稍微更灵活的净化和转换函数 to_int(),如下所示:
def drop_punct2(text: str) -> str:
return text.replace(",", "").replace("$", "")
@cleanse_before(drop_punct2)
def to_int(text: str, base: int = 10) -> int:
return int(text, base)
整数转换被装饰了一个净化函数。在这种情况下,净化函数移除了 $ 和 , 字符。整数转换被这个净化所包装。
之前定义的 to_int() 函数利用了内置的 int() 函数。一个避免使用 def 语句的替代定义如下:
to_int2 = cleanse_before(drop_punct2)(int)
这使用 drop_punct2() 来包装内置的 int() 转换函数。使用 mypy 工具的 reveal_type() 函数显示,to_int() 的类型签名与内置 int() 的类型签名相匹配。可以争论说,这种风格不如使用装饰器可读。
我们可以这样使用增强的整数转换:
>>> to_int("1,701")
1701
>>> to_int("42")
42
对于底层 int() 函数的类型提示已经被重写(并简化)以适用于装饰函数 to_int()。这是尝试使用装饰器包装内置函数的结果。
由于定义参数化装饰器的复杂性,这似乎是极限。装饰器模型似乎不适合这种设计。似乎复合函数的定义比构建装饰器所需的机制更清晰。
另一种选择是复制几行代码,这些代码对于所有转换函数都是相同的。我们可以使用:
def to_int_flat(text: str, base: int = 10) -> int:
try:
return int(text, base)
except (ValueError, decimal.InvalidOperation):
cleaned = drop_punct2(text)
return int(cleaned, base)
每种数据类型转换都会重复 try-except 块。装饰器的使用以某种方式隔离了这种设计特性,可以应用于任何数量的转换函数,而无需明确重述代码。当使用这种替代方案进行设计更改时,可能需要编辑多个类似函数,而不是更改一个装饰器。
通常,当我们想要在给定的函数(或类)中包含多个相对简单且固定的方面时,装饰器工作得很好。当这些额外的方面可以被视为基础设施或支持,而不是应用程序代码含义的必要部分时,装饰器也非常重要。
对于涉及多个正交设计方面的内容,我们可能需要求助于具有各种插件策略对象的调用类定义。这可能比等效装饰器有更简单的类定义。装饰器的另一种替代方案是仔细研究创建高阶函数。在某些情况下,具有各种参数组合的偏函数可能比装饰器更简单。
跨切面关注点的典型例子包括日志记录或安全测试。这些功能可以被视为不是特定于问题域的背景处理。当我们拥有的处理像围绕我们的空气一样无处不在时,装饰器可能是一种适当的设计技术。
12.7 概述
在本章中,我们探讨了两种类型的装饰器:无参数的简单装饰器和参数化装饰器。我们看到了装饰器如何涉及函数之间的间接组合:装饰器将一个函数(在装饰器内部定义)围绕另一个函数包装。
使用 functools.wraps() 装饰器确保我们的装饰器能够正确地复制被包装函数的属性。这应该是我们编写的每个装饰器的一部分。
在下一章中,我们将探讨 PyMonad 库,以直接在 Python 中表达函数式编程概念。通常我们不需要 monads,因为 Python 本质上是一种命令式编程语言。
12.8 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含了一些练习的部分解决方案。这些解决方案作为提示,允许读者探索替代方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已提供的单元测试用例相同。读者应将书籍中的示例函数名称替换为自己的解决方案,以确认其工作。
12.8.1 日期时间转换
在本章的预处理坏数据部分,我们介绍了数据转换函数的概念,这些函数包括特殊的不适用或不可用数据值。这些通常被称为空值;因此,数据库可能有一个通用的 NULL 文字面量。我们将它们称为“坏数据”,因为这是我们经常发现它们的方式。在第一次检查数据时,我们发现坏数据可能代表缺失或不适用的值。
这种类型的数据可能有以下可能的处理路径:
-
坏数据会被静默忽略;它们不会被计入总数和平均值。为了使这个过程有效,我们通常会想要用一致的值替换坏值。
None对象是一个很好的替换值。 -
坏数据会停止处理,引发异常。这相当容易实现,因为 Python 通常会自动这样做。在某些情况下,我们想要使用替代规则重试转换。我们将专注于这种方法进行练习。
-
坏数据被替换为插值或估计值。这通常意味着保留数据集合的两个版本:一个包含坏数据,另一个包含替换值,更有用。这不是一个简单的计算。
我们的核心 bad_data() 函数的想法是尝试转换,替换已知坏标点符号,然后再次尝试。例如,我们可能会从数值中删除“,”和“$”。
在本章的前面部分,我们描述了三个坏数据转换函数:bd_int()、bd_float() 和 bd_decimal()。每个都执行了一个相对直接的转换或替换算法。我们将 bd_datetime() 函数留作读者的练习。在这种情况下,替代日期格式可能会导致更多的复杂性。
我们假设日期必须采用以下三种格式之一:“yyyy-mon-dd”、“yyyy-mm-dd”或没有年份的“mon-dd”。在第一种和第三种格式中,月份名称是完整的。在第二种格式中,月份名称是数字的。这些由 datetime.strptime() 函数使用格式字符串如 "%Y-%b-%d"、"%b-%d" 和 "%Y-%m-%d" 处理。
编写一个 bd_datetime() 函数,尝试多种数据格式转换,寻找一个能生成有效日期的格式。在缺少年份的情况下,可以使用 datetime.replace() 方法结合当前年份构建最终的日期结果。
一旦基本实现完成,创建包含有效和无效日期的适当测试用例。
确保设计足够灵活,以便在不费太多力气的情况下添加另一个格式。
12.8.2 优化装饰器
在本章的向装饰器添加参数部分,我们定义了一个装饰器,用于替换给定字段中的“坏”字符,并重试尝试的转换。
这个装饰器有一个内部函数 clean_list(),它提供了一个递归定义,用于从字符串中移除坏字符。
这里是 Python 函数的定义:
def clean_list(text: str, *, to_replace: tuple[str, ...]) -> str:
...
这个递归有两个情况:
-
当
to_replace参数值为空时,没有要替换的内容,text参数的值将保持不变。 -
否则,将
to_replace字符串分割,将第一个字符与剩余字符分开。从text参数的值中删除任何第一个字符的出现,并使用to_replace字符串的剩余字符再次应用此函数。
回顾 第六章,递归和归约,我们回忆起这种尾递归可以转换为一个 for 语句。重写 clean_list() 函数以消除递归。
12.8.3 无容错函数
在本章的 向装饰器添加参数 部分,我们看到了使用 try:/except: 包装器来揭示带有虚假标点的数字的设计模式。类似的技巧可以用来检测 None 值并将它们通过一个函数传递,而不进行任何处理。
编写一个装饰器,可以用于 Callable[[float], float] 函数,该函数将优雅地处理 None 值。
如果无容错装饰器被命名为 @none_tolerant,这里是一个测试用例:
@none_tolerant
def x2(x: float) -> float:
return 2 * x
def test_x2() -> None:
assert x2(42.) == 84.0
assert x2(None) == None
assert list(map(x2, [1, 2, None, 3])) == [2, 3, None, 6]
12.8.4 记录日志
调试的一个常见需求是一组一致的日志消息。在许多紧密相关的函数中包含 logger.debug() 行可能会变得繁琐。如果函数具有一致的类型定义集,定义一个可以应用于多个相关函数的装饰器可能会有所帮助。
作为示例函数,我们将定义一组“模型”,这些模型从样本值计算期望结果。我们将从一个数据类开始,定义每个样本具有一个标识符、一个观察值和一个时间戳。它看起来像这样:
from dataclasses import dataclass
@dataclass(frozen=True)
class Sample:
id: int
observation: float
date_time: datetime.datetime
我们有三个模型来计算期望值 e,从样本中的观察值 s[o] 中得到:
-
e = 0.7412 × s[o]
-
e = 0.9 × s[o] − 90
-
e = 0.7724 × s[o]^(1.0134)
首先,定义这三个函数以及适当的测试用例。
第二,定义一个 @logging 装饰器,使用 logger.info() 记录样本值和计算出的期望值。
第三,在每个函数定义前添加 @logging 装饰器。
创建一个整体应用程序,使用 logging.basicConfig() 将日志级别设置为 logging.INFO,以确保可以看到信息性消息。(默认日志级别仅显示警告和错误。)
这允许为三个“模型”函数创建一个一致的日志设置。这反映了应用程序的日志方面与从样本值计算期望值之间的完全分离。这种分离是否清晰且有助于理解?是否存在这种分离可能不理想的情况?
实际测量结果在此给出。其中一个模型比其他模型更准确:
|
|
|
|
| 样本编号 | 观察 | 实际 |
|---|
|
|
|
|
| 1 | 1000 | 883 |
|---|---|---|
| 2 | 1500 | 1242 |
| 3 | 1500 | 1217 |
| 4 | 1600 | 1306 |
| 5 | 1750 | 1534 |
| 6 | 2000 | 1805 |
| 7 | 2000 | 1720 |
|
|
|
|
12.8.5 干运行检查
可以修改文件系统的应用程序需要广泛的单元测试以及集成测试。为了进一步降低风险,这些应用程序通常会有一个“干运行”模式,其中文件系统修改被记录但不会执行;文件不会被移动,目录不会被删除,等等。
这里的想法是为文件系统状态变化编写小的函数。然后,每个函数都可以用 @dry_run_check 装饰器装饰。这个装饰器可以检查一个全局变量,DRY_RUN。装饰器会写入一条日志消息。如果 DRY_RUN 的值是 True,则不会执行其他操作。如果 DRY_RUN 的值是 False,则评估基础函数以执行底层状态变化,例如删除文件或删除目录。
首先,定义多个函数来复制目录。以下状态变化需要单独的函数:
-
创建一个新的空目录。
-
从源目录的某个位置复制一个文件到目标目录。我们可以使用表达式
offset = source_path.relative_to(source_dir)来计算文件在源目录中的相对位置。我们可以使用target_dir / offset来计算目标目录中的新位置。pathlib.Path对象提供了所有必需的功能。
pathlib.Path.glob() 方法提供了一个有用的目录内容的视图。这可以由一个总函数使用,该函数调用其他两个函数来创建子目录并将文件复制到其中。
第二,定义一个装饰器来在干运行时阻止操作。将装饰器应用于目录创建函数和文件复制函数。请注意,这两个函数的签名不同。一个函数使用单个路径,另一个函数使用两个路径。
第三,创建一个合适的单元测试来确认干运行模式只是走形式,但不会改变底层文件系统。pytest.tmp_path 修复提供了一个临时工作目录;使用这个可以防止在调试时不断需要删除和重新创建输出目录。
第十三章:13
PyMonad 库
单子允许我们在一个本应宽容的语言中对表达式评估强加一个顺序。我们可以使用单子来坚持将表达式 a + b + c 按从左到右的顺序评估。这可能会干扰编译器优化表达式评估的能力。然而,当我们希望文件的内容以特定的顺序读取或写入时,这是必要的:单子是一种确保 read() 和 write() 函数以特定顺序评估的方法。
对宽容且具有优化编译器的语言来说,单子可以强加表达式评估的顺序,从而受益。Python 在很大程度上是严格的,并且不进行优化,这意味着在 Python 中对单子的实际需求很少。
虽然 PyMonad 包包含各种单子和其他函数式工具,但该包的大部分设计是为了帮助人们使用 Python 语法理解函数式编程。我们将关注一些功能来帮助阐明这一观点。
在本章中,我们将探讨以下内容:
-
下载和安装 PyMonad
-
柯里化的概念以及它如何应用于函数式组合
-
PyMonad 的星号操作符用于创建复合函数
-
用于使用更通用函数对数据项进行柯里化的函子和技术
-
使用 Python 的
>>操作符的bind()操作来创建有序单子 -
我们还将解释如何使用 PyMonad 技术构建马尔可夫链模拟。
重要的是,Python 不要求使用单子。在许多情况下,读者将能够使用纯 Python 构造重写示例。进行此类重写可以帮助巩固对函数式编程的理解。
13.1 下载和安装
PyMonad 包可在 Python 包索引(PyPI)上找到。为了将 PyMonad 添加到您的环境中,您需要使用 python -m pip pymonad 命令来安装它。
本书使用版本 2.4.0 测试了所有示例。有关更多信息,请访问 pypi.python.org/pypi/PyMonad。
一旦安装了 PyMonad 包,您可以使用以下命令进行确认:
>>> import pymonad
>>> help(pymonad)
这将显示模块的文档字符串并确认一切确实已正确安装。
整个项目名称 PyMonad 使用混合大小写。我们导入的已安装 Python 包名称 pymonad 全部为小写。
13.2 函数式组合和柯里化
一些函数式语言通过将多参数函数语法转换为单个参数函数的集合来工作。这个过程称为柯里化:它以逻辑学家 Haskell Curry 命名,他开发了从早期概念的理论。我们已经在第十一章 The Toolz Package 中深入探讨了柯里化。在这里,我们将从 PyMonad 的角度重新审视它。
柯里化是将多参数函数转换为高阶单参数函数的技术。在简单的情况下,考虑一个函数 f(x,y) → z;给定两个参数 x 和 y;这将返回某个结果值,z。我们可以将函数 f(x,y)柯里化为两个函数:fc1 → fc2 和 fc2 → z。给定第一个参数值,x,评估函数 fc1 将返回一个新的单参数函数,fc2。这个第二个函数可以给第二个参数值,y,并返回所需的结果,z。
我们可以用具体的参数值评估一个柯里化函数,如下所示:f_c1(2)(3)。我们将柯里化函数应用于第一个参数值 2,创建一个新的函数。然后,我们将这个新函数应用于第二个参数值 3。
让我们看看一个具体的 Python 例子。例如,我们有一个如下所示的功能:
from pymonad.tools import curry # type: ignore[import]
@curry(4) # type: ignore[misc]
def systolic_bp(
bmi: float, age: float, gender_male: float, treatment: float
) -> float:
return (
68.15 + 0.58 * bmi + 0.65 * age + 0.94 * gender_male + 6.44 * treatment
)
这是一个基于多重回归的收缩压简单模型。该模型从身体质量指数(BMI)、年龄、性别(值为 1 表示男性)和先前治疗史(值为 1 表示先前治疗过)预测血压。有关模型及其推导的更多信息,请访问sphweb.bumc.bu.edu/otlt/MPH-Modules/BS/BS704_Multivariable/BS704_Multivariable7.html。
我们可以用所有四个参数使用systolic_bp()函数,如下所示:
>>> systolic_bp(25, 50, 1, 0)
116.09
>>> systolic_bp(25, 50, 0, 1)
121.59
一个 BMI 为 25、50 岁且没有先前治疗史的男性预测血压接近 116。第二个例子显示了一个有治疗史的类似女性,其血压可能为 121。
因为我们已经使用了@curry装饰器,我们可以创建类似于部分应用函数的中间结果。看看以下命令片段,它创建了一个新的函数treated():
>>> treated = systolic_bp(25, 50, 0)
>>> treated(0)
115.15
>>> treated(1)
121.59
在前面的例子中,我们评估了systolic_bp(25, 50, 0)表达式来创建一个柯里化函数,并将其分配给treated变量。这构建了一个新的函数treated,其中包含一些参数的值。BMI、年龄和性别值对于一个特定的病人通常不会改变。现在我们可以将新的treated()函数应用于剩余的参数值,以根据患者病史获得不同的血压预期值。
这里是一个创建一些额外柯里化函数的例子:
>>> g_t = systolic_bp(25, 50)
>>> g_t(1, 0)
116.09
>>> g_t(0, 1)
121.59
这是一个基于我们初始模型的基于性别的处理函数。我们必须提供所需的性别和处理参数值,才能从模型中获得最终值。
在某些方面,这与functools.partial()函数类似。重要的区别在于柯里化创建了一个可以以多种方式工作的函数。functools.partial()函数创建了一个更专业的函数,它只能与给定的绑定值集一起使用。有关更多信息,请参阅第十章、Functools 模块。
13.2.1 使用柯里化高阶函数
柯里化在应用于高阶函数时显示出其重要应用。例如,我们可以柯里化reduce函数,如下所示:
>>> from pymonad.tools import curry
>>> from functools import reduce
>>> creduce = curry(2, reduce)
creduce()函数是一个柯里化函数;我们现在可以通过提供一些必需的参数值来使用它来创建函数。在下一个示例中,我们将使用operator.add作为两个参数值之一来进行归约。我们可以创建一个新的函数,并将其分配给my_sum。
我们可以创建并使用这个新的my_sum()函数,如下例所示:
>>> from operator import add
>>> my_sum = creduce(add)
>>> my_sum([1,2,3])
6
我们还可以使用我们的柯里化creduce()函数与其他二元运算符一起创建其他归约。以下是如何创建一个查找序列中最大值的归约函数的示例:
>>> my_max = creduce(lambda x,y: x if x > y else y)
>>> my_max([2,5,3])
5
我们使用一个 lambda 对象来定义了自己的max()函数版本,该对象选择两个值中较大的一个。我们可以使用内置的max()函数来完成这个任务。更有用的一点是,我们可以使用更复杂的比较来定位局部最大值。对于地理围栏应用,我们可能有一个东西方向的函数和一个南北方向的函数是分开的。
我们不能轻易地使用 PyMonad 的curry()函数来创建max()函数的更通用形式。这个实现专注于位置参数。尝试使用key=关键字参数会添加太多的复杂性,使得这项技术无法实现我们的目标,即简洁和表达性的函数程序。
内置的归约包括max()、min()和sorted()函数都依赖于一个可选的key=关键字参数范式。创建柯里化版本意味着我们需要这些函数的变体,它们接受一个函数作为第一个参数,就像filter()、map()和reduce()函数一样。我们还可以创建自己的更一致的更高阶柯里化函数库。这些函数将仅依赖于位置参数,并遵循首先提供函数然后提供值的模式。
13.2.2 使用 PyMonad 进行函数组合
使用柯里化函数的一个重要好处是能够通过函数组合来组合它们。我们在第五章、高阶函数和第十二章、装饰器设计技术中探讨了函数组合。
当我们创建了一个咖喱函数后,我们可以更容易地进行函数组合,以创建一个新的、更复杂的咖喱函数。在这种情况下,PyMonad 包定义了用于组合两个函数的 * 操作符。为了解释它是如何工作的,我们将定义两个可以组合的咖喱函数。首先,我们将定义一个计算乘积的函数,然后我们将定义一个计算特定范围值的函数。
这是我们的第一个函数,它计算乘积:
import operator
prod = creduce(operator.mul)
这基于我们之前定义的咖喱 creduce() 函数。它使用 operator.mul() 函数来计算可迭代对象的乘法降维:我们可以将乘积称为序列的乘法降维。
这是我们的第二个咖喱函数,它将生成一系列偶数或奇数值:
from collections.abc import Iterable
@curry(1) # type: ignore[misc]
def alt_range(n: int) -> Iterable[int]:
if n == 0:
return range(1, 2) # Only the value [1]
elif n % 2 == 0:
return range(2, n+1, 2) # Even
else:
return range(1, n+1, 2) # Odd
alt_range() 函数的结果将是偶数或奇数值。如果 n 是奇数,它将只包含(包括)n 以上的奇数。如果 n 是偶数,它将只包含 n 以上的偶数。这些序列对于实现半阶乘或双阶乘函数 n!! 是重要的。
这是我们将 prod() 和 alt_range() 函数组合起来计算结果的方法:
>>> prod(alt_range(9))
945
咖喱函数的一个非常有趣的应用是创建可以应用于参数值的函数组合。PyMonad 包提供了这样的操作符,但它们可能会令人困惑。更好的做法是利用 Monad 的 Compose 子类。
我们可以直接使用 Compose 来实现函数组合。以下示例展示了我们如何组合 alt_range() 和 prod() 函数来计算半阶乘:
>>> from pymonad.reader import Compose
>>> semi_fact = Compose(alt_range).then(prod)
>>> semi_fact(9)
945
我们从 alt_range() 函数与 prod() 函数的组合中构建了一个 Compose 单子。这个函数可以应用于一个参数值,从两个函数的组合中计算结果。
使用咖喱函数可以帮助通过省略一些参数传递的细节来澄清复杂的计算。
注意,then() 方法强加了一个严格的顺序:首先,计算范围。一旦完成,就使用结果来计算最终乘积。
13.3 函子 – 将一切变为函数
函子的概念是对简单数据的一个函数表示。3.14 的函子版本是一个零参数函数,返回这个值。考虑以下示例:
>>> pi = lambda: 3.14
>>> pi()
3.14
我们创建了一个零参数的 lambda 对象,它返回一个 Python 浮点对象。
当我们将咖喱函数应用于函子时,我们正在创建一个新的咖喱函子。这通过使用函数来表示参数、值以及函数本身,将应用函数到参数以获取值的概念进行了推广。
一旦我们程序中的所有内容都是函数,那么所有处理就变成了函数组合主题的变体。为了恢复底层的 Python 对象,我们可以使用函子对象的 value 属性来获取一个适合 Python 的简单类型,我们可以在未柯里化的代码中使用它。
由于这种编程基于函数组合,直到我们实际使用 value 属性要求一个值之前,不需要进行任何计算。而不是执行大量的中间计算,我们的程序定义了可以在请求时产生值的中间复杂对象。原则上,这种组合可以通过一个聪明的编译器或运行时系统进行优化。
为了礼貌地与具有多个参数的函数一起工作,PyMonad 提供了一个 to_arguments() 方法。这是一个澄清提供给柯里化函数的参数值的好方法。我们将在介绍 Maybe 和 Just 单子之后看到这个示例。
我们可以用 Maybe 单子的子类包装一个 Python 对象。Maybe 单子很有趣,因为它为我们提供了一种优雅地处理缺失数据的方法。我们在第十二章装饰器设计技术中使用的方法是装饰内置函数以使它们对 None 有意识。PyMonad 库采用的方法是装饰数据以区分只是一个对象和什么都没有。
Maybe 单子有两种子类:
-
Nothing -
Just(一些 Python 对象)
我们使用 Nothing 类似于 Python 的 None 值。这是我们表示缺失数据的方式。我们使用 Just() 来包装所有其他 Python 对象。这些也是函子,提供了类似函数的常量值的表示。
我们可以使用柯里化函数与这些 Maybe 对象一起使用,以优雅地容忍缺失的数据。以下是一个简短的例子:
>>> from pymonad.maybe import Maybe, Just, Nothing
>>> x1 = Maybe.apply(systolic_bp).to_arguments(Just(25), Just(50), Just(1), Just(0))
>>> x1.value
116.09
>>> x2 = Maybe.apply(systolic_bp).to_arguments(Just(25), Just(50), Just(1), Nothing)
>>> x2
Nothing
>>> x2.value is None
True
这表明了单子如何提供答案而不是引发 TypeError 异常。当与可能缺失或无效的大型、复杂数据集一起工作时,这可以非常方便。
我们必须使用 value 属性来提取未柯里化的 Python 代码的简单 Python 值。
13.3.1 使用懒的 ListMonad() 单子
ListMonad() 单子一开始可能会让人困惑。它极其懒惰,与 Python 内置的列表类型不同。当我们评估 list(range(10)) 表达式时,list() 函数将评估 range() 对象以创建一个包含 10 个项目的列表。然而,PyMonad 的 ListMonad() 单子懒惰到甚至不进行这种评估。
这里是对比:
>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> from pymonad.list import ListMonad
>>> ListMonad(range(10))
[range(0, 10)]
ListMonad() 单子没有评估 range() 对象的值序列的可迭代序列;它保留它而不进行评估。ListMonad() 单子对于收集函数而不评估它们非常有用。
我们可以在需要时评估 ListMonad() 单子:
>>> from pymonad.list import ListMonad
>>> x = ListMonad(range(10))
>>> x
[range(0, 10)]
>>> x[0]
range(0, 10)
>>> list(x[0])
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
我们创建了一个懒加载的 ListMonad() 对象,其中包含一个 range() 对象。然后我们在该列表的位置 0 提取并评估了一个 range() 对象。
ListMonad() 对象不会评估生成器函数。它将任何可迭代参数视为一个单一的迭代器对象。我们可以在以后应用被函子包含的函数。
这是 range() 函数的柯里化版本。它的下限是 1 而不是 0。这对于某些数学工作很有用,因为它允许我们避免内置 range() 函数中位置参数的复杂性:
from collections.abc import Iterator
from pymonad.tools import curry
@curry(1) # type: ignore[misc]
def range1n(n: int) -> range:
if n == 0: return range(1, 2) # Only the value 1
return range(1, n+1)
我们使用 PyMonad 包将内置的 range() 函数包装起来,使其可柯里化。
由于 ListMonad 对象是一个函子,我们可以将函数映射到 ListMonad 对象。该函数应用于 ListMonad 对象中的每个项目。
这是另一个例子:
>>> from pymonad.reader import Compose
>>> from pymonad.list import ListMonad
>>> fact = Compose(range1n).then(prod)
>>> seq1 = ListMonad(*range(20))
>>> f1 = seq1.map(fact)
>>> f1[:10]
[1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880]
我们定义了一个复合函数,fact(),它是由之前展示的 prod() 和 range1n() 函数构建的。这是阶乘函数。我们创建了一个 ListMonad() 函子,seq1,它是一个包含 20 个值的序列。我们将 fact() 函数映射到 seq1 函子,从而创建了一个阶乘值的序列,f1。最后,我们提取了这些值中的前 10 个。
这是我们将用于扩展这个示例的另一个小函数:
from pymonad.tools import curry
@curry(1) # type: ignore[misc]
def n21(n: int) -> int:
return 2*n+1
这个小小的 n21() 函数执行一个简单的计算。然而,它是柯里化的,因此我们可以将其应用于一个函子,如 ListMonad() 对象。以下是前面示例的下一部分:
>>> semi_fact = Compose(alt_range).then(prod)
>>> f2 = seq1.map(n21).then(semi_fact)
>>> f2[:10]
[1, 3, 15, 105, 945, 10395, 135135, 2027025, 34459425, 654729075]
我们定义了一个复合函数,由之前展示的 prod() 和 alt_range() 函数组成。f2 对象的值是通过将我们的小 n21() 函数应用于 seq1 序列来构建的。这创建了一个新的序列。然后我们对这个新序列中的每个对象应用了 semi_fact() 函数,以创建一个与 f1 值序列平行的值序列。
我们现在可以将 / 操作符,operator.truediv,映射到这两个平行的值序列,f1 和 f2:
>>> import operator
>>> 2 * sum(map(operator.truediv, f1, f2))
3.1415919276751456
内置的 map() 函数将给定的操作符应用于两个函子,得到一个可以相加的分数序列。
我们使用一些函数组合技术和一个函子类定义定义了一个相当复杂的计算。这是基于反正切的计算。以下是这个计算的完整定义:

理想情况下,我们更倾向于不使用只有 20 个值的固定大小的 ListMonad 对象。我们更希望有一个懒加载的、可能无限的整数值序列,这样我们可以得到任意精度的近似。然后我们可以使用 sum() 和 takewhile() 函数的柯里化版本来找到序列中值的和,直到这些值太小,无法对结果产生影响。
这个使用 takewhile() 函数的重写留给读者作为练习。
13.4 Monad bind() 函数
PyMonad 库的名字来源于函数式编程中的单子概念,即具有严格顺序的函数。许多函数式编程背后的基本假设是函数式评估是自由的:它可以按需优化或重新排序。单子提供了一个例外,强制执行从左到右的严格顺序。
正如我们所见,Python 已经是严格的。它不需要单子。然而,我们仍然可以在有助于澄清复杂算法的地方应用这个概念。下面我们将通过一个例子来看使用基于单子的方法来设计基于马尔可夫链的模拟。
强制严格评估的技术是连接一个单子(monad)和一个将返回单子的函数的绑定。一个平坦的表达式将变成嵌套的绑定,优化编译器无法重新排序。单子的then()方法强制这种严格的顺序。
在其他语言中,如 Haskell,单子对于需要严格顺序的文件输入和输出至关重要。Python 的命令式模式与 Haskell 的do块非常相似,它有一个隐式的 Haskell >>=运算符,用于强制按顺序评估语句。PyMonad 使用then()方法进行这种绑定。
13.5 使用单子实现模拟
预期单子将通过一种管道:单子将被作为参数传递给一个函数,并且将返回一个类似的单子作为函数的值。函数必须设计成可以接受和返回类似的结构。
我们将研究一个可以用于过程模拟的单子基础管道。这种模拟有时被称为蒙特卡洛模拟。在这种情况下,模拟将创建一个马尔可夫链。
马尔可夫链是一系列潜在事件的模型。每个事件发生的概率只取决于前一个事件中达到的状态。整个系统的每个状态都有一个概率集,这些概率定义了事件和相关状态变化。它非常适合涉及随机机会的游戏,如骰子或牌戏。它也适合于工业流程,其中小的随机效应可以“波及”整个系统,导致可能看似与微小初始问题直接无关的效果。
我们的例子涉及一些相当复杂模拟的规则。我们可以将图 13.1 中显示的以下状态变化可视化为创建一个以通过或失败事件结束的事件链。事件的数量有一个下限为 1。

图 13.1:马尔可夫链生成器
状态转换概率以分数形式表示,
,因为这个特定的马尔可夫链生成器来自使用两个骰子的游戏。掷骰子有 36 种可能的结果。当考虑两个骰子的和时,10 个不同的值有不同的概率,从值为 2 和 12 的
到值为 7 的
。
因为这是基于游戏的,所以实际算法比状态转换图要简单一些。简化算法描述的技巧是将多个类似行为组合成一个由参数 p 定义的单个状态。
算法使用内部状态。对于刚开始接触函数式编程的设计师来说,这是一个问题。我们在其他示例中展示的解决方案是将状态作为函数的参数公开。
我们将从一个具有显式状态的算法展示开始。
算法 9:马尔可夫链生成器
该算法可以看作是要求状态改变。或者,我们可以将其视为要附加到马尔可夫链上的操作序列,而不是状态改变。有一个必须首先使用的函数来创建初始结果或确定 p 的值。之后,另一个递归函数用于迭代,直到确定结果。这样,这种函数对的方法非常适合单子设计模式。
要构建马尔可夫链,我们需要一个随机数源:
import random
def rng() -> tuple[int, int]:
return (random.randint(1,6), random.randint(1,6))
from collections.abc import Callable
from typing import TypeAlias
DiceT: TypeAlias = Callable[[], tuple[int, int]]
上述函数将为我们生成一对骰子。我们还包含了一个类型提示DiceT,它可以用来描述任何返回包含两个整数的元组的类似函数。类型提示将在后续函数中用作任何类似随机数生成器的简称。
这是基于游戏算法的总体链生成器的预期:
from pymonad.maybe import Maybe, Just
def game_chain(dice: DiceT) -> Maybe:
outcome = (
Just(("", 0, []))
.then(initial_roll(dice))
.then(point_roll(dice))
)
return outcome
我们创建一个初始单子Just(("",`` 0,`` []))来定义我们将要工作的基本类型。一个游戏将产生一个包含结果文本、得分值和一系列掷骰子的三重元组。在每场游戏的开始,一个默认的三重元组建立了三重元组类型。
我们将这个单子传递给另外两个函数。这将创建一个包含游戏结果的最终单子outcome。我们使用then()方法以特定的顺序连接函数,在具有优化编译器的语言中,这将防止表达式被重新排列。
我们将使用value属性在末尾获取单子的值。由于单子对象是惰性的,这个请求将触发对各种单子的评估,以创建所需输出。
每个由三元组组成的序列结果是一个我们可以分析的马尔可夫链,以确定整体统计特性。我们通常对链的预期长度感兴趣。这可能很难从初始模型或算法中预测。
initial_roll() 函数也将 rng() 函数作为第一个参数进行柯里化。单子将成为这个函数的第二个参数。initial_roll() 函数可以掷骰子并应用出局规则以确定我们是否有通过、失败或确定点。
point_roll() 函数也将 rng() 函数作为第一个参数进行柯里化。单子将成为这个函数的第二个参数。point_roll() 函数可以掷骰子以查看游戏是否已解决。如果游戏未解决,这个函数将递归地操作以继续寻找解决方案。
initial_roll() 函数看起来像这样:
from pymonad.tools import curry
from pymonad.maybe import Maybe, Just
@curry(2) # type: ignore[misc]
def initial_roll(dice: DiceT, status: Maybe) -> Maybe:
d = dice()
if sum(d) in (7, 11):
return Just(("pass", sum(d), [d]))
elif sum(d) in (2, 3, 12):
return Just(("fail", sum(d), [d]))
else:
return Just(("point", sum(d), [d]))
一次掷骰子以确定初始结果是通过、失败还是建立点。我们返回一个适当的单子值,该值包括结果、点值和导致此状态的掷骰结果。立即通过和立即失败的点值实际上并不真正有意义。我们可以合理地在这里返回一个 0 值,因为没有真正建立点。
对于使用像 pylint 这样的工具的开发者来说,status 参数没有被使用。这会产生一个需要关闭的警告。添加 #`` pylint:`` disable= 可以关闭这个警告。
unused-argument 注释将关闭警告。
point_roll() 函数看起来像这样:
from pymonad.tools import curry
from pymonad.maybe import Maybe, Just
@curry(2) # type: ignore[misc]
def point_roll(dice: DiceT, status: Maybe) -> Maybe:
prev, point, so_far = status
if prev != "point":
# won or lost on a previous throw
return Just(status)
d = dice()
if sum(d) == 7:
return Just(("fail", point, so_far+[d]))
elif sum(d) == point:
return Just(("pass", point, so_far+[d]))
else:
return (
Just(("point", point, so_far+[d]))
.then(point_roll(dice))
)
我们将状态单子分解为元组的三个单独的值。我们可以使用小的 lambda 对象来提取第一个、第二个和第三个值。我们也可以使用 operator.itemgetter() 函数来提取元组的项。相反,我们使用了多重赋值。
如果没有确定点,之前的状态将是通过或失败。游戏在 initial_roll() 函数中解决,这个函数只是简单地返回状态单子。
如果已经确定了一个点,状态将是点。掷骰子并应用规则到这个新的掷骰结果上。如果掷出 7,游戏结束,并返回一个最终单子。如果掷出的是点,游戏胜利并返回适当的单子。否则,将稍微修改后的单子传递给 point_roll() 函数。修改后的状态单子包括这个掷骰结果在掷骰历史中。
典型的输出看起来像这样:
>>> game_chain()
(’fail’, 5, [(2, 3), (1, 3), (1, 5), (1, 6)])
最终的单子有一个显示结果的字符串。它有已建立的点以及导致最终结果的一系列掷骰。
我们可以使用模拟来检查不同的结果,以收集关于这个复杂、状态化的过程的统计数据。这种马尔可夫链模型可以反映许多奇怪的边缘情况,这些情况会导致结果分布的惊人变化。
通过一些简单且功能性的编程设计技术,可以构建大量的巧妙蒙特卡洛模拟。特别是,幺半群可以帮助在存在复杂顺序或内部状态时结构化这些计算。
13.6 PyMonad 的附加功能
PyMonad 的另一个特性是名为幺半群的令人困惑的特性。这直接来自数学,它指的是具有运算符和单位元素的数据元素组,并且该组在该运算符下是封闭的。以下是一个例子:当我们想到自然数、加法运算符和单位元素 0 时,这是一个正确的幺半群。对于正整数,使用运算符 * 和单位值 1,我们也有一个幺半群;使用 + 作为运算符和空字符串作为单位元素的字符串也符合条件。
PyMonad 包含了多个预定义的幺半群类。我们可以扩展这个功能来添加我们自己的幺半群类。目的是限制编译器只能进行某些类型的优化。我们还可以使用幺半群类来创建累积复杂值的数据结构,可能包括之前操作的历史记录。
pymonad.list 是一个幺半群的例子。单位元素是一个空列表,由 ListMonad() 定义。加法操作定义了列表连接。幺半群是 ListMonad() 类的一个方面。
该包的大部分内容有助于更深入地了解函数式编程。用文档中的话来说,这是一个在可能稍微宽容一些的环境中学习函数式编程的简单方法。我们不必学习整个语言和工具集来编译和运行函数式程序,我们只需通过交互式 Python 进行实验。
实际上,我们不需要太多这些功能,因为 Python 已经是状态性的,并且提供了严格的表达式评估。在 Python 中引入状态性对象或严格顺序评估没有实际的理由。我们可以通过将函数概念与 Python 的命令式实现混合来编写有用的程序。因此,我们不会更深入地探讨 PyMonad。
13.7 概述
在本章中,我们探讨了如何使用 PyMonad 库直接在 Python 中表达一些函数式编程概念。该模块包含了许多重要的函数式编程技术。
我们探讨了柯里化的概念,这是一种允许将参数组合应用于创建新函数的函数。柯里化函数还允许我们使用函数组合来从更简单的部分创建更复杂的函数。我们探讨了将简单数据对象包装成函数的函子,这些函数也可以用于函数组合。
单子是在使用优化编译器和懒加载规则时强加严格评估顺序的一种方式。在 Python 中,我们没有很好的单子用例,因为 Python 底层是一种命令式编程语言。在某些情况下,命令式 Python 可能比单子构造更具有表现力和简洁性。
在下一章中,我们将探讨我们可用的多进程和多线程技术。这些包在函数式编程环境中尤其有用。当我们消除复杂的共享状态并围绕非严格处理进行设计时,我们可以利用并行性来提高性能。
13.8 练习
本章的练习基于 GitHub 上 Packt Publishing 提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包含一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。它们通常与 GitHub 仓库中提供的单元测试用例相同。读者应将书中的示例函数名替换为自己的解决方案以确认其正确性。
13.8.1 修订反正切级数
在使用 lazy ListMonad()单子中,我们展示了涉及从使用阶乘 n!和双阶乘(2n + 1)!!的级数中求和的π的计算。
示例使用了一个只有 20 个值的序列seq1 = ListMonad(*range(20))。这个 20 的选择是随机的,目的是保持中间结果足够小,以便可视化。
更好的选择是使用sum()和takewhile()函数的柯里化版本来找到序列中值的和,直到这些值太小,无法对结果产生影响。
将计算π的近似值重写为精度为 10^(-15)。这接近 64 位浮点数能表示的极限。
13.8.2 统计计算
给定一个值列表v,我们可以使用Just(v)创建一个有用的单子。我们可以使用内置函数如sum()和len()与Just.map()方法来计算均值、方差和标准差所需的值。



使用 PyMonad 实现这些函数后,将这些定义与更传统的 Python 语言技术进行比较。单子结构对这些相对简单的计算有帮助吗?
13.8.3 数据验证
PyMonad 库包含一个名为 Either 的单子类。这与单子类中的 Maybe 类相似。Maybe 单子可以有一个值,或者没有值,提供一个类似 None 的对象。Either 单子有两个子类,Left 和 Right。如果我们使用 Right 实例来表示有效数据,那么我们可以使用 Left 实例来表示标识无效数据的错误信息。
上述概念表明可以使用 try:/except: 语句。如果没有抛出 Python 异常,结果将是 Right(v)。如果抛出异常,可以返回带有异常错误信息的 Left。
这允许 Compose 或 Pipe 处理数据,发出所有错误行作为 Left 单子。这可以导致一个有用的数据验证应用程序,因为它发现了数据中的所有问题。
首先,定义一个简单的验证规则,例如“值必须是 3 或 5 的倍数。”这意味着它们必须转换为整数值,并且整数除以 3 的余数为零或整数除以 5 的余数为零。其次,编写返回 Right 或 Left 对象的验证函数。
虽然 pymonad.io.IO 对象可以用来解析文件,但我们将从将验证函数应用于列表并检查结果开始。将验证函数应用于一系列值,保存结果序列的 Either 对象。
Either 对象有一个 .either() 方法,可以处理 Left 或 Right 实例。例如,e.either(lambda x: True, lambda x: False) 如果 e 单子的值是 Left 实例,将返回 True。
13.8.4 多个模型
给定的过程有几个备选模型,它们从观察样本值计算预期值。
每个模型都从样本中的观察值计算一个预期值 e:
-
e = 0.7412 × s[o]
-
e = 0.9 × s[o] − 90
-
e = 0.7724 × s[o]^(1.0134)
首先,我们需要将这些模型作为柯里化函数实现。这将使我们能够使用这些模型中的任何一个来计算预测值。
给定一个模型函数,我们接下来需要创建一个比较函数。我们可以使用通用的 PyMonad Composition 或 Pipe 来计算使用其中一个模型预测的值,并将预测值与观察值进行比较。
这个比较函数的结果可以用作 χ²(卡方)测试的一部分,以判断模型与观察结果拟合得如何。实际的卡方指标是第十六章《卡方案例研究》的主题。
现在,创建柯里化模型函数和 Composition 或 Pipe,以比较模型的预测与实际结果。
对于实际值和观察值,请参阅第十二章中的日志记录练习,装饰器设计技术。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

第十四章:14
多进程、多线程和 Concurrent.Futures 模块
当我们消除共享状态的复杂性,并围绕非严格处理的纯函数进行设计时,我们可以利用并发和并行来提高性能。在本章中,我们将探讨一些可用的多进程和多线程技术。当应用于从函数式视角设计的算法时,Python 库包变得特别有帮助。
这里的核心思想是将函数式程序分布在进程内的几个线程或 CPU 内的几个进程之间。如果我们创建了一个合理的函数式设计,我们可以避免应用程序组件之间的复杂交互;我们有接受参数值并产生结果的函数。这对于进程或线程来说是一个理想的结构。
在本章中,我们将关注几个主题:
-
函数式编程和并发的一般概念。
-
当我们考虑核心、CPU 和操作系统级别的并发与并行时,并发真正意味着什么。需要注意的是,并发并不能神奇地让一个糟糕的算法变快。
-
使用内置的
multiprocessing和concurrent.futures模块。这些模块允许多种并行执行技术。外部的dask包也能做很多这方面的工作。
我们将更多地关注进程级别的并行性,而不是多线程。使用进程并行性允许我们完全忽略 Python 的全局解释器锁(GIL)。
有关 Python 的全局解释器锁(GIL)的更多信息,请参阅docs.python.org/3/glossary.html#term-global-interpreter-lock。有关修改 GIL 操作方式的提案,请参阅peps.python.org/pep-0684/。此外,请参阅github.com/colesbury/nogil以了解一个提出完全移除 GIL 的项目。
GIL 在 Python 3.10 中是不可或缺的一部分,这意味着某些类型的计算密集型多线程不会显示出明显的速度提升。
我们将在本章中关注并发。并发工作交织在一起,与需要多个核心或多个处理器的并行工作不同。我们不想深入探讨并发和并行之间的细微差别。我们的重点是利用函数式方法,而不是探索在现代多进程操作系统上完成工作所有可能的方式。
14.1 函数式编程和并发
当执行的任务之间没有依赖关系时,最有效的并发处理才会发生。在开发并发(或并行)编程时,最大的困难来自于协调对共享资源的更新,其中任务依赖于一个公共资源。
当遵循函数式设计模式时,我们倾向于避免有状态的程序。函数式设计应该最小化或消除对共享对象的并发更新。如果我们能够设计出以懒加载、非严格评估为中心的软件,我们也可以设计出有助于并发评估的软件。在某些情况下,应用程序的某些部分可以具有令人尴尬的并行设计,其中大部分工作可以并发完成,计算之间几乎没有交互。映射和过滤尤其受益于并行处理;归约通常不能并行执行。
我们将要关注的框架都使用一个基本的 map() 函数来将工作分配给池中的多个工作者。这与我们在整本书中一直在探讨的高级函数式设计非常契合。如果我们已经针对 map() 函数构建了我们的应用程序,那么将工作分区成进程或线程不应该涉及破坏性的变更。
14.2 并发真正意味着什么
在一台小型计算机中,只有一个处理器和一个核心,所有的评估都通过处理器唯一的那个核心进行序列化。操作系统将通过巧妙的时间切片安排来交错多个进程和多个线程,使其看起来像是在并发发生。
在具有多个 CPU 或单个 CPU 中有多个核心的计算机上,可以有一些实际的并行处理 CPU 指令。所有其他并发性都是通过操作系统级别的时间切片来模拟的。一台 macOS X 笔记本电脑可以有 200 个并发进程共享 CPU;这比可用的核心数量多得多。从这一点来看,我们可以看出操作系统的时间切片负责了整个系统大部分看似并发的行为。
14.2.1 边界条件
让我们考虑一个假设的算法,其复杂度由 O(n²) 描述。这通常意味着两个嵌套的 for 语句,每个语句都在处理 n 个项目。让我们假设内部 for 语句的主体涉及 1,000 个 Python 操作码。当处理 10,000 个对象时,这可能执行 1000 亿次 Python 操作。我们可以称之为基本处理预算。我们可以尝试分配尽可能多的进程和线程,但我们无法改变处理预算。
单个 CPython 字节码——Python 语句和表达式的内部实现——并不都共享相同的、统一的执行时间。然而,在 macOS X 笔记本电脑上的长期平均数据显示,我们每秒可以期望执行大约 60 MB 的字节码操作。这意味着我们的 1000 亿次字节码操作可能需要大约 1,666 秒,或 28 分钟。
如果我们有一台双核四核的计算机,那么我们可能将所需的时间缩短到原始总时间的 25%:大约 7 分钟。这假设我们可以将工作分成四个(或更多)独立的操作系统进程。
这里的重要考虑是,100 亿字节码的整体预算是无法改变的。并发不会神奇地减少工作量。它只能改变调度,以可能减少执行所有这些字节码的经过时间。
转换为具有 O(nlog n)复杂度的更好算法可以显著减少工作量。我们需要测量实际的加速来确定影响;以下示例包含了一些假设。我们可能不需要进行 10,000²次迭代,而只需进行 10,000log 10,000 ≈ 132,877 次迭代,从 100 亿操作减少到大约 133,000 次操作。这可能是原始时间的
小。并发无法提供算法更改将带来的那种戏剧性改进。
14.2.2 与进程或线程共享资源
操作系统向我们保证,进程之间几乎没有或没有交互。当创建需要多个进程交互的应用程序时,必须显式共享一个常见的操作系统资源。这可能是一个公共文件、共享内存对象或具有进程之间共享状态的信号量。进程本质上是独立的;它们之间的交互是例外,而不是规则。
相比之下,多个线程属于单个进程的一部分;一个进程的所有线程通常共享资源,有一个特殊情况。线程局部内存可以自由使用,而不会受到其他线程的干扰。在线程局部内存之外,写入内存的操作可能会以不可预测的顺序设置进程的内部状态。一个线程可以覆盖另一个线程的结果。为了防止问题,必须使用互斥访问技术——通常是一种锁定形式。如前所述,来自并发线程和进程的指令的整体顺序通常以不可预测的顺序在核心之间交错。这种并发带来了对共享变量的破坏性更新的可能性,以及需要互斥访问。
当尝试设计多线程应用程序时,并发对象更新的存在可能会造成混乱。锁定是一种避免对共享对象进行并发写入的方法。避免使用共享对象通常也是一种可行的设计技术。第二种技术——避免写入共享对象——通常也适用于函数式编程。
在 CPython 中,全局解释器锁(GIL)用于确保操作系统线程调度不会干扰维护 Python 数据结构的内部机制。实际上,GIL 改变了调度的粒度,从机器指令到 Python 虚拟机操作组。
从实用主义的角度来看,全局解释器锁(GIL)对各种应用类型性能的影响通常是可以忽略不计的。大部分情况下,计算密集型应用倾向于看到 GIL 调度的最大影响。I/O 密集型应用的影响很小,因为线程花更多的时间等待 I/O 完成。对性能影响更大的因素是正在实施算法的基本内在复杂性。
14.2.3 利益将如何积累
一个进行大量计算和相对较少 I/O 的程序在单核上的并发处理中不会看到太多好处。如果一个计算有 28 分钟的预算,那么以不同方式交错操作不会产生显著影响。使用八个核心进行并行计算可能将时间缩短大约八分之一。实际的时间节省取决于操作系统和语言开销,这些开销很难预测。
当计算涉及大量的 I/O 时,在等待 I/O 请求完成的同时交错 CPU 处理可以显著提高性能。这个想法是在等待操作系统完成其他数据块的 I/O 时,对某些数据块进行计算。由于 I/O 通常涉及大量的等待,一个八核处理器可以交错处理来自数十(或数百)个并发 I/O 请求的工作。
并发是 Linux 的核心原则之一。如果我们不能在等待 I/O 的同时进行计算,那么我们的计算机在等待每个网络请求完成时会冻结。一个网站下载将涉及等待初始 HTML,然后等待每个单独的图形到达。在此期间,键盘、鼠标和显示器将无法工作。
这里有两种设计交错计算和 I/O 的应用程序的方法:
-
我们可以创建一个由处理阶段组成的流水线。单个项目必须通过所有阶段,在这些阶段中它被读取、过滤、计算、聚合,并写入。多个并发阶段的概念意味着每个阶段都会有不同的数据对象。阶段之间的时间切片将允许计算和 I/O 交错进行。
-
我们可以创建一个并发工作者池,每个工作者执行数据项的所有处理。数据项被分配给池中的工作者,结果从工作者那里收集。
这些方法之间的区别并不清晰。通常,会创建一个混合混合体,其中流水线的一个阶段涉及一个工作者池,以便使该阶段与其他阶段一样快。有一些形式化方法使设计并发程序变得相对容易。通信顺序进程(CSP)范式可以帮助设计消息传递应用程序。可以使用如pycsp之类的包将 CSP 形式化添加到 Python 中。
I/O 密集型程序通常能从并发处理中获得最显著的好处。其思路是将 I/O 和数据处理交织在一起。CPU 密集型程序从并发处理中获得的益处较小。
14.3 使用多进程池和任务
Python 的multiprocessing包引入了Pool对象的概念。Pool对象包含多个工作进程,并期望这些进程并发执行。这个包允许操作系统调度和时分复用来交织多个进程的执行。目的是让整个系统尽可能忙碌。
为了充分利用这一功能,我们需要将我们的应用程序分解成对非严格并发执行有益的组件。整个应用程序必须由可以按不确定顺序处理的离散任务构建而成。
例如,一个通过网络爬虫从互联网收集数据的应用程序,通常通过并发处理进行优化。多个单独的进程可以等待数据下载,而其他进程则在已接收的数据上执行爬取操作。我们可以创建一个包含几个相同工作者的Pool对象,这些工作者执行网站爬取。每个工作者被分配以 URL 形式的分析任务。等待下载的多个工作者几乎没有处理开销。另一方面,已经完全下载页面的工作者可以执行从内容中提取数据的实际工作。
分析多个日志文件的应用程序也是并发处理的良好候选者。我们可以创建一个分析工作者Pool对象。我们可以将每个日志文件分配给一个工作者;这允许在Pool对象中的各种工作者之间并发进行读取和分析。每个工作者将执行 I/O 和计算。然而,一些工作者可以在其他工作者等待 I/O 完成时进行分析。
由于益处取决于难以预测的输入和输出操作的时机,多进程总是涉及实验。更改池大小并测量经过时间是实现并发应用程序的基本部分。
14.3.1 处理大量大文件
这里是一个多进程应用程序的示例。我们将解析网络日志文件中的通用日志格式(CLF)行。这是通常用于 Web 服务器访问日志的格式。这些行通常很长,但当你将其折叠到书的边缘时,看起来如下所示:
99.49.32.197 - - [01/Jun/2012:22:17:54 -0400] "GET /favicon.ico\\
HTTP/1.1" 200 894 "-" "Mozilla/5.0 (Windows NT 6.0)\\
AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.52\\
Safari/536.5"
我们经常有大量的文件需要分析。许多独立文件的存在意味着并发处理对我们的爬取过程将有一些好处。一些工作者将等待数据,而其他工作者可以执行计算密集型的工作部分。
我们将分析分解为两个广泛的功能区域。处理的第一阶段是解析日志文件以收集相关信息的基本解析。我们将进一步将解析阶段分解为四个阶段。具体如下:
-
从多个源日志文件中读取所有行。
-
然后,我们从一个文件集合中的日志条目行创建简单的
NamedTuple对象。 -
日期和 URL 等更复杂字段的详细信息将单独解析。
-
从日志中拒绝无趣的路径,留下有趣的路径以供进一步处理。
一旦过了解析阶段,我们可以执行大量分析。为了演示multiprocessing模块的目的,我们将查看一个简单的分析来计数特定路径的出现次数。
第一部分是从源文件中读取。Python 对文件迭代器的使用将转换为底层 OS 请求以缓冲数据。每个 OS 请求意味着进程必须等待数据可用。
显然,我们希望交织其他操作,以便它们不是都在等待 I/O 完成。这些操作可以想象成从处理单个行到处理整个文件的一个光谱。我们将首先查看整个文件的交织,因为这相对容易实现。
解析 Apache CLF 文件的功能设计可能如下所示:
data = path_filter(
access_detail_iter(
access_iter(
local_gzip(filename))))
这个函数将更大的解析问题分解为多个函数。local_gzip()函数从本地缓存的 GZIP 文件中读取行。access_iter()函数为访问日志中的每一行创建一个NamedTuple对象。access_detail_iter()函数扩展了一些更难解析的字段。最后,path_filter()函数丢弃了一些没有太多分析价值的路径和文件扩展名。
将这种设计可视化为一个类似壳体的处理管道可能会有所帮助,如下所示:
(local_gzip(filename) | access_iter
| access_detail_iter | path_filter) >data
这借鉴了管道(—)的 shell 符号来从进程到进程传递数据。Python 没有这个操作符,直接使用。
实用上,我们可以使用toolz模块来定义这个管道:
from toolz.functoolz import pipe
data = pipe(filename,
local_gzip,
access_iter,
access_detail_iter,
path_filter
)
更多关于toolz模块的信息,请参阅第十一章,The Toolz Package。
我们将专注于设计这四个按阶段处理数据的函数。想法是将密集处理与等待 I/O 完成交织在一起。
14.3.2 解析日志文件 – 收集行
解析大量文件的第一阶段如下:读取每个文件并生成一个简单的行序列。由于日志文件以.gzip格式保存,我们需要使用gzip.open()函数打开每个文件。
以下local_gzip()函数从本地缓存的文件中读取行:
from collections.abc import Iterator
import gzip
from pathlib import Path
import sys
def local_gzip(zip_path: Path) -> Iterator[str]:
with gzip.open(zip_path, "rb") as log_file:
yield from (
line.decode(’us-ascii’).rstrip()
for line in log_file
)
函数遍历文件的所有行。我们创建了一个复合函数,用于封装以.gzip格式压缩的日志文件打开的细节,将文件分解为一系列行,并删除换行符(\n)。
此外,此函数还封装了文件的非标准编码。与以 UTF-8 或 UTF-16 等标准格式编码的 Unicode 不同,文件使用旧的 US-ASCII 编码。这与 UTF-8 非常相似。为了确保日志条目被正确读取,提供了确切的编码。
此函数与multiprocessing模块的工作方式非常吻合。我们可以创建一个工作池并将任务(如.gzip文件读取)映射到进程池。如果我们这样做,我们可以并行读取这些文件;打开的文件对象将是单独进程的一部分,资源消耗和等待时间将由操作系统管理。
此设计的扩展可以包括第二个功能,用于使用 SFTP 或 RESTful API(如果可用)从网络主机传输文件。当文件从网络服务器收集时,可以使用local_gzip()函数进行分析。
local_gzip()函数的结果被access_iter()函数用于为源文件中的每一行创建描述网络服务器文件访问的命名元组。
14.3.3 将日志行解析为命名元组
一旦我们获得了每个日志文件的所有行,我们就可以提取描述的访问细节。我们将使用正则表达式分解行。从那里,我们可以构建一个NamedTuple对象。
每个单独的访问可以总结为NamedTuple的一个子类,如下所示:
from typing import NamedTuple, Optional, cast
import re
class Access(NamedTuple):
host: str
identity: str
user: str
time: str
request: str
status: str
bytes: str
referer: str
user_agent: str
@classmethod
def create(cls: type, line: str) -> Optional["Access"]:
format_pat = re.compile(
r"(?P<host>[\d\.]+)\s+"
r"(?P<identity>\S+)\s+"
r"(?P<user>\S+)\s+"
r"\[(?P<time>.+?)\]\s+"
r’"(?P<request>.+?)"\s+’
r"(?P<status>\d+)\s+"
r"(?P<bytes>\S+)\s+"
r’"(?P<referer>.*?)"\s+’
r’"(?P<user_agent>.+?)"\s*’
)
if match := format_pat.match(line):
return cast(Access, cls(**match.groupdict()))
return None
从源文本构建Access对象的方法create()包含一个长的正则表达式,用于解析 CLF 文件中的行。这相当复杂,但我们可以使用铁路图来帮助简化它。以下图像显示了各种元素以及它们如何被正则表达式识别:
![hd.hsinisunus[tat]srarssdssbnbsrarsuausoiopdodpsospimnimpeneptaitapyoypenepsnspsgsaeneaeneaeyeaquyquatgtatntafeyfeaeyeatitcn-ncr-rcceecuiuce-ecrrcrrctetistieseessestsessseeeeaaetptpttprrggyayaaeecccnneeett](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/fn-py-prog-3e/img/file129.jpg)
图 14.1:解析日志文件的正则表达式图
此图显示了正则表达式中子句的顺序。每个矩形框代表一个命名的捕获组。例如,(?P<host>[\d\.]+)是一个名为host的组。椭圆形和圆形是字符类(例如,数字)或特定字符(例如,.)的类别,它们构成了捕获组的内容。
我们使用这个正则表达式将每一行分解为包含九个单独数据元素的字典。使用[]和"来界定复杂字段(如time、request、referer和user_agent参数)可以通过将文本转换为NamedTuple对象来优雅地处理。
我们努力确保NamedTuple字段名称与每个记录部分的(?P<name>)构造中的正则表达式组名称相匹配。通过确保名称匹配,我们可以非常容易地将解析后的字典转换为一个元组以进行进一步处理。这意味着我们为了与 RFC 文档兼容,错误地拼写了“referrer”。
这里是access_iter()函数,它要求每个文件都表示为文件行的迭代器:
from collections.abc import Iterator
def access_iter(source_iter: Iterator[str]) -> Iterator[Access]:
for line in source_iter:
if access := Access.create(line):
yield access
local_gzip()函数的输出是一系列字符串。外层序列基于单个日志文件的行。如果某行与给定的模式匹配,则表示某种类型的文件访问。我们可以从正则表达式解析的文本字典中创建一个Access实例。不匹配的行会被静默地丢弃。
这里关键的设计模式是从解析函数的结果构建一个不可变对象。在这种情况下,解析函数是一个正则表达式匹配器。其他类型的解析也可以适应这种设计模式。
有一些替代方法可以做到这一点。例如,这里有一个应用map()和filter()的函数:
def access_iter_2(source_iter: Iterator[str]) -> Iterator[Access]:
return filter(
None,
map(
Access.create,
source_iter
)
)
access_iter_2()函数将local_gzip()函数的输出转换为一个Access实例的序列。在这种情况下,我们将Access.create()函数应用于从读取文件集合得到的字符串迭代器。filter()函数从map()函数的结果中移除任何None对象。
我们在这里的目的是展示我们有一系列用于解析文件的函数式风格。在第四章,处理集合中,我们查看了一种非常简单的解析方法。在这里,我们执行更复杂的解析,使用各种技术。
14.3.4 解析访问对象的附加字段
之前创建的初始Access对象没有分解访问日志行中包含的九个字段中的某些内部元素。我们将从整体分解中单独解析这些项。单独执行这些解析操作使每个处理阶段更简单。这也允许我们在不破坏分析日志的一般方法的情况下替换整体过程的一个小部分。
下一个解析阶段的输出对象将是一个NamedTuple子类,AccessDetails,它包装了原始的Access元组。它将包含一些用于解析的附加字段:
from typing import NamedTuple, Optional
import datetime
import urllib.parse
class AccessDetails(NamedTuple):
access: Access
time: datetime.datetime
method: str
url: urllib.parse.ParseResult
protocol: str
referrer: urllib.parse.ParseResult
agent: dict[str, str]
@classmethod
def create(cls: type, access: Access) -> "AccessDetails":
meth, url, protocol = parse_request(access.request)
return AccessDetails(
access=access,
time=parse_time(access.time),
method=meth,
url=urllib.parse.urlparse(url),
protocol=protocol,
referrer=urllib.parse.urlparse(access.referer),
agent=parse_agent(access.user_agent)
)
access属性是原始的Access对象,一组简单的字符串集合。time属性是解析的access.time字符串。method、url和protocol属性来自分解access.request字段。referrer属性是一个解析后的 URL。
agent属性也可以分解成更细粒度的字段。规则相当复杂,我们决定使用一个将名称映射到其相关值的字典就足够了。
这里是分解字段的三种详细级别解析器:
from typing import Optional
import datetime
import re
def parse_request(request: str) -> tuple[str, str, str]:
words = request.split()
return words[0], ’ ’.join(words[1:-1]), words[-1]
def parse_time(ts: str) -> datetime.datetime:
return datetime.datetime.strptime(
ts, "%d/%b/%Y:%H:%M:%S %z"
)
def parse_agent(user_agent: str) -> dict[str, str]:
agent_pat = re.compile(
r"(?P<product>\S*?)\s+"
r"\((?P<system>.*?)\)\s*"
r"(?P<platform_details_extensions>.*)"
)
if agent_match := agent_pat.match(user_agent):
return agent_match.groupdict()
return {}
我们为 HTTP 请求、时间戳和用户代理信息编写了三个解析器。日志中的请求值通常是一个三个单词的字符串,例如GET /some/path HTTP/1.1。parse_request()函数提取这三个空格分隔的值。在不太可能的情况下,如果路径中有空格,我们将提取第一个单词和最后一个单词作为方法和协议;所有剩余的单词都是路径的一部分。
时间解析委托给了datetime模块。我们在parse_time()函数中提供了适当的格式。
解析用户代理具有挑战性。有许多变化;我们为parse_agent()函数选择了一个常见的选项。如果用户代理文本与给定的正则表达式匹配,我们将使用AgentDetails类的属性。如果用户代理信息不匹配正则表达式,我们将使用None值代替。在两种情况下,原始文本都将可在Access对象中找到。
我们将使用这三个解析器从给定的Access对象构建AccessDetails实例。access_detail_iter()函数的主体看起来是这样的:
from collections.abc import Iterable, Iterator
def access_detail_iter(
access_iter: Iterable[Access]
) -> Iterator[AccessDetails]:
for access in access_iter:
yield AccessDetails.create(access)
我们使用了与之前的access_iter()函数类似的设计模式。从解析某些输入对象的结果中构建一个新的对象。新的AccessDetails对象将包装之前的Access对象。这种技术允许我们使用不可变对象,同时仍然包含更详细的信息。
这个函数本质上是一个从Access对象到AccessDetails对象序列的映射。这里是一个使用map()高级函数的替代设计:
from collections.abc import Iterable, Iterator
def access_detail_iter_2(
access_iter: Iterable[Access]
) -> Iterator[AccessDetails]:
return map(AccessDetails.create, access_iter)
随着我们继续前进,我们会看到这种变化很好地与multiprocessing模块的工作方式相匹配。
在面向对象的编程环境中,这些额外的解析器可能是类定义的方法函数或属性。具有惰性解析方法的面向对象设计的优点是,除非需要,否则不会解析项目。这个特定的函数式设计解析了所有内容,假设它将被使用。
有可能创建一个惰性函数式设计。它可以依赖于三个解析函数,根据需要从给定的Access对象中提取和解析各种元素。我们不会使用details.time属性,而是使用parse_time(access.time)函数。语法更长,但它确保属性仅在需要时才被解析。我们也可以将其作为一个保留原始语法的属性。我们将这个作为练习留给读者。
14.3.5 过滤访问详情
我们将查看 AccessDetails 对象的几个过滤器。第一个是一组拒绝许多很少有趣的冗余文件的过滤器。第二个过滤器将是分析函数的一部分,我们将在稍后查看。
path_filter() 函数是三个函数的组合:
-
排除空路径
-
排除一些特定的文件名
-
排除具有给定扩展名的文件
灵活的设计可以定义每个测试为一个单独的一等,过滤器风格的函数。例如,我们可能有一个如下所示的函数来处理空路径:
def non_empty_path(detail: AccessDetails) -> bool:
path = detail.url.path.split(’/’)
return any(path)
此函数确保路径包含一个名称。我们可以为 non_excluded_names() 和 non_excluded_ext() 函数编写类似的测试。像 favicon.ico 和 robots.txt 这样的名称需要被排除。同样,像 .js 和 .css 这样的扩展也需要被排除。我们将这两个额外的过滤器留给读者作为练习。
整个 filter() 函数序列将看起来像这样:
def path_filter(
access_details_iter: Iterable[AccessDetails]
) -> Iterable[AccessDetails]:
non_empty = filter(non_empty_path, access_details_iter)
nx_name = filter(non_excluded_names, non_empty)
nx_ext = filter(non_excluded_ext, nx_name)
yield from nx_ext
这种堆叠过滤器的风格具有在添加新的过滤器标准时稍微容易扩展的优点。
生成器函数(如 filter() 函数)的使用意味着我们不会创建大型中间对象。每个中间变量 non_empty、nx_name 和 nx_ext 都是一个合适的惰性生成器函数;只有在客户端进程消耗数据之前,不会进行任何处理。
虽然这种风格很优雅,但因为它需要每个函数都解析 AccessDetails 对象中的路径,所以它效率不高。为了提高效率,我们可以使用 @cache 装饰器包装 path.split(’/’) 函数。另一种选择是在 / 字符上拆分路径,并将列表保存在 AccessDetails 对象中。
14.3.6 分析访问详情
我们将查看两个分析函数,我们可以使用这些函数来过滤和分析单个 AccessDetails 对象。第一个函数将过滤数据,并仅传递特定的路径。第二个函数将总结每个不同路径的出现次数。
我们将定义一个小的 book_in_path() 函数,并将其与内置的 filter() 函数结合使用,以将函数应用于细节。以下是复合的 book_filter() 函数:
from collections.abc import Iterable, Iterator
def book_filter(
access_details_iter: Iterable[AccessDetails]
) -> Iterator[AccessDetails]:
def book_in_path(detail: AccessDetails) -> bool:
path = tuple(
item
for item in detail.url.path.split(’/’)
if item
)
return path[0] == ’book’ and len(path) > 1
return filter(book_in_path, access_details_iter)
我们通过 book_in_path() 函数定义了一条规则,我们将将其应用于每个 AccessDetails 对象。如果路径至少有两个组件,并且路径的第一个组件是 ’book’,那么我们对这些对象感兴趣。所有其他 AccessDetails 对象都可以被默默地拒绝。
我们感兴趣的最终归约函数是 reduce_book_total()。
from collections import Counter
def reduce_book_total(
access_details_iter: Iterable[AccessDetails]
) -> dict[str, int]:
counts: Counter[str] = Counter(
detail.url.path for detail in access_details_iter
)
return counts
此函数将生成一个 Counter() 对象,显示 AccessDetails 对象中每个路径的频率。为了专注于特定的路径集,我们将使用 reduce_total(book_filter(details)) 表达式。这仅提供了通过给定过滤器的项目摘要。
由于 Counter 对象可以应用于各种类型,需要一个类型提示来提供狭窄的规范。在这种情况下,提示是 dict[str, int],以向 mypy 工具显示路径的字符串表示将被计数。
14.3.7 完整分析过程
这里是处理日志文件集合的复合 analysis() 函数:
def analysis(log_path: Path) -> dict[str, int]:
"""Count book chapters in a given log"""
details = access_detail_iter(
access_iter(
local_gzip(log_path)))
books = book_filter(path_filter(details))
totals = reduce_book_total(books)
return totals
analysis() 函数使用 local_gzip() 函数处理单个路径。它应用一系列解析函数 access_detail_iter() 和 access_iter(),以创建一个 AccessDetails 对象的可迭代序列。然后,它应用一系列过滤器来排除不感兴趣的路径。最后,它将一个 AccessDetails 对象序列应用于归约。结果是显示某些路径访问频率的 Counter 对象。
保存的 .gzip 格式日志文件样本总量约为 51 MB。使用此函数按顺序处理文件需要超过 140 秒。我们能否通过并发处理做得更好?
14.4 使用多进程池进行并发处理
一种优雅地使用 multiprocessing 模块的方法是创建一个处理 Pool 对象,并将工作分配给该池中的各个工作者。我们将依赖操作系统在各个进程之间交错执行。如果每个进程都有 I/O 和计算的混合,我们应该能够确保我们的处理器(和磁盘)保持非常忙碌。当进程等待 I/O 完成时,其他进程可以执行它们的计算。当一个 I/O 操作完成时,等待该操作的进程将准备好运行,并可以与其他进程竞争处理时间。
将工作映射到单独进程的配方看起来像这样:
def demo_mp(root: Path = SAMPLE_DATA, pool_size: int | None = None) -> None:
pool_size = (
multiprocessing.cpu_count() if pool_size is None
else pool_size
)
combined: Counter[str] = Counter()
with multiprocessing.Pool(pool_size) as workers:
file_iter = list(root.glob(LOG_PATTERN))
results_iter = workers.imap_unordered(analysis, file_iter)
for result in results_iter:
combined.update(result)
print(combined)
此函数创建一个具有单独工作者进程的 Pool 对象,并将此 Pool 对象分配给 workers 变量。然后,我们使用进程池将分析函数 analysis 映射到要执行的工作的可迭代队列。workers 池中的每个进程都会从可迭代队列中分配项目。在这种情况下,队列是 root.glob(LOG_PATTERN) 属性的结果,它是一系列文件名。
随着每个工作者完成 analysis() 函数并返回结果,创建 Pool 对象的父进程可以收集这些结果。这允许我们创建多个并发构建的 Counter 对象,并将它们合并成一个单一的综合结果。
如果我们在池中启动 p 个进程,我们的整体应用程序将包括 p + 1 个进程。将有一个父进程和 p 个子进程。这通常效果很好,因为父进程在启动子进程池后几乎没有什么事情可做。通常,工作者将被分配到单独的 CPU(或核心),而父进程将与 Pool 对象中的一个子进程共享一个 CPU。
普通的 Linux 父/子进程规则适用于此模块创建的子进程。如果父进程崩溃而没有从子进程中正确收集最终状态,则可能会留下僵尸进程运行。因此,进程Pool对象也是一个上下文管理器。当我们通过with语句使用池时,在上下文结束时,子进程将被正确收集。
默认情况下,一个Pool对象将根据multiprocessing.cpu_count()函数的值拥有一定数量的工作者。这个数字通常是最佳的,仅使用with multiprocessing.Pool() as workers:属性可能就足够了。
在某些情况下,拥有比 CPU 更多的工作者可能会有所帮助。这可能是在每个工作者都有 I/O 密集型处理时的情况。拥有许多工作者进程等待 I/O 完成可以提高应用程序的整体运行时间。
如果给定的Pool对象有 p 个工作者,这种映射可以将处理时间减少到几乎
处理所有日志所需的时间。从实际的角度来看,Pool对象中父进程和子进程之间的通信会有一些开销。这些开销将限制将工作细分为非常小的并发部分的有效性。
多进程Pool对象有几种类似于map()的方法来分配工作到池中。我们将查看map()、imap()、imap_unordered()和starmap()。这些方法都是将函数分配给进程池并将数据项映射到该函数的常见主题的变体。此外,还有两个异步变体:map_async()和starmap_async()。这些函数在分配工作和收集结果方面的细节上有所不同:
-
map(function, iterable)方法将可迭代对象中的项目分配给池中的每个工作者。完成的结果将按照它们分配给Pool对象的顺序收集,以保持顺序。 -
imap(function, iterable)方法比map()更懒惰。默认情况下,它将可迭代对象中的每个单独的项目发送到下一个可用的工作者。这可能涉及更多的通信开销。因此,建议使用大于 1 的块大小。 -
imap_unordered(function, iterable)方法与imap()方法类似,但结果顺序不被保留。允许映射按顺序处理,意味着每个进程完成时,结果将被收集。 -
starmap(function, iterable)方法与itertools.starmap()函数类似。可迭代对象中的每个项目必须是一个元组;元组通过*修饰符传递给函数,以便元组的每个值都成为位置参数值。实际上,它执行function(*iterable[0])、function(*iterable[1])等等。
两个_async变体不仅返回一个结果,还返回一个AsyncResult对象。这个对象有一些状态信息。例如,我们可以查看工作是否已经完成,或者是否在没有异常的情况下完成。AsyncResult对象最重要的方法是.get()方法,它查询工作者以获取结果。
当处理时间高度可变时,这种额外的复杂性可以很好地工作。我们可以随着结果变得可用而收集工作者的结果。对于非_async变体,行为是按照工作开始时的顺序收集结果,保留原始源数据在类似 map 操作中的顺序。
这里是前面映射主题的map_async()变体:
def demo_mp_async(root: Path = SAMPLE_DATA, pool_size: int | None = None) -> None:
pool_size = (
multiprocessing.cpu_count() if pool_size is None
else pool_size
)
combined: Counter[str] = Counter()
with multiprocessing.Pool(pool_size) as workers:
file_iter = root.glob(LOG_PATTERN)
results = workers.map_async(analysis, file_iter)
for result in results.get():
combined.update(result)
print(combined)
我们创建了一个Counter()函数,我们将用它来合并池中每个工作者的结果。我们根据可用的 CPU 数量创建了一个子进程池,并使用Pool对象作为上下文管理器。然后,我们将我们的analysis()函数映射到我们的文件匹配模式中的每个文件。analysis()函数产生的Counter对象被合并成一个单一的计数器。
这个版本分析一批日志文件大约需要 68 秒。通过使用多个并发进程,分析日志的时间被大幅缩短。单进程基线时间是 150 秒。其他实验需要运行在更大的池大小上,以确定需要多少个工作者来使系统尽可能忙碌。
我们使用multiprocessing模块的Pool.map_async()函数创建了一个两层的 map-reduce 过程。第一层是analysis()函数,它对一个单个日志文件执行 map-reduce 操作。然后我们在更高层次的 reduce 操作中合并这些减少。
14.4.1 使用 apply()进行单个请求
除了类似 map 的变体之外,池还有一个apply(function, *args, **kw)方法,我们可以用它将一个值传递给工作池。我们可以看到,各种map()方法实际上是一个for语句包裹在apply()方法周围。例如,我们可以使用以下命令处理多个文件:
list(
workers.apply(analysis, f)
for f in SAMPLE_DATA.glob(LOG_PATTERN)
)
对于我们的目的来说,这并不明显是一个重大的改进。我们几乎所有需要做的事情都可以用map()函数来表示。
14.4.2 更复杂的多进程架构
multiprocessing包支持多种架构。我们可以创建跨越多个服务器的多进程结构,并提供正式的认证技术来创建必要的安全级别。我们可以使用队列和管道在进程之间传递对象。我们可以在进程之间共享内存。我们还可以在进程之间共享低级锁,作为同步访问共享资源(如文件)的一种方式。
大多数这些架构都涉及在几个工作进程之间显式管理状态。特别是使用锁和共享内存,在本质上强制执行,并且与函数式编程方法不太兼容。
我们可以小心地以函数式方式处理队列和管道。我们的目标是分解设计为生产者和消费者函数。生产者可以创建对象并将它们插入队列。消费者将从队列中取出对象并处理它们,可能将中间结果放入另一个队列。这创建了一个并发处理器的网络,工作负载在这些不同的进程之间分配。
在设计复杂的应用服务器时,这种设计技术有一些优点。各种子进程可以存在于服务器的整个生命周期中,并发处理单个请求。
14.4.3 使用concurrent.futures模块
除了multiprocessing包,我们还可以利用concurrent.futures模块。这个模块也提供了一种将数据映射到线程或进程并发池的方法。模块 API 相对简单,在很多方面与multiprocessing.Pool()函数的接口相似。
下面是一个示例,展示了它们之间的相似性:
def demo_cf_threads(root: Path = SAMPLE_DATA, pool_size: int = 4) -> None:
pattern = "*itmaybeahack.com*.gz"
combined: Counter[str] = Counter()
with futures.ProcessPoolExecutor(max_workers=pool_size)
as workers:
file_iter = root.glob(LOG_PATTERN)
for result in workers.map(analysis, file_iter):
combined.update(result)
print(combined)
与前面的示例相比,最显著的变化是我们使用concurrent.futures.ProcessPoolExecutor对象实例而不是multiprocessing.Pool对象。基本设计模式是将analysis()函数映射到可用工作进程的列表。结果Counter对象被合并以创建最终结果。
concurrent.futures模块的性能几乎与multiprocessing模块相同。
14.4.4 使用concurrent.futures线程池
concurrent.futures模块为我们提供了第二种类型的执行器,我们可以在应用程序中使用它。我们不需要创建concurrent.futures.ProcessPoolExecutor对象,而是可以使用ThreadPoolExecutor对象。这将在一个进程内创建一个线程池。
线程池的语法几乎与使用ProcessPoolExecutor对象相同。然而,性能可能会有显著差异。在多线程环境中,CPU 密集型处理通常不会显示出改进,因为在等待 I/O 完成时没有计算。I/O 密集型处理可以从多线程中受益。
使用样本日志文件和一台运行 macOS X 的小型四核笔记本电脑,以下是表明共享 I/O 资源的线程和进程之间差异的结果:
-
使用
concurrent.futures线程池,耗时为 168 秒 -
使用进程池,耗时为 68 秒
在这两种情况下,Pool对象的大小都是4。单进程和单线程基线时间是 150 秒;添加线程使处理速度变慢。这种结果对于执行大量计算且相对较少等待输入和输出的程序来说是典型的。multithreading模块通常更适合以下类型的应用程序:
-
用户界面在长时间空闲等待用户移动鼠标或触摸屏幕时
-
在等待从大型、快速服务器通过网络传输数据到(相对较慢的)客户端时线程空闲的 Web 服务器
-
从多个 Web 服务器提取数据的 Web 客户端,特别是当这些客户端必须等待数据在网络中渗透时
做基准测试和性能测量很重要。
14.4.5 使用线程和队列模块
Python 的threading包包含许多有助于构建命令式应用程序的构造。此模块不专注于编写函数式应用程序。我们可以使用queue模块中的线程安全队列在线程之间传递对象。
队列允许安全的数据共享。由于队列处理涉及使用 OS 服务,这也可能意味着使用队列的应用程序可能会观察到来自 GIL 的干扰较少。
threading模块没有简单的方法将工作分配给各个线程。API 并不适合函数式编程。
与multiprocessing模块的更原始功能一样,我们可以尝试隐藏锁和队列的状态性和命令性本质。然而,似乎更易于使用concurrent.futures模块中的ThreadPoolExecutor方法。ThreadPoolExecutor.map()方法为我们提供了一个非常愉悦的接口,可以并发处理集合中的元素。
使用map()函数原语分配工作似乎与我们的函数式编程期望很好地匹配。因此,最好专注于concurrent.futures模块,作为编写并发函数式程序最易于访问的方式。
14.4.6 使用异步函数
asyncio模块帮助我们使用async函数来——也许——更好地交织处理和计算。重要的是要理解async处理利用了threading模型。这意味着它可以有效地交织等待 I/O 与计算。它不能有效地交织纯计算。
为了使用asyncio模块,我们需要做以下四件事:
-
在我们的各种解析和过滤函数中添加
async关键字,使它们成为协程。 -
在将结果传递给另一个协程之前,添加
await关键字以收集一个协程的结果。 -
创建一个整体的事件循环,以协调协程之间的
async/await处理。 -
创建一个线程池来处理文件读取。
列出的前三个步骤不涉及深层次的复杂性。asyncio模块帮助我们创建解析每个文件的任务,然后运行任务集合。事件循环确保协程将在await语句处暂停以收集结果。它还确保具有可用数据的协程有资格进行处理。协程的交织发生在单个线程中。如前所述,通过改变执行顺序,字节码操作的数量并不会神奇地变小。
这里的难点在于处理不属于asyncio模块的输入和输出操作。具体来说,读取和写入本地文件不是asyncio的一部分。每次我们尝试读取(或写入)文件时,操作系统请求可能会阻塞,等待操作完成。除非这个阻塞请求在单独的线程中,否则它会停止事件循环,并停止 Python 的所有巧妙交织的协程处理。有关使用线程池的更多信息,请参阅docs.python.org/3/library/asyncio-eventloop.html#id14。
要与本地文件一起工作,我们需要使用一个concurrent.futures.ThreadPoolExecutor对象来管理文件输入和输出操作。这将把工作分配给主事件循环之外的线程。因此,基于async/await的本地文件处理设计不会比直接使用concurrent.futures有显著的优势。
对于网络服务器和复杂的客户端,asyncio模块可以使应用程序对用户的输入非常响应。当大多数协程都在等待数据时,线程内协程的细粒度切换效果最佳。
14.4.7 设计并发处理
从函数式编程的角度来看,我们已经看到了将map()函数概念应用于数据项并发使用的三种方式。我们可以使用以下任何一个:
-
multiprocessing.Pool -
concurrent.futures.ProcessPoolExecutor -
concurrent.futures.ThreadPoolExecutor
这些在交互方式上几乎相同;这三个进程池都支持将函数应用于可迭代集合项的map()方法的不同变体。这巧妙地与其他函数式编程技术相结合。每个池的性能可能不同,因为并发线程与并发进程的性质不同。
在我们逐步设计的过程中,我们的日志分析应用分解为两个主要区域:
-
较低级别的解析:这是一种通用的解析,几乎任何日志分析应用都会使用。
-
较高级别的分析应用:这是更具体的过滤和减少,专注于我们应用的需求。
较低级别的解析可以分解为四个阶段:
-
从多个源日志文件中读取所有行。这是从文件名到行序列的
local_gzip()映射。 -
从文件集合中日志条目的行创建命名元组。这是从文本行到
Access对象的access_iter()映射。 -
解析更复杂字段如日期和 URL 的细节。这是从
Access对象到AccessDetails对象的access_detail_iter()映射。 -
从日志中拒绝无趣的路径。我们也可以将其视为仅传递有趣的路径。这更多的是一个过滤操作而不是映射操作。这是一个将过滤操作捆绑到
path_filter()函数中的过滤器集合。
我们定义了一个整体的analysis()函数,它解析并分析给定的日志文件。它将高级别的过滤和归约应用于低级别解析的结果。它还可以与一组通配符文件一起工作。
由于涉及到的映射数量,我们可以看到几种将这个问题分解为使用线程池或进程池的工作的方法。每个映射都是并发处理的机会。以下是我们可以考虑作为设计替代方案的一些映射:
-
将
analysis()函数映射到单个文件。我们在这个章节中一直使用这个作为一致的示例。 -
将
local_gzip()函数从整体analysis()函数中重构出来。这种重构允许将修订版的analysis()函数映射到local_gzip()函数的结果。 -
将
access_iter(local_gzip(pattern))函数从整体analysis()函数中重构出来。这个修订版的analysis()函数可以通过map()应用于Access对象的迭代序列。 -
将
access_detail_iter(access_iter(local_gzip(pattern)))函数重构为两个独立的迭代器。这允许使用map()应用一个函数来创建AccessDetail对象。对AccessDetail对象的迭代序列进行单独的、高级别的过滤和归约可以是一个单独的进程。 -
我们也可以将低级别的解析重构为一个函数,以保持其与高级别分析分离。我们可以将分析过滤和归约映射到低级别解析的输出。
所有这些都是相对简单的方法来重构示例应用程序。使用函数式编程技术的优势在于,整体过程的每个部分都可以定义为映射、过滤或归约。这使得考虑不同的架构以找到最佳设计变得实用。
然而,在这种情况下,我们需要将 I/O 处理分配到尽可能多的 CPU 或核心。大多数这些潜在的重构将在父进程中执行所有 I/O;这些只会将工作计算部分分布到多个并发进程,几乎没有带来什么好处。因此,我们希望专注于映射,因为这些映射可以将 I/O 分配到尽可能多的核心。
通常,最小化从进程到进程传递的数据量很重要。在这个例子中,我们只为每个工作进程提供了简短的文件名字符串。结果Counter对象比每个日志文件中 10MB 的压缩详细数据小得多。
运行基准测试实验以确认计算、输入和输出之间的实际时间也非常重要。这些信息对于揭示资源的最优分配,以及更好地平衡计算与等待 I/O 完成的设计至关重要。
下表包含了一些初步结果:
|
|
|
| 方法 | 持续时间 |
|---|---|
concurrent.futures/threadpool |
106.58s |
concurrent.futures/processpool |
40.81s |
multiprocessing/imap_unordered |
27.26s |
multiprocessing/map_async |
27.45s |
|
|
|
我们可以看到线程池不允许任何有用的工作序列化。这并不意外,提供了一种最坏情况的基准。
concurrent.futures/processpool行显示了 4 个工作者的时间。这个变体使用了map()将请求分发给工作者。可能需要以特定顺序处理工作和收集结果,这可能导致相对较慢的处理。
使用的multiprocessing模块默认使用核心数,对于所使用的计算机来说是 8 个。时间几乎缩短到
基线时间。为了更好地利用可用的处理器,进一步分解处理以创建分析行批次的做法可能是有意义的,并为分析和文件解析设置单独的工人池。由于工作负载很难预测,灵活的函数式设计允许重新组织工作,寻找最大化 CPU 使用的方法。
14.5 总结
在本章中,我们探讨了两种支持多个数据并发处理的方法:
-
multiprocessing模块:特别是Pool类和提供给工作池的各种映射类型。 -
concurrent.futures模块:特别是ProcessPoolExecutor和ThreadPoolExecutor类。这些类还支持一种映射,它将在线程或进程的工人之间分配工作。
我们还注意到了一些似乎不太适合函数式编程的替代方案。multiprocessing模块有许多其他功能,但它们与函数式设计不太匹配。同样,threading和queue模块可以用来构建多线程应用程序,但这些功能与函数程序不太匹配。
在下一章中,我们将探讨如何将函数式编程技术应用于构建 Web 服务应用程序。HTTP 的概念可以总结为response = httpd(request)。当 HTTP 处理是无状态的,这似乎与函数式设计完美匹配。
向此添加状态性 cookie 相当于提供一个作为后续请求参数期望的响应值。我们可以将其视为response,cookie = httpd``(``request``, cookie``),其中 cookie 对象对客户端是透明的。
14.6 练习
本章的练习基于 GitHub 上 Packt Publishing 提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者会注意到 GitHub 上提供的代码包括一些练习的部分解决方案。这些作为提示,允许读者探索其他解决方案。
在许多情况下,练习需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 存储库中已经提供的单元测试用例相同。读者应将书籍中的示例函数名替换为自己的解决方案以确认其工作。
14.6.1 延迟解析
在解析访问对象额外字段部分,我们查看了一个将通用日志文件(CLF)行初步分解为易于分离字段的函数。
我们随后应用了三个独立的功能来解析时间戳、请求、时间和用户代理信息的细节。这三个功能被积极应用,分解了这三个字段,即使它们从未被用于进一步的分析。
实现这些字段的延迟解析有两种常用的方法:
-
而不是解析文本来创建
details.time属性,我们可以定义一个parse_time()方法来解析access.time值。语法更长,但它确保属性仅在需要时解析。 -
一旦我们有了这个函数,我们可以将其变成一个属性。
首先,重新定义一个新的Access_Details类,使用三个独立的方法来解析复杂字段。
一旦这工作,将这些方法变成属性,以便提供像它们已经被积极解析的值。确保新的属性方法名称与之前显示的类中的原始属性名称匹配。
为了比较性能,我们需要知道这些额外的属性解析方法被使用的频率。有两个简单的假设是 100%的时间和 0%的时间。为了比较这两种设计,我们需要一些与Access_Details对象一起工作的统计摘要函数。
创建一个函数来获取所有属性的值,以计算多个直方图,例如。再创建另一个仅使用状态值来计算状态直方图的函数。比较两个Access_Details类变体和两种分析方法的性能,以查看哪个更快。预期是延迟解析会更快。问题是“快多少?”
14.6.2 过滤访问路径细节
在本章的 过滤访问详情 部分,我们展示了一个排除空路径以进行进一步分析的功能。
我们可以为 non_excluded_names() 和 non_excluded_ext() 函数编写类似的测试函数。像 ’favicon.ico’ 和 ’robots.txt’ 这样的名称需要排除。同样,像 ’.js’ 和 ’.css’ 这样的扩展也需要排除。
编写这两个函数以完成 path_filter() 函数的实现。这需要一些单元测试用例,就像利用三个独立的路径函数过滤器的整体 path_filter() 函数一样。
所有这些函数都使用分解的路径名。尝试为所有三个操作编写一个单一、复杂的函数是否合理?通过整体路径过滤函数组合三个独立的规则是否更有意义?
14.6.3 添加 @cache 装饰器
path_filter() 函数的实现应用了三个独立的过滤器。每个过滤器函数将解析 AccessDetails 对象中的路径。为了提高效率,可以将像 path.split(’/’) 这样的底层解析包装在 @cache 装饰器中。
编写(或重写)这三个过滤器函数以使用 @cache 装饰器。
一定要比较带有缓存和不带缓存的过滤器函数的性能。这可能具有挑战性,因为当我们使用简单的 @cache 装饰器时,原始的未缓存函数就不再可用。
如果我们使用类似 func_c`` =`` cache(func) 的方法,我们可以保留原始(未缓存)函数及其带有缓存的对应函数。有关如何工作的更多信息,请参阅 第十二章,装饰器设计技术。这样做可以让我们收集缓存和未缓存实现的计时数据。
14.6.4 创建样本数据
所示的设计使用从文件名到摘要计数的映射。每个文件由一组工作者并发处理。为了确定这是否最优,需要有一个高数据量来衡量性能。
对于一个使用频率较低的网站,日志文件每月平均约为 10 Mb。编写一个 Python 脚本以批量生成平均约 10 Mb 的合成日志行。使用简单的随机字符串并不是最佳方法,因为应用程序设计预期请求路径将具有可识别的模式。这需要一些细心来生成符合预期模式的合成数据。
创建合成数据的程序需要一些单元测试用例。整体分析程序是最终的验收测试用例:分析程序是否能够识别合成日志行中嵌入的数据模式?
14.6.5 修改管道结构
对于一个使用频率较低的网站,日志文件每月平均约为 10 Mb。在 MacBook Pro 上使用 Python 3.10,每个文件处理大约需要 16 秒。六个 10 Mb 文件的集合在最坏情况下的性能为 96 秒。在拥有超过六个核心的计算机上,最佳情况下的处理时间将是 16 秒。
本章中所示的设计将每个文件分配给一个单独的工作进程。
这是否是合适的粒度级别?没有探索替代方案,是无法知道的。这需要由前一个练习生成的样本数据文件。考虑实现替代设计并比较吞吐量。以下是一些建议的替代方案:
-
创建两个工作池:一个工作池读取文件并按 1,024 行的块返回行。第二个工作池包含
analysis()函数的大部分。这个第二个工作池有工作进程来解析块中的每一行以创建一个Access对象,创建一个AccessDetails对象,应用过滤器,并总结结果。这导致了从解析工作进程到分析工作进程的映射有两个层级。 -
将 10 Mb 的日志文件分解成更小的尺寸。编写一个应用程序来读取日志文件并写入新文件,每个文件限制为 4,096 个单独的日志条目。将分析应用程序应用于这个更大的小文件集合,而不是原始大日志文件的小集合。
-
将
analysis()函数分解为使用三个独立的工作池。一个工作池解析文件并返回Access对象的块。另一个工作池将Access对象转换为AccessDetails对象。第三个工作池的工作进程应用过滤器并总结AccessDetails对象。
总结使用不同的处理管道分析大量数据的结果。
第十五章:15
Web 服务的功能方法
我们将暂时离开探索性数据分析的主题,转向 Web 服务器和 Web 服务。Web 服务器在某种程度上是一系列函数的级联。我们可以将许多功能设计模式应用于呈现 Web 内容的问题。我们的目标是探讨我们可以如何接近表示状态转移(REST)。我们希望使用功能设计模式构建 RESTful Web 服务。
我们不需要再发明另一个 Python Web 框架。我们也不想从可用的框架中选择。Python 中有许多 Web 框架,每个框架都有其独特的一组特性和优势。
本章的目的是提出一些可以应用于大多数可用框架的原则。这将使我们能够利用功能设计模式来呈现 Web 内容。
当我们查看极大型或复杂的数据集时,我们可能需要一个支持子集或搜索的 Web 服务。我们也可能需要一个可以以各种格式下载子集的网站。在这种情况下,我们可能需要使用功能设计来创建支持这些更复杂要求的 RESTful Web 服务。
交互式 Web 应用程序通常依赖于有状态的会话来使网站更容易使用。用户的会话信息会通过 HTML 表单提供的数据、从数据库中检索的数据或从先前交互的缓存中恢复的数据进行更新。由于有状态的数据必须作为每个事务的一部分进行检索,它更像是一个输入参数或结果值。这可能导致即使在存在 cookies 和数据库更新的情况下,也会出现功能式编程。
在本章中,我们将探讨几个主题:
-
HTTP 请求和响应模型的一般概念。
-
Python 应用程序使用的 Web 服务器网关接口(WSGI)标准。
-
利用 WSGI,在可能将 Web 服务定义为函数的地方。这与 HTTP 无状态服务器的理念相符。
-
我们还将探讨授权客户端应用程序使用 Web 服务的方法。
15.1 HTTP 请求-响应模型
HTTP 协议几乎是无状态的:用户代理(或浏览器)发起请求,服务器提供响应。对于不涉及 cookies 的服务,客户端应用程序可以采用功能视图的协议。我们可以使用http.client或urllib.request模块构建客户端。HTTP 用户代理可以像以下函数一样实现:
import urllib.request
def urllib_get(url: str) -> tuple[int, str]:
with urllib.request.urlopen(url) as response:
body_bytes = response.read()
encoding = response.headers.get_content_charset("utf-8")
return response.status, body_bytes.decode(encoding)
类似于 wget 或 curl 这样的程序会使用作为命令行参数提供的 URL 进行此类处理。浏览器会在用户点击和指向时执行此操作;URL 通常来自用户的操作,通常是点击链接文本或图像。
注意,一个页面的编码通常在响应的两个不同位置进行描述。HTTP 头通常会命名正在使用的编码。在这个例子中,当头信息不完整时,会提供默认的 "utf-8" 编码。此外,HTML 内容也可以提供编码信息。具体来说,一个 <meta charset="utf-8"> 标签可以声明一个编码。理想情况下,它与头中注明的编码相同。或者,一个 <meta http-equiv...> 标签可以提供编码。
虽然 HTTP 处理是无状态的,但用户体验(UX)设计的实际考虑导致了一些需要保持状态的具体实现细节。为了使人类用户感到舒适,服务器必须知道他们做了什么,并保留事务状态。这是通过使客户端软件(浏览器或移动应用程序)跟踪 cookie 来实现的。为了使 cookie 起作用,响应头提供了 cookie 数据,后续请求必须将保存的 cookie 返回给服务器。
HTTP 响应将包括一个状态码。在某些情况下,这个状态码将需要用户代理采取额外的操作。300-399 范围内的许多状态码表示请求的资源已移动。然后应用程序或浏览器需要从 Location 头中保存详细信息并请求新的 URL。401 状态码表示需要认证;用户代理必须使用包含访问服务器凭证的 Authorization 头进行另一个请求。urllib 库实现处理这种有状态客户端处理。http.client 库类似,但它不会自动遵循 3xx 重定向状态码。
看到协议的另一边,一个静态内容服务器可以是无状态的。我们可以使用 http.server 库来做这件事,如下所示:
from http.server import HTTPServer, SimpleHTTPRequestHandler
from typing import NoReturn
def server_demo() -> NoReturn:
httpd = HTTPServer(
(’localhost’, 8080),
SimpleHTTPRequestHandler
)
print(f"Serving on http://localhost:8080...")
while True:
httpd.handle_request()
httpd.shutdown()
我们创建了一个 server 对象,并将其分配给 httpd 变量。我们提供了地址 localhost 和端口号 8080。作为接受请求的一部分,HTTP 协议将分配另一个端口;这用于创建处理程序类的实例。在一个端口上监听但在其他端口上执行工作允许服务器并发处理多个请求。
在这个例子中,我们提供了 SimpleHTTPRequestHandler 类作为每个请求的实例化类。这个类必须实现一个最小接口,该接口将发送头信息,然后将响应体的内容发送给客户端。这个特定的类将从本地目录中提供文件。如果我们想自定义它,我们可以创建一个子类,该子类实现了如 do_GET() 和 do_POST() 等方法来改变行为。
HTTPServer 类有一个 serve_forever() 方法,可以避免编写显式的 while 语句。我们在这里展示了 while 语句,以明确指出,如果需要停止服务器,通常必须使用中断信号来崩溃服务器。
本例使用端口号 8080,这个端口号不需要提升权限。Web 服务器通常使用端口号 80 和 443,这些端口号需要提升权限。通常,最好使用像 NGINX 或 Apache httpd 这样的服务器来管理特权端口。
15.1.1 通过 cookies 注入状态
cookies 的添加改变了客户端和服务器之间的整体关系,使其成为有状态的。有趣的是,这并不涉及对 HTTP 的任何更改。状态信息是通过请求和回复的头信息进行通信的。服务器将在响应头中向用户代理发送 cookies。用户代理将保存 cookies 并在请求头中回复它们。
用户代理或浏览器需要保留作为响应一部分提供的 cookie 值缓存,并在后续请求中包含适当的 cookies。Web 服务器将在请求头中查找 cookies,并在响应头中提供更新的 cookies。这种效果是使 Web 服务器无状态;状态变化仅发生在客户端。因为服务器将 cookies 视为请求中的附加参数,并在响应中提供额外的详细信息,这塑造了我们对于响应请求的功能的看法。
cookies 可以包含任何适合 4,096 字节的内容。它们通常被加密,以避免将 Web 服务器细节暴露给客户端计算机上运行的其它应用程序。传输大的 cookies 可能会很慢,应该避免。最佳实践是将会话信息保存在数据库中,并在 cookie 中只提供数据库键。这使得会话持久化,并允许会话处理由任何可用的 Web 服务器处理,从而实现服务器之间的负载均衡。
会话的概念是 Web 应用程序软件的一个特性,而不是 HTTP。会话通常通过 cookie 实现,以保留会话信息。当发起初始请求时,没有可用的 cookie,将创建一个新的会话 cookie。每个后续请求都将包括 cookie 的值。登录用户将在他们的会话 cookie 中包含额外的详细信息。会话可以持续到服务器愿意接受 cookie 的时间;cookie 可以是永久有效的,或者几分钟后过期。
RESTful 风格的 Web 服务不依赖于会话或 cookies。每个 REST 请求都是独特的。在许多情况下,每个请求都会提供一个Authorization头,以提供认证和授权的凭据。这通常意味着必须有一个单独面向客户端的应用程序来创建令人愉悦的用户体验,这通常涉及到会话。常见的架构是一个前端应用程序,可能是一个移动应用程序或基于浏览器的网站,用于提供对支持 RESTful Web 服务的视图。
我们将在本章中关注 RESTful Web 服务。RESTful 方法非常适合无状态的函数式设计模式。
无会话 REST 过程的后果之一是每个 REST 请求都是单独认证的。这通常意味着 REST 服务也必须使用安全套接字层(SSL)协议。HTTPS方案是用于从客户端到服务器安全传输凭证所必需的。
15.1.2 考虑具有功能设计的服务器
HTTP 背后的一个核心思想是服务器的响应是请求的函数。从概念上讲,网络服务应该有一个顶层实现,可以概括如下:
response = httpd(request)
虽然这是 HTTP 的本质,但它缺少许多重要细节。首先,HTTP 请求不是一个简单的、单一的数据结构。它包含一些必需的部分和一些可选的部分。一个请求可能包含头部信息、一个方法(例如,GET、POST、PUT、PATCH等)、一个 URL,并且可能有附件。URL 包含几个可选部分,包括路径、查询字符串和片段标识符。附件可能包括来自 HTML 表单的输入或上传的文件,或者两者都有。
其次,响应同样有三个部分。它有一个状态码、头部信息和响应体。我们简单的httpd()函数模型没有涵盖这些额外的细节。
我们需要扩展这种简单观点,以便更准确地分解网络处理为有用的函数。
15.1.3 深入探讨功能视图
HTTP 响应和请求都有与主体分开的头部。请求还可以包含一些附加的表单数据或其他上传。因此,我们可以更有用地将 Web 服务器视为这样:
headers, content = httpd(
headers, request, [attachments, either forms or uploads]
)
请求头部可能包括 cookie 值,这可以被视为添加了更多的参数。此外,Web 服务器通常依赖于其运行的操作系统环境。这些操作系统环境数据可以被视为作为请求一部分提供的更多参数。
多用途互联网邮件扩展(MIME)类型定义了网络服务可能返回的内容类型。MIME 描述了一个大但相对定义良好的内容范围。这可以包括纯文本、HTML、JSON、XML 或任何网站可能提供的大量非文本媒体。
HTTP 请求处理的一些常见特性是我们希望重用的。这种可重用元素的想法导致了从简单到复杂的各种网络服务框架的创建。功能设计允许我们重用函数的方式表明,功能方法有助于构建网络服务。
我们将通过检查我们如何创建服务响应的各种元素管道来研究网络服务的功能设计。我们将通过嵌套请求处理的函数来实现这一点,这样内部元素就可以免受外部元素提供的通用开销的影响。这也允许外部元素充当过滤器:无效的请求可以产生错误响应,从而使内部函数能够专注于应用程序处理。
15.1.4 嵌套服务
我们可以将网络请求处理看作是一系列分层上下文。基础可能包括会话管理:检查请求以确定这是现有会话中的另一个请求还是新会话。在这个基础上,另一层可以提供用于表单处理的令牌,这些令牌可以检测跨站请求伪造(CSRF)。在这些之上,可能还有一层处理会话内的用户身份验证。
对之前解释的功能的概念性视图可能如下所示:
response = content(
authentication(
csrf(
session(headers, request, forms)
)
)
)
这里的想法是每个函数都可以建立在先前函数的结果之上。每个函数要么丰富请求,要么拒绝它,因为它无效。例如,session() 函数可以使用头部信息来确定这是一个现有会话还是一个新会话。csrf() 函数将检查表单输入以确保使用了正确的令牌。CSRF 处理需要有效的会话。authentication() 函数可以为缺少有效凭证的会话返回错误响应;当存在有效凭证时,它可以丰富请求,添加用户信息。
content() 函数无需担心会话、伪造和非认证用户。它可以专注于解析路径,以确定应提供哪种类型的内容。在更复杂的应用中,content() 函数可能包括从路径元素到确定适当内容的函数的相当复杂的映射。
这种嵌套的函数视图存在一个深刻的问题。函数栈被定义为按照特定顺序使用。csrf() 函数必须首先执行,以便为 authentication() 函数提供有用的信息。然而,我们可以想象一个高安全场景,其中在检查 CSRF 令牌之前必须先进行身份验证。我们不希望为每种可能的网络架构定义独特的函数。
虽然每个上下文都必须有一个独特的焦点,但有一个单一的、统一的请求和响应处理视图会更有帮助。这允许独立构建各个部分。一个有用的网站将是多个不同函数的组合。
使用标准化接口,我们可以组合函数以实现所需功能。这将符合函数式编程的目标,即编写简洁且表达力强的程序来提供网络内容。WSGI 标准提供了一种统一的方式来构建复杂服务,作为部分的组合。
15.2 WSGI 标准
Web 服务器网关接口(WSGI)定义了创建对网络请求响应的标准接口。这是大多数基于 Python 的网络服务器的通用框架。以下链接提供了大量信息:wsgi.readthedocs.org/en/latest/。
关于 WSGI 的一些重要背景信息可以在以下链接中找到:www.python.org/dev/peps/pep-0333/。
Python 库的 wsgiref 包包含 WSGI 的参考实现。每个 WSGI 应用程序都具有相同的接口,如下所示:
def some_app(environ, start_response):
# compute the status, headers, and content of the response
start_response(status, headers)
return content
environ 参数是一个字典,它包含请求的所有参数,以单一、统一的结构。头部、请求方法、路径以及表单或文件上传的任何附件都将包含在环境字典中。除了这些之外,还提供了 OS 级别的上下文,以及一些属于 WSGI 请求处理的项。
start_response 参数是一个必须使用的函数,用于发送响应的状态和头部信息。负责构建响应的 WSGI 服务器部分将使用提供的 start_response() 函数,并将响应文档作为返回值构建。
从 WSGI 应用程序返回的响应是一个字符串或类似字符串的文件包装器的序列,这些序列将被返回给用户代理。如果使用 HTML 模板工具,则序列可能只有一个项目。在某些情况下,例如使用 Jinja2 模板构建 HTML 内容,模板可以延迟作为文本块的序列进行渲染。这允许服务器在向用户代理下载的同时混合模板填充。
wsgiref 包没有一组完整的类型定义。这通常不是问题。例如,在 werkzeug 包中,werkzeug.wsgi 模块包含有用的类型定义。由于 werkzeug 包通常与 Flask 一起安装,因此对于我们的目的来说非常方便。
werkzeug.wsgi 模块包含一个具有多个有用类型提示的存根文件。这些提示不是工作应用程序的一部分;它们仅由 mypy 工具使用。我们可以研究以下 werkzeug.wsgi 的 WSGI 应用程序类型提示:
from sys import _OptExcInfo
from typing import Any, Callable, Dict, Iterable, Protocol
class StartResponse(Protocol):
def __call__(
self, status: str, headers: list[tuple[str, str]], exc_info: "_OptExcInfo" | None = ...
) -> Callable[[bytes], Any]: ...
WSGIEnvironment = Dict[str, Any]
WSGIApplication = Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]
WSGIEnvironment 类型提示定义了一个没有对值有有用边界的字典。很难列举出 WSGI 标准定义的所有可能类型的值。与其使用详尽的复杂定义,似乎更好的方法是使用 Any。
StartResponse 类型提示是提供给 WSGI 应用的 start_response() 函数的签名。这被定义为 Protocol 以显示存在一个可选的第三个参数,用于异常信息。
整个 WSGI 应用程序 WSGIApplication 需要环境和 start_response() 函数。结果是字节的可迭代集合。
这些提示背后的想法是允许我们定义一个应用程序如下:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from _typeshed.wsgi import (
WSGIApplication, WSGIEnvironment, StartResponse
)
def static_text_app(
environ: "WSGIEnvironment",
start_response: "StartResponse"
) -> Iterable[bytes]:
...
我们包括了一个条件 import 来提供类型提示,仅在运行 mypy 工具时使用。在不在使用 mypy 工具的情况下,类型提示作为字符串提供。这种额外的说明可以帮助解释一个复杂函数集合的设计,这些函数响应 Web 请求。
每个 WSGI 应用程序都需要设计成函数的集合。这个集合可以看作是嵌套函数或变换链。链中的每个应用程序要么返回一个错误,要么将请求传递给另一个应用程序,该应用程序将确定最终结果。
通常,URL 路径用于确定将使用哪些许多替代应用程序中的哪一个。这会导致一个 WSGI 应用程序的树,这些应用程序可能共享公共组件。
这里有一个非常简单的路由应用程序,它接受 URL 路径的第一个元素,并使用它来定位提供内容的另一个 WSGI 应用程序:
from wsgiref.simple_server import demo_app
SCRIPT_MAP: dict[str, "WSGIApplication"] = {
"demo": demo_app,
"static": static_text_app,
"index.html": welcome_app,
"": welcome_app,
}
def routing(
environ: "WSGIEnvironment",
start_response: "StartResponse"
) -> Iterable[bytes]:
top_level = wsgiref.util.shift_path_info(environ)
if top_level:
app = SCRIPT_MAP.get(top_level, welcome_app)
else:
app = welcome_app
content = app(environ, start_response)
return content
此应用程序将使用wsgiref.util.shift_path_info()函数调整环境。更改是对请求路径的头部/尾部分割,可在environ[’PATH_INFO’]字典中找到。路径的头部,直到第一个"/",将被分配给环境中的SCRIPT_NAME项;PATH_INFO项将被更新以包含路径的尾部。返回值也将是路径的头部,与environ[’SCRIPT_NAME’]相同的值。在没有路径可解析的情况下,返回值是None,并且不进行环境更新。
routing()函数使用路径上的第一个项目在SCRIPT_MAP字典中定位应用程序。如果请求的路径不符合映射,我们使用welcome_app作为默认值。这似乎比 HTTP 404 NOT FOUND错误要好一些。
这个 WSGI 应用程序是一个函数,它从多个其他 WSGI 函数中选择。请注意,路由函数不返回一个函数;它将修改后的环境提供给选定的 WSGI 应用程序。这是从函数到函数传递工作的典型设计模式。
从这里,我们可以看到框架如何泛化路径匹配过程,使用正则表达式。我们可以想象配置routing()函数,使用一系列正则表达式和 WSGI 应用程序,而不是从字符串到 WSGI 应用程序的映射。增强的routing()函数将评估每个正则表达式以寻找匹配项。在匹配的情况下,可以使用任何match.groups()函数在调用请求的应用程序之前更新环境。
15.2.1 在 WSGI 处理期间引发异常
WSGI 应用程序的一个核心特征是链中的每个阶段都负责过滤请求。其理念是在处理过程中尽早拒绝错误的请求。当构建一系列独立的 WSGI 应用程序时,每个阶段有两个基本选择:
-
评估
start_response()函数以启动带有错误状态的回复 -
或者将带有扩展环境的请求传递给下一个阶段
考虑一个提供小型文本文件的 WSGI 应用程序。文件可能不存在,或者请求可能指向文件目录。我们可以定义一个提供静态内容的 WSGI 应用程序如下:
def headers(content: bytes) -> list[tuple[str, str]]:
return [
("Content-Type", ’text/plain;charset="utf-8"’),
("Content-Length", str(len(content))),
]
def static_text_app(
environ: "WSGIEnvironment",
start_response: "StartResponse"
) -> Iterable[bytes]:
log = environ[’wsgi.errors’]
try:
static_path = Path.cwd() / environ[’PATH_INFO’][1:]
with static_path.open() as static_file:
print(f"{static_path=}", file=log)
content = static_file.read().encode("utf-8")
start_response(’200 OK’, headers(content))
return [content]
except IsADirectoryError as exc:
return index_app(environ, start_response)
except FileNotFoundError as exc:
print(f"{static_path=} {exc=}", file=log)
message = f"Not Found {environ[’PATH_INFO’]}".encode("utf-8")
start_response(’404 NOT FOUND’, headers(message))
return [message]
此应用程序从当前工作目录和请求 URL 中提供的路径元素创建一个Path对象。路径信息是 WSGI 环境的一部分,在具有’PATH_INFO’键的项中。由于路径的解析方式,它将有一个前导的”/”,我们通过使用environ[’PATH_INFO’][1:]来丢弃它。
此应用程序尝试以文本文件的形式打开请求的路径。存在两个常见问题,这两个问题都作为异常处理:
-
如果文件是一个目录,我们将请求路由到不同的 WSGI 应用程序,即
index_app,以展示目录内容 -
如果文件根本找不到,我们将返回 HTTP
404NOT FOUND 响应
此 WSGI 应用程序引发的任何其他异常都不会被捕获。调用此应用程序的应用程序应该设计有某种通用的错误响应能力。如果应用程序没有处理异常,将使用通用的 WSGI 失败响应。
我们的处理涉及操作的严格顺序。我们必须读取整个文件,以便我们可以创建适当的 HTTP Content-Length 头。
此小型应用程序展示了 WSGI 的响应或转发请求到另一个形成响应的应用程序的想法。这种立即响应或转发设计模式使得构建多阶段管道成为可能。每个阶段要么拒绝请求,要么完全处理它,要么将其传递给其他应用程序。
这些管道通常被称为中间件,因为它们位于基础服务器(如 NGINX)和最终 Web 应用程序或 RESTful API 之间。想法是使用中间件为每个请求执行一系列常见的过滤器或映射。
15.2.2 实用型 Web 应用程序
WSGI 标准的意图不是定义一个完整的 Web 框架;意图是定义一组最小标准,允许灵活的 Web 相关处理互操作性。这个最小标准与函数式编程概念很好地匹配。
Web 应用程序框架专注于开发者的需求。它应该提供许多简化以提供 Web 服务。基础接口必须与 WSGI 兼容,以便可以在各种环境中使用。然而,开发者的观点将与最小的 WSGI 定义有所不同。
Web 服务器,如 Apache httpd 或 NGINX,有适配器,可以从 Web 服务器提供 WSGI 兼容的接口到 Python 应用程序。有关 WSGI 实现的更多信息,请访问wiki.python.org/moin/WSGIImplementations。
将我们的应用程序嵌入到更大的服务器中,可以使我们实现关注点的整洁分离。我们可以使用 Apache httpd 或 NGINX 来提供静态内容,例如.css、.js和图像文件。然而,对于 HTML 页面,像 NGINX 这样的服务器可以使用uwsgi模块将请求传递给一组 Python 进程。这使 Python 专注于处理网页内容的有趣且复杂的 HTML 部分。
下载静态内容需要很少的定制。通常没有特定于应用程序的处理。这最好在一个单独的服务中处理,该服务可以优化以执行此固定任务。
动态内容的处理(通常是网页的 HTML 内容)是 Python 相关有趣工作的发生地。这项工作可以分离到优化运行这种更复杂的应用特定计算的服务器上。
将静态内容与动态内容分离以提供优化的下载意味着我们必须创建一个单独的媒体服务器,或者定义我们的网站具有两组路径。对于较小的网站,单独的/media路径效果很好。对于较大的网站,则需要不同的媒体服务器。
WSGI 定义的一个重要后果是environ字典通常会更新额外的配置参数。通过这种方式,一些 WSGI 应用程序可以作为网关,从 cookie、头部、配置文件或数据库中提取信息来丰富环境。
15.3 将网络服务定义为函数
我们将研究一个 RESTful 网络服务,它可以切割和分割数据源,并提供 JSON、XML 或 CSV 文件的下载。
直接使用 WSGI 进行此类应用程序不是最优的,因为我们需要为传统网站处理的全部细节创建大量的“样板”处理。更有效的方法是使用更复杂的网络服务器,如 Flask、Django、Bottle 或这里列出的任何框架:wiki.python.org/moin/WebFrameworks。这些服务器处理传统情况更完整,使我们作为开发者能够专注于页面或网站的独特功能。
我们将使用一个包含四个数据对序列的简单数据集:Anscombe 四重奏。我们在第三章、函数、迭代器和生成器中探讨了读取和解析这些数据的方法。这是一个小的数据集,但它可以用来展示 RESTful 网络服务的原则。
我们将把我们的应用程序分为两层:一个网络层,它将提供可见的 RESTful 网络服务,和一个数据服务层,它将管理底层数据。我们将首先查看网络层,因为这为数据服务层必须运行的环境提供了上下文。
请求必须包含以下两块信息:
-
所需的数据序列。想法是通过过滤和提取所需子集来切割可用的信息池。
-
用户需要的输出格式。这包括常见的序列化格式,如 HTML、CSV、JSON 和 XML。
系列选择通常是通过请求路径完成的。我们可以请求/anscombe/I或/anscombe/II来选择四重奏中的特定系列。路径设计很重要,这似乎是识别数据的正确方式。
以下两个基本思想有助于定义路径:
-
一个 URL 定义了一个资源
-
没有充分的理由让 URL 发生变化
在这种情况下,I或II的数据集选择器不依赖于发布日期或某些组织批准状态,或其他外部因素。这种设计似乎创建出永恒且绝对的 URL。
另一方面,输出格式不是 URL 的一部分。它仅仅是一个序列化格式,而不是数据本身。一个选择是在 HTTP Accept头中命名格式。在某些情况下,为了使浏览器使用起来更方便,可以使用查询字符串来指定输出格式。一种方法是通过查询来指定序列化格式。我们可以在路径末尾使用?form=json、?format=json,甚至?output_serialization=json来指定输出序列化格式应为 JSON。HTTP Accept头是首选的,但仅使用浏览器进行实验可能比较困难。
我们可以使用一个浏览器友好的 URL,其形式如下:
http://localhost:8080/anscombe/III?form=csv
这将请求以 CSV 格式下载第三系列。
OpenAPI 规范提供了一种定义 URL 家族和预期结果的方法。这个规范是有帮助的,因为它作为网络服务器预期行为的清晰、正式合同。OpenAPI 规范最有帮助的是有一个具体的路径、参数和响应列表。一个好的规范将包括示例,有助于编写服务器的验收测试套件。
通常,OpenAPI 规范由网络服务器提供,以帮助客户端正确使用可用的服务。建议使用像"/openapi.yml"或"/openapi.json"这样的 URL 来提供关于网络应用程序所需的信息。
15.3.1 Flask 应用程序处理
我们将使用 Flask 框架,因为它提供了一个易于扩展的 Web 服务过程。它支持基于函数的设计,将请求路径映射到构建响应的视图函数。该框架还利用了装饰器,与函数式编程概念相匹配。
为了将所有配置和 URL 路由绑定在一起,使用一个总的Flask实例作为容器。我们的应用程序将是Flask类的一个实例。作为一种简化,每个视图函数都是单独定义的,并通过将 URL 映射到函数的路由表绑定到Flask实例。这个路由表是通过装饰器构建的。
应用程序的核心是这个视图函数集合。通常,每个视图函数需要做三件事:
-
验证请求。
-
执行请求的状态更改或数据访问。
-
准备响应。
理想情况下,视图函数不做任何其他事情。
这里是初始的 Flask 对象,它将包含路由及其函数:
from flask import Flask
app = Flask(__name__)
我们已创建 Flask 实例并将其分配给 app 变量。作为一个方便的默认值,我们使用了模块的名称,__name__,作为应用程序的名称。这通常足够。对于复杂的应用程序,可能更好的是提供一个不特定于 Python 模块或包名称的名称。
大多数应用程序都需要提供配置参数。在这种情况下,源数据是一个可能更改的可配置值。
对于较大的应用程序,通常有必要定位整个配置文件。对于这个小型应用程序,我们将提供配置值作为字面量:
from pathlib import Path
app.config[’FILE_PATH’] = Path.cwd() / "Anscombe.txt"
大多数视图函数应该是相对较小的、专注于其他应用层功能的函数。对于这个应用程序,网络表示依赖于数据服务层来获取和格式化数据。这导致以下三个步骤的函数:
-
验证各种输入。这包括验证路径、任何查询参数、表单输入数据、上传的文件、头部值,甚至是 cookie 值。
-
如果该方法涉及状态更改,如
POST、PUT、PATCH或DELETE,则执行状态更改操作。这些操作通常会返回一个指向将显示更改结果的路径的“重定向”响应。如果该方法涉及GET请求,则收集所需数据。 -
准备响应。
第 2 步的重要之处在于所有数据操作都与 RESTful 网络应用程序分离。网络表示建立在数据访问和操作的基础之上。网络应用程序被设计为一个视图或对底层结构的展示。
我们将查看网络应用程序的两个 URL 路径。第一个路径将提供 Anscombe 集合中可用系列索引。view 函数可以定义为以下内容:
from flask import request, abort, make_response, Response
@app.route("/anscombe/")
def index_view() -> Response:
# 1\. Validate
response_format = format()
# 2\. Get data
data = get_series_map(app.config[’FILE_PATH’])
index_listofdicts = [{"Series": k} for k in data.keys()]
# 3\. Prepare Response
try:
content_bytes = serialize(response_format, index_listofdicts, document_tag="Index", row_tag="Series")
response = make_response(content_bytes, 200, {"Content-Type": response_format})
return response
except KeyError:
abort(404, f"Unknown {response_format=}")
此函数具有 Flask 的 @app.route 装饰器。这表明哪些 URL 应由此 view 函数处理。这里有许多选项和替代方案可用。当请求与可用路由之一匹配时,将评估 view 函数。
format() 函数的定义将在稍后展示。它通过查找两个地方来定位用户期望的格式:URL 中的查询字符串,即 ? 后面,以及 Accept 头部。如果查询字符串值无效,将创建一个 404 响应。
get_series_map() 函数是数据服务层的一个基本功能。这将定位 Anscombe 系列数据,并将 Series 名称映射到系列数据。
索引信息以列表-of-dict 结构的形式存在。这种结构可以不经过太多复杂地转换为 JSON、CSV 和 HTML。创建 XML 则要困难一些。困难之处在于 Python 列表和字典对象没有特定的类名,这使得提供 XML 标签变得有些尴尬。
数据准备分为两部分进行。首先,索引信息以所需格式序列化。其次,使用字节、HTTP 状态码 200 和Content-Type头部的特定值构建一个 Flask Response对象。
abort()函数停止进程并返回带有给定代码和原因信息的错误响应。对于 RESTful Web 服务,添加一个将结果转换为 JSON 的小型辅助函数很有帮助。在数据验证和准备期间使用abort()函数使得在请求的第一个问题时结束处理变得容易。
format()函数定义如下:
def format() -> str:
if arg := request.args.get(’form’):
try:
return {
’xml’: ’application/xml’,
’html’: ’text/html’,
’json’: ’application/json’,
’csv’: ’text/csv’,
}[arg]
except KeyError:
abort(404, "Unknown ?form=")
else:
return request.accept_mimetypes.best or "text/html"
此函数从request对象的两个属性中查找输入:
-
args将包含 URL 中“?”之后出现的参数值 -
accept_mimetypes将包含从Accept头部解析出的值,允许应用程序定位满足客户端期望的响应
request对象是带有正在进行的 Web 请求详细信息的线程局部存储。它被用作全局变量,使得一些函数看起来有些笨拙。像request这样的全局变量往往会掩盖此函数的实际参数。使用显式参数还需要提供底层类型信息,这不过是视觉上的杂乱。
定义提供系列数据的series_view()函数如下:
@app.route("/anscombe/<series_id>")
def series_view(series_id: str, form: str | None = None) -> Response:
# 1\. Validate
response_format = format()
# 2\. Get data (and validate some more)
data = get_series_map(app.config[’FILE_PATH’])
try:
dataset = anscombe_filter(series_id, data)._as_listofdicts()
except KeyError:
abort(404, "Unknown Series")
# 3\. Prepare Response
try:
content_bytes = serialize(response_format, dataset, document_tag="Series", row_tag="Pair")
response = make_response(
content_bytes, 200, {"Content-Type": response_format}
)
return response
except KeyError:
abort(404, f"Unknown {response_format=}")
此函数结构与之前的index_view()函数类似。请求得到验证,数据获取,并准备响应。与之前的函数一样,工作被委托给另外两个数据访问函数:get_series_map()和anscombe_filter()。这些函数与 Web 应用程序分开,可能是命令行应用程序的一部分。
这两个函数都依赖于底层的数据访问层。我们将在下一节中查看这些函数。
15.3.2 数据访问层
get_series_map()函数与第三章使用生成器函数清理原始数据部分中显示的示例类似,函数、迭代器和生成器。在本节中,我们将包括一些重要的更改。我们将从以下两个NamedTuple定义开始:
from Chapter03.ch03_ex4 import (
series, head_split_fixed, row_iter)
from collections.abc import Callable, Iterable
from typing import NamedTuple, Any, cast
class Pair(NamedTuple):
x: float
y: float
@classmethod
def create(cls: type["Pair"], source: Iterable[str]) -> "Pair":
return Pair(*map(float, source))
class Series(NamedTuple):
series: str
data: list[Pair]
@classmethod
def create(cls: type["Series"], name: str, source: Iterable[tuple[str, str]]) -> "Series":
return Series(name, list(map(Pair.create, source)))
def _as_listofdicts(self) -> list[dict[str, Any]]:
return [p._asdict() for p in self.data]
我们定义了一个名为Pair的命名元组,并提供了一个@classmethod来构建Pair的实例。此定义将自动提供一个_asdict()方法,该方法返回一个形式为dict[str, Any]的字典,包含属性名称和值。这对于序列化很有帮助。
同样,我们定义了一个名为 Series 的命名元组。create() 方法可以从值列表的可迭代源构建一个元组。自动提供的 _asdict() 方法对于序列化可能很有帮助。然而,对于这个应用程序,我们将使用 _as_listofdicts 方法来创建可以序列化的字典列表。
从系列名称到 Series 对象的映射函数具有以下定义:
from pathlib import Path
def get_series_map(source_path: Path) -> dict[str, Series]:
with source_path.open() as source:
raw_data = list(head_split_fixed(row_iter(source)))
series_iter = (
Series.create(id_str, series(id_num, raw_data))
for id_num, id_str in enumerate(
[’I’, ’II’, ’III’, ’IV’])
)
mapping = {
series.series: series
for series in series_iter
}
return mapping
get_series_map() 函数打开本地数据文件,并将 row_iter() 函数应用于文件的每一行。这会将行解析为单独的项目行。使用 head_split_fixed() 函数从文件中移除标题。结果是元组列表结构,被分配给变量 raw_data。
从 raw_data 结构中,使用 Series.create() 方法将文件中的值序列转换为由单个 Pair 实例组成的 Series 对象。最后一步是使用字典推导式收集单个 Series 实例到一个从系列名称到 Series 对象的单个映射中。
由于 get_series_map() 函数的输出是一个映射,我们可以像以下示例那样通过名称选择特定的系列:
>>> source = Path.cwd() / "Anscombe.txt"
>>> get_series_map(source)[’I’]
Series(series=’I’, data=[Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ...])
给定一个键,例如,‘I’,该系列是一个包含 Pair 对象的列表,这些对象具有系列中每个项目的 x、y 值。
应用过滤器
在这个应用程序中,我们使用一个非常简单的过滤器。整个过滤器过程体现在以下函数中:
def anscombe_filter(
set_id: str, raw_data_map: dict[str, Series]
) -> Series:
return raw_data_map[set_id]
我们将这个简单的表达式转换为函数有三个原因:
-
函数表示法与其他 Flask 应用程序的各个部分略微更一致,并且比下标表达式更灵活
-
我们可以轻松扩展过滤功能以执行更多操作
-
我们可以为这个函数包含单独的单元测试
虽然简单的 lambda 函数可以工作,但测试起来可能不太方便。
对于错误处理,我们实际上什么都没做。我们专注于有时被称为“快乐路径”的理想事件序列。在这个函数中出现的任何问题都会抛出异常。WSGI 包装函数应该捕获所有异常,并返回适当的状态消息和错误响应内容。
例如,set_id 方法可能以某种方式出错。而不是过分关注它可能出错的所有方式,我们将允许 Python 抛出异常。实际上,这个函数遵循 Grace Hopper 海军上将的建议,即寻求宽恕比请求许可更好。这个建议在代码中体现为避免请求许可:没有试图验证参数有效性的预备 if 语句。只有宽恕处理:将抛出异常,并通过评估 Flask 的 abort() 函数来处理。
序列化结果
序列化是将 Python 数据转换为字节流的过程,适合传输。每种格式最好通过一个简单的函数来描述,该函数仅序列化该格式。然后,顶层通用序列化器可以从一系列特定序列化器中选择。
序列化器的一般类型提示如下:
from collections.abc import Callable
from typing import Any, TypeAlias
Serializer: TypeAlias = Callable[[list[dict[str, Any]]], bytes]
这个定义避免了具体的Series定义。它使用了一个更一般的list[dict[str, Any]]类型提示。这可以应用于Series的数据以及其他类似序列标签的项目。
从 MIME 类型到序列化器函数的映射将导致以下映射对象:
SERIALIZERS: dict[str, Serializer] = {
'application/xml': serialize_xml,
'text/html': serialize_html,
'application/json': serialize_json,
'text/csv': serialize_csv,
}
这个变量将在引用的四个函数定义之后定义。我们在这里提供它作为上下文,展示序列化设计的发展方向。
顶层serialize()函数可以定义如下:
def serialize(
format: str | None,
data: list[dict[str, Any]],
**kwargs: str
) -> bytes:
"""Relies on global SERIALIZERS, set separately"""
if format is None:
format = "text/html"
function = SERIALIZERS.get(
format.lower(),
serialize_html
)
return function(data, **kwargs)
总体上的serialize()函数在SERIALIZERS字典中定位一个特定的序列化器。这个特定的函数符合Serializer类型提示。该函数将Series对象转换为字节,可以下载到 Web 客户端应用程序。
serialize()函数不执行任何数据转换。它将 MIME 类型字符串映射到一个执行转换的函数。
我们将查看一些单独的序列化器。在 Python 处理中创建字符串相对常见。然后我们可以将这些字符串编码为字节。为了避免重复编码操作,我们将定义一个装饰器来组合序列化和字节编码。以下是我们可以使用的装饰器:
from collections.abc import Callable
from typing import TypeVar, ParamSpec
from functools import wraps
T = TypeVar("T")
P = ParamSpec("P")
def to_bytes(
function: Callable[P, str]
) -> Callable[P, bytes]:
@wraps(function)
def decorated(*args: P.args, **kwargs: P.kwargs) -> bytes:
text = function(*args, **kwargs)
return text.encode("utf-8")
return decorated
我们创建了一个名为@to_bytes的小型装饰器。这个装饰器将评估给定的函数,然后使用 UTF-8 编码结果以获取字节。请注意,装饰器将装饰函数的返回类型从str更改为bytes。我们使用了ParamSpec提示来收集装饰函数声明的参数。这确保了像 mypy 这样的工具可以将装饰函数的参数规范与基础函数相匹配。
我们将展示如何使用 JSON 和 CSV 序列化器来实现这一点。HTML 和 XML 序列化涉及更多的编程,但并没有显著的复杂性。
使用 JSON 或 CSV 格式序列化数据
JSON 和 CSV 序列化器是相似的,因为两者都依赖于 Python 的库进行序列化。这些库本质上是命令式的,因此函数体是语句的序列。
下面是 JSON 序列化器的示例:
import json
@to_bytes
def serialize_json(data: list[dict[str, Any]], **kwargs: str) -> str:
text = json.dumps(data, sort_keys=True)
return text
我们创建了一个字典列表结构,并使用json.dumps()函数创建了一个字符串表示形式。JSON 模块需要一个具体化的列表对象;我们不能提供一个惰性生成器函数。sort_keys=True参数值对于单元测试很有帮助,因为顺序被明确地说明了,并且可以用来匹配预期的结果。然而,它对于应用程序不是必需的,并且代表了一点点开销。
下面是 CSV 序列化器的示例:
import csv
import io
@to_bytes
def serialize_csv(data: list[dict[str, Any]], **kwargs: str) -> str:
buffer = io.StringIO()
wtr = csv.DictWriter(buffer, sorted(data[0].keys()))
wtr.writeheader()
wtr.writerows(data)
return buffer.getvalue()
csv 模块的读取器和写入器是命令式和函数式元素的混合。我们必须创建写入器,并且必须按照严格的顺序正确创建标题。此函数的客户端可以使用 Pair 命名元组的 _fields 属性来确定写入器的列标题。
writer 对象的 writerows() 方法将接受一个惰性生成器函数。此函数的客户端可以使用 NamedTuple 对象的 _asdict() 方法返回一个适合与 CSV 写入器一起使用的字典。
使用 XML 和 HTML 序列化数据
将数据序列化为 XML 的目标是创建一个看起来像这样的文档:
<?xml version="1.0" encoding="UTF-8"?>
<Series>
<Pair><x>2</x><y>3</y></Pair>
<Pair><x>5</x><y>7</y></Pair>
</Series>
此 XML 文档不包括对正式 XML 架构定义 (XSD) 的引用。然而,它被设计成与上面显示的命名元组定义并行。
生成此类文档的一种方法是通过创建模板并填写字段。这可以使用 Jinja 或 Mako 等包来完成。有许多复杂的模板工具可以创建 XML 或 HTML 页面。其中许多包括在模板中嵌入对对象序列(如字典列表)的迭代的能力,而无需在初始化序列化的函数中执行。访问 wiki.python.org/moin/Templating 获取替代方案列表。
在这里,一个更复杂的序列化库可能会有所帮助。有许多可供选择。访问 wiki.python.org/moin/PythonXml 获取替代方案列表。
现代 HTML 基于 XML。因此,可以通过将实际值填充到模板中来构建类似于 XML 文档的 HTML 文档。HTML 文档通常比 XML 文档有更多的开销。额外的复杂性源于在 HTML 中,文档被期望提供一个包含大量上下文信息的完整网页。
我们省略了创建 HTML 或 XML 的细节,将其留给读者作为练习。
15.4 跟踪使用
RESTful API 需要用于安全连接。这意味着服务器必须使用 SSL,并且连接将通过 HTTPS 协议进行。其想法是管理“前端”或客户端应用程序使用的 SSL 证书。在许多网络服务环境中,移动应用程序和基于 JavaScript 的交互式前端将拥有允许访问后端的证书。
除了 SSL 之外,另一个常见的做法是在每个事务中要求一个 API 密钥。API 密钥可以用来验证访问。它也可以用来授权特定功能。最重要的是,它对于跟踪实际使用至关重要。跟踪使用的一个后果是,如果在一个给定的时间段内过度使用 API 密钥,可能会限制请求。
商业模式的变体很多。例如,API 密钥的使用可能是一个可计费事件,并且将产生费用。对于其他业务,流量必须达到某个阈值,然后才需要支付。
重要的是要确保 API 使用的不可否认性。当执行交易以进行状态更改时,可以使用 API 密钥来识别发出请求的应用程序。这反过来意味着创建可以充当用户身份验证凭据的 API 密钥。密钥必须难以伪造,并且相对容易验证。
创建 API 密钥的一种方法是用加密随机数生成一个难以预测的密钥字符串。可以使用secrets模块生成唯一的 API 密钥值。以下是一个生成唯一密钥的示例,该密钥可以分配给客户端以跟踪活动:
>>> import secrets
>>> secrets.token_urlsafe(24)
’NLHirCPVf-S7aSAiaAJo3JECYk9dSeyq’
在随机字节上使用 64 进制编码来创建一串字符。使用三的倍数作为长度将避免在 64 进制编码中出现任何尾随的=符号。我们使用了 URL 安全的 64 进制编码,这意味着结果字符串中不会包含/或+字符。这意味着密钥可以用作 URL 的一部分,或者可以在标题中提供。
使用更复杂的方法生成令牌不会导致更随机的数据。使用secrets模块确保很难伪造分配给其他用户的密钥。
secrets模块作为单元和集成测试的一部分使用时,因其难以使用而闻名。为了生成高质量、安全的数据,它避免了像random模块那样有显式种子。由于可重复的单元测试用例不能依赖于secrets模块的可重复结果,因此在测试时应使用模拟对象。这一结果的后果是创建一个便于测试的设计。
随着 API 密钥的生成,它们需要发送给创建应用程序的用户,并保存在 API 服务的一部分数据库中。
如果请求中包含数据库中的密钥,则关联的用户负责该请求。如果 API 请求不包含已知的密钥,则请求可以拒绝,并返回401 UNAUTHORIZED响应。
这个小型数据库可以是一个文本文件,服务器在加载时将其映射到授权权限的 API 密钥。该文件可以在启动时读取,并检查修改时间以确定服务器缓存的版本是否仍然是最新的。当有新的密钥可用时,文件将被更新,服务器将重新读取该文件。
有关 API 密钥的更多信息,请参阅swagger.io/docs/specification/2-0/authentication/api-keys/。
对有效 API 密钥的基本检查如此常见,以至于 Flask 提供了一个装饰器来识别此功能。使用@app.before_app_request标记一个将在每个视图函数之前调用的函数。这个函数可以在允许任何处理之前确定 API 密钥的有效性。
这个 API 密钥检查通常被绕过了一些路径。例如,如果服务将下载其 OpenAPI 规范,则路径应在不考虑是否存在API-Key头的情况下处理。这通常意味着一个特殊情况检查,以查看request.path是否为openapi.json或其他规范常见名称之一。
同样,服务器可能需要根据 CORS 头的存在来响应请求。有关更多信息,请参阅www.w3.org/TR/cors/#http-cors-protocol。这可能会通过添加另一组异常使before_app_request()函数变得更加复杂。
好消息是,只有两个例外需要在每个请求中都包含API-Key头。一个是处理 OpenAPI 规范,另一个是 CORS 预请求。这不太可能改变,几个if语句就足够了。
15.5 摘要
在本章中,我们探讨了如何将函数式设计应用于基于 REST 的 Web 服务的内容服务问题。我们探讨了 WSGI 标准如何导致整体应用在一定程度上具有函数性。我们还探讨了如何通过从请求中提取元素以供我们的应用程序函数使用,将更函数化的设计嵌入到 WSGI 上下文中。
对于简单的服务,问题通常分解为三个不同的操作:获取数据、搜索或过滤,然后序列化结果。我们通过三个函数来解决这个问题:raw_data()、anscombe_filter()和serialize()。我们将这些函数包装在一个简单的 WSGI 兼容应用程序中,以将 Web 服务与提取和过滤数据的实际处理分离。
我们还探讨了 Web 服务函数如何专注于“快乐路径”并假设所有输入都是有效的。如果输入无效,普通的 Python 异常处理将引发异常。WSGI 包装函数将捕获错误并返回适当的状态码和错误内容。
我们还没有研究与上传数据或从表单中接受数据以更新持久数据存储相关的更复杂问题。这些问题并不比获取数据和序列化结果更复杂。
对于简单的查询和数据共享,一个小型网络服务应用程序可能会有所帮助。我们可以应用函数式设计模式,并确保网站代码简洁且易于理解。对于更复杂的网络应用程序,我们应该考虑使用一个能够正确处理细节的框架。
在下一章中,我们将探讨一个更完整的函数式编程示例。这是一个案例研究,它将一些统计措施应用于样本数据,以确定数据是否可能是随机的,或者可能包含一些有趣的关系。
15.6 练习
本章的练习基于 Packt Publishing 在 GitHub 上提供的代码。请参阅github.com/PacktPublishing/Functional-Python-Programming-3rd-Edition。
在某些情况下,读者可能会注意到 GitHub 上提供的代码包括一些练习的部分解决方案。这些作为提示,允许读者探索替代解决方案。
在许多情况下,练习将需要单元测试用例来确认它们确实解决了问题。这些通常与 GitHub 仓库中已提供的单元测试用例相同。读者应将书籍中的示例函数名称替换为自己的解决方案以确认其工作。
15.6.1 WSGI 应用程序:welcome
在本章的 The WSGI standard 部分,描述了一个路由应用程序。它展示了三个应用程序路由,包括以/demo开头的路径和一个针对/index.html路径的特殊情况。
通过 WSGI 创建应用程序可能具有挑战性。构建一个函数welcome_app(),该函数显示一个包含演示应用程序和静态下载应用程序链接的 HTML 页面。
为此应用程序编写的单元测试应使用模拟的StartResponse函数和一个模拟的环境。
15.6.2 WSGI 应用程序:demo
在本章的 The WSGI standard 部分,描述了一个路由应用程序。它展示了三个应用程序路由,包括以/demo开头的路径和一个针对/index.html路径的特殊情况。
构建一个函数demo_app(),以执行一些可能有用的活动。这里的意图是有一个路径可以响应 HTTP POST请求来完成一些工作,在日志文件中创建一个条目。结果必须是一个重定向(状态码 303,通常)到使用static_text_app()下载日志文件的 URL。这种行为被称为 Post/Redirect/Get,当导航回上一个页面时,可以提供良好的用户体验。有关此设计模式的更多详细信息,请参阅www.geeksforgeeks.org/post-redirect-get-prg-design-pattern/。
下面是演示应用程序可能实现的有用工作的两个示例:
-
一个
GET请求可以显示一个带有表单的 HTML 页面。表单上的提交按钮可以将POST请求发送到执行某种计算的函数。 -
一个
POST请求可以执行doctest.testfile()来运行单元测试套件并收集结果日志。
15.6.3 使用 XML 序列化数据
在本章的 Serializing data with XML and HTML 部分,我们描述了使用 Flask 构建的 RESTful API 的两个附加功能。
在那些示例中扩展响应,将结果数据序列化为 XML,除了 CSV 和 JSON。添加 XML 序列化的一个替代方案是下载并安装一个库,该库可以序列化 Series 和 Pair 对象。另一个选择是编写一个可以与 list[dict[str, Any]] 对象一起工作的函数。添加 XML 序列化格式还需要添加测试用例来确认响应具有预期的格式和内容。
15.6.4 使用 HTML 序列化数据
在本章的使用 XML 和 HTML 序列化数据部分,我们描述了使用 Flask 构建的 RESTful API 的两个附加功能。
在那些示例中扩展响应,将结果数据序列化为 HTML,除了 CSV 和 JSON。HTML 序列化可能比 XML 序列化更复杂,因为数据在 HTML 展示中有很多开销。而不是 Pair 对象的表示,通常的做法是包含一个完整的 HTML 表格结构,它反映了 CSV 的行和列。添加 HTML 序列化格式还需要添加测试用例来确认响应具有预期的格式和内容。
加入我们的社区 Discord 空间
加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU






浙公网安备 33010602011771号