首先,这段代码过于聪明,是why not to use recursion in Python的一个很好的例子:
>>> g = nats(10)
>>> [next(g) for _ in range(1000)]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 1, in <listcomp>
File "<stdin>", line 3, in nats
File "<stdin>", line 3, in nats
File "<stdin>", line 3, in nats
[Previous line repeated 994 more times]
RecursionError: maximum recursion depth exceeded
如果不破坏调用堆栈,我们甚至无法生成 1000 个自然数或素数。
深入研究代码,让我们从yield from 和nats 开始。 yield from 使递归调用能够将yield 结果传递给原始调用代码返回的生成器。 nats 生成从n 到无穷大的无限自然数序列。
实际上,nats 在 Python 中已经以 itertools.count 的形式存在。这不会破坏堆栈:
>>> from itertools import count
>>> g = count(10)
>>> len([next(g) for _ in range(10000000)])
10000000
如果你必须自己写nats,你会更直接、更安全地使用循环(itertools.count 的实现类似):
def nats(start=0):
while True:
yield start
start += 1
我们可以根据nats 看到生成器提供状态;直到您使用next() 请求结果才会返回结果,并且在每个yield 之后暂停执行。这对于无限序列很有用,因为我们可以在需要的时候获取我们想要的东西,而无需使用额外的空间来存储所有先前数字的列表或从头开始。
虽然我在谈论它,但nats 并不是最伟大的名字;目前尚不清楚它在没有上下文的情况下意味着什么,并且该函数在负数等非自然数上运行良好。
sieve 与nats 做同样的事情,递归地逐个质数地前进。每个递归调用都会创建一个新的生成器,该生成器根据前一个生成器s 的输出执行筛选(s 应该称为last_sieve)、(i for i in s if i%n != 0)。此生成器会跳过任何数字,该数字是上一个递归调用中前一个生成器生成的第一个素数的倍数,n。
关键的认识是生成器不只是消失:它们停留在一个调用框架中过滤一个特定的素数,并继续被未来的生成器在更深的框架中调用。
这有点像一个斗式旅。第一个生成器将所有数字的流发送到第二个生成器,第二个生成器过滤掉数字% 2,第三个生成器进一步过滤% 3,四个生成器过滤% 5上的流......每一帧,生成器链变长 1,数字必须经过越来越多的过滤器才能被视为素数。
这是算法的迭代版本,它不会破坏堆栈,并有一些调试打印显示生成器的工作原理。您可以看到每一步都拒绝了哪些数字(括号中的数字是每个生成器的唯一单调递增标识符):
from itertools import count
def make_debuggable_gen(gen_expr, identifier):
while True:
val = next(gen_expr)
print(f"[{identifier}] emitting '{val}'")
yield val
# note: no need to except StopIteration since our generators are infinite
def make_prime_gen(last_gen, prime, identifier):
return make_debuggable_gen((n for n in last_gen if n % prime), identifier)
def sieve():
identifier = 0
prime_gen = make_prime_gen(count(2), -float("inf"), identifier)
while True:
prime = next(prime_gen)
yield prime
identifier += 1
prime_gen = make_prime_gen(prime_gen, prime, identifier)
if __name__ == "__main__":
s = sieve()
for _ in range(6):
print(next(s))
示例运行:
[0] emitting '2'
2
[0] emitting '3'
[1] emitting '3'
3
[0] emitting '4'
[0] emitting '5'
[1] emitting '5'
[2] emitting '5'
5
[0] emitting '6'
[0] emitting '7'
[1] emitting '7'
[2] emitting '7'
[3] emitting '7'
7
[0] emitting '8'
[0] emitting '9'
[1] emitting '9'
[0] emitting '10'
[0] emitting '11'
[1] emitting '11'
[2] emitting '11'
[3] emitting '11'
[4] emitting '11'
11
[0] emitting '12'
[0] emitting '13'
[1] emitting '13'
[2] emitting '13'
[3] emitting '13'
[4] emitting '13'
[5] emitting '13'
13
希望这能回答您的问题,但要明确:
-
i 是前一个生成器 s(我们称之为“last_sieve”)从前一个调用帧发出的整数。
- 希望上面的调试输出能回答——第二个生成器(id 1)有
n = 2,因为那是生成器 id 0 发出的第一个素数。生成器 id 1 传递的值序列将是 3、5、7 ...它拒绝任何偶数 (% 2 == 0),当它达到 3 时,它会创建下一个 id 为 2 的生成器,用于过滤掉所有数字 % 3。
- 条件
i % n != 0 根据数字流是否可被此特定调用帧上的此特定生成器关心的一个素数n 整除来过滤数字流。素数n 表示链中前一个生成器找到的第一个素数(它可能应该称为prime 或last_prime)。
- 初始调用
sieve(nats(2)) 和i-th 调用之间的区别在于i-th 调用使用来自i-1-th 调用的生成器作为种子,该生成器对其进行了过滤某个素数。另一方面,第一个调用帧没有过滤,只有 nats 单调加 1。
-
for 循环只是一个普通的generator expression,它基本上是一个有状态的、惰性的列表理解。它所做的只是从s 中无限拉取数字,并且不会发出任何未通过过滤器的数字,在我们的例子中,过滤器是用于测试可除性的模数。
最后,这是上面代码没有调试的清理版本:
from itertools import count
def make_prime_gen(last_gen, prime):
return (n for n in last_gen if n % prime)
def sieve():
prime_gen = count(2)
while True:
prime = next(prime_gen)
yield prime
prime_gen = make_prime_gen(prime_gen, prime)
if __name__ == "__main__":
s = sieve()
for _ in range(6):
print(next(s))
请注意,函数make_prime_gen 充当prime 的闭包,其方式与原始代码让每个生成器在其自己的调用框架中跟踪n 的方式相似。我们不必在这里使用函数,但它是一种方便的习惯用法,可以方便地跟踪每个生成器的所有素数,而无需保留列表。
即使没有不可原谅的递归,这个函数的空间复杂性也是一个严重的缺点,似乎几乎挫败了生成器背后的想法。为每个素数创建一个全新的生成器是一个严重的危险信号。在传统的筛子中,我们没有一个简单的数组或一组先前的素数,而是有一堆生成器对象和调用帧。
从效率的角度来看,第一个生成器不仅需要遍历每个数字,而且还需要通过不断增加的生成器链将其传递到可以发出的点。这类似于 naive 算法的嵌套循环,但 naive 算法可以利用 Wikipedia 中描述的主循环中的各种烘焙跳过,更不用说更少的调用开销和可能更好的缓存局部性。