现代-Python-秘籍(五)

现代 Python 秘籍(五)

原文:zh.annas-archive.org/md5/185a6e8218e2ea258a432841b73d4359

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:更高级的类设计

在本章中,我们将看一下以下的配方:

  • 在继承和扩展之间进行选择 - is-a 问题

  • 通过多重继承分离关注点

  • 利用 Python 的鸭子类型

  • 管理全局和单例对象

  • 使用更复杂的结构 - 映射列表

  • 创建一个具有可排序对象的类

  • 定义一个有序集合

  • 从映射列表中删除

介绍

在第六章的类和对象的基础中,我们看了一些涵盖类设计基础的配方。在本章中,我们将更深入地了解 Python 类。

在第六章的设计具有大量处理的类使用属性进行惰性属性中,我们确定了面向对象编程的一个设计选择,即包装与扩展的选择。可以通过扩展向类添加功能,也可以创建一个新的类,将现有类包装起来添加新功能。Python 中有许多扩展技术可供选择。

Python 类可以从多个超类继承特性。这可能会导致混乱,但一个简单的设计模式,即mixin,可以避免问题。

一个更大的应用程序可能需要一些全局数据,这些数据被许多类或模块广泛共享。这可能很难管理。然而,我们可以使用一个模块来管理全局对象并创建一个简单的解决方案。

在第四章的内置数据结构 - 列表,集合,字典中,我们看了核心的内置数据结构。现在是时候结合一些特性来创建更复杂的对象了。这也可以包括扩展内置数据结构以添加复杂性。

在继承和扩展之间进行选择 - is-a 问题

在第五章的使用 cmd 创建命令行应用程序和第六章的扩展集合 - 进行统计的列表中,我们看到了扩展类的方法。在这两种情况下,我们的类都是内置类的子类。

扩展的概念有时被称为泛化-特化关系。有时也被称为is-a 关系

这里有一个重要的语义问题,我们也可以总结为包装与扩展问题

  • 我们真的是指子类是超类的一个例子吗?这就是 is-a 关系。Python 中的一个例子是内置的Counter,它扩展了基类dict

  • 或者我们是指其他的东西吗?也许有一种关联,有时被称为has-a 关系。这在第六章的设计具有大量处理的类中有一个例子,其中CounterStatistics包装了一个Counter对象。

有什么好方法来区分这两种技术吗?

准备工作

这个问题有点形而上学的哲学,特别关注本体论的思想。本体论是定义存在类别的一种方式。

当我们扩展一个对象时,我们必须问以下问题:

“这是一个新类的对象,还是现有类的对象的混合?”

我们将看两种模拟一副扑克牌的方法:

  • 作为一个扩展内置list类的新类对象

  • 作为一个将内置的list类与其他一些特性结合的包装器

一副牌是一组卡片。那么,核心成分就是底层的Card对象。我们将使用namedtuple()来非常简单地定义这个:

 **>>> from collections import namedtuple 
>>> Card = namedtuple('Card', ('rank', 'suit')) 
>>> SUITS = '\u2660\u2661\u2662\u2663' 
>>> Spades, Hearts, Diamonds, Clubs = SUITS 
>>> Card(2, Spades) 
Card(rank=2, suit='♣')** 

我们使用namedtuple()创建了类定义Card。这创建了一个具有两个属性 - ranksuit的简单类。

我们还将各种花色SUITS定义为 Unicode 字符的字符串。为了更容易地创建特定花色的卡片,我们还将字符串分解为四个单个字符的子字符串。如果您的交互环境无法正确显示 Unicode 字符,您可能会遇到问题。可能需要更改操作系统环境变量PYTHONIOENCODING为“UTF-8”,以便进行正确的编码。

\u2660字符串是一个 Unicode 字符。您可以通过len(SUITS) == 4来确认这一点。如果长度不是 4,请检查是否有多余的空格。

我们将在本配方的其余部分使用这个Card类。在一些纸牌游戏中,使用一副 52 张卡片的牌组。在其他游戏中,使用发牌鞋。鞋子是一个允许荷官将多副牌洗在一起并方便地发牌的盒子。

重要的是各种集合 - 牌组、鞋子和内置列表在它们支持的功能种类上有相当大的重叠。它们都或多或少相关吗?还是它们基本上是不同的?

如何做...

我们将在第六章中的使用类封装数据和处理配方中,与此配方一起包装类和对象的基础

  1. 使用原始故事或问题陈述中的名词和动词来识别所有的类。

  2. 寻找各种类的特征集中的重叠。在许多情况下,关系将直接来自问题陈述本身。在我们之前的例子中,游戏可以从一副牌中发牌,或者从一双鞋中发牌。在这种情况下,我们可能陈述这两种观点之一:

  • 鞋子是一个专门的牌组,由 52 张卡片的多个副本开始

  • 一副牌是一个专门的鞋子,只有 52 张卡片的一个副本

  1. 创建一个小本体,澄清类之间的关系。有几种关系。

一些类彼此独立。它们是为了实现用户故事而链接的。在我们的例子中,Card指的是花色的字符串。这两个对象彼此独立。许多卡片将共享一个常见的花色字符串。这些是对象之间的普通引用,没有特殊的设计考虑:

  • 聚合:一些对象被绑定到集合中,但这些对象具有独立的存在。我们的Card对象可能被聚合到一个Hand集合中。游戏结束时,Hand对象可以被删除,但Card对象仍然存在。我们可以创建一个引用内置listDeck

  • 组合:一些对象被绑定到集合中,但没有独立的存在。在看牌游戏时,一手牌不能没有玩家而存在。我们可能会说Player对象在某种程度上是由Hand组成的。如果一个Player被从游戏中淘汰,那么Hand对象也必须被移除。虽然这对于理解对象之间的关系很重要,但在下一节中我们将考虑一些实际的考虑。

  • 是一个或继承:这是一个Shoe是一个带有额外功能(或两个)的Deck的想法。这可能是我们设计的核心。我们将在本配方的扩展 - 继承部分详细研究这一点。

我们已经确定了几种实现关联的路径。聚合和组合案例都是包装技术。继承案例是扩展技术。我们将分别研究聚合和组合 - 包装技术和扩展技术。

包装 - 聚合和组合

包装是一种理解集合的方式。它可以是一个包含独立对象的类。它也是一个包装现有列表的组合,这意味着底层的Card对象将被list集合和Deck集合共享。

  1. 定义独立的集合。它可能是一个内置的集合,例如setlistdict。在这个例子中,它将是一个包含卡片的列表:
        domain = [Card(r+1,s) for r in range(13) for s in SUITS] 

  1. 定义聚合类。在这个例子中,名称带有_W后缀。这不是一个推荐的做法;这里只是为了更清楚地区分类定义之间的区别。稍后,我们将看到对这种设计的稍微不同的变化:
        class Deck_W: 

  1. 使用这个类的__init__()方法作为提供底层集合对象的一种方式。这也将初始化任何有状态的变量。我们可能会创建一个用于发牌的迭代器:
        def __init__(self, cards:List[Card]): 
            self.cards = cards.copy() 
            self.deal_iter = iter(cards) 

这使用了一个类型提示,List[Card]typing模块提供了List的必要定义。

  1. 如果需要,提供其他方法来替换集合,或更新集合。这在 Python 中很少见,因为底层属性cards可以直接访问。然而,提供一个替换self.cards值的方法可能是有帮助的。

  2. 提供适用于聚合对象的方法:

        def shuffle(self): 
            random.shuffle(self.cards) 
            self.deal_iter = iter(self.cards) 
        def deal(self) -> Card: 
            return next(self.deal_iter) 

shuffle()方法随机化内部列表对象self.cardsdeal()对象创建一个迭代器,可以用来遍历self.cards列表。我们在deal()上提供了一个类型提示,以澄清它返回一个Card实例。

这是我们如何使用这个类的方法。我们将共享一个Card对象列表。在这种情况下,domain变量是从一个列表推导式中创建的,该推导式生成了 13 个等级和四种花色的 52 种组合:

 **>>> domain = list(Card(r+1,s) for r in range(13) for s in SUITS) 
>>> len(domain) 
52** 

我们可以使用这个集合中的项目domain来创建一个共享相同底层Card对象的第二个聚合对象。我们将从domain变量中的对象列表构建Deck_W对象:

 **>>> import random 
>>> from ch07_r01 import Deck_W 
>>> d = Deck_W(domain)** 

一旦Deck_W对象可用,就可以使用独特的功能:

 **>>> random.seed(1) 
>>> d.shuffle() 
>>> [d.deal() for _ in range(5)]  
[Card(rank=13, suit='♡'), 
Card(rank=3, suit='♡'), 
Card(rank=10, suit='♡'), 
Card(rank=6, suit='♢'), 
Card(rank=1, suit='♢')]** 

我们已经种子化了随机数生成器,以强制卡片有一个定义的顺序。这样可以进行单元测试。之后,我们根据随机种子对牌组进行了洗牌。一旦种子被播下,结果就是一致的,这样单元测试就变得容易了。我们可以从牌组中发出五张牌。这展示了Deck_W对象d如何与domain列表共享相同的对象池。

我们可以删除Deck_W对象d,并从domain列表中创建一个新的牌组。这是因为Card对象不是组合的一部分。这些卡片与Deck_W集合有独立的存在。

扩展-继承

这是一种定义扩展对象集合的类的方法。我们将一个Deck定义为一个包装现有列表的聚合体。底层的Card对象将被列表和Deck共享:

  1. 将扩展类定义为内置集合的子类。在这个例子中,名称带有_X后缀。这不是一个推荐的做法;这里只是为了更清楚地区分这个配方中两个类定义之间的区别:
        class Deck_X(list): 

这是一个清晰而正式的陈述——Deck是一个列表。

  1. 使用从list类继承的__init__()方法。不需要代码。

  2. 使用list类的其他方法来向Deck添加、更改或删除项目。不需要代码。

  3. 为扩展对象提供适当的方法:

        def shuffle(self): 
            random.shuffle(self) 
            self.deal_iter = iter(self) 
        def deal(self) -> Card: 
            return next(self.deal_iter) 

shuffle()方法将对象作为一个整体进行随机化,因为它是列表的扩展。deal()对象创建一个迭代器,可以用来遍历self.cards列表。我们在deal()上提供了一个类型提示,以澄清它返回一个Card实例。

这是我们如何使用这个类的方法。首先,我们将构建一副牌:

 **>>> from ch07_r01 import Deck_X 
>>> d2 = Deck_X(Card(r+1,s) for r in range(13) for s in SUITS) 
>>> len(d2) 
52** 

我们使用生成器表达式构建了单独的Card对象。我们可以像使用list()类函数一样使用Deck_X()类函数。在这种情况下,我们从生成器表达式构建了一个Deck_X对象。我们也可以类似地构建一个list

我们没有为内置的__len__()方法提供实现。这是从list类继承的,并且工作得很好。

对于这个实现,使用特定于牌组的特性看起来与另一个实现Deck_W完全相同:

 **>>> random.seed(1) 
>>> d2.shuffle() 
>>> [d2.deal() for _ in range(5)]  
[Card(rank=13, suit='♡'), 
Card(rank=3, suit='♡'), 
Card(rank=10, suit='♡'), 
Card(rank=6, suit='♢'), 
Card(rank=1, suit='♢')]** 

我们已经初始化了随机数生成器,洗牌了牌组,并发了五张牌。扩展方法对Deck_XDeck_W同样适用。shuffle()deal()方法都能正常工作。

它是如何工作的...

Python 查找方法(或属性)的机制如下:

  1. 在类中搜索方法或属性。

  2. 如果在当前类中未定义名称,则在所有父类中搜索方法或属性。

这就是 Python 实现继承的方式。通过搜索父类,可以确保两件事:

  • 任何超类中定义的方法都可用于所有子类

  • 任何子类都可以重写一个方法来替换超类方法

因此,list类的子类继承了父类的所有特性。它是内置list类的专门变体。

这也意味着所有方法都有可能被子类重写。一些语言有方法可以锁定方法防止扩展。像 C++和 Java 这样的语言使用private关键字。Python 没有这个限制,子类可以重写任何方法。

要明确引用超类的方法,我们可以使用super()函数来强制搜索超类。这允许子类通过包装方法的超类版本来添加特性。我们可以像这样使用它:

    def some_method(self): 
        # do something extra 
        super().some_method() 

在这种情况下,some_method()对象将执行一些额外的操作,然后执行方法的超类版本。这使我们能够方便地扩展类的选定方法。我们可以保留超类的特性,同时添加子类独有的特性。

还有更多...

在设计类时,我们必须在几种基本技术之间进行选择:

  • 包装:这种技术创建了一个新的类。必须定义所有必需的方法。这可能需要大量的代码来提供所需的方法。包装可以分解为两种广泛的实现选择:

  • 聚合:被包装的对象与包装器具有独立的存在。Deck_W示例展示了Card对象甚至牌组列表与类是独立的。当任何Deck_W对象被删除时,底层列表将继续存在。

  • 组合:被包装的对象没有独立的存在;它们是组合的重要部分。这涉及到 Python 的引用计数的微妙难题。我们很快会详细看一下这个问题。

  • 通过继承进行扩展:这是 is-a 关系。当扩展内置类时,许多方法都可以从超类中获得。Deck_X示例通过创建一个作为内置list类扩展的牌组来展示了这种技术。

在查看对象的独立存在时,有一个重要的考虑因素。我们实际上并没有从内存中删除对象。相反,Python 使用一种称为引用计数的技术来跟踪对象被使用的次数。例如del deck这样的语句实际上并没有删除deck对象,而是删除了deck变量,这会减少底层对象的引用计数。如果引用计数为零,则对象未被使用,可以被删除。

考虑以下示例:

 **>>> c_2s = Card(2, Spades) 
>>> c_2s 
Card(rank=2, suit='♠') 
>>> another = c_2s 
>>> another 
Card(rank=2, suit='♠')** 

此时,我们有一个对象Card(2, Spades),以及两个引用该对象的变量c_2sanother

如果我们使用del语句删除其中一个变量,另一个变量仍然引用底层对象。直到两个变量都被删除,对象才能从内存中删除。

这一考虑使得聚合和组合的区别对于 Python 程序员来说基本上无关紧要。在不使用自动垃圾收集或引用计数器的语言中,组合变得重要,因为对象可能会消失。在 Python 中,对象不会意外消失。我们通常关注聚合,因为未使用的对象的删除是完全自动的。

另请参见

  • 我们已经在第四章中查看了内置集合,内置数据结构-列表、集合、字典。此外,在第六章中,类和对象的基础知识,我们已经了解了如何定义简单的集合。

  • 设计具有大量处理的类配方中,我们研究了用一个处理处理细节的单独类包装一个类。我们可以将其与第六章中的使用属性进行延迟属性配方进行对比,类和对象的基础知识,在那里我们将复杂的计算作为属性放入类中;这种设计依赖于扩展。

通过多重继承分离关注点

选择继承和扩展之间-是一个问题配方中,我们研究了定义一个Deck类的想法,它是扑克牌对象的组合。对于该示例,我们将每个Card对象简单地视为具有等级和花色。这产生了一些小问题:

  • 卡片的显示总是显示数字等级。我们没有看到 J、Q 或 K。相反,我们看到 11、12 和 13。同样,Ace 显示为 1 而不是 A。

  • 许多游戏,如BlackjackCribbage,为每个等级分配一个点值。通常,花牌有 10 点。对于 Blackjack,Ace 有两个不同的点值;取决于手中其他牌的总数,它可以值 1 点或 10 点。

我们如何处理卡牌游戏规则的所有变化?

准备好

Card类实际上是两个特征集的混合:

  1. 一些基本特性,如等级和花色。

  2. 一些特定于游戏的特性,如点数。对于Cribbage这样的游戏,无论上下文如何,点数都是一致的。然而,对于BlackjackHandHand中的Card对象之间存在关系。

Python 允许我们定义一个具有多个父类的类。一个类可以同时拥有Card超类和GameRules超类。

为了理解这种设计,我们经常将各种类层次结构分为两组特征:

  • 基本特征:这包括ranksuit

  • Mixin 特性:这些特性被混合到类定义中

这个想法是一个工作类定义将具有基本特征和 mixin 特征。

如何做…

  1. 定义基本类:
        class Card: 
            __slots__ = ('rank', 'suit') 
            def __init__(self, rank, suit): 
                super().__init__() 
                self.rank = rank 
                self.suit = suit 
            def __repr__(self): 
                return "{rank:2d} {suit}".format( 
                    rank=self.rank, suit=self.suit 
                ) 

我们已经定义了一个通用的Card类,适用于等级为 2 到 10。我们通过super().__init__()显式调用任何超类初始化。

  1. 定义任何子类来处理特殊化:
        class AceCard(Card): 
            def __repr__(self): 
                return " A {suit}".format( 
                    rank=self.rank, suit=self.suit 
                ) 
        class FaceCard(Card): 
            def __repr__(self): 
                names = {11: 'J', 12: 'Q', 13: 'K'} 
                return " {name} {suit}".format( 
                    rank=self.rank, suit=self.suit, 
                    name=names[self.rank] 
                ) 

我们已经定义了Card类的两个子类。AceCard类处理 Ace 的特殊格式规则。FaceCard类处理 Jack、Queen 和 King 的其他格式规则。

  1. 定义一个标识将要添加的附加特征的 mixin 超类。在某些情况下,mixin 将全部继承自一个共同的抽象类。在这个例子中,我们将使用一个处理 Ace 到 10 的规则的具体类:
        class CribbagePoints: 
            def points(self): 
                return self.rank 

对于Cribbage游戏,大多数卡片的点数等于卡片的等级。

  1. 为各种特征定义具体的 mixin 子类:
        class CribbageFacePoints(CribbagePoints): 
            def points(self): 
                return 10 

对于三个花色的牌,点数总是 10。

  1. 创建结合基本类和混合类的类定义。虽然在这里技术上可以添加独特的方法定义,但这经常会导致混乱。目标是有两组简单合并以创建结果类定义的特性。
        class CribbageAce(AceCard, CribbagePoints): 
            pass 

        class CribbageCard(Card, CribbagePoints): 
            pass 

        class CribbageFace(FaceCard, CribbageFacePoints): 
            pass 

  1. 创建一个工厂函数(或工厂类)来根据输入参数创建适当的对象:
        def make_card(rank, suit): 
            if rank == 1: return CribbageAce(rank, suit) 
            if 2 <= rank < 11: return CribbageCard(rank, suit) 
            if 11 <= rank: return CribbageFace(rank, suit) 

  1. 我们可以使用这个函数来创建一副牌:
 **>>> from ch07_r02 import make_card, SUITS 
      >>> import random 
      >>> random.seed(1) 
      >>> deck = [make_card(rank+1, suit) for rank in range(13) for suit in SUITS] 
      >>> random.shuffle(deck) 
      >>> len(deck) 
      52 
      >>> deck[:5] 
      [ K ♡,  3 ♡, 10 ♡,  6 ♢,  A ♢]** 

我们已经种子化了随机数生成器,以确保每次评估shuffle()函数时结果都是相同的。这使得单元测试成为可能。

我们使用列表推导来生成一个包含所有 13 个等级和四种花色的卡牌列表。这是 52 个单独的对象的集合。这些对象属于两个类层次结构。每个对象都是Card的子类,也是CribbagePoints的子类。这意味着所有对象都可以使用这两个特性集合。

例如,我们可以评估每个Card对象的points()方法:

 **>>> sum(c.points() for c in deck[:5]) 
      30** 

手中有两张花色牌,加上三、六和 A,所以总点数是30

它是如何工作的...

Python 查找方法(或属性)的机制如下:

  1. 在类中搜索方法或属性。

  2. 如果名称在当前类中未定义,则在所有父类中搜索该方法或属性。父类按照称为方法解析顺序MRO)的顺序进行搜索。

当类被创建时,方法解析顺序被计算。使用的算法称为 C3。更多信息可在en.wikipedia.org/wiki/C3_linearization找到。该算法确保每个父类只被搜索一次。它还确保了超类的相对顺序被保留,以便所有子类在任何父类之前被搜索。

我们可以使用类的mro()方法来查看方法解析顺序。这里有一个例子:

 **>>> c = deck[5] 
>>> c 
10 ♢ 
>>> c.__class__.__name__ 
'CribbageCard' 
>>> c.__class__.mro()  
[<class 'ch07_r02.CribbageCard'>, <class 'ch07_r02.Card'>, 
<class 'ch07_r02.CribbagePoints'>, <class 'object'>]** 

我们从牌堆中抽取了一张牌c。牌的__class__属性是对该类的引用。在这种情况下,类名是CribbageCard。这个类的mro()方法向我们展示了用于解析名称的顺序:

  1. 首先搜索类本身,CribbageCard

  2. 如果找不到,搜索Card

  3. 尝试在CribbagePoints中找到它。

  4. 最后使用object

类定义通常使用内部的dict对象来存储方法定义。这意味着搜索是一个非常快速的哈希查找。额外的开销差异大约是搜索object(当在任何之前的类中找不到时)比搜索Card多 3%的时间。

如果我们进行一百万次操作,我们会看到以下数字:

    Card.__repr__ 1.4413
    object.__str__ 1.4789

我们比较了查找Card中定义的__repr__()和查找object中定义的__str__()的时间。在一百万次重复中额外的时间总和是 0.03 秒。

由于成本微乎其微,这种能力是构建类层次结构设计的重要方式。

还有更多...

有几种关注点,我们可以像这样分开:

  • 持久性和状态的表示:我们可以添加方法来管理转换为一致的外部表示。

  • 安全性:这可能涉及一个执行一致授权检查的混合类,这成为每个对象的一部分。

  • 日志记录:创建一个跨多个类一致的记录器的混合类可能被定义。

  • 事件信号和变更通知:在这种情况下,我们可能有一些产生状态变化通知的对象,以及将订阅这些通知的对象。这些有时被称为可观察者和观察者设计模式。GUI 小部件可能观察对象的状态;当对象发生变化时,它会通知 GUI 小部件,以便刷新显示。

举个小例子,我们可以添加一个 mixin 来引入日志记录。我们将定义这个类,以便它必须首先在超类列表中提供。由于它在 MRO 列表中很早,super()函数将找到后面类列表中定义的方法。

这个类将为每个类添加logger属性:

    class Logged: 
        def __init__(self, *args, **kw): 
            self.logger = logging.getLogger(self.__class__.__name__) 
            super().__init__(*args, **kw) 
        def points(self): 
            p = super().points() 
            self.logger.debug("points {0}".format(p)) 
            return p 

请注意,我们已经使用super().__init__()来执行 MRO 中定义的任何其他类的__init__()方法。正如我们刚才指出的,通常最简单的方法是有一个类来定义对象的基本特征,所有其他 mixin 只是为该对象添加特性。

我们已经为points()提供了一个定义。这将在 MRO 列表中搜索其他类的points()实现。然后,它将记录另一个类的方法计算的结果。

以下是一些包含Logged mixin 特性的类:

    class LoggedCribbageAce(Logged, AceCard, CribbagePoints): 
        pass 
    class LoggedCribbageCard(Logged, Card, CribbagePoints): 
        pass 
    class LoggedCribbageFace(Logged, FaceCard, CribbageFacePoints): 
        pass 

这些类中的每一个都是由三个单独的类定义构建的。由于Logged类首先提供,我们可以确保所有类都具有一致的日志记录。我们还可以确保Logged中的任何方法都可以使用super()来定位在类定义中跟随它的超类列表中的实现。

要使用这些类,我们需要对应用程序进行一个小的改变:

    def make_logged_card(rank, suit): 
        if rank == 1: return LoggedCribbageAce(rank, suit) 
        if 2 <= rank < 11: return LoggedCribbageCard(rank, suit) 
        if 11 <= rank: return LoggedCribbageFace(rank, suit) 

我们需要使用这个函数来代替make_card()。这个函数将使用另一组类定义。

以下是我们如何使用这个函数来构建一副卡片实例:

    deck = [make_logged_card(rank+1, suit) 
        for rank in range(13) 
            for suit in SUITS] 

在创建一副牌时,我们用make_logged_card()替换了make_card()。一旦我们这样做,我们现在可以以一致的方式从多个类中获得详细的调试信息。

另请参阅

  • 在考虑多重继承时,始终要考虑包装器是否是更好的设计。参见选择继承和扩展之间的选择-是一个问题食谱。

利用 Python 的鸭子类型

大多数情况下,设计涉及继承,从超类到一个或多个子类都有一个明确的关系。在本章的选择继承和扩展之间的选择-是一个问题食谱以及第六章中的扩展集合-进行统计的列表食谱中,我们已经看到了涉及适当子类-超类关系的扩展。

Python 没有正式的抽象超类机制。然而,标准库有一个abc模块,支持创建抽象类。

然而,这并不总是必要的。Python 依赖于鸭子类型来定位类中的方法。这个名字来自这句话:

"当我看到一只像鸭子一样走路、游泳和嘎嘎叫的鸟时,我就称那只鸟为鸭子。"

这句话最初来自詹姆斯·惠特科姆·赖利。有时被视为归纳推理的总结:我们从观察到一个更完整的理论,其中包括了那个观察。在 Python 类关系的情况下,如果两个对象具有相同的方法和属性,这与具有共同的超类具有相同的效果。即使除了object类之外没有共同的超类定义,它也可以工作。

我们可以称方法和属性的集合为类的签名。签名唯一标识了类的属性和行为。在 Python 中,签名是动态的,匹配只是在对象的命名空间中查找名称。

我们能利用这个吗?

准备好

通常很容易创建一个超类,并确保所有子类都扩展了这个类。但在某些情况下,这可能会很麻烦。例如,如果一个应用程序分布在几个模块中,可能很难因素出一个共同的超类,并将其单独放在一个单独的模块中,以便可以广泛包含它。

相反,有时更容易避免共同的超类,只需检查两个类是否等效,使用鸭子测试——两个类具有相同的方法和属性,因此,它们实际上是某个没有正式实现为 Python 代码的超类的成员。

我们将使用一对简单的类来展示这是如何工作的。这些类都将模拟掷一对骰子。虽然问题很简单,但我们可以轻松地创建各种实现。

如何做...

  1. 定义一个具有所需方法和属性的类。在这个例子中,我们将有一个属性dice,它保留了上次掷骰子的结果,以及一个方法roll(),它改变了骰子的状态:
        class Dice1: 
            def __init__(self, seed=None): 
                self._rng = random.Random(seed) 
                self.roll() 
            def roll(self): 
                self.dice = (self._rng.randint(1,6), 
                    self._rng.randint(1,6)) 
                return self.dice 

  1. 定义其他具有相同方法和属性的类。以下是一个稍微复杂的定义,它创建了一个与Dice1类具有相同签名的类:
        class Die: 
            def __init__(self, rng): 
                self._rng= rng 
            def roll(self): 
                return self._rng.randint(1, 6) 
        class Dice2: 
            def __init__(self, seed=None): 
                self._rng = random.Random(seed) 
                self._dice = [Die(self._rng) for _ in range(2)] 
                self.roll() 
            def roll(self): 
                self.dice = tuple(d.roll() for d in self._dice) 
                return self.dice 

这个类引入了一个额外的属性,_dice。这种实现上的改变并不会改变单个属性dice和方法roll()的公开接口。

在这一点上,这两个类可以自由交换:

    def roller(dice_class, seed=None, *, samples=10): 
        dice = dice_class(seed) 
        for _ in range(samples): 
            yield dice.roll() 

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

 **>>> from ch07_r03 import roller, Dice1, Dice2 
>>> list(roller(Dice1, 1, samples=5)) 
[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)] 
>>> list(roller(Dice2, 1, samples=5)) 
[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)]** 

Dice1Dice2构建的对象有足够的相似之处,以至于它们是无法区分的。

当然,我们可以推动边界,并寻找_dice属性作为区分两个类的方法。我们也可以使用__class__来区分这两个类。

它是如何工作的...

当我们编写形式为namespace.name的表达式时,Python 将在给定的命名空间中查找名称。算法的工作方式如下:

  1. 搜索对象的self.__dict__集合以查找名称。一些类定义将节省空间,使用__slots__。有关此优化的更多信息,请参阅第六章中的使用 slots 优化小对象类和对象的基础知识。这通常是如何找到属性值的。

  2. 搜索对象的self.__class__.__dict__集合以查找名称。这通常是方法被找到的方式。

  3. 正如我们在选择继承和扩展之间的区别——is-a 问题通过多重继承分离关注点中所指出的,搜索可以继续通过类的所有超类。这个搜索是按照定义的方法解析顺序进行的。

有两个基本的结果:

  • 该值是一个不可调用的对象。这就是值。这是属性的典型特征。

  • 属性的值是类的绑定方法。这对于普通方法和属性都是正确的。有关属性的更多信息,请参阅第六章中的使用属性进行惰性属性类和对象的基础知识。绑定方法必须被评估。对于简单的方法,参数在方法名称后的()中。对于属性,没有带有方法参数值的()

注意

我们省略了一些关于如何使用描述符的细节。对于最常见的用例,描述符的存在并不重要。

这的本质是通过__dict__(或__slots__)名称集合进行搜索。如果对象有一个共同的超类,那么我们可以保证会找到匹配的名称。如果对象没有共同的超类,那么我们就没有同样的保证。我们必须依赖纪律性的设计和良好的测试覆盖率。

还有更多...

当我们查看 decimal 模块时,我们看到了一个与所有其他数值类型不同的数值类型的例子。为了使其正常工作,numbers 模块包括了将类注册为 Number 类层次结构的一部分的概念。这样可以在不使用继承的情况下将一个新类注入到层次结构中。

codecs 模块使用类似的技术来添加新的数据编码。我们可以定义一个新的编码并将其注册,而不使用 codecs 模块中定义的任何类。

之前,我们注意到类的方法的搜索涉及描述符的概念。在内部,Python 使用描述符对象来创建对象的可获取和可设置属性。

描述符对象必须实现一些特殊方法 __get____set____delete__ 的组合。当属性出现在表达式中时,将使用 __get__ 来定位值。当属性出现在赋值的左侧时,将使用 __set__。在 del 语句中,将使用 __delete__ 方法。

描述符对象充当中介,以便一个简单的属性可以在各种上下文中使用。很少直接使用描述符。我们可以使用 @property 装饰器为我们构建描述符。

另请参阅

  • 鸭子类型问题在选择继承和扩展之间——is-a 问题的示例中是隐含的;如果我们利用鸭子类型,我们也在声称两个类不是同一种东西。当我们绕过继承时,我们隐含地声称 is-a 关系不成立。

  • 当查看通过多重继承分离关注点的示例时,我们还可以利用鸭子类型来创建可能没有简单继承层次结构的组合类。由于使用混合设计模式非常简单,很少需要使用鸭子类型。

管理全局和单例对象

Python 环境包含许多隐式全局对象。这些对象提供了一种方便的方式来处理其他对象的集合。由于集合是隐式的,我们不必写任何显式的初始化代码,从而避免了麻烦。

其中一个例子是 random 模块中的一个隐式随机数生成对象。当我们评估 random.random() 时,实际上是在使用 random 模块中隐式的 random.Random 类的一个实例。

其他例子包括以下内容:

  • 可用的数值类型的集合。默认情况下,我们只有 intfloatcomplex 。但是,我们可以添加更多的数值类型,并且它们将与现有类型无缝配合。有一个可用数值类型的全局注册表。

  • 可用的数据编码/解码方法(编解码器)的集合。codecs 模块列出了可用的编码器和解码器。这也涉及到一个隐式注册。我们可以向这个注册表中添加编码和解码。

  • webbrowser 模块有一个已知浏览器的注册表。在大多数情况下,操作系统默认浏览器是用户首选的浏览器,也是要使用的正确的浏览器,但应用程序也可以启动用户首选浏览器之外的浏览器。还可以注册一个新的浏览器,该浏览器是特定应用程序的唯一浏览器。

我们如何处理这种隐式全局对象?

准备工作

通常,隐式对象可能会引起一些混淆。想法是提供一套功能作为独立的函数,而不是对象的方法。然而,好处是允许独立的模块共享一个公共对象,而无需编写任何显式协调模块之间的代码。

举个简单的例子,我们将定义一个具有全局单例对象的模块。我们将在第十三章中更详细地了解模块,应用集成

我们的全局对象将是一个计数器,我们可以用它来积累来自几个独立模块或对象的集中数据。我们将使用简单的函数来提供对这个对象的接口。

目标是能够编写类似这样的内容:

    for row in source: 
        count('input') 
        some_processing() 
    print(counts()) 

这意味着会有两个函数引用一个全局计数器:

  • count():它将增加计数器并返回当前值

  • counts():它将提供所有不同的计数器值

如何做...

有两种处理全局状态信息的方法。一种技术使用模块全局变量,因为模块是单例对象。另一种使用类级(静态)变量,因为类定义也是单例对象,我们将展示这两种技术。

模块全局变量

  1. 创建一个模块文件。这将是一个.py文件,其中包含定义。我们将其称为counter.py

  2. 如果有必要,为全局单例定义一个类。在我们的例子中,我们可以使用这个定义:

        from collections import Counter 

在某些情况下,可能会使用types.SimpleNamespace。在其他情况下,可能需要一个更复杂的类,其中包括方法和属性。

  1. 定义全局单例对象的唯一实例:
        _global_counter = Counter() 

我们在名称中使用了一个前导_,使其稍微不太可见。它不是——技术上——私有的。然而,它被许多 Python 工具和实用程序优雅地忽略了。

  1. 定义任何包装函数:
        def count(key, increment=1): 
            _global_counter[key] += increment 
        def counts(): 
            return _global_counter.most_common() 

我们定义了两个使用全局对象_global_counter的函数。这些函数封装了计数器的实现细节。

现在我们可以编写应用程序,在各种地方使用count()函数。然而,计数的事件完全集中在这个单一对象中。

我们可能有这样的代码:

 **>>> from ch07_r04 import count, counts 
>>> from ch07_r03 import Dice1 
>>> d = Dice1(1) 
>>> for _ in range(1000): 
...     if sum(d.roll()) == 7: count('seven') 
...     else: count('other') 
>>> print(counts()) 
[('other', 833), ('seven', 167)]** 

我们从一个中央模块导入了count()counts()函数。我们还导入了Dice1对象作为一个方便的对象,我们可以用它来创建一系列事件。当我们创建Dice1的一个实例时,我们提供一个初始化来强制使用特定的随机种子。这可以得到可重复的结果。

然后我们可以使用对象d来创建随机事件。在这个演示中,我们将事件分类为两个简单的桶,标记为sevenothercount()函数使用了一个隐含的全局对象。

当模拟完成时,我们可以使用counts()函数来输出结果。这将访问模块中定义的全局对象。

这种技术的好处是,几个模块都可以共享ch07_r04模块中的全局对象。只需要一个import语句。不需要进一步的协调或开销。

类级静态变量

  1. 定义一个类并在__init__方法之外提供一个变量。这个变量是类的一部分,而不是每个单独实例的一部分。它被所有类的实例共享:
        from collections import Counter 
        class EventCounter: 
            _counts = Counter() 

我们给类级变量加了一个前导下划线,使其不太公开。这是对使用类的任何人的一个提示,该属性是一个可能会改变的实现细节。它不是类的可见接口的一部分。

  1. 添加方法来更新和提取这个变量的数据:
        def count(self, key, increment=1): 
            EventCounter._counts[key] += increment 
        def counts(self): 
            return EventCounter._counts.most_common() 

我们在这个例子中没有使用self,是为了说明变量赋值和实例变量。当我们在赋值语句的右侧使用self.name时,名称可以由对象、类或任何超类解析。这是搜索类的普通规则。

当我们在赋值语句的左侧使用self.name时,那将创建一个实例变量。我们必须使用Class.name来确保更新类级变量,而不是创建一个实例变量。

各种应用程序组件可以创建对象,但所有对象都共享一个公共类级值:

>>> from ch07_r04 import EventCounter 
>>> c1 = EventCounter() 
>>> c1.count('input') 
>>> c2 = EventCounter() 
>>> c2.count('input') 
>>> c3 = EventCounter() 
>>> c3.counts() 
[('input', 2)] 

在这个示例中,我们创建了三个单独的对象,c1c2c3。由于所有三个对象共享EventCounter类中定义的一个公共变量,因此每个对象都可以用于增加该共享变量。这些对象可以是单独的模块、单独的类或单独的函数的一部分,但仍然共享一个共同的全局状态。

它是如何工作的...

Python 导入机制使用sys.modules来跟踪加载了哪些模块。一旦模块在这个映射中,它就不会再次加载。这意味着在模块内定义的任何变量都将是单例:只会有一个实例。

我们有两种方法来共享这些全局单例变量:

  • 显式使用模块名称。我们本可以在模块中简单地创建Counter的实例,并通过counter.counter共享它。这样可以工作,但它暴露了一个实现细节。

  • 使用包装函数,如本示例所示。这需要更多的代码,但它允许在不破坏应用程序的其他部分的情况下进行实现的更改。

这些函数提供了一种识别全局变量相关特征的方式,同时封装了它的实现细节。这使我们有自由考虑改变实现细节的自由。只要包装函数具有相同的语义,实现就可以自由更改。

由于我们通常只提供一个类的定义,Python 导入机制倾向于向我们保证类定义是一个正确的单例对象。如果我们错误地复制一个类定义,并将其粘贴到单个应用程序使用的两个或更多模块中,我们将不会在这些类定义之间共享一个全局对象。这是一个容易避免的错误。

我们如何在这两种机制之间进行选择?选择是基于多个类共享全局状态所造成的混乱程度。如前面的示例所示,三个变量共享一个公共的Counter对象。隐式共享全局状态的存在可能会令人困惑。

还有更多...

共享全局状态在某种程度上与面向对象编程相反。面向对象编程的一个理想是将所有状态变化封装在各个对象中。当我们有一个共享的全局状态时,我们已经偏离了这个理想:

  • 使用包装函数使共享对象变得隐式

  • 使用类级变量隐藏了对象是共享的事实

当然,另一种选择是显式地创建一个全局对象,并以一种更明显的方式将其作为应用程序的一部分。这可能意味着将对象作为初始化参数提供给整个应用程序中的对象。在复杂的应用程序中,这可能是一个相当大的负担。

拥有一些共享的全局对象更具吸引力,因为应用程序变得更简单。当这些对象用于普遍特性,如审计、日志记录和安全性时,它们可能会有所帮助。

这是一种容易被滥用的技术。依赖过多全局对象的设计可能会令人困惑。它也可能存在微妙的错误,因为在类中封装对象可能难以辨别。它也可能使单元测试用例难以编写,因为对象之间的隐式关系。

使用更复杂的结构 - 列表的映射

在第四章中,内置数据结构 - 列表,集合,字典,我们看了 Python 中可用的基本数据结构。这些示例通常独立地查看了各种结构。

我们将看一下一个常见的组合结构 - 从一个键到一个列表的映射。这用于累积有关由给定键标识的对象的详细信息。这个示例将把详细信息的平面列表转换成一个结构,其中一个列包含来自其他列的值。

准备工作

我们将使用一个虚构的网络日志,它已经从原始网络格式转换为 CSV(逗号分隔值)格式。这种转换通常是通过使用正则表达式来选择各种句法组完成的。有关解析可能如何工作的信息,请参阅第一章中的使用正则表达式解析字符串配方,数字、字符串和元组

原始数据看起来像这样:

 **[2016-04-24 11:05:01,462] INFO in module1: Sample Message One** 

 **[2016-04-24 11:06:02,624] DEBUG in module2: Debugging** 

 **[2016-04-24 11:07:03,246] WARNING in module1: Something might have gone wrong** 

文件中的每一行都有一个时间戳、一个严重级别、一个模块名称和一些文本。解析后,数据实际上是一个事件的平面列表。它看起来像这样:

 **>>> data = [ 
    ('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'), 
    ('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging'), 
    ('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong') 
]** 

我们想要检查日志,创建一个按模块组织的所有消息的列表,而不是按时间顺序。这种重组可以使分析更简单。

操作方法...

  1. collections导入defaultdict
        from collections import defaultdict 

  1. 使用list函数作为defaultdict的默认值:
        module_details = defaultdict(list) 

  1. 通过数据进行迭代,将其附加到与每个键关联的列表中。defaultdict对象将使用list()函数为每个新键构建一个空列表:
        for row in data: 
            module_details[row[2]].append(row) 

这将产生一个从模块到该模块名称的所有日志行的列表的字典。数据看起来像这样:

    { 
        'module1': [ 
            ('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'), 
            ('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong') 
            ], 
        'module2': [ 
            ('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging') 
        ] 
    } 

该映射的键是模块名称,映射中的值是该模块名称的行列表。现在我们可以专注于特定模块的分析。

工作原理...

当键未找到时,映射的行为有两种选择:

  • 内置的dict类在键丢失时会引发异常。

  • defaultdict类在键丢失时会评估一个创建默认值的函数。在许多情况下,该函数是intfloat,用于创建默认的数值。在这种情况下,该函数是list,用于创建一个空列表。

我们可以想象使用set函数为缺少的键创建一个空的set对象。这适用于从键到共享该键的对象集的映射。

还有更多...

当我们考虑 Python 3.5 和进行类型推断的能力时,我们需要有一种描述这种结构的方法:

    from typing import * 
    def summarize(data) -> Mapping[str, List]: 
        the body of the function. 

这使用符号Mapping[str, List]来显示结果是从字符串键到字符串数据项列表的映射。

我们还可以构建一个作为内置dict类的扩展版本:

    class ModuleEvents(dict): 
        def add_event(self, event): 
            if event[2] not in self: 
                self[event[2]] = list() 
            self[event[2]].append(row) 

我们已经定义了一个对这个类独有的方法add_event()。如果字典中当前不存在event[2]中的模块名称的键,则将添加空列表。在if语句之后,可以添加一个后置条件来断言该键现在在字典中。

这使我们能够使用以下代码:

    module_details = ModuleEvents() 
    for row in data: 
        module_details.add_event(row) 

结果结构与defaultdict非常相似。

另请参阅

  • 在第四章的创建字典 - 插入和更新配方中,我们看到了使用映射的基础知识

  • 在第四章的避免函数参数的可变默认值配方中,我们看到了其他使用默认值的地方

  • 在第六章的使用更复杂的集合配方中,我们看到了使用defaultdict类的其他示例

创建一个具有可排序对象的类

在模拟纸牌游戏时,能够将Card对象排序到一个定义的顺序中通常是至关重要的。当卡片形成一个序列时,有时被称为顺子,这可能是评分手的重要方式。这是类似扑克牌、Cribbage 甚至 Pinochle 的游戏的一部分。

我们的大多数类定义都没有包括对将对象排序的必要特征。许多示例都将对象保留在基于__hash__()计算的内部哈希值的映射或集合中。

为了将项目保留在有序集合中,我们需要实现<><=>===!=的比较方法。这些比较是基于每个对象的属性值。

我们如何创建可比较的对象?

准备工作

Pinochle 游戏通常涉及一副有 48 张牌的牌组。有六个等级——9、10、J、Q、K 和 A。有标准的四种花色。这 24 张牌中的每一张在牌组中都出现两次。我们必须小心使用诸如 dict 或 set 之类的结构,因为在 Pinochle 中卡片并不是唯一的;可能会有重复。

通过多重继承分离关注点的示例中,我们使用了两个类定义来定义纸牌。Card类层次结构定义了每张牌的基本特征。第二组混合类为每张牌提供了特定于游戏的特征。

我们需要为这些牌添加特征,以创建可以正确排序的对象。为了支持定义有序集合的示例,我们将研究 Pinochle 游戏的牌。

以下是设计的前两个元素:

    from ch07_r02 import AceCard, Card, FaceCard, SUITS 
    class PinochlePoints: 
        _points = {9: 0, 10:10, 11:2, 12:3, 13:4, 14:11} 
        def points(self): 
            return self._points[self.rank] 

我们已经导入了现有的Card层次结构。我们还定义了在玩牌过程中计算每张牌得分的规则,PinochlePoints类。这个类有一个从卡片等级到每张卡片可能令人困惑的点数的映射。

10 点值 10 分,A 值 11 分,但 K、J 和 Q 分别值 4、3 和 2 分。这可能会让新玩家感到困惑。

因为 A 的排名在识别顺子的目的上高于 K,所以我们将 A 的排名设为 14。这在一定程度上简化了处理过程。

为了使用有序的卡片集合,我们需要为卡片添加另一个特征。我们需要定义比较操作。用于对象比较的有六个特殊方法。

如何做...

  1. 我们正在使用混合设计。因此,我们将创建一个新的类来保存比较特征:
        class SortedCard: 

这个类将加入Card层次结构的成员加上PinochlePoints,以创建最终的复合类定义。

  1. 定义六个比较方法:
        def __lt__(self, other): 
            return (self.rank, self.suit) < (other.rank, other.suit) 

        def __le__(self, other): 
            return (self.rank, self.suit) <= (other.rank, other.suit) 

        def __gt__(self, other): 
            return (self.rank, self.suit) > (other.rank, other.suit) 

        def __ge__(self, other): 
            return (self.rank, self.suit) >= (other.rank, other.suit) 

        def __eq__(self, other): 
            return (self.rank, self.suit) == (other.rank, other.suit) 

        def __ne__(self, other): 
            return (self.rank, self.suit) != (other.rank, other.suit) 

我们已经完整地写出了所有六个比较。我们将Card的相关属性转换为元组,并依赖于 Python 的内置元组比较来处理细节。

  1. 编写复合类定义,由一个基本类和两个混合类构建以提供额外特征:
        class PinochleAce(AceCard, SortedCard, PinochlePoints): 
            pass 

        class PinochleFace(FaceCard, SortedCard, PinochlePoints): 
            pass 

        class PinochleNumber(Card, SortedCard, PinochlePoints): 
            pass 

最终的类包含具有三个独立且大部分独立的特征集的元素:基本的Card特征,混合比较特征和混合 Pinochle 特定特征。

  1. 创建一个函数,从先前定义的类中创建单独的卡片对象:
        def make_card(rank, suit): 
            if rank in (9, 10): 
                return PinochleNumber(rank, suit) 
            elif rank in (11, 12, 13): 
                return PinochleFace(rank, suit) 
            else: 
                return PinochleAce(rank, suit) 

尽管点数规则非常复杂,但复杂性隐藏在PinochlePoints类中。构建复合类作为CardPinochlePoints的基类子类会导致对牌的准确建模,而不会有太多明显的复杂性。

现在我们可以制作可以响应比较运算符的卡片:

 **>>> from ch07_r06a import make_card 
>>> c1 = make_card(9, '♡') 
>>> c2 = make_card(10, '♡') 
>>> c1 < c2 
True 
>>> c1 == c1 
True 
>>> c1 == c2 
False 
>>> c1 > c2 
False** 

这是一个构建 48 张牌的牌组的函数:

    SUITS = '\u2660\u2661\u2662\u2663' 
    Spades, Hearts, Diamonds, Clubs = SUITS 
    def make_deck(): 
        return [make_card(r, s) for _ in range(2) 
            for r in range(9, 15) 
            for s in SUITS] 

SUITS的值是四个 Unicode 字符。我们本可以分别设置每个花色字符串,但这样似乎稍微简单一些。make_deck()函数内部的生成器表达式构建了每张牌的两份副本。只有六种等级和四种花色。

工作原理...

Python 为大量的事情使用特殊方法。语言中几乎每个可见的行为都归因于某些特殊方法名称。在这个示例中,我们利用了六个比较运算符。

写下以下内容:

    c1 <= c2 

前面的代码被评估为我们写了以下内容:

    c1.__le__(c2) 

这种转换适用于所有表达式运算符。

仔细研究Python 语言参考第 3.3 节表明,特殊方法可以组织成几个不同的组:

  • 基本定制

  • 自定义属性访问

  • 自定义类创建

  • 自定义实例和子类检查

  • 模拟可调用对象

  • 模拟容器类型

  • 模拟数值类型

  • 使用语句上下文管理器

在这个配方中,我们只看了这些类别中的第一个。其他的遵循一些类似的设计模式。

当我们创建这个类层次结构的实例时,它看起来是这样的。第一个例子将创建一个 48 张牌的 Pinochle 牌组:

 **>>> from ch07_r06a import make_deck 
>>> deck = make_deck() 
>>> len(deck) 
48** 

如果我们看一下前八张牌,我们可以看到它们是如何由所有等级和花色的组合构建而成的:

 **>>> deck[:8] 
[ 9 ♠,  9 ♡,  9 ♢,  9 ♣, 10 ♠, 10 ♡, 10 ♢, 10 ♣]** 

如果我们看一下牌组的后半部分,我们会发现它与牌组的前半部分是一样的:

 **>>> deck[24:32] 
[ 9 ♠,  9 ♡,  9 ♢,  9 ♣, 10 ♠, 10 ♡, 10 ♢, 10 ♣]** 

由于deck变量是一个简单的列表,我们可以打乱列表对象并选择十二张牌。

 **>>> import random 
>>> random.seed(4) 
>>> random.shuffle(deck) 
>>> sorted(deck[:12]) 
[ 9 ♣, 10 ♣,  J ♠,  J ♢,  J ♢,  Q ♠,  Q ♣,  K ♠,  K ♠,  K ♣, A ♡,  A ♣]** 

重要的部分是使用sorted()函数。因为我们已经定义了适当的比较运算符,我们可以对Card实例进行排序,并按预期顺序呈现。

还有更多...

一点形式逻辑表明,我们实际上只需要实现两种比较。对于任何两个,其他所有的都可以推导出来。例如,如果我们只能执行小于(__lt__() )和等于(__eq__() )的操作,我们可以相当容易地计算出其余的三个:

aba < ba = b

aba > ba = b

ab ≡ ¬(a = b)

Python 明确不会为我们执行任何这种高级代数。我们需要仔细进行代数运算,或者如果我们对逻辑不确定,可以完整地写出所有六个比较。

我们假设每个Card都与另一张卡进行比较。试试这个:

 **>>> c1 = make_card(9, '♡') 
>>> c1 == 9** 

我们将得到一个AttributeError异常。

如果我们需要这个功能,我们将不得不修改比较运算符来处理两种比较:

  • CardCard

  • Cardint

这是通过使用isinstance()函数来区分参数类型来完成的。

我们的每个比较方法将被更改为这样:

    def __lt__(self, other): 
        if isinstance(other, Card): 
            return (self.rank, self.suit) < (other.rank, other.suit) 
        else: 
            return self.rank < other 

这处理了CardCard之间的情况,使用等级和花色进行比较。对于所有其他情况,Python 使用普通的规则来将等级与其他值进行比较。如果由于某种隐晦的原因,另一个值是float,那么将在self.rank上使用float()转换。

另请参阅

  • 查看依赖于对这些卡进行排序的定义有序集合配方

定义一个有序集合

在模拟纸牌游戏时,玩家的手可以被建模为一组牌或一叠牌。对于大多数传统的单副牌游戏,集合是很好的选择,因为任何给定的牌只有一个实例,并且集合类可以非常快速地执行操作来确认给定的牌是否在集合中(或不在)。

然而,在建模 Pinochle 时,我们面临一个具有挑战性的问题。Pinochle 牌组有 48 张牌;它有两张 9、10、J、Q、K 和 A。简单的集合对于这个不太适用;我们需要一个多重集或袋子。这是一个允许重复项的集合。

这些操作仍然仅限于成员测试。例如,我们可以多次添加对象Card(9,'♢')对象,然后多次删除它。

我们有多种方法来创建多重集:

  • 我们可以使用列表。添加一个项目几乎是固定成本,被描述为O(1)。搜索项目的性能有严重问题。测试成员资格的复杂性往往随着集合大小的增长而增长。它变成了O(n)。

  • 我们可以使用映射;值可以是重复元素出现的整数计数。这只需要映射中每个对象都有默认的__hash__()方法。我们有三种实现这种方法的方式:

  • 定义我们自己的 dict 子类。

  • 使用defaultdict。请参阅使用更复杂的结构-列表映射示例,该示例使用defaultdict(list)为每个键创建一个值列表。该列表的len()是键出现的次数。实际上,这是一种多重集。

  • 使用Counter。这可以非常简单。我们已经在许多示例中看过Counter。请参阅第四章中的避免函数参数的可变默认值内置数据结构-列表、集合、字典,以及第六章中的设计具有大量处理的类使用属性进行惰性属性类和对象的基础知识,以及本章的管理全局和单例对象示例。

  • 我们可以使用有序列表。插入维护此排序顺序的项目比插入列表稍微昂贵,O ( n log [2] n )。然而,搜索比无序列表便宜;它是O (log [2] n )。bisect模块提供了一组很好地执行此操作的函数。然而,这需要具有完整比较方法集的对象。

我们如何构建一个有序对象的有序集合?如何使用有序集合构建多重集或袋?

准备就绪

创建具有可排序对象的类示例中,我们定义了可以排序的卡片。这对于使用bisect至关重要。该模块中的算法要求对象之间进行全面的比较。

我们将定义一个多重集,以保留 12 张 Pinochle 手牌。由于重复,同一等级和花色的卡片将会有多张。

为了将手牌视为一种集合,我们还需要在手牌对象上定义一些集合运算符。其思想是定义集合成员和子集运算符。

我们希望有 Python 代码等效于以下内容:

cH

这是针对一张卡片c和一手牌H={ c [1] , c [2] , c [3] ,... }。

我们还希望有与此等效的代码:

{ J, Q } ⊂ H

这是针对一对特定的卡片,称为 Pinochle,以及一手牌,H

我们需要导入两样东西:

    from ch07_r06a import * 
    import bisect 

第一个导入将我们可排序的卡片定义从创建具有可排序对象的类示例中引入。第二个导入将我们将用来维护一个有序集合的各种 bisect 函数引入。

如何做...

  1. 定义一个类,其中初始化可以从任何可迭代的数据源加载集合:
        class Hand: 
            def __init__(self, card_iter): 
                self.cards = list(card_iter) 
                self.cards.sort() 

我们可以使用这个从列表或可能是生成器表达式构建一个Hand。如果列表不为空,我们需要将项目排序。self.cards列表的sort()方法将依赖于Card对象实现的各种比较运算符。

从技术上讲,我们只关心那些是SortedCard的子类的对象,因为这是定义比较方法的地方。

  1. 定义一个将卡片添加到手牌的方法:
        def add(self, aCard: Card): 
            bisect.insort(self.cards, aCard) 

我们使用bisect算法来确保卡片被正确插入到self.cards列表中。

  1. 定义一个查找给定卡片在手牌中位置的方法:
        def index(self, aCard: Card): 
            i = bisect.bisect_left(self.cards, aCard) 
            if i != len(self.cards) and self.cards[i] == aCard: 
                return i 
            raise ValueError 

我们使用bisect算法来定位给定的卡片。建议在bisect.bisect_left()的文档中使用额外的if测试来正确处理处理中的边缘情况。

  1. 定义实现in运算符的特殊方法:
        def __contains__(self, aCard: Card): 
            try: 
                self.index(aCard) 
                return True 
            except ValueError: 
                return False 

当我们在 Python 中编写card in some_hand时,它会被计算为如果我们编写了some_hand.__contains__(card)。我们使用index()方法来查找卡片或引发异常。异常被转换为False的返回值。

  1. 定义手牌上的迭代器。这只是对self.cards集合的简单委托:
        def __iter__(self): 
            return iter(self.cards) 

当我们在 Python 中编写iter(some_hand)时,它会被计算为如果我们编写了some_hand.__iter__()

  1. 在两个手实例之间定义一个子集操作:
        def __le__(self, other): 
            for card in self: 
                if card not in other: 
                    return False 
            return True 

Python 没有abab符号,因此<和<=被用来比较集合。当我们写pinochle <= some_hand来查看手中是否包含特定的卡片组合时,它被评估为如果我们写了pinochle.__le__(some_hand)。子集是 self 实例变量,目标 Hand 是另一个参数值。

in 运算符由 contains()方法实现。这显示了简单的 Python 语法是如何由特殊方法实现的。

我们可以像这样使用这个 Hand 类:

 **>>> from ch07_r06b import make_deck, make_card, Hand 
>>> import random 
>>> random.seed(4) 
>>> deck = make_deck() 
>>> random.shuffle(deck) 
>>> h = Hand(deck[:12]) 
>>> h.cards 
[ 9 ♣, 10 ♣,  J ♠,  J ♢,  J ♢,  Q ♠,  Q ♣,  K ♠,  K ♠,  K ♣, A ♡,  A ♣]** 

卡片在手中被正确排序。这是手的创建方式的结果。

以下是使用子集运算符<=将特定模式与整个手进行比较的示例:

 **>>> pinochle = Hand([make_card(11,'♢'), make_card(12,'♠')]) 
>>> pinochle <= h 
True** 

手是一个集合,并支持迭代。我们可以使用引用整个手中的卡对象的生成器表达式:

 **>>> sum(c.points() for c in h) 
56** 

它是如何工作的...

我们的 Hand 集合通过包装内部的 list 对象并对该对象应用重要的约束来工作。项目按排序顺序保留。这增加了插入新项目的成本,但减少了搜索项目的成本。

用于查找项目位置的核心算法是bisect模块的一部分,这样我们就不必编写(和调试)它们。这些算法实际上并不是非常复杂。但利用现有代码似乎更有效。

该模块的名称来自于对排序列表进行二分查找的想法。其本质是:

    while lo < hi: 
        mid = (lo+hi)//2 
        if x < a[mid]: hi = mid 
        else: lo = mid+1 

这将搜索列表a以查找给定值xlo的值最初为零,hi的值最初为列表的大小len(a)

首先,确定中点。如果目标值x小于中点值a[mid],那么它必须在列表的前半部分:将hi的值移动,以便只考虑前半部分。

如果目标值x大于或等于中点值a[mid],那么x必须在列表的后半部分:将lo的值移动,以便只考虑后半部分。

由于每次操作都会将列表减半,因此需要O(log[2]n)步才能使 lo 和 hi 的值收敛到应该具有目标值的位置。

如果我们有一个有 12 张卡的手,那么第一个比较会丢弃六张。下一个比较会再丢弃三张。下一个比较会丢弃最后三张中的一张。第四个比较将找到卡片应该占据的位置。

如果我们使用普通列表,卡片按到达的随机顺序存储,那么找到一张卡片将平均需要六次比较。最坏的情况意味着它是 12 张卡片中的最后一张,需要检查所有 12 张。

使用bisect,比较的次数始终是O(log[2]n)。这是平均值和最坏情况。

还有更多...

collections.abc模块为各种集合定义了抽象基类。如果我们希望我们的 Hand 表现得更像其他类型的集合,我们可以利用这些定义。

我们可以在这个类定义中添加许多集合运算符,使其更像内置的MutableSet抽象类定义。

MutableSetSet的扩展。Set类是由三个类定义构建的复合类:SizedIterableContainer。这意味着它必须定义以下方法:

  • __contains__()

  • __iter__()

  • __len__()

  • add()

  • discard()

我们还需要提供一些其他作为可变集合的方法:

  • clear()pop():这些将从集合中删除项目。

  • remove():与discard()不同,当尝试删除缺失的项目时,这将引发异常。

为了具有唯一的集合特性,还需要一些额外的方法。我们提供了一个基于 le()的子集的示例。我们还需要提供以下子集比较:

  • __le__()

  • __lt__()

  • __eq__()

  • __ne__()

  • __gt__()

  • __ge__()

  • isdisjoint()

这些通常不是简单的一行定义。为了实现核心比较集,我们经常会写两个,然后使用逻辑来基于这两个构建其余部分。

由于__eq__()很简单,让我们假设我们已经为==<=运算符定义了定义。其他的将定义如下:

xy ≡ ¬( x = y )

x < y ≡ ( xy ) ∧ ¬( x = y )

x > y ≡ ¬( xy )

xy ≡ ¬( x < y ) ≡ ¬( xy ) ∨ ( x = y )

为了进行集合操作,我们需要提供以下内容:

  • __and__()__iand__()。这些方法实现了 Python 的&运算符和&=赋值语句。在两个集合之间,这是一个集合的交集,或者ab

  • __or__()__ior__()。这些方法实现了 Python 的|运算符和|=赋值语句。在两个集合之间,这是一个集合的并集,或者ab

  • __sub__()__isub__()。这些方法实现了 Python 的-运算符和-=赋值语句。在集合之间,这是一个集合的差,通常写作a - b

  • __xor__()__ixor__()。这些方法实现了 Python 的^运算符和^=赋值语句。当应用于两个集合之间时,这是对称差,通常写作ab

抽象类允许每个运算符有两个版本。有两种情况:

  • 如果我们提供了__iand__(),那么语句A &= B将被计算为A.__iand__(B)。这可能会允许有效的实现。

  • 如果我们不提供__iand__(),那么语句A &= B将被计算为A = A.__and__(B)。这可能会有点不太高效,因为我们将创建一个新对象。新对象被标记为A,旧对象将从内存中删除。

几乎需要两打方法来为内置的集合类提供适当的替代。一方面,这是大量的代码。另一方面,Python 让我们以透明的方式扩展内置类,并使用相同的语义和操作符。

另请参阅

  • 查看创建一个具有可排序对象的类配方,以获取定义 Pinochle 卡的伴侣配方

从映射列表中删除

从列表中删除项目会产生有趣的后果。具体来说,当删除项目list[x]时,将会发生以下两种情况之一:

  • 项目list[x+1]取代了list[x]

  • 项目x+1 == len(list)取代了list[x],因为x是列表中的最后一个索引

这些是除了删除项目之外发生的副作用。因为列表中的项目可能会移动,所以一次删除多个项目变得更具挑战性。

当列表包含具有__eq__()特殊方法定义的项目时,列表remove()方法可以删除每个项目。当列表项没有简单的__eq__()测试时,从列表中删除多个项目就变得更具挑战性。

我们如何从列表中删除多个项目?

准备工作

我们将使用一个字典列表结构。在这种情况下,我们有一些包括歌曲名称、作者和持续时间的数据。数据看起来像这样:

 **>>> source = [ 
...    {'title': 'Eruption', 'writer': ['Emerson'], 'time': '2:43'}, 
...    {'title': 'Stones of Years', 'writer': ['Emerson', 'Lake'], 'time': '3:43'}, 
...    {'title': 'Iconoclast', 'writer': ['Emerson'], 'time': '1:16'}, 
...    {'title': 'Mass', 'writer': ['Emerson', 'Lake'], 'time': '3:09'}, 
...    {'title': 'Manticore', 'writer': ['Emerson'], 'time': '1:49'}, 
...    {'title': 'Battlefield', 'writer': ['Lake'], 'time': '3:57'}, 
...    {'title': 'Aquatarkus', 'writer': ['Emerson'], 'time': '3:54'} 
... ]** 

要使用这种数据结构,我们需要pprint函数:

 **>>> from pprint import pprint** 

我们可以很容易地使用for语句遍历值列表。问题是,我们如何删除选定的项目?

 **>>> data = source.copy() 
>>> for item in data: 
...     if 'Lake' in item['writer']: 
...        print("remove", item['title']) 
remove Stones of Years 
remove Mass 
remove Battlefield** 

我们不能简单地在这里使用语句del item,因为它对源集合data没有影响。这个语句只会通过删除item变量和相关对象来删除原始列表中项目的本地变量副本。

要正确地从列表中删除项目,我们必须使用列表中的索引位置。这是一个天真的方法,绝对行不通:

 **>>> data = source.copy() 
>>> for index in range(len(data)):  
...    if 'Lake' in data[index]['writer']: 
...       del data[index] 
Traceback (most recent call last): 
  File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/doctest.py", line 1320, in __run 
    compileflags, 1), test.globs) 
  File "<doctest __main__.__test__.chapter[5]>", line 2, in <module> 
    if 'Lake' in data[index]['writer']: 
IndexError: list index out of range** 

不能简单地使用range(len(data))基于列表的原始大小。随着项目的移除,列表变得更小。索引的值将被设置为一个太大的值。

当删除具有简单相等测试的简单项目时,我们将使用类似这样的东西:

    while x in list: 
        list.remove(x) 

问题在于我们没有一个__contains__()的实现,它可以识别item['writer']中带有Lake的项目。我们可以使用 dict 的子类来实现__eq__(),作为self['writer']中的字符串参数值。这显然违反了相等性的语义,因为它只检查一个字段。

我们不能扩展这些类的内置特性。这里的用例非常特定于问题域,而不是字典结构的一般特性。

为了与基本的while in...remove循环并行,我们需要写出类似这样的东西:

 **>>> def index(data): 
...    for i in range(len(data)): 
...        if 'Lake' in data[i]['writer']: 
...            return i 
>>> data = source.copy() 
>>> position = index(data) 
>>> while position: 
...    del data[position] # or data.pop(position) 
...    position = index(data)** 

我们编写了一个名为index()的函数,它定位目标值的第一个实例。这个函数的结果是一个提供两种信息的单个值:

  • 当返回的值不是None时,该项目存在于列表中

  • 返回值是列表中项目的正确索引

index()函数冗长且不灵活。如果我们有替代规则,我们需要编写多个index()函数,或者我们需要使测试更灵活。

更重要的是,考虑当目标值在n个项目的列表中出现x次时。这个循环将进行x次。每次循环平均检查列表中的O(x×n/2)次。最坏的情况是项目都在列表的末尾,导致处理迭代次数略少于O(x×n)。

我们可以做得更好。我们的首选解决方案建立在第二章的设计一个正确终止的 while 语句配方中的想法上,语句和语法,以设计一个适当的循环来从列表结构中删除复杂的项目。

如何做...

  1. 将索引值初始化为零。这建立了一个将遍历数据集合的变量:
        i = 0 

  1. 终止条件必须表明列表中的每个项目都已经被检查过了。此外,循环体需要删除所有符合目标条件的项目。这导致了一个不变条件,即item[i]尚未被检查。项目被检查后,它可能被保留,这意味着索引i必须被递增以重置尚未被检查的不变条件。如果项目被移除,那么项目将向前移动,item[i]将自动满足尚未被检查的不变条件:
        if 'Lake' in data[i]['writer']: 
            del data[i] # Remove 
        else: 
            i += 1 # Preserve 

删除一个项目时,列表变短了一个,索引值i将指向一个新的未检查的项目。保留一个项目时,索引值i将被提前到下一个未检查的项目。

  1. 终止条件用于包装处理体:
        while i != len(data): 

while语句结束时,i的值将表明所有项目都已经被检查过了。

这导致了以下结果:

 **>>> i = 0 
      >>> while i != len(data): 
      ...    if 'Lake' in data[i]['writer']: 
      ...        del data[i] 
      ...    else: 
      ...        i += 1 
      >>> pprint(data) 
      [{'time': '2:43', 'title': 'Eruption', 'writer': ['Emerson']}, 
       {'time': '1:16', 'title': 'Iconoclast', 'writer': ['Emerson']}, 
       {'time': '1:49', 'title': 'Manticore', 'writer': ['Emerson']}, 
       {'time': '3:54', 'title': 'Aquatarkus', 'writer': ['Emerson']}]** 

这使得数据只经过一次,并且在不引发索引错误或跳过应该被删除的项目的情况下删除了请求的项目。

工作原理...

目标是确切地检查每个项目一次,并且要么删除它,要么跳过它。循环设计反映了 Python 列表项目移除的工作方式。当一个项目被移除时,所有后续项目都会在列表中向前移动。

基于range()len()函数的天真过程将有两个问题:

  • 当项目向前移动并且范围对象产生下一个值时,项目将被跳过。

  • 索引可以超出列表结构的末尾,因为len()只被用来获取原始大小,而不是当前大小

由于这两个问题,循环体中不变条件的设计非常重要。这反映了两种可能的状态变化:

  • 如果一个项目被移除,索引就不会改变。列表本身将会改变。

  • 如果一个项目被保留,索引必须改变。

我们可以说循环使数据通过一次,并且具有O(n)的复杂度。这里没有考虑的是每次删除的相对成本。从列表中删除项目0意味着每个剩余项目都向前移动一个位置。每次删除的成本实际上是O(n)。因此,复杂度更像是O(n × x),其中从n个项目的列表中移除x个项目。

即使这个算法也不是从列表中删除项目的最快方法。

还有更多...

如果我们放弃删除的想法,我们甚至可以做得更好。制作项目的浅拷贝比从列表中删除项目要快得多,但使用的存储空间更多。这是一种常见的时间与内存的权衡。

我们可以使用类似以下的生成器表达式:

 **>>> data = [item for item in source if not('Lake' in item['writer'])]** 

这将创建一个列表中我们想要保留的项目的浅拷贝。我们不想保留的项目将被忽略。有关浅拷贝的更多信息,请参阅第四章中的制作对象的浅拷贝和深拷贝配方,内置数据结构 - 列表、集合、字典

我们还可以使用这样的高阶函数:

 **>>> data = list(filter(lambda item: not('Lake' in item['writer']), source))** 

filter()函数有两个参数:一个lambda对象和原始数据集。lambda对象是函数的一种退化情况:它有参数和一个单一表达式。在这种情况下,单一表达式用于决定要传递哪些项目。lambdaFalse的项目将被拒绝。

filter()函数是一个生成器。这意味着我们需要收集所有项目来创建最终的列表对象。for语句是处理生成器的所有结果的一种方法。list()tuple()函数也会消耗生成器的所有项目。

我们可以实现这一点的第三种方法是编写自己的生成器函数,体现了过滤的概念。这将使用比生成器或filter()函数更多的语句,但可能更清晰。

这是一个生成器函数定义:

    def writer_rule(iterable): 
        for item in iterable: 
            if 'Lake' in item['writer']: 
                continue 
            yield item 

我们使用for语句来检查源列表中的每个项目。如果项目在作者列表中有'Lake',我们将继续for语句的处理过程,有效地拒绝这个项目。如果'Lake'不在作者列表中,我们将产生该项目。

当我们调用这个函数时,它将产生有趣的列表。我们可以像这样使用函数writer_rule()

 **>>> from ch07_r07 import writer_rule 
>>> data = list(writer_rule(source)) 
>>> pprint(data) 
[{'time': '2:43', 'title': 'Eruption', 'writer': ['Emerson']}, 
 {'time': '1:16', 'title': 'Iconoclast', 'writer': ['Emerson']}, 
 {'time': '1:49', 'title': 'Manticore', 'writer': ['Emerson']}, 
 {'time': '3:54', 'title': 'Aquatarkus', 'writer': ['Emerson']}]** 

这将把有趣的行累积到一个新的结构中。由于这是一个浅拷贝,它不会浪费大量的存储空间。

另请参阅

  • 这是基于第二章中的设计一个正确终止的 while 语句配方,语句和语法

  • 我们还利用了另外两个配方:制作对象的浅拷贝和深拷贝切片和切割列表在第四章,内置数据结构 - 列表、集合、字典

第八章:功能和反应式编程特性

在本章中,我们将研究以下食谱:

  • 使用 yield 语句编写生成器函数

  • 使用堆叠的生成器表达式

  • 将转换应用于集合

  • 选择子集-三种过滤方式

  • 总结集合-如何减少

  • 组合映射和减少转换

  • 实现“存在”处理

  • 创建部分函数

  • 使用不可变数据结构简化复杂算法

  • 使用 yield from 语句编写递归生成器函数

介绍

函数式编程的理念是专注于编写执行所需数据转换的小型、表达力强的函数。组合函数通常可以创建比长串过程语句或复杂、有状态对象的方法更简洁和表达力更强的代码。Python 允许这三种编程方式。

传统数学将许多东西定义为函数。多个函数组合起来,从先前的转换中构建出复杂的结果。例如,我们可能有两个函数f(x)g(y),需要组合起来创建一个有用的结果:

y = f(x)

z = g(y)

理想情况下,我们可以从这两个函数创建一个复合函数:

z = (gf)(x)

使用复合函数(gf)可以帮助澄清程序的工作方式。它允许我们将许多小细节组合成更大的知识块。

由于编程经常涉及数据集合,我们经常会将函数应用于整个集合。这与数学中的集合构建器集合理解的概念非常契合。

有三种常见的模式可以将一个函数应用于一组数据:

  • 映射:这将一个函数应用于集合中的所有元素{M(x): xC}。我们将一些函数M应用于较大集合C的每个项目x

  • 过滤:这使用一个函数从集合中选择元素。{xcC if F(x)}。我们使用一个函数F来确定是否从较大的集合C中传递或拒绝项目x

  • 减少:这是对集合进行总结。细节各异,但最常见的减少之一是创建集合C中所有项目x的总和:Introduction

我们经常将这些模式结合起来创建更复杂的应用程序。这里重要的是小函数,如M(x)F(x),通过映射和过滤等高阶函数进行组合。即使各个部分非常简单,组合操作也可以变得复杂。

反应式编程的理念是在输入可用或更改时评估处理规则。这符合惰性编程的理念。当我们定义类定义的惰性属性时,我们创建了反应式程序。

反应式编程与函数式编程契合,因为可能需要多个转换来对输入值的变化做出反应。通常,这最清晰地表达为组合或堆叠成响应变化的复合函数。在第六章类和对象的基础中查看使用惰性属性食谱,了解一些反应式类设计的示例。

使用 yield 语句编写生成器函数

我们看过的大多数食谱都是为了与单个集合中的所有项目一起使用而设计的。这种方法是使用for语句来遍历集合中的每个项目,要么将值映射到新项目,要么将集合减少到某种摘要值。

从集合中产生单个结果是处理集合的两种方式之一。另一种方式是产生增量结果,而不是单个结果。

这种方法在我们无法将整个集合放入内存的情况下非常有帮助。例如,分析庞大的网络日志文件最好是分批进行,而不是创建一个内存集合。

有没有办法将集合结构与处理函数分离?我们是否可以在每个单独的项目可用时立即产生处理结果?

准备工作

我们将查看一些具有日期时间字符串值的网络日志数据。我们需要解析这些数据以创建适当的datetime对象。为了保持本食谱的重点,我们将使用 Flask 生成的简化日志。

条目最初是这样的文本行:

 **[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One** 

 **[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging** 

 **[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong** 

我们已经看到了在第七章的使用更复杂的结构——列表的映射食谱中处理这种日志的其他示例,更高级的类设计。使用第一章的使用正则表达式进行字符串解析食谱中的 REs,数字、字符串和元组,我们可以将每行分解为以下行集合:

 **>>> data = [ 
...    ('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'), 
...    ('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging'), 
...    ('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong') 
... ]** 

我们不能使用普通的字符串解析将复杂的日期时间戳转换为更有用的形式。但是,我们可以编写一个生成器函数,它可以处理日志的每一行,产生一个更有用的中间数据结构。

生成器函数是使用yield语句的函数。当一个函数有一个 yield 时,它会逐渐构建结果,以一种可以被客户端消耗的方式产生每个单独的值。消费者可能是一个for语句,也可能是另一个需要一系列值的函数。

如何做到...

  1. 这需要datetime模块:
        import datetime 

  1. 定义一个处理源集合的函数:
        def parse_date_iter(source): 

我们在后缀_iter中包含了这个函数将是一个可迭代对象而不是一个简单集合的提醒。

  1. 包括一个for语句,访问源集合中的每个项目:
        for item in source: 

  1. for语句的主体可以将项目映射到一个新项目:
        date = datetime.datetime.strptime( 
            item[0], 
            "%Y-%m-%d %H:%M:%S,%f") 
        new_item = (date,)+item[1:] 

在这种情况下,我们将一个字段从字符串映射到datetime对象。变量date是从item[0]中的字符串构建的。

然后,我们将日志消息的三元组映射到一个新的元组,用正确的datetime对象替换日期字符串。由于项目的值是一个元组,我们创建了一个带有(date,)的单例元组,然后将其与item[1:]元组连接起来。

  1. 使用yield语句产生新项目:
        yield new_item 

整个结构看起来是这样的,正确缩进:

    import datetime 
    def parse_date_iter(source): 
        for item in source: 
            date = datetime.datetime.strptime( 
                item[0], 
                "%Y-%m-%d %H:%M:%S,%f") 
            new_item = (date,)+item[1:] 
            yield new_item 

parse_date_iter()函数期望一个可迭代的输入对象。集合是可迭代对象的一个例子。然而更重要的是,其他生成器也是可迭代的。我们可以利用这一点构建处理来自其他生成器的数据的生成器堆栈。

这个函数不会创建一个集合。它会产生每个项目,以便可以单独处理这些项目。源集合会被分成小块进行处理,从而可以处理大量的数据。在一些示例中,数据将从内存集合开始。在后续的示例中,我们将处理来自外部文件的数据——处理外部文件最能从这种技术中获益。

以下是我们如何使用这个函数的方法:

 **>>> from pprint import pprint 
>>> from ch08_r01 import parse_date_iter 
>>> for item in parse_date_iter(data): 
...     pprint(item) 
(datetime.datetime(2016, 4, 24, 11, 5, 1, 462000), 
 'INFO', 
 'module1', 
 'Sample Message One') 
(datetime.datetime(2016, 4, 24, 11, 6, 2, 624000), 
 'DEBUG', 
 'module2', 
 'Debugging') 
(datetime.datetime(2016, 4, 24, 11, 7, 3, 246000), 
 'WARNING', 
 'module1', 
 'Something might have gone wrong')** 

我们使用for语句遍历parse_date_iter()函数的结果,一次处理一个项目。我们使用pprint()函数显示每个项目。

我们也可以使用类似这样的方法将项目收集到一个适当的列表中:

 **>>> details = list(parse_date_iter(data))** 

在这个例子中,list()函数消耗了parse_date_iter()函数产生的所有项目。使用list()for语句来消耗生成器中的所有项目是至关重要的。生成器是一个相对被动的结构——直到需要数据时,它不会做任何工作。

如果我们不主动消耗数据,我们会看到类似这样的情况:

 **>>> parse_date_iter(data) 
<generator object parse_date_iter at 0x10167ddb0>** 

parse_date_iter()函数的值是一个生成器。它不是一个项目的集合,而是一个能够按需生成项目的函数。

工作原理...

编写生成器函数可以改变我们对算法的理解方式。有两种常见的模式:映射和归约。映射将每个项目转换为一个新项目,可能计算一些派生值。归约从源集合中累积一个摘要,比如总和、平均值、方差或哈希。这些可以分解为逐项转换或过滤,与处理集合的整体循环分开。

Python 有一个复杂的构造叫做迭代器,它是生成器和集合的核心。迭代器会从集合中提供每个值,同时进行所有必要的内部记录以维护进程的状态。生成器函数的行为就像一个迭代器-它提供一系列的值并维护自己的内部状态。

考虑下面这段常见的 Python 代码:

    for i in some_collection: 
        process(i) 

在幕后,类似以下的事情正在发生:

    the_iterator = iter(some_collection) 
    try: 
        while True: 
            i = next(the_iterator) 
            process(i) 
    except StopIteration: 
        pass 

Python 对集合上的iter()函数进行评估,以创建该集合的迭代器对象。迭代器绑定到集合并维护一些内部状态信息。代码在迭代器上使用next()来获取每个值。当没有更多的值时,迭代器会引发StopIteration异常。

Python 的每个集合都可以产生一个迭代器。SequenceSet产生的迭代器会访问集合中的每个项。Mapping产生的迭代器会访问映射的每个键。我们可以使用映射的values()方法来迭代值而不是键。我们可以使用映射的items()方法来访问一个(key, value)两元组的序列。file的迭代器会访问文件中的每一行。

迭代器的概念也可以应用到函数上。带有yield语句的函数被称为生成器函数。它符合迭代器的模板。为了实现这一点,生成器在响应iter()函数时返回自身。在响应next()函数时,它会产生下一个值。

当我们对集合或生成器函数应用list()时,与for语句使用的相同的基本机制会得到各个值。iter()next()函数被list()用来获取这些项。然后这些项被转换成一个序列。

评估生成器函数的next()是有趣的。生成器函数会被评估,直到它达到一个yield语句。这个值就是next()的结果。每次评估next()时,函数会在yield语句之后恢复处理,并继续到下一个yield语句。

这里有一个产生两个对象的小函数:

 **>>> def gen_func(): 
...     print("pre-yield") 
...     yield 1 
...     print("post-yield") 
...     yield 2** 

当我们评估next()函数时会发生什么。在生成器上,这个函数会产生:

 **>>> y = gen_func() 
>>> next(y) 
pre-yield 
1 
>>> next(y) 
post-yield 
2** 

第一次评估next()时,第一个print()函数被评估,然后yield语句产生一个值。函数的处理被暂停,然后出现>>>提示符。第二次评估next()函数时,两个yield语句之间的语句被评估。函数再次被暂停,然后会显示一个>>>提示符。

接下来会发生什么?我们已经没有yield语句了:

 **>>> next(y)  
Traceback (most recent call last): 
  File "<pyshell...>", line 1, in <module> 
    next(y) 
StopIteration** 

在生成器函数的末尾会引发StopIteration异常。

还有更多...

生成器函数的核心价值在于能够将复杂的处理分解为两部分:

  • 要应用的转换或过滤

  • 要处理的源数据集

这是使用生成器来过滤数据的一个例子。在这种情况下,我们将过滤输入值,只保留质数,拒绝所有合数。

我们可以将处理写成一个 Python 函数,像这样:

    def primeset(source): 
        for i in source: 
            if prime(i): 
                yield prime 

对于源中的每个值,我们将评估prime()函数。如果结果为true,我们将产生源值。如果结果为false,源值将被拒绝。我们可以像这样使用primeset()

    p_10 = set(primeset(range(2,2000000))) 

primeset()函数将从源集合中产生单个素数值。源集合将是范围在 2 到 200 万之间的整数。结果是从提供的值构建的set对象。

这里唯一缺少的是prime()函数,用于确定一个数字是否为素数。我们将把这留给读者作为练习。

从数学上讲,常见的是使用集合生成器集合推导符号来指定从另一个集合构建一个集合的规则。

我们可能会看到类似这样的东西:

P[10] = {ii ∧ 2 ≤ 1 < 2,000,000 if Pi)}

这告诉我们P[10]是所有数字i的集合,在自然数集中,并且在 2 到 200 万之间,如果P(i)true。这定义了一个构建集合的规则。

我们也可以用 Python 写出这个:

    p_10 = {i for i in range(2,2000000) if prime(i)} 

这是素数子集的 Python 表示。从数学抽象中略微重新排列了子句,但表达式的所有基本部分都存在。

当我们开始看这样的生成器表达式时,我们会发现很多编程都符合一些常见的整体模式:

  • Mapmx):xS变为(m(x) for x in S)

  • FilterxxS if fx)变为(x for x in S if f(x))

  • Reduce:这有点复杂,但常见的缩减包括求和和计数。更多内容...sum(x for x in S)。其他常见的缩减包括查找一组数据的最大值或最小值。

我们也可以使用yield语句编写这些不同的高级函数。以下是通用映射的定义:

    def map(m, S): 
        for s in S: 
            yield m(s) 

此函数将某个其他函数m()应用于源集合S中的每个数据元素。映射函数的结果作为结果值的序列被产生。

我们可以为通用的filter函数编写类似的定义:

    def filter(f, S): 
        for s in S: 
            if f(s): 
                yield s 

与通用映射一样,我们将函数f()应用于源集合S中的每个元素。函数为true的地方,值被产生。函数为false的地方,值被拒绝。

我们可以像这样使用它来创建一个素数集:

    p_10 = set(filter(prime, range(2,2000000))) 

这将应用prime()函数到数据源范围。请注意,我们只写prime,不带()字符,因为我们是在命名函数,而不是在评估它。每个单独的值将由prime()函数检查。通过的值将被产生以组装成最终集合。那些是合数的值将被拒绝,不会出现在最终集合中。

另请参阅

  • 使用堆叠的生成器表达式的示例中,我们将结合生成器函数,从简单组件构建复杂的处理堆栈。

  • 对集合应用转换的示例中,我们将看到内置的map()函数如何被用于从简单函数和可迭代的数据源创建复杂的处理。

  • 选择子集-三种过滤方式的示例中,我们将看到内置的filter()函数也可以用于从简单函数和可迭代的数据源构建复杂的处理。

  • 有关小于 200 万的素数的具有挑战性的问题,请参阅projecteuler.net/problem=10。问题的部分似乎是显而易见的。然而,测试所有这些数字是否为素数可能很困难。

使用堆叠的生成器表达式

使用 yield 语句编写生成器函数的示例中,我们创建了一个简单的生成器函数,对数据进行了单一的转换。实际上,我们经常有几个函数,我们希望将其应用于传入的数据。

我们如何堆叠或组合多个生成器函数以创建一个复合函数?

准备工作

我们有一个用于记录大帆船燃油消耗的电子表格。它的行看起来像这样:

日期 启动引擎 燃油高度
关闭引擎 燃油高度
其他说明
10/25/2013 08:24 29
13:15 27
平静的海域 - 锚在所罗门岛
10/26/2013 09:12 27
18:25 22
波涛汹涌 - 锚在杰克逊溪

有关这些数据的更多背景信息,请参阅第四章的对列表进行切片和切块内置数据结构 - 列表、集合、字典

作为一个侧边栏,我们可以这样获取数据。我们将在第九章的使用 csv 模块读取分隔文件中详细讨论这个问题,输入/输出、物理格式和逻辑布局

 **>>> from pathlib import Path 
>>> import csv 
>>> with Path('code/fuel.csv').open() as source_file: 
...    reader = csv.reader(source_file) 
...    log_rows = list(reader) 
>>> log_rows[0] 
['date', 'engine on', 'fuel height'] 
>>> log_rows[-1] 
['', "choppy -- anchor in jackson's creek", '']** 

我们已经使用csv模块来读取日志详情。csv.reader()是一个可迭代对象。为了将项目收集到一个单一列表中,我们对生成器函数应用了list()函数。我们打印了列表中的第一个和最后一个项目,以确认我们确实有一个列表的列表结构。

我们想对这个列表的列表应用两个转换:

  • 将日期和两个时间转换为两个日期时间值

  • 将三行合并成一行,以便对数据进行简单的组织

如果我们创建一对有用的生成器函数,我们可以有这样的软件:

    total_time = datetime.timedelta(0) 
    total_fuel = 0 
    for row in date_conversion(row_merge(source_data)): 
        total_time += row['end_time']-row['start_time'] 
        total_fuel += row['end_fuel']-row['start_fuel'] 

组合的生成器函数date_conversion(row_merge(...))将产生一系列单行,其中包含起始信息、结束信息和注释。这种结构可以很容易地总结或分析,以创建简单的统计相关性和趋势。

如何做到这一点...

  1. 定义一个初始的减少操作,将行组合在一起。我们有几种方法可以解决这个问题。一种方法是总是将三行组合在一起。

另一种方法是注意到第零列在组的开头有数据;在组的下两行为空。这给了我们一个稍微更一般的方法来创建行的组。这是一种头尾合并算法。我们将收集数据,并在到达下一个组的头部时每次产生数据:

        def row_merge(source_iter): 
            group = [] 
            for row in source_iter: 
                if len(row[0]) != 0: 
                    if group: 
                        yield group 
                    group = row.copy() 
                else: 
                    group.extend(row) 
            if group: 
                yield group 

这个算法使用len(row[0])来确定这是一个组的头部还是组的尾部中的一行。在头部行的情况下,任何先前的组都会被产生。在那之后被消耗后,group集合的值将被重置为头部行的列数据。

组的尾部行简单地附加到group集合上。当数据耗尽时,group变量中通常会有一个最终组。如果根本没有数据,那么group的最终值也将是一个长度为零的列表,应该被忽略。

我们稍后会讨论copy()方法。这是必不可少的,因为我们正在处理一个列表的列表数据结构,而列表是可变对象。我们可以编写处理改变数据结构的处理,使得一些处理难以解释。

  1. 定义将在合并后的数据上执行的各种映射操作。这些应用于原始行中的数据。我们将使用单独的函数来转换两个时间列,并将时间与日期列合并:
        import datetime 
        def start_datetime(row): 
            travel_date = datetime.datetime.strptime(row[0], "%m/%d/%y").date() 
            start_time = datetime.datetime.strptime(row[1], "%I:%M:%S %p").time() 
            start_datetime = datetime.datetime.combine(travel_date, start_time) 
            new_row = row+[start_datetime] 
            return new_row 

        def end_datetime(row): 
            travel_date = datetime.datetime.strptime(row[0], "%m/%d/%y").date() 
            end_time = datetime.datetime.strptime(row[4], "%I:%M:%S %p").time() 
            end_datetime = datetime.datetime.combine(travel_date, end_time) 
            new_row = row+[end_datetime] 
            return new_row 

我们将把第零列中的日期与第一列中的时间结合起来,创建一个起始datetime对象。同样,我们将把第零列中的日期与第四列中的时间结合起来,创建一个结束datetime对象。

这两个函数有很多重叠之处,可以重构为一个带有列号作为参数值的单个函数。然而,目前我们的目标是编写一些简单有效的东西。效率的重构可以稍后进行。

  1. 定义适用于派生数据的映射操作。第八和第九列包含日期时间戳:
        for starting and ending.def duration(row): 
            travel_hours = round((row[10]-row[9]).total_seconds()/60/60, 1) 
            new_row = row+[travel_hours] 
            return new_row 

我们使用start_datetimeend_datetime创建的值作为输入。我们计算了时间差,这提供了以秒为单位的结果。我们将秒转换为小时,这是这组数据更有用的时间单位。

  1. 合并任何需要拒绝或排除坏数据的过滤器。在这种情况下,我们必须排除一个标题行:
        def skip_header_date(rows): 
            for row in rows: 
                if row[0] == 'date': 
                    continue 
                yield row 

这个函数将拒绝任何第一列中有date的行。continue语句恢复for语句,跳过体中的所有其他语句;它跳过yield语句。所有其他行将通过这个过程。输入是一个可迭代对象,这个生成器将产生没有以任何方式转换的行。

  1. 将操作组合起来。我们可以编写一系列生成器表达式,也可以使用内置的map()函数。以下是使用生成器表达式的示例:
        def date_conversion(source): 
            tail_gen = skip_header_date(source) 
            start_gen = (start_datetime(row) for row in tail_gen) 
            end_gen = (end_datetime(row) for row in start_gen) 
            duration_gen = (duration(row) for row in end_gen) 
            return duration_gen 

这个操作由一系列转换组成。每个转换对原始数据集中的一个值进行小的转换。添加或更改操作相对简单,因为每个操作都是独立定义的:

  • tail_gen生成器在跳过源的第一行后产生行

  • start_gen生成器将一个datetime对象附加到每一行的末尾,起始时间是从字符串构建到源列中的

  • end_gen生成器将一个datetime对象附加到每一行的末尾,结束时间是从字符串构建到源列中的

  • duration_gen生成器将一个float对象附加到每个腿的持续时间

整体date_conversion()函数的输出是一个生成器。可以使用for语句消耗它,也可以从项目构建一个list

工作原理...

当我们编写一个生成器函数时,参数值可以是一个集合,也可以是另一种可迭代对象。由于生成器函数是可迭代的,因此可以创建一种生成器函数的管道

每个函数可以包含一个小的转换,改变输入的一个特征以创建输出。然后我们将每个小的转换包装在生成器表达式中。因为每个转换都相对独立于其他转换,所以我们可以对其中一个进行更改而不破坏整个处理流水线。

处理是逐步进行的。每个函数都会被评估,直到产生一个单一的值。考虑这个陈述:

    for row in date_conversion(row_merge(data)): 
        print(row[11]) 

我们定义了几个生成器的组合。这个组合使用了各种技术:

  • row_merge()函数是一个生成器,将产生数据行。为了产生一行,它将从源中读取四行,组装成一个合并的行,并产生它。每次需要另一行时,它将再读取三行输入来组装输出行。

  • date_conversion()函数是由多个生成器构建的复杂生成器。

  • skip_header_date()旨在产生一个单一的值。有时它必须从源迭代器中读取两个值。如果输入行的第零列有date,则跳过该行。在这种情况下,它将读取第二个值,从row_merge()获取另一行;而row_merge()必须再读取三行输入来产生一个合并的输出行。我们将生成器分配给tail_gen变量。

  • start_genend_genduration_gen生成器表达式将对其输入的每一行应用相对简单的函数,例如start_datetime()end_datetime(),产生具有更有用数据的行。

示例中显示的最终for语句将通过反复评估next()函数来从date_conversion()迭代器中收集值。以下是创建所需结果的逐步视图。请注意,这在一个非常小的数据量上运行——每个步骤都会做出一个小的改变:

  1. date_conversion() 函数的结果是 duration_gen 对象。为了返回一个值,它需要来自其源 end_gen 的一行。一旦有了数据,它就可以应用 duration() 函数并产生该行。

  2. end_gen 表达式需要来自其源 start_gen 的一行。然后它可以应用 end_datetime() 函数并产生该行。

  3. start_gen 表达式需要来自其源 tail_gen 的一行。然后它可以应用 start_datetime() 函数并产生该行。

  4. tail_gen 表达式只是生成器 skip_header_date() 。这个函数将从其源中读取所需的行,直到找到一行,其中第零列不是列标题 date。它产生一个非日期行。其源是 row_merge() 函数的输出。

  5. row_merge() 函数将从其源中读取多行,直到可以组装符合所需模式的行集合。它将产生一个组合行,该行在第零列中有一些文本,后面是没有文本的行。其源是原始数据的列表集合。

  6. 行集合将由 row_merge() 函数内的 for 语句处理。这个处理将隐式地为集合创建一个迭代器,以便 row_merge() 函数的主体根据需要产生每个单独的行。

数据的每个单独行将通过这些步骤的管道。管道的某些阶段将消耗多个源行以产生单个结果行,重组数据。其他阶段消耗单个值。

这个例子依赖于将项目连接成一个长序列的值。项目由位置标识。对管道中阶段顺序的小改动将改变项目的位置。有许多方法可以改进这一点,我们将在接下来看一下。

这个核心是只处理单独的行。如果源是一个庞大的数据集合,处理可以非常快速。这种技术允许一个小的 Python 程序快速而简单地处理大量的数据。

还有更多...

实际上,一组相互关联的生成器是一种复合函数。我们可能有几个函数,像这样分别定义:

y = f(x)

z = g(y)

我们可以通过将第一个函数的结果应用到第二个函数来将它们组合起来:

z = g(f(x))

随着函数数量的增加,这可能变得笨拙。当我们在多个地方使用这对函数时,我们违反了不要重复自己DRY)原则。拥有多个这种复杂表达式的副本并不理想。

我们希望有一种方法来创建一个复合函数——类似于这样:

z = ( gf )( x )

在这里,我们定义了一个新函数(gf),将两个原始函数组合成一个新的、单一的复合函数。我们现在可以修改这个复合函数以添加或更改功能。

这个概念推动了复合 date_conversion() 函数的定义。这个函数由许多函数组成,每个函数都可以应用于集合的项。如果我们需要进行更改,我们可以轻松地编写更多简单的函数并将它们放入 date_conversion() 函数定义的管道中。

我们可以看到管道中的函数之间存在一些细微差异。我们有一些类型转换。然而,持续时间计算并不真正是一种类型转换。它是一种基于日期转换结果的独立计算。如果我们想要计算每小时的燃料使用量,我们需要添加几个计算。这些额外的摘要都不是日期转换的正确部分。

我们真的应该将高级 data_conversion() 分成两部分。我们应该编写另一个函数来进行持续时间和燃料使用计算,命名为 fuel_use()。然后这个其他函数可以包装 date_conversion()

我们可能会朝着这样的目标努力:

    for row in fuel_use(date_conversion(row_merge(data))): 
        print(row[11]) 

现在我们有一个非常复杂的计算,它由许多非常小的(几乎)完全独立的部分定义。我们可以修改一个部分而不必深入思考其他部分的工作方式。

命名空间而不是列表

一个重要的改变是停止避免使用简单的列表来存储数据值。对row[10]进行计算可能是一场潜在的灾难。我们应该适当地将输入数据转换为某种命名空间。

可以使用namedtuple。我们将在Simplifying complex algorithms with immutable data structures食谱中看到。

在某些方面,SimpleNamespace可以进一步简化这个处理过程。SimpleNamespace是一个可变对象,可以被更新。改变对象并不总是一个好主意。它的优点是简单,但对于可变对象的状态变化编写测试可能会稍微困难一些。

例如make_namespace()这样的函数可以提供一组名称而不是位置。这是一个必须在行合并后但在任何其他处理之前使用的生成器:

    from types import SimpleNamespace 

    def make_namespace(merge_iter): 
        for row in merge_iter: 
            ns = SimpleNamespace( 
                date = row[0], 
                start_time = row[1], 
                start_fuel_height = row[2], 
                end_time = row[4], 
                end_fuel_height = row[5], 
                other_notes = row[7] 
            ) 
            yield ns 

这将产生一个允许我们写row.date而不是row[0]的对象。当然,这将改变其他函数的定义,包括start_datetime()end_datetime()duration()

这些函数中的每一个都可以发出一个新的SimpleNamespace对象,而不是更新表示每一行的值列表。然后我们可以编写以下样式的函数:

    def duration(row_ns): 
        travel_time = row_ns.end_timestamp - row_ns.start_timestamp 
        travel_hours = round(travel_time.total_seconds()/60/60, 1) 
        return SimpleNamespace( 
            **vars(row_ns), 
            travel_hours=travel_hours 
        )

这个函数处理行作为SimpleNamespace对象,而不是list对象。列具有清晰而有意义的名称,如row_ns.end_timestamp,而不是晦涩的row[10]

构建新的SimpleNamespace的三部曲如下:

  1. 使用vars()函数提取SimpleNamespace实例内部的字典。

  2. 使用**vars(row_ns)对象基于旧命名空间构建一个新的命名空间。

  3. 任何额外的关键字参数,如travel_hours = travel_hours,都提供了将加载新对象的额外值。

另一种选择是更新命名空间并返回更新后的对象:

    def duration(row_ns): 
        travel_time = row_ns.end_timestamp - row_ns.start_timestamp 
        row_ns.travel_hours = round(travel_time.total_seconds()/60/60, 1) 
        return row_ns 

这样做的优点是稍微简单。缺点是有时会让有状态的对象变得混乱。在修改算法时,可能会失败地按正确顺序设置属性,以便懒惰(或反应性)编程能够正常运行。

尽管有状态的对象很常见,但它们应该始终被视为两种选择之一。不可变的namedtuple可能比可变的SimpleNamespace更好。

另请参阅

  • Writing generator functions with the yield statement食谱中,我们介绍了生成器函数

  • 在第四章的Slicing and dicing a list食谱中,了解有关燃料消耗数据集的更多信息

  • Combining map and reduce transformations食谱中,还有另一种组合操作的方法

对集合应用转换

Writing generator functions with the yield statement食谱中,我们看到了编写生成器函数的例子。我们看到的例子结合了两个元素:转换和数据源。它们通常看起来像这样:

    for item in source: 
        new_item = some transformation of item 
        yield new_item 

编写生成器函数的这个模板并不是必需的。它只是一个常见的模式。在for语句中隐藏了一个转换过程。for语句在很大程度上是样板代码。我们可以重构这个代码,使转换函数明确且与for语句分离。

Using stacked generator expressions食谱中,我们定义了一个start_datetime()函数,它从数据源集合的两个单独列中的字符串值计算出一个新的datetime对象。

我们可以在生成器函数的主体中使用这个函数,就像这样:

    def start_gen(tail_gen): 
        for row in tail_gen: 
            new_row = start_datetime(row) 
            yield new_row 

这个函数将start_datetime()函数应用于数据源tail_gen中的每个项目。每个生成的行都被产生,以便另一个函数或for语句可以消耗它。

使用堆叠的生成器表达式的示例中,我们看了另一种将这些转换函数应用于更大的数据集的方法。在这个例子中,我们使用了一个生成器表达式。代码看起来像这样:

    start_gen = (start_datetime(row) for row in tail_gen) 

这将start_datetime()函数应用于数据源tail_gen中的每个项目。另一个函数或for语句可以消耗start_gen可迭代中可用的值。

完整的生成器函数和较短的生成器表达式本质上是相同的,只是语法略有不同。这两者都与数学上的集合构建器集合推导的概念相似。我们可以用数学方式描述这个操作:

s = [ S ( r ): rT ]

在这个表达式中,Sstart_datetime()函数,T是称为tail_gen的值序列。结果序列是S(r)的值,其中r的每个值是集合T的一个元素。

生成器函数和生成器表达式都有类似的样板代码。我们能简化这些吗?

准备好了...

我们将查看使用带有 yield 语句的生成器函数示例中的 web 日志数据。这里有一个date作为一个字符串,我们想要将其转换为一个合适的时间戳。

这是示例数据:

 **>>> data = [ 
...    ('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'), 
...    ('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging'), 
...    ('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong') 
... ]** 

我们可以编写一个这样的函数来转换数据:

    import datetime 
    def parse_date_iter(source): 
        for item in source: 
            date = datetime.datetime.strptime( 
                item[0], 
                "%Y-%m-%d %H:%M:%S,%f") 
            new_item = (date,)+item[1:] 
            yield new_item 

这个函数将使用for语句检查源中的每个项目。第零列的值是一个date字符串,可以转换为一个合适的datetime对象。从datetime对象和从第一列开始的剩余项目构建一个新项目new_item

因为函数使用yield语句产生结果,所以它是一个生成器函数。我们可以像这样使用它与for语句:

    for row in parse_date_iter(data): 
        print(row[0], row[3]) 

这个语句将收集生成器函数产生的每个值,并打印两个选定的值。

parse_date_iter()函数将两个基本元素合并到一个函数中。大纲看起来像这样:

    for item in source: 
        new_item = transformation(item) 
        yield new_item 

foryield语句在很大程度上是样板代码。transformation()函数是这个非常有用和有趣的部分。

如何做...

  1. 编写应用于数据单行的转换函数。这不是一个生成器,也不使用yield语句。它只是修改集合中的单个项目:
        def parse_date(item): 
            date = datetime.datetime.strptime( 
                item[0], 
                "%Y-%m-%d %H:%M:%S,%f") 
            new_item = (date,)+item[1:] 
            return new_item 

这可以用三种方式:语句、表达式和map()函数。这是语句的显式for...yield模式:

        for item in collection: 
            new_item = parse_date(item) 
            yield new_item 

这使用了一个for语句来使用孤立的parse_date()函数处理集合中的每个项目。第二个选择是一个生成器表达式,看起来像这样:

        (parse_date(item) for item in data) 

这是一个生成器表达式,将parse_date()函数应用于每个项目。第三个选择是map()函数。

  1. 使用map()函数将转换应用于源数据。
        map(parse_date, data) 

我们提供函数名parse_date,在名称后面没有任何()。我们此时不应用函数。我们提供对象名给map()函数,以将parse_date()函数应用于可迭代的数据源data

我们可以这样使用:

        for row in map(parse_date, data): 
            print(row[0], row[3]) 

map()函数创建一个可迭代对象,将parse_date()函数应用于数据可迭代中的每个项目。它产生每个单独的项目。它使我们不必编写生成器表达式或生成器函数。

工作原理...

map()函数替换了一些常见的样板代码。我们可以想象定义看起来像这样:

    def map(f, iterable): 
        for item in iterable: 
            yield f(item) 

或者,我们可以想象它看起来像这样:

    def map(f, iterable): 
        return (f(item) for item in iterable) 

这两个定义总结了map()函数的核心特性。它是一个方便的简写,可以消除一些样板代码,用于将函数应用于可迭代的数据源。

还有更多...

在这个例子中,我们使用了map()函数来将一个接受单个参数的函数应用到单个可迭代对象的每个项目上。原来map()函数可以做的事情比这更多。

考虑这个函数:

 **>>> def mul(a, b): 
...    return a*b** 

还有这两个数据来源:

 **>>> list_1 = [2, 3, 5, 7] 
>>> list_2 = [11, 13, 17, 23]** 

我们可以将mul()函数应用于从每个数据源中提取的对:

 **>>> list(map(mul, list_1, list_2)) 
[22, 39, 85, 161]** 

这使我们能够使用不同类型的运算符合并两个值序列。例如,我们可以构建一个行为类似于内置的zip()函数的映射。

这是一个映射:

 **>>> def bundle(*args): 
...     return args 
>>> list(map(bundle, list_1, list_2)) 
[(2, 11), (3, 13), (5, 17), (7, 23)]** 

我们需要定义一个小的辅助函数,bundle(),它接受任意数量的参数,并将它们创建为一个元组。

这里是zip函数进行比较:

 **>>> list(zip(list_1, list_2)) 
[(2, 11), (3, 13), (5, 17), (7, 23)]** 

另请参阅...

  • 使用堆叠的生成器表达式示例中,我们研究了堆叠生成器。我们从许多单独的映射操作中构建了一个复合函数,这些操作被编写为生成器函数。我们还在堆栈中包含了一个单一的过滤器。

选择子集-三种过滤方式

使用堆叠的生成器表达式示例中,我们编写了一个生成器函数,它从一组数据中排除了一些行。我们定义了这样一个函数:

    def skip_header_date(rows): 
        for row in rows: 
            if row[0] == 'date': 
                continue 
            yield row 

当条件为true——row[0]date——continue语句将跳过for语句体中的其余语句。在这种情况下,只有一个语句yield row

有两个条件:

  • row[0] == 'date'yield语句被跳过;该行被拒绝进一步处理

  • row[0] != 'date'yield语句意味着该行将被传递给消耗数据的函数或语句

在四行代码中,这似乎有点冗长。for...if...yield模式显然是样板文件,只有条件在这种结构中才是真正的材料。

我们可以更简洁地表达这个吗?

准备好...

我们有一个用于记录大帆船燃料消耗的电子表格。它的行看起来像这样:

日期 引擎开启 燃料高度
关闭引擎 燃料高度
其他说明
10/25/2013 08:24 29
13:15 27
平静的海域 - 锚定所罗门岛
10/26/2013 09:12 27
18:25 22
波涛汹涌 - 锚定在杰克逊溪

有关这些数据的更多背景信息,请参阅切片和切块列表示例。

使用堆叠的生成器表达式示例中,我们定义了两个函数来重新组织这些数据。第一个将每个三行组合并为一个具有八列数据的单行:

    def row_merge(source_iter): 
        group = [] 
        for row in source_iter: 
            if len(row[0]) != 0: 
                if group: 
                    yield group 
                group = row.copy() 
            else: 
                group.extend(row) 
        if group: 
            yield group 

这是头尾算法的变体。当len(row[0]) != 0时,这是一个新组的标题行——任何先前完整的组都会被产生,然后group变量的工作值将根据这个标题行重置为一个新的、包含此标题行的列表。进行copy()操作,以便我们以后可以避免对列表对象进行变异。当len(row[0]) == 0时,这是组的尾部;该行被附加到group变量的工作值上。在数据源的末尾,通常有一个需要处理的完整组。有一个边缘情况,即根本没有数据;在这种情况下,也没有最终的组需要产生。

我们可以使用这个函数将数据从许多令人困惑的行转换为有用信息的单行:

 **>>> from ch08_r02 import row_merge, log_rows 
>>> pprint(list(row_merge(log_rows))) 

[['date', 
  'engine on', 
  'fuel height', 
  '', 
  'engine off', 
  'fuel height', 
  '', 
  'Other notes', 
  ''], 
 ['10/25/13', 
  '08:24:00 AM', 
  '29', 
  '', 
  '01:15:00 PM', 
  '27', 
  '', 
  "calm seas -- anchor solomon's island", 
  ''], 
 ['10/26/13', 
  '09:12:00 AM', 
  '27', 
  '', 
  '06:25:00 PM', 
  '22', 
  '', 
  "choppy -- anchor in jackson's creek", 
  '']]** 

我们看到第一行只是电子表格的标题。我们想跳过这一行。我们将创建一个生成器表达式来处理过滤,并拒绝这一额外的行。

如何做...

  1. 编写谓词函数,测试一个项目是否应该通过过滤器进行进一步处理。在某些情况下,我们将不得不从拒绝规则开始,然后编写反向规则,使其成为通过规则:
        def pass_non_date(row): 
            return row[0] != 'date' 

这可以用三种方式来使用:语句、表达式和filter()函数。这是一个显式的for...if...yield模式的语句示例,用于传递行:

        for item in collection: 
            if pass_non_date(item): 
                yield item 

这使用一个for语句来使用过滤函数处理集合中的每个项目。选择的项目被产生。其他项目被拒绝。

使用这个函数的第二种方式是在生成器表达式中使用它:

        (item for item in data if pass_non_date(item)) 

这个生成器表达式应用了filter函数pass_non_date()到每个项目。第三种选择是filter()函数。

  1. 使用filter()函数将函数应用于源数据:
        filter(pass_non_date, data) 

我们提供了函数名pass_non_date。我们在函数名后面不使用()字符,因为这个表达式不会评估函数。filter()函数将给定的函数应用于可迭代的数据源data。在这种情况下,data是一个集合,但它可以是任何可迭代的对象,包括以前生成器表达式的结果。pass_non_date()函数为true的每个项目将被过滤器传递;所有其他值都被拒绝。

我们可以这样使用:

        for row in filter(pass_non_date, row_merge(data)): 
            print(row[0], row[1], row[4]) 

filter()函数创建一个可迭代对象,将pass_non_date()函数作为规则应用于row_merge(data)可迭代对象中的每个项目,它产生了在第零列中没有date的行。

它是如何工作的...

filter()函数替换了一些常见的样板代码。我们可以想象定义看起来像这样:

    def filter(f, iterable): 
        for item in iterable: 
            if f(item): 
                yield f(item) 

或者,我们可以想象它看起来像这样:

    def filter(f, iterable): 
        return (item for item in iterable if f(item)) 

这两个定义总结了filter()函数的核心特性:一些数据被传递,一些数据被拒绝。这是一个方便的简写,消除了一些应用函数到可迭代数据源的样板代码。

还有更多...

有时候很难编写一个简单的规则来传递数据。如果我们编写一个拒绝数据的规则,可能会更清晰。例如,这可能更有意义:

    def reject_date(row): 
        return row[0] == 'date' 

我们可以以多种方式使用拒绝规则。这是一个for...if...continue...yield语句的模式。这将使用 continue 跳过被拒绝的行,并产生剩下的行:

    for item in collection: 
        if reject_date(item): 
            continue 
        yield item 

我们还可以使用这种变体。对于一些程序员来说,不拒绝的概念可能会变得令人困惑。这可能看起来像一个双重否定:

    for item in collection: 
        if not reject_date(item): 
            yield item 

我们也可以使用类似这样的生成器表达式:

    (item for item in data if not reject_date(item)) 

然而,我们不能轻松地使用filter()函数来拒绝数据的规则。filter()函数只能用于传递规则。

我们在处理这种逻辑时有两种基本选择。我们可以将逻辑包装在另一个表达式中,或者使用itertools模块中的函数。当涉及到包装时,我们还有两种选择。我们可以包装一个拒绝函数以创建一个传递函数。我们可以使用类似这样的东西:

    def pass_date(row): 
        return not reject_date(row) 

这使得可以创建一个简单的拒绝规则,并在filter()函数中使用它。包装逻辑的另一种方法是创建一个lambda对象:

     filter(lambda item: not reject_date(item), data) 

lambda函数是一个小的匿名函数。它是一个被简化为只有两个元素的函数:参数列表和一个单一的表达式。我们用lambda对象包装了reject_date()函数,以创建一种not_reject_date函数。

itertools模块中,我们使用filterfalse()函数。我们可以导入filterfalse()并使用它来代替内置的filter()函数。

另请参阅...

  • 使用堆叠的生成器表达式配方中,我们将这样的函数放在一堆生成器中。我们从许多单独的映射和过滤操作中构建了一个复合函数,这些操作被编写为生成器函数。

总结一个集合-如何减少

在本章的介绍中,我们注意到有三种常见的处理模式:映射、过滤和减少。我们在将变换应用到集合配方中看到了映射的例子,在挑选子集-三种过滤方式配方中看到了过滤的例子。很容易看出这些是如何变得非常通用的。

映射将一个简单的函数应用于集合的所有元素。{M(x): xC}将函数M应用于较大集合C的每个项目x。在 Python 中,它可以看起来像这样:

    (M(x) for x in C) 

或者,我们可以使用内置的map()函数来消除样板代码,并简化为这样:

    map(M, c) 

类似地,过滤使用一个函数从集合中选择元素。{x: xC if F(x)}使用函数F来确定是否传递或拒绝来自较大集合C的项目x。我们可以用各种方式在 Python 中表达这一点,其中之一就像这样:

    filter(F, c) 

这将一个谓词函数F()应用于集合c

第三种常见的模式是缩减。在设计具有大量处理的类扩展集合:执行统计的列表的示例中,我们看到了计算许多统计值的类定义。这些定义几乎完全依赖于内置的sum()函数。这是比较常见的缩减之一。

我们能否将求和概括为一种允许我们编写多种不同类型的缩减的方式?我们如何以更一般的方式定义缩减的概念?

准备就绪

最常见的缩减之一是求和。其他缩减包括乘积、最小值、最大值、平均值、方差,甚至是简单的值计数。

这是一种将数学定义的求和函数+应用于集合C中的值的方式:

准备就绪

我们通过在值序列C=c[0], c[1], c[2], ..., c[n]中插入+运算符来扩展了求和的定义。这种在+运算符中进行fold的想法捕捉了内置的sum()函数的含义。

类似地,乘积的定义如下:

准备就绪

在这里,我们对一系列值进行了不同的fold。通过 fold 扩展缩减涉及两个项目:一个二元运算符和一个基本值。对于求和,运算符是+,基本值是零。对于乘积,运算符是×,基本值是一。

我们可以定义一个通用的高级函数F[(⋄, ⊥)],它捕捉了 fold 的理想。fold 函数定义包括一个运算符⋄的占位符和一个基本值⊥的占位符。给定集合C的函数值可以用这个递归规则来定义:

准备就绪

如果集合C为空,则值为基本值⊥。当定义sum()时,基本值将为零。如果C不为空,则我们首先计算集合中除最后一个值之外的所有值的 fold,F◊, ⊥。然后我们将运算符(例如加法)应用于前一个 fold 结果和集合中的最后一个值C[n-][1]。对于sum(),运算符是+。

我们在 Pythonic 意义上使用了C[0..n]的符号。包括索引 0 到n-1 的值,但不包括索引n的值。这意味着C[0..0]=∅:这个范围C[0..0]中没有元素。

这个定义被称为fold left操作,因为这个定义的净效果是在集合中从左到右执行基础操作。这可以改为定义一个fold right操作。由于 Python 的reduce()函数是一个 fold left,我们将坚持使用它。

我们将定义一个prod()函数,用于计算阶乘值:

准备就绪

n的阶乘的值是 1 到n之间所有数字的乘积。由于 Python 使用半开区间,使用1x < n + 1来定义范围更符合 Python 的风格。这个定义更适合内置的range()函数。

使用我们之前定义的 fold 操作符,我们有这个。我们使用乘法*的操作符和基本值为 1 来定义了一个 fold(或者 reduce):

准备工作

折叠的概念是 Python 的reduce()概念的通用概念。我们可以将这应用于许多算法,可能简化定义。

如何做...

  1. functools模块导入reduce()函数:
 **>>> from functools import reduce** 

  1. 选择运算符。对于求和,是+。对于乘积,是*。这些可以以多种方式定义。这是长版本。其他定义必要的二进制运算符的方法将在后面展示:
 **>>> def mul(a, b): 
      ...     return a * b** 

  1. 选择所需的基值。对于求和,它是零。对于乘积,它是一。这使我们能够定义一个计算通用乘积的prod()函数:
 **>>> def prod(values): 
      ...    return reduce(mul, values, 1)** 

  1. 对于阶乘,我们需要定义将被减少的数值序列:
 **range(1, n+1)** 

这是prod()函数的工作原理:

 **>>> prod(range(1, 5+1)) 
      120** 

这是整个阶乘函数:

 **>>> def factorial(n): 
...    return prod(range(1, n+1))** 

这是一副 52 张牌的排列方式。这是值52!

 **>>> factorial(52) 
80658175170943878571660636856403766975289505440883277824000000000000** 

一副牌可以被洗牌的方式有很多种。

有多少种 5 张牌的可能手牌?二项式计算使用阶乘:

如何做...

 **>>> factorial(52)//(factorial(5)*factorial(52-5)) 
2598960** 

对于任何给定的洗牌,大约有 260 万种不同的可能扑克手牌。(是的,这是一种计算二项式的非常低效的方法。)

它是如何工作的...

reduce()函数的行为就好像它有这个定义:

    def reduce(function, iterable, base): 
        result = base 
        for item in iterable: 
            result = function(result, item) 
        return result 

这将从左到右迭代数值。它将在可迭代集合中的前一组数值和下一个项目之间应用给定的二进制函数。

当我们看递归函数和 Python 的堆栈限制这个教程时,我们可以看到 fold 的递归定义可以优化为这个for语句。

还有更多...

在设计reduce()函数时,我们需要提供一个二进制运算符。有三种方法来定义必要的二进制运算符。我们使用了一个完整的函数定义,如下所示:

    def mul(a, b): 
        return a * b 

还有两个选择。我们可以使用lambda对象而不是完整的函数:

 **>>> add = lambda a, b: a + b 
>>> mul = lambda a, b: a * b** 

lambda函数是一个匿名函数,只包含两个基本元素:参数和返回表达式。lambda 内部没有语句,只有一个表达式。在这种情况下,表达式只是使用所需的运算符。

我们可以像这样使用它:

 **>>> def prod2(values): 
...     return reduce(lambda a, b: a*b, values, 1)** 

这提供了乘法函数作为一个lambda对象,而不需要额外的函数定义开销。

我们还可以从operator模块导入定义:

    from operator import add, mul 

这对所有内置的算术运算符都适用。

请注意,使用逻辑运算符ANDOR的逻辑归约与其他算术归约有所不同。这些运算符会短路:一旦值为falseand-reduce就可以停止处理。同样,一旦值为Trueor-reduce就可以停止处理。内置函数any()all()很好地体现了这一点。使用内置的reduce()很难捕捉到这种短路特性。

最大值和最小值

我们如何使用reduce()来计算最大值或最小值?这更复杂一些,因为没有可以使用的平凡基值。我们不能从零或一开始,因为这些值可能超出被最小化或最大化的值范围。

此外,内置的max()min()必须对空序列引发异常。这些函数无法完全适应sum()函数和reduce()函数的工作方式。

我们必须使用类似这样的东西来提供期望的功能集:

    def mymax(sequence): 
        try: 
            base = sequence[0] 
            max_rule = lambda a, b: a if a > b else b 
            reduce(max_rule, sequence, base) 
        except IndexError: 
            raise ValueError 

这个函数将从序列中选择第一个值作为基值。它创建了一个名为max_rulelambda对象,它选择两个参数值中较大的那个。然后我们可以使用数据中的这个基值和lambda对象。reduce()函数将在非空集合中找到最大的值。我们捕获了IndexError异常,以便一个空集合会引发ValueError异常。

这个例子展示了我们如何发明一个更复杂或精密的最小值或最大值函数,它仍然基于内置的reduce()函数。这样做的好处是可以替换减少集合到单个值时的样板for语句。

滥用的潜力

请注意,折叠(或在 Python 中称为reduce())可能会被滥用,导致性能不佳。我们必须谨慎使用reduce()函数,仔细考虑最终算法可能是什么样子。特别是,被折叠到集合中的运算符应该是一个简单的过程,比如加法或乘法。使用reduce()会将O(1)操作的复杂性改变为On)。

想象一下,如果在减少过程中应用的运算符涉及对集合进行排序会发生什么。在reduce()中使用复杂的运算符-具有On log n)复杂度-会将整体reduce()的复杂度改变为O log n)。

组合映射和减少转换

在本章的其他配方中,我们一直在研究映射、过滤和减少操作。我们分别研究了这三个操作:

  • 对集合应用转换配方显示map()函数

  • 选择子集-三种过滤方法配方显示filter()函数

  • 总结集合-如何减少配方显示reduce()函数

许多算法将涉及函数的组合。我们经常使用映射、过滤和减少来生成可用数据的摘要。此外,我们需要看一下使用迭代器和生成器函数的一个深刻限制。即这个限制:

提示

迭代器只能产生一次值。

如果我们从生成器函数和集合数据创建一个迭代器,那么迭代器只会产生数据一次。之后,它将看起来是一个空序列。

这是一个例子:

 **>>> typical_iterator = iter([0, 1, 2, 3, 4]) 
>>> sum(typical_iterator) 
10 
>>> sum(typical_iterator) 
0** 

我们通过手动将iter()函数应用于文字列表对象来创建了一个值序列的迭代器。sum()函数第一次使用typical_iterator的值时,它消耗了所有五个值。下一次我们尝试将任何函数应用于typical_iterator时,将不会有更多的值被消耗-迭代器看起来是空的。

这种基本的一次性限制驱动了在使用多种类型的生成器函数与映射、过滤和减少一起工作时的一些设计考虑。我们经常需要缓存中间结果,以便我们可以对数据执行多次减少。

准备好

使用堆叠的生成器表达式配方中,我们研究了需要多个处理步骤的数据。我们使用生成器函数合并了行。我们过滤掉了一些行,将它们从结果数据中删除。此外,我们对数据应用了许多映射,将日期和时间转换为更有用的信息。

我们想要通过两次减少来补充这一点,以获得一些平均值和方差信息。这些统计数据将帮助我们更充分地理解数据。

我们有一个用于记录大帆船燃料消耗的电子表格。它的行看起来像这样:

日期 引擎开启 燃料高度
关闭引擎 燃料高度
其他说明
10/25/2013 08:24 29
13:15 27
平静的海洋-锚所罗门岛
10/26/2013 09:12 27
18:25 22
波涛汹涌-锚在杰克逊溪

最初的处理是一系列操作,改变数据的组织,过滤掉标题,并计算一些有用的值。

如何做到...

  1. 从目标开始。在这种情况下,我们想要一个可以像这样使用的函数:
 **>>> round(sum_fuel(clean_data(row_merge(log_rows))), 3) 
      7.0** 

这显示了这种处理的三步模式。这三步将定义我们创建减少的各个部分的方法:

  1. 首先,转换数据组织。有时这被称为数据规范化。在这种情况下,我们将使用一个名为row_merge()的函数。有关此信息,请参阅使用堆叠的生成器表达式食谱。

  2. 其次,使用映射和过滤来清洁和丰富数据。这被定义为一个单一函数,clean_data()

  3. 最后,使用sum_fuel()将数据减少到总和。还有各种其他减少的方法是有意义的。我们可能计算平均值,或其他值的总和。我们可能想应用很多减少。

  4. 如果需要,定义数据结构规范化函数。这几乎总是必须是一个生成器函数。结构性的改变不能通过map()应用:

        from ch08_r02 import row_merge 

使用堆叠的生成器表达式食谱所示,此生成器函数将把每次航行的三行数据重组为每次航行的一行数据。当所有列都在一行中时,数据处理起来更容易。

  1. 定义整体数据清洗和增强数据函数。这是一个由简单函数构建的生成器函数。它是一系列map()filter()操作,将从源字段派生数据:
        def clean_data(source): 
            namespace_iter = map(make_namespace, source) 
            fitered_source = filter(remove_date, namespace_iter) 
            start_iter = map(start_datetime, fitered_source) 
            end_iter = map(end_datetime, start_iter) 
            delta_iter = map(duration, end_iter) 
            fuel_iter = map(fuel_use, delta_iter) 
            per_hour_iter = map(fuel_per_hour, fuel_iter) 
            return per_hour_iter 

每个map()filter()操作都涉及一个小函数,对数据进行单个转换或计算。

  1. 定义用于清洗和派生其他数据的单个函数。

  2. 将合并的数据行转换为SimpleNamespace。这将允许我们使用名称,如start_time,而不是row[1]

        from types import SimpleNamespace 
        def make_namespace(row): 
            ns = SimpleNamespace( 
                date = row[0], 
                start_time = row[1], 
                start_fuel_height = row[2], 
                end_time = row[4], 
                end_fuel_height = row[5], 
                other_notes = row[7] 
            ) 
            return ns 

此函数从源数据的选定列构建一个SimpleNamspace。第三列和第六列被省略,因为它们始终是零长度的字符串,''

  1. 这是由filter()用于删除标题行的函数。如果需要,这可以扩展到从源数据中删除空行或其他不良数据。想法是尽快在处理中删除不良数据:
        def remove_date(row_ns): 
            return not(row_ns.date == 'date') 

  1. 将数据转换为可用形式。首先,我们将字符串转换为日期。接下来的两个函数依赖于这个timestamp()函数,它将一个列中的date字符串加上另一个列中的time字符串转换为一个适当的datetime实例:
        import datetime 
        def timestamp(date_text, time_text): 
            date = datetime.datetime.strptime(date_text, "%m/%d/%y").date() 
            time = datetime.datetime.strptime(time_text, "%I:%M:%S %p").time() 
            timestamp = datetime.datetime.combine(date, time) 
            return timestamp 

这使我们能够根据datetime库进行简单的日期计算。特别是,减去两个时间戳将创建一个timedelta对象,其中包含任何两个日期之间的确切秒数。

这是我们将如何使用此函数为航行的开始和结束创建适当的时间戳:

        def start_datetime(row_ns): 
            row_ns.start_timestamp = timestamp(row_ns.date, row_ns.start_time) 
            return row_ns 

        def end_datetime(row_ns): 
            row_ns.end_timestamp = timestamp(row_ns.date, row_ns.end_time) 
            return row_ns 

这两个函数都将向SimpleNamespace添加一个新属性,并返回命名空间对象。这允许这些函数在map()操作的堆栈中使用。我们还可以重写这些函数,用不可变的namedtuple()替换可变的SimpleNamespace,并仍然保留map()操作的堆栈。

  1. 计算派生时间数据。在这种情况下,我们也可以计算持续时间。这是一个必须在前两个之后执行的函数:
        def duration(row_ns): 
            travel_time = row_ns.end_timestamp - row_ns.start_timestamp 
            row_ns.travel_hours = round(travel_time.total_seconds()/60/60, 1) 
            return row_ns 

这将把秒数差转换为小时值。它还会四舍五入到最接近的十分之一小时。比这更精确的信息基本上是噪音。出发和到达时间(通常)至少相差一分钟;它们取决于船长记得看手表的时间。在某些情况下,她可能已经估计了时间。

  1. 计算分析所需的其他指标。这包括创建转换为浮点数的高度值。最终的计算基于另外两个计算结果:
        def fuel_use(row_ns): 
            end_height = float(row_ns.end_fuel_height) 
            start_height = float(row_ns.start_fuel_height) 
            row_ns.fuel_change = start_height - end_height 
            return row_ns 

        def fuel_per_hour(row_ns): 
            row_ns.fuel_per_hour = row_ns.fuel_change/row_ns.travel_hours 
            return row_ns 

每小时燃料消耗量取决于整个前面的计算堆栈。旅行小时数来自分别计算的开始和结束时间戳。

它是如何工作的...

想法是创建一个遵循常见模板的复合操作:

  1. 规范化结构:这通常需要一个生成器函数,以在不同结构中读取数据并产生数据。

  2. 过滤和清洗:这可能涉及一个简单的过滤,就像这个例子中所示的那样。我们稍后会看到更复杂的过滤器。

  3. 通过映射或类定义的惰性属性派生数据:具有惰性属性的类是一个反应式对象。对源属性的任何更改都应该导致计算属性的更改。

在某些情况下,我们可能需要将基本事实与其他维度描述相结合。例如,我们可能需要查找参考数据,或解码编码字段。

一旦我们完成了初步步骤,我们就有了可用于各种分析的数据。很多时候,这是一个减少操作。初始示例计算了燃料使用量的总和。这里还有另外两个例子:

    from statistics import * 
    def avg_fuel_per_hour(iterable): 
        return mean(row.fuel_per_hour for row in iterable) 
    def stdev_fuel_per_hour(iterable): 
        return stdev(row.fuel_per_hour for row in iterable) 

这些函数将mean()stdev()函数应用于丰富数据的每一行的fuel_per_hour属性。

我们可以这样使用它:

 **>>> round(avg_fuel_per_hour( 
...    clean_data(row_merge(log_rows))), 3) 
0.48** 

我们使用clean_data(row_merge(log_rows))映射管道来清理和丰富原始数据。然后我们对这些数据应用了减少以获得我们感兴趣的值。

现在我们知道我们的 30 英寸高的油箱可以支持大约 60 小时的动力。以 6 节的速度,我们可以在满油箱的情况下行驶大约 360 海里。

还有更多...

正如我们所指出的,我们只能对可迭代的数据源执行一次减少。如果我们想要计算多个平均值,或者平均值和方差,我们将需要使用稍微不同的模式。

为了计算数据的多个摘要,我们需要创建一种可以重复进行摘要的序列对象:

    data = tuple(clean_data(row_merge(log_rows))) 
    m = avg_fuel_per_hour(data) 
    s = 2*stdev_fuel_per_hour(data) 
    print("Fuel use {m:.2f} ±{s:.2f}".format(m=m, s=s)) 

在这里,我们从清理和丰富的数据中创建了一个tuple。这个tuple将产生一个可迭代对象,但与生成器函数不同,它可以多次产生这个可迭代对象。我们可以使用tuple对象计算两个摘要。

这个设计涉及大量的源数据转换。我们使用了一系列 map、filter 和 reduce 操作来构建它。这提供了很大的灵活性。

另一种方法是创建一个类定义。一个类可以设计为具有惰性属性。这将创建一种反应式设计,体现在单个代码块中。请参阅使用属性进行惰性属性配方,了解这方面的示例。

我们还可以在itertools模块中使用tee()函数进行这种处理:

    from itertools import tee 
    data1, data2 = tee(clean_data(row_merge(log_rows)), 2) 
    m = avg_fuel_per_hour(data1) 
    s = 2*stdev_fuel_per_hour(data2) 

我们使用tee()创建了clean_data(row_merge(log_rows))的可迭代输出的两个克隆。我们可以使用这两个克隆来计算平均值和标准差。

另请参阅

  • 我们已经看过如何在使用堆叠的生成器表达式配方中结合映射和过滤。

  • 我们在使用属性进行惰性属性配方中看过懒惰属性。此外,这个配方还涉及 map-reduce 处理的一些重要变化。

实现“存在”处理

我们一直在研究的处理模式都可以用量词对于所有来总结。这已经是所有处理定义的一个隐含部分:

  • 映射:对于源中的所有项目,应用映射函数。我们可以使用量词来明确这一点:{ M ( x ) ∀ x : xC }

  • 过滤:对于源中的所有项目,传递那些过滤函数为true的项目。这里也使用了量词来明确这一点。如果某个函数F(x)true,我们希望从集合C中获取所有值x:{ xx : xC if F ( x )}

  • 减少:对于源中的所有项目,使用给定的运算符和基本值来计算摘要。这个规则是一个递归,对于源集合或可迭代的所有值都清晰地适用:实现“存在”处理

我们在 Pythonic 意义上使用了C[0..n]的符号。索引位置为 0 和n-1的值是包括在内的,但索引位置为n的值不包括在内。这意味着这个范围内没有元素。

更重要的是C[0..n-1 ]C[n-1] = C 。也就是说,当我们从范围中取出最后一项时,不会丢失任何项——我们总是在处理集合中的所有项。此外,我们不会两次处理项C[n-1]。它不是C[0..n-1]范围的一部分,而是一个独立的项C[n-1]

我们如何使用生成器函数编写一个进程,当第一个值匹配某个谓词时停止?我们如何避免对于所有并用存在量化我们的逻辑?

准备工作

我们可能需要另一个量词——存在,∃。让我们看一个存在性测试的例子。

我们可能想知道一个数是素数还是合数。我们不需要知道一个数的所有因子就能知道它不是素数。只要证明存在一个因子就足以知道一个数不是素数。

我们可以定义一个素数谓词P(n),如下所示:

P ( n ) = ¬∃ i : 2 ≤ i < n if n mod i = 0

一个数n,如果不存在一个值i(在 2 和这个数之间),能够整除这个数,那么它是素数。我们可以将否定移到周围,并重新表述如下:

¬P ( n ) = ∃ i : 2 ≤ i < n if n mod i = 0

一个数n,如果存在一个值i,在 2 和这个数本身之间,能够整除这个数,那么它是合数(非素数)。我们不需要知道所有这样的值。满足谓词的一个值的存在就足够了。

一旦我们找到这样的数字,我们可以从任何迭代中提前中断。这需要在forif语句中使用break语句。因为我们不处理所有的值,所以我们不能轻易使用高阶函数,比如map()filter()reduce()

如何做...

  1. 定义一个生成器函数模板,它将跳过项目,直到找到所需的项目。这将产生只有一个通过谓词测试的值:
        def find_first(predicate, iterable): 
            for item in iterable: 
                if predicate(item): 
                    yield item 
                    break 

  1. 定义一个谓词函数。对于我们的目的,一个简单的lambda对象就可以了。此外,lambda 允许我们使用一个绑定到迭代的变量和一个自由于迭代的变量。这是表达式:
        lambda i: n % i == 0 

在这个 lambda 中,我们依赖一个非局部值n。这将是 lambda 的全局值,但仍然是整个函数的局部值。如果n % i0,那么in的一个因子,n不是素数。

  1. 使用给定的范围和谓词应用该函数:
        import math 
        def prime(n): 
            factors = find_first( 
                lambda i: n % i == 0, 
                range(2, int(math.sqrt(n)+1)) ) 
            return len(list(factors)) == 0 

如果factors可迭代对象有一个项,那么n是合数。否则,factors可迭代对象中没有值,这意味着n是一个素数。

实际上,我们不需要测试两个和n之间的每一个数字,以确定n是否是素数。只需要测试值i,使得2i < √ n

它是如何工作的...

find_first()函数中,我们引入了一个break语句来停止处理源可迭代对象。当for语句停止时,生成器将到达函数的末尾,并正常返回。

从这个生成器中消耗值的进程将得到StopIteration异常。这个异常意味着生成器不会再产生值。find_first()函数会引发一个异常,但这不是一个错误。这是信号一个可迭代对象已经完成了输入值的处理的正常方式。

在这种情况下,信号意味着两种可能:

  • 如果产生了一个值,那么这个值是n的一个因子

  • 如果没有产生值,那么n是素数

for语句中提前中断的这个小改变,使得生成器函数的含义发生了巨大的变化。与处理源的所有值不同,find_first()生成器将在谓词为true时停止处理。

这与过滤器不同,过滤器会消耗所有的源值。当使用break语句提前离开for语句时,一些源值可能不会被处理。

还有更多...

itertools模块中,有一个替代find_first()函数的方法。takewhile()函数使用一个谓词函数来保持从输入中获取值。当谓词变为false时,函数停止处理值。

我们可以很容易地将 lambda 从lambda i: n % i == 0改为lambda i: n % i != 0。这将允许函数在它们不是因子时接受值。任何是因子的值都会通过结束takewhile()过程来停止处理。

让我们来看两个例子。我们将测试13是否为质数。我们需要检查范围内的数字。我们还将测试15是否为质数:

 **>>> from itertools import takewhile 
>>> n = 13 
>>> list(takewhile(lambda i: n % i != 0, range(2, 4))) 
[2, 3] 
>>> n = 15 
>>> list(takewhile(lambda i: n % i != 0, range(2, 4))) 
[2]** 

对于质数,所有的测试值都通过了takewhile()谓词。结果是给定数字n的非因子列表。如果非因子的集合与被测试的值的集合相同,那么n是质数。在13的情况下,两个值的集合都是[2, 3]

对于合数,一些值通过了takewhile()谓词。在这个例子中,2不是15的因子。然而,3是一个因子;这不符合谓词。非因子的集合[2]与被测试的值的集合[2, 3]不同。

我们最终得到的函数看起来像这样:

    def prime_t(n): 
        tests = set(range(2, int(math.sqrt(n)+1))) 
        non_factors = set( 
            takewhile( 
                lambda i: n % i != 0, 
                tests 
            ) 
        ) 
        return tests == non_factors 

这创建了两个中间集合对象testsnon_factors。如果所有被测试的值都不是因子,那么这个数就是质数。之前展示的函数,基于find_first()只创建了一个中间列表对象。那个列表最多只有一个成员,使得数据结构更小。

itertools 模块

itertools模块中还有许多其他函数,我们可以用来简化复杂的映射-归约应用:

  • filterfalse():它是内置filter()函数的伴侣。它颠倒了filter()函数的谓词逻辑;它拒绝谓词为true的项目。

  • zip_longest():它是内置zip()函数的伴侣。内置的zip()函数在最短的可迭代对象耗尽时停止合并项目。zip_longest()函数将提供一个给定的填充值,以使短的可迭代对象与最长的可迭代对象匹配。

  • starmap():这是对基本map()算法的修改。当我们执行map(function, iter1, iter2)时,每个可迭代对象中的一个项目将作为给定函数的两个位置参数提供。starmap()期望一个可迭代对象提供一个包含参数值的元组。实际上:

        map = starmap(function, zip(iter1, iter2)) 

还有其他一些我们可能也会用到的:

  • accumulate():这个函数是内置sum()函数的一个变体。它会产生在达到最终总和之前产生的每个部分总和。

  • chain():这个函数将按顺序合并可迭代对象。

  • compress():这个函数使用一个可迭代对象作为数据源,另一个可迭代对象作为选择器的数据源。当选择器的项目为 true 时,相应的数据项目被传递。否则,数据项目被拒绝。这是基于真假值的逐项过滤器。

  • dropwhile():只要这个函数的谓词为true,它就会拒绝值。一旦谓词变为false,它就会传递所有剩余的值。参见takewhile()

  • groupby():这个函数使用一个键函数来控制组的定义。具有相同键值的项目被分组到单独的迭代器中。为了使结果有用,原始数据应该按键的顺序排序。

  • islice():这个函数类似于切片表达式,只不过它适用于可迭代对象,而不是列表。当我们使用list[1:]来丢弃列表的第一行时,我们可以使用islice(iterable, 1)来丢弃可迭代对象的第一个项目。

  • takewhile():只要谓词为true,这个函数就会传递值。一旦谓词变为false,就停止处理任何剩余的值。参见dropwhile()

  • tee():这将单个可迭代对象分成多个克隆。然后可以单独消耗每个克隆。这是在单个可迭代数据源上执行多个减少的一种方法。

创建一个部分函数

当我们查看reduce()sorted()min()max()等函数时,我们会发现我们经常有一些永久参数值。例如,我们可能会发现需要在几个地方写类似这样的东西:

    reduce(operator.mul, ..., 1) 

对于reduce()的三个参数,只有一个-要处理的可迭代对象-实际上会改变。运算符和基本值参数基本上固定为operator.mul1

显然,我们可以为此定义一个全新的函数:

    def prod(iterable): 
        return reduce(operator.mul, iterable, 1) 

然而,Python 有一些简化这种模式的方法,这样我们就不必重复使用样板defreturn语句。

我们如何定义一个具有预先提供一些参数的函数?

请注意,这里的目标与提供默认值不同。部分函数不提供覆盖默认值的方法。相反,我们希望创建尽可能多的部分函数,每个函数都提前绑定了特定的参数。

准备工作

一些统计建模是用标准分数来完成的,有时被称为z 分数。其想法是将原始测量标准化到一个可以轻松与正态分布进行比较的值,并且可以轻松与以不同单位测量的相关数字进行比较。

计算如下:

z = ( x - μ)/σ

这里,x是原始值,μ是总体均值,σ是总体标准差。值z的均值为 0,标准差为 1。这使得它特别容易处理。

我们可以使用这个值来发现异常值-与均值相距甚远的值。我们期望我们的z值(大约)99.7%会在-3 和+3 之间。

我们可以定义一个这样的函数:

    def standarize(mean, stdev, x): 
        return (x-mean)/stdev 

这个standardize()函数将从原始分数x计算出 z 分数。这个函数有两种类型的参数:

  • meanstdev的值基本上是固定的。一旦我们计算出总体值,我们将不断地将它们提供给standardize()函数。

  • x的值更加可变。

假设我们有一系列大块文本中的数据样本:

    text_1 = '''10  8.04 
    8       6.95 
    13      7.58 
    ... 
    5       5.68 
    ''' 

我们已经定义了两个小函数来将这些数据转换为数字对。第一个简单地将每个文本块分解为一系列行,然后将每行分解为一对文本项:

    text_parse = lambda text: (r.split() for r in text.splitlines()) 

我们已经使用文本块的splitlines()方法创建了一系列行。我们将其放入生成器函数中,以便每个单独的行都可以分配给r。使用r.split()将每行中的两个文本块分开。

如果我们使用list(text_parse(text_1)),我们会看到这样的数据:

    [['10', '8.04'], 
     ['8', '6.95'], 
     ['13', '7.58'], 
     ... 
     ['5', '5.68']] 

我们需要进一步丰富这些数据,使其更易于使用。我们需要将字符串转换为适当的浮点值。在这样做的同时,我们将从每个项目创建SimpleNamespace实例:

    from types import SimpleNamespace 
    row_build = lambda rows: (SimpleNamespace(x=float(x), y=float(y)) for x,y in rows) 

lambda对象通过将float()函数应用于每行中的每个字符串项来创建SimpleNamespace实例。这给了我们可以处理的数据。

我们可以将这两个lambda对象应用于数据,以创建一些可用的数据集。之前,我们展示了text_1。我们假设我们有一个类似的第二组数据分配给text_2

    data_1 = list(row_build(text_parse(text_1))) 
    data_2 = list(row_build(text_parse(text_2))) 

这样就创建了两个类似文本块的数据。每个都有数据点对。SimpleNamespace对象有两个属性,xy,分配给数据的每一行。

请注意,这个过程创建了types.SimpleNamespace的实例。当我们打印它们时,它们将使用namespace类显示。这些是可变对象,因此我们可以用标准化的 z 分数更新每一个。

打印data_1看起来像这样:

    [namespace(x=10.0, y=8.04), namespace(x=8.0, y=6.95), 
namespace(x=13.0, y=7.58), 
    ..., 
    namespace(x=5.0, y=5.68)] 

例如,我们将计算x属性的标准化值。这意味着获取均值和标准差。然后我们需要将这些值应用于标准化我们两个集合中的数据。看起来是这样的:

    import statistics 
    mean_x = statistics.mean(item.x for item in data_1) 
    stdev_x = statistics.stdev(item.x for item in data_1) 

    for row in data_1: 
        z_x = standardize(mean_x, stdev_x, row.x) 
        print(row, z_x) 

    for row in data_2: 
        z_x = standardize(mean_x, stdev_x, row.x) 
        print(row, z_x) 

每次评估standardize()时提供mean_v1stdev_v1值可能会使算法混乱,因为这些细节并不是非常重要。在一些相当复杂的算法中,这种混乱可能导致更多的困惑而不是清晰。

如何做...

除了简单地使用def语句创建具有部分参数值的函数之外,我们还有两种其他方法来创建部分函数:

  • 使用functools模块的partial()函数

  • 创建lambda对象

使用 functools.partial()

  1. functools导入partial函数:
        from functools import partial 

  1. 使用partial()创建对象。我们提供基本函数,以及需要包括的位置参数。在定义部分时未提供的任何参数在评估部分时必须提供:
        z = partial(standardize, mean_x, stdev_x) 

  1. 我们已为前两个位置参数meanstdev提供了值。第三个位置参数x必须在计算值时提供。

创建lambda对象

  1. 定义绑定固定参数的lambda对象:
        lambda x: standardize(mean_v1, stdev_v1, x) 

  1. 使用lambda创建对象:
        z = lambda x: standardize(mean_v1, stdev_v1, x) 

它是如何工作的...

这两种技术都创建了一个可调用对象——一个名为z()的函数,其值为mean_v1stdev_v1已经绑定到前两个位置参数。使用任一方法,我们的处理看起来可能是这样的:

    for row in data_1: 
        print(row, z(row.x)) 

    for row in data_2: 
        print(row, z(row.x)) 

我们已将z()函数应用于每组数据。因为函数已经应用了一些参数,所以在这里使用看起来非常简单。

我们还可以这样做,因为每行都是一个可变对象:

    for row in data_1: 
        row.z = z(row.v1) 

    for row in data_2: 
        row.z = z(row.v1) 

我们已更新行,包括一个新属性z,其值为z()函数。在复杂的算法中,调整行对象可能是一个有用的简化。

创建z()函数的两种技术之间存在显着差异:

  • partial()函数绑定参数的实际值。对使用的变量进行的任何后续更改都不会改变创建的部分函数的定义。创建z = partial(standardize(mean_v1, stdev_v1))后,更改mean_v1stdev_v1的值不会对部分函数z()产生影响。

  • lambda对象绑定变量名,而不是值。对变量值的任何后续更改都将改变 lambda 的行为方式。创建z = lambda x: standardize(mean_v1, stdev_v1, x)后,更改mean_v1stdev_v1的值将改变lambda对象z()使用的值。

我们可以稍微修改 lambda 以绑定值而不是名称:

    z = lambda x, m=mean_v1, s=stdev_v1: standardize(m, s, x) 

这将提取mean_v1stdev_v1的值以创建lambda对象的默认值。mean_v1stdev_v1的值现在与lambda对象z()的正常操作无关。

还有更多...

在创建部分函数时,我们可以提供关键字参数值以及位置参数值。在许多情况下,这很好用。也有一些情况不适用。

特别是reduce()函数不能简单地转换为部分函数。参数的顺序不是创建部分的理想顺序。reduce()函数具有以下概念定义。这不是它的定义方式——这是它看起来的定义方式:

    def reduce(function, iterable, initializer=None) 

如果这是实际定义,我们可以这样做:

    prod = partial(reduce(mul, initializer=1)) 

实际上,我们无法这样做,因为reduce()的定义比看起来更复杂一些。reduce()函数不允许命名参数值。这意味着我们被迫使用 lambda 技术:

 **>>> from operator import mul 
>>> from functools import reduce 
>>> prod = lambda x: reduce(mul, x, 1)** 

我们使用lambda对象定义了一个只有一个参数prod()函数。这个函数使用两个固定参数和一个可变参数与reduce()一起使用。

有了prod()的定义,我们可以定义依赖于计算乘积的其他函数。下面是factorial函数的定义:

 **>>> factorial = lambda x: prod(range(2,x+1)) 
>>> factorial(5) 
120** 

factorial()的定义取决于prod()prod()的定义是一种使用reduce()和两个固定参数值的部分函数。我们设法使用了一些定义来创建一个相当复杂的函数。

在 Python 中,函数是一个对象。我们已经看到了函数可以作为参数传递的多种方式。接受另一个函数作为参数的函数有时被称为高阶函数

同样,函数也可以返回一个函数对象作为结果。这意味着我们可以创建一个像这样的函数:

    def prepare_z(data): 
        mean_x = statistics.mean(item.x for item in data_1) 
        stdev_x = statistics.stdev(item.x for item in data_1) 
        return partial(standardize, mean_x, stdev_x) 

我们已经定义了一个在一组(xy)样本上的函数。我们计算了每个样本的x属性的均值和标准差。然后我们创建了一个可以根据计算出的统计数据标准化得分的部分函数。这个函数的结果是一个我们可以用于数据分析的函数:

    z = prepare_z(data_1) 
    for row in data_2: 
        print(row, z(row.x)) 

当我们评估prepare_z()函数时,它返回了一个函数。我们将这个函数赋给一个变量z。这个变量是一个可调用对象;它是函数z(),它将根据样本均值和标准差标准化得分。

使用不可变数据结构简化复杂算法

有状态对象的概念是面向对象编程的一个常见特性。我们在第六章和第七章中看过与对象和状态相关的一些技术,类和对象的基础更高级的类设计。面向对象设计的重点之一是创建能够改变对象状态的方法。

我们还在使用堆叠的生成器表达式组合 map 和 reduce 转换创建部分函数配方中看过一些有状态的函数式编程技术。我们使用types.SimpleNamespace,因为它创建了一个简单的、有状态的对象,具有易于使用的属性名称。

在大多数情况下,我们一直在处理具有 Python dict对象定义属性的对象。唯一的例外是使用 slots 优化小对象配方,其中属性由__slots__属性定义固定。

使用dict对象存储对象的属性有几个后果:

  • 我们可以轻松地添加和删除属性。我们不仅仅局限于设置和获取已定义的属性;我们也可以创建新属性。

  • 每个对象使用的内存量比最小必要量稍微大一些。这是因为字典使用哈希算法来定位键和值。哈希处理通常需要比其他结构(如listtuple)更多的内存。对于非常大量的数据,这可能会成为一个问题。

有状态的面向对象编程最重要的问题是有时很难对对象的状态变化写出清晰的断言。与其定义关于状态变化的断言,更容易的方法是创建完全新的对象,其状态可以简单地映射到对象的类型。这与 Python 类型提示结合使用,有时可以创建更可靠、更易于测试的软件。

当我们创建新对象时,数据项和计算之间的关系可以被明确捕获。mypy项目提供了工具,可以分析这些类型提示,以确认复杂算法中使用的对象是否被正确使用。

在某些情况下,我们也可以通过避免首先使用有状态对象来减少内存的使用量。我们有两种技术可以做到这一点:

  • 使用带有__slots__的类定义:有关此内容,请参阅使用 slots 优化小对象的示例。这些对象是可变的,因此我们可以使用新值更新属性。

  • 使用不可变的tuplesnamedtuples:有关此内容,请参阅设计具有少量独特处理的类的示例。这些对象是不可变的。我们可以创建新对象,但无法更改对象的状态。整体内存的成本节约必须平衡创建新对象的额外成本。

不可变对象可能比可变对象稍快。更重要的好处是算法设计。在某些情况下,编写函数从旧的不可变对象创建新的不可变对象可能比处理有状态对象的算法更简单、更容易测试和调试。编写类型提示可以帮助这个过程。

准备工作

正如我们在使用堆叠的生成器表达式实现“存在”处理的示例中所指出的,我们只能处理生成器一次。如果我们需要多次处理它,可迭代对象的序列必须转换为像列表或元组这样的完整集合。

这通常会导致一个多阶段的过程:

  • 初始提取数据:这可能涉及数据库查询或读取.csv文件。这个阶段可以被实现为一个产生行或甚至返回生成器函数的函数。

  • 清洗和过滤数据:这可能涉及一系列生成器表达式,可以仅处理一次源。这个阶段通常被实现为一个包含多个映射和过滤操作的函数。

  • 丰富数据:这也可能涉及一系列生成器表达式,可以一次处理一行数据。这通常是一系列的映射操作,用于从现有数据中创建新的派生数据。

  • 减少或总结数据:这可能涉及多个摘要。为了使其工作,丰富阶段的输出需要是可以多次处理的集合对象。

在某些情况下,丰富和总结过程可能会交错进行。正如我们在创建部分函数示例中看到的,我们可能会先进行一些总结,然后再进行更多的丰富。

处理丰富阶段有两种常见策略:

  • 可变对象:这意味着丰富处理会添加或设置属性的值。可以通过急切计算来完成,因为属性被设置。请参阅使用可设置属性更新急切属性的示例。也可以使用惰性属性来完成。请参阅使用惰性属性的示例。我们已经展示了使用types.SimpleNamespace的示例,其中计算是在与类定义分开的函数中完成的。

  • 不可变对象:这意味着丰富过程从旧对象创建新对象。不可变对象源自tuple或由namedtuple()创建的类型。这些对象的优势在于非常小且非常快。此外,缺乏任何内部状态变化使它们非常简单。

假设我们有一系列大块文本中的数据样本:

    text_1 = '''10  8.04 
    8       6.95 
    13      7.58 
    ... 
    5       5.68 
    ''' 

我们的目标是一个包括getcleanseenrich操作的三步过程:

    data = list(enrich(cleanse(get(text)))) 

get()函数从源获取数据;在这种情况下,它会解析大块文本。cleanse()函数将删除空行和其他无法使用的数据。enrich()函数将对清理后的数据进行最终计算。我们将分别查看此管道的每个阶段。

get()函数仅限于纯文本处理,尽量少地进行过滤:

    from typing import * 

    def get(text: str) -> Iterator[List[str]]: 
        for line in text.splitlines(): 
            if len(line) == 0: 
                continue 
            yield line.split() 

为了编写类型提示,我们已导入了typing模块。这使我们能够对此函数的输入和输出进行明确声明。get()函数接受一个字符串str。它产生一个List[str]结构。输入的每一行都被分解为一系列值。

这个函数将生成所有非空数据行。这里有一个小的过滤功能,但它与数据序列化的一个小技术问题有关,而不是一个特定于应用程序的过滤规则。

cleanse()函数将生成命名元组的数据。这将应用一些规则来确保数据是有效的:

    from collections import namedtuple 

    DataPair = namedtuple('DataPair', ['x', 'y']) 

    def cleanse(iterable: Iterable[List[str]]) -> Iterator[DataPair]: 
        for text_items in iterable: 
            try: 
                x_amount = float(text_items[0]) 
                y_amount = float(text_items[1]) 
                yield DataPair(x_amount, y_amount) 
            except Exception as ex: 
                print(ex, repr(text_items)) 

我们定义了一个namedtuple,名为DataPair。这个项目有两个属性,xy。如果这两个文本值可以正确转换为float,那么这个生成器将产生一个有用的DataPair实例。如果这两个文本值无法转换,这将显示一个错误,指出有问题的对。

注意mypy项目类型提示中的技术细微之处。带有yield语句的函数是一个迭代器。由于正式关系,我们可以将其用作可迭代对象,这种关系表明迭代器是可迭代对象的一种。

这里可以应用额外的清洗规则。例如,assert语句可以添加到try语句中。任何由意外或无效数据引发的异常都将停止处理给定输入行。

这个初始的cleanse()get()处理的结果如下:

    list(cleanse(get(text))) 
    The output looks like this: 
    [DataPair(x=10.0, y=8.04), 
     DataPair(x=8.0, y=6.95), 
     DataPair(x=13.0, y=7.58), 
     ..., 
     DataPair(x=5.0, y=5.68)] 

在这个例子中,我们将按每对的y值进行排名。这需要首先对数据进行排序,然后产生排序后的值,并陦一个额外的属性值,即y排名顺序。

如何做...

  1. 定义丰富的namedtuple
        RankYDataPair = namedtuple('RankYDataPair', ['y_rank', 'pair']) 

请注意,我们特意在这个新的数据结构中将原始对作为数据项包含在内。我们不想复制各个字段;相反,我们将原始对象作为一个整体合并在一起。

  1. 定义丰富函数:
        PairIter = Iterable[DataPair] 
        RankPairIter = Iterator[RankYDataPair] 

        def rank_by_y(iterable:PairIter) -> RankPairIter: 

我们在这个函数中包含了类型提示,以清楚地表明这个丰富函数期望和返回的类型。我们单独定义了类型提示,这样它们会更短,并且可以在其他函数中重复使用。

  1. 编写丰富的主体。在这种情况下,我们将进行排名排序,因此我们需要使用原始y属性进行排序。我们从旧对象创建新对象,因此函数会生成RankYDataPair的实例:
        all_data = sorted(iterable, key=lambda pair:pair.y) 
        for y_rank, pair in enumerate(all_data, start=1): 
            yield RankYDataPair(y_rank, pair) 

我们使用enumerate()为每个值创建排名顺序号。对于一些统计处理来说,起始值为1有时很方便。在其他情况下,默认的起始值0也能很好地工作。

整个函数如下:

    def rank_by_y(iterable: PairIter) -> RankPairIter: 
        all_data = sorted(iterable, key=lambda pair:pair.y) 
        for y_rank, pair in enumerate(all_data, start=1): 
            yield RankYDataPair(y_rank, pair) 

我们可以在一个更长的表达式中使用它来获取、清洗,然后排名。使用类型提示可以使这一点比涉及有状态对象的替代方案更清晰。在某些情况下,代码的清晰度可能会有很大的改进。

它是如何工作的...

rank_by_y()函数的结果是一个包含原始对象和丰富结果的新对象。这是我们如何使用这个堆叠的生成器序列的:rank_by_y()cleanse()get()

 **>>> data = rank_by_y(cleanse(get(text_1))) 
>>> pprint(list(data)) 
[RankYDataPair(y_rank=1, pair=DataPair(x=4.0, y=4.26)), 
 RankYDataPair(y_rank=2, pair=DataPair(x=7.0, y=4.82)), 
 RankYDataPair(y_rank=3, pair=DataPair(x=5.0, y=5.68)), 
 ..., 
 RankYDataPair(y_rank=11, pair=DataPair(x=12.0, y=10.84))]** 

数据按y值升序排列。我们现在可以使用这些丰富的数据值进行进一步的分析和计算。

在许多情况下,创建新对象可能更能表达算法,而不是改变对象的状态。这通常是一个主观的判断。

Python 类型提示最适合用于创建新对象。因此,这种技术可以提供强有力的证据,证明复杂的算法是正确的。使用mypy可以使不可变对象更具吸引力。

最后,当我们使用不可变对象时,有时会看到一些小的加速。这依赖于 Python 的三个特性之间的平衡才能有效:

  • 元组是小型数据结构。使用它们可以提高性能。

  • Python 中对象之间的任何关系都涉及创建对象引用,这是一个非常小的数据结构。一系列相关的不可变对象可能比一个可变对象更小。

  • 对象的创建可能是昂贵的。创建太多不可变对象会超过其好处。

前两个功能带来的内存节省必须与第三个功能带来的处理成本相平衡。当存在大量数据限制处理速度时,内存节省可以带来更好的性能。

对于像这样的小例子,数据量非常小,对象创建成本与减少内存使用量的任何成本节省相比都很大。对于更大的数据集,对象创建成本可能小于内存不足的成本。

还有更多...

这个配方中的get()cleanse()函数都涉及到类似的数据结构:Iterable[List[str]]Iterator[List[str]]。在collections.abc模块中,我们看到Iterable是通用定义,而IteratorIterable的特殊情况。

用于本书的mypy版本——mypy 0.2.0-dev——对具有yield语句的函数被定义为Iterator非常严格。未来的版本可能会放宽对子类关系的严格检查,允许我们在两种情况下使用同一定义。

typing模块包括namedtuple()函数的替代品:NamedTuple()。这允许对元组中的各个项目进行数据类型的指定。

看起来是这样的:

    DataPair = NamedTuple('DataPair', [ 
            ('x', float), 
            ('y', float) 
        ] 
    ) 

我们几乎可以像使用collection.namedtuple()一样使用typing.NamedTuple()。属性的定义使用了一个两元组的列表,而不是名称的列表。两元组有一个名称和一个类型定义。

这个补充类型定义被mypy用来确定NamedTuple对象是否被正确填充。其他人也可以使用它来理解代码并进行适当的修改或扩展。

在 Python 中,我们可以用不可变对象替换一些有状态的对象。但是有一些限制。例如,列表、集合和字典等集合必须保持为可变对象。在其他编程语言中,用不可变的单子替换这些集合可能效果很好,但在 Python 中不是这样的。

使用 yield from 语句编写递归生成器函数

有许多算法可以清晰地表达为递归。在围绕 Python 的堆栈限制设计递归函数配方中,我们看了一些可以优化以减少函数调用次数的递归函数。

当我们查看一些数据结构时,我们发现它们涉及递归。特别是,JSON 文档(以及 XML 和 HTML 文档)可以具有递归结构。JSON 文档可能包含一个包含其他复杂对象的复杂对象。

在许多情况下,使用生成器处理这些类型的结构有很多优势。我们如何编写能够处理递归的生成器?yield from语句如何避免我们编写额外的循环?

准备工作

我们将看一种在复杂数据结构中搜索有序集合的所有匹配值的方法。在处理复杂的 JSON 文档时,我们经常将它们建模为字典-字典和字典-列表结构。当然,JSON 文档不是一个两级的东西;字典-字典实际上意味着字典-字典-字典...同样,字典-列表实际上意味着字典-列表-...这些都是递归结构,这意味着搜索必须遍历整个结构以寻找特定的键或值。

具有这种复杂结构的文档可能如下所示:

    document = { 
        "field": "value1", 
        "field2": "value", 
        "array": [ 
            {"array_item_key1": "value"}, 
            {"array_item_key2": "array_item_value2"} 
        ], 
        "object": { 
            "attribute1": "value", 
            "attribute2": "value2" 
        } 
    } 

这显示了一个具有四个键fieldfield2arrayobject的文档。每个键都有一个不同的数据结构作为其关联值。一些值是唯一的,一些是重复的。这种重复是我们的搜索必须在整个文档中找到所有实例的原因。

核心算法是深度优先搜索。这个函数的输出将是一个标识目标值的路径列表。每个路径将是一系列字段名或字段名与索引位置混合的序列。

在前面的例子中,值value可以在三个地方找到:

  • ["array", 0, "array_item_key1"]:这个路径从名为array的顶级字段开始,然后访问列表的第零项,然后是一个名为array_item_key1的字段

  • ["field2"]:这个路径只有一个字段名,其中找到了值

  • ["object", "attribute1"]:这个路径从名为object的顶级字段开始,然后是该字段的子attribute1

find_value()函数在搜索整个文档寻找目标值时,会产生这两个路径。这是这个搜索函数的概念概述:

    def find_path(value, node, path=[]): 
        if isinstance(node, dict): 
            for key in node.keys(): 
                # find_value(value, node[key], path+[key]) 
                # This must yield multiple values 
        elif isinstance(node, list): 
            for index in range(len(node)): 
                # find_value(value, node[index], path+[index]) 
                # This will yield multiple values 
        else: 
            # a primitive type 
            if node == value: 
                yield path 

find_path()过程中有三种选择:

  • 当节点是一个字典时,必须检查每个键的值。值可以是任何类型的数据,因此我们将对每个值递归使用find_path()函数。这将产生一系列匹配。

  • 如果节点是一个列表,必须检查每个索引位置的项目。项目可以是任何类型的数据,因此我们将对每个值递归使用find_path()函数。这将产生一系列匹配。

  • 另一种选择是节点是一个原始值。JSON 规范列出了可能出现在有效文档中的许多原始值。如果节点值是目标值,我们找到了一个实例,并且可以产生这个单个匹配。

处理递归有两种方法。一种是这样的:

    for match in find_value(value, node[key], path+[key]): 
        yield match 

对于这样一个简单的想法来说,这似乎有太多的样板。另一种方法更简单,也更清晰一些。

如何做...

  1. 写出完整的for语句:
        for match in find_value(value, node[key], path+[key]): 
            yield match 

出于调试目的,我们可以在for语句的主体中插入一个print()函数。

  1. 一旦确定事情运行正常,就用yield from语句替换这个:
        yield from find_value(value, node[key], path+[key]) 

完整的深度优先find_value()搜索函数将如下所示:

    def find_path(value, node, path=[]): 
        if isinstance(node, dict): 
            for key in node.keys(): 
                yield from find_path(value, node[key], path+[key]) 
        elif isinstance(node, list): 
            for index in range(len(node)): 
                yield from find_path(value, node[index], path+[index]) 
        else: 
            if node == value: 
                yield path 

当我们使用find_path()函数时,它看起来像这样:

 **>>> list(find_path('array_item_value2', document)) 
[['array', 1, 'array_item_key2']]** 

find_path()函数是可迭代的。它可以产生许多值。我们消耗了所有的结果来创建一个列表。在这个例子中,列表只有一个项目,['array', 1, 'array_item_key2']。这个项目有指向匹配项的路径。

然后我们可以评估document['array'][1]['array_item_key2']来找到被引用的值。

当我们寻找非唯一值时,我们可能会看到这样的列表:

 **>>> list(find_value('value', document)) 
[['array', 0, 'array_item_key1'], 
 ['field2'], 
 ['object', 'attribute1']]** 

结果列表有三个项目。每个项目都提供了指向目标值value的路径。

它是如何工作的...

yield from X语句是以下内容的简写:

    for item in X: 
        yield item 

这使我们能够编写一个简洁的递归算法,它将作为迭代器运行,并正确地产生多个值。

这也可以在不涉及递归函数的情况下使用。在涉及可迭代结果的任何地方使用yield from语句都是完全合理的。然而,对于递归函数来说,这是一个很大的简化,因为它保留了一个明确的递归结构。

还有更多...

另一种常见的定义风格是使用追加操作组装列表。我们可以将这个重写为迭代器,避免构建列表对象的开销。

当分解一个数字时,我们可以这样定义质因数集:

还有更多...

如果值x是质数,它在质因数集中只有自己。否则,必须存在某个质数n,它是x的最小因数。我们可以从n开始组装一个因数集,并包括x/n的所有因数。为了确保只找到质因数,n必须是质数。如果我们按升序搜索,我们会在找到复合因数之前找到质因数。

我们有两种方法在 Python 中实现这个:一种是构建一个列表,另一种是生成因数。这是一个构建列表的函数:

    import math 
    def factor_list(x): 
        limit = int(math.sqrt(x)+1) 
        for n in range(2, limit): 
            q, r = divmod(x, n) 
            if r == 0: 
                return [n] + factor_list(q) 
        return [x] 

这个factor_list()函数将搜索所有数字n,使得 2 ≤ n < √ x。找到x的第一个因子的数字将是最小的因子。它也将是质数。当然,我们会搜索一些复合值,浪费时间。例如,在测试了二和三之后,我们还将测试四和六这样的值,尽管它们是复合数,它们的所有因子都已经被测试过了。

这个函数构建了一个list对象。如果找到一个因子n,它将以该因子开始一个列表。它将从x // n添加因子。如果没有x的因子,那么这个值是质数,我们将返回一个只包含该值的列表。

我们可以通过用yield from替换递归调用来将其重写为迭代器。函数将看起来像这样:

    def factor_iter(x): 
        limit = int(math.sqrt(x)+1) 
        for n in range(2, limit): 
            q, r = divmod(x, n) 
            if r == 0: 
                yield n 
                yield from factor_iter(q) 
                return 
        yield x 

与构建列表版本一样,这将搜索数字n,使得。当找到一个因子时,函数将产生该因子,然后通过对factor_iter()的递归调用找到任何其他因子。如果没有找到因子,函数将只产生质数,没有其他东西。

使用迭代器可以让我们从因子构建任何类型的集合。我们不再局限于总是创建一个list,而是可以使用collection.Counter类创建一个多重集。它看起来像这样:

 **>>> from collections import Counter 
>>> Counter(factor_iter(384)) 
Counter({2: 7, 3: 1})** 

这向我们表明:

384 = 2⁷ × 3

在某些情况下,这种多重集比因子列表更容易处理。

另请参阅

  • 围绕 Python 的堆栈限制设计递归函数的配方中,我们涵盖了递归函数的核心设计模式。这个配方提供了创建结果的另一种方法。
posted @ 2024-04-18 10:53  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报