



下面是一个简单的生成器函数:
函数matching_lines_from_file()展示了若干项重要的最新Python编程实践,值得深入学习。该函数对文本文件的每一行进行了简单的子字符串匹配,并返回包含该子字符串的行。
第一行代码创建了只读文件对象handle。如果你以前没有使用过with语句打开文件对象,最好现在就用起来。使用with语句的主要好处是一旦退出with代码,文件对象会自动关闭,即使因为异常导致提前退出也能关闭。类似于使用try和finally,如下所示:
本书第5章会讲解try/finally。接下来的代码是for line in handle,这是个实用的惯用法,特别适合处理文本文件,但很多人可能不熟悉。每当for循环执行一次迭代,就会从文本文件读取一行文本,并存储在变量line中。
有时人们会采取一种愚蠢的方法,必须特别注意:
readlines()方法(复数)读取整个文件,将其拆分成行,并返回字符串列表,每行一个字符串。这样就会破坏扩展性。
再次强调,生成器函数的扩展性取决于扩展性最差的代码行。因此,编写代码时务必小心,避免引入内存限制,降低生成器函数的性能。
另一种支持扩展的方法是使用文件对象的readline()方法(单数),逐行手动读取文件内容:
相比之下,for line in handle更加清晰简洁。
使用.readline()的更优方式是通过赋值表达式:
赋值语句,如x=y+1,本身没有值。换言之,Python代码if (x=y+1)>2:是错误的。这是有意设计的,避免将==误用为=。
但有时需要赋值并使用相同代码行的值,上面的while循环就是一个例子,这就是为什么Python中有:=运算符。
通过使用:=,我们只需编写一次line :=handle.readline(),无须使用两次line=handle.readline()。这样不仅语法更简洁,还消除了重复的代码,有时还能避免重复计算。这就是赋值表达式,许多人也称其为“海象运算符”,因为如果眯起眼睛,把头向右稍微歪一下,“:=”看起来有点像一只海象。
接下来,匹配的行会去掉结尾的换行符,并传递给其他代码。当编写生成器函数时,不妨问自己一个问题:“函数的最大内存占用是多少?如何最小化内存占用?”一般地,扩展性与内存占用量成反比。对于matching_lines_from_file(),内存占用大小约等于文本文件中最长的一行。因此,适用于一般的可读性较强的文本文件,每行都较短。(如果要处理的文本文件包含一万亿字节,且仅有一行,必须采用不同的方法。)
假设日志文件中包含如下内容:
假设调用matching_lines_from_file()函数:
输出如下:
假设代码需要字典形式的数据,如下所示:
我们想高效地将记录从一种形式转换为另一种形式,即从字符串(日志文件行)转换为Python字典。因此,我们创建一个新生成器函数,分别获取level和message:
将matching_lines_from_file()和parse_log_records()合并起来:
当然,可以单独使用parse_log_records():
matching_lines_from_file()和parse_log_records()就像模块,使用得当的话,可用于构建不同的数据处理程序,笔者称之为可扩展组合性。可扩展组合性不仅局限于设计可组合的函数和类型,更重要的是考虑如何使这些模块具备扩展性,以及由模块构建的程序都能保持扩展性。
接下来,我们讨论一个特别的设计细节。matching_lines_from_file()和parse_log_records()都能生成迭代器(或者,更具体地,生成的是生成器对象)
。不过,二者的输入参数有所不同:parse_log_records()接收迭代器作为输入,而matching_lines_from_file()则需要文件路径读取数据。这表明matching_lines_from_file()实际组合了两个函数,一是从文件读取文本,二是根据某些条件筛选数据。
组合函数在编程实践中很普遍。但在设计可以灵活组合的组件时,不一致的接口可能带来限制。我们将matching_lines_from_file()拆分为两个生成器函数:
可以如下方式组合:
或者,可以用其重新定义函数matching_lines_from_file():
从概念上,函数matching_lines进行了过滤操作,即读入所有行,仅输出部分结果。parse_log_records()则不同,一条输入记录(即一行str)对应一条输出记录(即一个dict)。从数学角度,这是一个映射操作。可以将函数matching_lines当作转换器或适配器。lines_from_file()则属于第三类,不像其他函数接收数据流作为输入,而是接收完全不同的参数。由于仍然返回迭代器,因此可以将lines_from_file()视为数据源。另一个程序,即接收者,最终会处理该数据源流,消费数据但不生成迭代器。
所有这些组件构成了运行的程序。当你设计这样一组可链接的生成器函数时,可以将其作为构建内部数据流水线的工具包。不妨问自己一些问题:每个组件是接收者还是数据源,进行的是筛选还是映射,或是这些功能的某种组合。仅仅提出问题就能让代码更加实用、易读和易于维护。如果你正在开发编程库,则更有可能创造出灵活强大的工具包,人们会用库构建各种各样的程序。
函数parse_log_records()值得深入分析。前文提到,parse_log_records()属于“映射”类别。具体地,它的映射关系是输入一行文本,输出一个字典。换言之,输入中的每一行文本(即str)会转换成输出中的一条记录(dict)。
除了“映射”,还有其他情况。有时,生成器函数需要接收多条输入记录,生成一条输出记录。或输入一条输入记录,生成多条输出记录。
举一个后者的例子。假设有一个文本文件,包含一首诗的三行: [1]
创建生成器函数words_in_text(),逐个生成单词。下面是第一种方法:
这个生成器函数
采用了扇出方式,不丢弃任何输入记录,即不做任何过滤。因此,仍然属于生成器函数中的“映射”类别。但是,它不是一对一的映射关系,一条输入记录会生成一条或多条输出记录。运行以下代码:
输出如下:
第一条输入记录“all night our room was outer-walled with rain”生成了八个单词(输出记录)。忽略诗句中的空白行,每行诗句至少会生成一个词,也可能是多个。
扇出处理的想法很有趣,但比较简单。扇入处理则复杂得多,生成器函数接收多条输入记录,生成单条输出记录。编码关键在于了解输入记录的结构,通常需要进行一些简单的解析处理。
对于一个包含住宅销售数据的文本文件,每条记录由一系列键值对组成,每行一个键值对,不同记录之间用空行分隔:
为了将数据转换为代码中可使用的格式,我们需要创建一个生成器函数house_records(),接收一组字符串(即文本行),将其解析为便于使用的字典:
如何实现呢?如果条件允许,读者最好动手试一试,打开代码编辑器,编写代码。
好了,时间到。代码如下:
注意yield关键字的位置。for循环的最后一行读取各键值对,向空字典填充数据,直到遇到空行。这表明已经完成当前记录,因此yield出去,并创建一个新字典。住宅数据文件housedata.txt中最后一条记录的结束不是通过空行标记的,而是通过文件结尾。这就是为什么需要最终的yield语句。
根据定义,如果使用house_records()从文本文件读取数据,操作起来有些烦琐。最好定义一个新生成器函数,接收文件路径作为参数:
读者可能注意到,在许多示例中,当生成器函数在内部调用另一个生成器函数时,会有一些固定的模式。house_records_from_file的最后两行就是如此:
Python专门为此提供了单行的便捷实现方式,即yield from:
区别于yield,尽管yield from由两个词组成,但在语义上是一个单独的关键字。yield from主要用于生成器函数,直接从另一个生成器对象返回值(或者调用另一个生成器函数)。正如函数house_records_from_file(),借助yield from可使代码更简洁。
回顾前文,matching_lines_from_file()的使用方法如下:
yield from实现的功能是“委托给子生成器”,加深了外部生成器对象与内部生成器对象之间的联系。特别地,生成器对象具有某些特定方法,如send()、throw()和close(),用于将信息传递回正在运行的生成器函数的上下文中。因为这些方法并不常用,本书不进行具体展开,感兴趣的读者可以阅读PEP 342 ( https://peps.python.org/pep-0342 )和PEP 380( https://www.python.org/dev/peps/pep-0380 )。如果你需要使用send()等方法,则yield from是必备的,以将信息流传回当前协程的作用域中。