【问题标题】:Python generator expression recursionPython生成器表达式递归
【发布时间】:2021-10-17 12:40:46
【问题描述】:

这是一个关于 Python 内部的问题。 以下代码取自this video about laziness in python:

def nats(n):
    yield n
    yield from nats(n + 1)


def sieve(s):
    n = next(s)
    yield n
    yield from sieve(i for i in s if i % n != 0)

s = sieve(nats(2))
print(next(s), next(s), next(s), next(s), next(s)) # Primes: 2, 3, 5, 7, 11...

sieve 函数懒惰地生成素数(请阅读this 了解原始概念)。从概念上讲,我们将过滤器添加到“筛子”中,因此每个数字(例如 10)都针对所有先前找到的素数(例如 2、3、5 和 7)进行测试,直到找到下一个素数,即 11。11 是然后添加到过滤器的“列表”中,依此类推。

这部分(i for i in s if i % n != 0)是generator expression (or "comprehension")。

我的问题与 Python 用来“嵌套”这些生成器表达式的机制有关。例如,让我们使用两个可能的通过表达式:

第一次检查时,我们取 nats(用于自然数)并向其添加 2 过滤器。

第二次,我们使用已经“通过”nats和2过滤器的生成器,并将3过滤器添加到它。我们从[3,2,nats] 产生,从[2,nats] 产生,从[nats] 产生。关键是,很明显,每层传递都需要保留一些变量上下文,例如,每一层“看到”不同的n。

但是 Python 到底在做什么呢?以下是一些我认为可行的选择:

  1. 是否为每个嵌套的生成器调用添加堆栈帧?
  2. 是否只需要创建一个闭包对象?
  3. 也许 python 将生成器“压缩”成一个类似于此表达式的生成器: i % 2 != 0 and i % 3 != 0 and i % 4 !=0?

或者我可能遗漏了一些关于这里发生的事情的基本信息?

【问题讨论】:

  • 您在此处添加到堆栈。事实上,通过尝试确定第 500 个素数之类的东西很容易溢出堆栈。

标签: python closures python-internals sieve-of-eratosthenes generator-expression


【解决方案1】:

如你所见in this visual demonstration of your code,

每个yield from 都会创建一个新的堆栈帧和一个新的生成器对象。

我认为nats 可以很容易地重写为使用循环而不是递归。但是,sieve 可能更难以优雅地重写以保持这个想法。

【讨论】:

    【解决方案2】:

    FWIW,您可以通过删除递归并在生成器中使用循环来避免堆栈溢出。这将允许您生成更大的素数,但这不是免费的午餐。您仍然通过捕获所有生成器对象来消耗内存,您只是没有通过递归来做到这一点。它会逐渐变慢并最终耗尽资源:

    def nats(n): 
        while True:
            yield n
            n += 1
    
    
    def sieve(s):
        while True:
            n = next(s)
            yield n
            s = filter(lambda i, n=n: i % n != 0, s)
    
    
    s = sieve(nats(2))
    
    # generate the 3000th prime
    for x in range(3000):
        n = next(s)
    
    print(n)
    # 27449
    

    【讨论】:

      【解决方案3】:

      很明显,每层传递都需要保留一些变量上下文,例如,每一层“看到”不同的n。

      是的,这不是特定于生成器,而是任何函数调用:如果该函数调用一个函数(可能是它自己),那么它的局部变量会保存在堆栈帧中,并且新的函数执行上下文会得到它自己的集合局部变量。

      是否为每个嵌套的生成器调用添加一个堆栈帧?

      是的。所以在sieve的情况下,sieve的每个执行上下文都有自己的n和s变量。

      在sieve 传递给递归调用的表达式中,它正在从作为参数获得的现有迭代器中创建一个新的、更具限制性的迭代器。我们可以向后工作,看看完整的迭代器是什么样子的。

      第一次递归调用,可扩展为:

      yield from sieve(i for i in 
                         (i for i in nat(3))   # this is roughly `s`
                       if i % 2 != 0)
      

      我写 nat(3) 而不是 nat(2) 因为值 2 已经从该迭代器中消耗了。

      然后该递归调用将产生 3,并进行下一次递归调用:

      yield from sieve(i for i in 
                         i for i in                 # }
                           (i for i in nat(3))      # } this is roughly `s` 
                         if i % 2 != 0 and i != 3)  # }
                       if i % 3 != 0)
      

      我再次添加 and i != 3,因为 3 已被单独的 next(s) 调用消耗。

      ...就这样继续下去。

      实际限制

      您可以想象,这非常消耗内存。在每次递归调用时,调用堆栈使用量都会增加,并且迭代器的嵌套构造中的每个迭代器都是sieve 的执行上下文之一中的变量s,并且必须完成其工作。

      虽然从理论上看这看起来很优雅,但在实际实现中并不实用:在遇到“超出最大递归深度”类型的错误之前可以生成的素数数量将非常少,令人失望。在 repl.it 上运行它时,在错误之前生成的最后一个素数是 3559。

      【讨论】:

        猜你喜欢
        • 2021-05-22
        • 2016-05-03
        • 2017-12-08
        • 1970-01-01
        • 2016-03-19
        • 2014-04-02
        • 2021-12-27
        • 2022-11-14
        • 2019-02-11
        相关资源
        最近更新 更多