💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] 我们已经讨论了很多Python的内置类型和习惯用法,乍一看,它们似乎更像是在非面向对象的伪装下访问对象。在本章中,我们将讨论看起来结构化的`for`循环是如何构成一套面向对象原则的轻量级包装。我们也会看到该语法的各种扩展,它们将自动创建更多对象类型。我们将学习: * 什么是设计模式 * 迭代器协议——最强大的设计模式之一 * 列表解析、集合解析和字典解析 * 生成器和协程 ## 设计模式概要 当工程师和建筑师决定建造一座桥、一座塔或一座建筑时,他们遵循某些原则来确保结构的完整性。有各种各样的可能的桥梁设计(例如悬索桥或悬臂桥),但是如果工程师没有使用某种标准设计,将不会有一个更好的设计,他/她设计的桥可能会倒塌。 </b> 设计模式是一种尝试,旨在为软件工程引入正确设计结构的相同的形式化定义。有许多不同的设计模式用来解决不同的一般性问题。创建设计模式的人首先识别开发人员在各种情况下面临的通用问题。然后他们从面向对象设计角度提出解决这个问题的理想方案。 </b> 然而,知道一个设计模式并选择在我们的软件中使用它,并不能 确保我们正在创建一个“正确”的解决方案。1907年,魁北克大桥(至今,它仍是世界上最长的悬臂桥)在施工完成前倒塌,因为设计工程师严重低估了用来建造它的钢的重量。同样,在软件开发中,我们可能也会错误地选择或应用设计模式,在正常运行情况下或当压力超过其原始设计极限时,造成软件“崩溃”。 </b> 任何一种设计模式都提出一组对象,如何以特定方式交互,用以解决一个普遍的问题。程序员的工作是识别他们所面对的特定问题,并在解决方案中调整总体设计。 </b> 在本章中,我们将讨论迭代器设计模式。这种模式是如此强大且普遍,为Python开发人员提供了多种语法来访问模式背后的面向对象原则。我们将在下两章涵盖其他设计模式。其中一些有语言支持,另外一些没有语言支持,但是它们中没有一个像迭代器模式那样成为Python程序员的日常生活中固有的一部分。 ## 迭代器 在典型的设计模式中,迭代器是一个具有`next()`方法和`done()`方法的对象;如果序列中没有剩余的项目,后者返回`True`。在没有内置迭代器支持的编程语言,迭代器将像这样循环: ``` while not iterator.done(): item = iterator.next() # 用item做些事情 ``` 在Python中,迭代是一种特殊的属性,因此这个方法有一个特殊的名称,`__next__`。可以使用`next(iterator)`内置函数来访问该方法。而`done`方法不太一样,迭代器协议会引发`StopIteration`来通知循环它已经完成。最后,我们有可读性更强的`for item in iterator`的迭代器语法,用于实际访问迭代器中的项,而不是更麻烦的`while`循环。让我们看看更多细节。 ### 迭代器协议 `collections. abc`模块中的抽象基类`Iterator`定义了Python中的迭代器协议。如上所述,它必须有一个`__next__`方法,用于`for`循环(和其他支持迭代的特性)调用该方法,从 序列中得到一个新的元素。此外,每个迭代器还必须包含`Iterable`接口。任何提供`__iter__`方法的类是可迭代的(译注:如果没有`__iter__`方法,是没办法生成一个迭代器的),该方法必须返回一个`Iterator`实例,实例将覆盖该类中的所有元素。因为迭代器已经循环遍历元素,它的`__iter__`函数(译注:就是`__iter__`方法)传统上返回自己。 </b> 这听起来可能有点混乱,所以看看下面的例子,但是要注意 这是解决这个问题的非常冗长的方法。它清楚地解释了迭代和这两个协议之间存在的问题,在本章后面我们将研究获得这种效果的几种更易读的方法来: ``` 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`类,其任务是循环遍历 字符串中的单词,并大写第一个字母输出。可迭代程序的大部分工作被传递给`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 ``` 这个例子首先构造一个可迭代对象,进而生成一个迭代器。两者之间的区别可能需要解释;可迭代对象是一个对象,其元素可以被循环历遍。通常,这些元素可能会被循环多次,即使同时循环或有重叠的代码在循环。另一方面,迭代器,表示可迭代对象的特定位置(译注:临时可消耗的对象);迭代器中有些项目已经被消耗掉了,有些还没有。两个不同的迭代器可能在单词列表中的不同位置,但是任何一个迭代器只能标记唯一一个位置。 </b> 每次迭代器调用`next()`时,它都会从可迭代对象按顺序返回另一个标记。最终,迭代器将耗尽(不再有任何元素返回),在这种情况下,停止迭代被引发,并且我们脱离循环。 </b> 当然,我们已经知道一个更简单的从iterable构造迭代器的语法: ``` >>> for i in iterable: ... print(i) ... The Quick Brown Fox Jumps Over The Lazy Dog ``` 正如你所看见的,在`for`语句中,尽管看不到可怕的面向对象,但它实际上是面向对象设计原则的快捷方式。记住这一点,我们还将在解析中讨论,它们同时也是面向对象工具的反极点(译注:反极点翻译的不好)。然而,它们使用同样的迭代协议,是另外一种快捷方式。 ## 解析 解析虽然简单,但有力,解析语法允许我们在一行代码里转化或迭代一个可迭代对象。结果对象可以是一个普通的列表、集合或字典,或是一个更有效率的生成器表达器。 ### 列表解析 列表解析是Python中最常使用的最有用的工具之一,人们倾向于将它作为高级工具。其实它并不是什么高级工具。实际上,我已经在之前的例子中展示了解析工具,并假设你们已经知道解析工具,虽然高级程序员用解析工具用得比较多,但它们是平凡的工具,在软件开发中处理一些最常见的情况。 </b> 让我们开一个最常见的操作:将列表元素转换为一个相关列表元素。特别的,让我们假设我们刚刚从一个文件读取了一个字符串列表,现在我们向把它转化为整数列表。我们知道列表元素都是整数,我们想在这些数字上做一些事情(例如,计算平均值),这里有一个简单的方法: ``` 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`方法。总体而言,这句代码非常简单地告诉我们正在发生什么,即使你还没有习惯解析语法。 </b> 中括号意味着,我们新建的仍然是一个列表。这个列表中,是一个对输入序列元素的`for`循环迭代。唯一有些令人困惑的是在列表开始括号和`for`循环开始之间到底发生了什么。其实这些表明正在对输入列表*每个*元素所做的事情。这些元素指代是循环的`num`变量。它将每个`num`变量的元素转换为`int`数据类型。 </b> 这就是列表解析。一点都不高级。解析是高度优化的C代码;对于元素数量较大的循环迭代,列表解析的速度远快于`for`循环。如果可读性不是唯一尽可能使用它们的原因,那么速度肯定是。 </b> 将列表元素转换为一个相关的列表,并不是列表解析唯一能做的事情,我们还可以在解析中加`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的字符串排除掉。这些就是列表解析的全部!我们使用它们将输入值映射到输出值,使用过滤包括或去除满足特定条件的值。 </b> 任何可迭代对象都可以输入到列表解析中;任何包含在`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)) ``` 这一次,我加了一些空格,这样可读性更好一些(列表解析不必只写在一行里)。这个例子根据压缩头部和从文件切分的每一行,创造了一个字典列表。 </b> 嗯,这都是些什么呢?不要担心代码或解释是否有意义;它确实挺令人迷惑的。这里一个列表解析做了很多事情,这段代码很难理解、很难读,当然最终也很难维护。这个例子表明,列表解析并不是总是最好的解决方案;大多数程序员都会承认`for`循环更加可读。 > 记住:不能滥用我们提供的那些工具!永远选择适合我们工作的合适的工具,写可维护的代码。 ### 集合和字典解析 解析不仅仅可以用在列表上,我们还可以使用带有大括号类似的语法创建集合和字典。让我们从集合开始。一种创建集合的方法是,将列表解析放入`set()`结构体中,然后将它转换为集合。但这样做,将浪费内存创建中间列表,随后又丢掉这个中间列表,为什么不能直接创建一个集合呢? </b> 下面这个例子,我们使用一个命名元组给`author/title/genre`三元组建模,然后从中提取符合特定`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'} ``` 集合解析代码与demo比确实少了很多!如果我们用列表解析,Terry Pratchett 将出现两次。这是因为集合将删除重复的元素,我们运行一下: ``` >>> fantasy_authors {'Turner', 'Pratchett', 'Le Guin'} ``` 我们可以加入冒号创建一个字典解析。它把序列通过*key: value*对转换为一个字典。例如,如果我们知道标题,在一个字典里迅速查找作者和流派,字典解析将很有用。我们可以通过字典解析将标题和其他对象之间建立映射: ``` fantasy_titles = { b.title: b for b in books if b.genre == 'fantasy'} ``` 现在我们有了字典,我们可以使用普通语法按标题进行查找。 </b> 总之,解析既不是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服务器、数据库、e-mail服务器很流行,它可能包含了GB级的数据(我最近不得不为一个很差的系统清理了2TB的日志)。如果我们想处理日志中每一行,不要使用列表解析;它将创建一个包含日志每一行的列表,这会把它们都放入内存中,电脑可能会崩溃(看你用什么操作系统)。 </b> 如果我们在日志文件上使用`for`循环,我们可能在读取下一行到内存中之前,每次只处理一行日志。如果我们可以使用解析语法得到同样的效果不是更好吗? </b> 这就是生成器表达式所做的事情。它们使用和解析相同的语法,但是它们并不创造最终的容器对象。为了创建一个生成器表达式,使用`()`替换`[]`或`{}`。 </b> 下面代码解析一个和之前例子格式相同的日志文件,然后输出一个新的仅包含`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. ``` 当然,对于这么少的输入文件,使用列表解析仍然时安全的,但是当文件有几百万行时,生成器表达式在内存和速度上都将有巨大的影响。 </b> 生成器表达式在函数调用上被广泛使用。例如,我们可以在生成器表达式上而不是列表上调用`sum`、`min`或 `max`函数,因为这些函数每次只处理一个对象。我们仅仅关心结果,而不是中间容器。 </b> 通常,尽可能使用生成器表达式。如果我们并不是需要一个列表、集合或字典,而是简单地过滤或转换一个序列上的元素,生成器表达式是最有效率的。如果我们需要知道列表的长度,或对结果排序,删除重复项,或创建一个字典,我们还是使用解析语法比较好。 ## 生成器 生成器表达式实际上也是一种解析;它们把更加高级(这次它真的是高级的!)的生成器语法压缩成一行。生成器语法的好处是它看起来并不像面向对象的语法,但是我们再探索一下,就会发现它是一种创建对象的简单的语法快捷方式。 </b> 让我们进一步看一下日志文件例子。如果我们想从我们的输出文件删除`WARNING`列(因为它是多余的,这个文件仅包含警告),在不同程度可读性上,我们有几种选择。我们可以用生成器表达式实现: ``` import sys inname = sys.argv[1] outname = sys.argv[2] 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 = sys.argv[1] outname = sys.argv[2] 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__`方法。 </b> `__next__`方法从文件中读取每一行,同时丢掉不包含`WARNING`的行。当它遇到`WARNING`的行,则返回这一行。然后`for`循环再次调用`__next__`处理下一个`WARNING`行。当我们历遍所有的行后,我们抛出一个`StopIteration`,告诉循环我们已经完成迭代。和其他例子比,它有点丑陋,但仍然很强大;既然我们手上有一个类,我们可以做一些我们想做的事情。 </b> 有了这些背景,让我们最后看看生成器是什么样子的。下面这个例子和我们之前的例子做相同的事情:创建一个带有`__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) ``` OK,可读性还不错,可能吧...至少代码少了很多。但是究竟发生了什么,好像没有新增什么有意义的。`yield`又是什么呢? </b> 实际上,`yield`是生成器的关键。当Python看到函数中的`yield`,它会将这个函数包装成一个对象,但这个对象并不同于我们之前例子中的对象。`yield`和`return`有点类似;都是退出函数并返回一行。不同点在于,但函数再次被调用时(通过`next()`),`yield`从它之前离开的下一行开始执行,而不是函数开始的位置。这里,函数在`yield`“之后”并没有下一行,因此它跳到`for`循环的下一个迭代。因为`yield`语句中在一个`if`语句中,它将仅仅得到包含`WARNING`的行。 </b> 虽然看上去这仍然时一个历遍各行的函数,实际上它创建了一个特殊类型的对象,一个生成器对象: ``` >>> print(warnings_filter([])) <generator object warnings_filter at 0xb728c6bc> ``` 我传入一个空列表到这个函数作为一个迭代器。这个函数将创建和返回一个生成器对象。这个对象有`__iter__`和`__next__`方法,就像我们之前的例子里所创建的那样。无论何时`__next__`被调用,生成器将运行函数直到它发现一个`yield`语句,它随后从`yield`语句返回一个值,等到下一次`__next__`被调用,它从`yield`离开的位置执行。 </b> 生成器的这种用法并不算高级,但是如果你没有意识到这个函数正在创建一个对象,它看起来会有点神奇。这个例子虽然很简单,但你从中看到`yield`在单个函数中多次调用将产生强大的作用;生成器只是从`yield`最近的位置重新开始,并继续下一个。 ### 从另一个可迭代程序中产生项目 通常,当我们构建一个生成器函数,我们最终会遇到这样一种情况,我们希望从另一个可迭代对象中产生数据,可能是我们在生成器中创建的一个列表解析或生成器表达式,或是从外部传入函数的元素。这总是可能的,通过循环遍历可迭代对象并单独产生每个项目。然而,从Python3.3开始,Python开发者引入了一种新的更加优雅的语法。 </b> 让我们稍微修改一下这个生成器例子,它将接受一个文件名,而不是一个行序列。这通常是不允许的,因为它将对象绑定在一个特定的范式上。可能的话,我们应该尽可能在作为输入的迭代器上进行操作;通过这种方法,相同的函数将被使用,不管日志行是来自文件、内存或基于网络的日志聚合器。所以下面的例子是出于教学的原因而设计的。 </b> 该版本的代码说明了你的生成器可以在从另一个可迭代对象(在本例中是生成器表达式)产生信息之前进行一些基本设置: ``` 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`循环的示例更易读。 </b> 因此,让我们考虑一个比它的替代方案更易读的例子。构建一个从多个其他生成器生成数据的生成器可能会很有用。例如,`itertools.chain`函数从序列中的可迭代对象产生数据,直到对象全部用完。使用`yield from`语法可以很容易地实现这一点,所以让我们考虑一个经典的计算机科学问题:历遍普通树。 </b> A common implementation of the general tree data structure is a computer's ilesystem. Let's model a few folders and iles in a Unix ilesystem so we can use yield from to walk them effectively: 通用树数据结构是计算机文件系统中的一个常见实现。让我们在一个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()`生成一个 从属于每个子级所有文件的列表,然后生成所有这些数据及其 自己的文件名。在这个简单的例子,如果只是遇到普通文件,它只是 产生文件名。 </b> 另外,不使用生成器解决前面的问题是很棘手的,这个问题是一个常见的面试问题。如果你这样回答,准备好面试官将对你留下深刻印象,并恼怒你如此轻易地回答了它。他们可能会要求你解释到底发生了什么?当然,如果你学到了这一章的原则,你不会有任何问题。 </b> 在编写链式生成器时,`yield from`语法是一个有用的快捷方式,但是它更常用于不同的目的:通过协程传送数据。我们将在第13章“并发”中看到许多这样的例子,但是现在,让我们先了解协程是什么。 ## 协程 协程是非常强大的构造器,经常与生成器混淆。许多作者不恰当地将协程描述为“多了一点额外语法的生成器”。这是一个很容易犯的错误,就像在Python 2.5中,当协程被引入时,它们被表示为“我们向生成器语法添加了一个`send`方法”。还有一些更复杂的事实,当你在Python创建一个协程,返回的对象是一个生成器。差别实际上很微妙,看几个例子后你会更更加清楚。 > 虽然Python中的协程与生成器语法关系密切,但它们只是在我们一直在讨论的迭代器协议上存在特殊关系。即将发布的Python 3.5版本使协程成为真正独立的对象,并将提供新的语法来使用它们。 要记住的另一件事是,协程很难理解。它们不常使用,你可以跳过这一部分,并且在Python中愉快地开发数年,都可能遇不到它们。有几个库广泛使用协程(主要用于并发或异步编程),但它们通常是这样编写的,你可以在不理解它们是如何工作的情况下使用协程!因此,如果你在这部分感到困惑,不要绝望。 </b> 但是,学习了下面这些例子,你将不会感到困惑。这是一个最简单的可能协程;它允许我们一直运行一个可以增加任意值的计数牌: ``` 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 ``` 首先,我们构建两个计数对象,每个队一个。是的,它们看起来像函数,但是与上一节中的生成器对象一样,事实上,这里的`yield`语句告诉python,花点力气把这个简单的函数转变变成一个对象。 </b> 然后,我们对每个协程对象调用`next()`。就像在任何生成器上调用`next`一样,也就是说,它执行每一行代码,直到它 遇到`yield`语句,返回该位置的值,然后暂停,直到 下一个`next()`调用。 </b> 到目前为止,没有什么新内容。但是回头看看我们协程中的`yield`语句: ``` increment = yield score ``` 与生成器不同,这个`yield`函数看起来应该返回一个值,并将它赋给一个变量。事实上,这正是正在发生的事情。协程仍然在`yield`语句处暂停,等待被另一个`next()`调用再次激活。 </b> 或者,正如你在交互式会话中看到的,我们调用了一个名为`send()`的方法。`send()`方法执行与`next()`完全相同的操作,除此之外,它将生成器推进到下一个`yield`语句。它还允许你从生成器外部传递值。该值被分配给`yield`语句的左边。 </b> 令许多人真正困惑的是协程的发生顺序: * `yield`出现,生成器暂停 * `send()`在函数外部发生,将生成器唤醒 * 发送的值被分配到`yield`语句的左侧 * 生成器继续处理,直到遇到另一个`yield`语句 所以,在这个特定的例子中,在我们构造了协程并通过调用`next()`把它推进到`yield`语句,每次对`send()`的连续调用都会传递一个值添加到协程中,协程将该值添加到它的分数中,返回到`while`循环,并继续处理,直到它再次遇到`yield`语句。`yield`语句返回一个值,该值成为最后调用`send()`的返回值。不要忘记:`send()`方法不只是向生成器发送一个值,它还从即将发生的`yield`语句返回这个值,就像 `next()`。这是生成器和协程之间的区别:生成器只生成值,而协程还可以消费它们。 </b> > `next(i)`、`i.__next__()`和`i.send(value)`的行为和语法相当不直观且令人沮丧。第一个是普通函数,第二种是特殊方法,最后一种是普通方法。但是三者都做同样的事情:推动生成器直到产生一个值并暂停。此外,`next()`函数和相关联的方法可以通过调用`i.send(None)`来模仿(译注:`next(blue_jays)`和`blue_jays.send(None)`是一样的)。 使用两个不同的方法名是有价值的,因为它有助于我们代码的读者很容易看出他们是在与协程还是生成器交互。我发现一个事实,在一个例子中,它是一个函数调用,在另外的例子里,它又成了一种普通方法,这有点令人恼火。 ### 返回日志解析 当然,前面的例子很容易用几个整数变量写代码,对它们调用`x += increment`。让我们看第二个例子,协程实际上为我们节省了一些代码。这个例子有点简单(教学原因),但是我在实际工作中必须解决的一个问题。事实上,它逻辑上遵循之前关于处理日志文件的讨论是完全偶然的;这些例子是为这本书的第一版写的,然而问题在四年后出现了! </b> 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:0)是相关联的,块设备标识符(例如`sda`)与总线是相关联的。最后,如果驱动器有一个损坏的文件系统,它可能会因XFS错误而失败。 </b> 现在,给定日志文件,我们需要解决的问题是如何获得任何有XFS错误的驱动器的序列号。数据中心技术人员稍后可能会使用这个序列号来识别和更换驱动器。 </b> 我们知道可以使用正则表达式来识别单独的行,但是我们将我们不得不在遍历这些行时更改正则表达式,因为我们将根据我们之前的发现寻找不同的东西。另一个困难的是,如果我们发现一个错误字符串,关于那条总线的信息包含该字符串,以及已经处理过了的总线上驱动器的序列号。这可以通过以相反的顺序迭代文件来解决。 </b> 在看这个例子之前,请注意,基于协程的解决方案代码非常少: ``` 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) ``` 这段代码巧妙地将作业分成两个独立的任务。第一个任务是循环所有行,并获得任何匹配给定正则表达式的行。第二项任务是与第一项任务互动,并指导它在任何给定时间搜索时应该使用什么样的正则表达式。 </b> 先看看`match_regex`协程。记住,当它被创建时,它不执行任何代码;相反,它只是创建一个协程对象。一旦建成,协程之外的人最终会调用`next()`来启动代码运行,此时它存储两个变量的状态,`filename`和`regex`。随后它读取文件中的所有行,并反向遍历它们。每一行都与传入的正则表达式比较,直到它找到匹配项。当找到匹配后,协程从正则表达式中生成第一个组并等待。 </b> 在将来的某个时候,其他代码将会发送一个新的搜索正则表达式。请注意,协程从不关心它正在尝试使用什么正则表达式进行匹配;它只是遍历行并将它们与正则表达式进行比较。决定提供什么正则表达式是别人的责任。 </b> 在这个例子里,其他人就是`get_serials`生成器。它不在乎 文件中的行,事实上它甚至没有意识到它们。它做的第一件事就是从 `match_regex`协程构造器创建一个`matcher`对象,给它一个默认要搜索的正则表达式。它将协程推进到第一个`yield`并把它返回的值储存起来。然后,它进入一个循环,指示`matcher`对象基于存储的设备ID搜索总线ID,然后基于该总线ID搜索序列号。 </b> 在指示`matcher`对象找到另一个设备ID之前,它会把这个序列号推给外层循环,并重复该循环。 </b> 基本上,协程(`match _ regex`,因为它使用`regex = yield`语法)的工作是搜索文件中的下一个重要行,而生成器的(`get_serial `,使用无赋值的`yield`语法)工作是决定哪一行是重要的。生成器有关于这个特定问题的信息,例如行以什么顺序出现在文件中。另一方面,协程可以插入任何需要在文件中搜索给定正则表达式的问题。 ### 关闭协程并抛出异常 普通生成器通过从内部抛出停止迭代(`StopIteration`)来发出它们退出的信号。如果我们将多个生成器链接在一起(例如,通过从另一个生成器内部迭代一个生成器),`StopIteration`异常将向外传播。最终,它会进入一个`for`循环,通知循环是时候退出了。 </b> 协程通常不遵循迭代机制;而是通过协程获取数据,直到遇到异常,数据通常被推入协程(使用`send`方法)。进行推送的实体通常是负责告知协程它已完成推送;它通过调用协程的`close`方法来完成这项工作。 </b> 调用时,`close()`方法将在协程正在等待一个值被发送进来时,抛出`GeneratorExit`异常。让协程将`yield`语句包装在`try...finally`代码块中,通常是个好政策,可以执行清理任务(例如关闭关联的文件或套接字)。 </b> 如果我们需要在协同中引发异常,我们可以以类似的方式使用`throw()`方法。它接受带有可选的值和回溯参数的异常类型 。当我们在一个协同过程中遇到异常,并且希望在临近的协程中抛出异常,可以保持回溯时,后者很有用。 </b> 如果您要构建健壮的基于协同程序的库,这两个特性都是至关重要的,但是我们在日常编码生活中不太可能遇到它们。 ### 协程、生成器、函数之间的关系 我们已经看到协程在起作用,所以现在让我们回到关于它们如何与生成器相关的讨论。在Python中,就像经常发生的情况一样,它们的区别非常模糊。事实上,所有的协程都是生成器对象,很多作者经常互换使用这两个术语。有时,他们将协程描述为生成器的子集(只有从`yield`中返回值的生成器才被认为是协程)。正如我们在前面几节中看到的,在Python中这在技术上是正确的。 </b> 然而,在理论计算机科学的更大范围内,协程被认为是更普遍的原则,而生成器是协程的一种特殊类型。此外,一般函数是协程的另一个不同子集。 </b> 协程是一个例程,它可以让数据在一个或多个点传入,并在一个或多个点传出。在Python中,数据传入传出的点是`yield`语句。 </b> 函数或子程序是最简单的协程类型。当函数返回时,你可以在一个点传入数据,在另一个点取出数据。虽然一个函数可以有多个返回语句,但是对于函数的任何给定调用,只能调用其中一个。 </b> 最后,生成器是一种协同程序,它可以在一个点传递数据,但可以在多个点传递数据。在Python中,数据将在一个yield语句中传递出去,但是不能将数据传递回来。如果你调用`send`,数据将被无声地丢弃。 </b> 所以理论上,生成器是协程的类型,函数是协程的类型,并且有些协同既不是函数也不是生成器。这很简单,呃?那为什么在python中感觉更复杂呢? </b> 在Python中,生成器和协同程序都是使用如下语法构建的,就像我们正在构建一个函数。但是最终得到的对象根本不是函数;这 一种完全不同的对象。当然,函数也是对象。但是他们有不同的界面;函数是可调用的,返回值,生成器使用`next()`将数据取出,协程使用`send`推入数据。 </b> [Python yield 使用浅析](https://www.runoob.com/w3cnote/python-yield-used-analysis.html):这里介绍了分块读取。 ## 个案研究 ## 摘要