原文:Improve Your Python: ‘yield’ and Generators Explained
译注:该文对 Python 关键字 yield 和生成器写的好好!文笔也很好!特译之。部分地方有意译。
在开始辅导学生之前,我请新学生填写对 Python 中各种各样的概念的理解的简短自测表。对一些主题(如 if/else 控制流或定义和使用函数),大部分学生甚至在开始辅导之前就已经理解。然而,还是有几乎所有学生都不知道或者知道的特别少的主题。在这些主题中,生成器(generators)
和 yield
关键字是其中最难的一点。我猜测,大部分 Python 编程新手也会存在这个问题。
有很多文章(report)的作者在理解生成器和关键字 yield 的时候有困难,即使他们已经很努力地学习这个主题。我想要改变这个现状。在这篇文章中,我将会解释 yield 关键字是什么,为什么它很有用和如何使用它。
什么是生成器(书本定义)
Python 生成器是指通过调用 yield 来返回一个生成器迭代器(可以迭代的对象)的函数。
yield 可能会连同值来一起调用(译注:如 yield value),而这个值会被当成一个“生成(generated)”的值。当下一次生成器的 next() 函数被调用时(例如,for 循环的下一轮),生成器从上次调用 yield 的地方而不是函数的开头恢复(resumes)执行。所有的状态,例如局部变量的值,会被恢复,而生成器会继续执行,直到对 yield 的下一次调用。
如果你不能理解这些,不用担心。我会用其他方式来解释书本上的定义。
注:最近几年,作为通过 PEPs 添加的功能,生成器变得越加强大。在我的下一篇文章,我会探索 yield 在协程(coroutines)、协同多任务(cooperative multitasking)和异步 I/O (asynchronous I/O)(特别是它们在 tulip 原型的实现 GvR 上的应用)。在此之前,我们需要对 yield 和生成器是如何工作的有一个深入的理解。
协程(Coroutines)和子例程(Subroutines)
译注:关于协程和子协程详细概念,请参考上一博文 Python 协程。
当我们调用一个普通函数时,会从该函数的第一行开始执行,直到遇到 return
语句、异常或函数末尾(相当于隐含的 return None
)。一旦该函数返回控制权给它的调用者,整个调用流程就结束了。所有该函数做的工作(译注:注意这里强调的是“该函数”所做的)和其局部变量都会丢失。对该函数新的调用会从头创建所有东西。
在计算机编程中,当我们讨论函数(更普遍地称为子程序)时,以上所说的是一个非常标准的流程。然而,有时候,拥有创建一个能够生成(yield)一系列的值(a series of values)而不单单是返回单个值的函数的能力是非常有用的。为了达到这点,这个函数需要能够“保存它的工作”。
我说到“生成(yield)一系列的值”,是因为这个假定的函数并不像常规函数一样“返回(return)”。return 意味着返回执行控制权给调用该函数的调用者。然而,“yield” 则隐含控制权的转移是暂时和自发的,而且我们的函数期望在未来重新获得控制权。
在 Python,拥有该能力的函数称为生成器,而且它们非常有用。生成器(和 yield 语句)一开始就是用于提供一种编写生成序列的代码的更加简要的方法。以前的时候,编写如随机数生成器这样的代码需要一个类或模块同时能够生成数据和保持调用之间的状态。引进生成器后,这就变得非常简单了。
为了更好理解生成器所要解决的问题,让我们一起来看看一个例子。贯穿这个例子,请牢牢记住所要解决的核心问题:生成(generate)一系列的值
。
注:Python 之外,除了(all but)最简单的生成器,生成器可以称为协程(coroutines)。在本文后边,我会使用这个术语。需要记住的重要事情是,本文所提到的协程都是生成器。Pyhon 对生成器有正式定义,而对协程,只用于讨论中而没有正式的定义。
例子:素数
假设我们的老板让我们写一个函数,该函数的输入是一个整数列表,输出是一些包含了素数的可迭代对象。
记住,一个可迭代对象只是一个能够一次返回一个值的对象。
“这很简单”,我们会说,然后写出如下代码:
1 | def get_primes(input_list): |
这个 get_primes 满足要求,所以我们告诉老板已经做的工作。她告诉我们这个函数工作的很好,正是她所需要的。
处理无穷序列
好吧,几天之后,我们的老板告诉我们她遇到了一个小问题:她想让 get_primes 函数能够接受非常大的列表输入。实际上,这个列表大到如果我们简单用他们创建素数会消耗掉系统的所有内存。为了解决这个问题,她想要 get_primes 函数能够接受一个起始值(start),然后返回所有大于该值的所有素数(可能她正在解决问题 Project Euler problem 10)
一旦我们考虑这个新的需求,我们就会很清楚不能只对 get_primes 函数作简单改进。很明显,我们不能返回从 start 到无穷大的所有素数列表(虽然生成无穷序列有很广泛的应用)。利用常规方法解决该问题貌似不可行。
在我们放弃之前,让我们确定阻止我们写出满足老板需求的代码的阻碍。想一想,就会得到如下结论:函数只有一次机会可以返回结果,因此必须一次性返回所有结果。我们想,说“函数就是这样子工作的”貌似没有什么意义。真正的价值在于提问:如果它们不这样子工作呢?
想象一下,如果 get_primes 函数只是简单返回下一个值而不是所有值一起,那就不需要去创建一个列表了。没有列表,就没有内存问题。因为老板告诉我们她只需要迭代地获取结果,她不会知道不同实现之间的区别的。
不幸的是,这看起来貌似不可行。即使我们有一个魔法函数,允许我们从 n 迭代到无穷大,但我们在其返回第一个值后就会被困住:
1 | def get_primes(start): |
想象一下,我们这样子调用 get_primes 函数:
1 | def solve_number_10(): |
很明显,在 get_primes 函数中,我们立即就会执行到 number = 3 的地方,从第 4 行返回(return),我们需要找到一个生成值的方法,且当我们要获得下一个值的时候,直接从上次调用时离开的地方开始执行
。
然而函数并不能做到这一点。当它们返回的时候,就已经完成了它们的任务。即使我们可以保证该函数会被再一次调用,我们也做不到让函数从上次调用离开的第 4 行而不是第 1 行开始。函数只有单一的入口:第一行代码。
深入生成器
这一类问题如此常见以至于一个新的概念被添加到 Python 中以解决该问题:生成器。生成器生成
数据。通过同时引入的生成器函数(generator functions)概念,生成器创建过程被设计的尽量简单。
生成器函数的定义类似于普通函数,但无论何时它要生成一个数值的时候,它是通过 yield 关键字而不是 return 来做到。如果函数定义(def)中含有 yield 关键字,那这个函数就自动转变为生成器函数(即使同时含有 return 语句)。除此之外,我们不需要做其他工作。
生成器函数会创建生成器迭代器。然而,这将是你最后一次看到生成器迭代器这个术语,因为它们几乎总是被当作“生成器”。只需记住生成器是一种特殊的迭代器
。要被认为是迭代器,生成器必须定义几个方法(methods),其中一个是 __next__()
。为了获得生成器的下一个值,我们使用跟迭代器内建的相同的函数 iterators:next()
。
再次重复:**为了获得生成器的下一个值,我们使用跟迭代器内建的相同的函数 **iterators:next()
。
(next() 函数会调用生成器的 next() 方法)
因为生成器是一种特殊的迭代器,所以它能够被用于 for
循环中。
所以无论何时 next() 函数调用生成器,生成器负责返回一个值给调用 next() 函数的人。生成器是通过调用带返回值的 yield 关键字做到的(如 yield 7)。
最容易记住 yield 所做的就是把它当成生成器函数的 return。
再一次:yield 只是生成器函数的 return(多了一点魔法)。
这是一个简单的生成器函数:
1 | def simple_generator_function(): |
两种使用该函数的简单方法:
1 | for value in simple_generator_function(): |
魔法?
魔法部分在哪里?很高兴你问了!当一个生成器函数调用了 yield,该函数的状态会被冻结住;直到 next() 函数被调用之前,该函数的所有变量值都会被保存起来,下一行即将执行的代码也会被记录下来。一旦再次调用,生成器函数会从上次调用时离开的地方开始执行。如果 next() 函数再也没有被调用,所有在调用 yield 时记录的所有状态最后都会被遗弃。
让我们把 get_primes 重写为生成器函数。注意我们再也不需要 magical_infinite_range 函数。利用简单的 While 循环,我们可以创建一个无限序列:
1 | def get_primes(number): |
当一个生成器函数调用 return 或者到达了定义末尾,StopIteration
异常会被抛出。这会向调用 next() 函数的人发出信号,生成器已经穷尽了(这是迭代器正常行为)。这也是我们为什么要在 get_primes 函数中添加 while True:
循环。如果不这样子做,第一次调用 next() 函数的时候回检查 number 是否为素数,并可能生成(yield)该数;当再次调用 next() 函数时,我们会把 number 累加 1,然后运行到该生成器函数末尾(导致 StopIteration 异常抛出)。一旦生成器穷尽时,调用 next() 函数会导致错误,所以你只能有一次机会消费所有生成器的值。下边代码不能工作:
1 | our_generator = simple_generator_function() |
因此,while 循环保证了我们永远不会到达 get_primes 函数结束点。它允许我们只要调用 next() 函数就会生成一个数值。这是一种处理无限序列(和生成器,通常情况下)的通用方法。
详细工作流程
让我们回到调用 get_primes 的函数 solve_number_10:
1 | def solve_number_10(): |
详述当我们在 solve_number_10 函数 for 循环中调用 get_primes 时最初的几个元素是如何创建的,非常有帮助。当这个 for 循环第一次调用 get_primes 时,我们就像进入一般函数一样进入该函数:
- 进入第 3 行的 while 循环
- 进入 if 分支(3 是素数)
- 生成(yield)并返回值 3,并把控制权切换回 solve_number_10 函数
然后,回到 solve_number_10 函数:
- 值 3 传回到 for 循环
- for 循环将该值赋予 next_prime
- next_prime 的值被累加到 total
- for 循环请求 get_primes 的下一个值
这一次,不是从 get_primes 函数的顶部开始执行,而是从上次调用离开的第 5 行开始执行
:
1 | def get_primes(number): |
更重要的是,number 的值仍然为上次我们调用 yild 时的值(也即 3)
。记住,yield 同时传回值给 next() 调用者和保存生成器的“状态”。很明显,number 累加至 4,然后程序运行至 while 循环顶部,number 继续累加,直到下一个素数 5。再一次,我们生成(yield)并返回 number 的值给 solve_number_10 中的 for 循环。这样一直循环,直到 for 循环停止(在第一个大于 2,000,000 的素数时)。
更多功能
在 PEP 342,支持向生成器传递值。PEP 342 让生成器能够生成(yield)值(跟之前一样)、接收值,或者在单个语句中同时生成和接收值。
为了说明数值是怎么发送给生成器的,然我们回到那个素数例子。这一次,不是简单打印每个大于 number 的素数,我们要找出大于 number 连续乘方的最小素数(例如,对于 10,我们想要找到依次大于 10、100、1000 的最小素数)。代码如下:
1 | def print_successive_primes(iterations, base=10): |
对 get_primes 的下一行需要稍加解释。yield number 在生成(yield)并返回 number 的值的同时,other = yield foo 形式的语句还意味着“生成(yield)并返回 foo,同时当有数据发送给我时,将其赋予 other”
。你可以利用生成器的 send
方法向其发送数值。
1 | def get_primes(number): |
这样,我们就能够设定生成器的 number 为不同的值。现在我们可以填充 print_successive_primes 函数中缺失部分:
1 | def print_successive_primes(iterations, base=10): |
有两件事需要注意:第一,我们打印了 generator.send 的结果,这是可以的,因为 send 函数向生成器发送数值的时候也接收生成器生成(yield)的数据
(与 yield 在生成器函数如何工作相对应)。
第二,注意 prime_generator.send(None) 这一行。当你使用 send 来“启动”一个生成器
时(也即,执行生成器函数的第一行代码到第一个 yield 语句中间的代码),你必须发送 None(译注:还可以调用生成器的 next() 方法开启动)。这讲得通,因为按定义,生成器还没有执行到第一个 yield 语句,所以如果我们发送一个真实数据过去,没有东西可以接收它。一旦生成器启动,我们就可以像以上做的一样给它发数据。
进阶(Round up)
在本文第二部分,我们讨论了生成器提升的各个方面和因此获得的能力。yield 已经变成 Python 中最强大的关键字之一。因为我们已经对 yield 如何工作有了一个深入的理解,所有我们现在有足够知识来理解 yield 所能够应用的更费脑的事情。
信或不信,我们也仅仅是了解了 yield 的表层能力。例如,虽然 send 确实如前文描述那样工作,但它几乎从不会用于生成简单序列,如我们的例子。下边,我展示了 send 的一个常用方式。我不会说太多,因为关于它如何工作以及为什么可以工作将会是第二部分的一个很好的热身。
1 | import random |
请记住…
这里有几个希望你从本文学习到的关键点:
- 生成器用于生成一系列的值
- yield 就像是生成器函数的 return
- yield 做的唯一一件事就是保存生成器函数的“状态”
- 生成器只是一种特殊的迭代器
- 向迭代器一样,我们可以使用 next() 函数来获得生成器的下一个值
- for 通过隐含调用 next() 函数来获取值
我希望这篇文章对你有帮助。如果你从来没有听说过生成器,我希望你现在知道它们是什么、为什么它们很有用,以及如何使用它们;如果你对生成器有点熟悉,我希望本文能够解决你的困惑。
如前,如果本文有哪些地方不清晰(或者更重要,隐含错误),请通过各种方法告诉我。你可以在本文下边评论,发邮件到 jeff@jeffknupp.com,或者直接 @ 我的 Twitter jeffknupp。