Skip to content

Latest commit

 

History

History
862 lines (608 loc) · 54.8 KB

File metadata and controls

862 lines (608 loc) · 54.8 KB

九、迭代器模式

我们已经讨论了 Python 的许多内置和习惯用法,乍一看似乎是非面向对象的,实际上是在幕后提供对主要对象的访问。在本章中,我们将讨论似乎如此结构化的for循环实际上是如何围绕一组面向对象原则的轻量级包装器。我们还将看到该语法的各种扩展,它们可以自动创建更多类型的对象。我们将包括:

  • 什么是设计模式
  • 迭代器协议是最强大的设计模式之一
  • 列表、集合和词典理解
  • 生成器和协同程序

设计模式简介

当工程师和建筑师决定建造桥梁、塔或建筑物时,他们会遵循一定的原则来确保结构的完整性。桥梁有各种可能的设计(例如,悬索桥或悬臂桥),但如果工程师不使用标准设计之一,并且没有出色的新设计,那么他/她设计的桥梁很可能会倒塌。

设计模式试图将正确设计的结构的相同形式定义引入软件工程。有许多不同的设计模式来解决不同的一般问题。创建设计模式的人首先确定了开发人员在各种情况下面临的一个常见问题。然后,从面向对象设计的角度,他们提出了这个问题的理想解决方案。

然而,了解设计模式并选择在我们的软件中使用它并不能保证我们正在创建一个“正确”的解决方案。1907 年,魁北克大桥(迄今为止,世界上最长的悬臂桥)在完工前倒塌,因为设计它的工程师严重低估了建造它所用的钢材的重量。类似地,在软件开发中,我们可能会错误地选择或应用设计模式,并创建在正常操作情况下或压力超过其原始设计极限时“崩溃”的软件。

任何一种设计模式都会提出一组以特定方式交互的对象来解决一般问题。程序员的工作是识别他们何时面临该问题的特定版本,并在解决方案中调整通用设计。

在本章中,我们将介绍迭代器设计模式。该模式功能强大且无处不在,因此 Python 开发人员提供了多种语法来访问该模式下的面向对象原则。在接下来的两章中,我们将介绍其他设计模式。它们中的一些具有语言支持,而另一些则没有,但它们中没有一个像迭代器模式那样本质上是 Python 程序员日常生活的一部分。

迭代器

在典型的设计模式术语中,迭代器是具有next()方法和done()方法的对象;如果序列中没有剩余项,则返回True。在没有内置迭代器支持的编程语言中,迭代器的循环如下:

while not iterator.done():
    item = iterator.next()
    # do something with the item

在 Python 中,迭代是一个特殊的特性,因此该方法得到一个特殊的名称,__next__。可使用next(iterator)内置按钮访问此方法。迭代器协议引发StopIteration来通知循环它已经完成,而不是done方法。最后,我们有了更具可读性的for item in iterator语法来实际访问迭代器中的项,而不是在while循环中乱搞。让我们更详细地看看这些。

迭代器协议

collections.abc模块中的抽象基类Iterator用 Python 定义迭代器协议。如前所述,它必须有一个__next__方法,for循环(和其他支持迭代的特性)可以调用该方法从序列中获取新元素。此外,每个迭代器还必须实现Iterable接口。任何提供__iter__方法的类都是可移植的;该方法必须返回一个Iterator实例,该实例将覆盖该类中的所有元素。由于迭代器已经在元素上循环,它的__iter__函数传统上会返回自身。

这听起来可能有点让人困惑,所以请看一下下面的示例,但请注意,这是一种非常详细的解决此问题的方法。它清楚地解释了迭代和讨论中的两个协议,但我们将在本章后面讨论几种更具可读性的方法来获得这种效果:

class CapitalIterable:
    def __init__(self, string):
        self.string = string

    def __iter__(self):
        return CapitalIterator(self.string)

class CapitalIterator:
    def __init__(self, string):
        self.words = [w.capitalize() for w in string.split()]
        self.index = 0

    def __next__(self):
        if self.index == len(self.words):
            raise StopIteration()

        word = self.words[self.index]
        self.index += 1
        return word

    def __iter__(self):
        return self

本例定义了一个CapitalIterable类,其任务是循环字符串中的每个单词,并以大写的第一个字母输出它们。iterable 的大部分工作都传递给了CapitalIterator实现。与此迭代器交互的规范方式如下:

>>> iterable = CapitalIterable('the quick brown fox jumps over the lazy dog')
>>> iterator = iter(iterable)
>>> while True:
...     try:
...         print(next(iterator))
...     except StopIteration:
...         break
... 
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog

本例首先构造一个 iterable 并从中检索迭代器。这种区别可能需要解释;iterable 是一个包含可以循环的元素的对象。通常,这些元素可以循环多次,甚至可能在同一时间或在重叠的代码中循环。另一方面,迭代器表示该 iterable 中的特定位置;有些物品已经被消费,有些还没有。两个不同的迭代器可能位于单词列表中的不同位置,但任何一个迭代器只能标记一个位置。

每次在迭代器上调用next()时,它都会按顺序从 iterable 返回另一个标记。最终,迭代器将被耗尽(不会有更多的元素返回),在这种情况下,Stopiteration被引发,我们中断循环。

当然,我们已经知道了从 iterable 构造迭代器的一种简单得多的语法:

>>> for i in iterable:
...     print(i)
... 
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog

正如您所看到的,for语句,尽管看起来不像是面向对象的,但实际上是一些明显面向对象设计原则的捷径。在我们讨论理解时,请记住这一点,因为它们似乎也是面向对象工具的对立物。然而,它们使用与for循环完全相同的迭代协议,只是另一种快捷方式。

理解

理解是简单但强大的语法,它允许我们在一行代码中转换或过滤一个 iterable 对象。结果对象可以是完全正常的列表、集合或字典,也可以是可以一次性高效使用的生成器表达式。

列表理解

列表理解是 Python 中最强大的工具之一,因此人们倾向于将其视为高级工具。他们不是。事实上,我已经冒昧地把前面的例子胡乱地说了一遍,并假设你能理解它们。虽然高级程序员确实经常使用理解,但这并不是因为它们很高级,而是因为它们很琐碎,可以处理软件开发中最常见的一些操作。

让我们看看其中一个常见的操作;即,将项目列表转换为相关项目列表。具体来说,假设我们只是从文件中读取字符串列表,现在我们想将其转换为整数列表。我们知道列表中的每个项目都是一个整数,我们想对这些数字进行一些活动(比如,计算平均值)。这里有一个简单的方法:

input_strings = ['1', '5', '28', '131', '3']

output_integers = []
for num in input_strings:
    output_integers.append(int(num))

这个工作正常,只有三行代码。如果你不习惯理解,你甚至不会认为它看起来很丑!现在,使用列表了解相同的代码:

input_strings = ['1', '5', '28', '131', '3']output_integers = [int(num) for num in input_strings]

我们将减少到一行,重要的是为了性能,我们已经为列表中的每个项目删除了一个append方法调用。总的来说,即使您不习惯理解语法,也很容易判断发生了什么。

和往常一样,方括号表示我们正在创建一个列表。在这个列表中有一个for循环,它迭代输入序列中的每个项目。唯一可能让人困惑的是列表的开始括号和for循环开始之间发生了什么。此处发生的任何情况都将应用于输入列表中的每个项。有问题的项由循环中的num变量引用。因此,它将每个单独的元素转换为int数据类型。

这就是基本列表理解的全部内容。他们毕竟不那么先进。理解是高度优化的 C 代码;当在大量项目上循环时,列表理解比for循环快得多。如果仅仅可读性还不能成为尽可能多地使用它们的令人信服的理由,那么速度应该是。

将一个项目列表转换为一个相关列表并不是我们使用列表理解所能做的唯一事情。我们也可以通过在理解中添加if语句来排除某些值。看看:

output_ints = [int(n) for n in input_strings if len(n) < 3]

我将变量的名称从num缩短为n,并将结果变量缩短为output_ints,因此它仍然适合一行。除此之外,本例与前一例的不同之处在于if len(n) < 3部分。此额外代码不包括任何超过两个字符的字符串。if语句应用于int函数之前,因此它正在测试字符串的长度。因为我们的输入字符串本质上都是整数,所以它排除了超过 99 的任何数字。这就是列出理解的全部内容!我们使用它们将输入值映射到输出值,同时应用过滤器来包括或排除满足特定条件的任何值。

任何 iterable 都可以作为列表理解的输入;我们可以在for循环中包装的任何东西也可以放在理解中。例如,文本文件是可编辑的;在文件的迭代器上对__next__的每次调用都将返回文件的一行。我们可以使用zip函数将第一行为标题行的制表符分隔文件加载到字典中:

import sys
filename = sys.argv[1]

with open(filename) as file:
    header = file.readline().strip().split('\t')
 contacts = [
 dict(
 zip(header, line.strip().split('\t'))
 ) for line in file
 ]

for contact in contacts:
    print("email: {email} -- {last}, {first}".format(
        **contact))

这一次,我添加了一些空白以使其更具可读性(列表理解不需要在一行中包含。此示例从压缩的标题创建字典列表,并为文件中的每一行拆分行。

呃,什么??如果代码或解释没有意义,不要担心;这有点令人困惑。一个列表理解是在这里做大量的工作,代码很难理解、阅读,最终也很难维护。这个例子表明,列表理解并不总是最好的解决方案;大多数程序员都同意for循环比这个版本更具可读性。

提示

记住:我们提供的工具不应该被滥用!始终为这项工作选择合适的工具,即编写可维护的代码。

集合与词典理解

理解并不局限于列表。我们也可以使用类似的语法和大括号来创建集合和字典。让我们从布景开始。创建集合的一种方法是在set()构造函数中包装一个列表,将其转换为集合。但是,当我们可以直接创建一个集合时,为什么要在一个被丢弃的中间列表上浪费内存呢?

下面是一个使用命名元组对 author/title/genre 三元组进行建模的示例,然后检索一组以特定类型编写的所有作者:

from collections import namedtuple

Book = namedtuple("Book", "author title genre")
books = [
        Book("Pratchett", "Nightwatch", "fantasy"),
        Book("Pratchett", "Thief Of Time", "fantasy"),
        Book("Le Guin", "The Dispossessed", "scifi"),
        Book("Le Guin", "A Wizard Of Earthsea", "fantasy"),
        Book("Turner", "The Thief", "fantasy"),
        Book("Phillips", "Preston Diamond", "western"),
        Book("Phillips", "Twice Upon A Time", "scifi"),
        ]

fantasy_authors = {
 b.author for b in books if b.genre == 'fantasy'}

与演示数据设置相比,突出显示的集合较短!当然,如果我们使用列表理解,Terry Pratchett 会被列出两次。。事实上,集合的性质消除了重复项,我们最终得到:

>>> fantasy_authors
{'Turner', 'Pratchett', 'Le Guin'}

我们可以引入冒号来创建字典。这将使用键:值对将序列转换为字典。例如,如果我们知道书名,在字典中快速查找作者或体裁可能会很有用。我们可以使用字典理解将标题映射到图书对象:

fantasy_titles = {
        b.title: b for b in books if b.genre == 'fantasy'}

现在,我们有了一本字典,可以使用正常语法按书名查找书籍。

总之,理解不是高级 Python,也不是应该避免的“非面向对象”工具。它们只是从现有序列创建列表、集合或字典的更简洁和优化的语法。

生成器表达式

有时我们希望处理一个新序列,而不将新列表、集合或字典放入系统内存。如果我们只是一次循环一个项目,而实际上并不关心创建最终的容器对象,那么创建该容器就是浪费内存。当一次处理一个项目时,我们只需要在任何时刻将当前对象存储在内存中。但是,当我们创建一个容器时,所有的对象都必须存储在该容器中,然后才能开始处理它们。

例如,考虑一个处理日志文件的程序。非常简单的日志可能包含以下格式的信息:

Jan 26, 2015 11:25:25    DEBUG        This is a debugging message.
Jan 26, 2015 11:25:36    INFO         This is an information method.
Jan 26, 2015 11:25:46    WARNING      This is a warning. It could be serious.
Jan 26, 2015 11:25:52    WARNING      Another warning sent.
Jan 26, 2015 11:25:59    INFO         Here's some information.
Jan 26, 2015 11:26:13    DEBUG        Debug messages are only useful if you want to figure something out.
Jan 26, 2015 11:26:32    INFO         Information is usually harmless, but helpful.
Jan 26, 2015 11:26:40    WARNING      Warnings should be heeded.
Jan 26, 2015 11:26:54    WARNING      Watch for warnings.

流行的 web 服务器、数据库或电子邮件服务器的日志文件可能包含很多 GB 的数据(我最近不得不从一个行为不正常的系统中清除近 2 TB 的日志)。如果我们想处理日志中的每一行,我们不能使用列表理解;它将创建一个包含文件中每一行的列表。这可能不适合 RAM,可能会使计算机崩溃,具体取决于操作系统。

如果我们在日志文件上使用for循环,我们可以一次处理一行,然后将下一行读入内存。如果我们可以使用理解语法来获得同样的效果,那不是很好吗?

这就是生成器表达式的用武之地。它们使用与理解相同的语法,但不创建最终的容器对象。要创建生成器表达式,请将理解内容包装在()中,而不是[]{}

下面的代码以前面介绍的格式解析日志文件,并输出一个只包含WARNING行的新日志文件:

import sys

inname = sys.argv[1]
outname = sys.argv[2]

with open(inname) as infile:
    with open(outname, "w") as outfile:
 warnings = (l for l in infile if 'WARNING' in l)
        for l in warnings:
            outfile.write(l)

此程序接受命令行上的两个文件名,使用生成器表达式过滤掉警告(在本例中,它使用if语法,不修改该行),然后将警告输出到另一个文件。如果在示例文件上运行它,输出如下所示:

Jan 26, 2015 11:25:46    WARNING     This is a warning. It could be serious.
Jan 26, 2015 11:25:52    WARNING     Another warning sent.
Jan 26, 2015 11:26:40    WARNING     Warnings should be heeded.
Jan 26, 2015 11:26:54    WARNING     Watch for warnings.

当然,对于如此短的输入文件,我们可以安全地使用列表理解,但是如果文件有数百万行长,生成器表达式将对内存和速度产生巨大影响。

生成器表达式通常是函数调用中最有用的。例如,我们可以对生成器表达式而不是列表调用summinmax,因为这些函数一次处理一个对象。我们只对结果感兴趣,而不是任何中间容器。

通常,应尽可能使用生成器表达式。如果我们实际上不需要列表、集合或字典,只需要过滤或转换序列中的项,那么生成器表达式将是最有效的。如果我们需要知道一个列表的长度,或者对结果进行排序,删除重复项,或者创建一个字典,我们就必须使用理解语法。

发电机

生成器表达式实际上也是一种理解;它们将更高级的生成器语法压缩到一行中(这次它确实更高级!)。更强大的生成器语法看起来比我们见过的任何东西都更不面向对象,但我们将再次发现,它是创建一种对象的简单语法快捷方式。

让我们进一步了解一下日志文件示例。如果我们想从输出文件中删除WARNING列(因为它是多余的:这个文件只包含警告),我们有几个选项,具有不同的可读性级别。我们可以使用生成器表达式执行此操作:

import sys
inname, outname = sys.argv[1:3]

with open(inname) as infile:
    with open(outname, "w") as outfile:
 warnings = (l.replace('\tWARNING', '')
 for l in infile if 'WARNING' in l)
        for l in warnings:
            outfile.write(l)

这是完全可读的,尽管我不想让表达式比这复杂得多。我们也可以使用正常的for循环:

import sys
inname, outname = sys.argv[1:3]

with open(inname) as infile:
    with open(outname, "w") as outfile:
 for l in infile:
 if 'WARNING' in l:
 outfile.write(l.replace('\tWARNING', ''))

这是可以维护的,但是在这么少的行中有这么多级别的缩进有点难看。更令人担忧的是,如果我们想对这些行做一些不同的事情,而不是仅仅打印出来,那么我们也必须复制循环和条件代码。现在让我们考虑一个真正的面向对象的解决方案,没有任何捷径:

import sys
inname, outname = sys.argv[1:3]

class WarningFilter:
 def __init__(self, insequence):
 self.insequence = insequence
 def __iter__(self):
 return self
 def __next__(self):
 l = self.insequence.readline()
 while l and 'WARNING' not in l:
 l = self.insequence.readline()
 if not l:
 raise StopIteration
 return l.replace('\tWARNING', '')

with open(inname) as infile:
    with open(outname, "w") as outfile:
        filter = WarningFilter(infile)
        for l in filter:
            outfile.write(l)

毫无疑问:这是如此丑陋和难以阅读,以至于你甚至可能无法说出发生了什么。我们创建了一个以文件对象作为输入的对象,并提供了一个类似于任何迭代器的__next__方法。

__next__方法从文件中读取行,如果不是WARNING行,则丢弃这些行。当它遇到一个WARNING行时,它返回它。然后for循环将再次调用__next__来处理下一条WARNING行。当我们的行数用完时,我们启动StopIteration来告诉循环我们已经完成了迭代。与其他例子相比,它相当丑陋,但它也很强大;现在我们手中有一门课,我们可以用它做任何我们想做的事情。

有了这样的背景,我们终于看到了发电机的运行。下一个示例的作用与上一个示例的作用完全相同:它使用一个__next__方法创建一个对象,在输入不足时引发StopIteration

import sys
inname, outname = sys.argv[1:3]

def warnings_filter(insequence):
 for l in insequence:
 if 'WARNING' in l:
 yield l.replace('\tWARNING', '')

with open(inname) as infile:
    with open(outname, "w") as outfile:
        filter = warnings_filter(infile)
        for l in filter:
            outfile.write(l)

好的,这很容易理解,也许。。。至少它很短。但这里到底发生了什么,毫无意义。那么yield到底是什么?

事实上,yield是发电机的关键。当 Python 在一个函数中看到yield时,它会接受该函数并将其封装到一个对象中,这与我们前面的示例中的对象没有什么不同。把yield语句想象成与return语句相似的语句;它退出函数并返回一行。然而,与return不同的是,当再次调用该函数时(通过next()调用),它将从它停止的地方开始—在yield语句之后的行上—而不是在函数的开头。在本例中,yield语句后面没有“after”行,因此它跳转到for循环的下一次迭代。由于yield语句位于if语句中,因此它只生成包含WARNING的行。

虽然看起来这只是一个在各行上循环的函数,但实际上它正在创建一种特殊类型的对象,一个生成器对象:

>>> print(warnings_filter([]))
<generator object warnings_filter at 0xb728c6bc>

我将一个空列表传递到函数中作为迭代器。该函数所做的只是创建并返回一个生成器对象。该对象上有__iter____next__方法,就像我们在上一个示例中创建的一样。无论何时调用__next__,生成器都会运行该函数,直到找到yield语句。然后它返回来自yield的值,下次调用__next__时,它将从停止的地方开始。

生成器的这种使用并没有那么先进,但是如果您没有意识到函数正在创建一个对象,那么它可能看起来很神奇。这个例子很简单,但是你可以通过在一个函数中多次调用yield来获得非常强大的效果;发电机只需在最近的yield处拾取并继续下一个。

从另一个 iterable 生成项目

通常,当我们构建一个生成器函数时,我们最终会遇到这样一种情况,即我们希望从另一个 iterable 对象生成数据,可能是我们在生成器内部构建的列表理解或生成器表达式,也可能是传递到函数中的一些外部项。通过在 iterable 上循环并单独生成每个项目,这始终是可能的。然而,在 Python 版本 3.3 中,Python 开发人员引入了一种新语法,使其更加优雅。

让我们稍微调整一下生成器示例,以便它不接受一系列行,而是接受一个文件名。这通常是不赞成的,因为它将对象与特定的范例联系起来。如果可能,我们应该将迭代器作为输入进行操作;这样,无论日志行来自文件、内存还是基于 web 的日志聚合器,都可以使用相同的函数。因此,下面的例子是出于教学原因而设计的。

此版本的代码说明,生成器可以在从另一个 iterable(在本例中为生成器表达式)生成信息之前进行一些基本设置:

import sys
inname, outname = sys.argv[1:3]

def warnings_filter(infilename):
    with open(infilename) as infile:
 yield from (
 l.replace('\tWARNING', '')
 for l in infile
 if 'WARNING' in l
 )

filter = warnings_filter(inname)
with open(outname, "w") as outfile:
    for l in filter:
        outfile.write(l)

此代码将上一示例中的for循环组合到生成器表达式中。请注意,我是如何将生成器表达式的三个子句(转换、循环和过滤器)放在单独的行中,以使它们更具可读性。还要注意的是,这种转变并没有起到足够的作用;上一个带有for循环的示例更具可读性。

让我们来考虑一个比它的替代品更可读的例子。构造从多个其他生成器生成数据的生成器非常有用。例如,itertools.chain函数按顺序从 iterables 生成数据,直到它们全部用完。这可以非常容易地使用 OLE T1 语法,所以让我们考虑一个经典的计算机科学问题:走一般的树。

通用树数据结构的一个常见实现是计算机的文件系统。让我们在 Unix 文件系统中对几个文件夹和文件进行建模,以便我们可以使用yield from有效地对它们进行遍历:

class File:
    def __init__(self, name):
        self.name = name

class Folder(File):
    def __init__(self, name):
        super().__init__(name)
        self.children = []

root = Folder('')
etc = Folder('etc')
root.children.append(etc)
etc.children.append(File('passwd'))
etc.children.append(File('groups'))
httpd = Folder('httpd')
etc.children.append(httpd)
httpd.children.append(File('http.conf'))
var = Folder('var')
root.children.append(var)
log = Folder('log')
var.children.append(log)
log.children.append(File('messages'))
log.children.append(File('kernel'))

这个设置代码看起来需要做很多工作,但在一个真正的文件系统中,它将更加复杂。我们必须从硬盘读取数据并将其组织到树中。然而,一旦进入内存,输出文件系统中每个文件的代码就相当优雅:

def walk(file):
    if isinstance(file, Folder):
 yield file.name + '/'
        for f in file.children:
 yield from walk(f)
    else:
 yield file.name

如果这段代码遇到一个目录,它会递归地要求walk()生成一个从属于每个子目录的所有文件的列表,然后生成所有这些数据加上它自己的文件名。在遇到普通文件的简单情况下,它只生成该名称。

另一方面,在不使用生成器的情况下解决前面的问题非常棘手,因此这个问题是一个常见的面试问题。如果你这样回答,你要准备好让你的面试官对你如此轻松的回答既印象深刻又有些恼火。他们可能会要求你准确解释发生了什么。当然,用你在本章学到的原则,你不会有任何问题。

在编写链式生成器时,yield from语法是一个有用的快捷方式,但它更常用于另一个用途:通过协程传递数据。我们将在第 13 章并发中看到许多这样的例子,但现在,让我们来了解什么是协同路由。

合作项目

协同程序是非常强大的构造,经常与生成器混淆。许多作者不恰当地将协同路由描述为“带有一点额外语法的生成器”。这是一个很容易犯的错误,因为早在 Python 2.5 中,当引入协同路由时,它们被表示为“我们在生成器语法中添加了一个send方法”。在 Python 中创建协同路由时,返回的对象是生成器。事实上,这种差别要微妙得多,在你看过几个例子后会更有意义。

虽然 Python 中的协同程序目前与生成器语法紧密耦合,但它们只是表面上与我们讨论的迭代器协议相关。即将发布的 Python3.5 版本使协同路由成为一个真正独立的对象,并将提供一种新的语法来处理它们。

要记住的另一件事是,协同程序很难理解。它们在野外并不经常使用,您可以跳过这一部分,在 Python 中愉快地开发多年,而不会错过甚至遇到它们。有两个库广泛使用协同路由(主要用于并发或异步编程),但它们通常是这样编写的,这样您就可以使用协同路由,而不必真正了解它们是如何工作的!所以,如果你在这一部分迷路了,不要绝望。

但是你不会迷路,学习了下面的例子。下面是一个最简单的协同程序;它允许我们保持可通过任意值增加的运行计数:

def tally():
    score = 0
    while True:
 increment = yield score
        score += increment

这段代码看起来像是不可能工作的黑魔法,所以在逐行描述之前,我们将看到它正在工作。这个简单的对象可以被棒球队的计分应用程序使用。每支球队都可以进行单独的计分,他们的得分可以随着每半局结束时累积的得分而增加。看看这个互动环节:

>>> white_sox = tally()
>>> blue_jays = tally()
>>> next(white_sox)
0
>>> next(blue_jays)
0
>>> white_sox.send(3)
3
>>> blue_jays.send(2)
2
>>> white_sox.send(2)
5
>>> blue_jays.send(4)
6

首先,我们构建两个tally对象,每个团队一个。是的,它们看起来像函数,但与上一节中的生成器对象一样,函数中有一个yield语句,这一事实告诉 Python 要花大量精力将简单函数转化为对象。

然后,我们对每个 coroutine 对象调用next()。这与在任何生成器上调用 next 的操作相同,也就是说,它执行每行代码,直到遇到一个yield语句,在该点返回值,然后暂停直到下一个next()调用。

到目前为止,没有什么新鲜事。但回顾一下我们合作计划中的yield声明:

increment = yield score

与生成器不同,这个屈服函数看起来应该返回一个值并将其赋给变量。事实上,这正是正在发生的事情。协同程序仍然在yield语句处暂停,并等待通过另一个对next()的调用再次激活。

或者更确切地说,正如在交互会话中所看到的,对名为send()的方法的调用。send()方法的next()完全相同,除了将生成器推进到下一个yield语句之外。它还允许您从生成器外部传入值。该值被分配到yield语句的左侧。

对许多人来说,真正令人困惑的是发生的顺序:

  • yield发生,发电机暂停
  • send()从功能外部发生,发电机唤醒
  • 发送的值被分配到yield语句的左侧
  • 生成器继续处理,直到遇到另一条yield语句

因此,在这个特定的例子中,在我们构造协同程序并通过调用next()将其推进到yield语句后,对send()的每个后续调用都会将一个值传递到协同程序中,该值将添加到其分数中,返回到while循环的顶部,并继续处理,直到它到达yield语句。yield语句返回一个值,该值成为最近调用send的返回值。不要错过:send()方法不仅仅向生成器提交一个值,它还返回即将到来的yield语句中的值,就像next()一样。这就是我们如何定义生成器和协同程序之间的区别:生成器只生成值,而协同程序也可以使用它们。

next(i)i.__next__()i.send(value)的行为和语法相当不直观和令人沮丧。第一种是正规函数,第二种是特殊方法,最后一种是正规方法。但这三种方法都做同样的事情:推进生成器,直到生成一个值,然后暂停。此外,可以通过调用i.send(None)来复制next()函数和相关方法。这里有两个不同的方法名是有价值的,因为它可以帮助代码的读者轻松地看到它们是与协同程序还是生成器交互。我只是发现,在一种情况下,它是一个函数调用,而在另一种情况下,它是一个正常的方法,这有点令人恼火。

返回日志解析

当然,前面的示例可以很容易地使用一对整数变量编码并调用x += increment。让我们看第二个例子,协同程序实际上为我们节省了一些代码。这个例子是我在实际工作中必须解决的一个问题的简化版(出于教学方面的原因)。从逻辑上讲,它源于先前关于处理日志文件的讨论,这完全是偶然的;这些例子是为本书第一版编写的,而这个问题是在四年后出现的!

Linux 内核日志包含的行看起来有点像,但不是完全像这样:

unrelated log messages
sd 0:0:0:0 Attached Disk Drive
unrelated log messages
sd 0:0:0:0 (SERIAL=ZZ12345)
unrelated log messages
sd 0:0:0:0 [sda] Options
unrelated log messages
XFS ERROR [sda]
unrelated log messages
sd 2:0:0:1 Attached Disk Drive
unrelated log messages
sd 2:0:0:1 (SERIAL=ZZ67890)
unrelated log messages
sd 2:0:0:1 [sdb] Options
unrelated log messages
sd 3:0:1:8 Attached Disk Drive
unrelated log messages
sd 3:0:1:8 (SERIAL=WW11111)
unrelated log messages
sd 3:0:1:8 [sdc] Options
unrelated log messages
XFS ERROR [sdc]
unrelated log messages

有一大堆分散的内核日志消息,其中一些与硬盘有关。硬盘消息可能与其他消息混杂在一起,但它们以可预测的格式和顺序出现,其中具有已知序列号的特定驱动器与总线标识符(例如0:0:0:0)相关联,并且块设备标识符(例如sda)与该总线相关联。最后,如果驱动器有一个损坏的文件系统,它可能会因 XFS 错误而失败。

现在,给定前面的日志文件,我们需要解决的问题是如何获取任何有 XFS 错误的驱动器的序列号。数据中心技术人员稍后可能会使用此序列号来识别和更换驱动器。

我们知道我们可以使用正则表达式识别单独的行,但是我们必须在循环行时更改正则表达式,因为我们将根据之前发现的内容查找不同的内容。另一个困难的地方是,如果我们发现一个错误字符串,关于哪个总线包含该字符串的信息,以及连接到该总线上驱动器的序列号的信息已经被处理。这可以通过以相反顺序迭代文件的行来轻松解决。

在查看此示例之前,请注意,基于协同路由的解决方案所需的代码量非常少:

import re

def match_regex(filename, regex):
    with open(filename) as file:
        lines = file.readlines()
    for line in reversed(lines):
        match = re.match(regex, line)
        if match:
 regex = yield match.groups()[0]

def get_serials(filename):
    ERROR_RE = 'XFS ERROR (\[sd[a-z]\])'
 matcher = match_regex(filename, ERROR_RE)
    device = next(matcher)
    while True:
        bus = matcher.send(
            '(sd \S+) {}.*'.format(re.escape(device)))
        serial = matcher.send('{} \(SERIAL=([^)]*)\)'.format(bus))
 yield serial
        device = matcher.send(ERROR_RE)

for serial_number in get_serials('EXAMPLE_LOG.log'):
    print(serial_number)

此代码将作业整齐地划分为两个单独的任务。第一个任务是循环所有行,并吐出与给定正则表达式匹配的任何行。第二个任务是与第一个任务交互,并指导它在任何给定时间搜索什么样的正则表达式。

先看match_regex合作项目。记住,它在构造时不执行任何代码;相反,它只是创建一个协同路由对象。一旦构建完成,协同程序之外的人最终会调用next()来启动代码运行,此时它存储两个变量filenameregex的状态。然后它读取文件中的所有行,并反向迭代它们。将每一行与传入的正则表达式进行比较,直到找到匹配项。当找到匹配项时,协程从正则表达式中产生第一个组并等待。

在将来的某个时刻,其他代码将发送一个新的正则表达式来搜索。注意,协程从不关心它试图匹配的正则表达式;它只是在行上循环,并将它们与正则表达式进行比较。其他人有责任决定提供什么样的正则表达式。

在本例中,其他人是get_serials生成器。它不关心文件中的行,事实上它甚至不知道它们。它要做的第一件事是从match_regex协程构造函数创建一个matcher对象,给它一个要搜索的默认正则表达式。它将协程推进到它的第一个yield并存储它返回的值。然后它进入一个循环,指示 matcher 对象根据存储的设备 ID 搜索总线 ID,然后根据该总线 ID 搜索序列号。

在指示匹配器找到另一个设备 ID 并重复该循环之前,它会将该序列号空闲地传递给外部for循环。

基本上,协同程序的(match_regex,因为它使用regex = yield语法)任务是搜索文件中的下一个重要行,而生成器的(get_serial,它使用yield语法,没有赋值)任务是决定哪一行是重要的。生成器具有有关此特定问题的信息,例如文件中将显示哪些订单行。另一方面,协同程序可以插入任何需要在文件中搜索给定正则表达式的问题。

关闭协程并抛出异常

正常发电机通过提升StopIteration从内部发出退出信号。如果我们将多个生成器链接在一起(例如,通过从一个生成器内部迭代另一个生成器),那么StopIteration异常将向外传播。最终,它将命中一个for循环,该循环将看到异常并知道是时候退出该循环了。

协同程序通常不遵循迭代机制;通常将数据推入其中(使用send),而不是在遇到异常之前将数据拉入其中。执行推送的实体通常是负责告知协同程序何时完成的实体;它通过调用相关协同路由上的close()方法来实现这一点。

调用时,close()方法将在协程等待发送值时引发GeneratorExit异常。通常情况下,将其yield语句封装在try中是一个很好的策略。。。finally块,以便执行任何清理任务(如关闭关联文件或套接字)。

如果我们需要在一个协同程序中引发一个异常,我们可以以类似的方式使用throw()方法。它接受带有可选的valuetraceback参数的异常类型。当我们在一个协同程序中遇到异常,并希望在保持回溯的同时在相邻的协同程序中发生异常时,后者非常有用。

如果您正在构建健壮的基于协同路由的库,那么这两个特性都是至关重要的,但是我们在日常编码生活中不太可能遇到它们。

协同程序、生成器和函数之间的关系

我们已经看到协同程序在起作用,现在让我们回到讨论它们与发电机的关系。在 Python 中,就像经常发生的情况一样,这种区别非常模糊。事实上,所有协同程序都是生成器对象,作者经常交替使用这两个术语。有时,他们将协同路由描述为生成器的子集(只有从 yield 返回值的生成器才被视为协同路由)。在 Python 中,这在技术上是正确的,正如我们在前面的部分中所看到的。

然而,在理论计算机科学的更大范围内,协程被认为是更一般的原则,而生成器是一种特定类型的协程。此外,正规函数是协程的另一个独特子集。

协同程序是一个例程,它可以在一个或多个点上传入数据,并在一个或多个点上导出数据。在 Python 中,数据传入和传出的点是yield语句。

函数或子例程是最简单的协同例程类型。当函数返回时,可以在一点传入数据,在另一点传出数据。虽然一个函数可以有多个return语句,但对于该函数的任何给定调用,只能调用其中一个语句。

最后,生成器是一种协同程序,可以在一个点上传入数据,但可以在多个点上传出数据。在 Python 中,数据将在一个yield语句中传递出去,但您不能将数据传递回去。如果您调用send,数据将被悄悄地丢弃。

所以在理论上,生成器是协程的类型,函数是协程的类型,有些协程既不是函数也不是生成器。这很简单,是吗?那么为什么在 Python 中感觉更复杂呢?

在 Python 中,生成器和协同程序都是使用一种语法构造的,看起来我们在构造一个函数。但结果对象根本不是一个函数;这是一种完全不同的对象。当然,函数也是对象。但它们有不同的界面;函数是可调用和返回值,生成器使用next()拉出数据,协同程序使用send推入数据。

案例研究

如今 Python 最流行的领域之一是数据科学。让我们实现一个基本的机器学习算法!机器学习是一个庞大的主题,但总体思路是利用从过去数据中获得的知识对未来数据进行预测或分类。这种算法的使用比比皆是,数据科学家正在寻找每天应用机器学习的新方法。一些重要的机器学习应用包括计算机视觉(如图像分类或面部识别)、产品推荐、识别垃圾邮件和语音识别。我们将看一个更简单的问题:给定 RGB 颜色定义,人类将识别该颜色的名称是什么?

在标准 RGB 颜色空间中有超过 1600 万种颜色,人类只为其中的一小部分命名。虽然有数千个名称(有些非常可笑;只要去任何一家汽车经销商或化妆品店就可以了),但让我们构建一个分类器,尝试将 RGB 空间划分为基本颜色:

  • 红色
  • 紫色
  • 蓝色
  • 绿色
  • 黄的
  • 橙色
  • 灰色
  • 白色
  • 粉红色

我们首先需要一个数据集来训练我们的算法。在一个制作系统中,你可能会从一个颜色列表网站上抓取颜色,或者调查数千人。相反,我创建了一个简单的应用程序,它呈现一种随机颜色,并要求用户从前面九个选项中选择一个进行分类。此应用程序包含在kivy_color_classifier目录中本章的示例代码中,但我们不会详细介绍此代码,因为它在这里的唯一目的是生成示例数据。

Kivy 有一个非常完善的面向对象的 API,你可能想自己去探索。如果你想开发在许多系统上运行的图形程序,从笔记本电脑到手机,你可能想看看我的书,用 Kivy创建应用程序,O'Reilly

在本案例研究中,该应用程序的重要内容是输出,它是一个逗号分隔值CSV)文件,每行包含四个值:红色、绿色和蓝色值(表示为介于 0 和 1 之间的浮点数),以及用户指定给该颜色的前九个名称之一。数据集如下所示:

0.30928279150905513,0.7536768153744394,0.3244011790604804,Green
0.4991001855115986,0.6394567277907686,0.6340502030888825,Grey
0.21132621004927998,0.3307376167520666,0.704037576789711,Blue
0.7260420945787928,0.4025279573860123,0.49781705131696363,Pink
0.706469868610228,0.28530423638868196,0.7880240251003464,Purple
0.692243900051664,0.7053550777777416,0.1845069151913028,Yellow
0.3628979381122397,0.11079495501215897,0.26924540840045075,Purple
0.611273677646518,0.48798521783547677,0.5346130557761224,Purple
.
.
.
0.4014121109376566,0.42176706818252674,0.9601866228083298,Blue
0.17750449496124632,0.8008214961070862,0.5073944321437429,Green

在我感到厌烦之前,我做了 200 个数据点(其中很少是不真实的),并决定是时候开始在这个数据集上进行机器学习了。如果您想使用我的数据(从来没有人告诉过我我是色盲,所以这应该是合理的),这些数据点随本章的示例一起提供。

我们将实现一种更简单的机器学习算法,称为 k-最近邻。该算法依赖于数据集中点之间的某种“距离”计算(在我们的例子中,我们可以使用毕达哥拉斯定理的三维版本)。给定一个新的数据点,它会找到一定数量的数据点(称为 k,如 k-最近邻),这些数据点在通过该距离计算进行测量时最接近它。然后它以某种方式组合这些数据点(平均值可能适用于线性计算;对于我们的分类问题,我们将使用模式),并返回结果。

我们不会详细讨论算法的功能;相反,我们将重点介绍一些可以将迭代器模式或迭代器协议应用于此问题的方法。

现在,让我们编写一个程序,按顺序执行以下步骤:

  1. 从文件中加载示例数据并从中构建模型。
  2. 生成 100 种随机颜色。
  3. 对每种颜色进行分类,并以与输入相同的格式将其输出到文件中。

一旦我们拥有第二个 CSV 文件,另一个 Kivy 程序可以加载该文件并渲染每种颜色,要求人类用户确认或否认预测的准确性,从而告知我们的算法和初始数据集的准确性。

第一步是一个相当简单的生成器,用于加载 CSV 数据并将其转换为符合我们需要的格式:

import csv

dataset_filename = 'colors.csv'

def load_colors(filename):
    with open(filename) as dataset_file:
 lines = csv.reader(dataset_file)
        for line in lines:
 yield tuple(float(y) for y in line[0:3]), line[3]

我们以前没有见过csv.reader函数。它在文件中的行上返回一个迭代器。迭代器返回的每个值都是字符串列表。在我们的例子中,我们本可以在逗号上拆分,但csv.reader还负责管理引号和逗号分隔值格式的各种其他细微差别。

然后我们在这些行上循环,并将它们转换为颜色和名称的元组,其中颜色是三个浮点整数的元组。此元组是使用生成器表达式构造的。可能有更可读的方法来构造这个元组;您认为代码的简洁性和生成器表达式的速度值得混淆吗?它不是返回颜色元组列表,而是一次生成一个颜色元组,从而构造生成器对象。

现在,我们需要 100 种随机颜色。有很多方法可以做到这一点:

  • 具有嵌套生成器表达式的列表理解:[tuple(random() for r in range(3)) for r in range(100)]
  • 基本生成函数
  • 实现__iter____next__协议的类
  • 将数据推送到一个协同路由管道中
  • 甚至只是一个基本的for循环

生成器版本似乎最具可读性,因此让我们将该函数添加到程序中:

from random import random

def generate_colors(count=100):
    for i in range(count):
 yield (random(), random(), random())

注意我们如何参数化要生成的颜色数。现在,我们可以在将来的其他颜色生成任务中重用此功能。

现在,在进行分类步骤之前,我们需要一个函数来计算两种颜色之间的“距离”。因为可以将颜色看作是三维的(例如,红色、绿色和蓝色可以映射到xyz轴),所以让我们使用一些基本数学:

import math

def color_distance(color1, color2):
    channels = zip(color1, color2)
    sum_distance_squared = 0
    for c1, c2 in channels:
        sum_distance_squared += (c1 - c2) ** 2
    return math.sqrt(sum_distance_squared)

这是一个非常基本的函数;看起来它甚至没有使用迭代器协议。没有yield功能,没有理解。然而,有一个for循环,对zip函数的调用也在进行一些真正的迭代(记住zip从每个输入迭代器生成包含一个元素的元组)。

然而,请注意,这个函数在我们的 k-最近邻算法中会被多次调用。如果我们的代码运行太慢,并且我们能够将此函数识别为瓶颈,那么我们可能希望用可读性较差但更优化的生成器表达式替换它:

def color_distance(color1, color2):
    return math.sqrt(sum((x[0] - x[1]) ** 2 for x in zip(
    color1, color2)))

但是,我强烈建议您在证明可读版本太慢之前不要进行此类优化。

现在我们已经有了一些管道,让我们来执行实际的 k-最近邻实现。这似乎是一个使用协同程序的好地方。下面是一些测试代码,以确保生成合理的值:

def nearest_neighbors(model_colors, num_neighbors):
    model = list(model_colors)
 target = yield
    while True:
        distances = sorted(
            ((color_distance(c[0], target), c) for c in model),
        )
 target = yield [
 d[1] for d in distances[0:num_neighbors]
 ]

model_colors = load_colors(dataset_filename)
target_colors = generate_colors(3)
get_neighbors = nearest_neighbors(model_colors, 5)
next(get_neighbors)

for color in target_colors:
    distances = get_neighbors.send(color)
    print(color)
    for d in distances:
        print(color_distance(color, d[0]), d[1])

协同程序接受两个参数,即用作模型的颜色列表和要查询的邻居数。它将模型转换为列表,因为它将被多次迭代。在协同程序的主体中,它使用yield语法接受 RGB 颜色值的元组。然后它将对sorted的调用与一个奇数生成器表达式相结合。看看你是否能弄清楚生成器表达式在做什么。

它为模型中的每种颜色返回一个元组(distance, color_data)。记住,模型本身包含(color, name)元组,其中color是三个 RGB 值的元组。因此,生成器在一个奇怪的数据结构上返回一个迭代器,如下所示:

(distance, (r, g, b), color_name)

然后,sorted调用根据结果的第一个元素(距离)对结果进行排序。这是一段复杂的代码,根本不是面向对象的。您可能希望将其分解为一个正常的for循环,以确保您理解生成器表达式的作用。如果您将一个键参数传递到排序函数中,而不是构造元组,那么想象一下这段代码的外观也可能是一个很好的练习。

yield语句不那么复杂;它从每个第一个 k(distance, color_data)元组中提取第二个值。更具体地说,它为距离最小的 k 值生成((r, g, b), color_name)元组。或者,如果您喜欢更抽象的术语,它会在给定模型中生成目标的 k-最近邻。

剩下的代码只是测试此方法的样板文件;它构造模型和一个颜色生成器,对协同程序进行预处理,并将结果打印到for循环中。

剩下的两个任务是根据最近邻选择颜色,并将结果输出到 CSV 文件。让我们再进行两次协作来完成这些任务。我们先做输出,因为它可以独立测试:

def write_results(filename="output.csv"):
    with open(filename, "w") as file:
        writer = csv.writer(file)
        while True:
 color, name = yield
            writer.writerow(list(color) + [name])

results = write_results()
next(results)
for i in range(3):
    print(i)
    results.send(((i, i, i), i * 10))

此协同程序将打开的文件维护为状态,并在使用send()发送代码时向其写入代码行。测试代码确保协同路由正常工作,因此现在我们可以将两个协同路由与第三个协同路由连接起来。

第二个协同程序使用了一个奇怪的技巧:

from collections import Counter
def name_colors(get_neighbors):
 color = yield
    while True:
 near = get_neighbors.send(color)
        name_guess = Counter(
            n[1] for n in near).most_common(1)[0][0]
 color = yield name_guess

此协同路由接受一个现有的协同路由,作为其参数。在本例中,它是nearest_neighbors的一个实例。此代码基本上代理通过该nearest_neighbors实例发送到其中的所有值。然后它对结果进行一些处理,从返回的值中获取最常见的颜色。在这种情况下,调整原始协同程序以返回名称可能同样有意义,因为它不用于任何其他用途。然而,在许多情况下,传递协同路由是有用的;我们就是这样做的。

现在我们所要做的就是将这些不同的协程和管道连接在一起,并通过一个函数调用启动流程:

def process_colors(dataset_filename="colors.csv"):
    model_colors = load_colors(dataset_filename)
    get_neighbors = nearest_neighbors(model_colors, 5)
 get_color_name = name_colors(get_neighbors)
    output = write_results()
 next(output)
 next(get_neighbors)
 next(get_color_name)

    for color in generate_colors():
 name = get_color_name.send(color)
 output.send((color, name))

process_colors()

所以,这个函数,与我们定义的几乎所有其他函数不同,是一个完全正常的函数,没有任何yield语句。它不会变成一个协同程序或生成器对象。然而,它确实构造了一个生成器和三个协程。注意get_neighbors协程是如何传递给name_colors的构造函数的?注意这三个协同程序是如何通过调用next而提前到其第一个yield语句的。

创建所有管道后,我们使用for循环将生成的每种颜色发送到get_color_name协同路由,然后将该协同路由生成的每个值通过管道发送到输出协同路由,输出协同路由将其写入文件。

就这样!我创建了第二个 Kivy 应用程序,加载生成的 CSV 文件并向用户显示颜色。用户可以选择,这取决于他们认为机器学习算法所做的选择是否与他们本应做出的选择相匹配。这在科学上是不准确的(观察偏差的时机已经成熟),但它已经足够好了。用我的眼睛看,它成功了大约 84%的时间,这比我 12 年级的平均成绩要好。对我们的第一次机器学习体验来说还不错吧?

您可能会想,“这与面向对象编程有什么关系?这段代码中甚至没有一个类!”。在某些方面,你是对的;协程和生成器通常都不被认为是面向对象的。但是,创建它们的函数返回对象;事实上,您可以将这些函数视为构造函数。构造对象有合适的send()__next__()方法。基本上,coroutine/generator 语法是一种特定类型对象的语法快捷方式,如果没有它,创建该对象将非常冗长。

本案例研究是自下而上设计的一个实践。我们创建了各种执行特定任务的低级对象,并在最后将它们连接在一起。我发现在使用协同程序开发时,这是一种常见的做法。另一种自上而下的设计有时会产生更多的单片代码,而不是独特的单个代码。总的来说,我们希望在太大的方法和太小的方法之间找到一个合适的中介,很难看出它们是如何结合在一起的。当然,这是正确的,不管迭代器协议是否像我们在这里所做的那样被使用。

练习

如果您在日常编码中不经常使用理解,那么您应该做的第一件事就是搜索一些现有代码并找到一些for循环。看看它们是否可以简单地转换为生成器表达式或列表、集合或字典。

测试列表理解比for循环快的说法。这可以通过内置的timeit模块完成。使用timeit.timeit功能的帮助文档了解如何使用它。基本上,编写两个做相同事情的函数,一个使用列表理解,另一个使用for循环。将每个函数传递到timeit.timeit,并比较结果。如果你喜欢冒险,也可以比较生成器和生成器表达式。使用timeit测试代码可能会让人上瘾,因此请记住,除非代码被执行了大量次,例如在一个巨大的输入列表或文件上,否则代码不需要超高速。

玩发电机的功能。从需要多个值的基本迭代器开始(数学序列是典型的例子;如果想不出更好的方法,Fibonacci 序列就会被过度使用)。尝试一些更高级的生成器,它们可以获取多个输入列表,并以某种方式生成合并它们的值。生成器也可以用于文件;你能写一个简单的生成器来显示两个文件中相同的行吗?

协同路由滥用迭代器协议,但实际上没有实现迭代器模式。您能否构建从日志文件获取序列号的非协同程序版本的代码?采用面向对象的方法,以便可以在类上存储额外的状态。如果您可以创建一个对象来替换现有的协同路由,那么您将学到很多关于协同路由的知识。

看看是否可以抽象案例研究中使用的协程,以便 k-最近邻算法可以用于各种数据集。您可能希望构造一个协程,该协程接受其他协程或函数作为参数进行距离和重组计算,然后调用这些函数来查找实际的最近邻。

总结

在本章中,我们了解到设计模式是为常见编程问题提供“最佳实践”解决方案的有用抽象。我们介绍了我们的第一个设计模式,迭代器,以及 Python 为了自己的邪恶目的而使用和滥用该模式的多种方式。最初的迭代器模式是非常面向对象的,但编码起来也相当难看和冗长。然而,Python 的内置语法将丑陋之处抽象了出来,为这些面向对象的构造留下了一个干净的接口。

理解和生成器表达式可以在一行中将容器构造与迭代结合起来。生成器对象可以使用yield语法构造。协同程序在外部看起来像发电机,但用途却大不相同。

在接下来的两章中,我们将介绍更多的设计模式。