Python-函数式编程(全)

Python 函数式编程(全)

原文:zh.annas-archive.org/md5/0A7865EB133E2D9D03688623C60BD998

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

编程语言有时可以很好地适应整洁的范畴,比如命令式和函数式。命令式语言可能进一步分为过程式和包含面向对象编程特性的语言。然而,Python 语言包含了所有这三种语言范畴的特点。虽然 Python 不是纯粹的函数式编程语言,但我们可以在 Python 中进行大量的函数式编程。

最重要的是,我们可以利用许多来自其他函数式语言的设计模式和技术,并将它们应用于 Python 编程。这些借鉴的概念可以帮助我们创建简洁而优雅的程序。特别是 Python 的生成器表达式,避免了创建大型内存数据结构的需要,从而导致程序可能更快地执行,因为它们使用更少的资源。

我们无法在 Python 中轻松创建纯函数式程序。Python 缺少许多必要的功能。例如,我们没有无限递归,所有表达式的惰性求值和优化编译器。

一般来说,Python 强调严格的求值规则。这意味着语句按顺序执行,表达式从左到右求值。虽然这偏离了函数式纯度,但它允许我们在编写 Python 时执行手动优化。我们将采用混合方法来使用 Python 的函数式特性,当它们可以增加清晰度或简化代码时使用,以及使用普通的命令式特性进行优化。

函数式编程语言中有几个关键特性在 Python 中也可以使用。其中最重要的一个是函数作为一等对象的概念。在一些语言中,函数只存在于源代码结构中:它们在运行时并不作为合适的数据结构存在。在 Python 中,函数可以将函数作为参数使用,并将函数作为结果返回。

Python 提供了许多高阶函数。像map()filter()functools.reduce()这样的函数在这方面被广泛使用。像sorted()min()max()这样不太明显的函数也是高阶函数;它们有一个默认函数,因此与更常见的例子有不同的语法。

函数式程序经常利用不可变数据结构。对无状态对象的强调允许灵活的优化。Python 提供了元组和命名元组作为复杂但不可变的对象。我们可以利用这些结构来借鉴其他函数式编程语言的一些设计实践。

许多函数式语言强调递归,但利用尾调用优化(TCO)。Python 倾向于将递归限制在相对较小的堆栈帧中。在许多情况下,我们可以将递归视为生成器函数。然后我们可以简单地重写它以使用yield from语句,自己进行尾调用优化。

我们将从 Python 的角度来看函数式编程的核心特性。我们的目标是借鉴函数式编程语言的好思想,并将这些思想用于在 Python 中创建富有表现力和简洁的应用程序。

本书涵盖的内容

第一章 介绍函数式编程,介绍了一些特征函数式编程的技术。我们将确定一些将这些特性映射到 Python 的方法,最后,我们还将讨论一些使用这些设计模式构建 Python 应用程序时函数式编程的好处。

第二章,“引入一些函数式特性”,将深入探讨函数式编程范式的六个核心特性。我们将详细研究每个特性在 Python 中的实现。我们还将指出一些不适用于 Python 的函数式语言特性。特别是,许多函数式语言具有复杂的类型匹配规则,需要支持编译和优化。

第三章,“函数,迭代器和生成器”,将展示如何利用不可变的 Python 对象和生成器表达式,并将函数式编程概念应用到 Python 语言中。我们将研究一些内置的 Python 集合以及如何在不远离函数式编程概念的情况下利用它们。

第四章,“使用集合”,展示了如何使用许多内置的 Python 函数来操作数据集合。本节将重点介绍一些相对简单的函数,如any()all(),它们将一组值减少为单个结果。

第五章,“高阶函数”,探讨了常用的高阶函数,如map()filter()。本章还包括许多其他高阶函数,以及如何创建我们自己的高阶函数。

第六章,“递归和归约”,展示了如何使用递归设计算法,然后将其优化为高性能的for循环。我们还将研究一些其他广泛使用的归约,包括collections.Counter()函数。

第七章,“附加元组技术”,展示了我们可以使用不可变元组和命名元组而不是有状态对象的许多方法。不可变对象有一个更简单的接口:我们永远不必担心滥用属性并将对象设置为一些不一致或无效的状态。

第八章,“Itertools 模块”,研究了标准库模块中的一些函数。这些函数集简化了处理集合或生成器函数的程序编写。

第九章,“更多 Itertools 技术”,涵盖了 itertools 模块中的组合函数。这些函数的用处相对较少。本章包括一些例子,说明了对这些函数的不慎使用以及组合爆炸的后果。

第十章,“Functools 模块”,将展示如何使用该模块中的一些函数进行函数式编程。该模块中的一些函数更适合构建装饰器,并留待下一章。然而,其他函数提供了设计和实现函数程序的几种更多方式。

第十一章,“装饰器设计技巧”,展示了如何将装饰器视为构建复合函数的一种方式。虽然这里有相当大的灵活性,但也有一些概念上的限制:我们将探讨过于复杂的装饰器可能变得令人困惑而不是有帮助的方式。

第十二章,“多进程和线程模块”,指出了良好的函数式设计的一个重要结果:我们可以分配处理工作负载。使用不可变对象意味着我们不能因为同步不良的写操作而破坏对象。

第十三章,“条件表达式和操作员模块”,将展示一些我们可以打破 Python 严格的求值顺序的方法。在这里我们所能实现的有限制。我们还将看看操作员模块以及操作员模块如何对一些简单的处理进行轻微澄清。

第十四章,“PyMonad 库”,审查了 PyMonad 库的一些特性。这提供了一些额外的函数式编程特性。这也提供了学习更多关于单子的方法。在一些函数式语言中,单子是强制执行特定顺序的重要方式,以防止被优化为不希望的顺序。由于 Python 已经对表达式和语句有严格的顺序,单子特性更多是具有教学意义而非实际意义。

第十五章,“Web 服务的功能方法”,展示了我们如何将 Web 服务视为一个嵌套的函数集合,将请求转换为回复。我们将看到如何利用函数式编程概念来构建响应迅速、动态的网络内容。

第十六章,“优化和改进”,包括一些有关性能和优化的额外提示。我们将强调诸如记忆化之类的技术,因为它们易于实现,并且在正确的情况下可以产生显著的性能改进。

您需要为这本书做些什么

这本书假设您对 Python 3 和应用程序开发的一般概念有一定了解。我们不会深入研究 Python 的微妙或复杂特性;我们将避免对语言内部的考虑。

我们将假设您对函数式编程有一定了解。由于 Python 不是一种函数式编程语言,我们无法深入探讨函数式概念。我们将挑选适合 Python 的函数式编程方面,并仅利用那些看似有用的方面。

一些示例使用探索性数据分析(EDA)作为问题领域,以展示函数式编程的价值。对基本概率和统计学的一些了解将有助于理解。只有少数示例会涉及更严肃的数据科学。

您需要安装并运行 Python 3.3 或 3.4。有关 Python 的更多信息,请访问www.python.org/

在第十四章,“PyMonad 库”,我们将看看如何安装这个额外的库。如果您有 Python 3.4,其中包括 pip 和 Easy Install,这将非常容易。如果您有 Python 3.3,您可能已经安装了 pip 或 Easy Install,或者两者都有。一旦您有了安装程序,您就可以添加 PyMonad。访问pypi.python.org/pypi/PyMonad/了解更多详情。

这本书是为谁准备的

这本书适用于希望通过借鉴函数式编程语言的技术和设计模式来创建简洁、表达力强的 Python 程序的程序员。一些算法可以用函数式风格优雅地表达;我们可以—而且应该—调整这一点,使 Python 程序更易读和易维护。

在某些情况下,对问题的功能方法也会导致极高性能的算法。Python 使得创建大型中间数据结构变得太容易,占用内存和处理器时间。通过函数式编程设计模式,我们经常可以用生成器表达式替换大型列表,这些表达式同样具有表现力,但占用的内存更少,运行速度更快。

约定

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

文本中的代码单词显示如下:“我们可以创建一个Pool对象,将任务分配给并发工作进程,并期望任务并发执行。”

代码块设置如下:

GIMP Palette
Name: Crayola
Columns: 16
#

任何命令行输入或输出都是这样写的:

def max(a, b):
 **f = {a >= b: lambda: a, b >= a: lambda: b}[True]
 **return f()

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会出现在这样。

第一章:介绍函数式编程

函数式编程使用表达式和求值来定义计算,通常封装在函数定义中。它淡化或避免了状态变化和可变对象的复杂性。这往往会创建更简洁和表达力更强的程序。在本章中,我们将介绍一些表征函数式编程的技术。我们将确定一些将这些特性映射到Python的方法。最后,我们还将讨论一些使用这些设计模式构建 Python 应用程序时函数式编程的好处。

Python 具有许多函数式编程特性。它不是一个纯粹的函数式编程语言。它提供了足够多的正确类型的特性,使其具有函数式编程的好处。它还保留了从命令式编程语言中获得的所有优化能力。

我们还将研究一个问题领域,我们将在本书的许多示例中使用它。我们将尽量紧密地遵循探索性数据分析EDA),因为它的算法通常是函数式编程的很好的例子。此外,函数式编程的好处在这个问题领域中迅速积累。

我们的目标是建立一些函数式编程的基本原则。更严肃的 Python 代码将从第二章 介绍一些函数式特性开始。

注意

在本书中,我们将专注于 Python 3 的特性。然而,一些示例也可能在 Python 2 中工作。

确定一个范式

很难确定编程范式的宇宙中填充了什么。对于我们的目的,我们将区分许多编程范式中的两种:函数式编程命令式编程。这两者之间的一个重要区别是状态的概念。

在命令式语言中,比如 Python,计算的状态由各个命名空间中变量的值反映出来。变量的值建立了计算的状态;每种语句都通过添加、改变(甚至删除)变量来对状态进行明确定义的改变。一种语言是命令式的,因为每个语句都是一个命令,以某种方式改变状态。

我们的一般重点是赋值语句以及它如何改变状态。Python 还有其他语句,比如globalnonlocal,它们修改特定命名空间中变量的规则。像defclassimport这样的语句改变了处理上下文。其他语句,比如tryexceptifelifelse,作为守卫来修改一组语句如何改变计算状态。类似地,像forwhile这样的语句包装了一块语句,以便这些语句可以重复地改变计算的状态。然而,所有这些不同类型的语句的重点都在于改变变量的状态。

理想情况下,每个语句都会从初始条件推进计算状态,朝着期望的最终结果。这个“推进计算”断言可能很难证明。一种方法是定义最终状态,找到一个语句来建立这个最终状态,然后推断出这个最终语句需要的前提条件。这个设计过程可以迭代,直到得出一个可接受的初始状态。

在函数式语言中,我们用函数的评估来替换状态——变量的变化值。每个函数评估都会从现有对象创建一个新对象或多个对象。由于函数式程序是函数的组合,我们可以设计易于理解的低级函数,并且我们将设计更高级的组合,这些组合也可以比复杂的语句序列更容易可视化。

函数评估更接近数学形式主义。因此,我们经常可以使用简单的代数来设计一个算法,清楚地处理边界情况和边界条件。这使我们更有信心函数能够正常工作。它还使得很容易找到正式单元测试的测试用例。

重要的是要注意,与命令式(面向对象或过程式)程序相比,函数式程序往往相对简洁、表达力强、高效。这种好处并不是自动的;它需要仔细的设计。这种设计工作通常比功能上类似的过程式编程更容易。

将过程式范例细分

我们可以将命令式语言细分为许多离散的类别。在本节中,我们将快速浏览过程式与面向对象的区别。重要的是要看到面向对象编程是命令式编程的一个子集。过程式和面向对象的区别并不反映函数式编程所代表的基本差异。

我们将使用代码示例来说明这些概念。对于一些人来说,这将感觉像是重复造轮子。对于其他人来说,这提供了对抽象概念的具体表达。

对于某些计算,我们可以忽略 Python 的面向对象特性,编写简单的数值算法。例如,我们可以编写类似以下内容来获取数字范围:

s = 0
for n in range(1, 10):
    if n % 3 == 0 or n % 5 == 0:
        s += n
print(s)

我们使这个程序严格过程化,避免了对 Python 对象特性的显式使用。程序的状态由变量sn的值定义。变量n取值使得 1 ≤ n < 10。由于loop涉及对n值的有序探索,我们可以证明当n == 10时它将终止。类似的代码也可以在 C 或 Java 中使用它们的原始(非对象)数据类型。

我们可以利用Python面向对象编程(OOP)特性,创建一个类似的程序:

m = list()
for n in range(1, 10):
    if n % 3 == 0 or n % 5 == 0:
        m.append(n)
print(sum(m))

这个程序产生了相同的结果,但它在进行过程中积累了一个有状态的集合对象m。计算的状态由变量mn的值定义。

m.append(n)sum(m)的语法可能会让人感到困惑。这导致一些程序员错误地坚持认为 Python 在某种程度上不是纯粹的面向对象,因为它混合了function()object.method()的语法。请放心,Python 是纯粹的面向对象。一些语言,比如C++,允许使用诸如intfloatlong之类的原始数据类型,这些类型不是对象。Python 没有这些原始类型。前缀语法的存在并不改变语言的本质。

要严谨一些,我们可以完全拥抱对象模型,子类,list类,并添加一个sum方法:

class SummableList(list):
    def sum( self ):
        s= 0
        for v in self.__iter__():
            s += v
        return s

如果我们使用SummableList()类而不是list()方法初始化变量m,我们可以使用m.sum()方法而不是sum(m)方法。这种改变有助于澄清 Python 确实是完全面向对象的想法。前缀函数表示法的使用纯粹是一种语法糖。

这三个例子都依赖于变量来明确显示程序的状态。它们依赖于assignment语句来改变变量的值,并推进计算向完成的方向。我们可以在这些例子中插入assert语句来证明预期的状态变化被正确实现。

重点不在于命令式编程在某种程度上有问题。重点在于函数式编程导致了观点的改变,这在许多情况下可能非常有帮助。我们将展示相同算法的函数视图。函数式编程并不会使这个例子变得更短或更快。

使用功能范式

从功能的角度来看,3 和 5 的倍数之和可以分为两部分:

  • 一系列数字的和

  • 一系列通过简单测试条件的值,例如,是 3 和 5 的倍数

序列的和有一个简单的递归定义:

def sum(seq):
    if len(seq) == 0: return 0
    return seq[0] + sum(seq[1:])

我们已经定义了两种情况下序列的和:基本情况表明长度为零的序列的和为 0,而递归情况表明序列的和是第一个值加上剩余序列的和。由于递归定义依赖于一个更短的序列,我们可以确定它最终会退化为基本情况。

在前面例子的最后一行上的+运算符和基本情况中的初始值 0 将方程式刻画为一个和。如果我们将运算符改为*并将初始值改为 1,它同样可以轻松地计算出一个乘积。我们将在接下来的章节中回到这个泛化的简单想法。

同样,一系列值可以有一个简单的递归定义,如下:

def until(n, filter_func, v):
    if v == n: return []
    if filter_func(v): return [v] + until( n, filter_func, v+1 )
    else: return until(n, filter_func, v+1)

在这个函数中,我们将给定的值v与上限n进行比较。如果v达到上限,结果列表必须为空。这是给定递归的基本情况。

给定的filter_func()函数定义了另外两种情况。如果v的值被filter_func()函数传递,我们将创建一个非常小的列表,包含一个元素,并将until()函数的剩余值附加到这个列表。如果v的值被filter_func()函数拒绝,这个值将被忽略,结果将简单地由until()函数的剩余值定义。

我们可以看到v的值将从初始值增加,直到达到n,确保我们很快就会达到基本情况。

以下是我们如何使用until()函数来生成 3 或 5 的倍数。首先,我们将定义一个方便的lambda对象来过滤值:

mult_3_5= lambda x: x%3==0 or x%5==0

(我们将使用 lambda 来强调简单函数的简洁定义。任何比一行表达式更复杂的东西都需要def语句。)

我们可以从命令提示符中看到这个 lambda 是如何工作的,以下是一个例子:

>>> mult_3_5(3)
True
>>> mult_3_5(4)
False
>>> mult_3_5(5)
True

这个函数可以与until()函数一起使用,生成一系列值,这些值是 3 或 5 的倍数。

until()函数用于生成一系列值的工作如下:

>>> until(10, lambda x: x%3==0 or x%5==0, 0)
[0, 3, 5, 6, 9]

我们可以使用我们的递归sum()函数来计算这些值的和。各种函数,如sum()until()mult_3_5()都被定义为简单的递归函数。这些值是在不恢复使用中间变量来存储状态的情况下计算出来的。

我们将在几个地方回到这个纯函数递归函数定义的思想。这里重要的是要注意,许多函数式编程语言编译器可以优化这些简单的递归函数。Python 无法进行相同的优化。

使用一个功能混合

我们将继续这个例子,使用前一个例子的大部分功能版本来计算 3 和 5 的倍数的和。我们的混合功能版本可能如下所示:

print( sum(n for n in range(1, 10) if n%3==0 or n%5==0) )

我们已经使用嵌套生成器****表达式来迭代一系列值并计算这些值的和。range(1, 10)方法是一个可迭代的,因此也是一种生成器表达式;它生成一系列值Using a functional hybrid。更复杂的表达式,n for n in range(1, 10) if n%3==0 or n%5==0,也是一个可迭代表达式。它产生一组值Using a functional hybrid。一个变量n绑定到每个值,更像是一种表达集合内容的方式,而不是计算状态的指示器。sum()函数消耗可迭代表达式,创建一个最终对象,23。

提示

一旦一个值绑定到变量上,绑定变量就不会改变。循环中的变量n本质上是range()函数可用值的简写。

表达式的if子句可以提取到一个单独的函数中,这样我们就可以轻松地将其用于其他规则。我们还可以使用一个名为filter()的高阶函数,而不是生成器表达式的if子句。我们将这个留到第五章,高阶函数

当我们使用生成器表达式时,我们会发现绑定变量处于定义计算状态的模糊边缘。在这个例子中,变量n并不直接可比较前两个命令式例子中的变量nfor语句在本地命名空间中创建一个适当的变量。生成器表达式不会像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

由于 Python 使用命名空间的方式,可能可以编写一个函数来观察生成器表达式中的n变量。但我们不会这样做。我们的目标是利用 Python 的函数式特性,而不是检测这些特性在底层是如何实现的。

看看对象的创建

在某些情况下,查看中间对象作为计算的历史可能有所帮助。重要的是计算的历史并不是固定的。当函数是可交换的或者是可结合的时,对评估顺序的更改可能导致创建不同的对象。这可能会在不改变结果的正确性的情况下带来性能改进。

考虑这个表达式:

>>> 1+2+3+4
10

我们正在研究多种潜在的计算历史,但结果相同。因为+运算符是可交换和可结合的,有大量的候选历史可以导致相同的结果。

在候选序列中,有两个重要的替代方案,如下所示:

>>> ((1+2)+3)+4
10
>>> 1+(2+(3+4))
10

在第一种情况下,我们从左到右折叠值。这是 Python 隐式工作的方式。中间对象 3 和 6 是作为这个评估的一部分创建的。

在第二种情况下,我们从右到左折叠。在这种情况下,中间对象 7 和 9 被创建。在简单的整数算术情况下,这两个结果的性能是相同的;没有优化的好处。

当我们使用类似list append 的东西时,当我们改变关联规则时,可能会看到一些优化改进。

这里有一个简单的例子:

>>> import timeit
>>> timeit.timeit("((([]+[1])+[2])+[3])+[4]")
0.8846941249794327
>>> timeit.timeit("[]+([1]+([2]+([3]+[4])))")
1.0207440659869462

在这种情况下,从左到右工作有一些好处。

对于函数式设计来说,重要的是+运算符(或add()函数)可以以任何顺序使用来产生相同的结果。+运算符没有隐藏的副作用,限制了该运算符的使用方式。

乌龟的堆栈

当我们使用 Python 进行函数式编程时,我们踏上了一条不严格函数式的混合路径。Python 不是 Haskell、OCaml 或 Erlang。同样,我们的底层处理器硬件也不是函数式的;它甚至不是严格的面向对象的——CPU 通常是过程式的。

所有编程语言都依赖于抽象、库、框架和虚拟机。这些抽象反过来又可能依赖于其他抽象、库、框架和虚拟机。最恰当的比喻是:世界是由一只巨大的乌龟背负着的。这只乌龟站在另一只巨大的乌龟背上。而那只乌龟又站在另一只乌龟的背上。**这是无穷无尽的乌龟。
--匿名


抽象层次没有实际的终点。

更重要的是,抽象和虚拟机的存在并不会实质性地改变我们利用 Python 的函数式编程特性来设计软件的方法。

即使在函数式编程社区内,也有更纯净和不那么纯净的函数式编程语言。一些语言广泛使用monads来处理像文件系统输入和输出这样的有状态的事物。其他语言依赖于类似于我们使用 Python 的混合环境。我们编写的软件通常是功能性的,但有精心选择的程序性例外。

我们的函数式 Python 程序将依赖以下三个抽象层次的堆栈:

  • 我们的应用程序将是函数——一直到最后都是对象

  • 支持我们函数式编程的底层 Python 运行时环境是对象——一直到最后都是对象

  • 支持 Python 的库是 Python 所依赖的一个对象

操作系统和硬件构成了它们自己的一堆对象。这些细节与我们要解决的问题无关。

函数式编程的一个经典例子

作为我们介绍的一部分,我们将看一个函数式编程的经典例子。这是基于约翰·休斯的经典论文为什么函数式编程很重要。这篇文章发表在一篇名为Research Topics in Functional Programming的论文中,由 D. Turner 编辑,1990 年由 Addison-Wesley 出版。

这是一篇关于函数式编程的论文Research Topics in Functional Programming的链接:

www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf

这篇关于函数式编程的讨论是深刻的。论文中给出了几个例子。我们只看一个:用于定位函数的根的牛顿-拉弗森算法。在这种情况下,函数是平方根。

这很重要,因为这个算法的许多版本都依赖于通过“循环”管理的显式状态。事实上,休斯的论文提供了一段强调有状态的命令式处理的Fortran代码片段。

这个近似的基础是从当前近似值计算下一个近似值。next_()函数接受x,一个sqrt(n)方法的近似值,并计算一个下一个值,该值将包围正确的根。看下面的例子:

def next_(n, x):
 **return (x+n/x)/2

这个函数计算一系列值。每次值之间的距离减半,所以它们很快就会收敛到这样一个值,使得A classic example of functional programming,这意味着A classic example of functional programming。我们不想调用next()方法,因为这个名称会与内置函数冲突。我们将其称为next_()方法,以便我们尽可能地遵循原始的演示。

当在命令提示符中使用该函数时,它的样子是这样的:

>>> 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,它将收敛到A classic example of functional programming。我们从 1.0 开始作为A classic example of functional programming的初始值。然后我们评估了一系列递归评估:A classic example of functional programmingA classic example of functional programming等等。我们使用生成器表达式来评估这些函数,这样我们可以四舍五入每个值。这样输出更容易阅读,并且更容易与doctest一起使用。这个序列似乎很快就会收敛到A classic example of functional programming

我们可以编写一个函数,它(原则上)会生成一个无限序列的值,这些值会收敛到正确的平方根:

def repeat(f, a):
 **yield a
 **for v in repeat(f, f(a)):
 **yield v

这个函数将使用函数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会比等价的映射或生成器表达式更清晰。

当然,我们不希望整个无限序列。当两个值非常接近时,我们将停止生成值,这样我们可以称其中一个为我们正在寻找的平方根。接近的值的常用符号是希腊字母Epsilonε,它可以被认为是我们将容忍的最大误差。

在 Python 中,我们需要对从无限序列中取出项的方法进行一些巧妙的处理。使用一个简单的接口函数来包装稍微复杂的递归是很好的。看一下以下代码片段:

def within(ε, iterable):
 **def head_tail(ε, a, iterable):
 **b= next(iterable)
 **if abs(a-b) <= ε: return b
 **return head_tail(ε, b, iterable)
 **return head_tail(ε, next(iterable), iterable)

我们定义了一个内部函数head_tail(),它接受公差ε、可迭代序列中的一个项a和可迭代序列的其余部分iterable。从iterable中绑定到名称b的下一个项。如果A classic example of functional programming,那么这两个值足够接近,我们已经找到了平方根。否则,我们使用b值在head_tail()函数的递归调用中来检查下一对值。

我们的within()函数仅仅是为了正确地使用iterable参数中的第一个值来初始化内部的head_tail()函数。

一些函数式编程语言提供了一种将值放回到可迭代序列中的技术。在 Python 中,这可能是一种将值放回迭代器的unget()previous()方法。Python 的可迭代对象并不提供这种丰富的功能。

我们可以使用next_()repeat()within()这三个函数来创建一个平方根函数,如下所示:

def sqrt(a0, ε, n):
 **return within(ε, repeat(lambda x: next_(n,x), a0))

我们使用repeat()函数基于next_(n,x)函数生成了一个(可能是)无限的值序列。当我们的within()函数找到两个差值小于ε的值时,它将停止生成序列中的值。

当我们使用这个版本的sqrt()方法时,我们需要提供一个初始种子值a0和一个ε值。像sqrt(1.0, .0001, 3)这样的表达式将从 1.0 的近似值开始,并计算出A classic example of functional programming的值,精确到 0.0001。对于大多数应用程序,初始的a0值可以是 1.0。然而,它越接近实际的平方根,这种方法收敛得越快。

这种近似算法的原始示例是在 Miranda 语言中展示的。很容易看出 Miranda 和 Python 之间有一些深刻的区别。最大的区别是 Miranda 能够构造cons,将一个值返回到iterable,做一种unget。Miranda 和 Python 之间的这种并行性使我们相信,许多种类的函数式编程可以在 Python 中轻松完成。

探索性数据分析

在本书的后面,我们将以探索性数据分析领域为具体例子,详细介绍函数式编程。这个领域充满了处理复杂数据集的算法和方法;函数式编程通常是问题领域和自动化解决方案之间非常好的契合。

尽管细节因作者而异,但探索性数据分析通常包括以下几个广泛接受的阶段:

  • 数据准备:这可能涉及从源应用程序中提取和转换。它可能涉及解析源数据格式并进行某些类型的数据清洗,以删除不可用或无效的数据。这是功能设计技术的一个很好的应用。

  • 数据探索:这是对可用数据的描述。这通常涉及基本的统计函数。这是另一个探索函数式编程的绝佳场所。我们可以将我们的重点描述为一元和二元统计,但这听起来太艰难和复杂了。这实际上意味着我们将专注于均值、中位数、众数和其他相关的描述统计。数据探索也可能涉及数据可视化。我们将绕过这个问题,因为它并不涉及太多的函数式编程。我建议您使用像SciPy这样的工具包。

访问以下链接,了解 SciPY 的工作原理和用法:

www.packtpub.com/big-data-and-business-intelligence/learning-scipy-numerical-and-scientific-computingwww.packtpub.com/big-data-and-business-intelligence/learning-python-data-visualization

  • 数据建模和机器学习:这往往是规定性的,因为它涉及将模型扩展到新数据。我们将绕过这一点,因为一些模型可能在数学上变得复杂。如果我们在这些主题上花费太多时间,就无法专注于函数式编程。

  • 评估和比较:当存在替代模型时,必须评估每个模型,以确定哪个更适合可用数据。这可能涉及模型输出的普通描述统计。这可以从功能设计技术中受益。

EDA 的目标通常是创建一个可以部署为决策支持应用程序的模型。在许多情况下,模型可能是一个简单的函数。简单的函数式编程方法可以将模型应用于新数据,并显示结果供人类消费。

总结

我们已经从编程范式的角度看了函数式范式与两种常见的命令式范式的区别。我们在本书中的目标是探索 Python 的函数式编程特性。我们注意到 Python 的一些部分不允许纯粹的函数式编程;我们将使用一些混合技术,将简洁、表达丰富的函数式编程特性与 Python 中的一些高性能优化相结合。

在下一章中,我们将详细介绍五种特定的函数式编程技术。这些技术将构成我们在 Python 中混合使用的函数式编程的基本基础。

第二章:介绍一些函数式特性

函数式编程的大多数特性已经是 Python 的一等部分。我们在编写函数式 Python 时的目标是尽可能地将我们的注意力从命令式(过程式或面向对象)技术转移。

我们将研究以下每个函数式编程主题:

  • 一等和高阶函数,也称为纯函数。

  • 不可变数据。

  • 严格和非严格评估。我们也可以称之为急切 vs. 懒惰评估。

  • 递归而不是显式循环状态。

  • 函数式类型系统。

这应该重申第一章的一些概念。首先,纯函数式编程避免了通过变量赋值维护显式状态的复杂性。其次,Python 不是一个纯函数式语言。

我们不提供对函数式编程的严格定义。相反,我们将找到一些不容置疑重要的共同特征。我们将避开模糊的边缘。

一等函数

函数式编程通常简洁而富有表现力。实现这一点的一种方法是将函数作为其他函数的参数和返回值。我们将看到许多操纵函数的例子。

为了使这个工作,函数必须是运行时环境中的一等对象。在诸如 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__对象有它自己的属性。实现细节并不重要。重要的是函数是一等对象,可以像所有其他对象一样被操纵。我们之前显示了函数对象的许多属性中的两个值。

纯函数

为了表达,函数在函数式编程设计中使用将不受副作用创建的混乱的影响。使用纯函数也可以通过改变评估顺序来实现一些优化。然而,最大的优势来自于纯函数在概念上更简单,更容易测试。

要在 Python 中编写纯函数,我们必须编写仅限于本地的代码。这意味着我们必须避免使用global语句。我们需要仔细查看任何使用nonlocal;虽然它是另一个作用域中的副作用,但它局限于nested函数定义。这是一个容易满足的标准。纯函数是 Python 程序的一个常见特性。

没有一种简单的方法来保证 Python 函数没有副作用。很容易粗心地违反纯函数规则。如果我们想担心我们能否遵循这个规则,我们可以编写一个使用dis模块扫描给定函数的__code__.co_code编译代码的全局引用的函数。它还可以报告内部闭包的使用,以及__code__.co_freevars tuple方法。这是一个对一个罕见问题的相当复杂的解决方案;我们不会进一步追求它。

Python 的lambda是一个纯函数。虽然这不是一个高度推荐的风格,但通过lambda值可以创建纯函数。

这是通过将lambda分配给变量创建的一个函数:

>>> mersenne = lambda x: 2**x-1
>>> mersenne(17)
131071

我们使用lambda创建了一个纯函数,并将其分配给变量mersenne。这是一个可调用对象,具有一个参数值,返回一个值。因为 lambda 不能有赋值语句,它们总是纯函数,适用于函数式编程。

高阶函数

我们可以使用高阶函数实现富有表现力、简洁的程序。这些函数接受一个函数作为参数,或者返回一个函数作为值。我们可以使用高阶函数来从简单的函数中创建复合函数。

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

默认行为是简单地比较序列中的每个tuple。这将返回在位置 0 上具有最大值的tuple

由于max()函数是一个高阶函数,我们可以提供另一个函数作为参数。在这种情况下,我们将使用lambda作为函数;这将被max()函数使用,如下所示:

>>> max(year_cheese, key=lambda yc: yc[1])
(2007, 33.5)

在这个例子中,max()函数应用了提供的lambda,并返回了位置 1 中最大值的元组。

Python 提供了丰富的高阶函数集合。我们将在后面的章节中看到 Python 的每个高阶函数的示例,主要在第五章中,高阶函数。我们还将看到如何轻松地编写我们自己的高阶函数。

不可变数据

由于我们不使用变量来跟踪计算的状态,我们的重点需要放在不可变对象上。我们可以广泛使用tuplesnamedtuples来提供更复杂的不可变数据结构。

不可变对象的概念对 Python 并不陌生。使用不可变的tuples而不是更复杂的可变对象可能会带来性能优势。在某些情况下,好处来自于重新思考算法,以避免对象变异的成本。

我们将几乎完全避免类定义。在面向对象(OOP)语言中避免对象似乎是一种厌恶。函数式编程根本不需要有状态的对象。我们将在本书中看到这一点。有理由定义callable对象;这是一种为密切相关的函数提供namespace的整洁方式,并且支持愉快的可配置性。

我们将看一个与不可变对象很好配合的常见设计模式:wrapper()函数。元组列表是一种相当常见的数据结构。我们经常以以下两种方式之一处理这个元组列表:

  • 使用高阶函数:如前所示,我们将lambda作为max()函数的参数提供:max(year_cheese, key=lambda yc: yc[1])

  • 使用 Wrap-Process-Unwrap 模式:在一个函数上下文中,我们应该称之为unwrap(process(wrap(structure)))模式

例如,看下面的命令片段:

>>> max(map(lambda yc: (yc[1],yc), year_cheese))
(33.5, (2007, 33.5))
>>> _[1]
(2007, 33.5)

这符合三部分模式,尽管它可能不明显地符合得很好。

首先,我们使用map(lambda yc: (yc[1],yc), year_cheese)进行包装。这将把每个项目转换成一个带有原始项目后面的两个元组。在这个例子中,比较键仅仅是yc[1]

其次,使用max()函数进行处理。由于每个数据片段都被简化为一个用于比较的两个元组,我们实际上不需要max()函数的高阶函数特性。max()函数的默认行为正是我们需要的。

最后,我们使用下标[1]进行解包。这将选择max()函数选定的两个元组中的第二个元素。

这种wrapunwrap是如此常见,以至于一些语言有特殊的函数,名称如fst()snd(),我们可以使用作为函数前缀,而不是语法后缀[0][1]。我们可以使用这个想法来修改我们的 wrap-process-unwrap 示例,如下:

snd= lambda x: x[1]
snd( max(map(lambda yc: (yc[1],yc), year_cheese)))

我们定义了一个snd()函数来选择元组中的第二个项目。这为我们提供了一个更易读的版本unwrap(process(wrap()))。我们使用map(lambda... , year_cheese)wrap我们的原始数据项。我们使用max()函数作为处理,最后使用snd()函数从元组中提取第二个项目。

在第十三章中,条件表达式和操作符模块,我们将看一些替代lambda函数的选择,比如fst()snd()

严格和非严格评估

函数式编程的效率部分来自于能够推迟计算直到需要。懒惰或非严格评估的想法非常有帮助。它是如此有帮助,以至于 Python 已经提供了这个特性。

在 Python 中,逻辑表达式运算符andorif-then-else都是非严格的。我们有时称它们为短路运算符,因为它们不需要评估所有参数来确定结果值。

以下命令片段显示了and运算符的非严格特性:

>>> 0 and print("right")
0
>>> True and print("right")
right

当我们执行上述命令片段时,and运算符的左侧等同于False;右侧不会被评估。当左侧等同于True时,右侧会被评估。

Python 的其他部分是严格的。在逻辑运算符之外,表达式会从左到右急切地进行评估。一系列的语句行也会按顺序严格进行评估。Literal列表和元组需要急切的评估。

当一个类被创建时,方法函数是按严格顺序定义的。在类定义的情况下,方法函数被收集到一个字典中(默认情况下),并且在创建后不保持顺序。如果我们提供了两个同名的方法,第二个方法会被保留,因为严格的评估顺序。

然而,Python 的生成器表达式和生成器函数是懒惰的。这些表达式不会立即创建所有可能的结果。如果不明确记录计算的细节,很难看到这一点。这是range()函数的一个版本的例子,它具有显示它创建的数字的副作用:

>>> def numbers():
...    for i in range(1024):
...        print( "=", i )
...        yield i

如果这个函数是急切的,它会创建所有 1024 个数字。由于它是懒惰的,它只在被请求时创建数字。

注意

旧的 Python 2 range()函数是急切的,并创建了一个包含所有请求的数字的实际列表对象。Python 2 有一个xrange()函数,它是懒惰的,并且与 Python 3 的range()函数的语义相匹配。

我们可以以一种显示懒惰评估的方式使用这个喧闹的numbers()函数。我们将编写一个函数,评估这个迭代器的一些值,但不是全部值:

>>> def sum_to(n):
...    sum= 0
...    for i in numbers():
...        if i == n: break
...        sum += i
...    return sum

sum_to()函数不会评估numbers()函数的整个结果。它只在消耗了numbers()函数的一些值后中断。我们可以在以下日志中看到对值的消耗:

>>> sum_to(5)
= 0
= 1
= 2
= 3
= 4
= 5
10

正如我们将在后面看到的,Python 生成器函数具有一些特性,使它们在简单的函数式编程中有些尴尬。具体来说,生成器在 Python 中只能使用一次。我们必须小心使用懒惰的 Python 生成器表达式。

递归而不是显式循环状态

函数式程序不依赖于循环和跟踪循环状态的相关开销。相反,函数式程序试图依赖于递归函数的更简单的方法。在一些语言中,程序被写成递归,但是编译器的尾递归优化TCO)将它们改为循环。我们将在第六章中介绍一些递归,并对其进行仔细的检查。

我们将看一个简单的迭代来测试一个数是否为质数。质数是一个自然数,只能被 1 和它自己整除。我们可以创建一个天真且性能不佳的算法来确定一个数在 2 和该数之间是否有任何因子。这个算法的优点是简单;它可以用来解决Project Euler问题。阅读Miller-Rabin素性测试,以获得一个更好的算法。

我们将使用术语互质来表示两个数只有 1 作为它们的公因数。例如,数字 2 和 3 是互质的。然而,数字 6 和 9 不是互质的,因为它们有 3 作为公因数。

如果我们想知道一个数n是否是质数,我们实际上是在问:数字n是否与所有质数p互质,使得递归而不是显式循环状态。我们可以简化这个问题,使用所有整数p,使得递归而不是显式循环状态

有时,将其形式化如下有所帮助:

递归而不是显式循环状态

在 Python 中,表达式可能如下所示:

not any(n%p==0 for p in range(2,int(math.sqrt(n))+1))

从数学形式转换为 Python 的更直接的转换将使用all(n%p != 0... ),但这需要严格评估所有值pnot any版本可以在找到True值时提前终止。

这个简单的表达式中有一个for循环:它不是无状态的函数式编程的纯例子。我们可以将其重新构建为一个处理值集合的函数。我们可以询问数字n是否在范围递归而不是显式循环状态内是互质的。这使用符号)来显示半开区间:包括较小的值,不包括较大的值。这是 Python range()函数的典型行为。我们还将限制自己在自然数的域内。例如,平方根值被隐式地截断为整数

我们可以将质数的定义看作是以下内容:

![递归而不是显式循环状态当在一系列简单的值上定义递归函数时,基本情况可以是一个空范围。非空范围通过处理一个值和一个比一个值更窄的范围来递归处理。我们可以将其形式化如下:递归而不是显式循环状态

通过检查以下两种情况,可以相对容易地确认这个版本:

  • 如果范围为空,递归而不是显式循环状态,我们会评估类似于:递归而不是显式循环状态。范围不包含任何值,因此返回值是一个微不足道的True

  • 如果范围不为空,我们会询问类似于递归而不是显式循环状态。这可以分解为递归而不是显式循环状态。对于这个例子,我们可以看到第一个子句是True,我们将递归地评估第二个子句。

作为读者的练习:可以重新定义这个递归,使其递减而不是递增,在第二种情况下使用a,b-1)

顺便说一句,有些人喜欢将空区间视为ab,而不是a=b。这是不必要的,因为a每次增加 1,我们可以很容易地保证ab,最初。没有办法让a通过函数中的某个错误跳过b;我们不需要过度指定空区间的规则。

以下是一个实现这个质数定义的 Python 代码片段:

def isprimer(n):
    def isprime(k, coprime):
        """Is k relatively prime to the value coprime?"""
        if k < coprime*coprime: return True
        if k % coprime == 0: return False
        return isprime(k, coprime+2)
    if n < 2: return False
    if n == 2: return True
    if n % 2 == 0: return False
    return isprime(n, 3)

这显示了一个isprime()函数的递归定义。半开区间递归而不是显式循环状态被缩减为只有低端参数a,在这个函数中被重命名为coprime以澄清其目的。基本情况被实现为n < coprime*coprime;从coprime1+math.sqrt(n)的值范围将为空。

非严格的and操作是通过将其拆分成一个单独的if语句来实现的,if n % coprime == 0return语句是具有不同coprime测试值的递归调用。

因为递归是函数的尾部,这是尾递归的一个例子。这个函数嵌入在一个函数中,该函数建立了n是一个大于 2 的奇数的边界条件。没有必要测试任何偶数是否为质数,因为 2 是唯一的偶数质数。

在这个例子中重要的是,这个递归函数的两种情况设计起来非常简单。将值范围作为内部isprime()函数的显式参数允许我们以反映不断缩小的区间的参数值递归调用函数。

虽然这通常非常简洁和富有表现力,但我们在使用 Python 中的递归时必须要小心。出现了两个问题。它们如下所述:

  • Python 对递归函数施加了递归限制,以检测具有不正确定义基本情况的递归函数。

  • Python 确实有一个编译器来进行尾调用优化(TCO)。

默认的递归限制是 1,000,对于许多算法来说是足够的。可以使用sys.setrecursionlimit()函数来更改这个限制。提高这个限制并不明智,因为这可能会导致超出操作系统的内存限制,并导致 Python 解释器崩溃。

如果我们尝试在一个超过 1,000,000 的数字上使用递归的isprimer()函数,我们将违反递归限制。如果我们使用了一个更聪明的isprimer()函数,它只检查质因数而不是所有因数,我们将在第 1,000 个质数 7,919 处停止,将我们的质数测试限制在 62,710,561 以下的数字。

一些函数式编程语言可以优化简单的递归函数,比如我们的isprimer()函数。优化编译器可以将isprimer(n, coprime+1)方法的递归评估转换为低开销的循环。优化往往会使调用堆栈混乱;调试优化程序变得困难。Python 不执行这种优化。性能和内存被牺牲以换取清晰和简单。

在 Python 中,当我们使用生成器表达式而不是递归函数时,我们实质上是手动进行尾调用优化。我们不依赖于某些函数式语言的编译器来进行这种优化。这是作为生成器表达式完成的 TCO:

def isprime(p):
    if p < 2: 
        return False    
    if p == 2: 
        return True    
    if p % 2 == 0: 
        return False    
    return not any(p==0 for p in range(3,int(math.sqrt(n))+1,2))

这个函数包含了许多函数式编程原则,但它使用了生成器表达式而不是纯递归。### 提示我们经常会优化一个纯递归函数,使用显式的for loop来进行生成器表达式。

这个算法对于大质数来说很慢。对于合数,该函数通常会快速返回一个值。如果用于像递归而不是显式循环状态这样的值,它将花费几分钟来证明这是质数。显然,慢的原因在于检查 1,518,500,249 个候选因子。

函数类型系统

一些函数式编程语言,如HaskellScala,是静态编译的,并依赖于声明的函数和它们的参数的类型。为了提供 Python 已经具有的灵活性,这些语言具有复杂的类型匹配规则,以便编写一个通用函数,可以适用于各种相关类型。

在面向对象的 Python 中,我们经常使用类继承层次结构,而不是复杂的函数类型匹配。我们依赖 Python 根据简单的名称匹配规则将运算符分派给适当的方法。

由于 Python 已经具有所需的灵活性,编译函数语言的类型匹配规则并不相关。事实上,我们可以说,复杂的类型匹配是静态编译强加的一种变通方法。Python 不需要这种变通方法,因为它是一种动态语言。

在某些情况下,我们可能不得不诉诸于使用isinstance(a, tuple)来检测参数值是tuple还是单个值。这在函数式程序中和面向对象程序中一样罕见。

熟悉的领域

从前面的主题列表中出现的一个想法是,大多数函数式编程已经存在于 Python 中。事实上,大多数函数式编程已经是面向对象编程的一个非常典型和常见的部分。

作为一个非常具体的例子,一个流利的应用程序接口API)是函数式编程的一个非常明显的例子。如果我们花时间创建一个类,在每个方法函数中都有return self(),我们可以这样使用它:

some_object.foo().bar().yet_more()

我们可以很容易地编写几个密切相关的函数,其工作如下:

yet_more(bar(foo(some_object)))

我们已经将语法从传统的面向对象的后缀表示法切换到了更具功能性的前缀表示法。Python 自由地使用这两种表示法,通常使用特殊方法名的前缀版本。例如,len()函数通常由类的__len__()特殊方法实现。

当然,上面显示的类的实现可能涉及高度状态化的对象。即使如此,观点上的微小变化可能会揭示出一个功能性的方法,可以导致更简洁或更表达的编程。

重点不是命令式编程在某种程度上有问题,或者函数式编程提供了如此大幅度的优越技术。重点是函数式编程导致了一种观点的改变,这在许多情况下可能非常有帮助。

保存一些高级概念

我们将把一些更高级的概念放在一边,以便在以后的章节中考虑。这些概念是纯函数语言的实现的一部分。由于 Python 不是纯函数的,我们的混合方法不需要深入考虑这些主题。

我们将提前识别这些内容,以使那些已经了解 Haskell 等函数语言并学习 Python 的人受益。这些基本问题存在于所有编程语言中,但我们将在 Python 中以不同的方式处理它们。在许多情况下,我们可以并且将会转入命令式编程,而不是使用严格的函数式方法。

主题如下:

  • 引用透明度:当看到惰性评估和编译语言中可能的各种优化时,多条路径指向同一对象的想法是重要的。在 Python 中,这并不重要,因为没有相关的编译时优化。

  • 柯里化:类型系统将使用柯里化将多参数函数减少为单参数函数。我们将在第十一章装饰器设计技术中深入研究柯里化。

  • 单子:这些是纯函数构造,允许我们以灵活的方式结构化顺序处理管道。在某些情况下,我们将求助于命令式 Python 来实现相同的目标。我们还将利用优雅的PyMonad库。我们将把这个推迟到第十四章PyMonad 库

总结

在本章中,我们确定了一些特征,这些特征表征了函数式编程范式。我们从头等和高阶函数开始。这个想法是一个函数可以是另一个函数的参数或函数的结果。当函数成为额外编程的对象时,我们可以编写一些非常灵活和通用的算法。

在命令式和面向对象的编程语言(如 Python)中,不可变数据的概念有时会显得奇怪。然而,当我们开始专注于函数式编程时,我们会看到状态变化可能会令人困惑或无益的方式。使用不可变对象可以是一个有益的简化。

Python 专注于严格评估:所有子表达式都通过语句从左到右进行评估。然而,Python 确实执行一些非严格评估。orandif-else逻辑运算符是非严格的:并非一定要评估所有子表达式。同样,生成器函数也是非严格的。我们也可以称之为急切与懒惰。Python 通常是急切的,但我们可以利用生成器函数来实现惰性评估。

虽然函数式编程依赖于递归而不是显式的循环状态,但 Python 在这方面施加了一些限制。由于堆栈限制和缺乏优化编译器,我们被迫手动优化递归函数。我们将在第六章递归和归约中回到这个话题。

尽管许多函数式语言拥有复杂的类型系统,但我们将依赖于 Python 的动态类型解析。在某些情况下,这意味着我们将不得不在类型之间进行手动转换。这也可能意味着我们将不得不创建类定义来处理非常复杂的情况。然而,在大多数情况下,Python 的内置规则将非常优雅地工作。

在下一章中,我们将探讨纯函数的核心概念以及这些概念如何与 Python 的内置数据结构配合。有了这个基础,我们可以看看 Python 中可用的高阶函数以及如何定义我们自己的高阶函数。

第三章:函数、迭代器和生成器

函数式编程的核心是使用纯函数将值从输入域映射到输出范围。纯函数没有副作用,在 Python 中相对容易实现。

避免副作用也意味着减少我们对变量赋值来维护计算状态的依赖。我们无法从 Python 语言中清除赋值语句,但我们可以减少对有状态对象的依赖。这意味着我们需要在可用的 Python 内置数据结构中进行选择,选择那些不需要有状态操作的数据结构。

本章将从功能的角度介绍几个 Python 特性,如下所示:

  • 无副作用的纯函数

  • 函数作为可以作为参数传递或作为结果返回的对象

  • 使用面向对象的后缀表示法和前缀表示法来使用 Python 字符串

  • 使用元组和命名元组来创建无状态对象的方法

  • 使用可迭代集合作为我们的主要功能编程设计工具

我们将研究生成器和生成器表达式,因为这些是处理对象集合的方法。正如我们在第二章中所指出的,介绍一些功能特性,在尝试用递归替换所有生成器表达式时会出现一些边界问题。Python 会强加递归限制,并且不会自动处理 TCO:我们必须使用生成器表达式手动优化递归。

我们将编写生成器表达式来执行以下任务:

  • 转换

  • 重构

  • 复杂计算

我们将快速调查许多内置的 Python 集合,以及在追求功能范式时如何使用集合。这可能会改变我们处理listsdictssets的方式。编写功能性的 Python 鼓励我们专注于元组和不可变集合。在下一章中,我们将强调更多与特定类型的集合一起工作的功能性方法。

编写纯函数

纯函数没有副作用:变量没有全局变化。如果我们避免使用global语句,我们几乎可以达到这个标准。我们还需要避免改变状态可变对象。我们将研究确保纯函数的这两个方面的几种方法。在 Python 全局中引用一个值,使用自由变量是我们可以重写为适当参数的。在大多数情况下,这是相当容易的。

这里有一个例子,解释了使用全局语句的用法:

 **def some_function(a, b, t):
 **return a+b*t+global_adjustment

我们可以重构这个函数,将global_adjustment变量变成一个适当的参数。我们需要改变对这个函数的每个引用,这可能会在一个复杂的应用程序中产生很大的连锁反应。全局引用将在函数体中作为自由变量可见。对于这个变量,既没有参数也没有赋值,因此可以清楚地看出它是全局的。

Python 中有许多内部对象,这些对象是有状态的。file类的实例和所有类似文件的对象都是常用的有状态对象的例子。我们观察到 Python 中最常用的有状态对象通常表现为上下文管理器。并非所有开发人员都使用可用的上下文管理器,但许多对象实现了所需的接口。在一些情况下,有状态对象并没有完全实现上下文管理器接口;在这些情况下,通常会有一个close()方法。我们可以使用contextlib.closing()函数为这些对象提供适当的上下文管理器接口。

我们无法轻易消除所有有状态的 Python 对象,除非是小型程序。因此,我们必须在利用函数式设计的优势的同时管理状态。为此,我们应该始终使用with语句将有状态的文件对象封装到一个明确定义的范围内。

提示

始终在with上下文中使用文件对象。

我们应该始终避免全局文件对象、全局数据库连接和相关的状态问题。全局文件对象是处理打开文件的非常常见的模式。我们可能有一个如下命令片段所示的函数:

def open(iname, oname):
 **global ifile, ofile
 **ifile= open(iname, "r")
 **ofile= open(oname, "w")

在这种情况下,许多其他函数可以使用ifileofile变量,希望它们正确地引用应用程序要使用的global文件,这些文件保持打开状态。

这不是一个很好的设计,我们需要避免它。文件应该是函数的适当参数,并且打开的文件应该嵌套在with语句中,以确保它们的有状态行为得到适当处理。

这种设计模式也适用于数据库。数据库连接对象通常可以作为应用程序函数的形式参数提供。这与一些流行的 Web 框架的工作方式相反,这些框架依赖于全局数据库连接,以使数据库成为应用程序的一个透明特性。此外,多线程 Web 服务器可能无法从共享单个数据库连接中受益。这表明使用功能设计和一些孤立的有状态特性的混合方法有一些好处。

函数作为一等对象

Python 函数是一等对象并不足为奇。在 Python 中,函数是带有许多属性的对象。参考手册列出了适用于函数的许多特殊成员名称。由于函数是带有属性的对象,我们可以使用特殊属性,如__doc____name__提取docstring函数或函数的名称。我们还可以通过__code__属性提取函数的主体。在编译语言中,由于需要保留源信息,这种内省相对复杂。在 Python 中,这很简单。

我们可以将函数分配给变量,将函数作为参数传递,并将函数作为值返回。我们可以轻松使用这些技术来编写高阶函数。

由于函数是对象,Python 已经具备了许多成为函数式编程语言所需的特性。

此外,可调用对象还帮助我们创建函数,这些函数是一等对象。我们甚至可以将可调用类定义视为高阶函数。我们需要谨慎地使用可调用对象的__init__()方法;我们应该避免设置有状态的类变量。一个常见的应用是使用__init__()方法创建符合策略设计模式的对象。

遵循策略设计模式的类依赖于另一个对象来提供算法或算法的部分。这允许我们在运行时注入算法细节,而不是将细节编译到类中。

以下是一个带有嵌入式策略对象的可调用对象的示例:

import collections
class Mersenne1(collections.Callable):
 **def __init__(self, algorithm):
 **self.pow2= algorithm
 **def __call__(self, arg):
 **return self.pow2(arg)-1

这个类使用__init__()保存对另一个函数的引用。我们没有创建任何有状态的实例变量。

作为策略对象给出的函数必须将 2 提升到给定的幂。我们可以将三个候选对象插入到这个类中,如下所示:

def shifty(b):
 **return 1 << b
def multy(b):
 **if b == 0: return 1
 **return 2*multy(b-1)
def faster(b):
 **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()函数使用分治策略,将执行函数作为一等对象次乘法,而不是b次乘法。

我们可以使用嵌入的策略算法创建Mersenne1类的实例,如下所示:

m1s= Mersenne1(shifty)
m1m= Mersenne1(multy)
m1f= Mersenne1(faster)

这显示了我们如何定义产生相同结果但使用不同算法的替代函数。

提示

Python 允许我们计算函数作为一等对象,因为这甚至没有接近 Python 的递归限制。这是一个非常大的质数,有 27 位数。

使用字符串

由于 Python 字符串是不可变的,它们是函数式编程对象的绝佳示例。Python 的string模块有许多方法,所有这些方法都会产生一个新的字符串作为结果。这些方法是没有副作用的纯函数。

string方法函数的语法是后缀的,其中大多数函数是前缀的。这意味着当它们与常规函数混合在一起时,复杂的字符串操作可能很难阅读。

在从网页中抓取数据时,我们可能会有一个更干净的函数,它对字符串应用多种转换以清除标点,并返回一个供应用程序其余部分使用的Decimal对象。这将涉及前缀和后缀语法的混合使用。

它可能看起来像以下命令片段:

from decimal import *
def clean_decimal(text):
 **if text is None: return text
 **try:
 **return Decimal(text.replace("$", "").replace(",", ""))
 **except InvalidOperation:
 **return text

此函数对字符串进行两次替换,以删除$,字符串值。生成的字符串被用作Decimal类构造函数的参数,该构造函数返回所需的对象。

为了使其更一致,我们可以考虑为string方法函数定义自己的前缀函数,如下所示:

def replace(data, a, b):
 **return data.replace(a,b)

这使我们可以使用具有一致外观的前缀语法Decimal(replace(replace(text, "$", ""), ",", ""))。在这种情况下,我们只是重新排列现有的参数值,允许我们使用额外的技术。我们可以对简单情况进行这样做,例如以下情况:

>>> replace=str.replace
>>> replace("$12.45","$","")

12.45

目前尚不清楚这种一致性是否比混合前缀和后缀符号的表示方式有重大改进。多参数函数的问题在于参数最终出现在表达式的各个位置。

一个稍微更好的方法可能是定义一个更有意义的前缀函数来去除标点,如下面的命令片段所示:

def remove( str, chars ):
 **if chars: return remove( str.replace(chars[0], ""), chars[1:] )
 **return str

此函数将递归地从char变量中删除每个字符。我们可以将其用作Decimal(remove(text, "$,")),以使我们的字符串清理意图更清晰。

使用元组和命名元组

由于 Python 元组是不可变对象,它们是适合函数式编程的另一个绝佳示例。Python 的“元组”几乎没有方法函数,因此几乎所有操作都是通过使用前缀语法的函数完成的。元组有许多用例,特别是在处理列表-元组、元组-元组和生成器-元组构造时。

当然,命名元组为元组添加了一个基本功能:我们可以使用名称而不是索引。我们可以利用命名元组来创建数据的堆积对象。这使我们能够编写基于无状态对象的纯函数,但仍然将数据绑定到整洁的对象包中。

我们几乎总是在值集合的上下文中使用元组(和命名元组)。如果我们处理单个值或精确两个值的整洁组,我们通常会将命名参数用作函数的参数。然而,在处理集合时,我们可能需要具有元组的可迭代对象或具有命名元组的可迭代对象。

使用“元组”或“命名元组”对象的决定完全是出于方便考虑。我们可能会将一系列值作为三元组(数字,数字,数字)的形式(假设三元组按照红色、绿色和蓝色的顺序排列)。

我们可以使用函数来拆分三元组,如下面的命令片段所示:

red = lambda color: color[0]
green = lambda color: color[1]
blue = lambda color: color[2]

或者,我们可以引入以下命令行:

Color = namedtuple("Color", ("red", "green", "blue", "name"))

这使我们可以使用item.red而不是red(item)

元组的函数式编程应用集中在可迭代元组设计模式上。我们将仔细研究一些可迭代元组技术。我们将在第七章中查看命名元组技术,其他元组技术

使用生成器表达式

我们已经展示了一些生成器表达式的示例。我们将在本章的后面展示更多示例。在本节中,我们将介绍一些更复杂的生成器技术。

我们需要在这里提到一小部分 Python 语法。通常会看到生成器表达式被用来通过list推导或dict推导创建listdict字面量。对于我们的目的,列表显示(或推导)只是生成器表达式的一种用法。我们可以尝试区分显示之外的生成器表达式和显示之内的生成器表达式,但这样做没有任何好处。语法是一样的,除了封闭的标点符号,语义是无法区分的。

显示包括封闭的文字语法:[x**2 for x in range(10)];这个例子是一个列表推导,它从封闭的生成器表达式创建一个列表对象。在本节中,我们将专注于生成器表达式。我们偶尔会创建一个显示,以演示生成器的工作原理。显示的缺点是创建(可能很大的)collection对象。生成器表达式是惰性的,只在需要时才创建对象。

我们必须提供关于生成器表达式的两个重要警告,如下:

  • 生成器看起来像是序列,除了像len()函数这样需要知道集合大小的函数。

  • 生成器只能使用一次。之后,它们会变为空。

这是一个我们将用于一些示例的生成器函数:

def pfactorsl(x):
 **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的所有因子。

如果我们无法找到一个因子,那么这个数字必须是质数,所以我们可以产出它。

我们将 2 作为一个特殊情况来处理,以减少迭代次数。除了 2 以外,所有的质数都是奇数。

我们除了使用递归之外,还使用了一个重要的for循环。这使我们能够轻松处理最多有 1,000 个因子的数字。这个数字至少和使用生成器表达式一样大,这是一个有 300 位数字的数字。由于for变量i在缩进的循环体之外没有被使用,i变量的有状态特性不会导致混淆,如果我们对循环体进行任何更改。

实际上,我们已经进行了尾递归优化,递归调用从 3 到使用生成器表达式for循环使我们免受深度递归调用的影响,这些调用会测试范围内的每个数字。

其他两个for循环只是为了消耗可迭代的递归函数的结果而存在。

提示

在递归生成器函数中,要小心 return 语句。

不要使用以下命令行:

return recursive_iter(args)

它只返回一个生成器对象;它不会评估函数以返回生成的值。使用以下任一种:

for result in recursive_iter(args): yield result

或者yield from recursive_iter(args)

作为替代,以下命令是一个更纯粹的递归版本:

def pfactorsr(x):
 **def factor_n(x, n):
 **if n*n > x:
 **yield x
 **return
 **if x % n == 0:
 **yield n
 **if x//n > 1:
 **yield from factor_n(x//n, n)
 **else:
 **yield from factor_n(x, n+2)
 **if x % 2 == 0:
 **yield 2
 **if x//2 > 1:
 **yield from pfactorsr(x//2)
 **return
 **yield from factor_n(x, 3)

我们定义了一个内部递归函数factor_n(),来测试范围内的因子n。如果候选因子n在范围之外,那么x就是质数。否则,我们将看看n是否是x的因子。如果是,我们将产出n使用生成器表达式的所有因子。如果n不是因子,我们将递归地使用n+2 进行函数求值。这种递归来测试使用生成器表达式的每个值可以被优化为一个for循环,就像前面的例子中所示的那样。

外部函数处理一些边缘情况。与其他与素数相关的处理一样,我们将 2 作为一个特殊情况处理。对于偶数,我们将产生 2,然后递归地评估pfactorsr()以获得x÷2。所有其他素数因子必须是大于或等于 3 的奇数。我们将从 3 开始评估factors_n()函数以测试这些其他候选素数因子。

提示

纯递归函数只能找到最多约 4,000,000 的数字的素数因子。在这之上,Python 的递归限制将被达到。

探索生成器的限制

我们注意到生成器表达式和生成器函数有一些限制。可以通过执行以下命令片段来观察这些限制:

>>> from ch02_ex4 import *
>>> pfactorsl( 1560 )
<generator object pfactorsl at 0x1007b74b0>
>>> 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()

在第一个例子中,我们看到生成器函数并不严格。它们是懒惰的,在我们消耗生成器函数之前没有正确的值。这并不是一个限制,这正是生成器表达式与 Python 中的函数式编程相匹配的整个原因。

在第二个例子中,我们从生成器函数中实现了一个列表对象。这对于查看输出和编写单元测试用例很方便。

在第三个例子中,我们看到了生成器函数的一个限制:没有len()

生成器函数的另一个限制是它们只能使用一次。例如,看下面的命令片段:

>>> result= pfactorsl(1560)
>>> sum(result)
27
>>> sum(result)
0

sum()方法的第一次评估执行了生成器的评估。sum()方法的第二次评估发现生成器现在为空了。我们只能消耗值一次。

生成器在 Python 中有一个有状态的生命周期。虽然它们对于函数式编程的某些方面非常好,但并不完美。

我们可以尝试使用itertools.tee()方法来克服一次性限制。我们将在第八章迭代工具模块中深入研究这个问题。这里是它的一个快速示例用法:

import itertools
def limits(iterable):
 **max_tee, min_tee = itertools.tee(iterable, 2)
 **return max(max_tee), min(min_tee)

我们创建了参数生成器表达式的两个克隆,max_tee()min_tee()。这使原始迭代器保持不变,这是一个令人愉快的特性,允许我们对函数进行非常灵活的组合。我们可以消耗这两个克隆来从可迭代对象中获得maximaminima

虽然吸引人,但我们会发现这在长期内并不奏效。一旦被消耗,可迭代对象将不再提供任何值。当我们想要计算多种类型的缩减,例如sumscountsminimumsmaximums时,我们需要考虑这种一次性限制。

组合生成器表达式

函数式编程的本质来自于我们如何轻松地组合生成器表达式和生成器函数来创建非常复杂的复合处理序列。在使用生成器表达式时,我们可以以几种方式组合生成器。

组合生成器函数的一种常见方式是当我们创建一个复合函数时。我们可能有一个计算(f(x) for x in range())的生成器。如果我们想计算g(f(x)),我们有几种方法来组合两个生成器。

我们可以调整原始的生成器表达式如下:

g_f_x = (g(f(x)) for x in range())

虽然在技术上是正确的,但这破坏了任何重用的想法。我们不是重用一个表达式,而是重写它。

我们还可以在另一个表达式中替换一个表达式,如下所示:

g_f_x = (g(y) for y in (f(x) for x in range()))

这有一个优点,允许我们使用简单的替换。我们可以稍微修改这个以强调重用,使用以下命令:

f_x= (f(x) for x in range())
g_f_x= (g(y) for y in f_x)

这有一个优点,它保留了初始表达式(f(x) for x in range()),基本上没有改变。我们所做的只是将表达式分配给一个变量。

生成的复合函数也是一个生成器表达式,也是懒惰的。这意味着从g_f_x中提取下一个值将从f_x中提取一个值,这将从源range()函数中提取一个值。

使用生成器函数清理原始数据

探索性数据分析中出现的任务之一是清理原始数据源。这通常作为一个复合操作,对每个输入数据应用多个标量函数来创建一个可用的数据集。

让我们看一个简化的数据集。这个数据通常用来展示探索性数据分析技术。它被称为Anscombe's Quartet,来源于 F. J. Anscombe 在 1973 年发表在American Statistician上的文章Graphs in Statistical Analysis。以下是一个下载文件中这个数据集的前几行:

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模块处理这个问题。我们必须对文件进行一些解析,以提取出文件中的有用信息。由于数据是正确的制表符分隔的,我们可以使用csv.reader()函数来遍历各行。我们可以定义一个数据迭代器如下:

import csv
def row_iter(source):
 **return csv.reader(source, delimiter="\t")

我们只是将一个文件包装在csv.reader函数中,以创建一个行的迭代器。我们可以在以下上下文中使用这个迭代器:

with open("Anscombe.txt") as source:
 **print( list(row_iter(source)) )

问题在于结果可迭代对象中的前三个项目不是数据。当打开 Anacombe's quartet 文件时,它看起来是这样的:

[["Anscombe's quartet"], ['I', 'II', 'III', 'IV'], ['x', 'y', 'x', 'y', 'x', 'y', 'x', 'y'],** 

我们需要从可迭代对象中过滤这些行。下面是一个可以整洁地切除三个预期标题行,并返回剩余行的迭代器的函数:

def head_split_fixed(row_iter):
 **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()函数都期望一个可迭代对象作为参数值,它们可以如下简单地组合:

with open("Anscombe.txt") as source:
 **print( list(head_split_fixed(row_iter(source))))

我们只是将一个迭代器应用到另一个迭代器的结果上。实际上,这定义了一个复合函数。当然,我们还没有完成;我们仍然需要将strings值转换为float值,而且我们还需要拆分每行中的四个并行数据系列。

最终的转换和数据提取更容易使用高阶函数,比如map()filter()。我们将在第五章高阶函数中回到这些内容。

使用列表、字典和集合。

Python 序列对象,比如list,是可迭代的。但它还有一些额外的特性。我们将把它看作是一个实现的可迭代对象。我们在几个例子中使用tuple()函数来收集生成器表达式或生成器函数的输出到一个单一的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(10)函数是惰性的;在通过迭代遍历值的上下文中才会产生这 10 个值。

第二个例子展示了由单个生成器函数组成的列表。要评估这个,我们将不得不使用嵌套循环。类似这样[x for gen in [range(10)] for x in gen]

第三个例子展示了从包含生成器函数的生成器表达式构建的list推导。函数range(10)通过生成器表达式x for x in range(10)进行评估。结果值被收集到一个list对象中。

我们也可以使用list()函数从可迭代对象或生成器表达式构建一个列表。这对于set()tuple()dict()也适用。

提示

list(range(10))函数评估了生成器表达式。[range(10)]列表文字不评估生成器函数。

虽然有listdictset的简写语法,使用[]{},但没有元组的简写语法。为了实现一个元组,我们必须使用tuple()函数。因此,使用list()tuple()set()函数作为首选语法似乎是最一致的。

在数据清洗示例中,我们使用一个复合函数来创建四个元组的列表。函数如下所示:

with open("Anscombe.txt") as source:
 **data = head_split_fixed(row_iter(source))
 **print(list(data))

我们将复合函数的结果分配给一个名为data的名称。数据如下所示:

[['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']]

我们需要做一些更多的处理才能让它有用。首先,我们需要从八个元组中选择一对列。我们可以使用一个函数选择一对列,如下面的命令片段所示:

from collections import namedtuple
Pair = namedtuple("Pair", ("x", "y"))
def series(n, row_iter):
 **for row in row_iter:
 **yield Pair(*row[n*2:n*2+2])

这个函数根据 0 到 3 之间的数字选择两个相邻的列。它从这两列创建一个namedtuple对象。这使我们可以从每一行中选择xy值。

我们现在可以创建一个元组集合,如下所示:

with open("Anscombe.txt") as source:
 **data = tuple(head_split_fixed(row_iter(source)))
 **sample_I= tuple(series(0,data))
 **sample_II= tuple(series(1,data))
 **sample_III= tuple(series(2,data))
 **sample_IV= tuple(series(3,data))

我们将tuple()函数应用于基于head_split_fixed()row_iter()方法的复合函数。这将创建一个对象,我们可以在其他几个函数中重复使用。如果我们不实现一个tuple对象,那么只有第一个样本会有任何数据。之后,源迭代器将被耗尽,所有其他尝试访问它都将产生空的序列。

series()函数将选择一对项目来创建Pair对象。同样,我们对结果的元组-命名元组序列应用了一个整体的tuple()函数,以便我们可以对每个序列进行进一步处理。

sample_I序列看起来像下面的命令片段:

(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'),** 
Etc.** 
Pair(x='5.0', y='5.68'))

其他三个序列的结构类似。然而,值是非常不同的。

我们需要做的最后一件事是从我们积累的字符串中创建适当的数值,以便我们可以计算一些统计摘要值。我们可以将float()函数转换应用为最后一步。有许多替代的地方可以应用float()函数,我们将在第五章高阶函数中看一些选择。

下面是一个描述float()函数用法的例子:

 **mean = sum(float(pair.y) for pair in sample_I)/len(sample_I)

这将提供Pair对象中y值的平均值。我们可以按如下方式收集一些统计信息:

for subset in sample_I, sample_II, sample_III, sample_III:
 **mean = sum(float(pair.y) for pair in subset)/len(subset)
 **print(mean)

我们计算了从源数据库构建的每个pairy值的平均值。我们创建了一个通用的元组-命名元组结构,这样我们就可以清晰地引用源数据集的成员。使用pair.ypair[1]更清晰一些。

为了减少内存使用并提高性能,我们尽可能使用生成器表达式和函数。这些以一种惰性(或非严格)的方式迭代集合,只在需要时计算值。由于迭代器只能使用一次,有时我们被迫将一个集合实现为tuple(或list)对象。实现一个集合会消耗内存和时间,所以我们不情愿地这样做。

熟悉Clojure的程序员可以使用lazy-seqlazy-cat函数与 Python 的惰性生成器相匹配。这个想法是我们可以指定一个潜在的无限序列,但只在需要时从中取值。

使用有状态的映射

Python 提供了几种有状态的集合;各种映射包括 dict 类和collections模块中定义的许多相关映射。我们需要强调这些映射的有状态性质,并谨慎使用它们。

对于我们在学习 Python 中的函数式编程技术的目的,mapping有两种用例:累积映射的有状态字典和冻结字典。在本章的第一个例子中,我们展示了一个被ElementTree.findall()方法使用的冻结字典。Python 没有提供一个易于使用的不可变映射的定义。collections.abc.Mapping抽象类是不可变的,但它不是我们可以轻易使用的东西。我们将在第六章中深入了解细节,递归和归约

而不是使用collections.abc.Mapping抽象类的形式,我们可以确认变量ns_map在赋值语句的左侧只出现一次,方法如ns_map.update()ns_map.pop()从未被使用,del语句也没有与映射项一起使用。

有状态的字典可以进一步分解为两种典型的用例;它们如下:

  • 一个字典只建立一次,从不更新。在这种情况下,我们将利用dict类的哈希键特性来优化性能。我们可以通过dict(sequence)从任何可迭代的(key, value)两元组序列创建字典。

  • 一个逐步构建的字典。这是一个我们可以使用的优化,可以避免实现和排序列表对象。我们将在第六章中看到这一点,递归和归约。我们将把collections.Counter类作为一个复杂的归约。逐步构建对于记忆化特别有帮助。我们将把记忆化推迟到第十六章中,优化和改进

第一个例子,只建立一次字典,源自一个具有三个操作阶段的应用程序:收集一些输入,创建一个dict对象,然后根据字典中的映射处理输入。作为这种应用程序的一个例子,我们可能正在进行一些图像处理,并且有一个特定的调色板,由名称和(R, G, B)元组表示。如果我们使用GNU 图像处理程序GIMPGNU 通用公共许可证GPL)文件格式,颜色调色板可能看起来像以下命令片段:

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

解析这个文件的细节是第六章的主题,递归和归约。重要的是解析的结果。

首先,我们假设我们正在使用以下的Color命名元组:

from collections import namedtuple
Color = namedtuple("Color", ("red", "green", "blue", "name"))

其次,我们假设有一个产生Color对象可迭代的解析器。如果我们将其实现为一个元组,它看起来会像这样:

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

其中,序列变量是先前显示的Color对象的可迭代对象,设计模式的wrap()元素简单地将每个Color对象c转换成两元组(c.name, c)。设计的process()元素使用dict()初始化来创建从名称到Color的映射。结果字典如下所示:

{'Caribbean Green': Color(red=28, green=211, blue=162, name='Caribbean Green'),'Peach': Color(red=255, green=207, blue=171, name='Peach'), 'Blizzard Blue': Color(red=172, green=229, blue=238, name='Blizzard Blue'),

顺序不能保证,所以你可能看不到加勒比绿色排在第一位。

现在我们已经实现了映射,我们可以在以后的一些处理中使用这个dict()对象,用于从颜色名称到(R, G, B)颜色数字的重复转换。查找将非常快,因为字典会快速将键转换为哈希值,然后在字典中查找。

使用 bisect 模块创建映射

在前面的例子中,我们创建了一个dict映射,以实现从颜色名称到Color对象的快速映射。这不是唯一的选择;我们可以使用bisect模块。使用bisect模块意味着我们必须创建一个排序对象,然后进行搜索。为了与dict映射完全兼容,我们可以使用collections.abc.Mapping作为基类。

dict映射使用哈希来几乎立即定位项。然而,这需要分配一个相当大的内存块。bisect映射进行搜索,不需要那么多的内存,但性能可以描述为立即。

static映射类看起来像以下命令片段:

import bisect
from collections.abc import Mapping
class StaticMapping(Mapping):
 **def __init__( self, iterable ):
 **self._data = tuple(iterable)
 **self._keys = tuple(sorted(key for key, _ in self._data))

 **def __getitem__(self, key):
 **ix= bisect.bisect_left(self._keys, key)
 **if ix != len(self._keys) and self._keys[ix] == key:
 **return self._data[ix][1]
 **raise ValueError("{0!r} not found".format(key))
 **def __iter__(self):
 **return iter(self._keys)
 **def __len__(self):
 **return len(self._keys)

这个类扩展了抽象超类collections.abc.Mapping。它提供了三个函数的初始化和实现,这些函数在抽象定义中缺失。__getitem__()方法使用bisect.bisect_left()函数来搜索键的集合。如果找到键,则返回相应的值。__iter__()方法返回一个迭代器,如超类所需。__len__()方法同样提供了集合的所需长度。

另一个选择是从collections.OrderedDict类的源代码开始,将超类更改为Mapping而不是MutableMapping,并删除所有实现可变性的方法。有关要保留哪些方法和要丢弃哪些方法的更多详细信息,请参阅Python 标准库第 8.4.1 节。

访问以下链接以获取更多详细信息:

docs.python.org/3.3/library/collections.abc.html#collections-abstract-base-classes

这个类似乎并不体现太多的函数式编程原则。我们的目标是支持一个最小化使用有状态变量的更大的应用程序。这个类保存了一组静态的键值对。作为一种优化,它实现了两个对象。

创建此类的实例的应用程序正在使用一个实例化对象来执行对键的快速查找。超类不支持对对象的更新。整个集合是无状态的。它不像内置的dict类那样快,但它使用的内存更少,并且通过作为Mapping子类的形式,我们可以确保该对象不用于包含处理状态。

使用有状态集合

Python 提供了几种有状态的集合,包括 set 集合。对于我们的目的,集合有两种用途:一个是累积项的有状态集合,另一个是用于优化搜索项的 frozenset。

我们可以像创建tuple对象一样从可迭代对象中创建 frozenset,fronzenset(some_iterable)方法;这将创建一个具有非常快速的in运算符的结构。这可以用于收集数据、创建集合,然后使用那个 frozenset 来处理其他数据项的应用程序。

我们可能有一组颜色,我们将使用作为一种色度-:我们将使用这种颜色创建一个蒙版,该蒙版将用于组合两个图像。从实用的角度来看,单一颜色并不合适,但一小组非常相似的颜色效果最佳。在这种情况下,我们将检查图像文件的每个像素,以查看像素是否在色度键集中。对于这种处理,色度键颜色在处理目标图像之前加载到 frozenset 中。有关色度键处理的更多信息,请阅读以下链接:

en.wikipedia.org/wiki/Chroma_key

与映射一样 - 具体来说是Counter类 - 有一些算法可以从一个记忆化的值集中受益。一些函数受益于记忆化,因为函数是域值和值域之间的映射,这是映射很好的工作。一些算法受益于一个有记忆的集合,它是有状态的,并且随着数据的处理而增长。

我们将在第十六章优化和改进中回顾记忆化。

总结

在本章中,我们仔细研究了编写纯函数:没有副作用。这里的标准很低,因为 Python 强制我们使用global语句来编写不纯的函数。我们研究了生成器函数以及我们如何将其用作函数式编程的支柱。

我们还研究了内置的集合类,以展示它们在函数范式中的使用方式。虽然函数式编程背后的一般理念是限制使用有状态的变量,但集合对象通常是有状态的,并且对于许多算法也是必不可少的。我们的目标是在使用 Python 的非函数式特性时要谨慎。

在接下来的两章中,我们将研究高阶函数:接受函数作为参数并返回函数的函数。我们将从探索内置的高阶函数开始。在后面的章节中,我们将研究定义自己的高阶函数的技术。我们还将在后面的章节中研究itertoolsfunctools模块及其高阶函数。

第四章:处理集合

Python 提供了许多处理整个集合的函数。它们可以应用于序列(列表或元组)、集合、映射和生成器表达式的可迭代结果。我们将从函数式编程的角度看一些 Python 的集合处理函数。

我们将首先看一下可迭代对象和一些与可迭代对象一起工作的简单函数。我们将看一些额外的设计模式来处理可迭代对象和递归序列,以及显式的for循环。我们将看一下如何使用生成器表达式将scalar()函数应用于数据集合。

在本章中,我们将展示如何使用以下函数来处理集合的示例:

  • any()all()

  • len()sum()以及与这些函数相关的一些高阶统计处理

  • zip()和一些相关的技术来构造和展平数据列表

  • reversed()

  • enumerate()

前四个函数都可以称为缩减函数;它们将集合减少为单个值。另外三个函数(zip()reversed()enumerate())是映射函数;它们从现有集合中产生一个新的集合。在下一章中,我们将看一些使用额外函数作为参数来定制其处理的mapping()reduction()函数。

在本章中,我们将首先看一下使用生成器表达式处理数据的方法。然后,我们将应用不同类型的集合级函数,以展示它们如何简化迭代处理的语法。我们还将看一些不同的数据重构方式。

在下一章中,我们将专注于使用高阶集合函数来进行类似的处理。

函数种类概述

我们需要区分以下两种广义函数:

  • 标量函数适用于单个值,并计算单个结果。abs()pow()和整个math模块都是标量函数的例子。

  • Collection()函数与可迭代集合一起工作。

我们可以进一步将集合函数细分为三个亚种:

  • 缩减:这使用一个函数来将集合中的值合并在一起,产生一个最终的单一值。我们可以称之为聚合函数,因为它为输入集合产生一个单一的聚合值。

  • 映射:这将一个函数应用于集合的所有项目;结果是相同大小的集合。

  • 过滤器:这将一个函数应用于集合的所有项目,拒绝一些项目并通过其他项目。结果是输入的子集。过滤器可能什么也不做,这意味着输出与输入匹配;这是一个不恰当的子集,但它仍然符合子集的更广泛定义。

我们将使用这个概念框架来描述我们使用内置集合函数的方式。

使用可迭代对象

正如我们在前几章中所指出的,我们经常使用 Python 的for循环来处理集合。当处理元素化的集合(如元组、列表、映射和集合)时,for循环涉及对状态的显式管理。虽然这偏离了纯函数式编程,但它反映了 Python 的必要优化。如果我们确保状态管理局限于作为for语句评估的一部分创建的迭代器对象,我们就可以利用这个特性,而不会偏离纯粹的函数式编程太远。例如,如果我们在缩进的loop体之外使用for循环变量,我们就偏离了纯粹的函数式编程。

我们将在第六章递归和缩减中回顾这一点。这是一个重要的话题,我们在这里只是简单地用一个快速的例子来介绍与生成器一起工作。

for循环可迭代处理的一个常见应用是unwrap(process(wrap(iterable)))设计模式。wrap()函数首先将可迭代对象的每个项转换为一个带有派生排序键或其他值的两个元组,然后是原始的不可变项。然后我们可以根据包装值处理这两个元组。最后,我们将使用unwrap()函数丢弃用于包装的值,恢复原始项。

这在功能上经常发生,我们有两个函数经常用于此目的; 它们如下:

fst = lambda x: x[0]
snd = lambda x: x[1]

这两个函数从元组中选择第一个和第二个值,对于process()unwrap()函数都很方便。

另一个常见的模式是wrap(wrap(wrap()))。在这种情况下,我们从简单的元组开始,然后用额外的结果包装它们,以构建更大更复杂的元组。这个主题的一个常见变体是extend(extend(extend())),其中额外的值构建新的更复杂的namedtuple实例,而不实际包装原始元组。我们可以将这两者总结为 Accretion 设计模式。

我们将应用 Accretion 设计来处理一系列简单的纬度和经度值。第一步将简单的路径上的点(lat, lon)转换为腿(begin, end)的对。结果中的每对将是((lat, lon), (lat, lon))。

在接下来的几节中,我们将展示如何创建一个生成器函数,它将迭代文件的内容。这个可迭代对象将包含我们将处理的原始输入数据。

一旦我们有了数据,后面的部分将展示如何在每条路径上装饰haversine距离。wrap(wrap(iterable())))处理的最终结果将是三个元组的序列:((lat, lon), (lat, lon), distance)。然后我们可以分析结果,找出最长、最短的距离,边界矩形和其他数据的摘要。

解析 XML 文件

我们将从解析一个XML可扩展 标记 语言的缩写)文件开始,以获取原始的纬度和经度对。这将展示我们如何封装 Python 的一些不太功能性的特性,以创建一个可迭代的值序列。我们将使用xml.etree模块。解析后,生成的ElementTree对象有一个findall()方法,可以遍历可用的值。

我们将寻找以下代码片段这样的结构:

<Placemark><Point>
<coordinates>-76.33029518659048,37.54901619777347,0</coordinates>
</Point></Placemark>

文件将有许多<Placemark>标签,每个标签中都有一个点和坐标结构。这是包含地理信息的Keyhole Markup Language (KML)文件的典型情况。

解析 XML 文件可以在两个抽象级别上进行。在较低级别,我们需要定位 XML 文件中的各种标签、属性值和内容。在较高级别,我们希望将文本和属性值转换为有用的对象。

较低级别的处理可以通过以下方式进行:

import xml.etree.ElementTree as XML
def row_iter_kml(file_obj):
 **ns_map= {
 **"ns0": "http://www.opengis.net/kml/2.2",
 **"ns1": "http://www.google.com/kml/ext/2.2"}
 **doc= XML.parse(file_obj)
 **return (comma_split(coordinates.text)
 **for coordinates in doc.findall("./ns0:Document/ns0:Folder/ns0:Placemark/ns0:Point/ns0:coordinates", ns_map))

这个函数需要一个已经打开的文件,通常是通过with语句打开的。但它也可以是 XML 解析器可以处理的任何文件类对象。该函数包括一个简单的静态dict对象ns_map,为我们将要搜索的 XML 标签提供namespace映射信息。这个字典将被XML ElementTree.findall()方法使用。

解析的本质是一个生成器函数,它使用doc.findall()定位的标签序列。然后,这些标签序列由comma_split()函数处理,将文本值分解为逗号分隔的组件。

comma_split()函数是字符串split()方法的功能版本,如下所示:

def comma_split(text):
 **return text.split(",")

我们使用功能包装器来强调略微更统一的语法。

这个函数的结果是一个可迭代的数据行序列。每一行将是一个由三个字符串组成的元组:纬度经度和路径上一个航路点的高度。这还不直接有用。我们需要做一些额外的处理,以获得纬度经度,并将这两个数字转换为有用的浮点值。

将较低级别解析的结果作为元组的可迭代序列的想法,使我们能够以一种简单和统一的方式处理某些类型的数据文件。在第三章中,函数、迭代器和生成器,我们看到逗号 分隔 CSV)文件可以很容易地处理为元组的行。在第六章中,递归和归约,我们将重新讨论解析的想法,以比较这些各种例子。

前一个函数的输出看起来像以下的代码片段:

[['-76.33029518659048', '37.54901619777347', '0'], ['-76.27383399999999', '37.840832', '0'], ['-76.459503', '38.331501', '0'], and so on ['-76.47350299999999', '38.976334', '0']]

每一行都是使用,分割的<ns0:coordinates>标签的源文本内容。这些值是东西经度、南北纬度和高度。我们将对这个函数的输出应用一些额外的函数,以创建一个可用的数据集。

在更高级别解析文件

一旦我们解析了低级语法,我们可以将原始数据重构为我们的 Python 程序中可用的形式。这种结构适用于 XML、JavaScript 对象表示JSON)、CSV 以及数据序列化的各种物理格式。

我们将致力于编写一套小的生成器函数,将解析后的数据转换为我们的应用程序可以使用的形式。生成器函数包括对row_iter_kml()函数找到的文本进行一些简单的转换,如下所示:

  • 丢弃高度,或者可能只保留纬度经度

  • 将顺序从(经度纬度)更改为(纬度经度

我们可以通过定义一个实用函数来使这两种转换具有更多的语法统一性,如下所示:

def pick_lat_lon(lon, lat, alt):
 **return lat, lon

我们可以按照以下方式使用这个函数:

def lat_lon_kml(row_iter):
 **return (pick_lat_lon(*row) for row in row_iter)

这个函数将对每一行应用pick_lat_lon()函数。我们使用*row将每个行的三元组的每个元素分配给pick_lat_lon()函数的单独参数。然后函数可以从每个三元组中提取和重新排序两个相关值。

重要的是要注意,一个良好的函数式设计允许我们自由地用其等效物替换任何函数,这使得重构非常简单。当我们提供各种函数的替代实现时,我们试图实现这个目标。原则上,一个聪明的函数式语言编译器可能会在优化过程中进行一些替换。

我们将使用以下类型的处理来解析文件并构建一个我们可以使用的结构,例如以下代码片段:

with urllib.request.urlopen("file:./Winter%202012-2013.kml") as source:
 **v1= tuple(lat_lon_kml(row_iter_kml(source)))
print(v1)

我们使用urllib命令打开一个源。在这种情况下,它是一个本地文件。然而,我们也可以打开一个远程服务器上的 KML 文件。我们使用这种文件打开的目的是确保我们的处理无论数据的来源如何都是统一的。

我们展示了两个执行 KML 源的低级解析的函数。row_iter_kml(source)表达式产生一个文本列的序列。lat_lon_kml()函数将提取和重新排序纬度经度的值。这创建了一个中间结果,为进一步处理奠定了基础。随后的处理与原始格式无关。

当我们运行这个函数时,我们会看到以下结果:

(('37.54901619777347', '-76.33029518659048'), ('37.840832', '-76.27383399999999'), ('38.331501', '-76.459503'), ('38.330166', '-76.458504'), ('38.976334', '-76.47350299999999'))

我们已经从一个复杂的 XML 文件中提取了纬度经度的值,使用了几乎纯函数式的方法。由于结果是可迭代的,我们可以继续使用函数式编程技术来处理从文件中检索到的每个点。

我们明确地将低级别的 XML 解析与数据的高级重组分开。XML 解析产生了一个通用的字符串结构元组。这与 CSV 解析器的输出兼容。在处理SQL数据库时,我们将有一个类似的元组结构的可迭代对象。这使我们能够编写用于处理来自各种来源的数据的高级处理代码。

我们将展示一系列转换,将这些数据从字符串集合重新排列为路径上的路标集合。这将涉及许多转换。我们需要重组数据,以及从字符串转换为浮点值。我们还将研究一些简化和澄清后续处理步骤的方法。我们将在后面的章节中使用这个数据集,因为它相当复杂。

从序列中配对项目

一个常见的重组要求是将序列中的点制作成起始-停止对。给定一个序列,从序列中配对项目,我们想要创建一个配对的序列从序列中配对项目。在进行时间序列分析时,我们可能会组合更广泛分开的值。在这个例子中,是相邻的值。

配对的序列将允许我们使用每对来计算点与点之间的距离,使用haversine函数的一个简单应用。这种技术也用于将点的路径转换为图形应用程序中的一系列线段。

为什么要配对项目?为什么不像这样做?

begin= next(iterable)
for end in iterable:
 **compute_something(begin, end)
 **begin = end

这显然会将数据的每个部分处理为一个起始-结束对。然而,处理函数和重组数据的循环是紧密绑定的,使得重用比必要复杂。配对算法很难在隔离中进行测试,因为它与compute_something()函数绑定在一起。

这个组合函数也限制了我们重新配置应用程序的能力。没有简单的方法来注入compute_something()函数的替代实现。此外,我们有一个显式状态,即begin变量,这可能使生活变得复杂。如果我们试图在loop的主体中添加功能,如果一个点被从考虑中删除,我们很容易无法正确设置begin变量。filter()函数引入了一个if语句,可能导致在更新begin变量时出错。

通过分离这个简单的配对函数,我们实现了更好的重用。这在长远来看是我们的一个目标。如果我们建立一个有用的原语库,比如这个配对函数,我们就可以更快更自信地解决问题。

有许多方法可以将路径上的点配对,以为每条路径创建起始和停止信息。我们将在这里看一些方法,然后在第五章高阶函数中重新讨论这个问题,再次在第八章Itertools 模块中重新讨论。

可以使用递归以纯函数方式创建配对。以下是一个配对路径上点的函数的一个版本:

def pairs(iterable):
 **def pair_from( head, iterable_tail ):
 **nxt= next(iterable_tail)
 **yield head, nxt
 **yield from pair_from( nxt, iterable_tail )
 **try:
 **return pair_from( next(iterable), iterable )
 **except StopIteration:
 **return

基本函数是内部的pair_from()函数。它使用可迭代对象头部的项目加上可迭代对象本身。它产生第一对,从可迭代对象中弹出下一个项目,然后递归调用自身以产生任何额外的对。

我们从pairs()函数中调用了这个函数。pairs()函数确保初始化被正确处理,并且终止异常被正确地消除。

注意

Python 可迭代递归涉及使用for循环来正确消耗并产生递归的结果。如果我们尝试使用一个看起来更简单的return pair_from(nxt, iterable_tail)方法,我们会发现它并没有正确消耗可迭代对象并产生所有的值。

生成器函数中的递归需要yield from 语句来消耗生成的可迭代对象。为此,使用yield from recursive_iter(args)

类似return recursive_iter(args)的语句将只返回一个生成器对象;它不会评估函数以返回生成的值。

我们进行尾递归优化的策略是用生成器表达式替换递归。我们可以将这种递归明显优化为简单的for循环。以下是另一个配对路线上点的函数的版本:

def legs(lat_lon_iter):
 **begin= next(lat_lon_iter)
 **for end in lat_lon_iter:
 **yield begin, end
 **begin= end

这个版本非常快速,没有堆栈限制。它不依赖于任何特定类型的序列,因为它将任何序列生成器发出的任何东西配对。由于循环内没有处理函数,我们可以根据需要重用legs()函数。

我们可以将这个函数看作是产生以下类型的配对序列:

list[0:1], list[1:2], list[2:3], ..., list[-2:]

这个函数的另一个视图如下:

zip(list, list[1:])

虽然信息丰富,但这另外两种表述只适用于序列对象。legs()pairs()函数适用于任何可迭代对象,包括序列对象。

使用 iter()函数显式地

纯粹的功能观点是,我们所有的可迭代对象都可以用递归函数处理,其中状态仅仅是递归调用堆栈。从实用的角度来看,Python 可迭代对象通常涉及其他for循环的评估。有两种常见情况:集合和可迭代对象。在处理集合时,for语句会创建一个迭代器对象。在处理生成器函数时,生成器函数是迭代器,并维护其自己的内部状态。从 Python 编程的角度来看,这些通常是等效的。在极少数情况下,通常是那些必须使用显式的next()函数的情况下,这两者不会完全等效。

我们之前展示的legs()函数有一个显式的next()函数调用,以从可迭代对象中获取第一个值。这在生成器函数、表达式和其他可迭代对象中非常有效。但在元组或lists等序列对象中却不起作用。

以下是三个例子,以阐明next()iter()函数的用法:

>>> list(legs(x for x in range(3)))
[(0, 1), (1, 2)]
>>> list(legs([0,1,2]))
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "<stdin>", line 2, in legs
TypeError: 'list' object is not an iterator
>>> list(legs( iter([0,1,2])))
[(0, 1), (1, 2)]

在第一种情况下,我们将legs()函数应用于一个可迭代对象。在这种情况下,可迭代对象是一个生成器表达式。根据本章中之前的例子,这是预期的行为。项目被正确地配对,以从三个航点中创建两条腿。

在第二种情况下,我们尝试将legs()函数应用于一个序列。这导致了一个错误。虽然list对象和可迭代对象在for语句中使用时是等效的,但它们在其他地方并不等效。序列不是迭代器;它不实现next()函数。然而,for语句通过自动从序列创建迭代器来优雅地处理这个问题。

为了使第二种情况起作用,我们需要显式地从list对象创建一个迭代器。这允许legs()函数从list项的迭代器中获取第一个项目。

扩展简单循环

我们有两种扩展可以因素成一个简单的循环。我们首先看一下filter扩展。在这种情况下,我们可能会拒绝进一步考虑的值。它们可能是数据异常值,或者可能是格式不正确的源数据。然后,我们将通过执行简单的转换来映射源数据,从原始对象创建新对象。在我们的情况下,我们将把strings转换为floating-point数字。然而,将简单的loop与映射扩展的想法适用于各种情况。我们将重新设计上面的pairs()函数。如果我们需要调整点的序列以丢弃一个值,会引入一个filter扩展来拒绝一些数据值。

由于我们设计的循环只是返回一对,而没有执行任何额外的应用相关处理,所以复杂性是最小的。简单意味着我们更不太可能混淆处理状态。

在这个设计中添加一个filter扩展可能看起来像以下代码片段:

def legs_filter(lat_lon_iter):
 **begin= next(lat_lon_iter)
 **for end in lat_lon_iter:
 **if #some rule for rejecting:
 **continue
 **yield begin, end
 **begin= end

我们已经插入了一个处理规则来拒绝某些值。由于loop保持简洁和表达力十足,我们有信心处理将会被正确完成。此外,我们可以很容易地为这个函数编写一个测试,因为结果适用于任何可迭代对象,而不管这些对象的长期目的地是什么。

下一次重构将向循环引入额外的映射。当设计正在演变时,添加映射是很常见的。在我们的情况下,我们有一系列string值。我们需要将它们转换为以后使用的floating-point值。这是一个相对简单的映射,展示了设计模式。

以下是通过包装一个生成器函数的生成器表达式来处理这些数据映射的一种方法:

print(tuple(legs((float(lat), float(lon)) for lat,lon in lat_lon_kml())))

我们将legs()函数应用于一个生成器表达式,该表达式从lat_lon_kml()函数的输出中创建float值。我们也可以以相反的顺序阅读。lat_lon_kml()函数的输出被转换为一对float值,然后转换为一系列legs

这开始变得复杂了。我们这里有大量嵌套的函数。我们将float()legs()tuple()应用于数据生成器。复杂表达式的一个常见重构是将生成器表达式与任何实现的集合分开。我们可以做以下工作来简化表达式:

flt= ((float(lat), float(lon)) for lat,lon in lat_lon_kml())
print(tuple(legs(flt)))

我们将生成函数分配给一个名为flt的变量。这个变量不是一个集合对象;我们没有使用list推导来创建一个对象。我们只是将生成器表达式分配给一个变量名。然后我们在另一个表达式中使用了flt变量。

tuple()方法的评估实际上导致了一个适当的对象被构建,以便我们可以打印输出。flt变量的对象只在需要时被创建。

我们可能还想做其他的重构。一般来说,数据的来源是我们经常想要更改的。在我们的例子中,lat_lon_kml()函数与表达式的其余部分紧密绑定。当我们有不同的数据源时,这使得重用变得困难。

float()操作是我们想要参数化以便重用的情况下,我们可以定义一个围绕生成器表达式的函数。我们将一些处理提取到一个单独的函数中,仅仅是为了将操作分组。在我们的情况下,字符串对到浮点对是特定于特定源数据的。我们可以将复杂的从字符串到浮点数的表达式重写为一个更简单的函数,如下所示:

def float_from_pair( lat_lon_iter ):
 **return ((float(lat), float(lon)) for lat,lon in lat_lon_iter)

float_from_pair()函数将float()函数应用于可迭代项中的第一个和第二个值,产生从输入值创建的两个浮点数元组。我们依赖于 Python 的for语句来分解这两个元组。

我们可以在以下上下文中使用这个函数:

legs( float_from_pair(lat_lon_kml()))

我们将创建从 KML 文件中获取的float值构建的legs。很容易将处理可视化,因为过程中的每个阶段都是一个简单的前缀函数。

在解析时,我们经常有一系列string值。对于数值应用,我们需要将strings转换为floatintDecimal值。这通常涉及将一个函数插入到一系列清理源数据的表达式中,比如float_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)))

我们需要创建一个更简单的转换函数的管道。上面,我们得到了flt= ((float(lat), float(lon)) for lat,lon in lat_lon_kml())。我们可以利用函数的替换规则,用一个具有相同值的函数替换一个复杂的表达式,比如(float(lat), float(lon)) for lat,lon in lat_lon_kml()),在这种情况下,是float_from_pair(lat_lon_kml())。这种重构允许我们确保简化具有与更复杂表达式相同的效果。

我们将在第五章高阶函数中查看一些简化。我们将在第六章递归和缩减中重新讨论这个问题,看看如何将这些简化应用到文件解析问题上。

将生成器表达式应用于标量函数

我们将看到一种更复杂的生成器表达式,将数据值从一种数据映射到另一种数据。在这种情况下,我们将对生成器创建的单个数据值应用一个相当复杂的函数。

我们将这些非生成器函数称为标量,因为它们处理简单的标量值。要处理数据集,标量函数将嵌入到生成器表达式中。

继续之前的示例,我们将提供一个haversine函数,然后使用生成器表达式将标量haversine()函数应用于我们 KML 文件中的一系列对。

haversine()函数如下所示:

from math import radians, sin, cos, sqrt, asin

MI= 3959
NM= 3440
KM= 6371

def haversine( point1, point2, R=NM ):
 **lat_1, lon_1= point1
 **lat_2, lon_2= point2

 **Δ_lat = radians(lat_2 - lat_1)
 **Δ_lon = radians(lon_2 - lon_1)
 **lat_1 = radians(lat_1)
 **lat_2 = radians(lat_2)
 **a = sin(Δ_lat/2)**2 + cos(lat_1)*cos(lat_2)*sin(Δ_lon/2)**2
 **c = 2*asin(sqrt(a))

 **return R * c

这是一个相对简单的实现,复制自World Wide Web

以下是我们如何使用我们的函数集合来检查一些 KML 数据并产生一系列距离的方式:

 **trip= ((start, end, round(haversine(start, end),4))
 **for start,end in legs(float_from_pair(lat_lon_kml())))
 **for start, end, dist in trip:
 **print(start, end, dist)

处理的精髓是分配给trip变量的生成器表达式。我们已经组装了三个元组,其中包括起点、终点和起点到终点的距离。起点和终点对来自legs()函数。legs()函数使用从 KML 文件中提取的纬度-经度对构建的浮点数据。

输出看起来像以下命令片段:

(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

每个单独的处理步骤都被简洁地定义了。同样,概述也可以被表达为函数和生成器表达式的组合。

显然,我们可能希望对这些数据应用几个进一步的处理步骤。首先,当然,是使用字符串的format()方法来产生更好看的输出。

更重要的是,我们想要从这些数据中提取一些聚合值。我们将这些值称为可用数据的缩减。我们想要缩减数据以获得最大和最小纬度,例如,以显示这条路线的极端北端和南端。我们想要缩减数据以获得一条腿的最大距离以及所有legs的总距离。

我们在使用 Python 时会遇到的问题是,trip变量中的输出生成器只能使用一次。我们无法轻松地对这些详细数据进行多次缩减。我们可以使用itertools.tee()来多次使用可迭代对象。然而,每次缩减都读取和解析 KML 文件似乎是一种浪费。

我们可以通过实现中间结果来使我们的处理更有效。我们将在下一节中看到这一点。然后,我们可以看看如何计算可用数据的多个缩减。

使用 any()和 all()作为缩减

any()all()函数提供了布尔缩减功能。这两个函数都将一组值缩减为单个TrueFalseall()函数确保所有值都为Trueany()函数确保至少有一个值为True

这些函数与用于表达数学逻辑的普遍量词和存在量词密切相关。例如,我们可能想要断言给定集合中的所有元素都具有某种属性。其中一种形式可能如下所示:

使用 any()和 all()作为缩减

我们会读到这样的内容:对于 SomeSet 中的所有 x,函数使用 any()和 all()作为缩减 是真的。我们在逻辑表达式前面放了一个量词。

在 Python 中,我们稍微改变了项目的顺序,以转录逻辑表达式如下:

all(isprime(x) for x in someset)

这将评估每个参数值(isprime(x))并将值集合缩减为单个TrueFalse

any()函数与存在量词有关。如果我们想要断言集合中没有值是素数,我们可能会有类似以下两个等价表达式之一:

使用 any()和 all()作为缩减

第一个陈述SomeSet 中的所有元素都不是素数。第二个版本断言SomeSet 中存在一个不是素数的元素。这两者是等价的——也就是说,如果不是所有元素都是素数,那么必定存在一个元素不是素数

在 Python 中,我们可以交换术语的顺序,并将其转录为以下工作代码:

not all(isprime(x) for x in someset)
any(not isprime(x) for x in someset)

由于它们是等价的,有两个原因可以优先选择一个而不是另一个:性能和清晰度。性能几乎相同,所以归结为清晰度。哪一个最清晰地陈述了条件?

all()函数可以描述为一组值的and缩减。结果类似于在给定值序列之间折叠and运算符。类似地,any()函数可以描述为or缩减。当我们查看第十章中的reduce()函数时,我们将回到这种通用缩减。Functools 模块

我们还需要看一下这些函数的退化情况。如果序列有 0 个元素会怎样?all(())all([])的值是什么?

如果我们问,“空集中的所有元素都是素数吗?”,那么答案是什么?由于没有元素,这个问题有点难以回答。

如果我们问“空集中的所有元素都是素数,SomeSet中的所有元素都是素数吗?”,我们对如何继续进行有一些提示。我们正在执行空集的and缩减和SomeSetand缩减。

使用 any()和 all()作为缩减

事实证明and运算符可以自由分布。我们可以将其重写为两个集合的并集,然后对其进行素数评估:

使用 any()和 all()作为缩减

显然,使用 any()和 all()作为缩减。如果我们联合一个空集,我们会得到原始集合。空集可以称为联合标识元素。这类似于 0 是加法标识元素的方式:使用 any()和 all()作为缩减

同样,any(())必须是or标识元素,即False。如果我们考虑乘法标识元素 1,其中使用 any()和 all()作为缩减,那么all(())必须是True

我们可以证明 Python 遵循这些规则:

>>> all(())
True
>>> any(())
False

Python 为我们提供了一些非常好的工具来执行涉及逻辑的处理。我们有内置的andornot运算符。但是,我们还有这些面向集合的any()all()函数。

使用 len()和 sum()

len()sum()函数提供了两个简单的缩减:元素的计数和序列中元素的总和。这两个函数在数学上是相似的,但它们的 Python 实现是非常不同的。

从数学上讲,我们可以观察到这种很酷的平行性。len()函数返回集合 X 中每个值的 1 的总和:使用 len()和 sum()

sum()函数返回集合 X 中每个值的x的总和:使用 len()和 sum()

sum()函数适用于任何可迭代对象。len()函数不适用于可迭代对象;它只适用于序列。这些函数的实现中存在的这种小不对称在统计算法的边缘有点尴尬。

对于空序列,这两个函数都返回一个适当的加法恒元素 0。

>>> sum(())
0

当其他数值类型被使用时,sum(())返回整数 0。整数 0 将被强制转换为可用数据的适当类型。

使用求和和计数进行统计

算术均值的定义基于sum()len()有一个吸引人的平凡定义,如下所示:

def mean( iterable ):
 **return sum(iterable)/len(iterable)

虽然优雅,但实际上这对于可迭代对象并不适用。这个定义只适用于序列。

事实上,我们很难基于可迭代对象执行均值或标准差的简单计算。在 Python 中,我们必须要么实例化一个序列对象,要么使用更复杂的操作。

我们有一个相当优雅的均值和标准差的表达式如下定义:

import math
s0= len(data) # sum(1 for x in data) # x**0
s1= sum(data) # sum(x for x in data) # x**1
s2= sum(x*x for x in data)

mean= s1/s0
stdev= math.sqrt(s2/s0 - (s1/s0)**2)

这三个总和s0s1s2有一个整洁的平行结构。我们可以很容易地从这两个总和中计算均值。标准差稍微复杂一些,但仍然基于这三个总和。

这种愉快的对称性也适用于更复杂的统计函数,比如相关性甚至最小二乘线性回归。

两组样本之间的相关性矩可以从它们的标准化值中计算。以下是一个计算标准化值的函数:

def z( x, μ_x, σ_x ):
 **return (x-μ_x)/σ_x

计算很简单,只需从均值μ_x中减去每个样本x,然后除以标准差σ_x。这给出了一个以 sigma 为单位的值,σ。±1σ的值预计大约有三分之二的时间。更大的值应该更少见。±3σ之外的值应该少于 1%的时间发生。

我们可以使用这个标量函数如下:

>>> 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中的一些原始数据基于标准化分数组成的list。我们使用了一个生成器表达式来将标量函数z()应用于序列对象。

mean()stdev()函数只是基于上面显示的例子:

def mean(x):** 
 **return s1(x)/s0(x)
def stdev(x):
 **return math.sqrt(s2(x)/s0(x) - (s1(x)/s0(x))**2)

同样,这三个总和函数是基于上面的例子:

def s0(data):
 **return sum(1 for x in data) # or len(data)
def s1(data):** 
 **return sum(x for x in data) # or sum(data)
def s2(data):** 
 **return sum(x*x for x in data)

虽然这非常表达和简洁,但有点令人沮丧,因为我们不能简单地在这里使用可迭代对象。我们正在计算一个均值,这需要对可迭代对象进行求和,再加上一个计数。我们还在计算一个需要从可迭代对象中进行两次求和和一个计数的标准差。对于这种统计处理,我们必须实例化一个序列对象,以便我们可以多次检查数据。

以下是我们如何计算两组样本之间的相关性:

def corr( sample1, sample2 ):
 **μ_1, σ_1 = mean(sample1), stdev(sample1)
 **μ_2, σ_2 = mean(sample2), stdev(sample2)
 **z_1 = (z(x, μ_1, σ_1) for x in sample1)
 **z_2 = (z(x, μ_2, σ_2) for x in sample2)
 **r = sum(zx1*zx2 for zx1, zx2 in zip(z_1, z_2) )/s0(sample1)
 **return r

这个相关性函数收集了两组样本的基本统计摘要:均值和标准差。在得到这些摘要后,我们定义了两个生成器函数,它们将为每组样本创建标准化值。然后我们可以使用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,] #  Height (m)
 **>>> 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,] # ...    Mass (kg)
 **>>> round(corr( xi, yi ), 5)
 **0.99458

我们展示了两组数据点xiyi。相关性超过了 0.99,显示了两个序列之间非常强的关系。

这显示了函数式编程的一个优点。我们使用了半打函数来创建一个方便的统计模块,这些函数的定义都是单个表达式。反例是corr()函数,它可以简化为一个非常长的表达式。这个函数中的每个内部变量只使用一次;一个局部变量可以用创建它的表达式的复制粘贴来替换。这告诉我们,corr()函数具有函数式设计,即使它是用 Python 的六行单独写出的。

使用 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 0x101d62ab8>
>>> list(zip( xi, yi ))
[(1.47, 52.21), (1.5, 53.12), (1.52, 54.48), (1.55, 55.84), (1.57, 57.2), (1.6, 58.57), (1.63, 59.93), (1.65, 61.29), (1.68, 63.11), (1.7, 64.47), (1.73, 66.28), (1.75, 68.1), (1.78, 69.92), (1.8, 72.19), (1.83, 74.46)]

zip()函数有许多边缘情况。我们必须问以下关于其行为的问题:

  • 如果没有任何参数会发生什么?

  • 当只有一个参数时会发生什么?

  • 当序列长度不同时会发生什么?

对于缩减(any()all()len()sum()),我们希望从缩减空序列中得到一个标识元素。

显然,这些边缘情况中的每一个都必须产生某种可迭代的输出。以下是一些例子,以澄清这些行为。首先是空参数列表:

>>> zip()
<zip object at 0x101d62ab8>
>>> list(_)
[]

我们可以看到,zip()函数没有参数是一个生成器函数,但不会有任何项。这符合输出是可迭代的要求。

接下来,我们将尝试一个单个可迭代对象:

>>> zip( (1,2,3) )
<zip object at 0x101d62ab8>
>>> list(_)
[(1,), (2,), (3,)]

在这种情况下,zip()函数从每个输入值中发出一个元组。这也是非常有意义的。

最后,我们将看一下zip()函数使用的不同长度的list方法:

>>> list(zip((1, 2, 3), ('a', 'b')))
[(1, 'a'), (2, 'b')]

这个结果是有争议的。为什么要截断?为什么不用None值填充较短的列表?zip()函数的另一种定义在itertools模块中作为zip_longest()函数可用。我们将在第八章中看到,迭代工具模块

解压压缩的序列

zip()映射可以被反转。我们将看几种解压元组集合的方法。

注意

我们无法完全解压元组的可迭代对象,因为我们可能需要多次遍历数据。根据我们的需求,我们可能需要实现可迭代对象以提取多个值。

第一种方法是我们已经看到很多次的;我们可以使用一个生成器函数来解压一个元组序列。例如,假设以下对是一个包含两个元组的序列对象:

p0= (x[0] for x in pairs)
p1= (x[1] for x in pairs)

这将创建两个序列。p0序列有每个两元组的第一个元素;p1序列有每个两元组的第二个元素。

在某些情况下,我们可以使用for循环的多重赋值来分解元组。以下是一个计算乘积和的示例:

sum(p0*p1 for for p0, p1 in pairs)

我们使用for语句将每个两元组分解为p0p1

展平序列

有时,我们会有需要展平的压缩数据。例如,我们的输入可能是一个看起来像这样的文件:

 **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)来创建一个包含十个元组的序列。

我们可能会有以下形式的数据块:

blocked = [['2', '3', '5', '7', '11', '13', '17', '19', '23', '29'], ['31', '37', '41', '43', '47', '53', '59', '61', '67', '71'],...

尽管这不是我们想要的。我们希望将数字放入一个单一的、扁平的序列中。输入中的每个项目都是一个十元组;我们宁愿不要与逐个分解这个项目打交道。

我们可以使用两级生成器表达式,如下面的代码片段所示,用于这种展平:

>>> (x for line in blocked for x in line)
<generator object <genexpr> at 0x101cead70>
>>> list(_)
['2', '3', '5', '7', '11', '13', '17', '19', '23', '29', '31', '37', '41', '43', '47', '53', '59', '61', '67', '71', … ]

最初,两级生成器是令人困惑的。我们可以通过一个简单的重写来理解这一点:

for line in data:
 **for x in line:
 **yield x

这个转换向我们展示了生成器表达式的工作原理。第一个for子句(for line in data)遍历数据中的每个十元组。第二个for子句(for x in line)遍历第一个for子句中的每个项目。

这个表达式将一个序列结构压缩成一个单一的序列。

构造平面序列

有时,我们会有原始数据,它是一列值的平面列表,我们希望将其分成子组。这有点复杂。我们可以使用itertools模块的groupby()函数来实现这一点。这将等到第八章迭代工具模块

假设我们有一个简单的平面list如下:

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 0x101cead70>
>>> list(_)
[('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')]

首先,我们创建了一个迭代器,它存在于我们将用来创建我们的序列结构的两个循环之外。生成器表达式使用tuple(next(flat_iter) for i in range(5))flat_iter变量中的可迭代值创建了五个元组。这个表达式被嵌套在另一个生成器中,它重复内部循环正确次数,以创建所需的值序列。

这仅在平面列表均匀分割时有效。如果最后一行有部分元素,我们需要单独处理它们。

我们可以使用这种函数将数据分组为相同大小的元组,最后一个元组的大小为奇数,使用以下定义:

def group_by_seq(n, sequence):
 **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

我们创建了一个初始的list,其中每个tuple的大小为n。如果有剩余的元素,我们将有一个长度非零的尾部tuple,我们可以将其附加到完整大小的项目的list上。如果尾部tuple的长度为 0,我们将忽略它。

这不像我们之前看过的其他算法那样简单和功能性。我们可以将其改写为一个相当愉快的生成器函数。以下代码使用while循环作为尾递归优化的一部分:

def group_by_iter( n, iterable ):** 
 **row= tuple(next(iterable) for i in range(n))
 **while row:
 **yield row
 **row= tuple(next(iterable) for i in range(n))

我们从输入可迭代对象中创建了所需长度的一行。当我们到达输入可迭代对象的末尾时,tuple(next(iterable) for i in range(n))的值将是一个长度为零的元组。这是递归的基本情况,我们已经将其写成了while循环的终止条件。

构造平面序列——另一种方法

假设我们有一个简单的平面list,我们想从这个list中创建成对的元素。以下是所需的数据:

flat= ['2', '3', '5', '7', '11', '13', '17', '19', '23', '29', '31', '37', '41', '43', '47', '53', '59', '61', '67', '71',... ]

我们可以使用列表切片创建成对的元素,如下所示:

zip(flat[0::2], flat[1::2])

切片flat[0::2]是所有偶数位置。切片flat[1::2]是所有奇数位置。如果我们将它们一起压缩,我们得到一个两个元素的元组(0),第一个偶数位置的值,和(1),第一个奇数位置的值。如果元素的数量是偶数,这将很好地产生成对。

这有一个相当简短的优点。前一节中显示的函数是解决相同问题的更长的方法。

这种方法可以泛化。我们可以使用*(args)方法生成必须被压缩在一起的序列结构。它看起来像以下的样子:

zip(*(flat[i::n] for i in range(n)))

这将生成n个切片:flat[0::n]flat[1::n]flat[2::n],…,flat[n-1::n]。这些切片的集合成为zip()的参数,然后交错地从每个切片中提取值。

回想一下,zip()会将序列截断为最短的list。这意味着,如果list不是分组因子n的偶数倍(len(flat)%n != 0),也就是最后一个切片,它的长度将不同于其他切片的长度,其他切片都将被截断。这很少是我们想要的。

如果我们使用itertools.zip_longest()方法,那么我们将看到最终的元组将填充足够的None值,使其长度为n。在某些情况下,这种填充是可以接受的。在其他情况下,额外的值是不希望的。

list切片方法对数据进行分组是另一种处理将扁平数据结构化为块的方法。作为一种通用解决方案,它似乎并没有比前一节中的函数提供太多优势。作为专门用于从扁平数据中制作两个元组的解决方案,它非常简单。

使用 reversed()改变顺序

有时我们需要一个反转的序列。Python 为我们提供了两种方法:reversed()函数和使用反转索引的切片。

例如,考虑将基数转换为十六进制或二进制。以下是一个简单的转换函数:

def digits(x, b):
 **if x == 0: return
 **yield x % b
 **for d in to_base(x//b, b):
 **yield d

此函数使用递归从最不重要的位到最重要的位产生数字。x%b的值将是基数bx的最不重要的数字。

我们可以将其形式化如下:

使用 reversed()改变顺序

在许多情况下,我们更喜欢以相反的顺序产生数字。我们可以使用reversed()函数包装这个函数,以交换数字的顺序:

def to_base(x, b):
 **return reversed(tuple(digits(x, b)))

注意

reversed()函数产生一个可迭代对象,但参数值必须是一个序列对象。然后该函数以相反的顺序产生该对象的项目。

我们也可以使用切片来做类似的事情,比如tuple(digits(x, b))[::-1]。然而,切片不是一个迭代器。切片是从另一个实例化对象构建的实例化对象。在这种情况下,对于这样小的值集合,区别是微不足道的。由于reversed()函数使用的内存较少,对于更大的集合来说可能更有优势。

使用 enumerate()包括序列号

Python 提供了enumerate()函数,将索引信息应用于序列或可迭代对象中的值。它执行一种特殊的包装,可以作为unwrap(process(wrap(data)))设计模式的一部分使用。

它看起来像下面的代码片段:

>>> xi
[1.47, 1.5, 1.52, 1.55, 1.57, 1.6, 1.63, 1.65, 1.68, 1.7, 1.73, 1.75, 1.78, 1.8, 1.83]
>>> list(enumerate(xi))
[(0, 1.47), (1, 1.5), (2, 1.52), (3, 1.55), (4, 1.57), (5, 1.6), (6, 1.63), (7, 1.65), (8, 1.68), (9, 1.7), (10, 1.73), (11, 1.75), (12, 1.78), (13, 1.8), (14, 1.83)]

enumerate()函数将每个输入item转换为一个带有序列号和原始item的对。它与以下内容略有相似:

zip(range(len(source)), source)

enumerate()的一个重要特点是,结果是可迭代的,并且可以与任何可迭代的输入一起使用。

例如,在统计处理时,enumerate()函数非常方便,可以通过为每个样本加上一个数字,将单个值序列转换为更适当的时间序列。

摘要

在本章中,我们详细介绍了使用多种内置缩减的方法。

我们使用any()all()来进行基本逻辑处理。这些都是使用简单运算符如orand的简洁示例。

我们还看了一些数值缩减,如len()sum()。我们应用这些函数来创建一些高阶统计处理。我们将在第六章递归和缩减中回顾这些缩减。

我们还看了一些内置映射。

zip()函数合并多个序列。这使我们考虑在结构化和扁平化更复杂的数据结构的上下文中使用它。正如我们将在后面的章节中看到的例子,嵌套数据在某些情况下很有帮助,而扁平数据在其他情况下很有帮助。

enumerate()函数将可迭代对象映射到两个元组的序列。每个两元组都有(0)作为序列号和(1)作为原始项目。

reversed()函数按照它们的原始顺序迭代序列对象中的项目。一些算法更有效地按照一种顺序产生结果,但我们希望以相反的顺序呈现这些结果。

在下一章中,我们将看看mappingreduction函数,它们使用额外的函数作为参数来定制它们的处理。接受函数作为参数的函数是我们的第一个高阶函数的例子。我们还将涉及返回函数作为结果的函数。

第五章:高阶函数

函数式编程范式的一个非常重要的特性是高阶函数。这些是接受函数作为参数或返回函数作为结果的函数。Python 提供了几种这种类型的函数。我们将看看它们和一些逻辑扩展。

正如我们所看到的,有三种高阶函数,它们如下:

  • 接受函数作为其参数之一的函数

  • 返回函数的函数

  • 接受函数并返回函数的函数

Python 提供了几种第一种高阶函数。我们将在本章中查看这些内置的高阶函数。我们将在后面的章节中查看一些提供高阶函数的库模块。

一个发出函数的函数的概念可能看起来有点奇怪。然而,当我们看一个 Callable 类对象时,我们看到一个返回 Callable 对象的函数。这是一个创建另一个函数的函数的例子。

接受函数并创建函数的函数包括复杂的 Callable 类以及函数装饰器。我们将在本章介绍装饰器,但将深入考虑装饰器直到第十一章装饰器设计技术

有时我们希望 Python 具有前一章中集合函数的高阶版本。在本章中,我们将展示使用 reduce(extract())设计模式从较大的元组中提取特定字段执行缩减。我们还将看看如何定义我们自己版本的这些常见的集合处理函数。

在这一章中,我们将看一下以下函数:

  • max()min()

  • 我们可以使用的Lambda形式来简化使用高阶函数

  • map()

  • filter()

  • iter()

  • sorted()

itertools模块中有许多高阶函数。我们将在第八章Itertools 模块和第九章更多 Itertools 技术中查看这个模块。

此外,functools模块提供了一个通用的reduce()函数。我们将在第十章Functools 模块中看到这一点。我们将推迟这个问题,因为它不像本章中的其他高阶函数那样普遍适用。

max()min()函数是缩减函数;它们从集合中创建一个单个值。其他函数是映射函数。它们不会将输入减少到单个值。

注意

max()min()sorted()函数也有默认行为和高阶函数行为。函数是通过key=参数提供的。map()filter()函数将函数作为第一个位置参数。

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

每个tuple有三个值:起始位置、结束位置和距离。位置以纬度和经度对的形式给出。东纬是正数,所以这些点位于美国东海岸,大约西经 76°。距离以海里为单位。

我们有三种方法可以从这个值序列中获取最大和最小距离。它们如下:

  • 使用生成器函数提取距离。这将只给我们距离,因为我们丢弃了每个 leg 的其他两个属性。如果我们有任何额外的处理要求,这不会很好地工作。

  • 使用unwrap(process(wrap()))模式。这将给我们具有最长和最短距离的 legs。从这些中,我们可以提取距离,如果那是所有需要的话。其他两个将给我们包含最大和最小距离的 leg。

  • 使用max()min()函数作为高阶函数。

为了提供上下文,我们将展示前两种解决方案。以下是一个构建旅程并使用前两种方法来找到最长和最短距离的脚本:

from ch02_ex3 import float_from_pair, lat_lon_kml, limits, haversine, legs
path= float_from_pair(lat_lon_kml())
trip= tuple((start, end, round(haversine(start, end),4))for start,end in legs(iter(path)))

这一部分根据从 KML 文件中读取的path构建的每个leghaversine距离创建了trip对象作为tuple

一旦我们有了trip对象,我们就可以提取距离并计算这些距离的最大值和最小值。代码如下所示:

long, short = max(dist for start,end,dist in trip), min(dist for start,end,dist in trip)
print(long, short)

我们使用了一个生成器函数来从trip元组的每个leg中提取相关项目。我们不得不重复生成器函数,因为每个生成器表达式只能被消耗一次。

以下是结果:

129.7748 0.1731

以下是带有unwrap(process(wrap()))模式的版本。我们实际上声明了名为wrap()unwrap()的函数,以清楚地说明这种模式的工作原理:

def wrap(leg_iter):
 **return ((leg[2],leg) for leg in leg_iter)

def unwrap(dist_leg):
 **distance, leg = dist_leg
 **return leg
long, short = unwrap(max(wrap(trip))), unwrap(min(wrap(trip)))
print(long, short)

与之前的版本不同,这个版本定位了具有最长和最短距离的legs的所有属性。而不仅仅是提取距离,我们首先将距离放在每个包装的元组中。然后,我们可以使用min()max()函数的默认形式来处理包含距离和 leg 详情的两个元组。处理后,我们可以剥离第一个元素,只留下leg详情。

结果如下所示:

((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)

最终且最重要的形式使用了max()min()函数的高阶函数特性。我们将首先定义一个helper函数,然后使用它来通过执行以下代码片段来将 legs 的集合减少到所需的摘要:

def by_dist(leg):
 **lat, lon, dist= leg
 **return dist
long, short = max(trip, key=by_dist), min(trip, key=by_dist)
print(long, short)

by_dist()函数拆分了每个leg元组中的三个项目,并返回距离项目。我们将在max()min()函数中使用这个函数。

max()min()函数都接受一个可迭代对象和一个函数作为参数。关键字参数key=被 Python 所有高阶函数使用,以提供一个用于提取必要键值的函数。

我们可以使用以下内容来帮助概念化max()函数如何使用key函数:

wrap= ((key(leg),leg) for leg in trip)
return max(wrap)[1]

max()min()函数的行为就好像给定的key函数被用来将序列中的每个项目包装成一个两元组,处理两元组,然后解构两元组以返回原始值。

使用 Python 的 lambda 形式

在许多情况下,定义一个helper函数需要太多的代码。通常,我们可以将key函数简化为一个单一表达式。必须编写defreturn语句来包装一个单一表达式似乎是浪费的。

Python 提供了 lambda 形式作为简化使用高阶函数的一种方式。lambda 形式允许我们定义一个小的匿名函数。函数的主体限制在一个单一表达式中。

以下是使用简单的lambda表达式作为 key 的示例:

long, short = max(trip, key=lambda leg: leg[2]), min(trip, key=lambda leg: leg[2])
print(long, short)

我们使用的lambda将从序列中获得一个项目;在这种情况下,每个 leg 三元组将被传递给lambdalambda参数变量leg被赋值,表达式leg[2]被评估,从三元组中取出距离。

在极少数情况下,lambda从未被重复使用,这种形式是理想的。然而,通常需要重复使用lambda对象。由于复制粘贴是一个坏主意,那么有什么替代方案呢?

我们总是可以定义一个函数。

我们还可以将 lambda 分配给变量,做法如下:

start= lambda x: x[0]
end = lambda x: x[1]
dist = lambda x: x[2]

lambda是一个callable对象,可以像函数一样使用。以下是一个交互提示的示例:

>>> leg = ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
>>> start= lambda x: x[0]
>>> end  = lambda x: x[1]
>>> dist = lambda x: x[2]
>>> dist(leg)
129.7748

Python 为元组的元素分配有意义的名称提供了两种方法:命名元组和一组 lambda。两者是等效的。

为了扩展这个例子,我们将看看如何获取起点或终点的纬度经度值。这是通过定义一些额外的 lambda 来完成的。

以下是交互会话的继续:

>>> start(leg)
(27.154167, -80.195663)
>>>** 
>>> lat = lambda x: x[0]
>>> lon = lambda x: x[1]
>>> lat(start(leg))
27.154167

lambda 与命名元组相比没有明显的优势。一组lambda用于提取字段需要更多的代码行来定义比一个命名元组。另一方面,我们可以使用前缀函数表示法,在函数编程上下文中可能更容易阅读。更重要的是,正如我们将在稍后的sorted()示例中看到的,lambdas可以比namedtuple属性名称更有效地被sorted()min()max()使用。

Lambda 和 lambda 演算

在一本纯函数式编程语言的书中,有必要解释 lambda 演算和 Haskell Curry 发明的我们称之为柯里化的技术。然而,Python 并没有严格遵循这种类型的lambda 演算。函数不是柯里化的,以将它们减少为单参数lambda 形式

我们可以使用functools.partial函数实现柯里化。我们将在第十章Functools 模块中保存这个。

使用 map()函数将函数应用于集合

标量函数将域中的值映射到范围中。当我们看math.sqrt()函数时,例如,我们正在看一个从floatx到另一个floaty = sqrt(x)的映射,使得使用 map()函数将函数应用于集合。域限制为正值。映射可以通过计算或表插值来完成。

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()函数应用于每个string值。这就是map()函数的优势所在。看一下以下代码片段:

>>> list(map(int,data))
[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]

map()函数将int()函数应用于集合中的每个值。结果是一系列数字而不是一系列字符串。

map()函数的结果是可迭代的。map()函数可以处理任何类型的可迭代对象。

这里的想法是,任何 Python 函数都可以使用map()函数应用于集合的项。有很多内置函数可以在这种 map 处理上下文中使用。

使用 lambda 表达式和 map()

假设我们想要将我们的航程距离从海里转换为英里。我们想要将每个航段的距离乘以 6076.12/5280,即 1.150780。

我们可以使用map()函数进行这个计算:

map(lambda x: (start(x),end(x),dist(x)*6076.12/5280), trip)

我们已经定义了一个lambda,它将被map()函数应用于航程中的每个航段。lambda将使用其他lambdas从每个航段中分离起点、终点和英里距离值。它将计算修订后的距离,并从起点、终点和英里距离组装一个新的航段元组。

这与以下生成器表达式完全相同:

((start(x),end(x),dist(x)*6076.12/5280) for x in trip)

我们对生成器表达式中的每个项目进行了相同的处理。

map()函数和生成器表达式之间的重要区别在于,map()函数往往比生成器表达式更快。加速大约减少了 20%的时间。

使用多个序列进行 map()处理

有时,我们会有两个需要相互对应的数据集合。在第四章,处理集合中,我们看到zip()函数如何交错两个序列以创建一系列成对。在许多情况下,我们真的想做这样的事情:

map(function, zip(one_iterable, another_iterable))

我们正在从两个(或更多)并行可迭代对象创建参数元组,并将函数应用于参数tuple。我们也可以这样看待:

(function(x,y) for x,y in zip(one_iterable, another_iterable))

在这里,我们用等效的生成器表达式替换了map()函数。

我们可能会有将整个事情概括到这样的想法:

def star_map(function, *iterables)
 **return (function(*args) for args in zip(*iterables))

有一个更好的方法已经可用于我们。实际上我们并不需要这些技术。让我们看一个替代方法的具体例子。

在第四章,处理集合中,我们看到了我们从 XML 文件中提取的一系列航路点的行程数据。我们需要从这些航路点列表中创建腿,显示每条腿的起点和终点。

以下是一个简化版本,使用了zip()函数应用于一种特殊类型的可迭代对象:

>>> waypoints= range(4)
>>> zip(waypoints, waypoints[1:])
<zip object at 0x101a38c20>
>>> list(_)
[(0, 1), (1, 2), (2, 3)]

我们创建了一个从单个平面列表中提取的成对序列。每对将有两个相邻的值。zip()函数在较短的列表用尽时会正确停止。这种zip( x, x[1:])模式只适用于实现的序列和range()函数创建的可迭代对象。

我们创建了成对,以便我们可以对每对应用haversine()函数来计算路径上两点之间的距离。以下是它在一个步骤序列中的样子:

from ch02_ex3 import lat_lon_kml, float_from_pair, haversine
path= tuple(float_from_pair(lat_lon_kml()))
distances1= 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()函数的一个巧妙特性来简化这个过程,如下所示:

distances2= map(lambda s, e: (s, e, haversine(s, e)), path, path[1:])

请注意,我们已经向map()函数提供了一个函数和两个可迭代对象。map()函数将从每个可迭代对象中取出下一个项目,并将这两个值作为给定函数的参数应用。在这种情况下,给定函数是一个lambda,它从起点、终点和距离创建所需的三元组。

map()函数的正式定义规定,它将使用无限数量的可迭代对象进行星图处理。它将从每个可迭代对象中取出项目,以创建给定函数的参数值元组。

使用 filter()函数来传递或拒绝数据

filter()函数的作用是使用并应用称为谓词的决策函数到集合中的每个值。True的决策意味着该值被传递;否则,该值被拒绝。itertools模块包括filterfalse()作为这一主题的变体。参考第八章,迭代工具模块,了解itertools模块的filterfalse()函数的用法。

我们可以将这个应用到我们的行程数据中,以创建超过 50 海里长的腿的子集,如下所示:

long= 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 0x101d5de50>
>>> sum(_)
23

我们定义了一个简单的lambda来检查一个数字是否是 3 的倍数或 5 的倍数。我们将这个函数应用到一个可迭代对象range(10)上。结果是一个可迭代的数字序列,通过决策规则传递。

lambdaTrue的数字是[0, 3, 5, 6, 9],所以这些值被传递。由于lambda对所有其他数字都为False,它们被拒绝。

这也可以通过执行以下代码来使用生成器表达式来完成:

>>> list(x for x in range(10) if x%3==0 or x%5==0)
[0, 3, 5, 6, 9]

我们可以使用以下集合推导符号来形式化这个过程:

使用 filter()函数来传递或拒绝数据

这意味着我们正在构建一个x值的集合,使得xrange(10)中,且x%3==0 or x%5==0filter()函数和正式的数学集合推导之间有非常优雅的对称性。

我们经常希望使用已定义的函数而不是lambda forms来使用filter()函数。以下是重用先前定义的谓词的示例:

>>> from ch01_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()的函数。然后我们将这个函数应用到一组值上,以传递素数并拒绝集合中的非素数。

这可能是生成素数表的一种非常低效的方法。这种表面上的简单性是律师所说的一种有吸引力的危险物。看起来可能很有趣,但它的扩展性非常差。更好的算法是埃拉托斯特尼筛法;这个算法保留了先前找到的素数,并使用它们来防止大量低效的重新计算。

使用 filter()来识别异常值

在上一章中,我们定义了一些有用的统计函数来计算平均值和标准偏差,并对值进行标准化。我们可以使用这些函数来定位我们旅行数据中的异常值。我们可以将mean()stdev()函数应用到旅行中每个leg的距离值上,以获得人口平均值和标准偏差。

然后我们可以使用z()函数来计算每个leg的标准化值。如果标准化值大于 3,数据就远离了平均值。如果我们拒绝这些异常值,我们就有了一个更统一的数据集,不太可能存在报告或测量错误。

以下是我们可以解决这个问题的方法:

from stats import mean, stdev, z
dist_data = list(map(dist, trip))
μ_d = mean(dist_data)
σ_d = stdev(dist_data)
outlier = lambda leg: z(dist(leg),μ_d,σ_d) > 3
print("Outliers", list(filter(outlier, trip)))

我们将距离函数映射到trip集合中的每个leg。由于我们将对结果进行几项操作,因此必须实现一个list对象。我们不能依赖迭代器,因为第一个函数会消耗它。然后我们可以使用这个提取来计算人口统计学μ_dσ_d,即平均值和标准偏差。

根据统计数据,我们使用异常值 lambda 来filter我们的数据。如果标准化值太大,数据就是异常值。

list(filter(outlier, trip))的结果是两条腿的列表,与人群中其他腿相比相当长。平均距离约为 34 纳米,标准偏差为 24 纳米。没有一次旅行的标准化距离可以小于-1.407。

注意

我们能够将一个相当复杂的问题分解为许多独立的函数,每个函数都可以很容易地独立测试。我们的处理是由更简单的函数组成的。这可以导致简洁、表达力强的函数式编程。

使用带有哨兵值的 iter()函数

内置的iter()函数在collection对象上创建一个迭代器。我们可以使用这个来在collection周围包装一个iterator对象。在许多情况下,我们将允许for语句隐式处理这一点。在一些情况下,我们可能希望显式地创建一个迭代器,以便我们可以将collection的头部与尾部分开。这个函数还可以通过可调用的or函数迭代直到找到一个sentinel值。这个特性有时与文件的read()函数一起使用,以消耗行直到找到某个sentinel值。在这种情况下,给定的函数可能是某个文件的readline()方法。向iter()提供一个callable函数对我们来说有点困难,因为这个函数必须在内部维护状态。这个隐藏的状态是一个开放文件的特性,例如,每个read()readline()函数都会将一些内部状态推进到下一个字符或下一行。

另一个例子是可变集合对象的pop()方法如何对对象进行有状态的更改。以下是使用pop()方法的示例:

>>> tail= iter([1, 2, 3, None, 4, 5, 6].pop, None)
>>> list(tail)
[6, 5, 4]

tail变量设置为一个迭代器,该迭代器在列表[1, 2, 3, None, 4, 5, 6]上进行遍历,该列表将由pop()函数遍历。pop()的默认行为是pop(-1),即元素以相反顺序弹出。当找到sentinel值时,iterator停止返回值。

我们尽可能地想要避免这种内部状态。因此,我们不会试图创造这个特性的用途。

使用 sorted()对数据进行排序

当我们需要按照定义的顺序产生结果时,Python 给了我们两种选择。我们可以创建一个list对象,并使用list.sort()方法对项目进行排序。另一种选择是使用sorted()函数。该函数适用于任何可迭代对象,但它会创建一个最终的list对象作为排序操作的一部分。

sorted()函数可以以两种方式使用。它可以简单地应用于集合。它也可以作为一个高阶函数使用key=参数。

假设我们有来自第四章示例中的旅行数据,与集合一起工作。我们有一个函数,它将为trip的每个leg生成一个包含起点、终点和距离的元组序列。数据如下:

(((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)从我们的旅行数据中提取距离。然后对这个可迭代的数字集合进行排序,以获得从 0.17 nm 到 129.77 nm 的距离。

如果我们想要保持原始的三个元组中的leg和距离在一起,我们可以让sorted()函数应用一个key()函数来确定如何对元组进行排序,如下面的代码片段所示:

>>> sorted(trip, key=dist)
[((35.505665, -76.653664), (35.508335, -76.654999), 0.1731), ((35.028175, -76.682495), (35.031334, -76.682663), 0.1898), ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)]

我们已经对旅行数据进行了排序,使用了一个dist lambda来从每个元组中提取距离。dist函数如下:

dist = lambda leg: leg[2]

这展示了使用简单的lambda将复杂的元组分解为组成元素的能力。

编写高阶函数

我们可以识别三种高阶函数;它们如下:

  • 接受函数作为其参数的函数。

  • 返回函数的函数。Callable类是一个常见的例子。返回生成器表达式的函数可以被认为是一个高阶函数。

  • 接受并返回函数的函数。functools.partial()函数是一个常见的例子。我们将这个保存在第十章中,Functools 模块。装饰器是不同的;我们将这个保存在第十一章中,装饰器设计技术

我们将使用一个高阶函数来扩展这些简单的模式,以转换数据的结构。我们可以进行一些常见的转换,比如以下几种:

  • 包装对象以创建更复杂的对象

  • 将复杂对象解包成其组件

  • 扁平化结构

  • 结构化一个扁平序列

Callable类对象是一个常用的函数返回callable对象的示例。我们将把它看作一种编写灵活函数的方式,可以向其中注入配置参数。

在本章中,我们还将介绍简单的装饰器。我们将把对装饰器的更深入考虑推迟到第十一章,“装饰器设计技术”中。

编写高阶映射和过滤

Python 的两个内置高阶函数map()filter()通常可以处理几乎我们想要处理的所有内容。很难以一般方式优化它们以实现更高的性能。我们将在 Python 3.4 的函数中查看这些函数,比如imap()ifilter()ifilterfalse(),在第八章,“itertools 模块”中。

我们有三种基本等效的表达映射的方式。假设我们有一些函数f(x)和一些对象集合C。我们有三种完全等效的表达映射的方式,它们如下:

  • map()函数:
map(f, C)
  • 生成器表达式:
(f(x) for x in C)
  • 生成器函数:
def mymap(f, C):
    for x in C:
        yield f(x)
mymap(f, C)

同样,我们有三种将filter函数应用于collection的方式,它们都是等效的:

  • filter()函数:
filter(f, C)
  • 生成器表达式:
(x for x in C if f(x))
  • 生成器函数:
def myfilter(f, C):
    for x in C:
        if f(x):
            yield x
myfilter(f, C)

有一些性能差异;map()filter()函数最快。更重要的是,有不同类型的扩展适用于这些映射和过滤设计,它们如下:

  • 我们可以创建一个更复杂的函数g(x),它应用于每个元素,或者我们可以在处理之前将函数应用于集合C。这是最一般的方法,适用于所有三种设计。这是我们的函数式设计能量的主要投入点。

  • 我们可以微调for循环。一个明显的调整是通过在生成器表达式中添加if子句来将映射和过滤合并为单个操作。我们还可以合并mymap()myfilter()函数,以合并映射和过滤。

我们可以做出的深刻改变是改变循环处理的数据结构。我们有许多设计模式,包括包装、解包(或提取)、扁平化和结构化。我们在之前的章节中已经看过了其中一些技术。

在设计结合太多转换的映射时,我们需要谨慎行事。尽可能地,我们希望避免创建不够简洁或表达单一思想的函数。由于 Python 没有优化编译器,我们可能被迫通过组合函数来手动优化慢应用程序。我们需要在对性能表现不佳的程序进行分析后,才会不情愿地进行这种优化。

在映射时解包数据

当我们使用这样的构造(f(x) for x, y in C)时,我们在for语句中使用了多重赋值来解包一个多值元组,然后应用一个函数。整个表达式是一个映射。这是一种常见的 Python 优化,用于改变结构并应用函数。

我们将使用来自第四章,“处理集合”的旅行数据。以下是一个解包映射的具体示例:

def convert(conversion, trip):
 **return (conversion(distance) for start, end, distance in trip)

这个高阶函数将由我们可以应用于原始数据的转换函数支持,如下所示:

to_miles = lambda nm: nm*5280/6076.12
to_km = lambda nm: nm*1.852
to_nm = lambda nm: nm

然后可以如下使用该函数提取距离并应用转换函数:

convert(to_miles, trip)

当我们解包时,结果将是一系列浮点值。结果如下:

[20.397120559090908, 35.37291511060606, ..., 44.652462240151515]

这个convert()函数对我们的起点-终点-距离行程数据结构非常具体,因为for循环分解了那个三元组。

我们可以构建一个更一般的解决方案,用于在映射设计模式中进行解包。它有点复杂。首先,我们需要像下面的代码片段一样的通用分解函数:

fst= lambda x: x[0]
snd= lambda x: x[1]
sel2= lambda x: x[2]

我们希望能够表示f(sel2(s_e_d)) for s_e_d in trip。这涉及到函数组合;我们正在组合一个像to_miles()这样的函数和一个像sel2()这样的选择器。我们可以使用另一个 lambda 在 Python 中表示函数组合,如下所示:

to_miles= lambda s_e_d: to_miles(sel2(s_e_d))

这给我们一个更长但更一般的解包版本,如下所示:

to_miles(s_e_d) for s_e_d in trip

虽然这个第二个版本有点更一般化,但似乎并不是特别有用。然而,当与特别复杂的元组一起使用时,它可能会很方便。

关于我们的高阶convert()函数需要注意的是,我们接受一个函数作为参数,并返回一个函数作为结果。convert()函数不是一个生成器函数;它不会yield任何东西。convert()函数的结果是一个必须进行评估以累积个别值的生成器表达式。

相同的设计原则适用于创建混合过滤器而不是映射。我们会在返回的生成器表达式的if子句中应用过滤器。

当然,我们可以结合映射和过滤来创建更复杂的函数。创建更复杂的函数来限制处理的数量似乎是个好主意。但这并不总是正确的;一个复杂的函数可能无法超越简单的map()filter()函数的嵌套使用性能。通常,我们只想创建一个更复杂的函数,如果它封装了一个概念,并且使软件更容易理解。

在映射时包装额外的数据

当我们使用这样的结构((f(x), x) for x in C)时,我们进行了包装以创建一个多值元组,同时应用了映射。这是一种常见的技术,可以保存派生结果以创建具有避免重新计算的好处的构造,而不会产生复杂的状态更改对象的责任。

这是第四章处理集合中显示的示例的一部分,用于从点的路径创建行程数据。代码如下:

from ch02_ex3 import float_from_pair, lat_lon_kml, limits, haversine, legs
path= float_from_pair(lat_lon_kml())
trip= tuple((start, end, round(haversine(start, end),4)) for start,end in legs(iter(path)))

我们可以稍微修改这个来创建一个将wrapping与其他函数分离的高阶函数。我们可以定义一个这样的函数:

def cons_distance(distance, legs_iter):
 **return ((start, end, round(distance(start,end),4)) for start, end in legs_iter)

这个函数将每个leg分解为两个变量,startend。这些将与给定的distance()函数一起用于计算点之间的距离。结果将构建一个更复杂的三元组,其中包括原始的两个leg,以及计算出的结果。

然后,我们可以重写我们的行程分配,应用haversine()函数来计算距离,如下所示:

path= float_from_pair(lat_lon_kml())
trip2= tuple(cons_distance(haversine, legs(iter(path))))

我们用高阶函数cons_distance()替换了一个生成器表达式。这个函数不仅接受一个函数作为参数,还返回一个生成器表达式。

这个稍微不同的表述如下:

def cons_distance3(distance, legs_iter):
 **return ( leg+(round(distance(*leg),4),) for leg in legs_iter)

这个版本使得从旧对象构建新对象的过程更加清晰。我们正在迭代行程的各个部分。我们正在计算leg上的距离。我们正在用leg和距离连接起来构建新的结构。

由于这两个cons_distance()函数都接受一个函数作为参数,我们可以利用这个特性来提供另一种距离公式。例如,我们可以使用math.hypot(lat(start)-lat(end), lon(start)-lon(end))方法来计算每个leg上的不太准确的平面距离。

在第十章,“Functools 模块”中,我们将展示如何使用partial()函数为haversine()函数的R参数设置一个值,从而改变计算距离的单位。

在映射时扁平化数据

在第四章,“处理集合”中,我们看了将嵌套的元组结构扁平化为单个可迭代对象的算法。当时我们的目标只是重新构造一些数据,而不进行任何真正的处理。我们可以创建混合解决方案,将函数与扁平化操作结合起来。

假设我们有一块文本,我们想将其转换为数字的平面序列。文本如下所示:

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

要将字符串转换为数字,我们必须应用转换函数,并解开其原始格式的阻塞结构,使用以下代码片段:

def numbers_from_rows(conversion, text):
 **return (conversion(v) for line in text.splitlines() for v in line.split())

此函数具有conversion参数,该参数是应用于将被发出的每个值的函数。这些值是通过使用上面显示的算法进行扁平化而创建的。

我们可以在以下类型的表达式中使用numbers_from_rows()函数:

print(list(numbers_from_rows(float, text)))

在这里,我们使用内置的float()从文本块中创建一个浮点数值列表。

我们有许多选择,可以使用混合高阶函数和生成器表达式。例如,我们可以将其表示如下:

map(float, v for line in text.splitlines() for v in line.split())

如果这有助于我们理解算法的整体结构,那可能会有所帮助。这个原则被称为分块;具有有意义名称的函数的细节可以被抽象化,我们可以在新的上下文中使用该函数。虽然我们经常使用高阶函数,但有时生成器表达式可能更清晰。

在过滤数据的同时构造数据

前三个示例将额外处理与映射结合在一起。将处理与过滤结合起来似乎不像与映射结合那样具有表现力。我们将详细查看一个示例,以表明,尽管它很有用,但似乎没有与映射和处理结合的用例那么引人注目。

在第四章,“处理集合”中,我们看了算法的结构。我们可以将过滤器与结构算法轻松地合并为单个复杂函数。以下是我们首选函数的版本,用于对可迭代对象的输出进行分组:

def group_by_iter(n, iterable):
 **row= tuple(next(iterable) for i in range(n))
 **while row:
 **yield row
 **row= tuple(next(iterable) for i in range(n))

这将尝试从可迭代对象中获取n个项目的元组。如果元组中有任何项目,则它们将作为结果可迭代对象的一部分产生。原则上,该函数然后对原始可迭代对象中剩余的项目进行递归操作。由于递归在 Python 中相对低效,我们已将其优化为显式的while循环。

我们可以按以下方式使用此函数:

 **group_by_iter(7, filter( lambda x: x%3==0 or x%5==0, range(100)))

这将对由range()函数创建的可迭代对象应用filter()函数的结果进行分组。

我们可以将分组和过滤合并为一个单一函数,在单个函数体中执行这两个操作。对group_by_iter()的修改如下:

def group_filter_iter(n, predicate, iterable):
 **data = filter(predicate, iterable)
 **row= tuple(next(data) for i in range(n))
 **while row:
 **yield row
 **row= tuple(next(data) for i in range(n))

此函数将过滤谓词函数应用于源可迭代对象。由于过滤器输出本身是非严格可迭代对象,因此data变量不会提前计算;数据的值将根据需要创建。这个函数的大部分与上面显示的版本相同。

我们可以稍微简化我们使用此函数的上下文,如下所示:

group_filter_iter(7, lambda x: x%3==0 or x%5==0, range(1,100))

在这里,我们应用了过滤谓词,并将结果分组在一个函数调用中。在filter()函数的情况下,将过滤器与其他处理一起应用很少是一个明显的优势。似乎一个单独的、可见的filter()函数比一个组合函数更有帮助。

编写生成器函数

许多函数可以被表达为生成器表达式。事实上,我们已经看到几乎任何一种映射或过滤都可以作为生成器表达式来完成。它们也可以使用内置的高阶函数,比如map()filter(),或者作为生成器函数来完成。在考虑多语句生成器函数时,我们需要小心,不要偏离函数式编程的指导原则:无状态函数评估。

在 Python 中进行函数式编程意味着在纯函数式编程和命令式编程之间走一条很窄的路。我们需要确定并隔离必须诉诸命令式 Python 代码的地方,因为没有纯函数式的替代方案可用。

当我们需要 Python 的语句特性时,我们有义务编写生成器函数。像下面这样的特性在生成器表达式中是不可用的:

  • 使用with上下文来处理外部资源。我们将在第六章递归和归约中讨论文件解析时看到这一点。

  • while语句可以比for语句更灵活地进行迭代。这个例子在在映射时展开数据部分中已经展示过。

  • 使用breakreturn语句来实现提前终止循环的搜索。

  • 使用try-except结构来处理异常。

  • 内部函数定义。我们在第一章介绍函数式编程和第二章介绍一些函数式特性中已经看过了这一点。我们还将在第六章递归和归约中重新讨论它。

  • 一个非常复杂的if-elif序列。试图通过if-else条件表达式来表达多个选择可能会变得复杂。

  • 在 Python 的边缘,我们有一些不常用的特性,比如for-elsewhile-elsetry-elsetry-else-finally。这些都是语句级别的特性,不适用于生成器表达式。

break语句最常用于提前结束集合的处理。我们可以在满足某些条件的第一项后结束处理。这是我们正在查看的any()函数的一个版本,用于查找具有给定属性的值的存在。我们也可以在处理一些较大的项目后结束,但不是全部。

找到单个值可以简洁地表示为min(some-big-expression)max(something big)。在这些情况下,我们承诺要检查所有的值,以确保我们已经正确地找到了最小值或最大值。

在一些情况下,我们可以使用first(function, collection)函数,其中第一个值为True就足够了。我们希望尽早终止处理,节省不必要的计算。

我们可以定义一个函数如下:

def first(predicate, collection):
 **for x in collection:
 **if predicate(x): return x

我们已经遍历了collection,应用了给定的谓词函数。如果谓词为True,我们将返回相关的值。如果我们耗尽了collection,将返回None的默认值。

我们也可以从PyPi下载这个版本。第一个模块包含了这个想法的一个变种。更多详情请访问:pypi.python.org/pypi/first

这可以作为一个辅助函数,用于确定一个数字是否是质数。以下是一个测试数字是否为质数的函数:

import math
def isprimeh(x):
 **if x == 2: return True
 **if x % 2 == 0: return False
 **factor= first( lambda n: x%n==0, range(3,int(math.sqrt(x)+.5)+1,2))
 **return factor is None

这个函数处理了关于数字 2 是质数以及每个其他偶数是合数的一些边缘情况。然后,它使用上面定义的first()函数来定位给定集合中的第一个因子。

first()函数返回因子时,实际数字并不重要。对于这个特定的例子来说,它的存在才是重要的。因此,如果没有找到因子,isprimeh()函数将返回True

我们可以做类似的事情来处理数据异常。以下是map()函数的一个版本,它还过滤了不良数据:

def map_not_none(function, iterable):
 **for x in iterable:
 **try:
 **yield function(x)
 **except Exception as e:
 **pass # print(e)

这个函数遍历可迭代对象中的项目。它尝试将函数应用于项目;如果没有引发异常,则产生新值。如果引发异常,则默默地丢弃有问题的值。

在处理包含不适用或缺失值的数据时,这可能很方便。我们尝试处理它们并丢弃无效的值,而不是制定复杂的过滤器来排除这些值。

我们可以使用map()函数将非 None值映射为以下形式:

data = map_not_none(int, some_source)

我们将int()函数应用于some_source中的每个值。当some_source参数是一个字符串的可迭代集合时,这可以是一个拒绝不表示数字的字符串的方便方法。

使用可调用对象构建高阶函数

我们可以将高阶函数定义为Callable类的实例。这建立在编写生成器函数的想法上;我们将编写可调用对象,因为我们需要 Python 的语句特性。除了使用语句外,我们在创建高阶函数时还可以应用静态配置。

Callable类定义的重要之处在于,由class语句创建的类对象本质上定义了一个发出函数的函数。通常,我们将使用callable对象来创建一个复合函数,将两个其他函数组合成相对复杂的东西。

为了强调这一点,考虑以下类:

from collections.abc import Callable
class NullAware(Callable):
 **def __init__(self, some_func):
 **self.some_func= some_func
 **def __call__(self, arg):
 **return None if arg is None else self.some_func(arg)

这个类创建了一个名为NullAware()的函数,它是一个高阶函数,用于创建一个新的函数。当我们评估NullAware(math.log)表达式时,我们正在创建一个可以应用于参数值的新函数。__init__()方法将保存给定的函数在结果对象中。

__call__()方法是对结果函数进行评估的方法。在这种情况下,创建的函数将优雅地容忍None值而不会引发异常。

常见的方法是创建新函数并将其保存以备将来使用,方法是给它分配一个名称,如下所示:

null_log_scale= NullAware(math.log)

这将创建一个新的函数并分配名称null_log_scale()。然后我们可以在另一个上下文中使用该函数。看一下以下示例:

>>> some_data = [10, 100, None, 50, 60]
>>> scaled = map(null_log_scale, some_data)
>>> list(scaled)
[2.302585092994046, 4.605170185988092, None, 3.912023005428146, 4.0943445622221]

一个不太常见的方法是在一个表达式中创建并使用发出的函数,如下所示:

>>> scaled= map(NullAware( math.log ), some_data)
>>> list(scaled)
[2.302585092994046, 4.605170185988092, None, 3.912023005428146, 4.0943445622221]

NullAware( math.log )的评估创建了一个函数。然后,这个匿名函数被map()函数用于处理一个可迭代的some_data

这个例子的__call__()方法完全依赖于表达式评估。这是一种优雅而整洁的方式,用于定义由低级组件函数构建而成的复合函数。在处理标量函数时,有一些复杂的设计考虑。当我们处理可迭代集合时,我们必须更加小心。

确保良好的函数设计

无状态函数式编程的概念在使用 Python 对象时需要一些小心。对象通常是有状态的。事实上,可以说,面向对象编程的整个目的是将状态变化封装到类定义中。因此,当使用 Python 类定义来处理集合时,我们发现自己在函数式编程和命令式编程之间被拉向相反的方向。

使用Callable创建复合函数的好处在于,当使用生成的复合函数时,语法会稍微简单一些。当我们开始使用可迭代的映射或缩减时,我们必须意识到我们如何以及为什么引入有状态的对象。

我们将回到上面显示的sum_filter_f()复合函数。这是一个基于Callable类定义构建的版本:

from collections.abc import Callable
class Sum_Filter(Callable):
 **__slots__ = ["filter", "function"]
 **def __init__(self, filter, function):
 **self.filter= filter
 **self.function= function
 **def __call__(self, iterable):
 **return sum(self.function(x) for x in iterable ifself.filter(x))

我们已经导入了抽象超类Callable,并将其用作我们类的基础。我们在这个对象中定义了确切的两个插槽;这对我们使用函数作为有状态对象施加了一些限制。这并不会阻止对生成的对象进行所有修改,但它限制了我们只能使用两个属性。尝试添加属性会导致异常。

初始化方法__init__()将两个函数名filterfunction存储在对象的实例变量中。__call__()方法返回一个基于使用两个内部函数定义的生成器表达式的值。self.filter()函数用于传递或拒绝项目。self.function()函数用于转换由filter()函数传递的对象。

这个类的一个实例是一个具有两个策略函数的函数。我们可以按照以下方式创建一个实例:

count_not_none = Sum_Filter(lambda x: x is not None, lambda x: 1)

我们构建了一个名为count_not_none()的函数,用于计算序列中的non-None值。它通过使用lambda传递non-None值和一个使用常量 1 而不是实际值的函数来实现这一点。

通常,这个count_not_none()对象会像任何其他 Python 函数一样行为。使用起来比我们之前的sum_filter_f()例子要简单一些。

我们可以这样使用count_not_None()函数:

N= count_not_none(data)

不使用sum_filter_f()函数:

N= sum_filter_f(valid, count_, data)

基于Callablecount_not_none()函数不需要像传统函数那样多的参数。这使得它表面上更容易使用。然而,这也可能使它有些更加晦涩,因为函数工作的细节在源代码的两个地方:一个是函数作为Callable类的实例创建的地方,另一个是函数被使用的地方。

看一些设计模式

max()min()sorted()函数在没有key=函数的情况下有默认行为。它们可以通过提供一个定义如何从可用数据计算键的函数来进行自定义。在我们的许多例子中,key()函数是对可用数据的简单提取。这不是必须的;key()函数可以做任何事情。

想象一下以下方法:max(trip, key=random.randint())。通常,我们尽量不要使用做一些晦涩操作的key()函数。

使用key=函数是一种常见的设计模式。我们的函数可以轻松地遵循这种模式。

我们还看过可以用来简化使用高阶函数的lambda forms。使用lambda forms的一个重要优势是它非常贴近函数式范式。当编写更传统的函数时,我们可能会创建命令式程序,这可能会使本来简洁和表达力强的函数式设计变得混乱。

我们已经看过几种与值集合一起工作的高阶函数。在前几章中,我们已经暗示了几种不同的高阶collectionscalar函数的设计模式。以下是一个广泛的分类:

  • 返回一个生成器。高阶函数可以返回一个生成器表达式。我们认为这个函数是高阶的,因为它没有返回scalar值或值的collections。其中一些高阶函数也接受函数作为参数。

  • 充当生成器。一些函数示例使用yield语句使它们成为一流的生成器函数。生成器函数的值是一个惰性评估的可迭代值集合。我们认为生成器函数本质上与返回生成器表达式的函数没有区别。两者都是非严格的。两者都可以产生一系列值。因此,我们也将考虑生成器函数为高阶函数。内置函数如map()filter()属于这一类。

  • 创建一个集合。一些函数必须返回一个实例化的集合对象:listtuplesetmapping。如果这些函数的参数中包含一个函数,那么这些函数可以是高阶函数。否则,它们只是普通的函数,恰好可以与collections一起使用。

  • 减少集合。一些函数与可迭代对象(或collection对象)一起工作,并创建一个scalar结果。len()sum()函数就是这样的例子。当我们接受一个函数作为参数时,我们可以创建高阶减少。我们将在下一章中回顾这一点。

  • 标量。一些函数作用于单个数据项。如果它们接受另一个函数作为参数,那么它们可以是高阶函数。

在设计我们自己的软件时,我们可以在这些已建立的设计模式中进行选择。

总结

在本章中,我们看到了两个高阶函数:max()min()。我们还研究了两个核心的高阶函数,map()filter()。我们还看了sorted()

我们还看了如何使用高阶函数来转换数据的结构。我们可以执行几种常见的转换,包括包装、解包、扁平化和不同类型的结构序列。

我们看了三种定义自己的高阶函数的方法,如下所示:

  • def语句。类似的是将lambda form分配给一个变量。

  • Callable类定义为一种发出复合函数的函数。

  • 我们还可以使用装饰器来发出复合函数。我们将在第十一章装饰器设计技术中回顾这一点。

在下一章中,我们将探讨通过递归实现纯函数迭代的概念。我们将使用 Python 结构对纯函数技术进行几种常见的改进。我们还将探讨将集合减少到单个值的相关问题。

第六章:递归和归约

在之前的章节中,我们已经看过几种相关的处理设计;其中一些如下:

  • 从集合中创建集合的映射和过滤

  • 从集合中创建标量值的归约

这种区别体现在诸如map()filter()之类的函数中,这些函数完成了第一种集合处理。还有几个专门的归约函数,包括min()max()len()sum()。还有一个通用的归约函数,functools.reduce()

我们还将考虑collections.Counter()函数作为一种归约运算符。它本身并不产生单个标量值,但它确实创建了数据的新组织形式,消除了一些原始结构。从本质上讲,它是一种计数分组操作,与计数归约更类似于映射。

在本章中,我们将更详细地研究归约函数。从纯粹的功能角度来看,归约是递归地定义的。因此,我们将首先研究递归,然后再研究归约算法。

一般来说,函数式编程语言编译器会优化递归函数,将函数尾部的调用转换为循环。这将大大提高性能。从 Python 的角度来看,纯递归是有限的,因此我们必须手动进行尾调用优化。Python 中可用的尾调用优化技术是使用显式的for循环。

我们将研究许多归约算法,包括sum()count()max()min()。我们还将研究collections.Counter()函数和相关的groupby()归约。我们还将研究解析(和词法扫描)是适当的归约,因为它们将标记序列(或字符序列)转换为具有更复杂属性的高阶集合。

简单的数值递归

我们可以认为所有数值运算都是通过递归定义的。要了解更多,请阅读定义数字的基本特征的皮亚诺公理en.wikipedia.org/wiki/Peano_axioms是一个开始的地方。

从这些公理中,我们可以看到加法是使用更原始的下一个数字或数字的后继n的概念递归地定义的,Simple numerical recursions

为了简化演示,我们假设我们可以定义一个前驱函数,Simple numerical recursions,使得Simple numerical recursions,只要Simple numerical recursions

两个自然数之间的加法可以递归地定义如下:

Simple numerical recursions

如果我们使用更常见的Simple numerical recursionsSimple numerical recursions而不是Simple numerical recursionsSimple numerical recursions,我们可以看到Simple numerical recursions

这在 Python 中可以很好地转换,如下面的命令片段所示:

def add(a,b):
 **if a == 0: return b
 **else: return add(a-1, b+1)

我们只是将常见的数学符号重新排列成 Python。if子句放在左边而不是右边。

通常,我们不会在 Python 中提供自己的函数来进行简单的加法。我们依赖于 Python 的底层实现来正确处理各种类型的算术。我们的观点是,基本的标量算术可以递归地定义。

所有这些递归定义都包括至少两种情况:非递归情况,其中函数的值直接定义,以及递归情况,其中函数的值是从对具有不同值的函数的递归评估中计算出来的。

为了确保递归会终止,重要的是要看递归情况如何计算接近定义的非递归情况的值。我们在这里的函数中通常省略了参数值的约束。例如,前面命令片段中的add()函数可以包括assert a>= and b>=0来建立输入值的约束。

在没有这些约束的情况下,a-1不能保证接近a == 0的非递归情况。

在大多数情况下,这是显而易见的。在少数情例中,可能难以证明。一个例子是 Syracuse 函数。这是终止不明确的病态情况之一。

实现尾递归优化

在某些函数的情况下,递归定义是经常被提及的,因为它简洁而富有表现力。最常见的例子之一是factorial()函数。

我们可以看到,这可以被重写为 Python 中的一个简单递归函数,从以下公式:

实现尾递归优化

前面的公式可以通过以下命令在 Python 中执行:

def fact(n):
 **if n == 0: return 1
 **else: return n*fact(n-1)

这样做的好处是简单。在 Python 中,递归限制人为地限制了我们;我们不能计算大约 fact(997)以上的任何值。1000!的值有 2568 位数,通常超出了我们的浮点容量;在某些系统上,这大约是实现尾递归优化。从实用的角度来看,通常会切换到log gamma函数,它在处理大浮点值时效果很好。

这个函数演示了典型的尾递归。函数中的最后一个表达式是对具有新参数值的函数的调用。优化编译器可以用一个很快执行的循环替换函数调用堆栈管理。

由于 Python 没有优化编译器,我们必须着眼于标量递归并对其进行优化。在这种情况下,函数涉及从nn-1的增量变化。这意味着我们正在生成一系列数字,然后进行缩减以计算它们的乘积。

走出纯粹的函数处理,我们可以定义一个命令式的facti()计算如下:

def facti(n):
 **if n == 0: return 1
 **f= 1
 **for i in range(2,n):
 **f= f*i
 **return f

这个阶乘函数的版本将计算超过 1000!的值(例如,2000!有 5733 位数)。它并不是纯粹的函数。我们已经将尾递归优化为一个有状态的循环,取决于i变量来维护计算的状态。

总的来说,我们在 Python 中被迫这样做,因为 Python 无法自动进行尾递归优化。然而,有些情况下,这种优化实际上并不会有所帮助。我们将看几种情况。

保留递归

在某些情况下,递归定义实际上是最优的。一些递归涉及分而治之的策略,可以将工作量最小化从保留递归保留递归。其中一个例子是平方算法的指数运算。我们可以正式地将其陈述如下:

保留递归

我们将这个过程分成三种情况,可以很容易地在 Python 中写成递归。看一下以下命令片段:

def fastexp(a, n):
 **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(a, 0)方法被定义为值为 1。另外两种情况采取了两种不同的方法。对于奇数,fastexp()方法被递归定义。指数n减少了 1。简单的尾递归优化对这种情况有效。

然而,对于偶数,fastexp()递归使用n/2,将问题分成原始大小的一半。由于问题规模减小了一半,这种情况会显著加快处理速度。

我们不能简单地将这种函数重新构建为尾递归优化循环。由于它已经是最优的,我们实际上不需要进一步优化。Python 中的递归限制将强加约束Leaving recursion in place,这是一个宽松的上限。

处理困难的尾递归优化

我们可以递归地查看斐波那契数的定义。以下是一个广泛使用的第n个斐波那契数的定义:

Handling difficult tail-call optimization

给定的斐波那契数,Handling difficult tail-call optimization,被定义为前两个数的和,Handling difficult tail-call optimization。这是一个多重递归的例子:它不能简单地优化为简单的尾递归。然而,如果我们不将其优化为尾递归,我们会发现它太慢而无法使用。

以下是一个天真的实现:

def fib(n):
 **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)方法。斐波那契函数的两个递归使用将使得计算量翻倍。

由于 Python 的从左到右的评估规则,我们可以计算到大约fib(1000)的值。然而,我们必须要有耐心。非常有耐心。

以下是一个替代方案,它重新陈述了整个算法,使用有状态变量而不是简单的递归:

def fibi(n):
 **if n == 0: return 0
 **if n == 1: return 1
 **f_n2, f_n1 = 1, 1
 **for i in range(3, n+1):
 **f_n2, f_n1 = f_n1, f_n2+f_n1
 **return f_n1

注意

我们的有状态版本的这个函数从 0 开始计数,不像递归,递归是从初始值n开始计数。它保存了用于计算Handling difficult tail-call optimizationHandling difficult tail-call optimization的值。这个版本比递归版本快得多。

重要的是,我们无法轻松地通过明显的重写来优化递归。为了用命令式版本替换递归,我们必须仔细研究算法,确定需要多少个有状态的中间变量。

通过递归处理集合

在处理集合时,我们也可以递归地定义处理。例如,我们可以递归地定义map()函数。形式主义如下所示:

Processing collections via recursion

我们已经将函数映射到空集合定义为一个空序列。我们还指定了将函数应用于集合可以通过三个步骤的表达式进行递归定义。首先,将函数应用于除最后一个元素之外的所有集合,创建一个序列对象。然后将函数应用于最后一个元素。最后,将最后的计算附加到先前构建的序列中。

以下是较旧的map()函数的纯递归函数版本:

def mapr(f, collection):
 **if len(collection) == 0: return []
 **return mapr(f, collection[:-1]) + [f(collection[-1])]

mapr(f,[])方法的值被定义为一个空的list对象。mapr()函数对非空列表的值将应用函数到列表的最后一个元素,并将其附加到从mapr()函数递归应用到列表头部构建的列表中。

我们必须强调这个mapr()函数实际上创建了一个list对象,类似于 Python 中较旧的map()函数。Python 3 中的map()函数是可迭代的,并不是尾递归优化的很好的例子。

虽然这是一个优雅的形式主义,但它仍然缺乏所需的尾递归优化。尾递归优化允许我们超过 1000 的递归深度,并且比这种天真的递归执行得更快。

集合的尾递归优化

我们有两种处理集合的一般方法:我们可以使用一个返回生成器表达式的高阶函数,或者我们可以创建一个使用for循环来处理集合中的每个项目的函数。这两种基本模式非常相似。

以下是一个行为类似于内置map()函数的高阶函数:

def mapf(f, C):
 **return (f(x) for x in C)

我们返回了一个生成器表达式,它产生了所需的映射。这使用了一个显式的for循环作为一种尾调用优化。

以下是一个具有相同值的生成器函数:

def mapg(f, C):
 **for x in C:
 **yield f(x)

这使用了一个完整的for语句进行所需的优化。

在这两种情况下,结果是可迭代的。我们必须在此之后做一些事情来实现一个序列对象:

>>> list(mapg(lambda x:2**x, [0, 1, 2, 3, 4]))
[1, 2, 4, 8, 16]

为了性能和可伸缩性,在 Python 程序中基本上需要这种尾调用优化。它使代码不纯粹功能。然而,好处远远超过了纯度的缺失。为了获得简洁和表达式功能设计的好处,有助于将这些不纯粹的函数视为适当的递归。

从实用的角度来看,这意味着我们必须避免用额外的有状态处理来使集合处理函数混乱。即使我们程序的一些元素不纯粹,函数式编程的核心原则仍然有效。

减少和折叠 - 从多个到一个

我们可以认为sum()函数具有以下类型的定义:

我们可以说一个集合的总和对于一个空集合是 0。对于一个非空集合,总和是第一个元素加上剩余元素的总和。

从多个到一个的减少和折叠

同样地,我们可以使用两种情况递归地计算一组数字的乘积:

从多个到一个的减少和折叠

基本情况将空序列的乘积定义为 1。递归情况将乘积定义为第一个项目乘以剩余项目的乘积。

我们在序列的每个项目之间有效地折叠了×+运算符。此外,我们对项目进行了分组,以便处理将从右到左进行。这可以称为将集合减少为单个值的右折叠方式。

在 Python 中,可以递归地定义乘积函数如下:

def prodrc(collection):
 **if len(collection) == 0: return 1
 **return collection[0] * prodrc(collection[1:])

从技术上讲,这是正确的。这是从数学符号转换为 Python 的一个微不足道的重写。然而,它不够优化,因为它倾向于创建大量中间的list对象。它也仅限于与显式集合一起使用;它不能轻松地与iterable对象一起使用。

我们可以稍微修改这个函数,使其适用于可迭代对象,从而避免创建任何中间的collection对象。以下是一个可以与可迭代数据源一起使用的适当递归乘积函数:

def prodri(iterable):
 **try:
 **head= next(iterable)
 **except StopIteration:
 **return 1
 **return head*prodri(iterable)

我们不能使用len()函数来查询可迭代对象有多少个元素。我们所能做的就是尝试提取iterable序列的头部。如果序列中没有项目,那么任何获取头部的尝试都将引发StopIteration异常。如果有一个项目,那么我们可以将该项目乘以序列中剩余项目的乘积。对于演示,我们必须明确地使用iter()函数从一个具体化的sequence对象中创建一个可迭代对象。在其他情境中,我们可能会有一个可迭代的结果可以使用。以下是一个例子:

>>> prodri(iter([1,2,3,4,5,6,7]))
5040

这个递归定义不依赖于 Python 的显式状态或其他命令式特性。虽然它更加纯粹功能,但它仍然局限于处理少于 1000 个项目的集合。从实用的角度来看,我们可以使用以下类型的命令式结构来进行减少函数:

def prodi(iterable):
 **p= 1
 **for n in iterable:
 **p *= n
 **return p

这缺乏递归限制。它包括所需的尾调用优化。此外,这将同样适用于sequence对象或可迭代对象。

在其他函数式语言中,这被称为foldl操作:运算符从左到右折叠到可迭代的值集合中。这与通常称为foldr操作的递归公式不同,因为在集合中的评估是从右到左进行的。

对于具有优化编译器和惰性评估的语言,fold-left 和 fold-right 的区别决定了中间结果的创建方式。这可能具有深远的性能影响,但这种区别可能并不明显。例如,fold-left 可能会立即消耗和处理序列中的第一个元素。然而,fold-right 可能会消耗序列的头部,但在整个序列被消耗之前不进行任何处理。

分组缩减-从多到少

一个非常常见的操作是通过某个键或指示器对值进行分组的缩减。在SQL中,这通常称为SELECT GROUP BY操作。原始数据按某些列的值分组,然后对其他列应用缩减(有时是聚合函数)。SQL 聚合函数包括SUMCOUNTMAXMIN

统计摘要称为模式,是按独立变量分组的计数。Python 为我们提供了几种在计算分组值的缩减之前对数据进行分组的方法。我们将首先看两种获取分组数据的简单计数的方法。然后我们将看看计算分组数据的不同摘要的方法。

我们将使用我们在第四章与集合一起工作中计算的行程数据。这些数据最初是一系列纬度-经度航点。我们重新构造它以创建由leg的起点、终点和距离表示的航段。数据如下所示:

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

一个常见的操作,可以作为有状态的映射或作为一个实现、排序的对象来处理,就是计算一组数据值的模式。当我们查看我们的行程数据时,变量都是连续的。要计算模式,我们需要量化所覆盖的距离。这也被称为分箱:我们将数据分组到不同的箱中。分箱在数据可视化应用中很常见。在这种情况下,我们将使用 5 海里作为每个箱的大小。

可以使用生成器表达式生成量化距离:

quantized= (5*(dist//5) for start,stop,dist in trip)

这将把每个距离除以 5-丢弃任何小数-然后乘以 5 来计算代表四舍五入到最近 5 海里的距离的数字。

使用 Counter 构建映射

collections.Counter方法这样的映射是进行创建计数(或总数)的优化的好方法,这些计数(或总数)是按集合中的某个值分组的。对于分组数据的更典型的函数式编程解决方案是对原始集合进行排序,然后使用递归循环来识别每个组的开始。这涉及将原始数据实现化,执行使用 Counter 构建映射排序,然后进行缩减以获得每个键的总和或计数。

我们将使用以下生成器创建一个简单的距离序列,转换为箱:

quantized= (5*(dist//5) for start,stop,dist in trip)

我们使用截断的整数除法将每个距离除以 5,然后乘以 5,以创建一个四舍五入到最近 5 英里的值。

以下表达式创建了一个从距离到频率的映射

from collections import Counter
Counter(quantized)

这是一个有状态的对象,由技术上的命令式面向对象编程创建。然而,由于它看起来像一个函数,它似乎很适合基于函数式编程思想的设计。

如果我们打印Counter(quantized).most_common()函数,我们将看到以下结果:

[(30.0, 15), (15.0, 9), (35.0, 5), (5.0, 5), (10.0, 5), (20.0, 5), (25.0, 5), (0.0, 4), (40.0, 3), (45.0, 3), (50.0, 3), (60.0, 3), (70.0, 2), (65.0, 1), (80.0, 1), (115.0, 1), (85.0, 1), (55.0, 1), (125.0, 1)]

最常见的距离约为 30 海里。记录的最短leg是 4 个 0 的实例。最长的航段是 125 海里。

请注意,你的输出可能与此略有不同。most_common()函数的结果按频率排序;相同频率的箱可能以任何顺序出现。这 5 个长度可能不总是按照所示的顺序排列:

(35.0, 5), (5.0, 5), (10.0, 5), (20.0, 5), (25.0, 5)

通过排序构建映射

如果我们想要在不使用Counter类的情况下实现这一点,我们可以使用更多基于函数的排序和分组方法。以下是一个常见的算法:

def group_sort(trip):
 **def group(data):
 **previous, count = None, 0
 **for d in sorted(data):
 **if d == previous:
 **count += 1
 **elif previous is not None: # and d != previous
 **yield previous, count
 **previous, count = d, 1
 **elif previous is None:
 **previous, count = d, 1
 **else:
 **raise Exception("Bad bad design problem.")
 **yield previous, count
 **quantized= (5*(dist//5) for start,stop,dist in trip)
 **return dict(group(quantized))

内部的group()函数遍历排序后的数据项序列。如果给定项已经被看到 - 它与previous中的值匹配 - 那么计数器可以递增。如果给定项与前一个值不匹配,并且前一个值不是None,那么我们就有了值的变化;我们可以输出前一个值和计数,并开始对新值进行新的累积计数。第三个条件只适用一次:如果前一个值从未被设置过,那么这是第一个值,我们应该保存它。

函数的最后一行从分组的项中创建一个字典。这个字典将类似于一个 Counter 字典。主要的区别在于Counter()函数有一个most_common()方法函数,而默认字典则没有。

elif previous is None方法是一个让人讨厌的开销。摆脱这个elif子句(并看到轻微的性能改进)并不是非常困难。

为了去掉额外的elif子句,我们需要在内部的group()函数中使用稍微更复杂的初始化:

 **def group(data):
 **sorted_data= iter(sorted(data))
 **previous, count = next(sorted_data), 1
 **for d in sorted_data:
 **if d == previous:
 **count += 1
 **elif previous is not None: # and d != previous
 **yield previous, count
 **previous, count = d, 1
 **else:
 **raise Exception("Bad bad design problem.")
 **yield previous, count

这会从数据集中挑选出第一个项目来初始化previous变量。然后剩下的项目通过循环进行处理。这种设计与递归设计有一定的相似之处,其中我们使用第一个项目初始化递归,每次递归调用都提供下一个项目或None来指示没有剩余项目需要处理。

我们也可以使用itertools.groupby()来实现这一点。我们将在第八章Itertools 模块中仔细研究这个函数。

按键值对数据进行分组或分区

我们可能想要对分组数据应用的归约类型没有限制。我们可能有一些独立和因变量的数据。我们可以考虑通过一个独立变量对数据进行分区,并计算每个分区中值的最大值、最小值、平均值和标准差等摘要。

进行更复杂的归约的关键是将所有数据值收集到每个组中。Counter()函数仅仅收集相同项的计数。我们想要基于关键值创建原始项的序列。

从更一般的角度来看,每个 5 英里的箱都将包含该距离的所有腿,而不仅仅是腿的计数。我们可以将分区视为递归,或者作为defaultdict(list)对象的有状态应用。我们将研究groupby()函数的递归定义,因为它很容易设计。

显然,对于空集合Cgroupby(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]

我们需要执行dict[key(head)].append(head)方法来将头元素包含在结果字典中。然后我们需要执行groupby(tail,key)方法来处理剩余的元素。

我们可以创建一个如下的函数:

def group_by(key, data):
 **def group_into(key, collection, dictionary):
 **if len(collection) == 0:** 
 **return dictionary
 **head, *tail= collection
 **dictionary[key(head)].append(head)
 **return group_into(key, tail, dictionary)
 **return group_into(key, data, defaultdict(list))

内部函数处理我们的基本递归定义。一个空集合返回提供的字典。非空集合被解析为头和尾。头用于更新字典。然后使用尾递归地更新字典中的所有剩余元素。

我们无法轻松地使用 Python 的默认值将其合并为一个函数。我们不能使用以下命令片段:

def group_by(key, data, dictionary=defaultdict(list)):

如果我们尝试这样做,group_by()函数的所有用法都共享一个defaultdict(list)对象。Python 只构建默认值一次。可变对象作为默认值很少能实现我们想要的效果。与其尝试包含更复杂的决策来处理不可变的默认值(如None),我们更喜欢使用嵌套函数定义。wrapper()函数正确地初始化了内部函数的参数。

我们可以按距离对数据进行分组,如下所示:

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

这也可以写成迭代,如下所示:

def partition(key, data):
 **dictionary= defaultdict(list)
 **for head in data:
 **dictionary[key(head)].append(head)
 **return dictionary

在进行尾递归优化时,命令式版本中的关键代码行将与递归定义相匹配。我们已经突出显示了该行以强调重写的目的是具有相同的结果。其余结构代表了我们采用的尾递归优化,这是一种常见的解决 Python 限制的方法。

编写更一般的分组约简

一旦我们对原始数据进行了分区,我们就可以对每个分区中的数据元素进行各种类型的约简。例如,我们可能希望每个距离箱的起始点是每个腿的最北端。

我们将介绍一些辅助函数来分解元组,如下所示:

start = lambda s, e, d: s
end = lambda s, e, d: e
dist = lambda s, e, d: d
latitude = lambda lat, lon: lat
longitude = lambda lat, lon: lon

这些辅助函数中的每一个都期望提供一个tuple对象,使用*运算符将元组的每个元素映射到lambda的单独参数。一旦元组扩展为sep参数,通过名称返回正确的参数就变得相当明显。这比尝试解释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

我们的初始点对象是一个嵌套的三元组,包括(0) - 起始位置,(1) - 结束位置和(2) - 距离。我们使用我们的辅助函数提取了各种字段。

有了这些辅助函数,我们可以找到每个箱中腿的最北端起始位置:

for distance in sorted(by_distance):
 **print(distance, max(by_distance[distance], key=lambda pt: latitude(*start(*pt))))

我们按距离分组的数据包括给定距离的每条腿。我们将每个箱中的所有腿提供给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)

编写高阶约简

我们将在这里看一个高阶约简算法的示例。这将介绍一个相当复杂的主题。最简单的约简类型是从一组值中生成一个值。Python 有许多内置的约简,包括any()all()max()min()sum()len()

正如我们在第四章中所指出的,处理集合,如果我们从一些简单的约简开始,我们可以进行大量的统计计算,例如以下内容:

def s0(data):
 **return sum(1 for x in data) # or len(data)
def s1(data):
 **return sum(x for x in data) # or sum(data)
def s2(data):
 **return sum(x*x for x in data)

这使我们能够使用几个简单的函数来定义均值、标准差、归一化值、校正,甚至最小二乘线性回归。

我们的最后一个简单约简s2()显示了我们如何应用现有的约简来创建高阶函数。我们可能会改变我们的方法,使其更像以下内容:

def sum_f(function, data):
 **return sum(function(x) for x in data)

我们添加了一个函数,用于转换数据。我们将计算转换值的总和。

现在我们可以以三种不同的方式应用此函数来计算三个基本总和,如下所示:

N= sum_f(lambda x: 1, data) # x**0
S= sum_f(lambda x: x, data) # x**1
S2= sum_f( lambda x: x*x, data ) # x**2

我们插入了一个小的lambda来计算Writing higher-order reductions,即计数,Writing higher-order reductions,即总和,以及Writing higher-order reductions,即平方和,我们可以用它来计算标准偏差。

这通常包括一个过滤器,用于拒绝某种方式未知或不合适的原始数据。我们可以使用以下命令来拒绝错误的数据:

def sum_filter_f(filter, function, data):
 **return sum(function(x) for x in data if filter(x))

执行以下命令片段允许我们以简单的方式拒绝None值:

count_= lambda x: 1
sum_ = lambda x: x
valid = lambda x: x is not None
N = sum_filter_f(valid, count_, data)

这显示了我们如何向sum_filter_f()函数提供两个不同的lambdafilter参数是一个拒绝None值的lambda,我们称之为valid以强调其含义。function参数是一个实现countsum方法的lambda。我们可以轻松地添加一个lambda来计算平方和。

重要的是要注意,这个函数与其他示例类似,因为它实际上返回一个函数而不是一个值。这是高阶函数的定义特征之一,在 Python 中实现起来非常简单。

编写文件解析器

我们经常可以将文件解析器视为一种缩减。许多语言有两个级别的定义:语言中的低级标记和从这些标记构建的高级结构。当查看 XML 文件时,标签、标签名称和属性名称形成了这种低级语法;由 XML 描述的结构形成了高级语法。

低级词法扫描是一种将单个字符组合成标记的缩减。这与 Python 的生成器函数设计模式非常匹配。我们经常可以编写如下的函数:

Def lexical_scan( some_source ):
 **for char in some_source:
 **if some_pattern completed: yield token
 **else: accumulate token

对于我们的目的,我们将依赖于低级文件解析器来处理这些问题。我们将使用 CSV、JSON 和 XML 包来管理这些细节。我们将基于这些包编写高级解析器。

我们仍然依赖于两级设计模式。一个低级解析器将产生原始数据的有用的规范表示。它将是一个文本元组的迭代器。这与许多种类的数据文件兼容。高级解析器将产生对我们特定应用程序有用的对象。这些可能是数字元组,或者是命名元组,或者可能是一些其他类的不可变 Python 对象。

我们在第四章处理集合中提供了一个低级解析器的示例。输入是一个 KML 文件;KML 是地理信息的 XML 表示。解析器的基本特征看起来类似于以下命令片段:

def comma_split(text):
 **return text.split(",")
def row_iter_kml(file_obj):
 **ns_map={
 **"ns0": "http://www.opengis.net/kml/2.2",
 **"ns1": "http://www.google.com/kml/ext/2.2"}
 **doc= XML.parse(file_obj)
 **return (comma_split(coordinates.text)
 **for coordinates in doc.findall("./ns0:Document/ns0:Folder/ns0:Placemark/ns0:Point/ns0:coordinates", ns_map)

row_iter_kml()函数的主要部分是 XML 解析,它允许我们使用doc.findall()函数来迭代文档中的<ns0:coordinates>标签。我们使用了一个名为comma_split()的函数来解析这个标签的文本为一个三元组的值。

这专注于使用规范化的 XML 结构。文档大部分符合数据库设计师对第一范式的定义,也就是说,每个属性都是原子的,只有一个值。XML 数据中的每一行都具有相同的列,数据类型一致。数据值并不是完全原子的;我们需要将经度、纬度和海拔分割成原子字符串值。

大量数据——xml 标签、属性和其他标点——被缩减为一个相对较小的体积,其中只包括浮点纬度和经度值。因此,我们可以将解析器视为一种缩减。

我们需要一个更高级别的转换来将文本的元组映射为浮点数。此外,我们希望丢弃海拔,并重新排列经度和纬度。这将产生我们需要的特定于应用程序的元组。我们可以使用以下函数进行此转换:

def pick_lat_lon(lon, lat, alt):
 **return lat, lon
def float_lat_lon(row_iter):
 **return (tuple(map(float, pick_lat_lon(*row)))for row in row_iter)

关键工具是float_lat_lon()函数。这是一个返回生成器表达式的高阶函数。生成器使用map()函数将float()函数转换应用到pick_lat_lon()类的结果上。我们使用*row参数将行元组的每个成员分配给pick_lat_lon()函数的不同参数。然后该函数以所需顺序返回所选项目的元组。

我们可以按以下方式使用此解析器:

with urllib.request.urlopen("file:./Winter%202012-2013.kml") as source:
 **trip = tuple(float_lat_lon(row_iter_kml(source)))

这将为原始 KML 文件中路径上的每个航路点构建一个元组表示。它使用低级解析器从原始表示中提取文本数据行。它使用高级解析器将文本项转换为更有用的浮点值元组。在这种情况下,我们没有实现任何验证。

解析 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()函数返回制表符分隔文件中行的迭代器。函数如下所示:

def row_iter_csv(source):
 **rdr= csv.reader(source, delimiter="\t")
 **return rdr

这是围绕 CSV 解析过程的简单包装。当我们回顾以前用于 XML 和纯文本的解析器时,这是那些解析器缺少的东西。生成可迭代的行元组可以是规范化数据解析器的常见特征。

一旦我们有了一行元组,我们可以传递包含可用数据的行,并拒绝包含其他元数据的行,例如标题和列名。我们将介绍一个辅助函数,我们可以使用它来执行一些解析,以及一个filter()函数来验证数据行。

以下是转换:

def float_none(data):
 **try:
 **data_f= float(data)
 **return data_f
 **except ValueError:
 **return None

此函数处理将单个string转换为float值,将错误数据转换为None值。我们可以将此函数嵌入到映射中,以便将行的所有列转换为floatNone值。lambda如下所示:

float_row = lambda row: list(map(float_none, row))

以下是基于使用all()函数的行级验证器,以确保所有值都是float(或没有值是None):

all_numeric = lambda row: all(row) and len(row) == 8

以下是一个高阶函数,它结合了行级转换和过滤:

def head_filter_map(validator, converter, validator, row_iter):
 **return filter(all_validator, map(converter, row_iter))

此函数为我们提供了一个稍微更完整的解析输入文件的模式。基础是一个低级函数,它迭代文本元组。然后我们可以将其包装在函数中以转换和验证转换后的数据。对于文件要么处于第一正规形式(所有行都相同),要么简单验证器可以拒绝其他行的情况,这种设计非常有效。

然而,并非所有解析问题都如此简单。一些文件的重要数据位于必须保留的标题或尾随行中,即使它与文件的其余部分的格式不匹配。这些非规范化文件将需要更复杂的解析器设计。

解析带有标题的纯文本文件

在第三章,“函数,迭代器和生成器”中,Crayola.GPL文件是在没有显示解析器的情况下呈现的。该文件如下所示:

GIMP Palette
Name: Crayola
Columns: 16
#
239 222 205  Almond
205 149 117  Antique Brass

我们可以使用正则表达式解析文本文件。我们需要使用过滤器来读取(和解析)标题行。我们还希望返回一个可迭代的数据行序列。这种相当复杂的两部分解析完全基于两部分 - 头部和尾部 - 文件结构。

以下是处理头部和尾部的低级解析器:

def row_iter_gpl(file_obj):
 **header_pat= re.compile(r"GIMP Palette\nName:\s*(.*?)\nColumns:\s*(.*?)\n#\n", re.M)
 **def read_head(file_obj):
 **match= header_pat.match("".join( file_obj.readline() for _ in range(4)))
 **return (match.group(1), match.group(2)), file_obj
 **def read_tail(headers, file_obj):
 **return headers, (next_line.split() for next_line in file_obj)
 **return read_tail(*read_head(file_obj))

我们已经定义了一个正则表达式,用于解析标题的所有四行,并将其分配给header_pat变量。有两个内部函数用于解析文件的不同部分。read_head()函数解析标题行。它通过读取四行并将它们合并成一个长字符串来实现这一点。然后使用正则表达式对其进行解析。结果包括标题中的两个数据项以及一个准备处理额外行的迭代器。

read_tail()函数接受read_head()函数的输出,并解析剩余行的迭代器。标题行的解析信息形成一个两元组,与剩余行的迭代器一起传递给read_tail()函数。剩余行仅仅是按空格分割,因为这符合 GPL 文件格式的描述。

注意

有关更多信息,请访问以下链接:

code.google.com/p/grafx2/issues/detail?id=518

一旦我们将文件的每一行转换为规范的字符串元组格式,我们就可以对这些数据应用更高级别的解析。这涉及转换和(如果必要)验证。

以下是一个更高级别的解析器命令片段:

def color_palette(headers, row_iter):
 **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()解析器的输出:它需要标题和迭代器。这个函数将使用多重赋值将color数字和剩余单词分成四个变量,rgbname。使用*name参数确保所有剩余值都将被分配给名字作为一个tuple。然后" ".join(name)方法将单词连接成一个以空格分隔的字符串。

以下是我们如何使用这个两层解析器:

with open("crayola.gpl") as source:
 **name, columns, colors = color_palette(*row_iter_gpl(source))
 **print(name, columns, colors)

我们将高级解析器应用于低级解析器的结果。这将返回标题和从Color对象序列构建的元组。

总结

在这一章中,我们已经详细讨论了两个重要的函数式编程主题。我们详细讨论了递归。许多函数式编程语言编译器将优化递归函数,将函数尾部的调用转换为循环。在 Python 中,我们必须通过使用显式的for循环而不是纯函数递归来手动进行尾调用优化。

我们还研究了包括sum()count()max()min()函数在内的归约算法。我们研究了collections.Counter()函数和相关的groupby()归约。

我们还研究了解析(和词法扫描)如何类似于归约,因为它们将标记序列(或字符序列)转换为具有更复杂属性的高阶集合。我们研究了一种将解析分解为尝试生成原始字符串元组的较低级别和创建更有用的应用对象的较高级别的设计模式。

在下一章中,我们将研究一些适用于使用命名元组和其他不可变数据结构的技术。我们将研究一些使有状态对象不必要的技术。虽然有状态的对象并不是纯粹的函数式,但类层次结构的概念可以用来打包相关的方法函数定义。

第七章:其他元组技术

我们所看到的许多示例要么是scalar函数,要么是从小元组构建的相对简单的结构。我们经常可以利用 Python 的不可变namedtuple来构建复杂的数据结构。我们将看看我们如何使用以及如何创建namedtuples。我们还将研究不可变的namedtuples可以用来代替有状态对象类的方法。

面向对象编程的一个有益特性是逐步创建复杂数据结构的能力。在某些方面,对象只是函数结果的缓存;这通常与功能设计模式很匹配。在其他情况下,对象范式提供了包括复杂计算的属性方法。这更适合功能设计思想。

然而,在某些情况下,对象类定义被用于有状态地创建复杂对象。我们将研究一些提供类似功能的替代方案,而不涉及有状态对象的复杂性。我们可以识别有状态的类定义,然后包括元属性以对方法函数调用的有效或必需排序。诸如如果在调用 X.q()之前调用 X.p(),结果是未定义的之类的陈述是语言形式主义之外的,是类的元属性。有时,有状态的类包括显式断言和错误检查的开销,以确保方法按正确的顺序使用。如果我们避免有状态的类,我们就消除了这些开销。

我们还将研究一些在任何多态类定义之外编写通用函数的技术。显然,我们可以依赖Callable类来创建多态类层次结构。在某些情况下,这可能是功能设计中不必要的开销。

使用不可变的命名元组作为记录

在第三章,“函数、迭代器和生成器”中,我们展示了处理元组的两种常见技术。我们也暗示了处理复杂结构的第三种方法。根据情况,我们可以执行以下任一操作:

  • 使用lambdas(或函数)通过索引选择一个命名项目

  • 使用lambdas(或函数)与*parameter通过参数名称选择一个项目,该参数名称映射到一个索引

  • 使用namedtuples通过属性名称或索引选择项目

我们的旅行数据,介绍在第四章,“与集合一起工作”,有一个相当复杂的结构。数据最初是一个普通的时间序列位置报告。为了计算覆盖的距离,我们将数据转换为一个具有起始位置、结束位置和距离的嵌套三元组的序列。

序列中的每个项目如下所示为一个三元组:

first_leg= ((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246)

这是在切萨皮克湾上两点之间的短途旅行。

嵌套元组可能相当难以阅读;例如,诸如first_leg[0][0]之类的表达式并不是很有信息量。

让我们看看从tuple中选择值的三种替代方案。第一种技术涉及定义一些简单的选择函数,可以按索引位置从tuple中选择项目:

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(first_leg))来引用特定的数据片段。

这些定义并没有提供有关所涉及的数据类型的指导。我们可以使用简单的命名约定来使这一点更加清晰。以下是一些使用后缀的选择函数的示例:

start_point = lambda leg: leg[0]
distance_nm= lambda leg: leg[2]
latitude_value= lambda point: point[0]

当使用得当时,这可能是有帮助的。它也可能退化为一个复杂的匈牙利符号,作为每个变量的前缀(或后缀)。

第二种技术使用*parameter符号来隐藏索引位置的一些细节。以下是一些使用*符号的选择函数:

start= lambda start, end, distance: start
end= lambda start, end, distance: end
distance= lambda start, end, distance: distance
latitude= lambda lat, lon: lat
longitude= lambda lat, lon: lon

有了这些定义,我们可以使用latitude(*start(*first_leg))来引用特定的数据。这有清晰度的优势。在这些选择函数的tuple参数前面看到*运算符可能有点奇怪。

第三种技术是namedtuple函数。在这种情况下,我们有嵌套的命名元组函数,如下所示:

Leg = namedtuple("Leg", ("start", "end", "distance"))
Point = namedtuple("Point", ("latitude", "longitude"))

这使我们可以使用first_leg.start.latitude来获取特定的数据。从前缀函数名称到后缀属性名称的变化可以被视为一种有用的强调。也可以被视为语法上的混乱转变。

我们还将在构建原始数据的过程中,用适当的Leg()Point()函数调用替换tuple()函数。我们还必须找到一些隐式创建元组的returnyield语句。

例如,看一下以下代码片段:

def float_lat_lon(row_iter):
 **return (tuple(map(float, pick_lat_lon(*row))) for row in row_iter)

前面的代码将被更改为以下代码片段:

def float_lat_lon(row_iter):
 **return (Point(*map(float, pick_lat_lon(*row))) for row in row_iter)

这将构建Point对象,而不是浮点坐标的匿名元组。

同样,我们可以引入以下内容来构建Leg对象的完整行程:

with urllib.request.urlopen("file:./Winter%202012-2013.kml") as source:
 **path_iter = float_lat_lon(row_iter_kml(source))
 **pair_iter = legs(path_iter)
 **trip_iter = (Leg(start, end, round(haversine(start, end),4)) for start,end in pair_iter)
 **trip= tuple(trip_iter)

这将遍历基本路径点,将它们配对以为每个Leg对象创建startend。然后使用这些配对使用start点、结束点和来自第四章的haversine()函数构建Leg实例,与集合一起工作

当我们尝试打印trip对象时,它将如下所示:

(Leg(start=Point(latitude=37.54901619777347, longitude=-76.33029518659048), end=Point(latitude=37.840832, longitude=-76.273834), distance=17.7246), Leg(start=Point(latitude=37.840832, longitude=-76.273834), end=Point(latitude=38.331501, longitude=-76.459503), distance=30.7382),...
Leg(start=Point(latitude=38.330166, longitude=-76.458504), end=Point(latitude=38.976334, longitude=-76.473503), distance=38.8019))

注意

重要的是要注意,haversine()函数是用简单的元组编写的。我们已经将这个函数与namedtuples一起重用。由于我们仔细保留了参数的顺序,Python 优雅地处理了这种表示上的小改变。

在某些情况下,namedtuple函数增加了清晰度。在其他情况下,namedtuple是从前缀到后缀的语法不必要的变化。

使用功能构造函数构建命名元组

我们可以使用三种方法构建namedtuple实例。我们选择使用的技术通常取决于在对象构建时有多少额外的信息可用。

在前一节的示例中,我们展示了三种技术中的两种。我们将在这里强调设计考虑因素。它包括以下选择:

  • 我们可以根据它们的位置提供参数值。当我们评估一个或多个表达式时,这种方法非常有效。我们在将haversine()函数应用于startend点以创建Leg对象时使用了它。
Leg(start, end, round(haversine(start, end),4))

  • 我们可以使用*argument符号根据元组中的位置分配参数。当我们从另一个可迭代对象或现有元组中获取参数时,这种方法非常有效。我们在使用map()float()函数应用于latitudelongitude值时使用了它。
Point(*map(float, pick_lat_lon(*row)))

  • 我们可以使用显式的关键字赋值。虽然在前面的示例中没有使用,但我们可能会看到类似以下的东西,以使关系更加明显:
Point(longitude=float(row[0]), latitude=float(row[1]))

拥有多种创建namedtuple实例的灵活性是有帮助的。这使我们更容易地转换数据结构。我们可以强调与阅读和理解应用程序相关的数据结构特性。有时,索引号 0 或 1 是需要强调的重要事项。其他时候,startenddistance的顺序是重要的。

通过使用元组族避免有状态的类

在之前的几个示例中,我们展示了Wrap-Unwrap设计模式的概念,它允许我们使用不可变的元组和namedtuples。这种设计的重点是使用包装其他不可变对象的不可变对象,而不是可变的实例变量。

两组数据之间的常见统计相关度测量是 Spearman 等级相关度。这比较了两个变量的排名。我们将比较相对顺序,而不是尝试比较可能具有不同规模的值。有关更多信息,请访问en.wikipedia.org/wiki/Spearman%27s_rank_correlation_coefficient

计算 Spearman 等级相关性需要为每个观察分配一个排名值。我们似乎应该能够使用enumerate(sorted())来做到这一点。给定两组可能相关的数据,我们可以将每组转换为一系列排名值,并计算相关度的度量。

我们将应用 Wrap-Unwrap 设计模式来做到这一点。我们将为了计算相关系数而将数据项与其排名wrap起来。

在第三章中,函数、迭代器和生成器,我们展示了如何解析一个简单的数据集。我们将从该数据集中提取四个样本,如下所示:

from ch03_ex5 import series, head_map_filter, row_iter
with open("Anscombe.txt") as source:
 **data = tuple(head_map_filter(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))

这些系列中的每一个都是Pair对象的tuple。每个Pair对象都有xy属性。数据如下所示:

(Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), …, Pair(x=5.0, y=5.68))

我们可以应用enumerate()函数来创建值序列,如下所示:

y_rank= tuple(enumerate(sorted(series_I, key=lambda p: p.y)))
xy_rank= tuple(enumerate(sorted(y_rank, key=lambda rank: rank[1].x)))

第一步将创建简单的两元组,(0)是排名数字,(1)是原始的Pair对象。由于数据是按每对中的y值排序的,排名值将反映这种排序。

序列将如下所示:

((0, Pair(x=8.0, y=5.25)), (1, Pair(x=8.0, y=5.56)), ..., (10, Pair(x=19.0, y=12.5)))

第二步将把这两个元组再包装一层。我们将按照原始原始数据中的x值进行排序。第二个枚举将按照每对中的x值进行排序。

我们将创建更深层次的嵌套对象,应该如下所示:

((0, (0, Pair(x=4.0, y=4.26))), (1, (2, Pair(x=5.0, y=5.68))), ..., (10, (9, Pair(x=14.0, y=9.96))))

原则上,我们现在可以使用xy的排名来计算两个变量之间的秩序相关。然而,提取表达式相当尴尬。对于数据集中的每个排名样本r,我们必须比较r[0]r[1][0]

为了克服这些尴尬的引用,我们可以编写选择器函数如下:

x_rank = lambda ranked: ranked[0]
y_rank= lambda ranked: ranked[1][0]
raw = lambda ranked: ranked[1][1]

这样我们就可以使用x_rank(r)y_rank(r)来计算相关性,使得引用值不那么尴尬。

我们已经两次wrapped原始的Pair对象,创建了带有排名值的新元组。我们避免了有状态的类定义来逐步创建复杂的数据结构。

为什么要创建深度嵌套的元组?答案很简单:懒惰。解包tuple并构建新的平坦tuple所需的处理只是耗时的。在现有的tuple上“wrap”涉及的处理更少。放弃深度嵌套结构有一些令人信服的理由。

我们希望做两个改进;它们如下:

我们希望有一个更扁平的数据结构。使用嵌套的(x rank, (y rank, Pair()))tuple并不感觉表达或简洁:

  • enumerate()函数不能正确处理并列。如果两个观察结果具有相同的值,则它们应该获得相同的排名。一般规则是对相等的观察位置进行平均。序列[0.8, 1.2, 1.2, 2.3, 18]应该具有排名值1, 2.5, 2.5, 4。在位置 2 和 3 上的两个并列具有它们的共同排名的中点值2.5

分配统计排名

我们将把排名排序问题分为两部分。首先,我们将研究一个通用的高阶函数,我们可以用它来为Pair对象的xy值分配排名。然后,我们将使用这个函数来创建一个wrapper,包含xy的排名。这将避免深度嵌套的结构。

以下是一个将为数据集中的每个观察创建一个等级顺序的函数:

from collections import defaultdict
def rank(data, key=lambda obj:obj):** 
 **def rank_output(duplicates, key_iter, base=0):
 **for k in key_iter:
 **dups= len(duplicates[k])
 **for value in duplicates[k]:
 **yield (base+1+base+dups)/2, value
 **base += dups
 **def build_duplicates(duplicates, data_iter, key):
 **for item in data_iter:
 **duplicates[key(item)].append(item)
 **return duplicates
 **duplicates= build_duplicates(defaultdict(list), iter(data), key)
 **return rank_output(duplicates, iter(sorted(duplicates)), 0)

我们创建排名顺序的函数依赖于创建一个类似于Counter的对象,以发现重复值。我们不能使用简单的Counter函数,因为它使用整个对象来创建一个集合。我们只想使用应用于每个对象的键函数。这使我们可以选择Pair对象的xy值。

在这个例子中,duplicates集合是一个有状态的对象。我们本来可以编写一个适当的递归函数。然后我们需要进行尾递归优化,以允许处理大量数据的工作。我们在这里展示了该递归的优化版本。

作为对这种递归的提示,我们提供了build_duplicates()的参数,以暴露状态作为参数值。显然,递归的基本情况是当data_iter为空时。当data_iter不为空时,从旧集合和头部next(data_iter)构建一个新集合。build_duplicates()的递归评估将处理data_iter的尾部中的所有项目。

同样,我们可以编写两个适当的递归函数来发出分配了排名值的集合。同样,我们将该递归优化为嵌套的for循环。为了清楚地说明我们如何计算排名值,我们包括了范围的低端(base+1)和范围的高端(base+dups),并取这两个值的中点。如果只有一个duplicate,我们评估(2*base+2)/2,这有一个通用解决方案的优势。

以下是我们如何测试这个确保它工作。

>>> list(rank([0.8, 1.2, 1.2, 2.3, 18]))
[(1.0, 0.8), (2.5, 1.2), (2.5, 1.2), (4.0, 2.3), (5.0, 18)]
>>> data= ((2, 0.8), (3, 1.2), (5, 1.2), (7, 2.3), (11, 18))
>>> list(rank(data, key=lambda x:x[1]))
[(1.0, (2, 0.8)), (2.5, (3, 1.2)), (2.5, (5, 1.2)), (4.0, (7, 2.3)), (5.0, (11, 18))]

示例数据包括两个相同的值。结果排名将位置 2 和 3 分开,以分配位置 2.5 给两个值。这是计算两组值之间的 Spearman 秩相关性的常见统计做法。

注意

rank()函数涉及重新排列输入数据以发现重复值。如果我们想在每对中的xy值上进行排名,我们需要两次重新排序数据。

重新包装而不是状态改变

我们有两种一般策略来进行包装;它们如下:

  • 并行性:我们可以创建数据的两个副本并对每个副本进行排名。然后,我们需要重新组合这两个副本,以便最终结果包括两个排名。这可能有点尴尬,因为我们需要以某种方式合并两个可能按不同顺序排列的序列。

  • 串行性:我们可以计算一个变量的排名,并将结果保存为一个包含原始原始数据的包装器。然后,我们可以对另一个变量上的这些包装数据进行排名。虽然这可能会创建一个复杂的结构,但我们可以稍微优化它,以创建最终结果的一个更平坦的包装器。

以下是我们如何创建一个对象,该对象使用基于y值的排名顺序包装一对:

Ranked_Y= namedtuple("Ranked_Y", ("r_y", "raw",))
def rank_y(pairs):
 **return (Ranked_Y(*row) for row in rank(pairs, lambda pair: pair.y))

我们定义了一个包含y值排名加上原始(raw)值的namedtuple函数。我们的rank_y()函数将通过使用一个lambda选择每个pairs对象的y值来应用rank()函数,从而创建这个元组的实例。然后我们创建了结果的两个元组的实例。

我们可以提供以下输入:

>>> data = (Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ..., Pair(x=5.0, y=5.68))

我们可以得到以下输出:

>>> list(rank_y(data))
[Ranked_Y(r_y=1.0, raw=Pair(x=4.0, y=4.26)), Ranked_Y(r_y=2.0, raw=Pair(x=7.0, y=4.82)), ... Ranked_Y(r_y=11.0, raw=Pair(x=12.0, y=10.84))]

原始的Pair对象已经被包装在一个包含排名的新对象中。这还不够;我们需要再次包装一次,以创建一个既有 x 排名信息又有 y 排名信息的对象。

重新包装而不是状态改变

我们可以使用一个名为Ranked_Xnamedtuple,其中包含两个属性:r_xranked_yranked_y属性是Ranked_Y的一个实例,它具有两个属性:r_yraw。虽然这看起来很简单,但由于r_xr_y值在一个平坦结构中不是简单的对等项,因此生成的对象令人讨厌。我们将引入一个稍微更复杂的包装过程,以产生一个稍微更简单的结果。

我们希望输出看起来像这样:

Ranked_XY= namedtuple("Ranked_XY", ("r_x", "r_y", "raw",))

我们将创建一个带有多个对等属性的平面namedtuple。这种扩展通常比深度嵌套的结构更容易处理。在某些应用中,我们可能有许多转换。对于这个应用程序,我们只有两个转换:x 排名和 y 排名。我们将把这分为两个步骤。首先,我们将看一个类似之前所示的简单包装,然后是一个更一般的解包-重新包装。

以下是x-y排名建立在 y 排名的基础上:

def rank_xy(pairs):
 **return (Ranked_XY(r_x=r_x, r_y=rank_y_raw[0], raw=rank_y_raw[1])
 **for r_x, rank_y_raw in rank(rank_y(pairs), lambda r: r.raw.x))

我们使用rank_y()函数构建了Rank_Y对象。然后,我们将rank()函数应用于这些对象,以便按照原始的x值对它们进行排序。第二个排名函数的结果将是两个元组,其中(0)x排名,(1)Rank_Y对象。我们从x排名(r_x)、y排名(rank_y_raw[0])和原始对象(rank_y_raw[1])构建了一个Ranked_XY对象。

在这第二个函数中,我们展示了一种更一般的方法来向tuple添加数据。Ranked_XY对象的构造显示了如何从数据中解包值并重新包装以创建第二个更完整的结构。这种方法通常可以用来向tuple引入新变量。

以下是一些样本数据:

>>> data = (Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ..., Pair(x=5.0, y=5.68))

这使我们可以创建以下排名对象:

>>> list(rank_xy(data))
[Ranked_XY(r_x=1.0, r_y=1.0, raw=Pair(x=4.0, y=4.26)), Ranked_XY(r_x=2.0, r_y=3.0, raw=Pair(x=5.0, y=5.68)), ...,** 
Ranked_XY(r_x=11.0, r_y=10.0, raw=Pair(x=14.0, y=9.96))]

一旦我们有了适当的xy排名的数据,我们就可以计算 Spearman 秩相关值。我们可以从原始数据计算 Pearson 相关性。

我们的多排名方法涉及分解一个tuple并构建一个新的、平坦的tuple,其中包含我们需要的附加属性。当从源数据计算多个派生值时,我们经常需要这种设计。

计算 Spearman 秩相关

Spearman 秩相关是两个变量排名之间的比较。它巧妙地绕过了值的大小,甚至在关系不是线性的情况下,它通常也能找到相关性。公式如下:

计算 Spearman 秩相关

这个公式告诉我们,我们将对观察值的所有对的排名差异进行求和,计算 Spearman 秩相关计算 Spearman 秩相关。这个 Python 版本依赖于sum()len()函数,如下所示:

def rank_corr(pairs):
 **ranked= rank_xy(pairs)
 **sum_d_2 = sum((r.r_x - r.r_y)**2 for r in ranked)
 **n = len(pairs)
 **return 1-6*sum_d_2/(n*(n**2-1))

我们为每对pair创建了Rank_XY对象。有了这个,我们就可以从这些对中减去r_xr_y的值来比较它们的差异。然后我们可以对差异进行平方和求和。

关于统计学的一篇好文章将提供关于系数含义的详细指导。约为 0 的值意味着两个数据点系列的数据排名之间没有相关性。散点图显示了点的随机分布。约+1 或-1 的值表示两个值之间的强关系。图表显示了明显的线条或曲线。

以下是基于安斯库姆四重奏系列 I 的一个例子:

>>> data = (Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), …, Pair(x=5.0, y=5.68))
>>> round(rank_corr( data ), 3)
0.818

对于这个特定的数据集,相关性很强。

在第四章中,处理集合,我们展示了如何计算 Pearson 相关系数。我们展示的corr()函数与两个独立的值序列一起工作。我们可以将它与我们的Pair对象序列一起使用,如下所示:

import ch04_ex4
def pearson_corr(pairs):
 **X = tuple(p.x for p in pairs)
 **Y = tuple(p.y for p in pairs)
 **return ch04_ex4.corr(X, Y)

我们已经解开了Pair对象,得到了我们可以与现有的corr()函数一起使用的原始值。这提供了一个不同的相关系数。Pearson 值基于两个序列之间标准化值的比较。对于许多数据集,Pearson 和 Spearman 相关性之间的差异相对较小。然而,对于一些数据集,差异可能相当大。

要了解对探索性数据分析具有多个统计工具的重要性,请比较 Anscombe's Quartet 中四组数据的 Spearman 和 Pearson 相关性。

多态和 Pythonic 模式匹配

一些函数式编程语言提供了处理静态类型函数定义的巧妙方法。问题在于,我们想要编写的许多函数对于数据类型来说是完全通用的。例如,我们的大多数统计函数对于integerfloating-point数字来说是相同的,只要除法返回的值是numbers.Real的子类(例如DecimalFractionfloat)。为了使单个通用定义适用于多种数据类型,编译器使用了复杂的类型或模式匹配规则。

与静态类型的函数式语言的(可能)复杂特性不同,Python 通过动态选择基于正在使用的数据类型的操作符的最终实现来改变问题。这意味着编译器不会验证我们的函数是否期望和产生正确的数据类型。我们通常依赖单元测试来解决这个问题。

在 Python 中,我们实际上是在编写通用定义,因为代码不绑定到任何特定的数据类型。Python 运行时将使用一组简单的匹配规则来定位适当的操作。语言参考手册中的3.3.7 强制规则部分和库中的numbers模块提供了关于操作到特殊方法名称映射的详细信息。

在罕见的情况下,我们可能需要根据数据元素的类型有不同的行为。我们有两种方法来解决这个问题,它们如下:

  • 我们可以使用isinstance()函数来区分不同的情况。

  • 我们可以创建自己的numbers.Numbertuple的子类,并实现适当的多态特殊方法名称。

在某些情况下,我们实际上需要两者都做,以便包含适当的数据类型转换。

当我们回顾前一节中的排名示例时,我们紧密地与将排名应用于简单对的想法联系在一起。虽然这是 Spearman 相关性的定义方式,但我们可能有一个多变量数据集,并且需要对所有变量进行排名相关性。

我们需要做的第一件事是概括我们对排名信息的想法。以下是一个处理排名元组和原始数据元组的namedtuple

Rank_Data = namedtuple("Rank_Data", ("rank_seq", "raw"))

对于任何特定的Rank_Data,比如r,我们可以使用r.rank_seq[0]来获取特定的排名,使用r.raw来获取原始观察值。

我们将为我们的排名函数添加一些语法糖。在许多以前的例子中,我们要求要么是一个可迭代对象,要么是一个集合。for语句在处理任一种情况时都很优雅。但是,我们并不总是使用for语句,对于一些函数,我们不得不明确使用iter()来使一个集合成为一个iterable。我们可以通过简单的isinstance()检查来处理这种情况,如下面的代码片段所示:

def some_function(seq_or_iter):
 **if not isinstance(seq_or_iter,collections.abc.Iterator):
 **yield from some_function(iter(seq_or_iter), key)
 **return
 **# Do the real work of the function using the iterable

我们已经包含了一个类型检查,以处理两个集合之间的小差异,它不适用于next()和一个支持next()的可迭代对象。

在我们的排名函数的上下文中,我们将使用这种设计模式的变体:

def rank_data(seq_or_iter, key=lambda obj:obj):
 **# Not a sequence? Materialize a sequence object
 **if isinstance(seq_or_iter, collections.abc.Iterator):
 **yield from rank_data(tuple(seq_or_iter), key)
 **data = seq_or_iter
 **head= seq_or_iter[0]
 **# Convert to Rank_Data and process.
 **if not isinstance(head, Rank_Data):
 **ranked= tuple(Rank_Data((),d) for d in data)
 **for r, rd in rerank(ranked, key):
 **yield Rank_Data(rd.rank_seq+(r,), rd.raw)
 **return
 **# Collection of Rank_Data is what we prefer.
 **for r, rd in rerank(data, key):
 **yield Rank_Data(rd.rank_seq+(r,), rd.raw)

我们已经将排名分解为三种不同类型数据的三种情况。当不同类型的数据不是共同超类的多态子类时,我们被迫这样做。以下是三种情况:

  • 给定一个(没有可用的__getitem__()方法的)可迭代对象,我们将实现一个我们可以使用的tuple

  • 给定一组某种未知类型的数据,我们将未知对象包装成Rank_Data元组。

  • 最后,给定一组Rank_Data元组,我们将在每个Rank_Data容器内部的排名元组中添加另一个排名。

这依赖于一个rerank()函数,它在Rank_Data元组中插入并返回另一个排名。这将从原始数据值的复杂记录中构建一个单独的排名集合。rerank()函数的设计与之前显示的rank()函数的示例略有不同。

这个算法的这个版本使用排序而不是在对象中创建分组,比如Counter对象:

def rerank(rank_data_collection, key):
 **sorted_iter= iter(sorted( rank_data_collection, key=lambda obj: key(obj.raw)))
 **head = next(sorted_iter)
 **yield from ranker(sorted_iter, 0, [head], key)

我们首先从头部和数据迭代器重新组装了一个可排序的集合。在使用的上下文中,我们可以说这是一个坏主意。

这个函数依赖于另外两个函数。它们可以在rerank()的主体内声明。我们将分开展示它们。以下是 ranker,它接受一个可迭代对象,一个基本排名数字,一个具有相同排名的值的集合,以及一个键:

def ranker(sorted_iter, base, same_rank_seq, key):
 **"""Rank values from a sorted_iter using a base rank value.
 **If the next value's key matches same_rank_seq, accumulate those.
 **If the next value's key is different, accumulate same rank values
 **and start accumulating a new sequence.
 **"""
 **try:
 **value= next(sorted_iter)
 **except StopIteration:
 **dups= len(same_rank_seq)
 **yield from yield_sequence((base+1+base+dups)/2, iter(same_rank_seq))
 **return
 **if key(value.raw) == key(same_rank_seq[0].raw):
 **yield from ranker(sorted_iter, base, same_rank_seq+[value], key)
 **else:
 **dups= len(same_rank_seq)
 **yield from yield_sequence( (base+1+base+dups)/2, iter(same_rank_seq))
 **yield from ranker(sorted_iter, base+dups, [value], key)

我们从已排序值的iterable集合中提取了下一个项目。如果这失败了,就没有下一个项目,我们需要发出same_rank_seq序列中相等值项目的最终集合。如果这成功了,那么我们需要使用key()函数来查看下一个项目,即一个值,是否与相同排名项目的集合具有相同的键。如果键相同,则整体值被递归地定义;重新排名是其余的排序项目,排名的相同基值,一个更大的same_rank项目集合,以及相同的key()函数。

如果下一个项目的键与相等值项目的序列不匹配,则结果是相等值项目的序列。这将在对其余排序项目进行重新排名之后,一个基值增加了相等值项目的数量,一个只有新值的相同排名项目的新列表,以及相同的key提取函数。

这取决于yield_sequence()函数,其如下所示:

def yield_sequence(rank, same_rank_iter):
 **head= next(same_rank_iter)
 **yield rank, head
 **yield from yield_sequence(rank, same_rank_iter)

我们以一种强调递归定义的方式编写了这个。我们实际上不需要提取头部,发出它,然后递归发出其余的项目。虽然单个for语句可能更短,但有时更清晰地强调已经优化为for循环的递归结构。

以下是使用此函数对数据进行排名(和重新排名)的一些示例。我们将从一个简单的标量值集合开始:

>>> scalars= [0.8, 1.2, 1.2, 2.3, 18]
>>> list(ranker(scalars))
[Rank_Data(rank_seq=(1.0,), raw=0.8), Rank_Data(rank_seq=(2.5,), raw=1.2), Rank_Data(rank_seq=(2.5,), raw=1.2), Rank_Data(rank_seq=(4.0,), raw=2.3), Rank_Data(rank_seq=(5.0,), raw=18)]

每个值都成为Rank_Data对象的raw属性。

当我们处理稍微复杂的对象时,我们也可以有多个排名。以下是两个元组的序列:

>>> pairs= ((2, 0.8), (3, 1.2), (5, 1.2), (7, 2.3), (11, 18))
>>> rank_x= tuple(ranker(pairs, key=lambda x:x[0] ))
>>> rank_x
(Rank_Data(rank_seq=(1.0,), raw=(2, 0.8)), Rank_Data(rank_seq=(2.0,), raw=(3, 1.2)), Rank_Data(rank_seq=(3.0,), raw=(5, 1.2)), Rank_Data(rank_seq=(4.0,), raw=(7, 2.3)), Rank_Data(rank_seq=(5.0,), raw=(11, 18)))
>>> rank_xy= (ranker(rank_x, key=lambda x:x[1] ))
>>> tuple(rank_xy)
(Rank_Data(rank_seq=(1.0, 1.0), raw=(2, 0.8)),Rank_Data(rank_seq=(2.0, 2.5), raw=(3, 1.2)), Rank_Data(rank_seq=(3.0, 2.5), raw=(5, 1.2)), Rank_Data(rank_seq=(4.0, 4.0), raw=(7, 2.3)), Rank_Data(rank_seq=(5.0, 5.0), raw=(11, 18)))

在这里,我们定义了一组对。然后,我们对这两个元组进行了排名,将Rank_Data对象的序列分配给rank_x变量。然后,我们对这个Rank_Data对象的集合进行了排名,创建了第二个排名值,并将结果分配给rank_xy变量。

生成的序列可以用于稍微修改的rank_corr()函数,以计算Rank_Data对象的rank_seq属性中任何可用值的排名相关性。我们将把这个修改留给读者作为练习。

总结

在本章中,我们探讨了使用namedtuple对象实现更复杂的数据结构的不同方法。namedtuple的基本特性与函数式设计非常匹配。它们可以通过创建函数创建,并且可以按位置和名称访问。

我们研究了如何使用不可变的namedtuples而不是有状态的对象定义。核心技术是将对象包装在不可变的tuple中,以提供额外的属性值。

我们还研究了如何处理 Python 中的多种数据类型。对于大多数算术运算,Python 的内部方法分派会找到合适的实现。然而,对于处理集合,我们可能希望稍微不同地处理迭代器和序列。

在接下来的两章中,我们将看一下itertools模块。这个模块提供了许多函数,帮助我们以复杂的方式处理迭代器。其中许多工具都是高阶函数的例子。它们可以帮助使函数式设计保持简洁和表达力。

第八章. 迭代工具模块

函数式编程强调无状态编程。在 Python 中,这导致我们使用生成器表达式、生成器函数和可迭代对象。在本章中,我们将研究itertools库,其中有许多函数可以帮助我们处理可迭代的集合。

我们在第三章中介绍了迭代器函数,函数、迭代器和生成器。在本章中,我们将扩展对其的简单介绍。我们在第五章中使用了一些相关函数,高阶函数

注意

一些函数只是表现得像是合适的、惰性的 Python 可迭代对象。重要的是要查看每个函数的实现细节。其中一些函数会创建中间对象,导致可能消耗大量内存。由于实现可能会随着 Python 版本的发布而改变,我们无法在这里提供逐个函数的建议。如果您遇到性能或内存问题,请确保检查实现。

这个模块中有大量的迭代器函数。我们将在下一章中检查一些函数。在本章中,我们将看一下三种广泛的迭代器函数。它们如下:

  • 与无限迭代器一起工作的函数。这些函数可以应用于任何可迭代对象或任何集合上的迭代器;它们将消耗整个源。

  • 与有限迭代器一起工作的函数。这些函数可以多次累积源,或者它们会产生源的减少。

  • tee 迭代器函数可以将迭代器克隆为几个可以独立使用的副本。这提供了一种克服 Python 迭代器的主要限制的方法:它们只能使用一次。

我们需要强调一个重要的限制,这是我们在其他地方提到过的。

注意

可迭代对象只能使用一次。

这可能令人惊讶,因为没有错误。一旦耗尽,它们似乎没有元素,并且每次使用时都会引发StopIteration异常。

迭代器的一些其他特性并不是如此深刻的限制。它们如下:

  • 可迭代对象没有len()函数。在几乎所有其他方面,它们似乎都是容器。

  • 可迭代对象可以进行next()操作,而容器不行。

  • for语句使容器和可迭代对象之间的区别变得不可见;容器将通过iter()函数产生一个可迭代对象。可迭代对象只是返回自身。

这些观点将为本章提供一些必要的背景。itertools模块的理念是利用可迭代对象的功能来创建简洁、表达力强的应用程序,而不需要复杂的管理可迭代对象的细节。

与无限迭代器一起工作

itertools模块提供了许多函数,我们可以用它们来增强或丰富可迭代的数据源。我们将看一下以下三个函数:

  • count(): 这是range()函数的无限版本

  • cycle(): 这将重复迭代一组值

  • repeat(): 这可以无限次重复单个值

我们的目标是了解这些各种迭代器函数如何在生成器表达式和生成器函数中使用。

使用 count()进行计数

内置的range()函数由上限定义:下限和步长是可选的。另一方面,count()函数有一个起始和可选的步长,但没有上限。

这个函数可以被认为是像enumerate()这样的函数的原始基础。我们可以用zip()count()函数来定义enumerate()函数,如下所示:

enumerate = lambda x, start=0: zip(count(start),x)

enumerate()函数的行为就像使用count()函数生成与某个迭代器相关联的值的zip()函数。

因此,以下两个命令彼此等价:

zip(count(), some_iterator)
enumerate(some_iterator)

两者都会发出与迭代器中的项目配对的两个元组的数字序列。

zip()函数在使用count()函数时变得稍微简单,如下命令所示:

zip(count(1,3), some_iterator)

这将提供 1、4、7、10 等值,作为枚举器的每个值的标识符。这是一个挑战,因为enumerate没有提供更改步长的方法。

以下命令描述了enumerate()函数:

((1+3*e, x) for e,x in enumerate(a))

注意

count()函数允许非整数值。我们可以使用类似count(0.5, 0.1)的方法提供浮点值。如果增量值没有精确表示,这将累积相当大的误差。通常最好使用(0.5+x*.1 for x in count())方法来确保表示错误不会累积。

这是一种检查累积误差的方法。我们将定义一个函数,该函数将评估来自迭代器的项目,直到满足某个条件。以下是我们如何定义until()函数的方法:

def until(terminate, iterator):
 **i = next(iterator)
 **if terminate(*i): return i
 **return until(terminate, iterator)

我们将从迭代器中获取下一个值。如果通过测试,那就是我们的值。否则,我们将递归地评估这个函数,以搜索通过测试的值。

我们将提供一个源可迭代对象和一个比较函数,如下所示:

source = zip(count(0, .1), (.1*c for c in count()))
neq = lambda x, y: abs(x-y) > 1.0E-12

当我们评估until(neq, source)方法时,我们发现结果如下:

(92.799999999999, 92.80000000000001)

经过 928 次迭代,错误位的总和累积到Counting with count()。两个值都没有精确的二进制表示。

注意

count()函数接近 Python 递归限制。我们需要重写我们的until()函数,使用尾递归优化来定位具有更大累积误差的计数。

最小可检测差异可以计算如下:

>>> until(lambda x, y: x != y, source)
(0.6, 0.6000000000000001)

仅经过六步,count(0, 0.1)方法已经累积了一个可测的误差Counting with count()。不是很大的误差,但在 1000 步内,它将变得相当大。

使用 cycle()重复循环

cycle()函数重复一系列值。我们可以想象使用它来解决愚蠢的 fizz-buzz 问题。

访问rosettacode.org/wiki/FizzBuzz获取对一个相当琐碎的编程问题的全面解决方案。还可以参见projecteuler.net/problem=1获取这个主题的有趣变化。

我们可以使用cycle()函数发出TrueFalse值的序列,如下所示:

m3= (i == 0 for i in cycle(range(3)))

m5= (i == 0 for i in cycle(range(5)))

如果我们将一组有限的数字压缩在一起,我们将得到一组三元组,其中一个数字和两个标志,显示该数字是否是 3 的倍数或 5 的倍数。引入有限的可迭代对象以创建正在生成的数据的适当上限是很重要的。以下是一系列值及其乘法器标志:

multipliers = zip(range(10), m3, m5)

现在我们可以分解三元组,并使用过滤器传递是倍数的数字并拒绝所有其他数字:

sum(i for i, *multipliers in multipliers if any(multipliers))

这个函数还有另一个更有价值的用途,用于探索性数据分析。

我们经常需要处理大量数据的样本。清洗和模型创建的初始阶段最好使用小数据集开发,并使用越来越大的数据集进行测试。我们可以使用cycle()函数从较大的集合中公平选择行。人口规模,使用 cycle()重复循环,和期望的样本大小,使用 cycle()重复循环,表示我们可以使用循环的时间长短:

使用 cycle()重复循环

我们假设数据可以使用csv模块解析。这导致了一种优雅的方式来创建子集。我们可以使用以下命令创建子集:

chooser = (x == 0 for x in cycle(range(c)))
rdr= csv.reader(source_file)
wtr= csv.writer(target_file)
wtr.writerows(row for pick, row in zip(chooser, rdr) if pick)

我们根据选择因子c创建了一个cycle()函数。例如,我们可能有一千万条记录的人口:选择 1,000 条记录的子集涉及选择 1/10,000 的记录。我们假设这段代码片段被安全地嵌套在一个打开相关文件的with语句中。我们还避免显示与 CSV 格式文件的方言问题的细节。

我们可以使用一个简单的生成器表达式来使用cycle()函数和来自 CSV 读取器的源数据来过滤数据。由于chooser表达式和用于写入行的表达式都是非严格的,所以从这种处理中几乎没有内存开销。

我们可以通过一个小改变,使用random.randrange(c)方法而不是cycle(c)方法来实现类似大小的子集的随机选择。

我们还可以重写这个方法来使用compress()filter()islice()函数,这些我们将在本章后面看到。

这种设计还可以将文件从任何非标准的类 CSV 格式重新格式化为标准的 CSV 格式。只要我们定义返回一致定义的元组的解析器函数,并编写将元组写入目标文件的消费者函数,我们就可以用相对简短、清晰的脚本进行大量的清洗和过滤。

使用repeat()重复单个值

repeat()函数似乎是一个奇怪的特性:它一遍又一遍地返回一个单个值。它可以作为cycle()函数的替代。我们可以使用repeat(0)方法来扩展我们的数据子集选择函数,而不是在表达式行中使用cycle(range(100))方法,例如,(x==0 for x in some_function)

我们可以考虑以下命令:

all = repeat(0)
subset= cycle(range(100))
chooser = (x == 0 for x in either_all_or_subset)

这使我们可以进行简单的参数更改,要么选择所有数据,要么选择数据的子集。

我们可以将这个嵌套在循环中,以创建更复杂的结构。这里有一个简单的例子:

>>> list(tuple(repeat(i, times=i)) for i in range(10))
[(), (1,), (2, 2), (3, 3, 3), (4, 4, 4, 4), (5, 5, 5, 5, 5), (6, 6, 6, 6, 6, 6), (7, 7, 7, 7, 7, 7, 7), (8, 8, 8, 8, 8, 8, 8, 8), (9, 9, 9, 9, 9, 9, 9, 9, 9)]
>>> list(sum(repeat(i, times=i)) for i in range(10))
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

我们使用repeat()函数的times参数创建了重复的数字序列。

使用有限迭代器

itertools模块提供了许多函数,我们可以用它们来生成有限的值序列。我们将在这个模块中看到十个函数,以及一些相关的内置函数:

  • enumerate(): 这个函数实际上是__builtins__包的一部分,但它可以与迭代器一起使用,与itertools模块中的其他函数非常相似。

  • accumulate(): 这个函数返回输入可迭代对象的一系列减少。它是一个高阶函数,可以进行各种巧妙的计算。

  • chain(): 这个函数将多个可迭代对象串联起来。

  • groupby(): 这个函数使用一个函数将单个可迭代对象分解为输入数据子集的可迭代对象序列。

  • zip_longest(): 这个函数将来自多个可迭代对象的元素组合在一起。内置的zip()函数会将序列截断到最短可迭代对象的长度。zip_longest()函数会用给定的填充值填充较短的可迭代对象。

  • compress(): 这个函数基于第二个Boolean值可迭代对象来过滤第一个可迭代对象。

  • islice(): 当应用于可迭代对象时,这个函数相当于对序列的切片。

  • dropwhile()takewhile(): 这两个函数都使用一个Boolean函数来过滤可迭代的项。与filter()filterfalse()不同,这些函数依赖于单个TrueFalse值来改变它们对所有后续值的过滤行为。

  • filterfalse(): 这个函数对可迭代对象应用一个过滤函数。这是内置的filter()函数的补充。

  • starmap(): 这个函数将一个函数映射到一个元组的可迭代序列,使用每个可迭代对象作为给定函数的*args参数。map()函数使用多个并行可迭代对象做类似的事情。

我们已将这些函数分成了大致的类别。这些类别与重构可迭代对象、过滤和映射的概念大致相关。

使用 enumerate()分配数字

在第七章中,其他元组技术,我们使用enumerate()函数对排序数据进行了天真的排名分配。我们可以做一些事情,比如将一个值与其在原始序列中的位置配对,如下所示:

pairs = tuple(enumerate(sorted(raw_values)))

这将对raw_values中的项目进行排序,创建两个具有升序数字序列的元组,并实现我们可以用于进一步计算的对象。命令和结果如下:

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

在第七章中,其他元组技术,我们实现了一个替代形式的 enumerate,rank()函数,它将以更具统计意义的方式处理并列。

这是一个常见的功能,它被添加到解析器中以记录源数据行号。在许多情况下,我们将创建某种row_iter()函数,以从源文件中提取字符串值。这可能会迭代 XML 文件中标签的string值,或者 CSV 文件的列中的值。在某些情况下,我们甚至可能会解析用 Beautiful Soup 解析的 HTML 文件中呈现的数据。

在第四章中,与集合一起工作,我们解析了一个 XML 文件,创建了一个简单的位置元组序列。然后我们创建了带有起点、终点和距离的Leg。然而,我们没有分配一个明确的Leg编号。如果我们对行程集合进行排序,我们将无法确定Leg的原始顺序。

在第七章中,其他元组技术,我们扩展了基本解析器,为行程的每个Leg创建了命名元组。增强解析器的输出如下所示:

(Leg(start=Point(latitude=37.54901619777347, longitude=-76.33029518659048), end=Point(latitude=37.840832, longitude=-76.273834), distance=17.7246), Leg(start=Point(latitude=37.840832, longitude=-76.273834), end=Point(latitude=38.331501, longitude=-76.459503), distance=30.7382), Leg(start=Point(latitude=38.331501, longitude=-76.459503), end=Point(latitude=38.845501, longitude=-76.537331), distance=31.0756),...,Leg(start=Point(latitude=38.330166, longitude=-76.458504), end=Point(latitude=38.976334, longitude=-76.473503), distance=38.8019))

第一个Leg函数是在切萨皮克湾上两点之间的短途旅行。

我们可以添加一个函数,它将构建一个更复杂的元组,其中包含输入顺序信息作为元组的一部分。首先,我们将定义Leg类的一个稍微复杂的版本:

Leg = namedtuple("Leg", ("order", "start", "end", "distance"))

这类似于第七章中显示的Leg实例,其他元组技术,但它包括顺序以及其他属性。我们将定义一个函数,将成对分解并创建Leg实例如下:

def ordered_leg_iter(pair_iter):
 **for order, pair in enumerate(pair_iter):
 **start, end = pair
 **yield Leg(order, start, end, round(haversine(start, end),4))

我们可以使用此函数对每对起始和结束点进行枚举。我们将分解该对,然后重新组装orderstartend参数以及haversine(start,end)参数的值作为单个Leg实例。这个generator函数将与可迭代序列一起工作。

在前面的解释的背景下,它的用法如下:

with urllib.request.urlopen("file:./Winter%202012-2013.kml") as source:
 **path_iter = float_lat_lon(row_iter_kml(source))
 **pair_iter = legs(path_iter)
 **trip_iter = ordered_leg_iter(pair_iter)
 **trip= tuple(trip_iter)

我们已经将原始文件解析为路径点,创建了起始-结束对,然后创建了一个由单个Leg对象构建的行程。enumerate()函数确保可迭代序列中的每个项目都被赋予一个唯一的数字,该数字从默认的起始值 0 递增。可以提供第二个参数值以提供替代的起始值。

使用 accumulate()进行累积总数

accumulate()函数将给定的函数折叠到可迭代对象中,累积一系列的减少。这将迭代另一个迭代器中的累积总数;默认函数是operator.add()。我们可以提供替代函数来改变从总和到乘积的基本行为。Python 库文档显示了max()函数的一个特别巧妙的用法,以创建迄今为止的最大值序列。

累积总数的一个应用是对数据进行四分位数处理。我们可以计算每个样本的累积总数,并用int(4*value/total)计算将它们分成四分之一。

使用 enumerate()分配数字部分,我们介绍了一系列描述航行中一系列航段的纬度-经度坐标。我们可以使用距离作为四分位数航路点的基础。这使我们能够确定航行的中点。

trip变量的值如下:

(Leg(start=Point(latitude=37.54901619777347, longitude=-76.33029518659048), end=Point(latitude=37.840832, longitude=-76.273834), distance=17.7246), Leg(start=Point(latitude=37.840832, longitude=-76.273834), end=Point(latitude=38.331501, longitude=-76.459503), distance=30.7382), ..., Leg(start=Point(latitude=38.330166, longitude=-76.458504), end=Point(latitude=38.976334, longitude=-76.473503), distance=38.8019))

每个Leg对象都有一个起点、一个终点和一个距离。四分位数的计算如下例所示:

distances= (leg.distance for leg in trip)
distance_accum= tuple(accumulate(distances))
total= distance_accum[-1]+1.0
quartiles= tuple(int(4*d/total) for d in distance_accum)

我们提取了距离数值,并计算了每段的累积距离。累积距离的最后一个就是总数。我们将1.0添加到总数中,以确保4*d/total为 3.9983,这将截断为 3。如果没有+1.0,最终的项目将具有值4,这是一个不可能的第五个四分位数。对于某些类型的数据(具有极大的值),我们可能需要添加一个更大的值。

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()等函数来创建每个四分位数中各段的不同集合。

使用 chain()组合迭代器

我们可以使用chain()函数将一系列迭代器组合成一个单一的整体迭代器。这对于组合通过groupby()函数分解的数据非常有用。我们可以使用这个来处理多个集合,就好像它们是一个单一的集合一样。

特别是,我们可以将chain()函数与contextlib.ExitStack()方法结合使用,以处理文件集合作为单个可迭代值序列。我们可以做如下操作:

from contextlib import ExitStack
import csv
def row_iter_csv_tab(*filenames):
 **with ExitStack() as stack:
 **files = [stack.enter_context(open(name, 'r', newline=''))
 **for name in filenames]
 **readers = [csv.reader(f, delimiter='\t') for f in files]
 **readers = map(lambda f: csv.reader(f, delimiter='\t'), files)
 **yield from chain(*readers)

我们创建了一个ExitStack对象,可以包含许多单独的上下文打开。当with语句结束时,ExitStack对象中的所有项目都将被正确关闭。我们创建了一个简单的打开文件对象序列;这些对象也被输入到了ExitStack对象中。

给定files变量中的文件序列,我们在readers变量中创建了一系列 CSV 读取器。在这种情况下,我们所有的文件都有一个共同的制表符分隔格式,这使得使用一个简单、一致的函数对文件序列进行打开非常愉快。

我们还可以使用以下命令打开文件:

readers = map(lambda f: csv.reader(f, delimiter='\t'), files)

最后,我们将所有的读取器链接成一个单一的迭代器,使用chain(*readers)。这用于从所有文件中产生行的序列。

重要的是要注意,我们不能返回chain(*readers)对象。如果这样做,将退出with语句上下文,关闭所有源文件。相反,我们必须产生单独的行,以保持with语句上下文处于活动状态。

使用 groupby()对迭代器进行分区

我们可以使用groupby()函数将迭代器分成较小的迭代器。这是通过对给定可迭代对象中的每个项目评估给定的key()函数来实现的。如果键值与前一个项目的键值匹配,则两个项目属于同一分区。如果键值与前一个项目的键值不匹配,则结束前一个分区并开始一个新的分区。

groupby()函数的输出是两个元组的序列。每个元组都有组的键值和组中项目的可迭代对象。每个组的迭代器可以保留为元组,也可以处理以将其减少为某些摘要值。由于组迭代器的创建方式,它们无法被保留。

使用 accumulate()计算累积总数部分,在本章的前面,我们展示了如何计算输入序列的四分位值。

给定具有原始数据的trip变量和具有四分位数分配的quartile变量,我们可以使用以下命令对数据进行分组:

group_iter= groupby(zip(quartile, trip), key=lambda q_raw:
 **q_raw[0])
for group_key, group_iter in group_iter:
 **print(group_key, tuple(group_iter))

这将从原始行程数据开始,将四分位数与原始行程数据一起进行迭代。groupby()函数将使用给定的lambda变量按四分位数分组。我们使用for循环来检查groupby()函数的结果。这显示了我们如何获得组键值和组成员的迭代器。

groupby()函数的输入必须按键值排序。这将确保组中的所有项目都是相邻的。

请注意,我们还可以使用defaultdict(list)方法创建组,如下所示:

def groupby_2(iterable, key):
 **groups = defaultdict(list)
 **for item in iterable:
 **groups[key(item)].append(item)
 **for g in groups:
 **yield iter(groups[g])

我们创建了一个defaultdict类,其中list对象作为与每个键关联的值。每个项目将应用给定的key()函数以创建键值。项目将附加到具有给定键的defaultdict类中的列表中。

一旦所有项目被分区,我们就可以将每个分区作为共享公共键的项目的迭代器返回。这类似于groupby()函数,因为传递给此函数的输入迭代器不一定按照完全相同的顺序排序;可能会有相同成员的组,但顺序可能不同。

使用zip_longest()zip()合并可迭代对象

我们在第四章与集合一起工作中看到了zip()函数。zip_longest()函数与zip()函数有一个重要的区别:zip()函数在最短的可迭代对象结束时停止,而zip_longest()函数填充短的可迭代对象,并在最长的可迭代对象结束时停止。

fillvalue关键字参数允许使用除默认值None之外的值进行填充。

对于大多数探索性数据分析应用程序,使用默认值进行填充在统计上很难证明。Python 标准库文档显示了一些可以使用zip_longest()函数完成的巧妙事情。很难在不远离我们对数据分析的关注的情况下扩展这些内容。

使用compress()进行过滤

内置的filter()函数使用谓词来确定是否传递或拒绝项目。我们可以使用第二个并行可迭代对象来确定要传递哪些项目,要拒绝哪些项目,而不是使用计算值的函数。

我们可以将filter()函数视为具有以下定义:

def filter(iterable, function):
 **i1, i2 = tee(iterable, 2)
 **return compress(i1, (function(x) for x in i2))

我们使用tee()函数克隆了可迭代对象。(我们稍后将详细讨论这个函数。)我们对每个值评估了过滤谓词。然后我们将原始可迭代对象和过滤函数可迭代对象提供给compress,传递和拒绝值。这从compress()函数的更原始特性中构建了filter()函数的特性。

在本章的使用 cycle()重复循环部分,我们看到了使用简单的生成器表达式进行数据选择。其本质如下:

chooser = (x == 0 for x in cycle(range(c)))
keep= (row for pick, row in zip(chooser, some_source) if pick)

我们定义了一个函数,它将产生一个值1,后跟c-1个零。这个循环将被重复,允许从源中仅选择1/c行。

我们可以用repeat(0)函数替换cycle(range(c))函数以选择所有行。我们还可以用random.randrange(c)函数替换它以随机选择行。

保持表达式实际上只是一个compress(some_source,chooser)方法。如果我们进行这种更改,处理将变得简化:

all = repeat(0)
subset = cycle(range(c))
randomized = random.randrange(c)
selection_rule = one of all, subset, or randomized
chooser = (x == 0 for x in selection_rule)
keep = compress(some_source, chooser)

我们定义了三种替代选择规则:allsubsetrandomized。子集和随机化版本将从源中选择1/c行。chooser表达式将根据选择规则之一构建一个TrueFalse值的可迭代对象。应用源可迭代对象到行选择可迭代对象来选择要保留的行。

由于所有这些都是非严格的,直到需要时才从源中读取行。这使我们能够高效地处理非常大的数据集。此外,Python 代码的相对简单意味着我们实际上不需要复杂的配置文件和相关解析器来在选择规则中进行选择。我们可以选择使用这段 Python 代码作为更大数据采样应用程序的配置。

使用 islice()选择子集

在第四章中,与集合一起工作,我们看到了使用切片表示法从集合中选择子集。我们的示例是从list对象中切片出成对的项目。以下是一个简单的列表:

flat= ['2', '3', '5', '7', '11', '13', '17', '19', '23', '29', '31', '37', '41', '43', '47', '53', '59', '61', '67', '71',... ]

我们可以使用列表切片创建成对的元素,如下所示:

zip(flat[0::2], flat[1::2])

islice()函数为我们提供了类似的功能,而不需要实例化list对象,并且看起来像以下内容:

flat_iter_1= iter(flat)
flat_iter_2= iter(flat)
zip(islice(flat_iter_1, 0, None, 2), islice(flat_iter_2, 1, None, 2))

我们在一个扁平数据点列表上创建了两个独立的迭代器。这些可能是打开文件或数据库结果集上的两个独立迭代器。这两个迭代器需要是独立的,以便一个islice()函数的更改不会干扰另一个islice()函数。

islice()函数的两组参数类似于flat[0::2]flat[1::2]方法。没有类似切片的简写,因此需要指定开始和结束参数值。步长可以省略,默认值为 1。这将从原始序列产生两个元组的序列:

[(2, 3), (5, 7), (11, 13), (17, 19), (23, 29), ... (7883, 7901), (7907, 7919)]

由于islice()与可迭代对象一起工作,这种设计可以处理非常大的数据集。我们可以使用它从较大的数据集中选择一个子集。除了使用filter()compress()函数外,我们还可以使用islice(source,0,None,c)方法从较大的数据集中选择使用 islice()选择子集项。

使用 dropwhile()和 takewhile()进行有状态过滤

dropwhile()takewhile()函数是有状态的过滤函数。它们以一种模式开始;给定的predicate函数是一种开关,可以切换模式。dropwhile()函数以拒绝模式开始;当函数变为False时,它切换到通过模式。takewhile()函数以通过模式开始;当给定函数变为False时,它切换到拒绝模式。

由于这些是过滤器,两个函数都将消耗整个可迭代对象。给定一个像count()函数这样的无限迭代器,它将无限继续。由于 Python 中没有简单的整数溢出,对dropwhile()takewhile()函数的不考虑使用不会在整数溢出后经过几十亿次迭代后崩溃。它确实可以运行非常非常长的时间。

我们可以将这些与文件解析一起使用,以跳过输入中的标题或页脚。我们使用dropwhile()函数来拒绝标题行并传递剩余数据。我们使用takewhile()函数来传递数据并拒绝尾部行。我们将返回第三章中显示的简单 GPL 文件格式,函数、迭代器和生成器。该文件的标题如下所示:

GIMP Palette
Name: Crayola
Columns: 16
#

接下来是以下示例的行:

255  73 108  Radical Red

我们可以使用基于dropwhile()函数的解析器轻松定位标题的最后一行——#行,如下所示:

with open("crayola.gpl") as source:
 **rdr = csv.reader(source, delimiter='\t')
 **rows = dropwhile(lambda row: row[0] != '#', rdr)

我们创建了一个 CSV 读取器,以制表符为基础解析行。这将从名称中整齐地分离出color三元组。三元组需要进一步解析。这将产生一个以#行开头并继续文件其余部分的迭代器。

我们可以使用islice()函数丢弃可迭代对象的第一项。然后我们可以按以下方式解析颜色细节:

 **color_rows = islice(rows, 1, None)
 **colors = ((color.split(), name) for color, name in color_rows)
 **print(list(colors))

islice(rows, 1, None)表达式类似于请求rows[1:]切片:第一项被悄悄丢弃。一旦标题行的最后一行被丢弃,我们就可以解析颜色元组并返回更有用的颜色对象。

对于这个特定的文件,我们还可以使用 CSV 读取器函数定位的列数。我们可以使用dropwhile(lambda row: len(row) == 1, rdr)方法来丢弃标题行。这在一般情况下并不总是奏效。定位标题行的最后一行通常比尝试定位一些区分标题(或尾部)行与有意义的文件内容的一般特征更容易。

使用 filterfalse()和 filter()进行过滤的两种方法

在第五章中,高阶函数我们看了内置的filter()函数。itertools模块中的filterfalse()函数可以从filter()函数中定义如下:

filterfalse = lambda pred, iterable:
 **filter(lambda x: not pred(x), iterable)

filter()函数一样,谓词函数可以是None值。filter(None, iterable)方法的值是可迭代对象中的所有True值。filterfalse(None, iterable)方法的值是可迭代对象中的所有False值:

>>> filter(None, [0, False, 1, 2])
<filter object at 0x101b43a50>
>>> list(_)
[1, 2]
>>> filterfalse(None, [0, False, 1, 2])
<itertools.filterfalse object at 0x101b43a50>
>>> list(_)
[0, False]

拥有filterfalse()函数的目的是促进重用。如果我们有一个简洁的函数可以做出过滤决定,我们应该能够使用该函数将输入分成通过和拒绝组,而不必费力地处理逻辑否定。

执行以下命令的想法是:

iter_1, iter_2 = iter(some_source), iter(some_source)
good = filter(test, iter_1)
bad = filterfalse(test, iter_2)

这将显然包括源自所有项目。test()函数保持不变,我们不能通过不正确使用()引入微妙的逻辑错误。

通过 starmap()和 map()将函数应用于数据

内置的map()函数是一个高阶函数,它将map()函数应用于可迭代对象中的项目。我们可以将map()函数的简单版本看作如下:

map(function, arg_iter) == (function(a) for a in arg_iter)

arg_iter参数是单个值列表时,这很有效。itertools模块中的starmap()函数只是map()函数的*a版本,如下所示:

starmap(function, arg_iter) == (function(*a) for a in arg_iter)

这反映了map()函数语义的小变化,以正确处理元组结构。

map()函数也可以接受多个可迭代对象;这些额外可迭代对象的值被压缩,并且它的行为类似于starmap()函数。源可迭代对象的每个压缩项都成为给定函数的多个参数。

我们可以将map(function, iter1, iter2, ..., itern)方法定义为以下两个命令:

(function(*args) for args in zip(iter1, iter2, ..., itern))
starmap(function, zip(iter1, iter2, ..., itern))

各种迭代器值被用来通过*args构造一个参数元组。实际上,starmap()函数就像这种更一般的情况。我们可以从更一般的starmap()函数构建简单的map()函数。

当我们查看行程数据时,可以根据前面的命令重新定义基于starmap()函数的Leg对象的构造。在创建Leg对象之前,我们创建了点对。每对如下所示:

((Point(latitude=37.54901619777347, longitude=-76.33029518659048), Point(latitude=37.840832, longitude=-76.273834)), ...,(Point(latitude=38.330166, longitude=-76.458504), Point(latitude=38.976334, longitude=-76.473503)))

我们可以使用starmap()函数来组装Leg对象,如下所示:

with urllib.request.urlopen(url) as source:
 **path_iter = float_lat_lon(row_iter_kml(source))
 **pair_iter = legs(path_iter)
 **make_leg = lambda start, end: Leg(start, end, haversine(start,end))
 **trip = list(starmap(make_leg, pair_iter))

legs()函数创建反映航程的腿的起点和终点的点对象对。有了这些对,我们可以创建一个简单的函数make_leg,它接受一对Points对象,并返回一个具有起点、终点和两点之间距离的Leg对象。

starmap(function, some_list)方法的好处是可以替换潜在冗长的(function(*args) for args in some_list)生成器表达式。

使用 tee()克隆迭代器

tee()函数为我们提供了一种规避处理可迭代对象的重要 Python 规则的方法。这条规则非常重要,我们在这里重复一遍。

注意

迭代器只能使用一次。

tee()函数允许我们克隆一个迭代器。这似乎使我们摆脱了必须实现一个序列以便我们可以对数据进行多次遍历的限制。例如,对于一个庞大的数据集,可以按照以下方式编写一个简单的平均值:

def mean(iterator):
 **it0, it1= tee(iterator,2)
 **s0= sum(1 for x in it0)
 **s1= sum(x for x in it1)
 **return s0/s1

这将计算平均值,而不会以任何形式在内存中出现整个数据集。

虽然在原则上很有趣,但tee()函数的实现受到严重限制。在大多数 Python 实现中,克隆是通过实现一个序列来完成的。虽然这可以规避小集合的“一次性”规则,但对于庞大的集合来说效果不佳。

此外,tee()函数的当前实现会消耗源迭代器。可能会很好地创建一些语法糖来允许对迭代器进行无限使用。这在实践中很难管理。相反,Python 要求我们仔细优化tee()函数。

itertools 配方

Python 库文档的itertools章节,Itertools Recipes,是非常出色的。基本定义后面是一系列非常清晰和有用的配方。由于没有理由重复这些,我们将在这里引用它们。它们应该被视为 Python 中函数式编程的必读内容。

注意

Python 标准库10.1.2章节,Itertools Recipes,是一个很好的资源。参见

docs.python.org/3/library/itertools.html#itertools-recipes

重要的是要注意,这些不是itertools模块中可导入的函数。需要阅读和理解一个配方,然后可能在应用程序中复制或修改它。

以下表总结了一些从 itertools 基础构建的函数式编程算法的配方:

函数名称 参数 结果
take (n, iterable) 这将可迭代对象的前 n 个项目作为列表返回。这在一个简单的名称中包装了islice()的使用。
tabulate (function, start=0) 这返回function(0)function(1)。这基于map(function, count())
consume (iterator, n) 这将迭代器向前推进 n 步。如果nNone,迭代器将完全消耗这些步骤。
nth (iterable, n, default=None) 这返回第 n 个项目或默认值。这在一个简单的名称中包装了islice()的使用。
quantify (iterable, pred=bool) 这计算谓词为真的次数。这使用sum()map(),并依赖于布尔谓词在转换为整数值时的方式。
padnone (iterable) 这返回序列元素,然后无限返回None。这可以创建行为类似于zip_longest()或 map()的函数。
ncycles (iterable, n) 这将序列元素n次返回。
dotproduct (vec1, vec2) 这是点积的基本定义。将两个向量相乘并找到结果的和。
flatten (listOfLists) 这将嵌套的一级展平。这将各种列表链接成一个单一的列表。
repeatfunc (func, times=None, *args) 这使用指定的参数重复调用func
pairwise (iterable): s -> (s0,s1), (s1,s2), (s2, s3).
grouper (iterable, n, fillvalue=None) 将数据收集到固定长度的块中。
roundrobin (*iterables) roundrobin('ABC', 'D', 'EF') --> A D E B F C
partition (pred, iterable) 这使用谓词将条目分成False条目和True条目。
unique_ everseen (iterable, key=None) 这列出唯一的元素,保留顺序。记住所有已经看到的元素。unique_ everseen('AAAABBBCCDAABBB') - -> A B C D.
unique_justseen (iterable, key=None) 这列出了唯一的元素,保留顺序。只记住刚看到的元素。unique_justseen('AAAABBBCCDAABBB') - -> A B C D A B.
iter_except (func, exception, first=None) 反复调用函数,直到引发异常。这可以用于迭代直到KeyErrorIndexError

总结

在本章中,我们已经看过了itertools模块中的许多函数。这个库模块提供了许多函数,帮助我们以复杂的方式处理迭代器。

我们已经看过了无限迭代器;这些重复而不终止。这些包括count()cycle()repeat()函数。由于它们不终止,消耗函数必须确定何时停止接受值。

我们还看过了许多有限迭代器。其中一些是内置的,一些是itertools模块的一部分。这些与源可迭代对象一起工作,因此当该可迭代对象耗尽时它们终止。这些函数包括enumerate()accumulate()chain()groupby()zip_longest()zip()compress()islice()dropwhile()takewhile()filterfalse()filter()starmap()map()。这些函数允许我们用看起来更简单的函数替换可能复杂的生成器表达式。

此外,我们还研究了文档中的配方,这些配方提供了更多我们可以研究和复制到我们自己的应用程序中的函数。配方列表显示了丰富的常见设计模式。

在第九章中,更多迭代工具技术,我们将继续研究itertools模块。我们将看看专注于排列和组合的迭代器。这些不适用于处理大量数据。它们是一种不同类型的基于迭代器的工具。

第九章:更多迭代工具技术

函数式编程强调无状态编程。在 Python 中,这导致我们使用生成器表达式、生成器函数和可迭代对象。在本章中,我们将继续研究itertools库,其中包含许多函数,帮助我们处理可迭代集合。

在上一章中,我们看了三种广泛的迭代器函数分组。它们如下:

  • 与无限迭代器一起工作的函数可以应用于任何可迭代对象或任何集合上的迭代器;它们将消耗整个源

  • 与有限迭代器一起工作的函数可以多次累积源,或者它们可以产生源的减少

  • tee()迭代器函数将一个迭代器克隆成几个独立可用的副本

在本章中,我们将研究与排列和组合一起工作的itertools函数。这些包括几个函数和一些基于这些函数构建的配方。这些函数如下:

  • product(): 此函数形成一个等同于嵌套for循环的笛卡尔积

  • permutations(): 此函数按所有可能的顺序从宇宙p中发出长度为r的元组;没有重复的元素

  • combinations(): 此函数按排序顺序从宇宙p中发出长度为r的元组;没有重复的元素

  • combinations_with_replacement(): 此函数按照排序顺序从p中发出长度为r的元组,其中包含重复的元素

这些函数体现了从输入数据的小集合迭代可能非常大的结果集的算法。某些问题的解决方案基于详尽地枚举可能庞大的排列组合的宇宙。这些函数使得发出大量的排列组合变得简单;在某些情况下,这种简单实际上并不是最优的。

枚举笛卡尔积

笛卡尔积这个术语指的是枚举从多个集合中抽取的所有可能组合的想法。

从数学上讲,我们可能会说两个集合的乘积,枚举笛卡尔积,有 52 对如下:

{(1, C), (1, D), (1, H), (1, S), (2, C), (2, D), (2, H), (2, S), ..., (13, C), (13, D), (13, H), (13, S)}

我们可以通过执行以下命令来产生前述结果:

>>> list(product(range(1, 14), '♣♦♥♠'))
[(1, '♣'), (1, '♦'), (1, '♥'), (1, '♠'),(2, '♣'), (2, '♦'), (2, '♥'), (2, '♠'),… (13, '♣'), (13, '♦'), (13, '♥'), (13, '♠')]

产品的计算可以扩展到任意数量的可迭代集合。使用大量的集合可能会导致非常大的结果集。

减少一个乘积

在关系数据库理论中,表之间的连接可以被视为一个经过筛选的乘积。一个没有WHERE子句的 SQL SELECT语句将产生表中行的笛卡尔积。这可以被认为是最坏情况的算法:一个没有任何过滤来选择正确结果的乘积。

我们可以使用join()函数来连接两个表,如下所示的命令:

def join(t1, t2, where):):
 **return filter(where, product(t1, t2)))))

计算两个可迭代对象t1t2的所有组合。filter()函数将应用给定的where函数来通过或拒绝不符合给定条件的项目,以匹配每个可迭代对象的适当行。当where函数返回一个简单的布尔值时,这将起作用。

在某些情况下,我们没有一个简单的布尔匹配函数。相反,我们被迫搜索项目之间的某种距离的最小值或最大值。

假设我们有一个Color对象的表如下:

[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 保留为三元组,而不是分解每个单独的字段。

一幅图像将有一个像素集合:

pixels= [(([(r, g, b), (r, g, b), (r, g, b), ...)

实际上,Python Imaging LibraryPIL)包以多种形式呈现像素。其中之一是从(xy)坐标到 RGB 三元组的映射。有关更多信息,请访问Pillow 项目文档

给定一个PIL.Image对象,我们可以使用以下命令迭代像素集合:

def pixel_iter(image):
 **w, h = img.size
 **return ((c, img.getpixel(c)) for c in product(range(w), range(h)))

我们已经确定了每个坐标的范围,基于图像大小。product(range(w), range(h))方法的计算创建了所有可能的坐标组合。实际上,这是两个嵌套的for循环。

这样做的好处是为每个像素提供其坐标。然后我们可以以任意顺序处理像素,仍然可以重建图像。当使用多核或多线程来分配工作负载时,这是非常方便的。concurrent.futures模块提供了一种在多个核心或处理器之间分配工作的简单方法。

计算距离

许多决策问题要求我们找到一个足够接近的匹配。我们可能无法使用简单的相等测试。相反,我们必须使用距离度量,并找到与我们目标的最短距离的项目。对于文本,我们可能使用 Levenshtein 距离;这显示了从给定文本块到我们目标需要多少更改。

我们将使用一个稍微简单的例子。这将涉及非常简单的数学。然而,即使它很简单,如果我们天真地对待它,它也不会很好地解决问题。

在进行颜色匹配时,我们不会有一个简单的相等测试。我们很少能够检查像素颜色的确切相等。我们经常被迫定义一个最小距离函数,以确定两种颜色是否足够接近,而不是相同的 R、G 和 B 三个值。有几种常见的方法,包括欧几里得距离、曼哈顿距离,以及基于视觉偏好的其他复杂加权。

以下是欧几里得距离和曼哈顿距离函数:

def euclidean(pixel, color):
 **return math.sqrt(sum(map(lambda x, y: (x-y)**2, pixel, color.rgb)))))))
def manhattan(pixel, color):
 **return sum(map(lambda x, y: abs(x-y), 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(rgb=(255, 73, 108), name='Radical Red'), 196.19887869200477), ((0, 0), (92, 139, 195), Color(rgb=(253, 94, 83), name='Sunset Orange'), 201.2212712413874), ((0, 0), (92, 139, 195), Color(rgb=(255, 174, 66), name='Yellow Orange'), 210.7961100210343))

我们展示了一个包含多个四元组的整体元组。每个四元组包含以下内容:

  • 像素的坐标,例如(0,0)

  • 像素的原始颜色,例如(92, 139, 195)

  • 例如,我们从七种颜色中选择一个Color对象,比如 Color(rgb=(239, 222, 205),name='Almond')

  • 原始颜色与给定的Color对象之间的欧几里得距离

我们可以看到最小的欧几里得距离是最接近的匹配颜色。这种缩减很容易用min()函数实现。如果将整个元组分配给一个变量名choices,像素级的缩减将如下所示:

min(choices, key=lambda xypcd: xypcd[3]))])

我们称每个四元组为 xypcd,即 xy 坐标、像素、颜色和距离。然后,最小距离计算将选择一个单个的四元组作为像素和颜色之间的最佳匹配。

获取所有像素和所有颜色

我们如何得到包含所有像素和所有颜色的结构?答案很简单,但正如我们将看到的那样,不够理想。

将像素映射到颜色的一种方法是使用product()函数枚举所有像素和所有颜色:

xy = lambda xyp_c: xyp_c[0][0]
p = lambda xyp_c: xyp_c[0][1]
c = lambda xyp_c: xyp_c[1]
distances= (( = ((xy(item), p(item), c(item), euclidean(p(item), c(item)))
 **for item in product(pixel_iter(img), colors)))))

这个核心是product(pixel_iter(img), colors)方法,它创建了所有像素与所有颜色的组合。我们将对数据进行一些重组以使其扁平化。我们将应用euclidean()函数来计算像素颜色和Color对象之间的距离。

最终颜色的选择使用了groupby()函数和min(choices,...)表达式,如下面的命令片段所示:

for _, choices in groupby(distances, key=lambda xy_p_c_d:
 **xy_p_c_d[0]):
 **print(min(choices, key=lambda xypcd: xypcd[3])))]))

像素和颜色的整体乘积是一个长而扁平的可迭代对象。我们将可迭代对象分组成小集合,其中坐标匹配。这将把大的可迭代对象分成小的可迭代对象,每个对象只与一个像素相关联的颜色。然后我们可以为每种颜色选择最小的颜色距离。

在一个 3,648×2,736 像素,有 133 种 Crayola 颜色的图片中,我们有一个可迭代的项数为 1,327,463,424。是的。这是由这个distances表达式创建的十亿种组合。这个数字并不一定是不切实际的。它完全在 Python 可以处理的范围内。然而,它揭示了对product()函数的天真使用的一个重要缺陷。

我们不能轻易地进行这种大规模处理,而不进行一些分析来看看它有多大。这些只对每个计算进行了 1,000,000 次的timeit数字如下:

  • 欧几里德 2.8

  • 曼哈顿 1.8

从 100 万扩展到 10 亿意味着 1,800 秒,也就是说,曼哈顿距离需要大约半小时,而计算欧几里德距离需要 46 分钟。看来 Python 的核心算术运算对于这种天真的大规模处理来说太慢了。

更重要的是,我们做错了。这种宽度×高度×颜色的处理方法只是一个糟糕的设计。在许多情况下,我们可以做得更好。

性能分析

任何大数据算法的一个关键特征是找到一种执行某种分而治之策略的方法。这对于函数式编程设计和命令式设计都是正确的。

我们有三种选项来加速这个处理;它们如下:

  • 我们可以尝试使用并行处理来同时进行更多的计算。在一个四核处理器上,时间可以缩短到大约 1/4。这将把曼哈顿距离的时间缩短到 8 分钟。

  • 我们可以看看缓存中间结果是否会减少冗余计算的数量。问题是有多少颜色是相同的,有多少颜色是唯一的。

  • 我们可以寻找算法上的根本变化。

我们将通过计算源颜色和目标颜色之间的所有可能比较来结合最后两点。在这种情况下,与许多其他情境一样,我们可以轻松枚举整个映射,并避免在像素级别上进行冗余计算。我们还将把算法从一系列比较改为一系列简单的查找在一个映射对象中。

当考虑预先计算从源颜色到目标颜色的所有转换时,我们需要一些任意图像的整体统计数据。与本书相关的代码包括IMG_2705.jpg。以下是从指定图像收集一些数据的基本算法:

from collections import defaultdict, Counter
palette = defaultdict(list)
for xy_p in pixel_iter(img):
 **xy, p = xy_p
 **palette[p].append(xy)
w, h = img.size
print(""("Total pixels", w*h)
print(""("Total colors", len(palette)))))

我们将所有给定颜色的像素收集到一个按颜色组织的列表中。从中,我们将学到以下事实中的第一个:

  • 像素的总数是 9,980,928。对于一个 1000 万像素的图像来说,这并不奇怪。

  • 颜色的总数是 210,303。如果我们尝试计算实际颜色和 133 种颜色之间的欧几里德距离,我们只需要进行 27,970,299 次计算,可能需要大约 76 秒。

  • 使用 3 位掩码0b11100000,512 种可能颜色中使用了 214 种。

  • 使用 4 位掩码0b11110000,4,096 种颜色中使用了 1,150 种。

  • 使用 5 位掩码0b11111000,32,768 种颜色中使用了 5,845 种。

  • 使用 6 位掩码0b11111100,262,144 种颜色中有 27,726 种颜色。

这给了我们一些关于如何重新排列数据结构、快速计算匹配颜色,然后重建图像而不进行 10 亿次比较的见解。

我们可以使用以下命令片段将掩码值应用于 RGB 字节:

masked_color= tuple(map(lambda x: x&0b11100000, c))

这将挑选出红色、绿色和蓝色值的最重要的 3 位。如果我们使用这个来创建一个Counter对象,我们会看到我们有 214 个不同的值。

重新排列问题

对所有像素和所有颜色使用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 秒。这只需要做一次,就可以计算出 20 万个映射。

  • 在图像的一次遍历中,使用修改后的颜色表构建一个新的图像。在某些情况下,我们可以利用整数值的截断。我们可以使用这样的表达式(0b11100000&r0b11100000&g0b11100000&b)来去除图像颜色的最不重要的位。我们稍后将看到这种额外的计算减少。

这将用 1 亿次欧几里得距离计算替换成 1000 万次字典查找。这将用大约 30 秒的计算替换 30 分钟的计算。

我们不再为所有像素进行颜色映射,而是从输入到输出值创建一个静态映射。我们可以使用简单的查找映射从原始颜色到新颜色来构建图像。

一旦我们有了所有 20 万种颜色的调色板,我们就可以应用快速的曼哈顿距离来找到输出中最接近的颜色,比如蜡笔颜色。这将使用早期显示的颜色匹配算法来计算映射,而不是结果图像。区别将集中在使用palette.keys()函数而不是pixel_iter()函数。

我们将再次引入另一个优化:截断。这将给我们一个更快的算法。

结合两个转换

在结合多个转换时,我们可以从源到中间目标再到结果构建一个更复杂的映射。为了说明这一点,我们将截断颜色并应用映射。

在某些问题情境中,截断可能很困难。在其他情况下,它通常很简单。例如,将美国邮政编码从 9 位截断为 5 位是常见的。邮政编码可以进一步截断为三个字符,以确定代表更大地理区域的区域设施。

对于颜色,我们可以使用之前显示的位掩码来将三个 8 位值的颜色(24 位,1600 万种颜色)截断为三个 3 位值(9 位,512 种颜色)。

以下是一种构建颜色映射的方法,它同时结合了到给定一组颜色的距离和源颜色的截断:

bit3 = range(0, 256, 0b100000)
best = (min(((((euclidean(rgb, c), rgb, c) for c in colors)
 **for rgb in product(bit3, bit3, bit3)))))
color_map = dict(((((b[1], b[2].rgb) for b in best)

我们创建了一个range对象bit3,它将遍历所有 8 个 3 位颜色值。

注意

range对象不像普通的迭代器;它们可以被多次使用。因此,product(bit3, bit3, bit3)表达式将产生我们将用作输出颜色的所有 512 种颜色组合。

对于每个截断的 RGB 颜色,我们创建了一个三元组,其中包括(0)与所有蜡笔颜色的距离,(1)RGB 颜色和(2)蜡笔“颜色”对象。当我们要求这个集合的最小值时,我们将得到最接近截断的 RGB 颜色的蜡笔“颜色”对象。

我们建立了一个字典,将截断的 RGB 颜色映射到最接近的蜡笔。为了使用这个映射,我们将在查找映射中最接近的蜡笔之前截断源颜色。这种截断与预先计算的映射的结合显示了我们可能需要结合映射技术。

以下是图像替换的命令:

clone = img.copy()
for xy, p in pixel_iter(img):
 **r, g, b = p
 **repl = color_map[(([(0b11100000&r, 0b11100000&g, 0b11100000&b)]])]
 **clone.putpixel(xy, repl)
clone.show()

这只是使用一些 PIL 功能来用其他像素替换图片中的所有像素。

我们看到,一些函数式编程工具的天真使用可能导致表达力和简洁的算法,但也可能效率低下。计算计算复杂度的基本工具——有时被称为大 O 分析——对于函数式编程和命令式编程一样重要。

问题不在于product()函数效率低下。问题在于我们可以在一个低效的算法中使用product()函数。

排列一组值

当我们排列一组值时,我们将详细说明所有项目的可能顺序。有排列一组值种排列排列一组值项的方法。我们可以使用排列作为各种优化问题的一种蛮力解决方案。

通过访问en.wikipedia.org/wiki/Combinatorial_optimization,我们可以看到对于更大的问题,穷举所有排列并不合适。使用itertools.permutations()函数是探索非常小问题的方便方法。

这些组合优化问题的一个常见例子是分配问题。我们有n个代理人和n个任务,但每个代理人执行给定任务的成本并不相等。想象一下,一些代理人在某些细节上有困难,而其他代理人在这些细节上表现出色。如果我们能正确分配任务给代理人,我们就可以最小化成本。

我们可以创建一个简单的网格,显示给定代理人执行给定任务的能力。对于半打代理人和任务的小问题,将有一个 36 个成本的网格。网格中的每个单元格显示代理人 0 到 5 执行任务 A 到 F。

我们可以轻松列举所有可能的排列。然而,这种方法不具有良好的可扩展性。10!等于 3,628,800。我们可以使用list(permutations(range(10)))方法查看这个包含 300 万项的序列。

我们期望在几秒钟内解决这样大小的问题。如果我们将问题规模扩大到 20!,我们将会遇到可扩展性问题:将有 2,432,902,008,176,640,000 种排列。如果生成 10!排列大约需要 0.56 秒,那么生成 20!排列将需要大约 12,000 年。

假设我们有一个包含 36 个值的成本矩阵,显示了六个代理人和六个任务的成本。我们可以将问题表述如下:

perms = permutations(range(6)))))
alt= [(([(sum(cost[x][y] for y, x in enumerate(perm)), perm) for perm in perms]
m = min(alt)[0]
print([[([ans for s, ans in alt if s == m]))])

我们已经创建了六个代理人的所有任务的排列。我们已经计算了分配给每个代理人的每个任务的成本矩阵的所有成本之和。最小成本就是最佳解决方案。在许多情况下,可能会有多个最佳解决方案;我们将找到所有这些解决方案。

对于小型教科书示例,这是非常快的。对于较大的示例,逼近算法更合适。

生成所有组合

itertools模块还支持计算一组值的所有组合。在查看组合时,顺序并不重要,因此组合远少于排列。组合的数量通常表示为生成所有组合。这是我们可以从整体上取p个项目中的r个项目的组合方式。

例如,有 2,598,960 种 5 张牌的扑克手。我们可以通过执行以下命令列举所有 200 万手:

hands = list(combinations(tuple(product(range(13), '♠♥♦♣')), 5))

更实际的是,我们有一个包含多个变量的数据集。一个常见的探索技术是确定数据集中所有变量对之间的相关性。如果有v个变量,那么我们将枚举必须通过执行以下命令进行比较的所有变量:

combinations(range(v), 2)

让我们从www.tylervigen.com获取一些样本数据,以展示这将如何工作。我们将选择三个具有相同时间范围的数据集:数字 7、43 和 3890。我们将简单地将数据层压成网格,重复年份列。

这是年度数据的第一行和剩余行的样子:

[('year', 'Per capita consumption of cheese (US)Pounds (USDA)', 'Number of people who died by becoming tangled in their bedsheetsDeaths (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 VenezuelaMillions 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 种可能的组合。我们将不得不拒绝涉及yearyear的组合。这些将与值 1.00 显然相关。

这是一个从我们的数据集中挑选数据列的函数:

def column(source, x):
 **for row in source:
 **yield row[x]

这使我们能够使用第四章中的corr()函数,比较两列数据。

这是我们如何计算所有相关组合的方法:

from itertools import *
from Chapter_4.ch04_ex4 import corr
for p, q in combinations(range(9), 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)
 **print("{"{("{2: 4.2f}: {0} vs {1}".format(header_p, header_q, r_pq)))))

对于每一列的组合,我们从数据集中提取了两列数据,并使用多重赋值将标题与剩余的数据行分开。如果标题匹配,我们正在比较一个变量与自身。这将对来自冗余年份列的yearyear的三种组合为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 bedsheetsDeaths (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 VenezuelaMillions 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 bedsheetsDeaths (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 VenezuelaMillions of barrels (Dept. of Energy) vs Per capita consumption of high fructose corn syrup (US)Pounds (USDA)

这种模式的含义一点也不清楚。我们使用了一个简单的表达式combinations(range(9), 2),来枚举所有可能的数据组合。这种简洁、表达力强的技术使我们更容易专注于数据分析问题,而不是组合算法的考虑。

示例

Python 库文档中的 itertools 章节非常出色。基本定义后面是一系列非常清晰和有用的示例。由于没有理由重复这些,我们将在这里引用它们。它们是 Python 中函数式编程的必读材料。

Python 标准库10.1.2Itertools Recipes是一个很好的资源。访问docs.python.org/3/library/itertools.html#itertools-recipes获取更多详细信息。

这些函数定义不是itertools模块中可导入的函数。这些是需要阅读和理解的想法,然后可能在应用程序中复制或修改的想法。

以下表总结了一些从 itertools 基础构建的函数式编程算法的示例:

函数名称 参数 结果
powerset (iterable) 这会生成可迭代对象的所有子集。每个子集实际上是一个tuple对象,而不是一个集合实例。
random_product (*args, repeat=1) 这从itertools.product(*args, **kwds)中随机选择。
random_permutation (iterable, r=None) 这从itertools.permutations(iterable, r)中随机选择。
random_combination (iterable, r) 这从itertools.combinations(iterable, r)中随机选择。

总结

在本章中,我们看了itertools模块中的许多函数。这个库模块提供了许多帮助我们以复杂的方式处理迭代器的函数。

我们看了product()函数,它将计算从两个或多个集合中选择的元素的所有可能组合。permutations()函数给我们提供了重新排列给定一组值的不同方式。combinations()函数返回原始集合的所有可能子集。

我们还看了product()permutations()函数可以天真地用来创建非常大的结果集的方法。这是一个重要的警示。简洁而富有表现力的算法也可能涉及大量的计算。我们必须进行基本的复杂性分析,以确保代码能在合理的时间内完成。

在下一章中,我们将看一下functools模块。这个模块包括一些用于处理函数作为一等对象的工具。这是建立在第二章 介绍一些函数特性和第五章 高阶函数中展示的一些材料上。

第十章:Functools 模块

函数式编程强调函数作为一等对象。我们有许多接受函数作为参数或返回函数作为结果的高阶函数。在本章中,我们将查看functools库,其中包含一些函数来帮助我们创建和修改函数。

我们将在本章中查看一些高阶函数。之前,我们在第五章中看了高阶函数。我们还将在第十一章中继续研究高阶函数技术,装饰器设计技术

在本模块中,我们将查看以下函数:

  • @lru_cache:这个装饰器对某些类型的应用程序可能会带来巨大的性能提升。

  • @total_ordering:这个装饰器可以帮助创建丰富的比较运算符。然而,它让我们看到了面向对象设计与函数式编程的更一般问题。

  • partial():它创建一个应用于给定函数的一些参数的新函数。

  • reduce():它是一个泛化的sum()等归约的高阶函数。

我们将把这个库的另外两个成员推迟到第十一章,装饰器设计技术update_wrapper()wraps()函数。我们还将在下一章更仔细地研究编写我们自己的装饰器。

我们将完全忽略cmp_to_key()函数。它的目的是帮助转换 Python 2 代码(使用比较)以在 Python 3 下运行,Python 3 使用键提取。我们只对 Python 3 感兴趣;我们将编写适当的键函数。

函数工具

我们在第五章中看了许多高阶函数,高阶函数。这些函数要么接受一个函数作为参数,要么返回一个函数(或生成器表达式)作为结果。所有这些高阶函数都有一个基本算法,可以通过注入另一个函数来定制。像max()min()sorted()这样的函数接受一个key=函数来定制它们的行为。像map()filter()这样的函数接受一个函数和一个可迭代对象,并将该函数应用于参数。在map()函数的情况下,函数的结果被简单地保留。在filter()函数的情况下,函数的布尔结果用于从可迭代对象中传递或拒绝值。

第五章中的所有函数,高阶函数都是 Python __builtins__包的一部分:它们无需进行import即可使用。它们是无处不在的,因为它们非常普遍有用。本章中的函数必须通过import引入,因为它们并不是如此普遍可用。

reduce()函数跨越了这个界限。它最初是内置的。经过多次讨论,它从__builtins__包中移除,因为可能会被滥用。一些看似简单的操作可能表现得非常糟糕。

使用 lru_cache 进行记忆先前的结果

lru_cache装饰器将给定的函数转换为可能执行得更快的函数。LRU表示最近最少使用:保留了一组最近使用的项目。不经常使用的项目被丢弃以保持池的有界大小。

由于这是一个装饰器,我们可以将其应用于任何可能从缓存先前结果中受益的函数。我们可以这样使用它:

from functools import lru_cache
@lru_cache(128)
def fibc(n):
 **"""Fibonacci numbers with naive recursion and caching
 **>>> fibc(20)
 **6765
 **>>> fibc(1)
 **1
 **"""
 **if n == 0: return 0
 **if n == 1: return 1
 **return fibc(n-1) + fibc(n-2)

这是基于第六章的一个例子,递归和简化。我们已经将@lru_cache装饰器应用于天真的斐波那契数计算。由于这个装饰,对fibc(n)函数的每次调用现在将被检查装饰器维护的缓存。如果参数n在缓存中,将使用先前计算的结果,而不是进行可能昂贵的重新计算。每个返回值都被添加到缓存中。当缓存满时,最旧的值将被弹出以腾出空间给新值。

我们强调这个例子,因为在这种情况下,天真的递归是非常昂贵的。计算任何给定的斐波那契数的复杂性,Memoizing previous results with lru_cache,不仅涉及计算Memoizing previous results with lru_cache,还涉及计算Memoizing previous results with lru_cache。这些值的树导致了一个Memoizing previous results with lru_cache的复杂度。

我们可以尝试使用timeit模块来经验性地确认这些好处。我们可以分别执行两种实现一千次,以查看时间的比较。使用fib(20)fibc(20)方法显示了没有缓存的情况下这个计算是多么昂贵。因为天真的版本太慢了,timeit的重复次数被减少到只有 1,000 次。以下是结果:

  • Naive 3.23

  • 缓存 0.0779

请注意,我们无法在fibc()函数上轻易使用timeit模块。缓存的值将保持不变:我们只会计算一次fibc(20)函数,这将在缓存中填充这个值。其余的 999 次迭代将简单地从缓存中获取值。我们需要在使用fibc()函数之间清除缓存,否则时间几乎降为 0。这是通过装饰器构建的fibc.cache_clear()方法来完成的。

记忆化的概念是强大的。有许多算法可以从结果的记忆化中受益。也有一些算法可能受益不那么多。

p个事物中以r个为一组的组合数通常被陈述如下:

Memoizing previous results with lru_cache

这个二项式函数涉及计算三个阶乘值。在阶乘函数上使用@lru_cache装饰器可能是有意义的。计算一系列二项式值的程序将不需要重新计算所有这些阶乘。对于重复计算类似值的情况,加速可能会令人印象深刻。对于很少重复使用缓存值的情况,维护缓存值的开销超过了任何加速。

当重复计算类似值时,我们看到以下结果:

  • Naive Factorial 0.174

  • 缓存阶乘 0.046

  • 清除缓存阶乘 1.335

如果我们使用timeit模块重新计算相同的二项式,我们只会真正计算一次,并在其余时间返回相同的值;清除缓存的阶乘显示了在每次计算之前清除缓存的影响。清除缓存操作——cache_clear()函数——引入了一些开销,使其看起来比实际上更昂贵。故事的寓意是lru_cache装饰器很容易添加。它经常产生深远的影响;但也可能没有影响,这取决于实际数据的分布。

重要的是要注意,缓存是一个有状态的对象。这种设计推动了纯函数式编程的边界。一个可能的理想是避免赋值语句和相关状态的改变。避免有状态变量的概念通过递归函数得到了体现:当前状态包含在参数值中,而不是在变量的变化值中。我们已经看到,尾递归优化是一种必要的性能改进,以确保这种理想化的递归实际上可以很好地与可用的处理器硬件和有限的内存预算配合使用。在 Python 中,我们通过用for循环替换尾递归来手动进行尾递归优化。缓存是一种类似的优化:我们将根据需要手动实现它。

原则上,每次调用带有 LRU 缓存的函数都有两个结果:预期结果和一个新的缓存对象,应该用于以后的所有请求。实际上,我们将新的缓存对象封装在fibc()函数的装饰版本内。

缓存并不是万能的。与浮点值一起工作的应用程序可能不会从记忆化中受益太多,因为所有浮点数之间的差异都很小。浮点值的最低有效位有时只是随机噪音,这会阻止lru_cache装饰器中的精确相等测试。

我们将在第十六章中重新讨论这个问题,优化和改进。我们将看一些其他实现这个的方法。

定义具有完全排序的类

total_ordering装饰器有助于创建实现丰富的比较运算符的新类定义。这可能适用于子类numbers.Number的数值类。它也可能适用于半数值类。

作为一个半数值类的例子,考虑一张扑克牌。它有一个数值 rank 和一个符号 suit。只有在模拟某些游戏时,rank 才重要。这在模拟赌场二十一点时尤为重要。像数字一样,卡牌有一个顺序。我们经常对每张卡的点数进行求和,使它们类似于数字。然而,card × card的乘法实际上没有任何意义。

我们几乎可以用namedtuple()函数模拟一张扑克牌:

Card1 = namedtuple("Card1", ("rank", "suit"))

这受到了一个深刻的限制:所有比较默认包括 rank 和 suit。这导致了以下尴尬的行为:

>>> c2s= Card1(2, '\u2660')
>>> c2h= Card1(2, '\u2665')
>>> c2h == c2s
False

这对于二十一点游戏不起作用。它也不适用于某些扑克模拟。

我们真的希望卡片只按照它们的 rank 进行比较。以下是一个更有用的类定义。我们将分两部分展示。第一部分定义了基本属性:

@total_ordering
class Card(tuple):
 **__slots__ = ()
 **def __new__( class_, rank, suit ):
 **obj= tuple.__new__(Card, (rank, suit))
 **return obj
 **def __repr__(self):
 **return "{0.rank}{0.suit}".format(self)
 **@property
 **def rank(self):
 **return self[0]
 **@property
 **def suit(self):
 **return self[1]

这个类扩展了tuple类;它没有额外的插槽,因此是不可变的。我们重写了__new__()方法,以便我们可以初始化一个 rank 和一个 suit 的初始值。我们提供了一个__repr__()方法来打印Card的字符串表示。我们提供了两个属性,使用属性名称提取 rank 和 suit。

类定义的其余部分显示了我们如何定义只有两个比较:

 **def __eq__(self, other):
 **if isinstance(other,Card):
 **return self.rank == other.rank
 **elif isinstance(other,Number):
 **return self.rank == other
 **def __lt__(self, other):
 **if isinstance(other,Card):
 **return self.rank < other.rank
 **elif isinstance(other,Number):
 **return self.rank < other

我们已经定义了__eq__()__lt__()函数。@total_ordering装饰器处理了所有其他比较的构造。在这两种情况下,我们允许卡片之间的比较,也允许卡片和数字之间的比较。

首先,我们只能得到 rank 的正确比较如下:

>>> c2s= Card(2, '\u2660')
>>> c2h= Card(2, '\u2665')
>>> c2h == c2s
True
>>> c2h == 2
True

我们可以使用这个类进行许多模拟,使用简化的语法来比较卡牌的 rank。此外,我们还有一套丰富的比较运算符,如下所示:

>>> c2s= Card(2, '\u2660')
>>> c3h= Card(3, '\u2665')
>>> c4c= Card(4, '\u2663')
>>> c2s <= c3h < c4c
True
>>> c3h >= c3h
True
>>> c3h > c2s
True
>>> c4c != c2s
True

我们不需要编写所有的比较方法函数;它们是由装饰器生成的。装饰器创建的运算符并不完美。在我们的情况下,我们要求使用整数进行比较以及在Card实例之间进行比较。这揭示了一些问题。

c4c > 33 < c4c这样的操作会引发TypeError异常。这是total_ordering装饰器的局限性。这种混合类强制转换在实践中很少出现问题,因为这种情况相对不常见。

面向对象编程并不与函数式编程对立。两种技术在某些领域是互补的。Python 创建不可变对象的能力与函数式编程技术特别契合。我们可以轻松避免有状态对象的复杂性,但仍然受益于封装,以保持相关的方法函数在一起。定义涉及复杂计算的类属性特别有帮助;这将计算绑定到类定义,使应用程序更容易理解。

定义数字类

在某些情况下,我们可能希望扩展 Python 中可用的数字体系。numbers.Number的子类可能简化函数式程序。例如,我们可以将复杂算法的部分隔离到Number子类定义中,从而使应用程序的其他部分更简单或更清晰。

Python 已经提供了丰富多样的数字类型。内置类型的intfloat变量涵盖了各种问题领域。在处理货币时,decimal.Decimal包可以优雅地处理这个问题。在某些情况下,我们可能会发现fractions.Fraction类比float变量更合适。

例如,在处理地理数据时,我们可能考虑创建float变量的子类,引入额外的属性,用于在纬度(或经度)和弧度之间进行转换。这个子类中的算术操作可以简化穿越赤道或本初子午线的计算。

由于 Python 的Numbers类旨在是不可变的,普通的函数式设计可以应用于所有各种方法函数。特殊的 Python 就地特殊方法(例如,__iadd__()函数)可以简单地忽略。

当使用Number的子类时,我们有以下一系列设计考虑:

  • 相等性测试和哈希值计算。关于数字的哈希计算的核心特性在Python 标准库9.1.2 类型实现者注意事项部分有详细说明。

  • 其他比较操作符(通常通过@total_ordering装饰器定义)。

  • 算术操作符:+-*///%**。前向操作有特殊方法,还有额外的方法用于反向类型匹配。例如,对于表达式a-b,Python 使用a的类型来尝试找到__sub__()方法函数的实现:实际上是a.__sub__(b)方法。如果左侧值的类,在这种情况下是a,没有该方法或返回NotImplemented异常,那么将检查右侧值,看看b.__rsub__(a)方法是否提供结果。还有一个特殊情况,当b的类是a的类的子类时,这允许子类覆盖左侧操作选择。

  • 位操作符:&|^>><<~。这些可能对浮点值没有意义;省略这些特殊方法可能是最好的设计。

  • 一些额外的函数,如round()pow()divmod(),是通过数字特殊方法名称实现的。这些可能对这类数字有意义。

第七章,《精通面向对象的 Python》提供了创建新类型数字的详细示例。访问链接以获取更多详细信息:

www.packtpub.com/application-development/mastering-object-oriented-python

正如我们之前所指出的,函数式编程和面向对象编程可以是互补的。我们可以轻松地定义遵循函数式编程设计模式的类。添加新类型的数字是利用 Python 的面向对象特性创建更易读的函数式程序的一个例子。

使用 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 形式稍快,具体如下:

  • 0.37 部分

  • lambda 0.42

这是在 100 万次迭代中超过 0.05 秒:并没有显著的节省。

由于 lambda 形式具有partial()函数的所有功能,因此我们可以安全地将此函数设置为不是非常有用。我们将在第十四章PyMonad 库中返回它,并看看我们如何使用柯里化来实现这一点。

使用reduce()函数减少数据集

sum()len()max()min()函数在某种程度上都是reduce()函数表达的更一般算法的特殊化。reduce()函数是一个高阶函数,它将一个函数折叠到可迭代对象中的每一对项目中。

给定一个序列对象如下:

d = [2, 4, 4, 4, 5, 5, 7, 9]

函数reduce(lambda x,y:x+y,d)+运算符折叠到列表中如下:

2+4+4+4+5+5+7+9

包括()可以显示有效的分组如下:

((((((2+4)+4)+4)+5)+5)+7)+9

Python 对表达式的标准解释涉及对运算符的从左到右的评估。左折叠并没有太大的意义变化。

我们也可以提供一个初始值如下:

reduce(lambda x,y: x+y**2, iterable, 0)

如果我们不这样做,序列的初始值将被用作初始化。当有map()函数和reduce()函数时,提供初始值是必不可少的。以下是如何使用显式 0 初始化器计算正确答案的:

0+ 2**2+ 4**2+ 4**2+ 4**2+ 5**2+ 5**2+ 7**2+ 9**2

如果我们省略 0 的初始化,并且reduce()函数使用第一个项目作为初始值,我们会得到以下错误答案:

2+ 4**2+ 4**2+ 4**2+ 5**2+ 5**2+ 7**2+ 9**2

我们可以使用reduce()高阶函数定义一些内置的缩减如下:

sum2= lambda iterable: reduce(lambda x,y: x+y**2, iterable, 0)
sum= lambda iterable: reduce(lambda x, y: x+y, iterable)
count= lambda iterable: reduce(lambda x, y: x+1, iterable, 0)
min= lambda iterable: reduce(lambda x, y: x if x < y else y, iterable)
max= lambda iterable: reduce(lambda x, y: x if x > y else y, iterable)

sum2()缩减函数是平方和,用于计算一组样本的标准偏差。这个sum()缩减函数模仿了内置的sum()函数。count()缩减函数类似于len()函数,但它可以在可迭代对象上工作,而len()函数只能在实例化的collection对象上工作。

min()max()函数模仿了内置的缩减。因为可迭代对象的第一个项目被用于初始化,所以这两个函数将正常工作。如果我们为这些reduce()函数提供任何初始值,我们可能会错误地使用原始可迭代对象中从未出现的值。

结合 map()和 reduce()

我们可以看到如何围绕这些简单定义构建高阶函数。我们将展示一个简单的 map-reduce 函数,它结合了map()reduce()函数,如下所示:

def map_reduce(map_fun, reduce_fun, iterable):
 **return reduce(reduce_fun, map(map_fun, iterable))

我们从map()reduce()函数中创建了一个复合函数,它接受三个参数:映射、缩减操作和要处理的可迭代对象或序列。

我们可以分别使用map()reduce()函数构建一个平方和缩减,如下所示:

def sum2_mr(iterable):
 **return map_reduce(lambda y: y**2, lambda x,y: x+y, iterable)

在这种情况下,我们使用了lambda y: y**2参数作为映射来对每个值进行平方。缩减只是lambda x,y: x+y参数。我们不需要明确提供初始值,因为初始值将是map()函数对其进行平方后的可迭代对象中的第一项。

lambda x,y: x+y参数只是+运算符。Python 在operator模块中提供了所有算术运算符作为简短的函数。以下是我们如何稍微简化我们的 map-reduce 操作:

import operator
def sum2_mr2(iterable):
 **return map_reduce(lambda y: y**2, operator.add, iterable)

我们使用了operator.add方法来对值进行求和,而不是更长的 lambda 形式。

以下是我们如何在可迭代对象中计算值的数量:

def count_mr(iterable):
 **return map_reduce(lambda y: 1, operator.add, iterable)

我们使用lambda y: 1参数将每个值映射为简单的 1。然后计数是使用operator.add方法进行reduce()函数。

通用的reduce()函数允许我们从大型数据集创建任何种类的缩减到单个值。然而,对于我们应该如何使用reduce()函数存在一些限制。

我们应该避免执行以下命令:

reduce(operator.add, ["1", ",", "2", ",", "3"], "")

是的,它有效。然而,"".join(["1", ",", "2", ",", "3"])方法要高效得多。我们测得每百万次执行"".join()函数需要 0.23 秒,而执行reduce()函数需要 0.69 秒。

使用reduce()partial()

注意

sum()函数可以看作是partial(reduce, operator.add)方法。这也给了我们一个提示,即我们可以创建其他映射和其他缩减。实际上,我们可以将所有常用的缩减定义为 partial 而不是 lambda。

以下是两个例子:

sum2= partial(reduce, lambda x,y: x+y**2)
count= partial(reduce, lambda x,y: x+1)

现在我们可以通过sum2(some_data)count(some_iter)方法使用这些函数。正如我们之前提到的,目前还不清楚这有多大的好处。可能可以用这样的函数简单地解释特别复杂的计算。

使用map()reduce()来清理原始数据

在进行数据清理时,我们经常会引入各种复杂程度的过滤器来排除无效值。在某些情况下,我们还可以包括一个映射,以清理值,即在有效但格式不正确的值可以被替换为有效且正确的值的情况下。

我们可能会产生以下输出:

def comma_fix(data):
 **try:
 **return float(data)
 **except ValueError:
 **return float(data.replace(",", ""))
def clean_sum(cleaner, data):
 **return reduce(operator.add, map(cleaner, data))

我们定义了一个简单的映射,即comma_fix()类,它将数据从几乎正确的格式转换为可用的浮点值。

我们还定义了一个 map-reduce,它将给定的清理函数(在本例中是comma_fix()类)应用于数据,然后使用operator.add方法进行reduce()函数。

我们可以按照以下方式应用先前描述的函数:

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

我们已经清理了数据,修复了逗号,并计算了总和。这种语法非常方便,可以将这两个操作结合起来。

然而,我们必须小心,不要多次使用清理函数。如果我们还要计算平方和,我们真的不应该执行以下命令:

comma_fix_squared = lambda x: comma_fix(x)**2

如果我们将clean_sum(comma_fix_squared, d)方法作为计算标准差的一部分使用,我们将对数据进行两次逗号修复操作:一次用于计算总和,一次用于计算平方和。这是一个糟糕的设计;使用lru_cache装饰器可以帮助缓存结果。将经过清理的中间值实现为临时的tuple对象可能更好。

使用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
def partition(iterable, key=lambda x:x):
 **"""Sort not required."""
 **pd = defaultdict(list)
 **for row in iterable:
 **pd[key(row)].append(row)
 **for k in sorted(pd):
 **yield k, iter(pd[k])

这将把可迭代对象中的每个项目分成单独的组。key()函数用于从每个项目中提取一个键值。这个键用于将每个项目附加到pd字典中的列表中。这个函数的结果值与itertools.groupby()函数的结果相匹配:它是一个可迭代的(group key, iterator)对序列。

以下是使用itertools.groupby()函数完成的相同特性:

def partition_s(iterable, key= lambda x:x):
 **"""Sort required"""
 **return groupby(iterable, key)

我们可以按如下方式总结分组数据:

mean= lambda seq: sum(seq)/len(seq)
var= lambda mean, seq: sum( (x-mean)**2/mean for x in seq)
def summarize( key_iter ):
 **key, item_iter= key_iter
 **values= tuple((v for k,v in item_iter))
 **μ= mean(values)
 **return key, μ, var(μ, values)

partition()函数的结果将是一个(key, iterator)两个元组的序列。我们将键与项目迭代器分开。项目迭代器中的每个项目都是源数据中的原始对象之一;这些是(key, value)对;我们只需要值,因此我们使用了一个简单的生成器表达式来将源键与值分开。

我们还可以执行以下命令,从两个元组中选择第二个项目:

map(snd, item_iter)

这需要snd= lambda x: x[1]方法。

我们可以使用以下命令将summarize()函数应用于每个分区:

>>> partition1= partition(list(data), key=lambda x:x[0])
>>> groups= map(summarize, partition1)

替代命令如下:

>>> partition2= partition_s(sorted(data), key=lambda x:x[0])
>>> groups= map(summarize, partition2)

两者都将为我们提供每个组的汇总值。生成的组统计如下:

1 5.34 0.93
2 7.72 0.63
3 8.56 0.89
4 5.5 0.7

方差可以作为使用 groupby()和 reduce()的一部分来测试数据的零假设是否成立。零假设断言没有什么可看的;数据中的方差基本上是随机的。我们还可以比较四个组之间的数据,看各种平均值是否与零假设一致,或者是否存在一些统计学上显著的变化。

摘要

在本章中,我们研究了functools模块中的许多函数。这个库模块提供了许多函数,帮助我们创建复杂的函数和类。

我们已经将@lru_cache函数视为一种提高某些类型的应用程序的方法,这些应用程序需要频繁重新计算相同值。这个装饰器对于那些接受integerstring参数值的某些类型的函数来说是非常有价值的。它可以通过简单地实现记忆化来减少处理。

我们将@total_ ordering函数视为装饰器,以帮助我们构建支持丰富排序比较的对象。这在函数式编程的边缘,但在创建新类型的数字时非常有帮助。

partial()函数创建一个新函数,其中包含参数值的部分应用。作为替代,我们可以构建一个具有类似特性的lambda。这种用例是模棱两可的。

我们还研究了reduce()函数作为高阶函数。这概括了像sum()函数这样的缩减。我们将在后面的章节中的几个示例中使用这个函数。这与filter()map()函数在逻辑上是一致的,是一个重要的高阶函数。

在接下来的章节中,我们将看看如何使用装饰器构建高阶函数。这些高阶函数可以导致稍微更简单和更清晰的语法。我们可以使用装饰器来定义我们需要合并到许多其他函数或类中的孤立方面。

第十一章:装饰器设计技术

Python 为我们提供了许多创建高阶函数的方法。在第五章中,高阶函数,我们探讨了两种技术:定义一个接受函数作为参数的函数,以及定义Callable的子类,该子类可以初始化为一个函数或者使用函数作为参数调用。

在本章中,我们将探讨使用装饰器基于另一个函数构建函数。我们还将研究functools模块中的两个函数update_wrapper()wraps(),这些函数可以帮助我们构建装饰器。

装饰函数的好处之一是我们可以创建复合函数。这些是单个函数,包含来自多个来源的功能。复合函数,装饰器设计技术,可能比装饰器设计技术更能表达复杂算法。对于表达复杂处理,有多种语法替代方式通常是有帮助的。

装饰器作为高阶函数

装饰器的核心思想是将一些原始函数转换为另一种形式。装饰器创建了一种基于装饰器和被装饰的原始函数的复合函数。

装饰器函数可以用以下两种方式之一使用:

  • 作为一个前缀,创建一个与基本函数同名的新函数,如下所示:
@decorator
def original_function():
 **pass

  • 作为一个显式操作,返回一个新的函数,可能有一个新的名称:
def original_function():
 **pass
original_function= decorator(original_function)

这些是相同操作的两种不同语法。前缀表示法的优点是整洁和简洁。对于某些读者来说,前缀位置更加可见。后缀表示法是显式的,稍微更加灵活。虽然前缀表示法很常见,但使用后缀表示法的一个原因是:我们可能不希望结果函数替换原始函数。我们可能希望执行以下命令,允许我们同时使用装饰和未装饰的函数:

new_function = decorator(original_function)

Python 函数是一等对象。接受函数作为参数并返回函数作为结果的函数显然是语言的内置特性。那么,我们如何更新或调整函数的内部代码结构呢?

答案是我们不需要。

与其在代码内部胡乱搞,不如定义一个包装原始函数的新函数更清晰。在定义装饰器时,我们涉及两个高阶函数层次:

  • 装饰器函数将包装器应用于基本函数,并返回新的包装器。此函数可以作为构建装饰函数的一次性评估。

  • 包装函数可以(通常会)评估基本函数。每次评估装饰函数时,都会评估此函数。

这是一个简单装饰器的例子:

from functools import wraps
def nullable(function):
 **@wraps(function)
 **def null_wrapper(arg):
 **return None if arg is None else function(arg)
 **return null_wrapper

我们几乎总是希望使用functools.wraps()函数来确保装饰的函数保留原始函数的属性。例如,复制__name____doc__属性可以确保结果装饰的函数具有原始函数的名称和文档字符串。

所得到的复合函数,在装饰器的定义中称为null_wrapper()函数,也是一种高阶函数,它将原始函数function()函数与一个保留None值的表达式相结合。原始函数不是一个显式参数;它是一个自由变量,将从定义wrapper()函数的上下文中获取其值。

装饰器函数的返回值将返回新创建的函数。装饰器只返回函数,不会尝试处理任何数据。装饰器是元编程:创建代码的代码。然而,wrapper()函数将用于处理真实的数据。

我们可以应用我们的@nullable装饰器来创建一个复合函数,如下所示:

nlog = nullable(math.log)

现在我们有了一个函数nlog(),它是math.log()函数的空值感知版本。我们可以使用我们的复合函数nlog(),如下所示:

>>> some_data = [10, 100, None, 50, 60]
>>> scaled = map(nlog, some_data)** 
>>> list(scaled)
[2.302585092994046, 4.605170185988092, None, 3.912023005428146, 4.0943445622221]

我们已经将函数应用于一组数据值。None值礼貌地导致None结果。没有涉及异常处理。

注意

这个例子并不适合进行单元测试。我们需要对值进行四舍五入以进行测试。为此,我们还需要一个空值感知的round()函数。

以下是使用装饰符表示法创建空值感知舍入函数的方法:

@nullable
def nround4(x):
 **return round(x,4)

这个函数是round()函数的部分应用,包装成空值感知。在某些方面,这是一种相对复杂的函数式编程,对 Python 程序员来说是很容易使用的。

我们还可以使用以下方法创建空值感知的四舍五入函数:

nround4= nullable(lambda x: round(x,4))

这具有相同的效果,但在清晰度方面有一些成本。

我们可以使用round4()函数来创建一个更好的测试用例,用于我们的nlog()函数,如下所示:

>>> some_data = [10, 100, None, 50, 60]
>>> scaled = map(nlog, some_data)
>>> [nround4(v) for v in scaled]
[2.3026, 4.6052, None, 3.912, 4.0943]

这个结果将独立于任何平台考虑。

这个装饰器假设被装饰的函数是一元的。我们需要重新审视这个设计,以创建一个更通用的空值感知装饰器,可以处理任意集合的参数。

在第十四章中,PyMonad 库,我们将看一种容忍None值的问题的替代方法。PyMonad库定义了一个Maybe对象类,它可能有一个适当的值,也可能是None值。

使用 functool 的 update_wrapper()函数

@wraps装饰器应用update_wrapper()函数以保留包装函数的一些属性。一般来说,这默认情况下就做了我们需要的一切。这个函数将一些特定的属性从原始函数复制到装饰器创建的结果函数中。具体的属性列表是什么?它由一个模块全局变量定义。

update_wrapper()函数依赖于一个模块全局变量来确定要保留哪些属性。WRAPPER_ASSIGNMENTS变量定义了默认情况下要复制的属性。默认值是要复制的属性列表:

('__module__', '__name__', '__qualname__', '__doc__', '__annotations__')

对这个列表进行有意义的修改是困难的。为了复制额外的属性,我们必须确保我们的函数是用这些额外的属性定义的。这是具有挑战性的,因为def语句的内部不容易进行简单的修改或更改。

因为我们不能轻松地合并新的属性,所以很难找到修改或扩展包装函数工作方式的原因。将这个变量作为参考信息大多是有趣的。

如果我们要使用callable对象,那么我们可能会有一个类,它在定义中提供了一些额外的属性。然后我们可能会遇到这样一种情况,装饰器可能需要将这些额外的属性从原始的被包装的callable对象复制到正在创建的包装函数中。然而,似乎更简单的是在类定义本身中进行这些更改,而不是利用棘手的装饰器技术。

虽然有很多灵活性可用,但大部分对于普通应用程序开发并不有用。

横切关注点

装饰器背后的一个一般原则是允许我们从应用装饰器的原始函数和装饰器构建一个复合函数。这个想法是有一个常见装饰器库,可以为常见关注点提供实现。

我们经常称这些横切关注,因为它们适用于几个函数。这些是我们希望通过装饰器设计一次并在应用程序或框架中的相关类中应用的事物。

通常集中描述的关注点包括以下内容:

  • 记录

  • 审计

  • 安全

  • 处理不完整的数据

例如,logging装饰器可能会向应用程序的日志文件写入标准化消息。审计装饰器可能会写入围绕数据库更新的详细信息。安全装饰器可能会检查一些运行时上下文,以确保登录用户具有必要的权限。

我们的一个示例是对函数的空值感知包装器是一个横切关注。在这种情况下,我们希望有许多函数处理None值,而不是引发异常返回None值。在数据不完整的应用程序中,我们可能需要以简单、统一的方式处理行,而不必编写大量分散注意力的if语句来处理缺失值。

组合设计

复合函数的常见数学表示如下:

组合设计

这个想法是我们可以定义一个新函数,组合设计,它结合了另外两个函数,组合设计组合设计

Python 的多行定义形式如下:

@f
def g(x):
 **something

这在某种程度上相当于组合设计。等价性并不是非常精确,因为@f装饰器与组合组合设计组合设计的数学抽象不同。在讨论函数组合的目的时,我们将忽略组合设计的抽象和@f装饰器之间的实现断开连接。

因为装饰器包装另一个函数,Python 提供了一个稍微更一般化的组合。我们可以将 Python 设计思考如下:

组合设计

装饰器应用于某些应用程序函数,组合设计,将包括一个包装器函数。包装器的一部分,组合设计,应用于包装函数之前,另一部分,组合设计,应用于包装函数之后。

Wrapper()函数通常如下所示:

@wraps(argument_function)
def something_wrapper(*args, **kw):
 **# The "before" part, w_α, applied to *args or **kw
 **result= argument_function(*args, **kw)
 **# the "after" part, w_β, applied to the result

细节会有所不同,而且差异很大。在这个一般框架内可以做很多聪明的事情。

大量的函数式编程归结为组合设计种类的构造。我们经常拼写这些函数,因为将函数总结为一个组合,组合设计,并没有真正的好处。然而,在某些情况下,我们可能希望使用一个高阶函数,比如map()filter()reduce()来使用一个复合函数。

我们总是可以使用map(f, map(g, x))方法。然而,使用map(f_g, x)方法来应用一个复合到一个集合可能更清晰。重要的是要注意,这两种技术都没有固有的性能优势。map()函数是惰性的:使用两个map()函数,一个项目将从x中取出,由g()函数处理,然后由f()函数处理。使用单个map()函数,一个项目将从x中取出,然后由f_g()复合函数处理。

在第十四章中,PyMonad 库,我们将看看从单独的柯里化函数创建复合函数的另一种方法。

预处理坏数据

在一些探索性数据分析应用中的一个横切关注点是如何处理丢失或无法解析的数值。我们经常有一些floatintDecimal货币值的混合,我们希望以一定的一致性处理它们。

在其他情境中,我们有不适用不可用的数据值,不应干扰计算的主线。允许Not Applicable值在不引发异常的情况下通过表达式通常很方便。我们将专注于三个坏数据转换函数:bd_int()bd_float()bd_decimal()。我们要添加的复合特性将在内置转换函数之前定义。

这是一个简单的坏数据装饰器:

import decimal
def bad_data(function):
 **@wraps(function)
 **def wrap_bad_data(text, *args, **kw):
 **try:
 **return function(text, *args, **kw)
 **except (ValueError, decimal.InvalidOperation):
 **cleaned= text.replace(",", "")
 **return function(cleaned, *args, **kw)
 **return wrap_bad_data

这个函数包装了一个给定的转换函数,以尝试在第一次转换涉及坏数据时进行第二次转换。在保留None值作为Not Applicable代码的情况下,异常处理将简单地返回None值。

在这种情况下,我们提供了 Python 的*args**kw参数。这确保了包装函数可以提供额外的参数值。

我们可以使用这个包装器如下:

bd_int= bad_data(int)
bd_float= bad_data(float)
bd_decimal= bad_data(Decimal)

这将创建一套函数,可以对良好的数据进行转换,同时也可以进行有限的数据清洗,以处理特定类型的坏数据。

以下是使用bd_int()函数的一些示例:

>>> bd_int("13")
13
>>> bd_int("1,371")
1371
>>> bd_int("1,371", base=16)
4977

我们已经将bd_int()函数应用于一个字符串,它转换得很整洁,还有一个带有特定类型标点符号的字符串,我们将容忍它。我们还表明我们可以为每个转换函数提供额外的参数。

我们可能希望有一个更灵活的装饰器。我们可能希望添加的一个功能是处理各种数据清洗的能力。简单的,移除并不总是我们需要的。我们可能还需要移除$°符号。我们将在下一节中看到更复杂的、带参数的装饰器。

向装饰器添加参数

一个常见的要求是使用额外的参数自定义装饰器。我们不仅仅是创建一个复合的向装饰器添加参数,我们做的事情要复杂一些。我们正在创建向装饰器添加参数。我们应用了一个参数,c,作为创建包装器的一部分。这个参数化的复合物,向装饰器添加参数,然后可以与实际数据x一起使用。

在 Python 语法中,我们可以写成如下形式:

@deco(arg)
def func( ):
 **something

这将为基本函数定义提供一个参数化的deco(arg)函数。

效果如下:

def func( ):
 **something
func= deco(arg)(func)

我们已经做了三件事,它们如下:

  1. 定义一个函数func.

  2. 将抽象装饰器deco()应用于其参数,以创建一个具体的装饰器deco(arg).

  3. 将具体的装饰器deco(arg)应用于基本函数,以创建函数的装饰版本deco(arg)(func).

带有参数的装饰器涉及间接构建最终函数。我们似乎已经超越了仅仅是高阶函数,进入了更抽象的领域:创建高阶函数的高阶函数。

我们可以扩展我们的bad-data感知装饰器,以创建一个稍微更灵活的转换。我们将定义一个可以接受要移除的字符参数的装饰器。以下是一个带参数的装饰器:

import decimal
def bad_char_remove(*char_list):
 **def cr_decorator(function):
 **@wraps(function)
 **def wrap_char_remove(text, *args, **kw):
 **try:
 **return function(text, *args, **kw)
 **except (ValueError, decimal.InvalidOperation):
 **cleaned= clean_list(text, char_list)
 **return function(cleaned, *args, **kw)
 **return wrap_char_remove
 **return cr_decorator

一个带参数的装饰器有三个部分,它们如下:

  • 整体装饰器。这定义并返回抽象装饰器。在这种情况下,cr_decorator是一个抽象装饰器。它有一个自由变量char_list,来自初始装饰器。

  • 抽象装饰器。在这种情况下,cr_decorator 装饰器将绑定其自由变量 char_list,以便可以应用到一个函数。

  • 装饰包装器。在这个例子中,wrap_char_remove 函数将替换被包装的函数。由于 @wraps 装饰器,__name__(和其他属性)将被替换为被包装的函数的名称。

我们可以使用这个装饰器来创建转换函数,如下所示:

@bad_char_remove("$", ",")
def currency(text, **kw):
 **return Decimal(text, **kw)

我们已经使用我们的装饰器来包装一个 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 值。

实现更复杂的描述符

我们可以轻松地编写以下命令:

@f_wrap
@g_wrap
def h(x):
 **something

Python 中没有任何阻止我们的东西。这有一些类似于 实现更复杂的描述符。然而,名称仅仅是 实现更复杂的描述符。因此,当创建涉及深度嵌套描述符的函数时,我们需要谨慎。如果我们的意图只是处理一些横切关注,那么每个装饰器可以处理一个关注而不会造成太多混乱。

另一方面,如果我们使用装饰来创建一个复合函数,那么使用以下命令可能更好:

f_g_h= f_wrap(g_wrap(h))

这澄清了正在发生的事情。装饰器函数并不完全对应于函数被组合的数学抽象。装饰器函数实际上包含一个包装器函数,该包装器函数将包含被组合的函数。当尝试理解应用程序时,函数和创建函数组合的装饰器之间的区别可能会成为一个问题。

与函数式编程的其他方面一样,简洁和表达力是目标。具有表达力的装饰器是受欢迎的。编写一个可以在应用程序中完成所有事情的超级可调用函数,只需要进行轻微的定制,可能是简洁的,但很少是表达性的。

识别设计限制

在我们的数据清理的情况下,简单地去除杂散字符可能是不够的。在处理地理位置数据时,我们可能会有各种各样的输入格式,包括简单的度数(37.549016197),度和分钟(37° 32.94097′),以及度-分-秒(37° 32′ 56.46″)。当然,还可能存在更微妙的清理问题:一些设备会创建一个带有 Unicode U+00BA 字符 º 的输出,而不是类似的度字符 °,它是 U+00B0。

因此,通常需要提供一个单独的清理函数,与转换函数捆绑在一起。这个函数将处理输入格式非常不一致的输入所需的更复杂的转换,比如纬度和经度。

我们如何实现这个?我们有很多选择。简单的高阶函数是一个不错的选择。另一方面,装饰器并不是一个很好的选择。我们将看一个基于装饰器的设计,以了解装饰器的合理性有限制。

要求有两个正交设计考虑,它们如下:

  1. 输出转换(intfloatDecimal

  2. 输入清理(清除杂散字符,重新格式化坐标)

理想情况下,其中一个方面是一个被包装的基本函数,另一个方面是通过包装器包含的内容。本质与包装的选择并不清晰。其中一个原因是我们之前的例子比简单的两部分组合要复杂一些。

在之前的例子中,我们实际上创建了一个三部分的组合:

  • 输出转换(intfloatDecimal

  • 输入清洁——可以是简单的替换,也可以是更复杂的多字符替换

  • 尝试转换的函数,作为对异常的响应进行清洁,并再次尝试转换

第三部分——尝试转换和重试——实际上是包装器,也是组合函数的一部分。正如我们之前提到的,包装器包含一个前阶段和一个后阶段,我们分别称之为识别设计限制识别设计限制

我们想要使用这个包装器来创建两个额外函数的组合。对于语法,我们有两种选择。我们可以将清洁函数作为装饰器的参数包含在转换中,如下所示:

@cleanse_before(cleanser)
def convert(text):
 **something

或者,我们可以将转换函数作为清洁函数的装饰器的参数包含如下:

@then_convert(converter)
def clean(text):
 **something

在这种情况下,我们可以选择@then_convert(converter)样式的装饰器,因为我们在很大程度上依赖于内置转换。我们的观点是要表明选择并不是非常清晰的。

装饰器如下所示:

def then_convert(convert_function):
 **def clean_convert_decorator(clean_function):
 **@wraps(clean_function)
 **def cc_wrapper(text, *args, **kw):
 **try:
 **return convert_function(text, *args, **kw)
 **except (ValueError, decimal.InvalidOperation):
 **cleaned= clean_function(text)
 **return convert_function(cleaned, *args, **kw)
 **return cc_wrapper
 **return clean_convert_decorator

我们定义了一个三层装饰器。核心是cc_wrapper()函数,应用convert_function函数。如果失败,它会使用clean_function函数,然后再次尝试convert_function函数。这个函数被then_convert_decorator()具体装饰器函数包裹在clean_function函数周围。具体装饰器具有convert_function函数作为自由变量。具体装饰器由装饰器接口then_convert()创建,该接口由转换函数定制。

现在我们可以构建一个稍微更灵活的清洁和转换函数,如下所示:

@then_convert(int)
def drop_punct(text):
 **return text.replace(",", "").replace("$", "")

整数转换是应用于给定清洁函数的装饰器。在这种情况下,清洁函数移除了$,字符。整数转换包裹在这个清洁函数周围。

我们可以如下使用整数转换:

>>> drop_punct("1,701")
1701
>>> drop_punct("97")
97

虽然这可以将一些复杂的清洁和转换封装成一个非常整洁的包,但结果可能令人困惑。函数的名称是核心清洁算法的名称;另一个函数对组合的贡献被忽略了。

作为替代,我们可以如下使用整数转换:

def drop_punct(text):
 **return text.replace(",", "").replace("$", "")
drop_punct_int = then_convert(int)(drop_punct)

这将允许我们为装饰的清洁函数提供一个新的名称。这解决了命名问题,但是通过then_convert(int)(drop_punct)方法构建最终函数的过程相当不透明。

看起来我们已经触及了边界。装饰器模式并不适合这种设计。一般来说,当我们有一些相对简单和固定的方面要与给定的函数(或类)一起包含时,装饰器的效果很好。当这些额外的方面可以被看作是基础设施或支持,而不是应用代码含义的重要部分时,装饰器也很重要。

对于涉及多个正交维度的事物,我们可能希望使用各种插件策略对象的Callables函数。这可能提供更可接受的东西。我们可能需要仔细研究创建高阶函数。然后,我们可以为高阶函数的各种参数组合创建部分函数。

典型的日志记录或安全测试示例可以被视为与问题域无关的后台处理类型。当我们的处理与我们周围的空气一样普遍时,那么装饰器可能更合适。

总结

在本章中,我们看了两种类型的装饰器:没有参数的简单装饰器和带参数的装饰器。我们看到装饰器涉及函数之间的间接组合:装饰器将一个函数(在装饰器内部定义)包裹在另一个函数周围。

使用functools.wraps()装饰器可以确保我们的装饰器能够正确地从被包装的函数中复制属性。这应该是我们编写的每个装饰器的一部分。

在下一章中,我们将看一下可用于我们的多进程和多线程技术。这些包在函数式编程环境中特别有帮助。当我们消除复杂的共享状态并设计非严格处理时,我们可以利用并行性来提高性能。

第十二章:多进程和线程模块

当我们消除复杂的共享状态并设计非严格处理时,我们可以利用并行性来提高性能。在本章中,我们将研究可用于我们的多进程和多线程技术。Python 库包在应用于允许惰性评估的算法时尤其有帮助。

这里的核心思想是在一个进程内或跨多个进程中分发一个函数式程序。如果我们创建了一个合理的函数式设计,我们就不会有应用程序组件之间的复杂交互;我们有接受参数值并产生结果的函数。这是进程或线程的理想结构。

我们将专注于“多进程”和concurrent.futures模块。这些模块允许多种并行执行技术。

我们还将专注于进程级并行而不是多线程。进程并行的理念使我们能够忽略 Python 的全局解释器锁(GIL),实现出色的性能。

有关 Python 的 GIL 的更多信息,请参阅docs.python.org/3.3/c-api/init.html#thread-state-and-the-global-interpreter-lock

我们不会强调“线程”模块的特性。这经常用于并行处理。如果我们的函数式编程设计得当,那么由多线程写访问引起的任何问题都应该被最小化。然而,GIL 的存在意味着在 CPython 中,多线程应用程序会受到一些小限制的影响。由于等待 I/O 不涉及 GIL,一些 I/O 绑定的程序可能具有异常良好的性能。

最有效的并行处理发生在正在执行的任务之间没有依赖关系的情况下。通过一些精心设计,我们可以将并行编程视为一种理想的处理技术。开发并行程序的最大困难在于协调对共享资源的更新。

在遵循函数式设计模式并避免有状态的程序时,我们还可以最小化对共享对象的并发更新。如果我们能够设计出中心是惰性、非严格评估的软件,我们也可以设计出可以进行并发评估的软件。

程序总是会有一些严格的依赖关系,其中操作的顺序很重要。在2*(3+a)表达式中,(3+a)子表达式必须首先进行评估。然而,在处理集合时,我们经常遇到集合中项目的处理顺序并不重要的情况。

考虑以下两个例子:

x = list(func(item) for item in y)
x = list(reversed([func(item) for item in y[::-1]]))

尽管项目以相反的顺序进行评估,但这两个命令都会产生相同的结果。

事实上,即使是以下命令片段也会产生相同的结果:

import random
indices= list(range(len(y)))
random.shuffle(indices)
x = [None]*len(y)
for k in indices:
 **x[k] = func(y[k])

评估顺序是随机的。由于每个项目的评估是独立的,评估顺序并不重要。许多允许非严格评估的算法都是如此。

并发真正意味着什么

在一台小型计算机上,只有一个处理器和一个核心,所有评估都是通过处理器的核心进行串行化的。操作系统将通过巧妙的时间切片安排交错执行多个进程和多个线程。

在具有多个 CPU 或单个 CPU 中的多个核心的计算机上,可以对 CPU 指令进行一些实际的并发处理。所有其他并发都是通过操作系统级别的时间切片模拟的。Mac OS X 笔记本电脑可以有 200 个共享 CPU 的并发进程;这比可用核心数多得多。由此可见,操作系统的时间切片负责大部分表面上的并发行为。

边界条件

让我们考虑一个假设的算法,其中有边界条件。假设有一个涉及 1000 字节 Python 代码的内部循环。在处理 10000 个对象时,我们执行了 1000 亿次 Python 操作。这是基本的处理预算。我们可以尝试分配尽可能多的进程和线程,但处理预算是不能改变的。

单个 CPython 字节码没有简单的执行时间。然而,在 Mac OS X 笔记本上的长期平均值显示,我们可以预期每秒执行大约 60MB 的代码。这意味着我们的 1000 亿字节码操作将需要大约 1666 秒,或 28 分钟。

如果我们有一台双处理器、四核的计算机,那么我们可能将经过时间缩短到原始总时间的 25%:7 分钟。这假设我们可以将工作分成四个(或更多)独立的操作系统进程。

这里的重要考虑因素是我们的 1000 亿字节码的预算是不能改变的。并行性不会神奇地减少工作量。它只能改变时间表,也许可以减少经过时间。

切换到一个更好的算法可以将工作量减少到 132MB 的操作。以 60MBps 的速度,这个工作量要小得多。并行性不会像算法改变那样带来戏剧性的改进。

与进程或线程共享资源

操作系统确保进程之间几乎没有交互。要使两个进程交互,必须显式共享一些公共的操作系统资源。这可以是一个共享文件,一个特定的共享内存对象,或者是进程之间共享状态的信号量。进程本质上是独立的,交互是例外。

另一方面,多个线程是单个进程的一部分;进程的所有线程共享操作系统资源。我们可以例外地获得一些线程本地内存,可以自由写入而不受其他线程干扰。除了线程本地内存,写入内存的操作可能以潜在的不可预测顺序设置进程的内部状态。必须使用显式锁定来避免这些有状态更新的问题。正如之前所指出的,指令执行的整体顺序很少是严格并发的。并发线程和进程的指令通常以不可预测的顺序交错执行。使用线程会带来对共享变量的破坏性更新的可能性,需要仔细的锁定。并行处理会带来操作系统级进程调度的开销。

事实上,即使在硬件级别,也存在一些复杂的内存写入情况。有关内存写入问题的更多信息,请访问en.wikipedia.org/wiki/Memory_disambiguation

并发对象更新的存在是设计多线程应用程序时所面临的困难。锁定是避免对共享对象进行并发写入的一种方法。避免共享对象是另一种可行的设计技术。这更适用于函数式编程。

在 CPython 中,GIL 用于确保操作系统线程调度不会干扰对 Python 数据结构的更新。实际上,GIL 将调度的粒度从机器指令改变为 Python 虚拟机操作。没有 GIL,内部数据结构可能会被竞争线程的交错交互所破坏。

利益将会产生的地方

一个进行大量计算而相对较少 I/O 的程序不会从并发处理中获得太多好处。如果一个计算有 28 分钟的计算时间,那么以不同的方式交错操作不会产生太大影响。从严格到非严格评估 1000 亿个字节码不会缩短经过的执行时间。

然而,如果一个计算涉及大量 I/O,那么交错 CPU 处理和 I/O 请求可能会影响性能。理想情况下,我们希望在等待操作系统完成下一批数据输入时对一些数据进行计算。

我们有两种交错计算和 I/O 的方法。它们如下:

  • 我们可以尝试将 I/O 和计算整体问题交错进行。我们可以创建一个包含读取、计算和写入操作的处理流水线。这个想法是让单独的数据对象从一个阶段流向下一个阶段。每个阶段可以并行操作。

  • 我们可以将问题分解成可以并行处理的独立部分,从头到尾进行处理。

这些方法之间的差异并不明显;有一个模糊的中间区域,不太清楚是哪一个。例如,多个并行流水线是两种设计的混合体。有一些形式化方法可以更容易地设计并发程序。通信顺序进程CSP)范式可以帮助设计消息传递应用程序。像pycsp这样的包可以用来向 Python 添加 CSP 形式化方法。

I/O 密集型程序通常受益于并发处理。这个想法是交错 I/O 和处理。CPU 密集型程序很少受益于尝试并发处理。

使用多处理池和任务

为了在更大的上下文中使用非严格评估,multiprocessing包引入了Pool对象的概念。我们可以创建一个并发工作进程的Pool对象,将任务分配给它们,并期望任务并发执行。正如之前所述,这个创建并不实际意味着同时创建Pool对象。这意味着顺序很难预测,因为我们允许操作系统调度交错执行多个进程。对于一些应用程序,这允许在更少的经过时间内完成更多的工作。

为了充分利用这一能力,我们需要将应用程序分解成组件,对于这些组件,非严格并发执行是有益的。我们希望定义可以以不确定顺序处理的离散任务。

通过网络抓取从互联网收集数据的应用程序通常通过并行处理进行优化。我们可以创建几个相同的网站抓取器的Pool对象。任务是由池化进程分析的 URL。

分析多个日志文件的应用程序也是并行化的一个很好的候选。我们可以创建一个分析进程的Pool对象。我们可以将每个日志文件分配给一个分析器;这允许在Pool对象的各个工作进程之间并行进行读取和分析。每个单独的工作进程将涉及串行 I/O 和计算。然而,一个工作进程可以在其他工作进程等待 I/O 完成时分析计算。

处理许多大文件

这是一个多处理应用程序的例子。我们将在网络日志文件中抓取通用日志格式CLF)行。这是访问日志的通用格式。这些行往往很长,但在书的边距处包装时看起来像下面这样:

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"

我们经常有大量大文件需要分析。许多独立文件的存在意味着并发对我们的抓取过程有一些好处。

我们将分解分析为两个广泛的功能领域。任何处理的第一阶段都是解析日志文件以收集相关信息的基本阶段。我们将这分解为四个阶段。它们如下:

  1. 读取来自多个源日志文件的所有行。

  2. 然后,从文件集合中的日志条目的行创建简单的命名元组。

  3. 更复杂字段的细节,如日期和 URL,被解析。

  4. 日志中的无趣路径被拒绝;我们也可以认为这是只传递有趣的路径。

一旦过了解析阶段,我们就可以执行大量的分析。为了演示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()函数将丢弃一些分析价值不高的路径和文件扩展名。

解析日志文件-收集行

这是解析大量文件的第一阶段:读取每个文件并生成一系列简单的行。由于日志文件以.gzip格式保存,我们需要使用gzip.open()函数而不是io.open()函数或__builtins__.open()函数来打开每个文件。

local_gzip()函数从本地缓存的文件中读取行,如下命令片段所示:

def local_gzip(pattern):
 **zip_logs= glob.glob(pattern)
 **for zip_file in zip_logs:
 **with gzip.open(zip_file, "rb") as log:
 **yield (line.decode('us-ascii').rstrip() for line in log)

前面的函数遍历所有文件。对于每个文件,生成的值是一个生成器函数,它将遍历该文件中的所有行。我们封装了一些东西,包括通配符文件匹配、打开以.gzip格式压缩的日志文件的细节,以及将文件分解为一系列不带任何尾随\n字符的行。

这里的基本设计模式是产生每个文件的生成器表达式的值。前面的函数可以重新表述为一个函数和一个将该函数应用于每个文件的映射。

还有其他几种方法可以产生类似的输出。例如,以下是前面示例中内部for循环的另一种替代版本。line_iter()函数还将发出给定文件的行:

 **def line_iter(zip_file):
 **log= gzip.open(zip_file, "rb")
 **return (line.decode('us-ascii').rstrip() for line in log)

line_iter()函数应用gzip.open()函数和一些行清理。我们可以使用映射将line_iter()函数应用于符合模式的所有文件,如下所示:

map(line_iter, glob.glob(pattern))

虽然这种替代映射很简洁,但它的缺点是在没有更多引用时,会留下等待被正确垃圾回收的打开文件对象。处理大量文件时,这似乎是一种不必要的开销。因此,我们将专注于先前显示的local_gzip()函数。

先前的替代映射具有与“多进程”模块配合良好的明显优势。我们可以创建一个工作进程池,并将任务(如文件读取)映射到进程池中。如果这样做,我们可以并行读取这些文件;打开的文件对象将成为单独的进程的一部分。

对这种设计的扩展将包括第二个函数,用于使用 FTP 从 Web 主机传输文件。当从 Web 服务器收集文件时,可以使用local_gzip()函数对其进行分析。

local_gzip()函数的结果被access_iter()函数使用,为源文件中描述文件访问的每一行创建命名元组。

将日志行解析为命名元组

一旦我们可以访问每个日志文件的所有行,我们就可以提取描述的访问的详细信息。我们将使用正则表达式来分解行。从那里,我们可以构建一个namedtuple对象。

以下是解析 CLF 文件中行的正则表达式:

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+' # [SIC]
    r'"(?P<user_agent>.+?)"\s*'
)** 

我们可以使用这个正则表达式将每一行分解为九个单独的数据元素的字典。使用[]"来界定复杂字段(如timerequestreferreruser_agent参数)的方式由命名元组模式优雅地处理。

每个单独的访问可以总结为一个namedtuple()函数,如下所示:

Access = namedtuple('Access', ['host', 'identity', 'user', 'time', 'request', 'status', 'bytes', 'referrer', 'user_agent'])

注意

我们已经费心确保namedtuple函数的字段与(?P<name>)构造中每条记录的正则表达式组名匹配。通过确保名称匹配,我们可以非常容易地将解析的字典转换为元组以进行进一步处理。

以下是access_iter()函数,它要求每个文件都表示为文件行的迭代器:

def access_iter(source_iter):
 **for log in source_iter:
 **for line in log:
 **match= format_pat.match(line)
 **if match:
 **yield Access(**match.groupdict())

local_gzip()函数的输出是一个序列的序列。外部序列由单独的日志文件组成。对于每个文件,都有一个可迭代的行序列。如果行与给定模式匹配,它就是某种文件访问。我们可以从match字典中创建一个Access命名元组。

这里的基本设计模式是从解析函数的结果构建静态对象。在这种情况下,解析函数是一个正则表达式匹配器。

有一些替代方法可以做到这一点。例如,我们可以修改map()函数的使用如下:

 **def access_builder(line):
 **match= format_pat.match(line)
 **if match:
 **return Access(**match.groupdict())

先前的替代函数仅包含基本的解析和构建Access对象的处理。它将返回一个AccessNone对象。这与上面的版本不同,后者还过滤了不匹配正则表达式的项目。

以下是我们如何使用此函数将日志文件展平为Access对象的单个流:

 **map(access_builder, (line for log in source_iter for line in log))

这显示了我们如何将local_gzip()函数的输出转换为Access实例的序列。在这种情况下,我们将access_builder()函数应用于从读取文件集合中产生的嵌套迭代器的可迭代结构。

我们的重点在于展示我们有许多解析文件的功能样式。在第四章中,与集合一起工作,我们展示了非常简单的解析。在这里,我们正在执行更复杂的解析,使用各种技术。

解析访问对象的其他字段

先前创建的初始Access对象并没有分解组成访问日志行的九个字段中的一些内部元素。我们将这些项目分别从整体分解成高级字段。如果我们将这个分解成单独的解析操作,可以使解析正则表达式变得更简单。

结果对象是一个namedtuple对象,它将包装原始的Access元组。它将具有一些额外的字段,用于单独解析的细节:

AccessDetails = namedtuple('AccessDetails', ['access', 'time', 'method', 'url', 'protocol', 'referrer', 'agent'])

access属性是原始的Access对象。time属性是解析的access.time字符串。methodurlprotocol属性来自分解access.request字段。referrer属性是解析的 URL。agent属性也可以分解为细粒度字段。以下是组成代理详情的字段:

AgentDetails= namedtuple('AgentDetails', ['product', 'system', 'platform_details_extensions'])

这些字段反映了代理描述的最常见语法。在这个领域有相当大的变化,但这个特定的值子集似乎是相当常见的。

我们将三个详细的解析器函数合并成一个整体解析函数。这是第一部分,包括各种详细解析器:

def access_detail_iter(iterable):
 **def parse_request(request):
 **words = request.split()
 **return words[0], ' '.join(words[1:-1]), words[-1]
 **def parse_time(ts):
 **return datetime.datetime.strptime(ts, "%d/%b/%Y:%H:%M:%S %z")
 **agent_pat= re.compile(r"(?P<product>\S*?)\s+"
 **r"\((?P<system>.*?)\)\s*"
 **r"(?P<platform_details_extensions>.*)")
 **def parse_agent(user_agent):
 **agent_match= agent_pat.match(user_agent)
 **if agent_match:
 **return AgentDetails(**agent_match.groupdict())

我们已经为 HTTP 请求、时间戳和用户代理信息编写了三个解析器。请求通常是一个包含三个单词的字符串,例如GET /some/path HTTP/1.1。 “parse_request()”函数提取这三个以空格分隔的值。如果路径中有空格,我们将提取第一个单词和最后一个单词作为方法和协议;其余所有单词都是路径的一部分。

时间解析委托给datetime模块。我们在“parse_time()”函数中提供了正确的格式。

解析用户代理是具有挑战性的。有许多变化;我们为“parse_agent()”函数选择了一个常见的变体。如果用户代理与给定的正则表达式匹配,我们将拥有AgentDetails命名元组的属性。如果用户代理信息不匹配正则表达式,我们将简单地使用None值。

我们将使用这三个解析器从给定的“访问”对象构建AccessDetails实例。 “access_detail_iter()”函数的主体如下:

 **for access in iterable:
 **try:
 **meth, uri, protocol = parse_request(access.request)
 **yield AccessDetails(
                access= access,
                time= parse_time(access.time),
                method= meth,
                url= urllib.parse.urlparse(uri),
                protocol= protocol,
                referrer = urllib.parse.urlparse(access.referer),
                agent= parse_agent(access.user_agent)** 
 **except ValueError as e:
 **print(e, repr(access))

我们已经使用了与之前的“access_iter()”函数类似的设计模式。从解析某个输入对象的结果构建了一个新对象。新的AccessDetails对象将包装先前的Access对象。这种技术允许我们使用不可变对象,但仍然包含更精细的信息。

这个函数本质上是从Access对象到AccessDetails对象的映射。我们可以想象改变设计以使用“map()”如下:

def access_detail_iter2(iterable):
 **def access_detail_builder(access):
 **try:
 **meth, uri, protocol = parse_request(access.request)
 **return AccessDetails(access= access,time= parse_time(access.time),method= meth,url= urllib.parse.urlparse(uri),protocol= protocol,referrer = urllib.parse.urlparse(access.referer),agent= parse_agent(access.user_agent))
 **except ValueError as e:
 **print(e, repr(access))
 **return filter(None, map(access_detail_builder, iterable))

我们已经更改了AccessDetails对象的构造方式,使其成为返回单个值的函数。我们可以将该函数映射到Access对象的可迭代输入流。这也与multiprocessing模块的工作方式非常匹配。

在面向对象的编程环境中,这些额外的解析器可能是类定义的方法函数或属性。这种设计的优点是,除非需要,否则不会解析项目。这种特定的功能设计解析了一切,假设它将被使用。

不同的函数设计可能依赖于三个解析器函数,根据需要从给定的Access对象中提取和解析各个元素。我们将使用“parse_time(access.time)”参数,而不是使用details.time属性。语法更长,但只有在需要时才解析属性。

过滤访问细节

我们将查看AccessDetails对象的几个过滤器。第一个是一组过滤器,拒绝了许多很少有趣的开销文件。第二个过滤器将成为分析函数的一部分,我们稍后会看到。

“path_filter()”函数是三个函数的组合:

  1. 排除空路径。

  2. 排除一些特定的文件名。

  3. 排除具有特定扩展名的文件。

“path_filter()”函数的优化版本如下:

def path_filter(access_details_iter):
 **name_exclude = {'favicon.ico', 'robots.txt', 'humans.txt', 'crossdomain.xml' ,'_images', 'search.html', 'genindex.html', 'searchindex.js', 'modindex.html', 'py-modindex.html',}
 **ext_exclude = { '.png', '.js', '.css', }
 **for detail in access_details_iter:
 **path = detail.url.path.split('/')
 **if not any(path):
 **continue
 **if any(p in name_exclude for p in path):
 **continue
 **final= path[-1]
 **if any(final.endswith(ext) for ext in ext_exclude):
 **continue
 **yield detail

对于每个单独的AccessDetails对象,我们将应用三个过滤测试。如果路径基本为空,或者部分包括被排除的名称之一,或者路径的最终名称具有被排除的扩展名,该项目将被静默地忽略。如果路径不符合这些标准之一,它可能是有趣的,并且是path_filter()函数产生的结果的一部分。

这是一个优化,因为所有的测试都是使用命令式风格的for循环体应用的。

设计始于每个测试作为一个单独的一流过滤器风格函数。例如,我们可能有一个处理空路径的函数如下:

 **def non_empty_path(detail):
 **path = detail.url.path.split('/')
 **return any(path)

这个函数只是确保路径包含一个名称。我们可以使用filter()函数如下:

filter(non_empty_path, access_details_iter)

我们可以为non_excluded_names()non_excluded_ext()函数编写类似的测试。整个filter()函数序列将如下所示:

filter(non_excluded_ext,
    filter(non_excluded_names,
        filter(non_empty_path, access_details_iter)))** 

这将每个filter()函数应用于前一个filter()函数的结果。空路径将被拒绝;从这个子集中,被排除的名称和被排除的扩展名也将被拒绝。我们也可以将前面的示例陈述为一系列赋值语句如下:

 **ne= filter(non_empty_path, access_details_iter)
 **nx_name= filter(non_excluded_names, ne)
 **nx_ext= filter(non_excluded_ext, nx_name)
 **return nx_ext

这个版本的优点是在添加新的过滤条件时稍微更容易扩展。

注意

使用生成器函数(如filter()函数)意味着我们不会创建大型的中间对象。每个中间变量nenx_namenx_ext都是适当的惰性生成器函数;直到数据被客户端进程消耗之前,都不会进行处理。

虽然优雅,但这会导致一些小的低效,因为每个函数都需要解析AccessDetails对象中的路径。为了使这更有效,我们需要使用lru_cache属性包装path.split('/')函数。

分析访问细节

我们将看看两个分析函数,我们可以用来过滤和分析单个AccessDetails对象。第一个函数,一个filter()函数,将只传递特定的路径。第二个函数将总结每个不同路径的出现次数。

我们将filter()函数定义为一个小函数,并将其与内置的filter()函数结合起来,将该函数应用于细节。这是复合filter()函数:

def book_filter(access_details_iter):
 **def book_in_path(detail):
 **path = tuple(l for l in detail.url.path.split('/') if l)
 **return path[0] == 'book' and len(path) > 1
 **return filter(book_in_path, access_details_iter)

我们定义了一个规则,即book_in_path()属性,我们将应用于每个AccessDetails对象。如果路径不为空,并且路径的第一级属性是book,那么我们对这些对象感兴趣。所有其他AccessDetails对象可以被静默地拒绝。

这是我们感兴趣的最终减少:

from collections import Counter
def reduce_book_total(access_details_iter):
 **counts= Counter()
 **for detail in access_details_iter:
 **counts[detail.url.path] += 1
 **return counts

这个函数将产生一个Counter()对象,显示了AccessDetails对象中每个路径的频率。为了专注于特定的路径集,我们将使用reduce_total(book_filter(details))方法。这提供了一个仅显示通过给定过滤器的项目的摘要。

完整的分析过程

这是消化日志文件集合的复合analysis()函数:

def analysis(filename):
 **details= path_filter(access_detail_iter(access_iter(local_gzip(filename))))
 **books= book_filter(details)
 **totals= reduce_book_total(books)
 **return totals

前面的命令片段将适用于单个文件名或文件模式。它将一组标准的解析函数path_filter()access_detail_iter()access_iter()local_gzip()应用于文件名或文件模式,并返回AccessDetails对象的可迭代序列。然后,它将我们的分析过滤器和减少器应用于AccessDetails对象的这个序列。结果是一个Counter对象,显示了某些路径的访问频率。

一组特定的保存为.gzip格式的日志文件总共约 51MB。使用这个函数串行处理文件需要超过 140 秒。我们能否使用并发处理做得更好?

使用多进程池进行并发处理

使用multiprocessing模块的一个优雅的方法是创建一个处理Pool对象,并将工作分配给该池中的各个进程。我们将使用操作系统在各个进程之间交错执行。如果每个进程都有 I/O 和计算的混合,我们应该能够确保我们的处理器非常忙碌。当进程等待 I/O 完成时,其他进程可以进行计算。当 I/O 完成时,一个进程将准备好运行,并且可以与其他进程竞争处理时间。

将工作映射到单独的进程的方法如下:

 **import multiprocessing
 **with multiprocessing.Pool(4) as workers:
 **workers.map(analysis, glob.glob(pattern))

我们创建了一个具有四个独立进程的Pool对象,并将该Pool对象分配给workers变量。然后,我们将一个名为analysis的函数映射到要执行的工作的可迭代队列上,使用进程池。workers池中的每个进程将被分配来自可迭代队列的项目。在这种情况下,队列是glob.glob(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(function, iterable)方法将可迭代对象中的项目分配给池中的每个工作进程。完成的结果按照它们分配给Pool对象的顺序进行收集,以保持顺序。

imap(function, iterable) 方法被描述为比 map 方法“更懒”。默认情况下,它会将可迭代对象中的每个单独项目发送给下一个可用的工作进程。这可能涉及更多的通信开销。因此建议使用大于 1 的块大小。

imap_unordered(function, iterable)方法类似于imap()方法,但结果的顺序不被保留。允许映射无序处理意味着每个进程完成时结果都被收集。否则,结果必须按顺序收集。

starmap(function, iterable)方法类似于itertools.starmap()函数。可迭代对象中的每个项目必须是一个元组;使用*修饰符将元组传递给函数,以便元组的每个值成为位置参数值。实际上,它执行function(*iterable[0])function(*iterable[1])等等。

以下是前述映射主题的一个变体:

 **import multiprocessing
 **pattern = "*.gz"
 **combined= Counter()
 **with multiprocessing.Pool() as workers:
 **for result in workers.imap_unordered(analysis, glob.glob(pattern)):
 **combined.update(result)

我们创建了一个Counter()函数,用于整合池中每个工作进程的结果。我们根据可用 CPU 的数量创建了一个子进程池,并使用Pool对象作为上下文管理器。然后我们将我们的analysis()函数映射到我们文件匹配模式中的每个文件上。来自analysis()函数的结果Counter对象被合并成一个单一的计数器。

这大约需要 68 秒。使用多个并发进程,分析日志的时间减少了一半。

我们使用multiprocessing模块的Pool.map()函数创建了一个两层的 map-reduce 过程。第一层是analysis()函数,它对单个日志文件执行了 map-reduce。然后我们在更高级别的 reduce 操作中 consolide 这些减少。

使用 apply()来发出单个请求

除了map()函数的变体外,池还有一个apply(function, *args, **kw)方法,我们可以使用它来将一个值传递给工作池。我们可以看到map()方法实际上只是一个包装在apply()方法周围的for循环,例如,我们可以使用以下命令:

list(workers.apply(analysis, f) for f in glob.glob(pattern))

对于我们的目的来说,这并不明显是一个重大的改进。我们几乎可以把所有需要做的事情都表达为一个map()函数。

使用 map_async(),starmap_async()和 apply_async()

map()starmap()apply()函数的行为是将工作分配给Pool对象中的子进程,然后在子进程准备好响应时收集响应。这可能导致子进程等待父进程收集结果。_async()函数的变体不会等待子进程完成。这些函数返回一个对象,可以查询该对象以获取子进程的单个结果。

以下是使用map_async()方法的变体:

 **import multiprocessing
 **pattern = "*.gz"
 **combined= Counter()
 **with multiprocessing.Pool() as workers:
 **results = workers.map_async(analysis, glob.glob(pattern))
 **data= results.get()
 **for c in data:
 **combined.update(c)

我们创建了一个Counter()函数,用于整合池中每个工作进程的结果。我们根据可用 CPU 的数量创建了一个子进程池,并将这个Pool对象用作上下文管理器。然后我们将我们的analysis()函数映射到我们文件匹配模式中的每个文件上。map_async()函数的响应是一个MapResult对象;我们可以查询这个对象以获取池中工作进程的结果和整体状态。在这种情况下,我们使用get()方法获取Counter对象的序列。

来自analysis()函数的结果Counter对象被合并成一个单一的Counter对象。这个聚合给我们提供了多个日志文件的总体摘要。这个处理并没有比之前的例子更快。使用map_async()函数允许父进程在等待子进程完成时做额外的工作。

更复杂的多进程架构

multiprocessing包支持各种各样的架构。我们可以轻松创建跨多个服务器的多进程结构,并提供正式的身份验证技术,以创建必要的安全级别。我们可以使用队列和管道在进程之间传递对象。我们可以在进程之间共享内存。我们还可以在进程之间共享较低级别的锁,以同步对共享资源(如文件)的访问。

大多数这些架构都涉及显式管理多个工作进程之间的状态。特别是使用锁和共享内存,这是必要的,但与函数式编程方法不太匹配。

我们可以通过一些小心处理,以函数式方式处理队列和管道。我们的目标是将设计分解为生产者和消费者函数。生产者可以创建对象并将它们插入队列。消费者将从队列中取出对象并处理它们,可能将中间结果放入另一个队列。这样就创建了一个并发处理器网络,工作负载分布在这些不同的进程之间。使用pycsp包可以简化进程之间基于队列的消息交换。欲了解更多信息,请访问pypi.python.org/pypi/pycsp

在设计复杂的应用服务器时,这种设计技术有一些优势。各个子进程可以存在于服务器的整个生命周期中,同时处理各个请求。

使用concurrent.futures模块

除了multiprocessing包,我们还可以使用concurrent.futures模块。这也提供了一种将数据映射到并发线程或进程池的方法。模块 API 相对简单,并且在许多方面类似于multiprocessing.Pool()函数的接口。

以下是一个示例,展示它们有多相似:

 **import concurrent.futures
 **pool_size= 4
 **pattern = "*.gz"
 **combined= Counter()
 **with concurrent.futures.ProcessPoolExecutor(max_workers=pool_size) as workers:
 **for result in workers.map(analysis, glob.glob(pattern)):
 **combined.update(result)

前面示例和之前的示例之间最显著的变化是,我们使用了concurrent.futures.ProcessPoolExecutor对象的实例,而不是multiprocessing.Pool方法。基本的设计模式是使用可用工作进程池将analysis()函数映射到文件名列表。生成的Counter对象被合并以创建最终结果。

concurrent.futures模块的性能几乎与multiprocessing模块相同。

使用concurrent.futures线程池

concurrent.futures模块提供了第二种我们可以在应用程序中使用的执行器。我们可以使用concurrent.futures.ProcessPoolExecutor对象,也可以使用ThreadPoolExecutor对象。这将在单个进程中创建一个线程池。

语法与使用ProcessPoolExecutor对象完全相同。然而,性能却有显著不同。日志文件处理受 I/O 控制。一个进程中的所有线程共享相同的操作系统调度约束。因此,多线程日志文件分析的整体性能与串行处理日志文件的性能大致相同。

使用示例日志文件和运行 Mac OS X 的小型四核笔记本电脑,以下是表明共享 I/O 资源的线程和进程之间差异的结果类型:

  • 使用concurrent.futures线程池,经过的时间是 168 秒

  • 使用进程池,经过的时间是 68 秒

在这两种情况下,Pool对象的大小都是 4。目前尚不清楚哪种应用程序受益于多线程方法。一般来说,多进程似乎对 Python 应用程序最有利。

使用线程和队列模块

Python 的threading包涉及一些有助于构建命令式应用程序的构造。这个模块不专注于编写函数式应用程序。我们可以利用queue模块中的线程安全队列,在线程之间传递对象。

threading模块没有一种简单的方法来将工作分配给各个线程。API 并不理想地适用于函数式编程。

multiprocessing模块的更原始特性一样,我们可以尝试隐藏锁和队列的有状态和命令性本质。然而,似乎更容易利用concurrent.futures模块中的ThreadPoolExecutor方法。ProcessPoolExecutor.map()方法为我们提供了一个非常愉快的界面,用于并发处理集合的元素。

使用map()函数原语来分配工作似乎与我们的函数式编程期望很好地契合。因此,最好专注于concurrent.futures模块作为编写并发函数程序的最可访问的方式。

设计并发处理

从函数式编程的角度来看,我们已经看到了三种并发应用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()函数映射到Access对象的可迭代序列。

  • access_detail_iter(access-iter(local_gzip(pattern)))函数重构为一个单独的可迭代对象。然后我们将对AccessDetail对象的可迭代序列进行path_filter()函数和更高级别的过滤和减少映射。

  • 我们还可以将下层解析重构为与更高级别分析分开的函数。我们可以将分析过滤器和减少映射到下层解析的输出。

所有这些都是对示例应用程序相对简单的重组。使用函数式编程技术的好处在于整个过程的每个部分都可以定义为一个映射。这使得考虑不同的架构来找到最佳设计变得实际可行。

在这种情况下,我们需要将 I/O 处理分配到尽可能多的 CPU 或核心。大多数潜在的重构将在父进程中执行所有 I/O;这些重构只会将计算分配给多个并发进程,但效益很小。然后,我们希望专注于映射,因为这些可以将 I/O 分配到尽可能多的核心。

最小化从一个进程传递到另一个进程的数据量通常很重要。在这个例子中,我们只向每个工作进程提供了短文件名字符串。结果的Counter对象比每个日志文件中 10MB 压缩详细数据要小得多。我们可以通过消除仅出现一次的项目来进一步减少每个Counter对象的大小;或者我们可以将我们的应用程序限制为仅使用最受欢迎的 20 个项目。

我们可以自由重新组织这个应用程序的设计,并不意味着我们应该重新组织设计。我们可以运行一些基准实验来确认我们的怀疑,即日志文件解析主要受到读取文件所需的时间的影响。

总结

在本章中,我们已经看到了支持多个数据并发处理的两种方法:

  • multiprocessing模块:具体来说,Pool类和可用于工作池的各种映射。

  • concurrent.futures模块:具体来说,ProcessPoolExecutorThreadPoolExecutor类。这些类还支持一种映射,可以在线程或进程之间分配工作。

我们还注意到了一些似乎不太适合函数式编程的替代方案。multiprocessing模块还有许多其他特性,但它们与函数式设计不太匹配。同样,threadingqueue模块可以用于构建多线程应用,但这些特性与函数式程序不太匹配。

在下一章中,我们将介绍operator模块。这可以用来简化某些类型的算法。我们可以使用内置的操作函数,而不是定义 lambda 形式。我们还将探讨一些灵活决策设计的技巧,并允许表达式以非严格顺序进行评估。

第十三章.条件表达式和操作模块

函数式编程强调操作的惰性或非严格顺序。其思想是允许编译器或运行时尽可能少地计算答案。Python 倾向于对评估施加严格顺序。

例如,我们使用了 Python 的ifelifelse语句。它们清晰易读,但暗示了对条件评估的严格顺序。在这里,我们可以在一定程度上摆脱严格的顺序,并开发一种有限的非严格条件语句。目前还不清楚这是否有帮助,但它展示了一些以函数式风格表达算法的替代方式。

本章的第一部分将探讨我们可以实现非严格评估的方法。这是一个有趣的工具,因为它可以导致性能优化。

在前几章中,我们看了一些高阶函数。在某些情况下,我们使用这些高阶函数将相当复杂的函数应用于数据集合。在其他情况下,我们将简单的函数应用于数据集合。

实际上,在许多情况下,我们编写了微小的lambda对象来将单个 Python 运算符应用于函数。例如,我们可以使用以下内容来定义prod()函数:

>>> prod= lambda iterable: functools.reduce(lambda x, y: x*y, iterable, 1)
>>> prod((1,2,3))
6

使用lambda x,y: x*y参数似乎有点冗长,用于乘法。毕竟,我们只想使用乘法运算符*。我们能简化语法吗?答案是肯定的;operator模块为我们提供了内置运算符的定义。

operator模块的一些特性导致了一些简化和潜在的澄清,以创建高阶函数。尽管在概念上很重要,但operator模块并不像最初看起来那么有趣。

评估条件表达式

Python 对表达式施加了相对严格的顺序;显著的例外是短路运算符andor。它对语句评估施加了非常严格的顺序。这使得寻找避免这种严格评估的不同方式变得具有挑战性。

事实证明,评估条件表达式是我们可以尝试非严格顺序语句的一种方式。我们将研究一些重构ifelse语句的方法,以探索 Python 中这种非严格评估的方面。

Python 的ifelifelse语句是按从头到尾的严格顺序进行评估的。理想情况下,一种语言可能会放松这个规则,以便优化编译器可以找到更快的顺序来评估条件表达式。这个想法是让我们按照读者理解的顺序编写表达式,即使实际的评估顺序是非严格的。

缺乏优化编译器,这个概念对 Python 来说有点牵强。尽管如此,我们确实有替代的方式来表达涉及函数评估而不是执行命令式语句的条件。这可以让您在运行时进行一些重新排列。

Python 确实有条件ifelse表达式。当只有一个条件时,可以使用这种表达式形式。然而,当有多个条件时,可能会变得非常复杂:我们必须小心地嵌套子表达式。我们可能最终会得到一个命令,如下所示,这是相当难以理解的:

(x if n==1 else (y if n==2 else z))

我们可以使用字典键和lambda对象来创建一组非常复杂的条件。以下是一种表达阶乘函数的方法:

def fact(n):
 **f= { n == 0: lambda n: 1,
 **n == 1: lambda n: 1,
 **n == 2: lambda n: 2,
 **n > 2: lambda n: fact(n-1)*n }[True]
 **return f(n)

这将传统的ifelifelifelse语句序列重写为单个表达式。我们将其分解为两个步骤,以使发生的事情稍微清晰一些。

在第一步中,我们将评估各种条件。给定条件中的一个将评估为True,其他条件应该都评估为False。生成的字典中将有两个项目:一个具有True键和一个lambda对象,另一个具有False键和一个lambda对象。我们将选择True项目并将其分配给变量f

我们在此映射中使用 lambda 作为值,以便在构建字典时不评估值表达式。我们只想评估一个值表达式。return语句评估与True条件相关联的一个表达式。

利用非严格的字典规则

字典的键没有顺序。如果我们尝试创建一个具有共同键值的多个项目的字典,那么在生成的dict对象中只会有一个项目。不清楚哪个重复的键值将被保留,也不重要。

这是一个明确不关心哪个重复键被保留的情况。我们将看一个max()函数的退化情况,它只是选择两个值中的最大值:

def max(a, b):
 **f = {a >= b: lambda: a, b >= a: lambda: b}[True]
 **return f()

a == b的情况下,字典中的两个项目都将具有True条件的键。实际上只有两者中的一个会被保留。由于答案是相同的,保留哪个并将哪个视为重复并覆盖并不重要。

过滤真条件表达式

我们有多种方法来确定哪个表达式是True。在前面的示例中,我们将键加载到字典中。由于字典的加载方式,只有一个值将保留具有True键的值。

这是使用filter()函数编写的这个主题的另一个变体:

def semifact(n):
 **alternatives= [(n == 0, lambda n: 1),
 **(n == 1, lambda n: 1),
 **(n == 2, lambda n: 2),
 **(n > 2, lambda n: semifact(n-2)*n)]
 **c, f= next(filter(itemgetter(0), alternatives))
 **return f(n)

我们将替代方案定义为conditionfunction对的序列。当我们使用filter()函数并使用itemgetter(0)参数时,我们将选择那些具有True条件的对。在那些True的对中,我们将选择filter()函数创建的可迭代对象中的第一个项目。所选条件分配给变量c,所选函数分配给变量f。我们可以忽略条件(它将是True),并且可以评估filter()函数。

与前面的示例一样,我们使用 lambda 来推迟对函数的评估,直到条件被评估之后。

这个semifact()函数也被称为双阶乘。半阶乘的定义类似于阶乘的定义。重要的区别是它是交替数字的乘积而不是所有数字的乘积。例如,看一下以下公式:

过滤真条件表达式过滤真条件表达式

使用operator模块而不是 lambda

在使用max()min()sorted()函数时,我们有一个可选的key=参数。作为参数值提供的函数修改了高阶函数的行为。在许多情况下,我们使用简单的 lambda 形式来从元组中选择项目。以下是我们严重依赖的两个示例:

fst = lambda x: x[0]
snd = lambda x: x[1]

这些与其他函数式编程语言中的内置函数相匹配。

我们实际上不需要编写这些函数。operator模块中有一个版本描述了这些函数。

以下是一些我们可以使用的示例数据:

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

这是年度奶酪消费量。我们在第二章和第九章中使用了这个示例,介绍一些功能特性更多的迭代工具技术

我们可以使用以下命令找到具有最小奶酪的数据点:

>>> min(year_cheese, key=snd)
(2000, 29.87)

operator模块为我们提供了从元组中选择特定元素的替代方法。这样可以避免使用lambda变量来选择第二个项目。

我们可以使用itemgetter(0)itemgetter(1)参数,而不是定义自己的fst()snd()函数,如下所示:

>>> from operator import *
>>> max( year_cheese, key=itemgetter(1))
(2007, 33.5)

itemgetter()函数依赖于特殊方法__getitem__(),根据它们的索引位置从元组(或列表)中挑选项目。

在使用高阶函数时获取命名属性

让我们来看一下稍微不同的数据集合。假设我们使用的是命名元组而不是匿名元组。我们有两种方法来定位奶酪消耗量的范围,如下所示:

>>> from collections import namedtuple
>>> YearCheese = namedtuple("YearCheese", ("year", "cheese"))
>>> year_cheese_2 = list(YearCheese(*yc) for yc in year_cheese)
>>> year_cheese_2
[YearCheese(year=2000, cheese=29.87), YearCheese(year=2001, cheese=30.12), YearCheese(year=2002, cheese=30.6), YearCheese(year=2003, cheese=30.66), YearCheese(year=2004, cheese=31.33), YearCheese(year=2005, cheese=32.62), YearCheese(year=2006, cheese=32.73), YearCheese(year=2007, cheese=33.5), YearCheese(year=2008, cheese=32.84), YearCheese(year=2009, cheese=33.02), YearCheese(year=2010, cheese=32.92)]

我们可以使用 lambda 形式,也可以使用attrgetter()函数,如下所示:

>>> min(year_cheese_2, key=attrgetter('cheese'))
YearCheese(year=2000, cheese=29.87)
>>> max(year_cheese_2, key=lambda x: x.cheese)
YearCheese(year=2007, cheese=33.5)

这里重要的是,使用lambda对象时,属性名称在代码中表示为一个标记。而使用attrgetter()函数时,属性名称是一个字符串。这可以是一个参数,这使我们可以相当灵活。

使用运算符的星形映射

itertools.starmap()函数可以应用于运算符和一系列值对。这里有一个例子:

>>> d= starmap(pow, zip_longest([], range(4), fillvalue=60))

itertools.zip_longest()函数将创建一对序列,如下所示:

[(60, 0), (60, 1), (60, 2), (60, 3)]

它之所以这样做,是因为我们提供了两个序列:[]括号和range(4)参数。当较短的序列用尽数据时,fillvalue参数将被使用。

当我们使用starmap()函数时,每对都成为给定函数的参数。在这种情况下,我们提供了operator.pow()函数,即**运算符。我们计算了[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)))

我们将两个四个值的序列压缩在一起。我们使用了starmap()函数和operator.truediv()函数,即/运算符。这将计算出一个我们求和的分数序列。总和实际上是Starmapping with operators的近似值。

这是一个更简单的版本,它使用map(f, x, y)函数,而不是starmap(f, zip(x,y))函数:

>>> pi = sum(map(truediv, p, d))
>>> pi
3.1415925925925925

在这个例子中,我们有效地将一个基数为60的分数值转换为基数为10。变量d中的值是适当的分母。可以使用类似本节前面解释的技术来转换其他基数。

一些近似涉及潜在无限的和(或积)。可以使用本节前面解释的类似技术来评估这些近似。我们可以利用itertools模块中的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))
>>> 2*sum(terms)
3.1415926533011587

num变量是一个基于阶乘函数的潜在无限序列的分子。den变量是一个基于半阶乘(有时称为双阶乘)函数的潜在无限序列的分母。

为了创建项,我们使用map()函数将operators.truediv()函数(即/运算符)应用于每对值。我们将其包装在takewhile()函数中,这样我们只取值,而分数大于某个相对较小的值;在这种情况下,Starmapping with operators

这是基于 4 arctan(1)=Starmapping with operators的级数展开。展开式是Starmapping with operators

系列展开主题的一个有趣变化是用fractions.Fraction()函数替换operator.truediv()函数。这将创建精确的有理值,不会受到浮点近似的限制。

operators模块中包含所有 Python 运算符。这包括所有位操作运算符以及比较运算符。在某些情况下,生成器表达式可能比看起来相当复杂的starmap()函数与表示运算符的函数更简洁或更表达。

问题在于operator模块只提供了一个运算符,基本上是lambda的简写。我们可以使用operator.add方法代替add=lambda a,b: a+b方法。如果我们有更复杂的表达式,那么lambda对象是编写它们的唯一方法。

使用运算符进行缩减

我们将看一种我们可能尝试使用运算符定义的方式。我们可以将它们与内置的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()函数以及operator.and()operator.or()函数的用途。

注意

and()or()函数是位运算符&/。如果我们想要正确的布尔运算符,我们必须使用all()any()函数,而不是reduce()函数。

一旦我们有了prod()函数,这意味着阶乘可以定义如下:

fact= lambda n: 1 if n < 2 else n*prod(range(1,n))

这有一个简洁的优势:它提供了一个阶乘的单行定义。它还有一个优势,不依赖于递归,但有可能触发 Python 的堆栈限制。

目前尚不清楚这是否比我们在 Python 中拥有的许多替代方案具有明显优势。从原始部分构建复杂函数的概念,如partial()reduce()函数以及operator模块非常优雅。然而,在大多数情况下,operator模块中的简单函数并不是很有用;我们几乎总是希望使用更复杂的 lambda。

总结

在本章中,我们探讨了替代ifelifelse语句序列的方法。理想情况下,使用条件表达式可以进行一些优化。从实用的角度来看,Python 并不进行优化,因此处理条件的更奇特方式几乎没有实质性的好处。

我们还看了如何使用operator模块与max()min()sorted()reduce()等高阶函数。使用运算符可以避免我们创建许多小的 lambda 函数。

在下一章中,我们将研究PyMonad库,直接在 Python 中表达函数式编程概念。通常情况下,我们不需要单子,因为 Python 在底层是一种命令式编程语言。

一些算法可能通过单子比通过有状态的变量赋值更清晰地表达。我们将看一个例子,其中单子导致对一组相当复杂的规则进行简洁的表达。最重要的是,operator模块展示了许多函数式编程技术。

第十四章:PyMonad 库

单子允许我们在一个否则宽松的语言中对表达式的评估施加顺序。我们可以使用单子来坚持要求像a + b + c这样的表达式按从左到右的顺序进行评估。一般来说,单子似乎没有什么意义。然而,当我们希望文件按特定顺序读取或写入其内容时,单子是一种确保read()write()函数按特定顺序进行评估的便捷方式。

宽松且具有优化编译器的语言受益于单子,以对表达式的评估施加顺序。Python 在大多数情况下是严格的,不进行优化。我们对单子几乎没有实际用途。

然而,PyMonad 模块不仅仅是单子。它具有许多具有独特实现的函数式编程特性。在某些情况下,PyMonad 模块可以导致比仅使用标准库模块编写的程序更简洁和表达力更强。

下载和安装

PyMonad 模块可在Python Package IndexPyPi)上找到。为了将 PyMonad 添加到您的环境中,您需要使用 pip 或 Easy Install。以下是一些典型情况:

  • 如果您使用的是 Python 3.4 或更高版本,您将拥有这两个安装包工具

  • 如果您使用的是 Python 3.x,可能已经有了其中一个必要的安装程序,因为您已经添加了包

  • 如果你使用的是 Python 2.x,你应该考虑升级到 Python 3.4

  • 如果你没有 pip 或 Easy Install,你需要先安装它们;考虑升级到 Python 3.4 以获取这些安装工具

访问pypi.python.org/pypi/PyMonad/获取更多信息。

对于 Mac OS 和 Linux 开发人员,必须使用sudo命令运行命令pip install PyMonadeasy_install-3.3 pymonad。当运行诸如sudo easy_install-3.3 pymonad的命令时,系统会提示您输入密码,以确保您具有进行安装所需的管理权限。对于 Windows 开发人员,sudo命令不相关,但您需要具有管理权限。

安装了pymonad包后,可以使用以下命令进行确认:

>>> import pymonad
>>> help(pymonad)

这将显示docstring模块,并确认事情确实安装正确。

函数组合和柯里化

一些函数式语言通过将多参数函数语法转换为一组单参数函数来工作。这个过程称为柯里化——它是以逻辑学家 Haskell Curry 的名字命名的,他从早期概念中发展出了这个理论。

柯里化是一种将多参数函数转换为高阶单参数函数的技术。在简单情况下,我们有一个函数函数组合和柯里化;给定两个参数xy,这将返回一些结果值z。我们可以将其柯里化为两个函数:函数组合和柯里化函数组合和柯里化。给定第一个参数值x,函数返回一个新的单参数函数,函数组合和柯里化返回一个新的单参数函数,函数组合和柯里化。第二个函数可以给定一个参数y,并返回结果值z

我们可以在 Python 中评估柯里化函数,如下所示:f_c(2)(3)。我们将柯里化函数应用于第一个参数值2,创建一个新函数。然后,我们将该新函数应用于第二个参数值3

这适用于任何复杂度的函数。如果我们从一个函数开始Functional composition and currying,我们将其柯里化为一个函数Functional composition and currying。这是递归完成的。首先,Functional composition and currying函数返回一个带有 b 和 c 参数的新函数,Functional composition and currying。然后,我们可以对返回的两参数函数进行柯里化,创建Functional composition and currying

我们可以使用g_c(1)(2)(3)来评估这个柯里化函数。当我们将Functional composition and currying应用于参数 1 时,我们得到一个函数;当我们将返回的函数应用于 2 时,我们得到另一个函数。当我们将最终函数应用于 3 时,我们得到预期的结果。显然,正式的语法很臃肿,因此我们使用一些语法糖将g_c(1)(2)(3)减少到更容易接受的形式,如g(1,2,3)

让我们以 Python 中的一个具体例子为例,例如,我们有一个如下所示的函数:

from pymonad import curry
@curry
def systolic_bp(bmi, age, gender_male, treatment):
 **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= systolic_bp(25, 50, 0)
>>> treated(0)
115.15
>>> treated(1)
121.59

在前面的例子中,我们评估了systolic_bp(25, 50, 0)方法来创建一个柯里化函数,并将其分配给变量treatment。BMI、年龄和性别值通常不会改变。我们现在可以将新函数treatment应用于剩余的参数,根据患者的历史得到不同的血压期望。

在某些方面,这与functools.partial()函数类似。重要的区别在于柯里化创建了一个可以以多种方式工作的函数。functools.partial()函数创建了一个更专门的函数,只能与给定的一组绑定值一起使用。

这是创建一些额外柯里化函数的示例:

>>> g_t= systolic_bp(25, 50)
>>> g_t(1, 0)
116.09
>>> g_t(0, 1)
121.59

这是基于我们初始模型的基于性别的治疗函数。我们必须提供性别和治疗值才能从模型中得到最终值。

使用柯里化的高阶函数

虽然柯里化在使用普通函数时很容易进行可视化,但当我们将柯里化应用于高阶函数时,其真正价值就显现出来了。在理想情况下,functools.reduce()函数将是“可柯里化的”,这样我们就可以这样做:

sum= reduce(operator.add)
prod= reduce(operator.mul)

然而,pymonad库无法对reduce()函数进行柯里化,因此这实际上不起作用。然而,如果我们定义自己的reduce()函数,我们可以像之前展示的那样对其进行柯里化。以下是一个可以像之前展示的那样使用的自制reduce()函数的示例:

import collections.abc
from pymonad import curry
@curry
def myreduce(function, iterable_or_sequence):
 **if isinstance(iterable_or_sequence, collections.abc.Sequence):
 **iterator= iter(iterable_or_sequence)
 **else:
 **iterator= iterable_or_sequence
 **s = next(iterator)
 **for v in iterator:
 **s = function(s,v)
 **return s

myreduce()函数将表现得像内置的reduce()函数。myreduce()函数适用于可迭代对象或序列对象。给定一个序列,我们将创建一个迭代器;给定一个可迭代对象,我们将简单地使用它。我们将结果初始化为迭代器中的第一项。我们将函数应用于正在进行的总和(或乘积)和每个后续项。

注意

也可以包装内置的reduce()函数以创建一个可柯里化的版本。这只需要两行代码;这是留给你的一个练习。

由于myreduce()函数是一个柯里化函数,我们现在可以使用它来基于我们的高阶函数myreduce()创建函数:

>>> from operator import *
>>> sum= myreduce(add)
>>> sum([1,2,3])
6
>>> max= myreduce(lambda x,y: x if x > y else y)
>>> max([2,5,3])
5

我们使用柯里化的 reduce 应用于add运算符定义了我们自己版本的sum()函数。我们还使用lambda对象定义了我们自己版本的默认max()函数,它选择两个值中较大的一个。

这种方式不能轻松地创建max()函数的更一般形式,因为柯里化侧重于位置参数。尝试使用key=关键字参数会增加太多复杂性,使得这种技术无法朝着我们简洁和表达式丰富的函数程序的总体目标发展。

要创建max()函数的更一般化版本,我们需要跳出key=关键字参数范例,这些函数如max()min()sorted()依赖于。我们必须接受高阶函数作为第一个参数,就像filter()map()reduce()函数一样。我们还可以创建我们自己的更一致的高阶柯里化函数库。这些函数将完全依赖于位置参数。高阶函数将首先提供,以便我们自己的柯里化max(function, iterable)方法遵循map()filter()functools.reduce()函数设定的模式。

艰难的柯里化

我们可以手动创建柯里化函数,而不使用pymonad库中的装饰器;其中一种方法是执行以下命令:

def f(x, *args):
 **def f1(y, *args):
 **def f2(z):
 **return (x+y)*z
 **if args:
 **return f2(*args)
 **return f2
 **if args:
 **return f1(*args)
 **return f1

这将一个函数柯里化成一个函数f(x),它返回一个函数。在概念上,我们然后对中间函数进行柯里化,创建f1(y)f2(z)函数。

当我们评估f(x)函数时,我们将得到一个新的函数f1作为结果。如果提供了额外的参数,这些参数将传递给f1函数进行评估,要么产生最终值,要么产生另一个函数。

显然,这可能会出现错误。然而,它确实有助于定义柯里化的真正含义以及它在 Python 中的实现方式。

函数组合和 PyMonad 乘法运算符

柯里化函数的一个重要价值在于能够通过函数组合来结合它们。我们在第五章和第十一章中讨论了函数组合,高阶函数装饰器设计技术

当我们创建了一个柯里化函数,我们可以轻松地执行函数组合,创建一个新的、更复杂的柯里化函数。在这种情况下,PyMonad 包为组合两个函数定义了*运算符。为了展示这是如何工作的,我们将定义两个可以组合的柯里化函数。首先,我们将定义一个计算乘积的函数,然后我们将定义一个计算特定值范围的函数。

这是我们计算乘积的第一个函数:

import  operator
prod = myreduce(operator.mul)

这是基于我们之前定义的柯里化myreduce()函数。它使用operator.mul()函数来计算可迭代对象的“乘法减少”:我们可以称一个乘积为序列的 a 次减少。

这是我们的第二个柯里化函数,它将产生一系列值:

@curry
def alt_range(n):
 **if n == 0: return range(1,2) # Only 1
 **if n % 2 == 0:
 **return range(2,n+1,2)
 **else:
 **return range(1,n+1,2)

alt_range()函数的结果将是偶数值或奇数值。如果n是奇数,它将只有值直到(包括)n。如果n是偶数,它将只有偶数值直到n。这些序列对于实现半阶乘或双阶乘函数很重要。

以下是如何将 prod()alt_range() 函数组合成一个新的柯里化函数:

>>> semi_fact= prod * alt_range
>>> semi_fact(9)
945

这里的 PyMonad * 运算符将两个函数组合成一个名为 semi_fact 的复合函数。alt_range() 函数被应用到参数上。然后,prod() 函数被应用到 alt_range 函数的结果上。

通过在 Python 中手动执行这些操作,实际上是在创建一个新的 lambda 对象:

semi_fact= lambda x: prod(alt_range(x))

柯里化函数的组合涉及的语法比创建一个新的 lambda 对象要少一些。

理想情况下,我们希望像这样使用函数组合和柯里化函数:

sumwhile= sum * takewhile(lambda x: x > 1E-7)

这将定义一个可以处理无限序列的 sum() 函数版本,在达到阈值时停止生成值。这似乎行不通,因为 pymonad 库似乎无法像处理内部的 List 对象一样处理无限可迭代对象。

函子和应用函子

函子的概念是简单数据的函数表示。数字 3.14 的函子版本是一个零参数函数,返回这个值。考虑以下示例:

pi= lambda : 3.14

我们创建了一个具有简单值的零参数 lambda 对象。

当我们将柯里化函数应用于函子时,我们正在创建一个新的柯里化函子。这通过使用函数来表示参数、值和函数本身来概括了“应用函数到参数以获得值”的概念。

一旦我们的程序中的所有内容都是函数,那么所有处理都只是函数组合的变体。柯里化函数的参数和结果可以是函子。在某个时候,我们将对一个 functor 对象应用 getValue() 方法,以获得一个可以在非柯里化代码中使用的 Python 友好的简单类型。

由于我们所做的只是函数组合,直到我们使用 getValue() 方法要求值时才需要进行计算。我们的程序不是执行大量计算,而是定义了一个复杂的对象,可以在需要时产生值。原则上,这种组合可以通过聪明的编译器或运行时系统进行优化。

当我们将一个函数应用到一个 functor 对象时,我们将使用类似于 map() 的方法,该方法实现为 * 运算符。我们可以将 function * functormap(function, functor) 方法看作是理解函子在表达式中扮演的角色的一种方式。

为了礼貌地处理具有多个参数的函数,我们将使用 & 运算符构建复合函子。我们经常会看到 functor & functor 方法来构建一个 functor 对象。

我们可以用 Maybe 函子的子类来包装 Python 的简单类型。Maybe 函子很有趣,因为它为我们提供了一种优雅地处理缺失数据的方法。我们在第十一章中使用的方法是装饰内置函数,使其具有 None 意识。PyMonad 库采用的方法是装饰数据,使其能够优雅地拒绝被操作。

Maybe 函子有两个子类:

  • Nothing

  • Just(some simple value)

我们使用 Nothing 作为简单 Python 值 None 的替代。这是我们表示缺失数据的方式。我们使用 Just(some simple value) 来包装所有其他 Python 对象。这些函子是常量值的函数式表示。

我们可以使用这些 Maybe 对象的柯里化函数来优雅地处理缺失的数据。以下是一个简短的示例:

>>> x1= systolic_bp * Just(25) & Just(50) & Just(1) & Just(0)
>>> x1.getValue()
116.09
>>> x2= systolic_bp * Just(25) & Just(50) & Just(1) & Nothing
>>> x2.getValue() is None
True

* 运算符是函数组合:我们正在将 systolic_bp() 函数与一个参数复合。& 运算符构建一个复合函子,可以作为多参数柯里化函数的参数传递。

这向我们表明,我们得到了一个答案,而不是TypeError异常。在处理大型复杂数据集时,数据可能缺失或无效,这非常方便。这比不得不装饰所有函数以使它们具有None感知性要好得多。

这对于柯里化函数非常有效。我们不能在未柯里化的 Python 代码中操作Maybe函子,因为函子的方法非常少。

注意

我们必须使用getValue()方法来提取未柯里化的 Python 代码的简单 Python 值。

使用惰性 List()函子

List()函子一开始可能会让人困惑。它非常懒惰,不像 Python 的内置list类型。当我们评估内置list(range(10))方法时,list()函数将评估range()对象以创建一个包含 10 个项目的列表。然而,PyMonad 的List()函子太懒惰了,甚至不会进行这种评估。

这是比较:

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> List(range(10))
[range(0, 10)]

List()函子没有评估range()对象,它只是保留了它而没有被评估。PyMonad.List()函数用于收集函数而不对其进行评估。我们可以根据需要稍后对其进行评估:

>>> x= List(range(10))
>>> x
[range(0, 10)]
>>> list(x[0])
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

我们创建了一个带有range()对象的惰性List对象。然后我们提取并评估了该列表中位置0处的range()对象。

List对象不会评估生成器函数或range()对象;它将任何可迭代参数视为单个迭代器对象。但是,我们可以使用*运算符来展开生成器或range()对象的值。

注意

请注意,*运算符有几种含义:它是内置的数学乘法运算符,是由 PyMonad 定义的函数组合运算符,以及在调用函数时用于将单个序列对象绑定为函数的所有位置参数的内置修饰符。我们将使用*运算符的第三个含义来将一个序列分配给多个位置参数。

这是range()函数的柯里化版本。它的下限是 1 而不是 0。对于某些数学工作很方便,因为它允许我们避免内置range()函数中的位置参数的复杂性。

@curry
def range1n(n):
 **if n == 0: return range(1,2) # Only 1
 **return range(1,n+1)

我们简单地包装了内置的range()函数,使其可以由 PyMonad 包进行柯里化。

由于List对象是一个函子,我们可以将函数映射到List对象。该函数应用于List对象中的每个项目。这是一个例子:

>>> fact= prod * range1n
>>> seq1 = List(*range(20))
>>> f1 = fact * seq1
>>> f1[:10]
[1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880]

我们定义了一个复合函数fact(),它是从先前显示的prod()range1n()函数构建的。这是阶乘函数,Using the lazy List() functor。我们创建了一个List()函子seq1,它是一个包含 20 个值的序列。我们将fact()函数映射到seq1函子,从而创建了一个阶乘值的序列f1。我们之前展示了其中的前 10 个值。

注意

函数的组合和函数与函子的组合之间存在相似之处。prod*range1nfact*seq1都使用函数组合:一个组合明显是函数的东西,另一个组合是函数和函子。

这是另一个我们将用来扩展此示例的小函数:

@curry
def n21(n):
 **return 2*n+1

这个小的n21()函数执行简单的计算。但是,它是柯里化的,因此我们可以将其应用于像List()函数这样的函子。这是前面示例的下一部分:

>>> semi_fact= prod * alt_range
>>> f2 = semi_fact * n21 * seq1
>>> f2[:10]
[1, 3, 15, 105, 945, 10395, 135135, 2027025, 34459425, 654729075]

我们从先前显示的prod()alt_range()函数定义了一个复合函数。函数f2是半阶乘或双阶乘,Using the lazy List() functor。函数f2的值是通过将我们的小n21()函数应用于seq1序列来构建的。这创建了一个新序列。然后我们将semi_fact函数应用于这个新序列,以创建一个Using the lazy List() functor值的序列,与Using the lazy List() functor值的序列相对应。

现在我们可以将/运算符映射到map()operator.truediv并行函子:

>>> 2*sum(map(operator.truediv, f1, f2))
3.1415919276751456

map()函数将给定的运算符应用于两个函子,产生一系列分数,我们可以将它们相加。

注意

f1 & f2方法将创建两个List对象的所有值的组合。这是List对象的一个重要特性:它们可以很容易地枚举所有的组合,允许一个简单的算法计算所有的替代方案,并过滤适当的子集。这是我们不想要的;这就是为什么我们使用map()函数而不是operator.truediv * f1 & f2方法。

我们使用了一些函数组合技术和一个函子类定义来定义了一个相当复杂的计算。这是这个计算的完整定义:

使用惰性 List()函子

理想情况下,我们不希望使用固定大小的List对象。我们更希望有一个惰性的、潜在无限的整数值序列。然后我们可以使用sum()takewhile()函数的柯里化版本来找到序列中值的和,直到这些值对结果没有贡献。这将需要一个更懒惰的List()对象的版本,它可以与itertools.counter()函数一起使用。在 PyMonad 1.3 中,我们没有这个潜在无限的列表;我们只能使用固定大小的List()对象。

单子概念、bind()函数和二进制右移运算符

PyMonad 库的名称来自函数式编程概念中的单子,即具有严格顺序的函数。函数式编程的基本假设是函数求值是自由的:它可以根据需要进行优化或重新排列。单子提供了一个例外,强加了严格的从左到右的顺序。

正如我们所见,Python 是严格的。它不需要单子。然而,在可以帮助澄清复杂算法的地方,我们仍然可以应用这个概念。

强制求值的技术是单子和将返回一个单子的函数之间的绑定。一个扁平表达式将变成嵌套的绑定,不能被优化编译器重新排序。bind()函数映射到>>运算符,允许我们编写这样的表达式:

Just(some file) >> read header >> read next >> read next

前面的表达式将转换为以下形式:

bind(bind(bind(Just(some file), read header), read next), read next)

bind()函数确保在对这个表达式进行求值时施加了严格的从左到右的顺序。另外,注意前面的表达式是函数组合的一个例子。当我们使用>>运算符创建一个单子时,我们正在创建一个复杂的对象,当我们最终使用getValue()方法时,它将被求值。

Just()子类用于创建一个简单的单子兼容对象,它包装了一个简单的 Python 对象。

单子概念对于表达严格的求值顺序是至关重要的——在一个经过高度优化和宽松的语言中。Python 不需要单子,因为它使用从左到右的严格求值。这使得单子很难展示,因为在 Python 环境中它并没有真正做一些全新的事情。事实上,单子多余地陈述了 Python 遵循的典型严格规则。

在其他语言中,比如 Haskell,单子对于需要严格顺序的文件输入和输出至关重要。Python 的命令式模式很像 Haskell 的do块,它有一个隐式的 Haskell >>=运算符来强制语句按顺序求值。(PyMonad 使用bind()函数和 Haskell 的>>运算符来执行 Haskell 的>>=操作。)

使用单子实现模拟

单子被期望通过一种“管道”传递:一个单子将作为参数传递给一个函数,类似的单子将作为函数的值返回。这些函数必须设计为接受和返回类似的结构。

我们将看一下一个简单的流水线,用于模拟一个过程。这种模拟可能是蒙特卡洛模拟的一个正式部分。我们将直接进行蒙特卡洛模拟,并模拟一个赌场骰子游戏——Craps。这涉及到对相当复杂的模拟进行状态规则的模拟。

涉及了很多非常奇怪的赌博术语。我们无法提供有关各种术语的背景信息。在某些情况下,这些术语的起源已经迷失在历史中。

Craps 涉及有人掷骰子(射击者)和额外的赌徒。游戏的进行方式如下:

第一次投掷被称为“come out”投掷。有三种情况:

  1. 如果骰子总数为 7 或 11,则射击者获胜。任何在“pass”线上下注的人都将被支付为赢家,而所有其他赌注都将输掉。游戏结束,射击者可以再玩一次。

  2. 如果骰子总数为 2、3 或 12,射击者输掉。任何在“don't pass”线上下注的人都会赢,而所有其他赌注都会输掉。游戏结束,射击者必须将骰子传递给另一个射击者。

  3. 任何其他总数(即 4、5、6、8、9 或 10)都会建立一个“point”。游戏从“come out”投掷状态转变为“point”投掷状态。游戏继续进行。

如果已经建立了一个点,每个“point”投掷都会根据三个条件进行评估:

  • 如果骰子总数为 7,射击者输掉。实际上,几乎所有的赌注都是输家,除了“don't pass”赌注和一个特殊的提议赌注。由于射击者输了,骰子被传递给另一个射击者。

  • 如果骰子总数等于最初的点数,射击者获胜。任何在 pass 线上下注的人都将被支付为赢家,而所有其他赌注都将输掉。游戏结束,射击者可以再玩一次。

  • 任何其他总数都会使游戏继续进行,没有解决。

规则涉及一种状态变化。我们可以将其视为一系列操作,而不是状态变化。有一个必须首先使用的函数。之后使用另一个递归函数。这样,它很好地符合单子设计模式。

实际上,赌场在游戏过程中允许进行许多相当复杂的副注。我们可以将这些与游戏的基本规则分开进行评估。其中许多赌注(提议、场地赌注和购买数字)是玩家在游戏的“point roll”阶段简单下注的赌注。还有一个额外的“come”和“don't come”一对赌注,建立了一个嵌套游戏中的点。我们将在以下示例中坚持游戏的基本轮廓。

我们需要一个随机数源:

import random
def rng():
 **return (random.randint(1,6), random.randint(1,6))

前面的函数将为我们生成一对骰子。

以下是我们对整个游戏的期望:

def craps():
 **outcome= Just(("",0, []) ) >> come_out_roll(rng) >> point_roll(rng)
 **print(outcome.getValue())

我们创建一个初始单子,Just(("",0, [])),来定义我们要处理的基本类型。游戏将产生一个三元组,其中包含结果、点数和一系列投掷。最初,它是一个默认的三元组,用于定义我们要处理的类型。

我们将这个单子传递给另外两个函数。这将创建一个结果单子,outcome,其中包含游戏的结果。我们使用>>运算符按特定顺序连接函数,以便它们按顺序执行。在优化语言中,这将防止表达式被重新排列。

我们使用getValue()方法在最后获取单子的值。由于单子对象是惰性的,这个请求会触发对各种单子的评估,以创建所需的输出。

come_out_roll()函数将rng()函数作为第一个参数柯里化。单子将成为这个函数的第二个参数。come_out_roll()函数可以掷骰子,并应用开局规则来确定我们是赢了、输了还是建立了一个点。

point_roll()函数也将rng()函数作为第一个参数柯里化。单子将成为第二个参数。然后point_roll()函数可以掷骰子来查看赌注是否解决。如果赌注没有解决,这个函数将递归操作继续寻找解决方案。

come_out_roll()函数看起来像这样:

@curry
def come_out_roll(dice, status):
 **d= dice()
 **if sum(d) in (7, 11):
 **return Just(("win", sum(d), [d]))
 **elif sum(d) in (2, 3, 12):
 **return Just(("lose", sum(d), [d]))
 **else:
 **return Just(("point", sum(d), [d]))

我们掷骰子一次,以确定我们是首次投掷赢,输,还是点数。我们返回一个适当的单子值,其中包括结果,点数值和骰子的投掷。立即赢得和立即输掉的点数值并不真正有意义。我们可以合理地在这里返回0,因为实际上并没有建立点数。

point_roll()函数看起来像这样:

@curry
def point_roll(dice, status):
 **prev, point, so_far = status
 **if prev != "point":
 **return Just(status)
 **d = dice()
 **if sum(d) == 7:
 **return Just(("craps", point, so_far+[d]))
 **elif sum(d) == point:
 **return Just(("win", point, so_far+[d]))
 **else:
 **return Just(("point", point, so_far+[d])) >> point_roll(dice)

我们将status单子分解为元组的三个单独值。我们可以使用小的lambda对象来提取第一个,第二个和第三个值。我们也可以使用operator.itemgetter()函数来提取元组的项目。相反,我们使用了多重赋值。

如果没有建立点数,先前的状态将是“赢”或“输”。游戏在一次投掷中解决,这个函数只是返回status单子。

如果建立了一个点数,就会掷骰子并应用规则到新的投掷。如果投掷是 7,游戏就输了,并返回最终的单子。如果投掷是点数,游戏就赢了,并返回适当的单子。否则,一个稍微修改的单子被传递给point_roll()函数。修改后的status单子包括这次投掷在投掷历史中。

典型的输出看起来像这样:

>>> craps()
('craps', 5, [(2, 3), (1, 3), (1, 5), (1, 6)])

最终的单子有一个显示结果的字符串。它有建立的点数和骰子投掷的顺序。每个结果都有一个特定的赔付,我们可以用来确定投注者赌注的总波动。

我们可以使用模拟来检查不同的投注策略。我们可能正在寻找一种方法来击败游戏内置的庄家优势。

附注

游戏基本规则存在一些小的不对称性。11 作为立即赢家与 3 作为立即输家平衡。2 和 12 也是输家的事实是这个游戏中庄家优势的基础,为 5.5%(1/18 = 5.5)。想法是确定哪些额外的投注机会会削弱这个优势。

一些简单的、功能性的设计技术可以构建出许多巧妙的蒙特卡洛模拟。特别是单子可以帮助结构化这些类型的计算,当存在复杂的订单或内部状态时。

附加的 PyMonad 功能

PyMonad 的另一个特性是令人困惑地命名为monoid。这直接来自数学,它指的是一组具有运算符、单位元素,并且对于该运算符是封闭的数据元素。当我们考虑自然数、add运算符和单位元素0时,这是一个合适的单子。对于正整数,使用运算符*和单位值1,我们也有一个单子;使用|作为运算符和空字符串作为单位元素的字符串也符合条件。

PyMonad 包括许多预定义的单子类。我们可以扩展这个来添加我们自己的monoid类。目的是限制编译器对某些类型的优化。我们还可以使用单子类来创建累积复杂值的数据结构,可能包括以前操作的历史。

其中许多内容提供了对函数式编程的见解。总结文档,这是一个学习函数式编程的简单方法,在可能稍微宽容的环境中。与其学习整个语言和工具集来编译和运行函数式程序,我们可以只是用交互式 Python 进行实验。

从实用的角度来看,我们不需要太多这些功能,因为 Python 已经是有状态的,并且提供了表达式的严格评估。在 Python 中引入有状态的对象或严格排序的评估没有实际理由。我们可以通过将函数式概念与 Python 的命令式实现相结合来编写有用的程序。因此,我们不会深入研究 PyMonad。

总结

在本章中,我们看了如何使用 PyMonad 库直接在 Python 中表达一些函数式编程概念。该模块展示了许多重要的函数式编程技术。

我们看了柯里化的概念,这是一种允许组合参数的函数,以创建新函数的方法。柯里化函数还允许我们使用函数组合,从简单的部分创建更复杂的函数。我们看了一下函子,它们包装简单的数据对象,使它们成为可以与函数组合一起使用的函数。

单子是一种在使用优化编译器和惰性评估规则时强加严格评估顺序的方法。在 Python 中,我们没有单子的一个很好的用例,因为 Python 在底层是一种命令式编程语言。在某些情况下,命令式 Python 可能比单子构造更具表现力和简洁。

在下一章中,我们将看看如何应用函数式编程技术来构建 Web 服务应用程序。HTTP 的概念可以总结为response = httpd(request)。理想情况下,HTTP 是无状态的,这使其与函数式设计完美匹配。然而,大多数网站将保持状态,使用 cookie 来跟踪会话状态。

第十五章:面向 Web 服务的功能性方法

我们将远离探索性数据分析,而是仔细研究 Web 服务器和 Web 服务。在某种程度上,这些都是一系列函数。我们可以将许多函数设计模式应用于呈现 Web 内容的问题上。我们的目标是探索我们可以使用表述状态转移REST)的方式。我们希望使用函数设计模式构建 RESTful Web 服务。

我们不需要再发明另一个 Python Web 框架;有很多框架可供选择。我们将避免创建一个庞大的通用解决方案。

我们不想在可用的框架中进行选择。每个框架都有不同的特性和优势。

我们将提出一些可以应用于大多数可用框架的原则。我们应该能够利用功能设计模式来呈现 Web 内容。这将使我们能够构建具有功能设计优势的基于 Web 的应用程序。

例如,当我们查看极大的数据集或极复杂的数据集时,我们可能需要一个支持子集或搜索的 Web 服务。我们可能需要一个能够以各种格式下载子集的网站。在这种情况下,我们可能需要使用功能设计来创建支持这些更复杂要求的 RESTful Web 服务。

最复杂的 Web 应用程序通常具有使网站更易于使用的有状态会话。会话信息通过 HTML 表单提供的数据更新,或者从数据库中获取,或者从以前的交互的缓存中获取。虽然整体交互涉及状态更改,但应用程序编程可以在很大程度上是功能性的。一些应用程序函数在使用请求数据、缓存数据和数据库对象时可能是非严格的。

为了避免特定 Web 框架的细节,我们将专注于Web 服务器网关接口WSGI)设计模式。这将使我们能够实现一个简单的 Web 服务器。以下链接提供了大量信息:

wsgi.readthedocs.org/en/latest/

有关 WSGI 的一些重要背景信息可以在以下链接找到:

www.python.org/dev/peps/pep-0333/

我们将从 HTTP 协议开始。然后,我们可以考虑诸如 Apache httpd 之类的服务器来实现此协议,并了解mod_wsgi如何成为基本服务器的合理扩展。有了这些背景,我们可以看看 WSGI 的功能性质以及如何利用功能设计来实现复杂的 Web 搜索和检索工具。

HTTP 请求-响应模型

基本的 HTTP 协议理想上是无状态的。用户代理或客户端可以从功能性的角度看待协议。我们可以使用http.clienturllib库构建客户端。HTTP 用户代理基本上执行类似于以下内容的操作:

import urllib.request
with urllib.request.urlopen(""http://slott-softwarearchitect.blogspot.com"") as response:
 **print(response.read())

wgetcurl这样的程序在命令行上执行此操作;URL 是从参数中获取的。浏览器响应用户的指向和点击执行此操作;URL 是从用户的操作中获取的,特别是点击链接文本或图像的操作。

然而,互联网协议的实际考虑导致了一些有状态的实现细节。一些 HTTP 状态代码表明用户代理需要额外的操作。

3xx 范围内的许多状态代码表示所请求的资源已经移动。然后,用户代理需要根据Location头部中发送的信息请求新的位置。401 状态代码表示需要进行身份验证;用户代理可以响应一个包含访问服务器的凭据的授权头部。urllib库的实现处理这种有状态的开销。http.client库不会自动遐射 3xx 重定向状态代码。

用户代理处理 3xx 和 401 代码的技术并不是深度有状态的。可以使用简单的递归。如果状态不表示重定向,那么它是基本情况,函数有一个结果。如果需要重定向,可以使用重定向地址递归调用函数。

在协议的另一端,静态内容服务器也应该是无状态的。HTTP 协议有两个层次:TCP/IP 套接字机制和依赖于较低级别套接字的更高级别的 HTTP 结构。较低级别的细节由scoketserver库处理。Python 的http.server库是提供更高级别实现的库之一。

我们可以使用http.server库如下:

from http.server import HTTPServer, SimpleHTTPRequestHandler
running = True
httpd = HTTPServer(('localhost',8080), SimpleHTTPRequestHandler)
while running:
 **httpd.handle_request()
httpd.shutdown()

我们创建了一个服务器对象,并将其分配给httpd变量。我们提供了地址和端口号,以便监听连接请求。TCP/IP 协议将在一个单独的端口上生成一个连接。HTTP 协议将从这个其他端口读取请求并创建一个处理程序的实例。

在这个例子中,我们提供了SimpleHTTPRequestHandler作为每个请求实例化的类。这个类必须实现一个最小的接口,它将发送头部,然后将响应的主体发送给客户端。这个特定的类将从本地目录中提供文件。如果我们希望自定义这个,我们可以创建一个子类,实现do_GET()do_POST()等方法来改变行为。

通常,我们使用serve_forever()方法而不是编写自己的循环。我们在这里展示循环是为了澄清服务器通常必须崩溃。如果我们想要礼貌地关闭服务器,我们将需要一些方法来改变shutdown变量的值。例如,Ctrl + C信号通常用于这个目的。

添加 cookie 改变了客户端和服务器之间的整体关系,使其变得有状态。有趣的是,这并没有改变 HTTP 协议本身。状态信息通过请求和回复的头部进行通信。用户代理将在请求头中发送与主机和路径匹配的 cookie。服务器将在响应头中向用户代理发送 cookie。

因此,用户代理或浏览器必须保留 cookie 值的缓存,并在每个请求中包含适当的 cookie。Web 服务器必须接受请求头中的 cookie,并在响应头中发送 cookie。Web 服务器不需要缓存 cookie。服务器仅仅将 cookie 作为请求中的附加参数和响应中的附加细节。

虽然 cookie 原则上可以包含几乎任何内容,但是 cookie 的使用已经迅速发展为仅包含会话状态对象的标识符。服务器可以使用 cookie 信息来定位某种持久存储中的会话状态。这意味着服务器还可以根据用户代理请求更新会话状态。这也意味着服务器可以丢弃旧的会话。

“会话”的概念存在于 HTTP 协议之外。它通常被定义为具有相同会话 cookie 的一系列请求。当进行初始请求时,没有 cookie 可用,会创建一个新的会话。随后的每个请求都将包括该 cookie。该 cookie 将标识服务器上的会话状态对象;该对象将具有服务器提供一致的 Web 内容所需的信息。

然而,REST 方法对 Web 服务不依赖于 cookie。每个 REST 请求都是独立的,不适用于整体会话框架。这使得它比使用 cookie 简化用户交互的交互式站点不那么“用户友好”。

这也意味着每个单独的 REST 请求原则上是单独进行身份验证的。在许多情况下,服务器会生成一个简单的令牌,以避免客户端在每个请求中发送更复杂的凭据。这导致 REST 流量使用安全套接字层SSL)协议进行安全处理;然后使用https方案而不是http。在本章中,我们将统称这两种方案为 HTTP。

考虑具有功能设计的服务器

HTTP 的一个核心理念是守护程序的响应是请求的函数。从概念上讲,一个 Web 服务应该有一个可以总结如下的顶层实现:

response = httpd(request)

然而,这是不切实际的。事实证明,HTTP 请求并不是一个简单的、整体的数据结构。它实际上有一些必需的部分和一些可选的部分。一个请求可能有头部,有一个方法和一个路径,还可能有附件。附件可能包括表单或上传的文件或两者都有。

让事情变得更加复杂的是,浏览器的表单数据可以作为一个查询字符串发送到GET请求的路径中。或者,它可以作为POST请求的附件发送。虽然存在混淆的可能性,但大多数 Web 应用程序框架将创建 HTML 表单标签,通过<form>标签中的"method=POST"语句提供它们的数据;然后表单数据将成为一个附件。

更深入地观察功能视图

HTTP 响应和请求都有头部和正文。请求可以有一些附加的表单数据。因此,我们可以将 Web 服务器看作是这样的:

headers, content = httpd(headers, request, [uploads])

请求头可能包括 cookie 值,这可以被视为添加更多参数。此外,Web 服务器通常依赖于其运行的操作系统环境。这个操作系统环境数据可以被视为作为请求的一部分提供的更多参数。

内容有一个大而相当明确定义的范围。多用途互联网邮件扩展MIME)类型定义了 Web 服务可能返回的内容类型。这可以包括纯文本、HTML、JSON、XML,或者网站可能提供的各种非文本媒体。

当我们更仔细地观察构建对 HTTP 请求的响应所需的处理时,我们会看到一些我们想要重用的共同特征。可重用元素的这一理念导致了从简单到复杂的 Web 服务框架的创建。功能设计允许我们重用函数的方式表明,功能方法似乎非常适合构建 Web 服务。

我们将通过嵌套请求处理的各种元素来创建服务响应的管道,来研究 Web 服务的功能设计。我们将通过嵌套请求处理的各种元素来创建服务响应的管道,这样内部元素就可以摆脱外部元素提供的通用开销。这也允许外部元素充当过滤器:无效的请求可以产生错误响应,从而使内部函数可以专注于应用程序处理。

嵌套服务

我们可以将 Web 请求处理视为许多嵌套上下文。例如,外部上下文可能涵盖会话管理:检查请求以确定这是现有会话中的另一个请求还是新会话。内部上下文可能提供用于表单处理的令牌,可以检测跨站点请求伪造CSRF)。另一个上下文可能处理会话中的用户身份验证。

先前解释的函数的概念视图大致如下:

response= content(authentication(csrf(session(headers, request, [forms]))))

这里的想法是每个函数都可以建立在前一个函数的结果之上。每个函数要么丰富请求,要么拒绝请求,因为它是无效的。例如,session函数可以使用标头来确定这是一个现有会话还是一个新会话。csrf函数将检查表单输入,以确保使用了正确的令牌。CSRF 处理需要一个有效的会话。authentication函数可以为缺乏有效凭据的会话返回错误响应;当存在有效凭据时,它可以丰富请求的用户信息。

content函数不必担心会话、伪造和非经过身份验证的用户。它可以专注于解析路径,以确定应提供什么类型的内容。在更复杂的应用程序中,content函数可能包括从路径元素到确定适当内容的函数的相当复杂的映射。

然而,嵌套函数视图仍然不太对。问题在于每个嵌套上下文可能还需要调整响应,而不是或者除了调整请求之外。

我们真的希望更像这样:

def session(headers, request, forms):
 **pre-process: determine session
 **content= csrf(headers, request, forms)
 **post-processes the content
 **return the content
def csrf(headers, request, forms):
 **pre-process: validate csrf tokens
 **content=  authenticate(headers, request, forms)
 **post-processes the content
 **return the content

这个概念指向了通过一系列嵌套的函数来创建丰富输入或丰富输出或两者的功能设计。通过一点巧妙,我们应该能够定义一个简单的标准接口,各种函数可以使用。一旦我们标准化了接口,我们就可以以不同的方式组合函数并添加功能。我们应该能够满足我们的函数式编程目标,编写简洁而富有表现力的程序,提供 Web 内容。

WSGI 标准

Web 服务器网关接口WSGI)为创建对 Web 请求的响应定义了一个相对简单的标准化设计模式。Python 库的wsgiref包包括了 WSGI 的一个参考实现。

每个 WSGI“应用程序”都具有相同的接口:

def some_app(environ, start_response):
 **return content

environ是一个包含请求参数的字典,具有统一的结构。标头、请求方法、路径、表单或文件上传的任何附件都将在环境中。除此之外,还提供了操作系统级别的上下文以及一些属于 WSGI 请求处理的项目。

start_response是一个必须用于发送响应状态和标头的函数。负责构建响应的 WSGI 服务器的部分将使用start_response函数来发送标头和状态,以及构建响应文本。对于某些应用程序,可能需要使用高阶函数包装此函数,以便向响应添加额外的标头。

返回值是一个字符串序列或类似字符串的文件包装器,将返回给用户代理。如果使用 HTML 模板工具,则序列可能只有一个项目。在某些情况下,比如Jinja2模板,模板可以作为文本块序列进行延迟渲染,将模板填充与向用户代理下载交错进行。

由于它们的嵌套方式,WSGI 应用程序也可以被视为一个链。每个应用程序要么返回错误,要么将请求交给另一个应用程序来确定结果。

这是一个非常简单的路由应用程序:

SCRIPT_MAP = {
 **""demo"": demo_app,
 **""static"": static_app,
 **"""": welcome_app,
}
def routing(environ, start_response):
 **top_level= wsgiref.util.shift_path_info(environ)
 **app= SCRIPT_MAP.get(top_level, SCRIPT_MAP[''])
 **content= app(environ, start_response)
 **return content

此应用程序将使用wsgiref.util.shift_path_info()函数来调整环境。这将对请求路径中的项目进行“头/尾拆分”,可在environ['PATH_INFO']字典中找到。路径的头部——直到第一个“拆分”——将被移动到环境中的SCRIPT_NAME项目中;PATH_INFO项目将被更新为路径的尾部。返回值也将是路径的头部。在没有要解析的路径的情况下,返回值是None`,不会进行环境更新。

routing()函数使用路径上的第一项来定位SCRIPT_MAP字典中的应用程序。我们使用SCRIPT_MAP['']字典作为默认值,以防所请求的路径不符合映射。这似乎比 HTTP 404 NOT FOUND错误好一点。

这个 WSGI 应用程序是一个选择多个其他函数的函数。它是一个高阶函数,因为它评估数据结构中定义的函数。

很容易看出,一个框架可以使用正则表达式来概括路径匹配过程。我们可以想象使用一系列正则表达式(REs)和 WSGI 应用程序来配置routing()函数,而不是从字符串到 WSGI 应用程序的映射。增强的routing()函数应用程序将评估每个 RE 以寻找匹配项。在匹配的情况下,可以使用任何match.groups()函数来在调用请求的应用程序之前更新环境。

在 WSGI 处理过程中抛出异常

WSGI 应用程序的一个中心特点是,沿着链的每个阶段都负责过滤请求。其想法是尽可能早地拒绝有错误的请求。Python 的异常处理使得这变得特别简单。

我们可以定义一个 WSGI 应用程序,提供静态内容如下:

def static_app(environ, start_response):
 **try:
 **with open(CONTENT_HOME+environ['PATH_INFO']) as static:
 **content= static.read().encode(""utf-8"")
 **headers= [
 **(""Content-Type"",'text/plain; charset=""utf-8""'),(""Content-Length"",str(len(content))),]
 **start_response('200 OK', headers)
 **return [content]
 **except IsADirectoryError as e:
 **return index_app(environ, start_response)
 **except FileNotFoundError as e:
 **start_response('404 NOT FOUND', [])
 **return([repr(e).encode(""utf-8"")])

在这种情况下,我们只是尝试打开所请求的路径作为文本文件。我们无法打开给定文件的两个常见原因,这两种情况都作为异常处理:

  • 如果文件是一个目录,我们将使用不同的应用程序来呈现目录内容

  • 如果文件根本找不到,我们将返回一个 HTTP 404 NOT FOUND 响应

此 WSGI 应用程序引发的任何其他异常都不会被捕获。调用此应用程序的应用程序应设计有一些通用的错误响应能力。如果它不处理异常,将使用通用的 WSGI 失败响应。

注意

我们的处理涉及严格的操作顺序。我们必须读取整个文件,以便我们可以创建一个适当的 HTTP Content-Length头。

此外,我们必须以字节形式提供内容。这意味着 Python 字符串必须被正确编码,并且我们必须向用户代理提供编码信息。甚至错误消息repr(e)在下载之前也要被正确编码。

务实的 WSGI 应用程序

WSGI 标准的目的不是定义一个完整的 Web 框架;目的是定义一组最低限度的标准,允许 Web 相关处理的灵活互操作。一个框架可以采用与内部架构完全不同的方法来提供 Web 服务。但是,它的最外层接口应与 WSGI 兼容,以便可以在各种上下文中使用。

诸如Apache httpdNginx之类的 Web 服务器有适配器,它们提供了从 Web 服务器到 Python 应用程序的 WSGI 兼容接口。有关 WSGI 实现的更多信息,请访问

wiki.python.org/moin/WSGIImplementations

将我们的应用程序嵌入到一个更大的服务器中,可以让我们有一个整洁的关注分离。我们可以使用 Apache httpd 来提供完全静态的内容,比如.css、.js 和图像文件。但是对于 HTML 页面,我们可以使用 Apache 的mod_wsgi接口将请求转交给一个单独的 Python 进程,该进程只处理网页内容的有趣部分。

这意味着我们必须要么创建一个单独的媒体服务器,要么定义我们的网站有两组路径。如果我们采取第二种方法,一些路径将有完全静态的内容,可以由 Apache httpd 处理。其他路径将有动态内容,将由 Python 处理。

在使用 WSGI 函数时,重要的是要注意我们不能以任何方式修改或扩展 WSGI 接口。例如,提供一个附加参数,其中包含定义处理链的函数序列,似乎是一个好主意。每个阶段都会从列表中弹出第一个项目作为处理的下一步。这样的附加参数可能是函数设计的典型,但接口的改变违背了 WSGI 的目的。

WSGI 定义的一个后果是配置要么使用全局变量,要么使用请求环境,要么使用一个函数,该函数从缓存中获取一些全局配置对象。使用模块级全局变量适用于小例子。对于更复杂的应用程序,可能需要一个配置缓存。可能还有必要有一个 WSGI 应用程序,它仅仅更新environ字典中的配置参数,并将控制权传递给另一个 WSGI 应用程序。

将 web 服务定义为函数

我们将研究一个 RESTful web 服务,它可以“切割和切块”数据源,并提供 JSON、XML 或 CSV 文件的下载。我们将提供一个整体的 WSGI 兼容包装器,但是应用程序的“真正工作”的函数不会被狭窄地限制在 WSGI 中。

我们将使用一个简单的数据集,其中包括四个子集合:安斯康姆四重奏。我们在第三章“函数、迭代器和生成器”中讨论了读取和解析这些数据的方法。这是一个小数据集,但可以用来展示 RESTful web 服务的原则。

我们将把我们的应用程序分成两个层次:一个是 web 层,它将是一个简单的 WSGI 应用程序,另一个是其余的处理,它将是更典型的函数式编程。我们首先看看 web 层,这样我们就可以专注于提供有意义的结果的函数式方法。

我们需要向 web 服务提供两个信息:

  • 我们想要的四重奏——这是一个“切割和切块”的操作。在这个例子中,它主要是一个“切片”。

  • 我们想要的输出格式。

数据选择通常通过请求路径完成。我们可以请求/anscombe/I//anscombe/II/来从四重奏中选择特定的数据集。这个想法是 URL 定义了一个资源,而且没有好的理由让 URL 发生变化。在这种情况下,数据集选择器不依赖于日期,或者一些组织批准状态或其他外部因素。URL 是永恒和绝对的。

输出格式不是 URL 的一部分。它只是一个序列化格式,而不是数据本身。在某些情况下,格式是通过 HTTP“接受”头请求的。这在浏览器中很难使用,但在使用 RESTful API 的应用程序中很容易使用。从浏览器中提取数据时,通常使用查询字符串来指定输出格式。我们将在路径的末尾使用?form=json方法来指定 JSON 输出格式。

我们可以使用的 URL 看起来像这样:

http://localhost:8080/anscombe/III/?form=csv

这将请求第三个数据集的 CSV 下载。

创建 WSGI 应用程序

首先,我们将使用一个简单的 URL 模式匹配表达式来定义我们应用程序中唯一的路由。在一个更大或更复杂的应用程序中,我们可能会有多个这样的模式:

import re
path_pat= re.compile(r""^/anscombe/(?P<dataset>.*?)/?$"")

这种模式允许我们在路径的顶层定义一个整体的 WSGI 意义上的“脚本”。在这种情况下,脚本是“anscombe”。我们将路径的下一个级别作为要从 Anscombe Quartet 中选择的数据集。数据集值应该是IIIIIIIV中的一个。

我们对选择条件使用了一个命名参数。在许多情况下,RESTful API 使用以下语法进行描述:

/anscombe/{dataset}/

我们将这种理想化的模式转化为一个适当的正则表达式,并在路径中保留了数据集选择器的名称。

这是演示这种模式如何工作的单元测试的一种类型:

test_pattern= """"""
>>> m1= path_pat.match(""/anscombe/I"")
>>> m1.groupdict()
{'dataset': 'I'}
>>> m2= path_pat.match(""/anscombe/II/"")
>>> m2.groupdict()
{'dataset': 'II'}
>>> m3= path_pat.match(""/anscombe/"")
>>> m3.groupdict()
{'dataset': ''}
""""""

我们可以使用以下命令将三个先前提到的示例包含在整个 doctest 中:

__test__ = {
 **""test_pattern"": test_pattern,
}

这将确保我们的路由按预期工作。能够从 WSGI 应用程序的其余部分单独测试这一点非常重要。测试完整的 Web 服务器意味着启动服务器进程,然后尝试使用浏览器或测试工具(如 Postman 或 Selenium)进行连接。访问www.getpostman.comwww.seleniumhq.org以获取有关 Postman 和 Selenium 用法的更多信息。我们更喜欢单独测试每个功能。

以下是整个 WSGI 应用程序,其中突出显示了两行命令:

import traceback
import urllib
def anscombe_app(environ, start_response):
 **log= environ['wsgi.errors']
 **try:
 **match= path_pat.match(environ['PATH_INFO'])
 **set_id= match.group('dataset').upper()
 **query= urllib.parse.parse_qs(environ['QUERY_STRING'])
 **print(environ['PATH_INFO'], environ['QUERY_STRING'],match.groupdict(), file=log)
 **log.flush()
 **dataset= anscombe_filter(set_id, raw_data())
 **content, mime= serialize(query['form'][0], set_id, dataset)
 **headers= [
 **('Content-Type', mime),('Content-Length', str(len(content))),        ]
 **start_response(""200 OK"", headers)
 **return [content]
 **except Exception as e:
 **traceback.print_exc(file=log)
 **tb= traceback.format_exc()
 **page= error_page.substitute(title=""Error"", message=repr(e), traceback=tb)
 **content= page.encode(""utf-8"")
 **headers = [
 **('Content-Type', ""text/html""),('Content-Length', str(len(content))),]
 **start_response(""404 NOT FOUND"", headers)
 **return [content]

此应用程序将从请求中提取两个信息:PATH_INFOQUERY_STRING方法。PATH_INFO请求将定义要提取的集合。QUERY_STRING请求将指定输出格式。

应用程序处理分为三个函数。raw_data()函数从文件中读取原始数据。结果是一个带有Pair对象列表的字典。anscombe_filter()函数接受选择字符串和原始数据的字典,并返回一个Pair对象的列表。然后,将成对的列表通过serialize()函数序列化为字节。序列化器应该生成字节,然后可以与适当的头部打包并返回。

我们选择生成一个 HTTPContent-Length头。这并不是必需的,但对于大型下载来说是礼貌的。因为我们决定发出这个头部,我们被迫实现序列化的结果,以便我们可以计算字节数。

如果我们选择省略Content-Length头部,我们可以大幅改变此应用程序的结构。每个序列化器可以更改为生成器函数,该函数将按照生成的顺序产生字节。对于大型数据集,这可能是一个有用的优化。但是,对于观看下载的用户来说,这可能并不那么愉快,因为浏览器无法显示下载的完成进度。

所有错误都被视为404 NOT FOUND错误。这可能会产生误导,因为可能会出现许多个别问题。更复杂的错误处理将提供更多的try:/except:块,以提供更多信息反馈。

出于调试目的,我们在生成的网页中提供了一个 Python 堆栈跟踪。在调试的上下文之外,这是一个非常糟糕的主意。来自 API 的反馈应该足够修复请求,什么都不多。堆栈跟踪为潜在的恶意用户提供了太多信息。

获取原始数据

raw_data()函数在很大程度上是从第三章函数,迭代器和生成器中复制的。我们包含了一些重要的更改。以下是我们用于此应用程序的内容:

from Chapter_3.ch03_ex5 import series, head_map_filter, row_iter, Pair
def raw_data():
 **""""""
 **>>> raw_data()['I'] #doctest: +ELLIPSIS
 **(Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ...
 **""""""
 **with open(""Anscombe.txt"") as source:
 **data = tuple(head_map_filter(row_iter(source)))
 **mapping = dict((id_str, tuple(series(id_num,data)))
 **for id_num, id_str in enumerate(['I', 'II', 'III', 'IV'])
 **)
 **return mapping

我们打开了本地数据文件,并应用了一个简单的row_iter()函数,以将文件的每一行解析为一个单独的行。我们应用了head_map_filter()函数来从文件中删除标题。结果创建了一个包含所有数据的元组结构。

我们通过从源数据中选择特定系列,将元组转换为更有用的dict()函数。每个系列将是一对列。对于系列"I,",它是列 0 和 1。对于系列"II,",它是列 2 和 3。

我们使用dict()函数与生成器表达式保持一致,与list()tuple()函数一样。虽然这并非必要,但有时看到这三种数据结构及其使用生成器表达式的相似之处是有帮助的。

series()函数为数据集中的每个xy对创建了单独的Pair对象。回顾一下,我们可以看到修改这个函数后的输出值,使得生成的namedtuple类是这个函数的参数,而不是函数的隐式特性。我们更希望看到series(id_num,Pair,data)方法,以查看Pair对象是如何创建的。这个扩展需要重写第三章中的一些示例,函数、迭代器和生成器。我们将把这留给读者作为练习。

这里的重要变化是,我们展示了正式的doctest测试用例。正如我们之前指出的,作为一个整体,Web 应用程序很难测试。必须启动 Web 服务器,然后必须使用 Web 客户端来运行测试用例。然后必须通过阅读 Web 日志来解决问题,这可能很困难,除非显示完整的回溯。最好尽可能多地使用普通的doctestunittest测试技术来调试 Web 应用程序。

应用过滤器

在这个应用程序中,我们使用了一个非常简单的过滤器。整个过滤过程体现在下面的函数中:

def anscombe_filter(set_id, raw_data):
 **""""""
 **>>> anscombe_filter(""II"", raw_data()) #doctest: +ELLIPSIS
 **(Pair(x=10.0, y=9.14), Pair(x=8.0, y=8.14), Pair(x=13.0, y=8.74), ...
 **""""""
 **return raw_data[set_id]

我们将这个微不足道的表达式转换成一个函数有三个原因:

  • 函数表示法略微更一致,比下标表达式更灵活

  • 我们可以很容易地扩展过滤功能

  • 我们可以在此函数的文档字符串中包含单独的单元测试

虽然简单的 lambda 可以工作,但测试起来可能不太方便。

对于错误处理,我们什么也没做。我们专注于有时被称为“快乐路径”的内容:理想的事件序列。在这个函数中出现的任何问题都将引发异常。WSGI 包装函数应该捕获所有异常并返回适当的状态消息和错误响应内容。

例如,set_id方法可能在某些方面是错误的。与其过分关注它可能出错的所有方式,我们宁愿让 Python 抛出异常。事实上,这个函数遵循了 Python I 的建议,“最好是寻求宽恕,而不是征求许可”。这个建议在代码中体现为避免“征求许可”:没有寻求将参数限定为有效的准备性if语句。只有“宽恕”处理:异常将被引发并在 WSGI 包装函数中处理。这个基本建议适用于前面的原始数据和我们现在将看到的序列化。

序列化结果

序列化是将 Python 数据转换为适合传输的字节流的过程。每种格式最好由一个简单的函数来描述,该函数只序列化这一种格式。然后,顶层通用序列化程序可以从特定序列化程序列表中进行选择。序列化程序的选择导致以下一系列函数:

serializers = {
 **'xml': ('application/xml', serialize_xml),
 **'html': ('text/html', serialize_html),
 **'json': ('application/json', serialize_json),
 **'csv': ('text/csv', serialize_csv),
}
def serialize(format, title, data):
 **""""""json/xml/csv/html serialization.
 **>>> data = [Pair(2,3), Pair(5,7)]
 **>>> serialize(""json"", ""test"", data)
 **(b'[{""x"": 2, ""y"": 3}, {""x"": 5, ""y"": 7}]', 'application/json')
 **""""""
 **mime, function = serializers.get(format.lower(), ('text/html', serialize_html))
 **return function(title, data), mime

整体serialize()函数找到必须在响应中使用的特定序列化程序和特定 MIME 类型。然后调用其中一个特定的序列化程序。我们还在这里展示了一个doctest测试用例。我们没有耐心测试每个序列化程序,因为显示一个工作似乎就足够了。

我们将分别查看序列化器。我们将看到序列化器分为两组:产生字符串的序列化器和产生字节的序列化器。产生字符串的序列化器将需要将字符串编码为字节。产生字节的序列化器不需要进一步处理。

对于生成字符串的序列化器,我们需要使用标准的转换为字节的函数组合。我们可以使用装饰器进行函数组合。以下是我们如何将转换为字节标准化:

from functools import wraps
def to_bytes(function):
 **@wraps(function)
 **def decorated(*args, **kw):
 **text= function(*args, **kw)
 **return text.encode(""utf-8"")
 **return decorated

我们创建了一个名为@to_bytes的小装饰器。这将评估给定的函数,然后使用 UTF-8 对结果进行编码以获得字节。我们将展示如何将其与 JSON、CSV 和 HTML 序列化器一起使用。XML 序列化器直接产生字节,不需要与此额外函数组合。

我们还可以在serializers映射的初始化中进行函数组合。我们可以装饰函数定义的引用,而不是装饰函数对象的引用。

serializers = {
 **'xml': ('application/xml', serialize_xml),
 **'html': ('text/html', to_bytes(serialize_html)),
 **'json': ('application/json', to_bytes(serialize_json)),
 **'csv': ('text/csv', to_bytes(serialize_csv)),
}

虽然这是可能的,但这似乎并不有用。产生字符串和产生字节的序列化器之间的区别并不是配置的重要部分。

将数据序列化为 JSON 或 CSV 格式

JSON 和 CSV 序列化器是类似的函数,因为两者都依赖于 Python 的库进行序列化。这些库本质上是命令式的,因此函数体是严格的语句序列。

这是 JSON 序列化器:

import json
@to_bytes
def serialize_json(series, data):
 **""""""
 **>>> data = [Pair(2,3), Pair(5,7)]
 **>>> serialize_json(""test"", data)
 **b'[{""x"": 2, ""y"": 3}, {""x"": 5, ""y"": 7}]'
 **""""""
 **obj= [dict(x=r.x, y=r.y) for r in data]
 **text= json.dumps(obj, sort_keys=True)
 **return text

我们创建了一个字典结构的列表,并使用json.dumps()函数创建了一个字符串表示。JSON 模块需要一个具体化的list对象;我们不能提供一个惰性生成器函数。sort_keys=True参数值对于单元测试是必不可少的。但对于应用程序并不是必需的,而且代表了一些额外的开销。

这是 CSV 序列化器:

import csv, io
@to_bytes
def serialize_csv(series, data):
 **""""""

 **>>> data = [Pair(2,3), Pair(5,7)]
 **>>> serialize_csv(""test"", data)
 **b'x,y\\r\\n2,3\\r\\n5,7\\r\\n'
 **""""""
 **buffer= io.StringIO()
 **wtr= csv.DictWriter(buffer, Pair._fields)
 **wtr.writeheader()
 **wtr.writerows(r._asdict() for r in data)
 **return buffer.getvalue()

CSV 模块的读取器和写入器是命令式和函数式元素的混合。我们必须创建写入器,并严格按顺序创建标题。我们使用了Pair命名元组的_fields属性来确定写入器的列标题。

写入器的writerows()方法将接受一个惰性生成器函数。在这种情况下,我们使用了每个Pair对象的_asdict()方法返回适用于 CSV 写入器的字典。

将数据序列化为 XML

我们将使用内置库来看一种 XML 序列化的方法。这将从单个标签构建文档。一个常见的替代方法是使用 Python 内省来检查和映射 Python 对象和类名到 XML 标签和属性。

这是我们的 XML 序列化:

import xml.etree.ElementTree as XML
def serialize_xml(series, data):
 **""""""
 **>>> data = [Pair(2,3), Pair(5,7)]
 **>>> serialize_xml(""test"", data)
 **b'<series name=""test""><row><x>2</x><y>3</y></row><row><x>5</x><y>7</y></row></series>'
 **""""""
 **doc= XML.Element(""series"", name=series)
 **for row in data:
 **row_xml= XML.SubElement(doc, ""row"")
 **x= XML.SubElement(row_xml, ""x"")
 **x.text= str(row.x)
 **y= XML.SubElement(row_xml, ""y"")
 **y.text= str(row.y)
 **return XML.tostring(doc, encoding='utf-8')

我们创建了一个顶级元素<series>,并将<row>子元素放在该顶级元素下面。在每个<row>子元素中,我们创建了<x><y>标签,并为每个标签分配了文本内容。

使用 ElementTree 库构建 XML 文档的接口往往是非常命令式的。这使得它不适合于否则功能设计。除了命令式风格之外,注意我们没有创建 DTD 或 XSD。我们没有为标签正确分配命名空间。我们还省略了通常是 XML 文档中的第一项的<?xml version=""1.0""?>处理指令。

更复杂的序列化库将是有帮助的。有许多选择。访问wiki.python.org/moin/PythonXml获取备选列表。

将数据序列化为 HTML

在我们最后一个序列化示例中,我们将看到创建 HTML 文档的复杂性。复杂性的原因是在 HTML 中,我们需要提供一个带有一些上下文信息的整个网页。以下是解决这个 HTML 问题的一种方法:

import string
data_page = string.Template(""""""<html><head><title>Series ${title}</title></head><body><h1>Series ${title}</h1><table><thead><tr><td>x</td><td>y</td></tr></thead><tbody>${rows}</tbody></table></body></html>"""""")
@to_bytes
def serialize_html(series, data):
 **"""""">>> data = [Pair(2,3), Pair(5,7)]>>> serialize_html(""test"", data) #doctest: +ELLIPSISb'<html>...<tr><td>2</td><td>3</td></tr>\\n<tr><td>5</td><td>7</td></tr>...""""""
 **text= data_page.substitute(title=series,rows=""\n"".join(
 **""<tr><td>{0.x}</td><td>{0.y}</td></tr>"".format(row)
 **for row in data)
 **)
 **return text

我们的序列化函数有两个部分。第一部分是一个string.Template()函数,其中包含了基本的 HTML 页面。它有两个占位符,可以将数据插入模板中。${title}方法显示了标题信息可以插入的位置,${rows}方法显示了数据行可以插入的位置。

该函数使用简单的格式字符串创建单独的数据行。然后将它们连接成一个较长的字符串,然后替换到模板中。

虽然对于像前面的例子这样简单的情况来说是可行的,但对于更复杂的结果集来说并不理想。有许多更复杂的模板工具可以创建 HTML 页面。其中一些包括在模板中嵌入循环的能力,与初始化序列化的功能分开。访问wiki.python.org/moin/Templating获取备选列表。

跟踪使用情况

许多公开可用的 API 需要使用"API 密钥"。API 的供应商要求您注册并提供电子邮件地址或其他联系信息。作为交换,他们提供一个激活 API 的 API 密钥。

API 密钥用于验证访问。它也可以用于授权特定功能。最后,它还用于跟踪使用情况。这可能包括在给定时间段内过于频繁地使用 API 密钥时限制请求。

商业模式的变化是多种多样的。例如,使用 API 密钥是一个计费事件,会产生费用。对于其他企业来说,流量必须达到一定阈值才需要付款。

重要的是对 API 的使用进行不可否认。这反过来意味着创建可以作为用户身份验证凭据的 API 密钥。密钥必须难以伪造,相对容易验证。

创建 API 密钥的一种简单方法是使用加密随机数来生成难以预测的密钥字符串。像下面这样的一个小函数应该足够好:

import random
rng= random.SystemRandom()
import base64
def make_key_1(rng=rng, size=1):
 **key_bytes= bytes(rng.randrange(0,256) for i in range(18*size))
 **key_string= base64.urlsafe_b64encode(key_bytes)
 **return key_string

我们使用了random.SystemRandom类作为我们安全随机数生成器的类。这将使用os.urandom()字节来初始化生成器,确保了一个可靠的不可预测的种子值。我们单独创建了这个对象,以便每次请求密钥时都可以重复使用。最佳做法是使用单个随机种子从生成器获取多个密钥。

给定一些随机字节,我们使用了 base 64 编码来创建一系列字符。在初始随机字节序列中使用三的倍数,可以避免在 base 64 编码中出现任何尾随的"="符号。我们使用了 URL 安全的 base 64 编码,这不会在结果字符串中包含"/"或"+"字符,如果作为 URL 或查询字符串的一部分使用可能会引起混淆。

注意

更复杂的方法不会导致更多的随机数据。使用random.SystemRandom可以确保没有人可以伪造分配给另一个用户的密钥。我们使用了18×8个随机位,给我们大量的随机密钥。

有多少随机密钥?看一下以下命令及其输出:

>>> 2**(18*8)
22300745198530623141535718272648361505980416

成功伪造其他人的密钥的几率很小。

另一种选择是使用uuid.uuid4()来创建一个随机的通用唯一标识符UUID)。这将是一个 36 个字符的字符串,其中包含 32 个十六进制数字和四个"-"标点符号。随机 UUID 也难以伪造。包含用户名或主机 IP 地址等数据的 UUID 是一个坏主意,因为这会编码信息,可以被解码并用于伪造密钥。使用加密随机数生成器的原因是避免编码任何信息。

RESTful Web 服务器然后将需要一个带有有效密钥和可能一些客户联系信息的小型数据库。如果 API 请求包括数据库中的密钥,相关用户将负责该请求。如果 API 请求不包括已知密钥,则可以用简单的401 未经授权响应拒绝该请求。由于密钥本身是一个 24 个字符的字符串,数据库将非常小,并且可以很容易地缓存在内存中。

普通的日志抓取可能足以显示给定密钥的使用情况。更复杂的应用程序可能会将 API 请求记录在单独的日志文件或数据库中,以简化分析。

总结

在本章中,我们探讨了如何将功能设计应用于使用基于 REST 的 Web 服务提供内容的问题。我们看了一下 WSGI 标准导致了总体上有点功能性的应用程序的方式。我们还看了一下如何通过从请求中提取元素来将更功能性的设计嵌入到 WSGI 上下文中,以供我们的应用程序函数使用。

对于简单的服务,问题通常可以分解为三个不同的操作:获取数据,搜索或过滤,然后序列化结果。我们用三个函数解决了这个问题:raw_data()anscombe_filter()serialize()。我们将这些函数封装在一个简单的 WSGI 兼容应用程序中,以将 Web 服务与围绕提取和过滤数据的“真实”处理分离。

我们还看了 Web 服务函数可以专注于“快乐路径”,并假设所有输入都是有效的方式。如果输入无效,普通的 Python 异常处理将引发异常。WSGI 包装函数将捕获错误并返回适当的状态代码和错误内容。

我们避免了与上传数据或接受来自表单的数据以更新持久数据存储相关的更复杂的问题。这些问题与获取数据和序列化结果并没有显著的复杂性。它们已经以更好的方式得到解决。

对于简单的查询和数据共享,小型 Web 服务应用程序可能会有所帮助。我们可以应用功能设计模式,并确保网站代码简洁而富有表现力。对于更复杂的 Web 应用程序,我们应考虑使用一个能够正确处理细节的框架。

在下一章中,我们将看一些可用于我们的优化技术。我们将扩展来自第十章Functools 模块@lru_cache装饰器。我们还将研究一些其他优化技术,这些技术在第六章递归和归约中提出。

第十六章:优化和改进

在本章中,我们将研究一些优化方法,以创建高性能的函数程序。我们将扩展第十章中的@lru_cache装饰器,Functools 模块。我们有多种方法来实现记忆化算法。我们还将讨论如何编写自己的装饰器。更重要的是,我们将看到如何使用Callable对象来缓存记忆化结果。

我们还将研究第六章中提出的一些优化技术,递归和归约。我们将回顾尾递归优化的一般方法。对于一些算法,我们可以将记忆化与递归实现结合,实现良好的性能。对于其他算法,记忆化并不是非常有帮助,我们必须寻找其他地方来提高性能。

在大多数情况下,对程序的小改动将导致性能的小幅提升。用lambda对象替换函数对性能影响很小。如果我们的程序运行速度不可接受,我们通常必须找到一个全新的算法或数据结构。一些算法具有糟糕的“大 O”复杂度;没有什么能让它们神奇地运行得更快。

一个开始的地方是www.algorist.com。这是一个资源,可以帮助找到给定问题的更好算法。

记忆化和缓存

正如我们在第十章中看到的,Functools 模块,许多算法可以从记忆化中受益。我们将从回顾一些先前的例子开始,以描述可以通过记忆化得到帮助的函数类型。

在第六章中,我们看了一些常见的递归类型。最简单的递归类型是具有可以轻松匹配到缓存值的参数的尾递归。如果参数是整数、字符串或实例化的集合,那么我们可以快速比较参数,以确定缓存是否具有先前计算的结果。

我们可以从这些例子中看到,像计算阶乘或查找斐波那契数这样的整数数值计算将明显改善。查找质因数和将整数提升到幂次方是更多适用于整数值的数值算法的例子。

当我们看递归版本的斐波那契数计算器时,我们发现它包含两个尾递归。以下是定义:

记忆化和缓存

这可以转换为一个循环,但设计变更需要一些思考。这个记忆化版本可以非常快,而且设计上不需要太多思考。

Syracuse 函数,如第六章中所示,递归和归约,是用于计算分形值的函数的一个例子。它包含一个简单的规则,递归应用。探索 Collatz 猜想(“Syracuse 函数是否总是导致 1?”)需要记忆化中间结果。

Syracuse 函数的递归应用是具有“吸引子”的函数的一个例子,其中值被吸引到 1。在一些更高维度的函数中,吸引子可以是一条线,或者可能是一个分形。当吸引子是一个点时,记忆化可以帮助;否则,记忆化实际上可能是一个障碍,因为每个分形值都是唯一的。

在处理集合时,缓存的好处可能会消失。如果集合恰好具有相同数量的整数值、字符串或元组,那么集合可能是重复的,可以节省时间。但是,如果需要对集合进行多次计算,手动优化是最好的:只需进行一次计算,并将结果分配给一个变量。

在处理可迭代对象、生成器函数和其他惰性对象时,缓存整个对象基本上是不可能的。在这些情况下,记忆化根本不会有所帮助。

通常包括测量的原始数据使用浮点值。由于浮点值之间的精确相等比较可能不会很好地工作,因此记忆化中间结果可能也不会很好地工作。

然而,包括计数的原始数据可能受益于记忆化。这些是整数,我们可以相信精确的整数比较可以(可能)节省重新计算先前值。一些统计函数在应用于计数时,可以受益于使用fractions模块而不是浮点值。当我们用Fraction(x,y)方法替换x/y时,我们保留了进行精确值匹配的能力。我们可以使用float(some_fraction)方法生成最终结果。

专门化记忆化

记忆化的基本思想是如此简单,以至于可以通过@lru_cache装饰器来捕捉。这个装饰器可以应用于任何函数以实现记忆化。在某些情况下,我们可能能够通过更专业的东西来改进通用的想法。有大量的潜在可优化的多值函数。我们将在这里选择一个,并在更复杂的案例研究中看另一个。

二项式,专门化记忆化,显示了n个不同的事物可以以大小为m的组合方式排列的数量。该值如下:

专门化记忆化

显然,我们应该缓存阶乘计算,而不是重新进行所有这些乘法。然而,我们也可能受益于缓存整体的二项式计算。

我们将创建一个包含多个内部缓存的可调用对象。这是我们需要的一个辅助函数:

from functools import reduce
from operator import mul
prod = lambda x: reduce(mul, x)

prod()函数计算数字的可迭代乘积。它被定义为使用*运算符的缩减。

这是一个带有两个缓存的可调用对象,它使用了prod()函数:

from collections.abc import Callable
class Binomial(Callable):
 **def __init__(self):
 **self.fact_cache= {}
 **self.bin_cache= {}
 **def fact(self, n):
 **if n not in self.fact_cache:
 **self.fact_cache[n] = prod(range(1,n+1))
 **return self.fact_cache[n]
 **def __call__(self, n, m):
 **if (n,m) not in self.bin_cache:
 **self.bin_cache[n,m] = self.fact(n)//(self.fact(m)*self.fact(n-m))
 **return self.bin_cache[n,m]

我们创建了两个缓存:一个用于阶乘值,一个用于二项式系数值。内部的fact()方法使用fact_cache属性。如果值不在缓存中,它将被计算并添加到缓存中。外部的__call__()方法以类似的方式使用bin_cache属性:如果特定的二项式已经被计算,答案将被简单地返回。如果没有,将使用内部的fact()方法计算一个新值。

我们可以像这样使用前面的Callable类:

>>> binom= Binomial()
>>> binom(52,5)
2598960

这显示了我们如何从我们的类创建一个可调用对象,然后在特定的参数集上调用该对象。一副 52 张的牌可以以 5 张牌的方式发出。有 260 万种可能的手牌。

尾递归优化

在第六章中,递归和缩减,我们看到了如何将简单的递归优化为for循环,还有许多其他情况。

  • 设计递归。这意味着基本情况和递归情况。例如,这是一个计算的定义:尾递归优化

要设计递归,请执行以下命令:

def fact(n):
 **if n == 0: return 1
 **else: return n*fact(n-1)

  • 如果递归在末尾有一个简单的调用,将递归情况替换为for循环。命令如下:
def facti(n):
 **if n == 0: return 1
 **f= 1
 **for i in range(2,n):
 **f= f*i
 **return f

如果递归出现在简单函数的末尾,它被描述为尾调用优化。许多编译器将其优化为循环。Python——由于其编译器缺乏这种优化——不会进行这种尾调用转换。

这种模式非常常见。进行尾调用优化可以提高性能,并消除可以执行的递归次数的上限。

在进行任何优化之前,确保函数已经正常工作是绝对必要的。对此,一个简单的doctest字符串通常就足够了。我们可能会像这样在我们的阶乘函数上使用注释:

def fact(n):
 **"""Recursive Factorial
 **>>> fact(0)
 **1
 **>>> fact(1)
 **1
 **>>> fact(7)
 **5040
 **"""
 **if n == 0: return 1
 **else: return n*fact(n-1)

我们添加了两个边缘情况:显式基本情况和超出基本情况的第一项。我们还添加了另一个涉及多次迭代的项目。这使我们可以有信心地调整代码。

当我们有更复杂的函数组合时,我们可能需要执行这样的命令:

test_example="""
>>> binom= Binomial()
>>> binom(52,5)
2598960
"""
__test__ = {
 **"test_example": test_example,
}

__test__变量由doctest.testmod()函数使用。与__test__变量关联的字典中的所有值都会被检查是否包含doctest字符串。这是测试由多个函数组合而成的功能的一种方便的方法。这也被称为集成测试,因为它测试了多个软件组件的集成。

具有一组测试的工作代码使我们有信心进行优化。我们可以轻松确认优化的正确性。以下是一个常用的引用,用来描述优化:

"使一个错误的程序变得更糟并不是罪过。"
--Jon Bentley

这出现在 Addison-Wesley, Inc.出版的More Programming PearlsBumper Sticker Computer Science章节中。重要的是,我们只应该优化实际正确的代码。

优化存储

优化没有通用规则。我们通常关注优化性能,因为我们有诸如大 O 复杂度度量这样的工具,它们可以告诉我们算法是否是给定问题的有效解决方案。优化存储通常是单独处理的:我们可以查看算法中的步骤,并估计各种存储结构所需的存储空间大小。

在许多情况下,这两种考虑是相互对立的。在某些情况下,具有出色性能的算法需要一个大的数据结构。这种算法在没有大幅增加所需存储空间的情况下无法扩展。我们的目标是设计一个相当快速并且使用可接受的存储空间的算法。

我们可能需要花时间研究算法替代方案,以找到适当的时空权衡方式。有一些常见的优化技术。我们通常可以从维基百科上找到相关链接:en.wikipedia.org/wiki/Space–time_tradeoff

我们在 Python 中有一种内存优化技术,即使用可迭代对象。这具有一些适当材料化集合的属性,但不一定占用存储空间。有一些操作(如len()函数)无法在可迭代对象上执行。对于其他操作,内存节省功能可以使程序处理非常大的集合。

优化准确性

在一些情况下,我们需要优化计算的准确性。这可能是具有挑战性的,并且可能需要一些相当高级的数学来确定给定方法的准确性限制。

在 Python 中,我们可以用fractions.Fraction值替换浮点近似。对于一些应用程序,这可以比浮点更准确地创建答案,因为分子和分母使用的比特比浮点尾数更多。

使用decimal.Decimal值处理货币很重要。常见的错误是使用float值。使用float值时,由于提供的Decimal值与浮点值使用的二进制近似之间的不匹配,会引入额外的噪声比特。使用Decimal值可以防止引入微小的不准确性。

在许多情况下,我们可以对 Python 应用程序进行小的更改,从float值切换到FractionDecimal值。在处理超越函数时,这种更改并不一定有益。超越函数——根据定义——涉及无理数。

根据观众要求降低精度

对于某些计算,分数值可能比浮点值更直观地有意义。这是以一种观众可以理解并采取行动的方式呈现统计结果的一部分。

例如,卡方检验通常涉及计算实际值和预期值之间的比较。然后,我们可以将这个比较值进行测试,看它是否符合累积分布函数。当预期值和实际值没有特定的关系时-我们可以称之为空关系-变化将是随机的;值趋向于很小。当我们接受零假设时,我们将寻找其他地方的关系。当实际值与预期值显著不同时,我们可能会拒绝零假设。通过拒绝零假设,我们可以进一步探讨确定关系的确切性质。

决策通常基于选定的自由度的累积分布函数(CDF)的表格和给定的值。虽然表格化的 CDF 值大多是无理数值,但我们通常不会使用超过两到三位小数。这仅仅是一个决策工具,0.049 和 0.05 之间在意义上没有实际区别。

拒绝零假设的广泛使用概率为 0.05。这是一个小于 1/20 的“分数”对象。当向观众呈现数据时,有时将结果描述为分数会有所帮助。像 0.05 这样的值很难想象。描述一个关系有 20 分之 1 的机会可以帮助表征相关性的可能性。

案例研究-做出卡方决策

我们将看一个常见的统计决策。该决策在www.itl.nist.gov/div898/handbook/prc/section4/prc45.htm中有详细描述。

这是一个关于数据是否随机分布的卡方决策。为了做出这个决定,我们需要计算一个预期分布并将观察到的数据与我们的预期进行比较。显著差异意味着有些东西需要进一步调查。不显著的差异意味着我们可以使用零假设,即没有更多需要研究的内容:差异只是随机变化。

我们将展示如何使用 Python 处理数据。我们将从一些背景开始-一些不属于案例研究的细节,但通常包括“探索性数据分析”(EDA)应用程序。我们需要收集原始数据并生成一个有用的摘要,以便我们可以进行分析。

在生产质量保证操作中,硅晶圆缺陷数据被收集到数据库中。我们可以使用 SQL 查询来提取缺陷细节以进行进一步分析。例如,一个查询可能是这样的:

SELECT SHIFT, DEFECT_CODE, SERIAL_NUMBER
FROM some tables;

这个查询的输出可能是一个带有单个缺陷细节的 CSV 文件:

shift,defect_code,serial_number
1,None,12345
1,None,12346
1,A,12347
1,B,12348
and so on. for thousands of wafers

我们需要总结前面的数据。我们可以在 SQL 查询级别使用COUNTGROUP BY语句进行总结。我们也可以在 Python 应用程序级别进行总结。虽然纯数据库摘要通常被描述为更有效,但这并不总是正确的。在某些情况下,简单提取原始数据并使用 Python 应用程序进行总结可能比 SQL 摘要更快。如果性能很重要,必须测量两种替代方案,而不是希望数据库最快。

在某些情况下,我们可能能够高效地从数据库中获取摘要数据。这个摘要必须具有三个属性:班次、缺陷类型和观察到的缺陷数量。摘要数据如下:

shift,defect_code,count
1,A,15
2,A,26
3,A,33
and so on.

输出将显示所有 12 种班次和缺陷类型的组合。

在下一节中,我们将专注于读取原始数据以创建摘要。这是 Python 特别强大的上下文:处理原始源数据。

我们需要观察和比较班次和缺陷计数与总体预期。如果观察到的计数与预期计数之间的差异可以归因于随机波动,我们必须接受零假设,即没有发生任何有趣的错误。另一方面,如果数字与随机变化不符合,那么我们就有一个需要进一步调查的问题。

使用 Counter 对象对原始数据进行过滤和归约

我们将基本缺陷计数表示为collections.Counter参数。我们将从详细的原始数据中按班次和缺陷类型构建缺陷计数。以下是从 CSV 文件中读取一些原始数据的函数:

import csv
from collections import Counter
from types import SimpleNamespace
def defect_reduce(input):
 **rdr= csv.DictReader(input)
 **assert sorted(rdr.fieldnames) == ["defect_type", "serial_number", "shift"]
 **rows_ns = (SimpleNamespace(**row) for row in rdr)
 **defects = ((row.shift, row.defect_type) for row in rows_ns:
 **if row.defect_type)
 **tally= Counter(defects)
 **return tally

前面的函数将基于通过input参数提供的打开文件创建一个字典读取器。我们已经确认列名与三个预期列名匹配。在某些情况下,文件中会有额外的列;在这种情况下,断言将类似于all((c in rdr.fieldnames) for c in […])。给定一个列名的元组,这将确保所有必需的列都存在于源中。我们还可以使用集合来确保set(rdr.fieldnames) <= set([...])

我们为每一行创建了一个types.SimpleNamespace参数。在前面的示例中,提供的列名是有效的 Python 变量名,这使我们可以轻松地将字典转换为命名空间。在某些情况下,我们需要将列名映射到 Python 变量名以使其工作。

SimpleNamespace参数允许我们使用稍微简单的语法来引用行内的项目。具体来说,下一个生成器表达式使用诸如row.shiftrow.defect_type之类的引用,而不是臃肿的row['shift']row['defect_type']引用。

我们可以使用更复杂的生成器表达式来进行映射-过滤组合。我们将过滤每一行,忽略没有缺陷代码的行。对于有缺陷代码的行,我们正在映射一个表达式,该表达式从row.shiftrow.defect_type引用创建一个二元组。

在某些应用中,过滤器不会是一个像row.defect_type这样的简单表达式。可能需要编写一个更复杂的条件。在这种情况下,使用filter()函数将复杂条件应用于提供数据的生成器表达式可能会有所帮助。

给定一个将产生(shift, defect)元组序列的生成器,我们可以通过从生成器表达式创建一个Counter对象来对它们进行总结。创建这个Counter对象将处理惰性生成器表达式,它将读取源文件,从行中提取字段,过滤行,并总结计数。

我们将使用defect_reduce()函数来收集和总结数据如下:

with open("qa_data.csv", newline="" ) as input:
 **defects= defect_reduce(input)
print(defects)

我们可以打开一个文件,收集缺陷,并显示它们以确保我们已经按班次和缺陷类型正确进行了总结。由于结果是一个Counter对象,如果我们有其他数据源,我们可以将其与其他Counter对象结合使用。

defects值如下:

Counter({('3', 'C'): 49, ('1', 'C'): 45, ('2', 'C'): 34, ('3', 'A'): 33, ('2', 'B'): 31, ('2', 'A'): 26, ('1', 'B'): 21, ('3', 'D'): 20, ('3', 'B'): 17, ('1', 'A'): 15, ('1', 'D'): 13, ('2', 'D'): 5})

我们按班次和缺陷类型组织了缺陷计数。接下来我们将看一下摘要数据的替代输入。这反映了一个常见的用例,即摘要级别的数据是可用的。

读取数据后,下一步是开发两个概率,以便我们可以正确计算每个班次和每种缺陷类型的预期缺陷。我们不想将总缺陷数除以 12,因为这并不反映出实际的班次或缺陷类型的偏差。班次可能生产的效率可能更或者更少相等。缺陷频率肯定不会相似。我们期望一些缺陷非常罕见,而其他一些则更常见。

读取总结数据

作为读取所有原始数据的替代方案,我们可以考虑只处理摘要计数。我们想创建一个类似于先前示例的Counter对象;这将具有缺陷计数作为值,并具有班次和缺陷代码作为键。给定摘要,我们只需从输入字典创建一个Counter对象。

这是一个读取我们的摘要数据的函数:

from collections import Counter
import csv
def defect_counts(source):
 **rdr= csv.DictReader(source)
 **assert rdr.fieldnames == ["shift", "defect_code", "count"]
 **convert = map(lambda d: ((d['shift'], d['defect_code']), int(d['count'])),
 **rdr)
 **return Counter(dict(convert))

我们需要一个打开的文件作为输入。我们将创建一个csv.DictReader()函数,帮助解析从数据库获取的原始 CSV 数据。我们包括一个assert语句来确认文件确实具有预期的数据。

我们定义了一个lambda对象,它创建一个带有键和计数的两元组。键本身是一个包含班次和缺陷信息的两元组。结果将是一个序列,如((班次,缺陷),计数),((班次,缺陷),计数),…)。当我们将lambda映射到DictReader参数时,我们将得到一个可以发出两元组序列的生成函数。

我们将从两个元组的集合中创建一个字典,并使用这个字典来构建一个Counter对象。Counter对象可以很容易地与其他Counter对象结合使用。这使我们能够结合从几个来源获取的详细信息。在这种情况下,我们只有一个单一的来源。

我们可以将这个单一来源分配给变量defects。该值如下:

Counter({('3', 'C'): 49, ('1', 'C'): 45, ('2', 'C'): 34, ('3', 'A'): 33, ('2', 'B'): 31, ('2', 'A'): 26,('1', 'B'): 21, ('3', 'D'): 20, ('3', 'B'): 17, ('1', 'A'): 15, ('1', 'D'): 13, ('2', 'D'): 5})

这与先前显示的详细摘要相匹配。然而,源数据已经被总结。当数据从数据库中提取并使用 SQL 进行分组操作时,通常会出现这种情况。

从 Counter 对象计算概率

我们需要计算按班次和按类型的缺陷概率。为了计算预期概率,我们需要从一些简单的总数开始。首先是所有缺陷的总数,可以通过执行以下命令来计算:

total= sum(defects.values())

这是直接从分配给defects变量的Counter对象的值中得到的。这将显示样本集中总共有 309 个缺陷。

我们需要按班次和按类型获取缺陷。这意味着我们将从原始缺陷数据中提取两种子集。"按班次"提取将仅使用Counter对象中(班次,缺陷类型)键的一部分。"按类型"将使用键对的另一半。

我们可以通过从分配给defects变量的Counter对象的初始集合中提取额外的Counter对象来进行总结。以下是按班次总结:

shift_totals= sum((Counter({s:defects[s,d]}) for s,d in defects), Counter())

我们创建了一组单独的Counter对象,这些对象以班次s为键,并与该班次相关的缺陷计数defects[s,d]。生成器表达式将创建 12 个这样的Counter对象,以提取所有四种缺陷类型和三个班次的数据。我们将使用sum()函数将Counter对象组合起来,以获得按班次组织的三个摘要。

注意

我们不能使用sum()函数的默认初始值 0。我们必须提供一个空的Counter()函数作为初始值。

类型总数的创建方式与用于创建班次总数的表达式类似:

type_totals= sum((Counter({d:defects[s,d]}) for s,d in defects), Counter())

我们创建了一打Counter对象,使用缺陷类型d作为键,而不是班次类型;否则,处理是相同的。

班次总数如下:

Counter({'3': 119, '2': 96, '1': 94})

缺陷类型的总数如下:

Counter({'C': 128, 'A': 74, 'B': 69, 'D': 38})

我们将摘要保留为Counter对象,而不是创建简单的dict对象或甚至list实例。从这一点开始,我们通常会将它们用作简单的 dict。但是,在某些情况下,我们会希望使用适当的Counter对象,而不是缩减。

替代摘要方法

我们分两步读取数据并计算摘要。在某些情况下,我们可能希望在读取初始数据时创建摘要。这是一种优化,可能会节省一点处理时间。我们可以编写一个更复杂的输入缩减,它会输出总数、班次总数和缺陷类型总数。这些Counter对象将逐个构建。

我们专注于使用Counter实例,因为它们似乎为我们提供了灵活性。对数据采集的任何更改仍将创建Counter实例,并不会改变后续的分析。

这是我们如何计算按班次和缺陷类型计算缺陷概率的方法:

from fractions import Fraction
P_shift = dict( (shift, Fraction(shift_totals[shift],total))
for shift in sorted(shift_totals))
P_type = dict((type, Fraction(type_totals[type],total)) for type in sorted(type_totals))

我们创建了两个字典:P_shiftP_typeP_shift字典将一个班次映射到一个Fraction对象,显示了该班次对总缺陷数的贡献。类似地,P_type字典将一个缺陷类型映射到一个Fraction对象,显示了该类型对总缺陷数的贡献。

我们选择使用Fraction对象来保留输入值的所有精度。在处理这样的计数时,我们可能会得到更符合人们直观理解的概率值。

我们选择使用dict对象,因为我们已经切换了模式。在分析的这一点上,我们不再积累细节;我们使用缩减来比较实际和观察到的数据。

P_shift数据看起来像这样:

{'2': Fraction(32, 103), '3': Fraction(119, 309), '1': Fraction(94, 309)}

P_type数据看起来像这样:

{'B': Fraction(23, 103), 'C': Fraction(128, 309), 'A': Fraction(74, 309), 'D': Fraction(38, 309)}

对于一些人来说,例如 32/103 或 96/309 可能比 0.3106 更有意义。我们可以很容易地从Fraction对象中获得float值,后面我们会看到。

所有班次似乎在缺陷产生方面大致相同。缺陷类型有所不同,这是典型的。似乎缺陷C是一个相对常见的问题,而缺陷B则要少得多。也许第二个缺陷需要更复杂的情况才会出现。

计算预期值并显示列联表

预期的缺陷产生是一个综合概率。我们将计算按班次的缺陷概率乘以基于缺陷类型的概率。这将使我们能够计算所有 12 种概率,从所有班次和缺陷类型的组合中。我们可以用观察到的数字来加权这些概率,并计算缺陷的详细预期。

这是预期值的计算:

expected = dict(((s,t), P_shift[s]*P_type[t]*total) for t in P_type:for s in P_shift)

我们将创建一个与初始defects Counter对象相对应的字典。这个字典将有一个包含两个元组的序列,带有键和值。键将是班次和缺陷类型的两个元组。我们的字典是从一个生成器表达式构建的,该表达式明确列举了从P_shiftP_type字典中的所有键的组合。

expected字典的值看起来像这样:

{('2', 'B'): Fraction(2208, 103), ('2', 'D'): Fraction(1216, 103),('3', 'D'): Fraction(4522, 309), ('2', 'A'): Fraction(2368, 103),('1', 'A'): Fraction(6956, 309), ('1', 'B'): Fraction(2162, 103),('3', 'B'): Fraction(2737, 103), ('1', 'C'): Fraction(12032, 309),('3', 'C'): Fraction(15232, 309), ('2', 'C'): Fraction(4096, 103),('3', 'A'): Fraction(8806, 309), ('1', 'D'): Fraction(3572, 309)}

映射的每个项目都有一个与班次和缺陷类型相关的键。这与基于缺陷的概率的Fraction值相关联,基于班次的概率乘以基于缺陷类型的概率乘以总缺陷数。一些分数被简化,例如,6624/309 的值可以简化为 2208/103。

大数字作为适当的分数是尴尬的。将大值显示为float值通常更容易。小值(如概率)有时更容易理解为分数。

我们将成对打印观察到的和预期的时间。这将帮助我们可视化数据。我们将创建类似以下内容的内容来帮助总结我们观察到的和我们期望的:

obs exp    obs exp    obs exp    obs exp** 
15 22.51    21 20.99    45 38.94    13 11.56    94
26 22.99    31 21.44    34 39.77     5 11.81    96
33 28.50    17 26.57    49 49.29    20 14.63    119
74        69        128        38        309

这显示了 12 个单元格。每个单元格都有观察到的缺陷数和期望的缺陷数。每行以变化总数结束,每列都有一个包含缺陷总数的页脚。

在某些情况下,我们可能会以 CSV 格式导出这些数据并构建电子表格。在其他情况下,我们将构建列联表的 HTML 版本,并将布局细节留给浏览器。我们在这里展示了一个纯文本版本。

以下是创建先前显示的列联表的一系列语句:

print("obs exp"*len(type_totals))
for s in sorted(shift_totals):
 **pairs= ["{0:3d} {1:5.2f}".format(defects[s,t], float(expected[s,t])) for t in sorted(type_totals)]
 **print("{0} {1:3d}".format( "".join(pairs), shift_totals[s]))
footers= ["{0:3d}".format(type_totals[t]) for t in sorted(type_totals)]
print("{0} {1:3d}".format("".join(footers), total))

这将缺陷类型分布到每一行。我们已经写了足够的obs exp列标题来涵盖所有的缺陷类型。对于每个变化,我们将发出一行观察到的和实际的对,然后是一个变化的总数。在底部,我们将发出一个只有缺陷类型总数和总数的页脚行。

这样的列联表有助于我们可视化观察值和期望值之间的比较。我们可以计算这两组数值的卡方值。这将帮助我们决定数据是否是随机的,或者是否有值得进一步调查的地方。

计算卡方值

计算卡方值的值基于计算卡方值,其中e值是期望值,o值是观察值。

我们可以计算指定公式的值如下:

diff= lambda e,o: (e-o)**2/e
chi2= sum(diff(expected[s,t], defects[s,t]) for s in shift_totals:
 **for t in type_totals
 **)

我们定义了一个小的lambda来帮助我们优化计算。这使我们能够只执行一次expected[s,t]defects[s,t]属性,即使期望值在两个地方使用。对于这个数据集,最终的卡方值为 19.18。

基于三个变化和四种缺陷类型,总共有六个自由度。由于我们认为它们是独立的,我们得到2×3=6。卡方表告诉我们,低于 12.5916 的任何值都反映了数据真正随机的 1/20 的机会。由于我们的值是 19.18,数据不太可能是随机的。

计算卡方值的累积分布函数显示,19.18 的值有大约 0.00387 的概率:大约 1000 次中有 4 次是随机的。下一步是进行后续研究,以发现各种缺陷类型和变化的详细信息。我们需要看看哪个自变量与缺陷有最大的相关性,并继续分析。

与进行这个案例研究不同,我们将看一个不同且有趣的计算。

计算卡方阈值

计算卡方阈值测试的本质是基于自由度的数量和我们愿意接受或拒绝零假设的不确定性水平的阈值。通常,我们建议使用约 0.05(1/20)的阈值来拒绝零假设。我们希望数据只有 20 分之 1 的机会是纯粹随机的,而且它看起来是有意义的。换句话说,我们希望有 20 次中有 19 次数据反映了简单的随机变化。

卡方值通常以表格形式提供,因为计算涉及一些超越函数。在某些情况下,库将提供计算卡方阈值累积分布函数,允许我们计算一个值,而不是在重要值的表上查找一个值。

对于计算卡方阈值x和自由度f的累积分布函数定义如下:

计算卡方阈值

将随机性视为计算卡方阈值是很常见的。也就是说,如果p > 0.05,则数据可以被理解为随机的;零假设成立。

这需要两个计算:不完整的伽玛函数,计算卡方阈值,和完整的伽玛函数,计算卡方阈值。这可能涉及一些相当复杂的数学。我们将简化一些步骤,并实现两个非常好的近似,专注于解决这个问题。这些函数中的每一个都将允许我们查看功能设计问题。

这两个函数都需要一个阶乘计算,计算卡方阈值。我们已经看到了几种分数主题的变化。我们将使用以下一个:

@lru_cache(128)
def fact(k):
 **if k < 2: return 1
 **return reduce(operator.mul, range(2, int(k)+1))

这是计算卡方阈值:从 2 到k(包括k)的数字的乘积。我们省略了单元测试案例。

计算部分伽玛值

部分伽玛函数有一个简单的级数展开。这意味着我们将计算一系列值,然后对这些值进行求和。欲了解更多信息,请访问dlmf.nist.gov/8

计算部分伽玛值

这个系列将有一系列项,最终变得太小而不相关。计算计算部分伽玛值将产生交替的符号:

计算部分伽玛值

s=1z=2时,项的序列如下:

 **2/1, -2/1, 4/3, -2/3, 4/15, -4/45, ..., -2/638512875

在某些时候,每个额外的项对结果不会产生重大影响。

当我们回顾累积分布函数计算部分伽玛值时,我们可以考虑使用fractions.Fraction值。自由度k将是一个除以 2 的整数。计算部分伽玛值x可能是分数浮点值;它很少是一个简单的整数值。

在评估计算部分伽玛值时,计算部分伽玛值的值将涉及整数,并且可以表示为适当的分数值。计算部分伽玛值的值可能是分数浮点值;当计算部分伽玛值不是整数值时,它将导致无理数值。计算部分伽玛值的值将是一个适当的分数值,有时它将具有整数值,有时它将具有涉及 1/2 的值。

在这里使用分数值虽然可能,但似乎并不有用,因为将计算出一个无理数值。然而,当我们看这里给出的完整伽玛函数时,我们会发现分数值有潜在的帮助。在这个函数中,它们只是偶然发生的。

这是先前解释的级数展开的实现:

def gamma(s, z):
 **def terms(s, z):
 **for k in range(100):
 **t2= Fraction(z**(s+k))/(s+k)
 **term= Fraction((-1)**k,fact(k))*t2
 **yield term
 **warnings.warn("More than 100 terms")
 **def take_until(function, iterable):
 **for v in iterable:
 **if function(v): return
 **yield v
 **ε= 1E-8
 **return sum(take_until(lambda t:abs(t) < ε, terms(s, z)))

我们定义了一个term()函数,它将产生一系列项。我们使用了一个带有上限的for语句来生成只有 100 个项。我们可以使用itertools.count()函数来生成无限序列的项。使用带有上限的循环似乎更简单一些。

我们计算了无理数计算部分伽玛值值,并从这个值本身创建了一个分数值。如果z的值也是分数值而不是浮点值,那么t2的值将是分数值。term()函数的值将是两个分数对象的乘积。

我们定义了一个take_until()函数,它从可迭代对象中获取值,直到给定的函数为真。一旦函数变为真,就不会再从可迭代对象中获取更多的值。我们还定义了一个小的阈值ε,为计算部分 gamma 值。我们将从term()函数中获取值,直到这些值小于ε。这些值的总和是对部分gamma函数的近似。

以下是一些测试案例,我们可以用来确认我们正在正确计算这个值:

  • 计算部分 gamma 值

  • 计算部分 gamma 值

  • 计算部分 gamma 值

误差函数erf()是另一个有趣的函数。我们不会在这里探讨它,因为它在 Python 数学库中可用。

我们的兴趣集中在卡方分布上。我们通常不对其他数学目的的不完整gamma函数感兴趣。因此,我们可以将我们的测试案例限制在我们期望使用的值类型上。我们还可以限制结果的精度。大多数卡方检验涉及三位数的精度。我们在测试数据中显示了七位数,这比我们可能需要的要多。

计算完整的 gamma 值

完整的gamma函数有点更难。有许多不同的近似值。有关更多信息,请访问dlmf.nist.gov/5。Python 数学库中有一个版本。它代表了一个广泛有用的近似值,专为许多情况而设计。

我们实际上并不对完整的gamma函数的一般实现感兴趣。我们只对两种特殊情况感兴趣:整数值和一半。对于这两种特殊情况,我们可以得到确切的答案,不需要依赖近似值。

对于整数值,计算完整的 gamma 值。整数的gamma函数可以依赖于我们之前定义的阶乘函数。

对于一半,有一个特殊的形式:

计算完整的 gamma 值

这包括一个无理数值,因此我们只能使用floatFraction对象来近似表示它。

由于卡方累积分布函数只使用完整gamma函数的以下两个特征,我们不需要一般方法。我们可以欺骗并使用以下两个值,它们相当精确。

如果我们使用适当的“分数”值,那么我们可以设计一个具有几个简单情况的函数:一个“整数”值,一个分母为 1 的“分数”值,以及一个分母为 2 的“分数”值。我们可以使用“分数”值如下:

sqrt_pi = Fraction(677622787, 382307718)
def Gamma_Half(k):
 **if isinstance(k,int):
 **return fact(k-1)
 **elif isinstance(k,Fraction):
 **if k.denominator == 1:
 **return fact(k-1)
 **elif k.denominator == 2:
 **n = k-Fraction(1,2)
 **return fact(2*n)/(Fraction(4**n)*fact(n))*sqrt_pi
 **raise ValueError("Can't compute Γ({0})".format(k))

我们将函数称为Gamma_Half,以强调这仅适用于整数和一半。对于整数值,我们将使用之前定义的fact()函数。对于分母为 1 的“分数”对象,我们将使用相同的fact()定义:计算完整的 gamma 值

对于分母为 2 的情况,我们可以使用更复杂的“闭式”值。我们对值计算完整的 gamma 值使用了显式的Fraction()函数。我们还为无理数值计算完整的 gamma 值提供了一个Fraction近似值。

以下是一些测试案例:

  • 计算完整的 gamma 值

  • 计算完整的 gamma 值

  • 计算完整的 gamma 值

  • 计算完整的 gamma 值

这些也可以显示为适当的“分数”值。无理数导致大而难以阅读的分数。我们可以使用类似这样的东西:

 **>>> g= Gamma_Half(Fraction(3,2))
 **>>> g.limit_denominator(2000000)
 **Fraction(291270, 328663)

这提供了一个数值,其中分母被限制在 1 到 200 万的范围内;这提供了看起来很好的六位数,我们可以用于单元测试目的。

计算分布随机性的概率

现在我们有了不完整的gamma函数,gamma,和完整的gamma函数,Gamma_Half,我们可以计算出分布随机性的概率值。CDF值向我们展示了给定值的分布随机性或可能的相关性的概率。

函数本身非常小:

def cdf(x, k):
 **"""X² cumulative distribution function.
 **:param x: X² value -- generally sum (obs[i]-exp[i])**2/exp[i]
 **for parallel sequences of observed and expected values.:param k: degrees of freedom >= 1; generally len(data)-1
 **"""
 **return 1-gamma(Fraction(k,2), Fraction(x/2))/Gamma_Half(Fraction(k,2))

我们包含了一些docstring注释来澄清参数。我们从自由度和卡方值x创建了正确的Fraction对象。当将一个float值转换为一个Fraction对象时,我们将得到一个非常大的分数结果,带有许多完全无关的数字。

我们可以使用Fraction(x/2).limit_denominator(1000)来限制x/2Fraction方法的大小为一个相当小的数字。这将计算出一个正确的CDF值,但不会导致有数十位数字的巨大分数。

这里有一些从一个表中调用的样本数据,用于计算分布随机性的概率。访问en.wikipedia.org/wiki/Chi-squared_distribution获取更多信息。

要计算正确的CDF值,请执行以下命令:

>>> round(float(cdf(0.004, 1)), 2)
0.95
>>> cdf(0.004, 1).limit_denominator(100)
Fraction(94, 99)
>>> round(float(cdf(10.83, 1)), 3)
0.001
>>> cdf(10.83, 1).limit_denominator(1000)
Fraction(1, 1000)
>>> round(float(cdf(3.94, 10)), 2)
0.95
>>> cdf(3.94, 10).limit_denominator(100)
Fraction(19, 20)
>>> round(float(cdf(29.59, 10)), 3)
0.001
>>> cdf(29.59, 10).limit_denominator(10000)
Fraction(8, 8005)

给定一个自由度的值和一个自由度的数量,我们的CDF函数产生了与一个广泛使用的值表相同的结果。

这是一个从一个表中的整行,用一个简单的生成器表达式计算出来的:

>>> chi2= [0.004, 0.02, 0.06, 0.15, 0.46, 1.07, 1.64, 2.71, 3.84, 6.64, 10.83]
>>> act= [round(float(x), 3) for x in map(cdf, chi2, [1]*len(chi2))]
>>> act
[0.95, 0.888, 0.806, 0.699, 0.498, 0.301, 0.2, 0.1, 0.05, 0.01, 0.001]

预期值如下:

[0.95, 0.90, 0.80, 0.70, 0.50, 0.30, 0.20, 0.10, 0.05, 0.01, 0.001]

我们在第三位小数上有一些微小的差异。

我们可以从一个值中得到一个概率,这个值是从一个分布随机性的概率表中获取的。从我们之前展示的例子中,自由度为 6 时的 0.05 概率对应的值为 12.5916。

>>> round(float(cdf(12.5916, 6)), 2)
0.05

在例子中,我们得到的实际值为 19.18。这是这个值是随机的概率:

>>> round(float(cdf(19.18, 6)), 5)
0.00387

这个概率是 3/775,分母限制为 1000。这些不是数据随机性的好概率。

总结

在本章中,我们讨论了三种优化技术。第一种技术涉及找到合适的算法和数据结构。这对性能的影响比任何其他单个设计或编程决策都要大。使用正确的算法可以轻松将运行时间从几分钟减少到几秒钟。例如,将一个使用不当的序列更改为一个正确使用的映射,可能会将运行时间减少 200 倍。

我们通常应该优化我们所有的递归为循环。这在 Python 中会更快,而且不会被 Python 施加的调用堆栈限制所阻止。在其他章节中有许多递归被转换为循环的例子,主要是第六章, 递归和简化。此外,我们可能还可以通过两种其他方式来提高性能。首先,我们可以应用记忆化来缓存结果。对于数值计算,这可能会产生很大的影响;对于集合,影响可能会小一些。其次,用可迭代对象替换大型物化数据对象也可能通过减少所需的内存管理量来提高性能。

在本章介绍的案例研究中,我们看到了使用 Python 进行探索性数据分析的优势——初始数据获取包括一点解析和过滤。在某些情况下,需要大量的工作来规范来自各种来源的数据。这是 Python 擅长的任务。

计算一个Summary值涉及三个sum()函数:两个中间的生成器表达式,以及一个最终的生成器表达式来创建一个带有期望值的字典。最后一个sum()函数创建了统计数据。在不到十几个表达式的情况下,我们创建了一种复杂的数据分析,这将帮助我们接受或拒绝零假设。

我们还评估了一些复杂的统计函数:不完全和完全的gamma函数。不完全的gamma函数涉及潜在的无限级数;我们对此进行了截断并求和。完全的gamma函数具有一定的复杂性,但在我们的情况下并不适用。

使用功能性方法,我们可以编写简洁而富有表现力的程序,完成大量处理。Python 并不是一种完全的函数式编程语言。例如,我们需要使用一些命令式编程技术。这种限制迫使我们远离纯函数式递归。我们获得了一些性能优势,因为我们被迫将尾递归优化为显式循环。

我们还看到了采用 Python 的混合式函数式编程风格的许多优势。特别是,使用 Python 的高阶函数和生成器表达式给了我们许多编写高性能程序的方法,这些程序通常非常清晰简单。

posted @ 2024-05-04 21:28  绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报