Skip to content

Latest commit

 

History

History
847 lines (650 loc) · 30.4 KB

File metadata and controls

847 lines (650 loc) · 30.4 KB

六、生成器和协程——无限,一次一步

生成器是一种特定类型的迭代器,通过函数生成值。传统方法构建并返回list项,而生成器只需在调用者请求时单独yield每个值。这种方法有几个好处:

  • 生成器完全暂停执行,直到生成下一个值,这使它们完全懒惰。如果从生成器中获取五项,则只生成五项,因此不需要进行其他计算。
  • 生成器不需要保存值。传统函数需要创建一个list并存储所有结果直到返回,而生成器只需要存储一个值。
  • 生成器可以具有无限大小。没有要求在某一点停止。

然而,这些好处是有代价的。这些好处的直接结果是一些缺点:

  • 在完成处理之前,您永远不知道还剩下多少值;它甚至可能是无限的。这使得使用在某些情况下变得危险;执行list(some_infinite_generator)将耗尽内存。
  • 不能对生成器进行切片。
  • 如果不生成该索引之前的所有值,则无法获取特定项。
  • 无法重新启动生成器。所有值只生成一次。

除了生成器之外,生成器的语法还有一个变体,用于创建协同路由。协同程序是允许多任务处理而不需要多个线程或进程的函数。虽然生成器只能向调用方生成值,但协同路由实际上在调用方仍在运行时从调用方接收值。虽然这种技术有一些局限性,但如果它适合您的目的,它可以以很低的成本获得出色的性能。

简言之,本章涵盖的主题包括:

  • 生成器的特性和用途
  • 生成器理解
  • 生成函数
  • 生成器类别
  • 集束生成器
  • 协同程序

什么是生成器?

生成器的最简单形式是一个函数,它一次返回一个元素,而不是返回一组项。这种方法最重要的优点是,它只需要很少的内存,而且不需要预定义大小。创建一个无休止的生成器(如第 4 章函数式编程中讨论的itertools.count迭代器——可读性与简洁性)实际上相当容易,但当然也要付出代价。没有对象的大小会使某些模式难以实现。

编写生成器(作为函数)的基本技巧是使用yield语句。我们以itertools.count生成器为例,用stop变量对其进行扩展:

>>> def count(start=0, step=1, stop=10):
...     n = start
...     while n <= stop:
...         yield n
...         n += step

>>> for x in count(10, 2.5, 20):
...     print(x)
10
12.5
15.0
17.5
20.0

由于生成器的潜在无限特性,需要谨慎。如果没有stop变量,只需执行list(count())就会很快导致内存不足。

那么这是如何工作的呢?这只是一个普通的for循环,但它与返回项目列表的常规方法的最大区别在于yield语句一次返回一个项目。这里需要注意的一点是,return语句产生一个StopIteration并将某个内容传递给return将成为StopIteration的参数。应该注意的是,这种行为在 Python 3.3 中发生了变化;在 Python3.2 和更早的版本中,除了None之外,根本不可能返回任何内容。以下是一个例子:

>>> def generator():
...     yield 'this is a generator'
...     return 'returning from a generator'

>>> g = generator()
>>> next(g)
'this is a generator'
>>> next(g)
Traceback (most recent call last):
 ...
StopIteration: returning from a generator

当然,与往常一样,使用 Python 创建生成器有多种方法。除了函数之外,还有生成器理解和类可以做同样的事情。生成器理解与列表理解几乎相同,但使用括号而不是括号,例如:

>>> generator = (x ** 2 for x in range(4))

>>> for x in generator:
...    print(x)
0
1
4
9

为完整起见,count函数的类版本如下:

>>> class Count(object):
...     def __init__(self, start=0, step=1, stop=10):
...         self.n = start
...         self.step = step
...         self.stop = stop
...
...     def __iter__(self):
...         return self
...
...     def __next__(self):
...         n = self.n
...         if n > self.stop:
...             raise StopIteration()
...
...         self.n += self.step
...         return n

>>> for x in Count(10, 2.5, 20):
...     print(x)
10
12.5
15.0
17.5
20.0

类与基于函数的方法之间最大的区别在于,您需要显式地提出StopIteration,而不仅仅是返回它。除此之外,它们非常相似,尽管基于类的版本显然增加了一些冗长。

生成器的优缺点

您已经看到了一些生成器示例,并了解了如何使用它们的基本知识。然而,记住它们的优点和缺点是很重要的。

以下是最重要的优点:

  • 内存使用。一次可以处理一个项目,因此通常不需要将整个列表保存在内存中。
  • 结果可能取决于外部因素,而不是静态列表。例如,考虑处理队列/堆栈。
  • 生成器是懒惰的。这意味着,如果只使用生成器的前五个结果,那么其余的结果甚至不会被计算出来。
  • 通常,它比列表生成函数更容易编写。

最重要的缺点是:

  • 结果只提供一次。在处理生成器的结果后,不能再次使用它。
  • 在完成处理之前,大小是未知的,这可能对某些算法有害。
  • 生成器不可转位,这意味着some_generator[5]无法工作。

考虑到所有的优点和缺点,我的一般建议是在可能的情况下使用生成器,并且只在实际需要时返回listtuple。将生成器转换为listlist(some_generator)一样简单,因此这不应该阻止您,因为生成器功能往往比生成list的等效功能更简单。

内存使用的优势是可以理解的;一个项目需要的内存比许多项目少。然而,懒惰部分需要一些额外的解释,因为它有一个小障碍:

>>> def generator():
...     print('Before 1')
...     yield 1
...     print('After 1')
...     print('Before 2')
...     yield 2
...     print('After 2')
...     print('Before 3')
...     yield 3
...     print('After 3')

>>> g = generator()
>>> print('Got %d' % next(g))
Before 1
Got 1

>>> print('Got %d' % next(g))
After 1
Before 2
Got 2

如您所见,生成器实际上在yield语句之后立即冻结,因此即使是After 2也不会打印,直到3被生成。

这有着重要的优势,但这绝对是你需要考虑的。您不能在yield之后立即进行清理,因为它将在下一个yield之前执行。

管道——生成器的有效使用

生成器在理论上的可能性是无限的(没有双关语),但它们的实际用途可能很难找到。如果您熟悉 Unix/Linux shell,那么您可能以前使用过管道,例如,类似于ps aux | grep python'的东西来列出所有 Python 进程。当然,有很多方法可以做到这一点,但是让我们在 Python 中模拟类似的东西,以查看一个实际的示例。为了创建一个简单且一致的输出,我们将创建一个名为lines.txt的文件,其中包含以下行:

spam
eggs
spam spam
eggs eggs
spam spam spam
eggs eggs eggs

现在,让我们使用以下 Linux/Unix/Mac shell 命令读取经过一些修改的文件:

# cat lines.txt | grep spam | sed 's/spam/bacon/g'
bacon
bacon bacon
bacon bacon bacon

这将使用cat读取文件,使用grep输出包含spam的所有行,并使用sed命令将spam替换为bacon。现在,让我们看看如何使用 Python 生成器重新创建:

>>> def cat(filename):
...     for line in open(filename):
...         yield line.rstrip()
...
>>> def grep(sequence, search):
...     for line in sequence:
...         if search in line:
...             yield line
...
>>> def replace(sequence, search, replace):
...     for line in sequence:
...         yield line.replace(search, replace)
...
>>> lines = cat('lines.txt')
>>> spam_lines = grep(lines, 'spam')
>>> bacon_lines = replace(spam_lines, 'spam', 'bacon')

>>> for line in bacon_lines:
...     print(line)
...
bacon
bacon bacon
bacon bacon bacon

# Or the one-line version, fits within 78 characters:
>>> for line in replace(grep(cat('lines.txt'), 'spam'),
...                     'spam', 'bacon'):
...     print(line)
...
bacon
bacon bacon
bacon bacon bacon

这就是生成器的最大优势。您可以多次包装列表或序列,但对性能的影响很小。在请求值之前,所涉及的函数中没有一个执行任何操作。

三通–多次使用输出

正如前面提到的一样,生成器的最大缺点之一是结果只能使用一次。幸运的是,Python 有一个函数,允许您将输出复制到多个生成器。如果您习惯于在命令行 shell 中工作,那么您可能会很熟悉这个名称teetee程序允许您将输出写入屏幕和文件,因此您可以存储输出,同时仍保持实时视图。

Python 版本itertools.tee做了类似的事情,只是它返回几个迭代器,允许您单独处理结果。

默认情况下,tee将把生成器拆分为一个包含两个不同生成器的元组,这就是为什么元组解包在这里工作得很好。通过传递n参数,可以很容易地将其更改为支持 2 台以上的生成器。以下是一个例子:

>>> import itertools

>>> def spam_and_eggs():
...     yield 'spam'
...     yield 'eggs'

>>> a, b = itertools.tee(spam_and_eggs())
>>> next(a)
'spam'
>>> next(a)
'eggs'
>>> next(b)
'spam'
>>> next(b)
'eggs'
>>> next(b)
Traceback (most recent call last):
 ...
StopIteration

看到这个代码后,您可能会想知道tee的内存使用情况。它需要为您存储整个列表吗?幸运的是,没有。tee函数在处理这个问题上非常聪明。假设您有一个包含 1000 项的生成器,您同时读取了a中的前 100 项和b中的前75项。然后tee将只在内存中保留差异(100 - 75 = 25项),并在迭代结果时删除其余项。

当然,在你的情况下,tee是否是最好的解决方案取决于。如果在读取实例b之前从开始到(几乎)结束读取实例a,那么使用tee不是一个好主意。简单地将生成器转换为list会更快,因为它涉及的操作要少得多。

生成器发电

正如我们前面所看到的,我们可以使用生成器过滤、修改、添加和删除项目。然而,在许多情况下,您会注意到,在编写生成器时,您将从子生成器和/或序列返回。例如,使用itertools库创建powerset时:

>>> import itertools

>>> def powerset(sequence):
...     for size in range(len(sequence) + 1):
...         for item in itertools.combinations(sequence, size):
...             yield item

>>> for result in powerset('abc'):
...     print(result)
()
('a',)
('b',)
('c',)
('a', 'b')
('a', 'c')
('b', 'c')
('a', 'b', 'c')

这种模式非常常见,因此实际上对 yield 语法进行了增强,使其更容易实现。Python 3.3 引入了yield from语法,而不是手动循环结果,这使得这种常见模式更加简单:

>>> import itertools

>>> def powerset(sequence):
...     for size in range(len(sequence) + 1):
...         yield from itertools.combinations(sequence, size)

>>> for result in powerset('abc'):
...     print(result)
()
('a',)
('b',)
('c',)
('a', 'b')
('a', 'c')
('b', 'c')
('a', 'b', 'c')

这就是只需三行代码就可以创建一个 powerset 的方法。

也许,更有用的例子是递归展平序列:

>>> def flatten(sequence):
...     for item in sequence:
...         try:
...             yield from flatten(item)
...         except TypeError:
...             yield item
...
>>> list(flatten([1, [2, [3, [4, 5], 6], 7], 8]))
[1, 2, 3, 4, 5, 6, 7, 8]

请注意,此代码使用TypeError检测不可编辑的对象。结果是,如果序列(可能是生成器)返回一个TypeError,它将默默地隐藏它。

还要注意,这是一个非常基本的展平函数,它没有任何类型检查。例如,包含str的 iterable 将递归展平,直到达到最大递归深度,因为str中的每个项也返回一个str

上下文管理器

与本书中描述的大多数技术一样,Python 还附带了一些有用的生成器。其中一些(例如itertoolscontextlib.contextmanager已经在第 4 章函数式编程——可读性与简洁性第 5 章修饰符中讨论过但是我们可以用一些额外的例子来说明它们是多么的简单和强大。

Python 上下文管理器似乎与生成器没有直接关系,但这是它们内部使用的大部分内容:

>>> import datetime
>>> import contextlib

# Context manager that shows how long a context was active
>>> @contextlib.contextmanager
... def timer(name):
...     start_time = datetime.datetime.now()
...     yield
...     stop_time = datetime.datetime.now()
...     print('%s took %s' % (name, stop_time - start_time))

# The write to log function writes all stdout (regular print data) to
# a file. The contextlib.redirect_stdout context wrapper
# temporarily redirects standard output to a given file handle, in
# this case the file we just opened for writing.
>>> @contextlib.contextmanager
... def write_to_log(name):
...     with open('%s.txt' % name, 'w') as fh:
...         with contextlib.redirect_stdout(fh):
...             with timer(name):
...                 yield

# Use the context manager as a decorator
>>> @write_to_log('some function')
... def some_function():
...     print('This function takes a bit of time to execute')
...     ...
...     print('Do more...')

>>> some_function()

虽然所有这些都很好,但三个级别的上下文管理器往往有点不可读。一般来说,装饰师可以解决这个问题。然而,在本例中,我们需要一个上下文管理器的输出作为下一个上下文管理器的输入。

这就是的作用。它允许轻松组合多个上下文管理器:

>>> import contextlib

>>> @contextlib.contextmanager
... def write_to_log(name):
...     with contextlib.ExitStack() as stack:
...         fh = stack.enter_context(open('stdout.txt', 'w'))
...         stack.enter_context(contextlib.redirect_stdout(fh))
...         stack.enter_context(timer(name))
...
...         yield

>>> @write_to_log('some function')
... def some_function():
...     print('This function takes a bit of time to execute')
...     ...
...     print('Do more...')

>>> some_function()

看起来至少简单一点,不是吗?虽然这种情况下的必要性是有限的,但当您需要进行特定拆卸时,ExitStack的便利性很快就会显现出来。除了前面看到的自动处理外,还可以将上下文转移到新的ExitStack并手动处理关闭:

>>> import contextlib

>>> with contextlib.ExitStack() as stack:
...     spam_fh = stack.enter_context(open('spam.txt', 'w'))
...     eggs_fh = stack.enter_context(open('eggs.txt', 'w'))
...     spam_bytes_written = spam_fh.write('writing to spam')
...     eggs_bytes_written = eggs_fh.write('writing to eggs')
...     # Move the contexts to a new ExitStack and store the
...     # close method
...     close_handlers = stack.pop_all().close

>>> spam_bytes_written = spam_fh.write('still writing to spam')
>>> eggs_bytes_written = eggs_fh.write('still writing to eggs')

# After closing we can't write anymore
>>> close_handlers()
>>> spam_bytes_written = spam_fh.write('cant write anymore')
Traceback (most recent call last):
 ...
ValueError: I/O operation on closed file.

contextlib函数中的大多数函数在 Python 手册中都有丰富的文档。具体而言,使用中的许多示例记录了ExitStackhttps://docs.python.org/3/library/contextlib.html#contextlib.ExitStack 。我建议密切关注contextlib文档,因为每一个 Python 版本都有很大的改进。

合作项目

协同程序是子例程,通过多个入口点提供非先发制人的多任务处理。基本前提是协同程序允许两个函数在运行时相互通信。通常,这种类型的通信只为多任务解决方案保留,但协同路由提供了一种相对简单的实现方法,几乎不增加性能成本。

由于默认情况下生成器是惰性的,所以协同程序的工作是相当明显的。直到结果被消耗,发生器休眠;但在消耗结果时,生成器将激活。常规生成器和协同路由的区别在于,协同路由不只是向调用函数返回值,还可以接收值。

一个基本的例子

在前面的段落中,我们看到了常规生成器是如何产生值的。但这并不是生成器所能做的全部。它们实际上也可以接收值。基本用法相当简单:

>>> def generator():
...     value = yield 'spam'
...     print('Generator received: %s' % value)
...     yield 'Previous value: %r' % value

>>> g = generator()
>>> print('Result from generator: %s' % next(g))
Result from generator: spam
>>> print(g.send('eggs'))
Generator received: eggs
Previous value: 'eggs'

这就是它的全部。函数被冻结,直到调用了send方法,此时它将一直处理到下一个yield语句。

启动

由于生成器是惰性的,所以不能只向全新的生成器发送值。在将值发送到生成器之前,必须使用next()获取结果,或者必须发出send(None)以便实际达到代码。这样做的必要性是可以理解的,但有时有点乏味。让我们创建一个简单的 decorator,省去这方面的需要:

>>> import functools

>>> def coroutine(function):
...     @functools.wraps(function)
...     def _coroutine(*args, **kwargs):
...         active_coroutine = function(*args, **kwargs)
...         next(active_coroutine)
...         return active_coroutine
...     return _coroutine

>>> @coroutine
... def spam():
...     while True:
...         print('Waiting for yield...')
...         value = yield
...         print('spam received: %s' % value)

>>> generator = spam()
Waiting for yield...

>>> generator.send('a')
spam received: a
Waiting for yield...

>>> generator.send('b')
spam received: b
Waiting for yield...

您可能已经注意到,尽管生成器仍然是惰性的,但它现在会自动执行所有代码,直到再次到达yield语句。此时,它将保持休眠状态,直到发送新值。

请注意,从这一点开始,本章将使用coroutine装饰器。为了简洁起见,我们将在下面的示例中省略它。

关闭和抛出异常

与常规的生成器不同,它只需在输入序列耗尽时退出,而协同例程通常使用无限while循环,这意味着它们不会以正常方式被拆除。这就是为什么协程同时支持closethrow方法,这将退出函数。这里重要的不是结束,而是添加一个拆卸方法的可能性。本质上,它与上下文包装器使用__enter____exit__方法的功能非常相似,但在本例中使用协同程序:

@coroutine
def simple_coroutine():
    print('Setting up the coroutine')
    try:
        while True:
            item = yield
            print('Got item: %r' % item)
    except GeneratorExit:
        print('Normal exit')
    except Exception as e:
        print('Exception exit: %r' % e)
        raise
    finally:
        print('Any exit')

print('Creating simple coroutine')
active_coroutine = simple_coroutine()
print()

print('Sending spam')
active_coroutine.send('spam')
print()

print('Close the coroutine')
active_coroutine.close()
print()

print('Creating simple coroutine')
active_coroutine = simple_coroutine()
print()

print('Sending eggs')
active_coroutine.send('eggs')
print()

print('Throwing runtime error')
active_coroutine.throw(RuntimeError, 'Oops...')
print()

此生成以下输出,这应该是预期的无奇怪行为,但只是退出协同程序的两种方法:

# python3 H06.py
Creating simple coroutine
Setting up the coroutine

Sending spam
Got item: 'spam'

Close the coroutine
Normal exit
Any exit

Creating simple coroutine
Setting up the coroutine

Sending eggs
Got item: 'eggs'

Throwing runtime error
Exception exit: RuntimeError('Oops...',)
Any exit
Traceback (most recent call last):
...
 File ... in <module>
 active_coroutine.throw(RuntimeError, 'Oops...')
 File ... in simple_coroutine
 item = yield
RuntimeError: Oops...

双向管线

在前面的段落中,我们看到了管道;它们按顺序单向处理输出。但是,在某些情况下,您需要一个管道,该管道不仅向下一个管道发送值,而且还从子管道接收信息。我们可以通过这种方式在执行之间维护生成器的状态,而不是始终处理单个列表。因此,让我们从将早期管道转换为协同路由开始。首先,再次使用lines.txt文件:

spam
eggs
spam spam
eggs eggs
spam spam spam
eggs eggs eggs

现在,是协同程序管道。这些函数与以前相同,但改用协同程序:

>>> @coroutine
... def replace(search, replace):
...     while True:
...         item = yield
...         print(item.replace(search, replace))

>>> spam_replace = replace('spam', 'bacon')
>>> for line in open('lines.txt'):
...     spam_replace.send(line.rstrip())
bacon
eggs
bacon bacon
eggs eggs
bacon bacon bacon
eggs eggs eggs

在这个例子中,您可能想知道为什么我们现在打印值而不是生成值。好我们可以,但请记住,生成器会冻结,直到生成值为止。让我们看看如果我们只是yield值而不是调用print会发生什么。默认情况下,您可能会尝试这样做:

>>> @coroutine
... def replace(search, replace):
...     while True:
...         item = yield
...         yield item.replace(search, replace)

>>> spam_replace = replace('spam', 'bacon')
>>> spam_replace.send('spam')
'bacon'
>>> spam_replace.send('spam spam')
>>> spam_replace.send('spam spam spam')
'bacon bacon bacon'

现在有一半的值消失了,所以问题是,“它们去了哪里?”注意第二个yield没有存储结果。这就是价值正在消失的地方。我们还需要存储这些数据:

>>> @coroutine
... def replace(search, replace):
...     item = yield
...     while True:
...         item = yield item.replace(search, replace)

>>> spam_replace = replace('spam', 'bacon')
>>> spam_replace.send('spam')
'bacon'
>>> spam_replace.send('spam spam')
'bacon bacon'
>>> spam_replace.send('spam spam spam')
'bacon bacon bacon'

但即便如此,这也远远不是最优的。我们现在基本上是在使用协程来模拟生成器的行为。虽然它有效,但它只是有点傻,不太清楚。这次让我们制作一个真正的管道,其中协同路由将数据发送到下一个协同路由(或多个协同路由),并通过将结果发送到多个协同路由来实际显示协同路由的威力:

# Grep sends all matching items to the target
>>> @coroutine
... def grep(target, pattern):
...     while True:
...         item = yield
...         if pattern in item:
...             target.send(item)

# Replace does a search and replace on the items and sends it to
# the target once it's done
>>> @coroutine
... def replace(target, search, replace):
...     while True:
...         target.send((yield).replace(search, replace))

# Print will print the items using the provided formatstring
>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring % (yield))

# Tee multiplexes the items to multiple targets
>>> @coroutine
... def tee(*targets):
...     while True:
...         item = yield
...         for target in targets:
...             target.send(item)

# Because we wrap the results we need to work backwards from the
# inner layer to the outer layer.

# First, create a printer for the items:
>>> printer = print_('%s')

# Create replacers that send the output to the printer
>>> replacer_spam = replace(printer, 'spam', 'bacon')
>>> replacer_eggs = replace(printer, 'spam spam', 'sausage')

# Create a tee to send the input to both the spam and the eggs
# replacers
>>> branch = tee(replacer_spam, replacer_eggs)

# Send all items containing spam to the tee command
>>> grepper = grep(branch, 'spam')

# Send the data to the grepper for all the processing
>>> for line in open('lines.txt'):
...     grepper.send(line.rstrip())
bacon
spam
bacon bacon
sausage
bacon bacon bacon
sausage spam

这使得代码更加简单易读,但更重要的是,它展示了如何将单个源拆分为多个目的地。虽然这看起来可能不太令人兴奋,但肯定是。如果仔细观察,tee方法会将输入拆分为两个不同的输出,但这两个输出都回写到同一print_实例。这意味着,在不费吹灰之力的情况下,您可以沿着任何方便的方式将数据路由到同一端点。

无论如何,这个例子仍然没有那么有用,因为这些函数仍然没有使用协同程序的所有功能。在这种情况下,最重要的特性,一致性状态,并没有真正使用。

从这些线路中学到的最重要的一课是,在大多数情况下,混合生成器和协同程序不是一个好主意,因为如果使用不当,它可能会产生非常奇怪的副作用。尽管两人都使用yield语句,但他们是行为明显不同的生物。下一段将展示将协同路由和生成器混合使用的少数情况之一。

使用状态

既然我们知道了如何编写基本的协程,以及我们必须注意哪些陷阱,那么编写一个需要记住状态的函数怎么样?也就是说,该函数始终为您提供所有发送值的平均值。这是将协同程序和生成器语法结合起来仍然相对安全和有用的少数情况之一:

>>> @coroutine
... def average():
...     count = 1
...     total = yield
...     while True:
...         total += yield total / count
...         count += 1

>>> averager = average()
>>> averager.send(20)
20.0
>>> averager.send(10)
15.0
>>> averager.send(15)
15.0
>>> averager.send(-25)
5.0

但它仍然需要一些额外的逻辑才能正常工作。为了确保不被零除,我们将count初始化为1。之后,我们使用yield获取第一个项目,但此时我们不发送任何数据,因为第一个yield是初始值,在我们获取值之前执行。一旦这些都设置好了,我们可以很容易地在求和时得到平均值。不是那么糟糕,但是纯协同程序版本更容易理解,因为我们不必担心启动:

>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring % (yield))

>>> @coroutine
... def average(target):
...     count = 0
...     total = 0
...     while True:
...         count += 1
...         total += yield
...         target.send(total / count)

>>> printer = print_('%.1f')
>>> averager = average(printer)
>>> averager.send(20)
20.0
>>> averager.send(10)
15.0
>>> averager.send(15)
15.0
>>> averager.send(-25)
5.0

尽可能简单,只需保留计数和总值,并为每个新值发送新的平均值。

另一个很好的示例是itertools.groupby,使用协同程序也很简单。为了进行比较,我们将再次展示 generator 协同程序和纯协同程序版本:

>>> @coroutine
... def groupby():
...     # Fetch the first key and value and initialize the state
...     # variables
...     key, value = yield
...     old_key, values = key, []
...     while True:
...         # Store the previous value so we can store it in the
...         # list
...         old_value = value
...         if key == old_key:
...             key, value = yield
...         else:
...             key, value = yield old_key, values
...             old_key, values = key, []
...         values.append(old_value)

>>> grouper = groupby()
>>> grouper.send(('a', 1))
>>> grouper.send(('a', 2))
>>> grouper.send(('a', 3))
>>> grouper.send(('b', 1))
('a', [1, 2, 3])
>>> grouper.send(('b', 2))
>>> grouper.send(('a', 1))
('b', [1, 2])
>>> grouper.send(('a', 2))
>>> grouper.send((None, None))
('a', [1, 2])

如您所见,此函数使用了一些技巧。我们存储之前的keyvalue,以便我们可以检测组(key)何时更改。这是第二个问题;显然,在组发生更改之前,我们无法识别组,因此只有在组发生更改之后,才会返回结果。这意味着,只有在最后一个组之后发送另一个组时,才会发送最后一个组,因此才会发送(None, None)。现在,这里是纯协同程序版本:

>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring % (yield))

>>> @coroutine
... def groupby(target):
...     old_key = None
...     while True:
...         key, value = yield
...         if old_key != key:
...             # A different key means a new group so send the
...             # previous group and restart the cycle.
...             if old_key and values:
...                 target.send((old_key, values))
...             values = []
...             old_key = key
...         values.append(value)

>>> grouper = groupby(print_('group: %s, values: %s'))
>>> grouper.send(('a', 1))
>>> grouper.send(('a', 2))
>>> grouper.send(('a', 3))
>>> grouper.send(('b', 1))
group: a, values: [1, 2, 3]
>>> grouper.send(('b', 2))
>>> grouper.send(('a', 1))
group: b, values: [1, 2]
>>> grouper.send(('a', 2))
>>> grouper.send((None, None))
group: a, values: [1, 2]

虽然这些函数非常相似,但纯协同程序版本再一次非常简单。这是因为我们不必考虑启动和可能丢失的值。

总结

本章向我们展示了如何创建生成器以及它们所具有的优势和劣势。此外,现在应该清楚如何绕过它们的局限性以及这样做的影响。

虽然关于协同程序的段落应该提供一些关于它们是什么以及如何使用它们的见解,但并不是所有内容都已经展示出来了。我们看到了同时作为生成器的纯协程和协程的构造,但它们仍然是同步的。协同路由允许将结果发送到许多其他协同路由,因此可以一次有效地执行许多函数,但如果某个操作被阻塞,它们仍然可以完全冻结 Python。这就是我们下一章将要用到的地方。

Python 3.5 引入了一些有用的特性,例如asyncawait语句。这些使协同路由完全异步和无阻塞成为可能,而本章使用了自 Python 2.5 以来可用的基本协同路由特性。

下一章将介绍更新的功能,包括asyncio模块。该模块使使用协同路由对端点(如 TCP、UDP、文件和进程)进行异步 I/O 变得非常简单。