在哪些情况下应该在Python中实际使用生成器?

9
我正在试图理解生成器并学习如何使用它们。我看过很多例子,了解到它们一次只产生一个结果,而不像常规函数一样一次性输出所有结果。但是我看到的所有例子都需要遍历列表并打印通过函数生成的值。如果您想要实际创建一个列表呢?
例如,我看到一个关于偶数的例子,它只生成偶数并将其打印出来,但是如果我想要一个类似这样的偶数列表:
def even(k):
    for i in range(k):
        if (i%2):
           yield k

even_list = []
for i in even(100):
    even_list.append(i)

如果这样做会生成一个偶数列表,那么使用生成器的目的是否被击败了?这种方法仍然能节省一些内存/时间吗?

或者不使用生成器的下面方法是否同样有效。

def even(k):
    evens_list = []
    for i in range(k):
        if (i%2):
           evens_list.append(i)
    return evens_list

在什么情况下生成器才有用?

1
通常为了节省内存(不需要同时存在所有元素),或时间(如果你认为只有前 k 个元素是必需的),或者对于那些不会结束的事物(比如网络摄像头的视频流),我们会使用迭代器。 - Willem Van Onsem
@WillemVanOnsem 我认为问题更偏向于 for i in even(100): 是否会一次性生成整个序列 - roganjosh
有时候,通过使用yield构建函数比在函数中构建结果更简单/更清晰。 - Stephen Rauch
@roganjosh:前两点同样适用于for i in even(100) - Willem Van Onsem
4个回答

12
我可以帮您翻译成中文。此段内容涉及编程问题,有关生成器的使用。生成器在创建一个偶数列表时是否有意义?在哪些情况下使用生成器是有用的?这有点主观,但在某些情况下,列表可能行不通(例如因为硬件限制)。保存 CPU 循环(时间)想象一下,你有一个偶数列表,然后想要取前五个数字的总和。在 Python 中,我们可以使用 islice 来完成这个操作:
sumfirst5even = sum(islice(even(100), 5))

如果我们首先生成一个包含100个偶数的列表(不知道我们以后会用这个列表做什么),那么我们在构建这样的列表时花费了大量的CPU周期,而这些都是浪费的。通过使用生成器,我们可以将其限制为仅获取我们真正需要的元素。因此,我们只会yield前五个元素。该算法将永远不会计算大于10的元素。是的,在这里,这是否会产生任何(显着)影响还存在疑问。甚至可能发生“生成器协议”需要比生成列表更多的CPU周期,因此对于小型列表来说,没有优势。但现在想象一下,我们使用了even(100000),那么我们在生成整个列表上花费的“无用CPU周期”的数量可以显著减少。

节省内存

另一个潜在的好处是节省内存,因为我们不需要同时在内存中保存生成器的所有元素。例如,看下面的例子:
for x in even(1000):
    print(x)

如果even(..)构造一个包含1000个元素的列表,那么所有这些数字都需要同时存在于内存中作为对象。根据Python解释器的不同,对象可能占用相当大的内存空间。例如,在CPython中,一个int占用28字节的内存。因此,包含500个这样的int的列表可能需要大约14 kB的内存(还需要一些额外的内存用于列表)。是的,大多数Python解释器采用“轻量级”模式来减轻小整数的负担(这些整数是共享的,因此我们在过程中不会为每个构造的int创建单独的对象),但仍然可能很容易地累加。对于even(1000000),我们将需要14 MB的内存。
如果我们使用生成器,那么取决于我们如何使用生成器,我们可能会节省内存。为什么?因为一旦我们不再需要数字123456(因为for循环前进到下一个项目),对象“占用”的空间可以被回收,并赋予值为12348的int对象。因此,这意味着 - 假设我们使用生成器的方式允许这样做 - 内存使用量保持恒定,而对于列表来说,则会按比例缩放。当然,生成器本身也需要进行适当的管理:如果在生成器代码中,我们建立了一个集合,那么内存当然也会增加。

在32位系统中,这甚至可能导致一些问题,因为Python列表具有最大长度。列表最多可以包含536'870'912个元素。是的,这是一个巨大的数字,但是如果您例如想要生成给定列表的所有排列,该怎么办?如果我们将排列存储在列表中,那么对于32位系统,13个或更多元素的列表,我们将永远无法构造这样的列表。

“在线”程序

在理论计算机科学中,“在线算法”被一些研究者定义为逐步接收输入的算法,因此事先不知道整个输入。
一个实际的例子可以是一个网络摄像头,每秒钟拍摄一张图像,并将其发送到Python Web服务器。我们此时不知道24小时内摄像头拍摄的图像会是什么样子。但是我们可能有兴趣检测盗贼的行踪。在这种情况下,帧列表不包含所有图像。然而,生成器可以构造一个优雅的“协议”,我们可以迭代地获取图像,检测盗贼并发出警报,例如:
for frame in from_webcam():
    if contains_burglar(frame):
        send_alarm_email('Maurice Moss')

无限生成器

我们不需要网络摄像头或其他硬件来利用生成器的优越性。生成器可以产生一个“无限”的序列。或者甚至生成器可能看起来像:

def even():
    i = 0
    while True:
        yield i
        i += 2

这是一个生成器,最终将生成所有偶数。如果我们不断迭代它,最终会产生数字123'456'789'012'345'678(尽管可能需要很长时间)。
如果我们想要实现一个程序,例如不断生成回文偶数,上述内容可能会有用。这可能看起来像:
for i in even():
    if is_palindrome(i):
        print(i)

我们可以假设这个程序将继续工作,并且不需要“更新”偶数列表。在一些纯函数式语言中,能够透明地进行惰性编程,程序被编写得好像你创建了一个列表,但实际上通常是一个生成器。
“增强”的生成器:range(..)和朋友们
在Python中,许多类在迭代时并不会构造列表,例如range(1000)对象不会首先构造列表(在中会,但在中不会)。range(..)对象仅仅表示一个范围。 range(..)对象不是一个生成器,但它是一个可以生成迭代器对象的类,其行为类似于生成器。
除了迭代,我们可以使用range(..)对象进行所有与列表相同的操作,但是效率不高。

例如,如果我们想知道1000000000是否为range(400,10000000000,2)的元素,则可以编写1000000000 in range(400,10000000000,2)。现在有一个算法来检查这个元素是否属于range(..)对象(因此大于或等于400,小于10000000000),并且是否被产生(考虑步长),这不需要迭代它。因此,成员资格检查可以立即完成。

如果我们生成了一个列表,这意味着Python必须枚举每个元素,直到最终找到该元素(或达到列表的末尾)。对于像1000000000这样的数字,这可能需要几分钟,几小时,甚至几天。

我们还可以对范围对象进行“切片”,这将产生另一个 range(..) 对象,例如:
>>> range(123, 456, 7)[1::4]
range(130, 459, 28)

通过一个算法,我们可以立即将 range(..) 对象切片成一个新的 range 对象。对列表进行切片需要线性时间。这可能会再次(对于大型列表)花费相当长的时间和内存。

4

生成器更短更易读:

在你的例子中,你需要创建一个空列表,使用append方法并返回结果列表:

def even(k):
    evens_list = []
    for i in range(k):
        if i % 2 != 0:
           evens_list.append(i)
    return evens_list

生成器只需要使用yield关键字:
def even(k):
    for i in range(k):
        if i % 2 != 0:
           yield i

如果你真的需要一个列表,那么使用方法几乎相同。只需用

event_list = even(100)

这条线变成了

event_list = list(even(100))

3
生成器一般来说具有懒惰语义并具有一些优势:
  • 您可以创建无限列表
  • 它可以节省大量内存,因为它不会在内存中保留所有列表
  • 通常用于昂贵的IO操作,因此只有在真正需要时才能有效地检索数据
但也有一些缺点:
  • 开销
    • 您需要在内存中保存生成器函数的变量
    • 还有可能出现内存泄漏的风险
  • 每次要重复使用集合中的元素时必须重新生成

在我上面的例子中,它确实将偶数列表保存在内存中。所以我想知道,生成器方法是否比上面的原始方法更好? - jan93
取决于两个因素:列表的大小和您使用此列表的次数。对于包含100个元素的列表,我认为没有空间问题。因此,最后一个因素是您使用这个偶数列表的次数,如果只使用一次,则可以使用生成器,否则建议使用列表。 - Mikedev
我不知道你是否知道,生成偶数的两种更短的方法:filter(lambda el: el % 2 == 1, range(100))是一个生成器或者"生成器推导式"(el for el in range(100) if el % 2 == 1)也是一个生成器。 - Mikedev
如果我的列表有一亿个元素,那么我的生成器方法是否更有效?我想我很难理解的是,两种方法都涉及将值附加到列表中,那么在将所有值添加到列表后,生成器方法究竟更有效的因素是什么... - jan93
它并不更高效,但在许多计算机上,您无法将1000000000个元素保持在内存中,因此您被迫使用生成器来处理这个数据序列。 - Mikedev

-1

你可以使用 list() 构造函数轻松高效地将生成器的输出转换为列表:

even_list = list(even(100))

那确实违背了生成器的初衷。这不是OP所问的。 - roganjosh
生成器提供了选择的灵活性,而不会牺牲效率。这就是为什么在Python 3中range返回一个生成器而不是列表,与Python 2不同。OP的代码以一种更低效的方式将生成器的输出转换为列表。因此,这就是我的答案。 - blhsing
但是标题是“在哪些情况下应该实际使用Python中的生成器?”。你认为这回答了吗?你展示了如何耗尽一个生成器;相对于你的答案,为什么要在第一次构建它时费心呢? - roganjosh
2
第一段代码是否比第二段更好,这是我试图确定的。如果不是,那么在什么情况下它才能真正节省内存。 - jan93
使用生成器更好,因为它提供了选择的灵活性。调用者可以选择不将每个元素实现为列表,如果调用者只想迭代它,则可以选择将其转换为列表使用 list() 构造函数。当您想要一个列表时,您发现生成器效率较低的原因是因为您以一种低效的方式将其转换为列表。因此,我的答案指出,如果您以正确的方式进行转换,它就不会有这样的缺点。 - blhsing

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接