您当前位置: 首页 深入 Python 3

难度等级: ♦♦♦♢♢

闭包 & 生成器

我的拼写很糟糕。它拼写不错,但很糟糕,字母总是出现在错误的位置。
— 小熊维尼

 

深入

我从小就受身为图书管理员和英语专业的父亲的影响,我一直对语言着迷。不是编程语言。当然,编程语言也很有趣,但自然语言更令人着迷。比如英语。英语是一种精神分裂的语言,它借鉴了德语、法语、西班牙语和拉丁语(仅举几例)的词汇。实际上,“借鉴”这个词用错了;“掠夺”更准确。或者也许是“同化”——就像博格人一样。是的,我喜欢这个词。

我们是博格人。你的语言学和词源学特征将被添加到我们自己的特征中。抵抗是徒劳的。

在本章中,您将学习关于复数名词的知识。另外,还有返回其他函数的函数、高级正则表达式和生成器。但首先,让我们来谈谈如何制作复数名词。(如果您还没有阅读 关于正则表达式的章节,现在是个好时机。本章假设您理解正则表达式的基本知识,并且很快就会深入到更高级的应用中。)

如果您在英语国家长大或在正规学校学习英语,您可能熟悉基本规则

(我知道,有很多例外。Man 变成 menwoman 变成 women,但 human 变成 humansMouse 变成 micelouse 变成 lice,但 house 变成 housesKnife 变成 kniveswife 变成 wives,但 lowlife 变成 lowlifes。更别提那些本身就是复数的词,比如 sheepdeerhaiku。)

当然,其他语言完全不同。

让我们设计一个 Python 库,它可以自动将英语名词变为复数。我们先从这四个规则开始,但请记住,您必然需要添加更多规则。

我知道,让我们使用正则表达式!

所以您正在查看单词,至少在英语中,这意味着您正在查看字符的字符串。您有一些规则说您需要查找不同字符组合,然后对它们进行不同的处理。这听起来像是正则表达式的任务!

[下载 plural1.py]

import re

def plural(noun):          
    if re.search('[sxz]$', noun):             
        return re.sub('$', 'es', noun)        
    elif re.search('[^aeioudgkprt]h$', noun):
        return re.sub('$', 'es', noun)       
    elif re.search('[^aeiou]y$', noun):      
        return re.sub('y$', 'ies', noun)     
    else:
        return noun + 's'
  1. 这是一个正则表达式,但它使用了您在 正则表达式 中没有看到的语法。方括号表示“匹配这些字符中的一个”。因此 [sxz] 表示“sxz”,但只能匹配一个。$ 应该很熟悉;它匹配字符串的结尾。组合起来,这个正则表达式测试 noun 是否以 sxz 结尾。
  2. re.sub() 函数执行基于正则表达式的字符串替换。

让我们更详细地了解正则表达式替换。

>>> import re
>>> re.search('[abc]', 'Mark') 
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.sub('[abc]', 'o', 'Mark') 
'Mork'
>>> re.sub('[abc]', 'o', 'rock') 
'rook'
>>> re.sub('[abc]', 'o', 'caps') 
'oops'
  1. 字符串 Mark 是否包含 abc?是的,它包含 a
  2. 好的,现在找到 abc,并将其替换为 oMark 变成 Mork
  3. 相同的函数会将 rock 变成 rook
  4. 您可能认为这会将 caps 变成 oaps,但事实并非如此。re.sub 会替换所有匹配项,而不仅仅是第一个匹配项。因此,这个正则表达式会将 caps 变成 oops,因为 ca 都被替换成了 o

现在,让我们回到 plural() 函数……

def plural(noun):          
    if re.search('[sxz]$', noun):            
        return re.sub('$', 'es', noun)         
    elif re.search('[^aeioudgkprt]h$', noun):  
        return re.sub('$', 'es', noun)
    elif re.search('[^aeiou]y$', noun):        
        return re.sub('y$', 'ies', noun)     
    else:
        return noun + 's'
  1. 在这里,您用字符串 es 替换字符串的结尾(由 $ 匹配)。换句话说,就是将 es 添加到字符串中。您可以使用字符串连接来完成相同的操作,例如 noun + 'es',但我选择对每个规则使用正则表达式,原因将在本章后面说明。
  2. 仔细看看,这是一个新的变体。^ 作为方括号内的第一个字符具有特殊含义:否定。[^abc] 表示“任何除了 abc 之外的单个字符”。因此 [^aeioudgkprt] 表示除了 aeioudgkprt 之外的任何字符。然后,该字符需要后跟 h,最后是字符串的结尾。您正在查找以 H 结尾的单词,其中 H 可以被听到。
  3. 这里也是相同的模式:匹配以 Y 结尾的单词,其中 Y 之前的字符不是 aeiou。您正在查找以 Y 结尾且发音像 I 的单词。

让我们更详细地了解否定正则表达式。

>>> import re
>>> re.search('[^aeiou]y$', 'vacancy') 
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.search('[^aeiou]y$', 'boy') 
>>> 
>>> re.search('[^aeiou]y$', 'day')
>>> 
>>> re.search('[^aeiou]y$', 'pita') 
>>> 
  1. vacancy 匹配此正则表达式,因为它以 cy 结尾,并且 c 不是 aeiou
  2. boy 不匹配,因为它以 oy 结尾,并且您明确表示 Y 之前的字符不能是 oday 不匹配,因为它以 ay 结尾。
  3. pita 不匹配,因为它不以 y 结尾。
>>> re.sub('y$', 'ies', 'vacancy') 
'vacancies'
>>> re.sub('y$', 'ies', 'agency')
'agencies'
>>> re.sub('([^aeiou])y$', r'\1ies', 'vacancy') 
'vacancies'
  1. 这个正则表达式会将 vacancy 变成 vacancies,将 agency 变成 agencies,这就是您想要的结果。请注意,它还会将 boy 变成 boies,但这永远不会在函数中发生,因为您先进行了 re.search 以确定是否应该进行此 re.sub
  2. 顺便提一下,我想指出,可以将这两个正则表达式(一个用于确定规则是否适用,另一个用于实际应用规则)组合成一个正则表达式。以下是它的样子。大部分应该很熟悉:您正在使用一个记忆组,您在 案例研究:解析电话号码 中学习过。该组用于记住字母 y 之前的字符。然后在替换字符串中,您使用了一种新的语法 \1,它表示“嘿,您记住的第一个组?把它放在这里”。在这种情况下,您记住 y 之前的 c;当您进行替换时,您用 c 替换 c,用 ies 替换 y。(如果您有多个记忆组,可以使用 \2\3 等等。)

正则表达式替换非常强大,\1 语法使它们更加强大。但是将整个操作组合成一个正则表达式也更难阅读,并且它不直接映射到您最初描述的复数化规则。您最初制定了类似“如果单词以 S、X 或 Z 结尾,则添加 ES”的规则。如果您查看此函数,您有两行代码说“如果单词以 S、X 或 Z 结尾,则添加 ES”。这已经非常直接了。

函数列表

现在您将添加一层抽象。您最初定义了一组规则:如果满足此条件,则执行此操作,否则转到下一条规则。让我们暂时使程序的一部分变得复杂,以便您可以简化另一部分。

[下载 plural2.py]

import re

def match_sxz(noun):
    return re.search('[sxz]$', noun)

def apply_sxz(noun):
    return re.sub('$', 'es', noun)

def match_h(noun):
    return re.search('[^aeioudgkprt]h$', noun)

def apply_h(noun):
    return re.sub('$', 'es', noun)

def match_y(noun):                             
    return re.search('[^aeiou]y$', noun)
        
def apply_y(noun):                             
    return re.sub('y$', 'ies', noun)

def match_default(noun):
    return True

def apply_default(noun):
    return noun + 's'

rules = ((match_sxz, apply_sxz),               
         (match_h, apply_h),
         (match_y, apply_y),
         (match_default, apply_default)
         )

def plural(noun):           
    for matches_rule, apply_rule in rules:       
        if matches_rule(noun):
            return apply_rule(noun)
  1. 现在,每个匹配规则都是一个独立的函数,它返回调用 re.search() 函数的结果。
  2. 每个应用规则也是一个独立的函数,它调用 re.sub() 函数以应用相应的复数化规则。
  3. 您没有用一个函数 (plural()) 来包含多个规则,而是使用 rules 数据结构,它是一个函数对序列。
  4. 由于规则已经分解成一个单独的数据结构,因此新的 plural() 函数可以简化为几行代码。使用 for 循环,您可以一次性从 rules 结构中取出匹配规则和应用规则(一个匹配,一个应用)。在 for 循环的第一次迭代中,matches_rule 将获得 match_sxz,而 apply_rule 将获得 apply_sxz。在第二次迭代(假设您到达这一步)中,matches_rule 将被赋值为 match_h,而 apply_rule 将被赋值为 apply_h。该函数保证最终会返回某个值,因为最终的匹配规则 (match_default) 只返回 True,这意味着对应的应用规则 (apply_default) 将始终被应用。

这种技术之所以有效,是因为 Python 中的一切都是对象,包括函数。rules 数据结构包含函数——不是函数的名称,而是实际的函数对象。当它们在 for 循环中被赋值时,matches_ruleapply_rule 就会成为可以调用的实际函数。在 for 循环的第一次迭代中,这相当于调用 matches_sxz(noun),如果它返回一个匹配项,则调用 apply_sxz(noun)

如果这额外的抽象层令人困惑,请尝试展开函数以查看等效关系。整个 for 循环等效于以下代码


def plural(noun):
    if match_sxz(noun):
        return apply_sxz(noun)
    if match_h(noun):
        return apply_h(noun)
    if match_y(noun):
        return apply_y(noun)
    if match_default(noun):
        return apply_default(noun)

这里的好处是 plural() 函数现在得到了简化。它接受一个规则序列(在其他地方定义),并以通用方式迭代它们。

  1. 获取匹配规则
  2. 它匹配了吗?如果是,则调用应用规则并返回结果。
  3. 没有匹配?转到步骤 1。

规则可以定义在任何地方,以任何方式定义。plural() 函数并不关心。

现在,添加这层抽象值得吗?嗯,还没。让我们考虑一下向函数添加新规则需要做什么。在第一个示例中,这将需要在 plural() 函数中添加一个 if 语句。在第二个示例中,这将需要添加两个函数 match_foo()apply_foo(),然后更新 rules 序列以指定新的匹配函数和应用函数应该相对于其他规则按什么顺序调用。

但这仅仅是通往下一节的垫脚石。让我们继续……

模式列表

为每个匹配规则和应用规则定义单独命名的函数实际上没有必要。您永远不会直接调用它们;您将它们添加到 rules 序列中,并通过该序列调用它们。此外,每个函数都遵循两种模式之一。所有匹配函数都调用 re.search(),所有应用函数都调用 re.sub()。让我们将模式提取出来,以便更容易地定义新规则。

[下载 plural3.py]

import re

def build_match_and_apply_functions(pattern, search, replace):
    def matches_rule(word):                                     
        return re.search(pattern, word)
    def apply_rule(word):                                       
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)                           
  1. build_match_and_apply_functions() 是一个动态构建其他函数的函数。它接收 patternsearchreplace,然后定义一个 matches_rule() 函数,该函数使用传递给 build_match_and_apply_functions() 函数的 pattern 和传递给您正在构建的 matches_rule() 函数的 word 调用 re.search()。哇。
  2. 构建 apply 函数的方式相同。apply 函数是一个接受一个参数的函数,它使用传递给 build_match_and_apply_functions() 函数的 searchreplace 参数,以及传递给您正在构建的 apply_rule() 函数的 word 调用 re.sub()。这种在动态函数中使用外部参数值的技术称为闭包。您本质上是在构建的 apply 函数中定义常量:它接受一个参数 (word),但它会根据该参数加上另外两个值 (searchreplace) 进行操作,这两个值是在您定义 apply 函数时设置的。
  3. 最后,build_match_and_apply_functions() 函数返回一个包含两个值的元组:您刚刚创建的两个函数。您在这些函数中定义的常量 (patternmatches_rule() 函数中,searchreplaceapply_rule() 函数中) 会一直保留在这些函数中,即使您从 build_match_and_apply_functions() 中返回也是如此。这太酷了。

如果这非常令人困惑(确实应该如此,这很奇怪),那么当您看到如何使用它时,它可能会变得更清楚。

patterns = \                                                        
  (
    ('[sxz]$',           '$',  'es'),
    ('[^aeioudgkprt]h$', '$',  'es'),
    ('(qu|[^aeiou])y$',  'y$', 'ies'),
    ('$',                '$',  's')                                 
  )
rules = [build_match_and_apply_functions(pattern, search, replace)  
         for (pattern, search, replace) in patterns]
  1. 我们的复数化“规则”现在定义为一个包含字符串(而不是函数)的元组的元组。每个组中的第一个字符串是您将在 re.search() 中使用的正则表达式模式,以查看该规则是否匹配。每个组中的第二个和第三个字符串是您将在 re.sub() 中使用的搜索和替换表达式,以实际应用规则将名词转换为其复数形式。
  2. 这里有一个小的变化,在回退规则中。在前面的示例中,match_default() 函数只是返回 True,这意味着如果没有任何更具体的规则匹配,代码将只在给定单词的末尾添加一个 s。这个例子做了一些在功能上等效的事情。最后的正则表达式询问该单词是否有一个结尾 ($ 与字符串的结尾匹配)。当然,每个字符串都有一个结尾,即使是一个空字符串也是如此,所以这个表达式总是匹配。因此,它与总是返回 Truematch_default() 函数具有相同的作用:它确保如果没有任何更具体的规则匹配,代码会在给定单词的末尾添加一个 s
  3. 这一行很神奇。它将 patterns 中的字符串序列转换为函数序列。怎么做?通过将字符串“映射”到 build_match_and_apply_functions() 函数。也就是说,它接受每个字符串三元组,并使用这三个字符串作为参数调用 build_match_and_apply_functions() 函数。build_match_and_apply_functions() 函数返回一个包含两个函数的元组。这意味着 rules 最终在功能上等效于前面的示例:一个函数列表,其中每个元组都是一对函数。第一个函数是调用 re.search() 的匹配函数,第二个函数是调用 re.sub() 的应用函数。

这个版本的脚本的最后部分是主入口点,即 plural() 函数。

def plural(noun):
    for matches_rule, apply_rule in rules:  
        if matches_rule(noun):
            return apply_rule(noun)
  1. 由于 rules 列表与前面的示例相同(实际上,它是相同的),因此 plural() 函数完全没有改变也就不足为奇了。它完全是通用的;它接受一个规则函数列表,并按顺序调用它们。它不关心规则是如何定义的。在前面的示例中,它们是作为独立命名的函数定义的。现在,它们是通过将 build_match_and_apply_functions() 函数的输出映射到一个原始字符串列表来动态构建的。这并不重要;plural() 函数仍然以相同的方式工作。

一个模式文件

您已经提取了所有重复的代码,并添加了足够的抽象,以便复数化规则定义在一个字符串列表中。下一个合乎逻辑的步骤是将这些字符串放入一个单独的文件中,以便它们可以与使用它们的代码分开维护。

首先,让我们创建一个包含您想要规则的文本文件。没有花哨的数据结构,只有三列以空格分隔的字符串。让我们将其命名为 plural4-rules.txt

[下载 plural4-rules.txt]

[sxz]$               $    es
[^aeioudgkprt]h$     $    es
[^aeiou]y$          y$    ies
$                    $    s

现在让我们看看如何使用这个规则文件。

[下载 plural4.py]

import re

def build_match_and_apply_functions(pattern, search, replace):  
    def matches_rule(word):
        return re.search(pattern, word)
    def apply_rule(word):
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)

rules = []
with open('plural4-rules.txt', encoding='utf-8') as pattern_file:  
    for line in pattern_file:                                      
        pattern, search, replace = line.split(None, 3)             
        rules.append(build_match_and_apply_functions(              
                pattern, search, replace))
  1. build_match_and_apply_functions() 函数没有改变。您仍然使用闭包来动态构建两个使用外部函数中定义的变量的函数。
  2. 全局 open() 函数打开一个文件并返回一个文件对象。在本例中,我们打开的文件包含用于将名词复数化的模式字符串。with 语句创建了一个名为上下文的东西:当 with 块结束时,Python 会自动关闭文件,即使在 with 块内引发了异常。您将在 文件 章节中了解有关 with 块和文件对象的更多信息。
  3. for line in <fileobject> 惯用法一次读取打开文件中的数据,并将其分配给 line 变量。您将在 文件 章节中了解有关从文件读取的更多信息。
  4. 文件中的每一行实际上都有三个值,但它们被空格 (制表符或空格,没有区别) 分隔。要将其拆分,请使用 split() 字符串方法。split() 方法的第一个参数是 None,这意味着“以任何空格 (制表符或空格,没有区别) 分隔”。第二个参数是 3,这意味着“以空格分隔 3 次,然后保留该行的剩余部分”。像 [sxz]$ $ es 这样的行将被分解成列表 ['[sxz]$', '$', 'es'],这意味着 pattern 将获得 '[sxz]$'search 将获得 '$',而 replace 将获得 'es'。在一行小小的代码中包含了如此强大的功能。
  5. 最后,您将 patternsearchreplace 传递给 build_match_and_apply_functions() 函数,该函数返回一个包含函数的元组。您将这个元组追加到 rules 列表中,而 rules 最终存储了 plural() 函数期望的匹配和应用函数列表。

这里的改进在于您已将复数化规则完全分离到一个外部文件中,因此它可以与使用它的代码分开维护。代码是代码,数据是数据,生活很美好。

生成器

如果有一个通用的 plural() 函数可以解析规则文件,那岂不是很好?获取规则,检查匹配项,应用适当的转换,转到下一条规则。这就是 plural() 函数需要做的,也是 plural() 函数应该做的。

[下载 plural5.py]

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 3)
            yield build_match_and_apply_functions(pattern, search, replace)

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))

这到底是怎么做到的?让我们先看看一个交互式示例。

>>> def make_counter(x):
...     print('entering make_counter')
...     while True:
...  yield x 
...         print('incrementing x')
...         x = x + 1
... 
>>> counter = make_counter(2) 
>>> counter 
<generator object at 0x001C9C10>
>>> next(counter) 
entering make_counter
2
>>> next(counter) 
incrementing x
3
>>> next(counter) 
incrementing x
4
  1. make_counter 中存在 yield 关键字意味着这不是一个普通函数。它是一种特殊的函数,可以一次生成一个值。您可以将其视为可恢复函数。调用它将返回一个生成器,该生成器可用于生成 x 的连续值。
  2. 要创建 make_counter 生成器的实例,只需像任何其他函数一样调用它。请注意,这实际上并没有执行函数代码。您可以通过查看 make_counter() 函数的第一行调用 print(),但还没有打印任何内容来判断这一点。
  3. make_counter() 函数返回一个生成器对象。
  4. next() 函数接受一个生成器对象并返回其下一个值。第一次使用 counter 生成器调用 next() 时,它会执行 make_counter() 中的代码,直到第一个 yield 语句,然后返回所产生的值。在本例中,这将是 2,因为您最初是通过调用 make_counter(2) 来创建生成器的。
  5. 重复使用相同的生成器对象调用 next() 会从它停止的地方继续执行,直到遇到下一个 yield 语句。所有变量、本地状态、&c. 都会在 yield 上保存,并在 next() 上恢复。要执行的下一行代码调用 print(),它会打印 incrementing x。之后,语句 x = x + 1。然后它再次循环遍历 while 循环,它遇到的第一件事是语句 yield x,该语句保存所有内容的状态并返回 x 的当前值(现在是 3)。
  6. 第二次调用 next(counter) 时,您会再次执行所有相同的事情,但这次 x 现在是 4

由于 make_counter 设置了一个无限循环,因此理论上您可以永远执行此操作,它只会不断递增 x 并输出值。但让我们看看生成器的更有效用途。

一个斐波那契生成器

[下载 fibonacci.py]

def fib(max):
    a, b = 0, 1          
    while a < max:
        yield a          
        a, b = b, a + b  
  1. 斐波那契数列是一个数列,其中每个数字都是前两个数字的总和。它以 0 和 1 开始,最初缓慢增加,然后越来越快。要启动该序列,您需要两个变量:a 从 0 开始,b1 开始。
  2. a 是序列中的当前数字,因此请将其产生出来。
  3. b 是序列中的下一个数字,因此将其分配给 a,但也计算下一个值 (a + b) 并将其分配给 b 以备后用。请注意,这是并行发生的;如果 a3b5,则 a, b = b, a + b 将将 a 设置为 5b 的前一个值)并将 b 设置为 8ab 的前一个值的总和)。

所以您有一个函数可以输出连续的斐波那契数。当然,您可以使用递归来完成,但这种方式更易于阅读。此外,它与 for 循环配合使用效果很好。

>>> from fibonacci import fib
>>> for n in fib(1000): 
...  print(n, end=' ') 
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987
>>> list(fib(1000)) 
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987]
  1. 您可以直接在 for 循环中使用像 fib() 这样的生成器。for 循环会自动调用 next() 函数以从 fib() 生成器获取值,并将它们分配给 for 循环索引变量 (n)。
  2. 每次遍历 for 循环时,n 都会从 fib() 中的 yield 语句获取一个新值,您只需将其打印出来即可。一旦 fib() 用完数字 (a 变得大于 max,在本例中为 1000),for 循环就会优雅地退出。
  3. 这是一个有用的惯用法:将一个生成器传递给 list() 函数,它将遍历整个生成器(就像前面的示例中的 for 循环一样)并返回所有值的列表。

一个复数规则生成器

让我们回到 plural5.py 并看看这个版本的 plural() 函数是如何工作的。

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 3)                   
            yield build_match_and_apply_functions(pattern, search, replace)  

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):                   
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))
  1. 这里没有魔法。请记住,规则文件中的行有三个以空格分隔的值,因此您使用 line.split(None, 3) 获取三个“列”并将它们分配给三个局部变量。
  2. 然后您产生。您产生了什么?两个函数,使用您老朋友 build_match_and_apply_functions() 动态构建,它与前面的示例完全相同。换句话说,rules() 是一个按需输出匹配和应用函数的生成器。
  3. 由于 rules() 是一个生成器,您可以直接在 for 循环中使用它。第一次循环时,您将调用 rules() 函数,该函数将打开模式文件,读取第一行,根据该行中的模式动态构建匹配函数和应用函数,并生成动态构建的函数。第二次循环时,您将从 rules() 中您上次离开的地方继续(它在 for line in pattern_file 循环的中间)。它将做的第一件事是读取文件的下一行(该文件仍然处于打开状态),根据文件该行上的模式动态构建另一个匹配和应用函数,并生成这两个函数。

与阶段 4 相比,您获得了什么?启动时间。在阶段 4 中,当您导入 plural4 模块时,它会读取整个模式文件并构建所有可能规则的列表,甚至在您开始调用 plural() 函数之前。使用生成器,您可以延迟执行所有操作:您读取第一条规则并创建函数并尝试它们,如果成功,您将永远不会读取文件的其余部分或创建任何其他函数。

您损失了什么?性能!每次调用 plural() 函数时,rules() 生成器都会从头开始,这意味着重新打开模式文件并从头开始逐行读取。

如果您能两全其美:最小的启动成本(在 import 时不执行任何代码),以及最佳性能(不要反复构建相同的函数)。哦,您仍然希望将规则保存在单独的文件中(因为代码是代码,数据是数据),只要您不必读取同一行两次即可。

为此,您需要构建自己的迭代器。但在您构建 之前,您需要学习 Python 类。

进一步阅读

© 2001–11 Mark Pilgrim