生成器推导式表达式之间的区别

16
据我所知,通过推导式有三种方式可以创建生成器1

其中一种是经典的写法:

def f1():
    g = (i for i in range(10))

yield 变体:

def f2():
    g = [(yield i) for i in range(10)]

使用yield from的变量(在函数外部引用会触发SyntaxError错误):

def f3():
    g = [(yield from range(10))]

三种变体会产生不同的字节码,这并不奇怪。似乎第一个是最好的选择,因为它是通过推导式创建生成器的专用,简单的语法。然而,它并不能产生最短的字节码。

在Python 3.6中反汇编

经典的生成器推导式

>>> dis.dis(f1)
4           0 LOAD_CONST               1 (<code object <genexpr> at...>)
            2 LOAD_CONST               2 ('f1.<locals>.<genexpr>')
            4 MAKE_FUNCTION            0
            6 LOAD_GLOBAL              0 (range)
            8 LOAD_CONST               3 (10)
           10 CALL_FUNCTION            1
           12 GET_ITER
           14 CALL_FUNCTION            1
           16 STORE_FAST               0 (g)

5          18 LOAD_FAST                0 (g)
           20 RETURN_VALUE

yield 变体

>>> dis.dis(f2)
8           0 LOAD_CONST               1 (<code object <listcomp> at...>)
            2 LOAD_CONST               2 ('f2.<locals>.<listcomp>')
            4 MAKE_FUNCTION            0
            6 LOAD_GLOBAL              0 (range)
            8 LOAD_CONST               3 (10)
           10 CALL_FUNCTION            1
           12 GET_ITER
           14 CALL_FUNCTION            1
           16 STORE_FAST               0 (g)

9          18 LOAD_FAST                0 (g)
           20 RETURN_VALUE

yield from variant

>>> dis.dis(f3)
12           0 LOAD_GLOBAL              0 (range)
             2 LOAD_CONST               1 (10)
             4 CALL_FUNCTION            1
             6 GET_YIELD_FROM_ITER
             8 LOAD_CONST               0 (None)
            10 YIELD_FROM
            12 BUILD_LIST               1
            14 STORE_FAST               0 (g)

13          16 LOAD_FAST                0 (g)
            18 RETURN_VALUE
        

此外,timeit 对比表明,在 Python 3.6 中 yield from 的变体是最快的:


>>> timeit(f1)
0.5334039637357152

>>> timeit(f2)
0.5358906506760719

>>> timeit(f3)
0.19329123352712596

f3 的速度比 f1f2 快大约 2.7 倍。

正如Leon在评论中提到的,生成器的效率最好通过它能被迭代的速度来衡量。 因此,我改变了这三个函数,使它们迭代生成器,并调用一个虚拟函数。

def f():
    pass

def fn():
    g = ...
    for _ in g:
        f()

结果更加明显:

>>> timeit(f1)
1.6017412817975778

>>> timeit(f2)
1.778684261368946

>>> timeit(f3)
0.1960603619517669

f3现在比f1快8.4倍,比f2快9.3倍。

注意:当可迭代对象不是range(10)而是静态的可迭代对象时,例如[0, 1, 2, 3, 4, 5],结果或多或少相同。 因此,速度差异与range被优化无关。


那么,这三种方法有什么区别? 更具体地说,yield from变体和另外两个有什么区别?

自然构造(elt for elt in it)比诡计多端的[(yield from it)]慢是正常行为吗? 从现在开始,我是否应该在我的所有脚本中用后者替换前者,或者使用yield from结构存在任何缺点?


编辑

这一切都是相关的,所以我不想提一个新问题,但这变得更加奇怪了。 我尝试比较range(10)[(yield from range(10))]

def f1():
    for i in range(10):
        print(i)
    
def f2():
    for i in [(yield from range(10))]:
        print(i)

>>> timeit(f1, number=100000)
26.715589237537195

>>> timeit(f2, number=100000)
0.019948781941049987

那么,现在用[(yield from range(10))] 循环迭代的速度比裸的range(10)快186倍?

你如何解释为什么用[(yield from range(10))] 循环迭代比用range(10)快这么多?


1: 对于怀疑者,接下来的三个表达式确实会生成一个generator对象;尝试调用type


2
"最佳表达式"是如何定义的? - DeepSpace
1
@WillemVanOnsem 不完全是这样,第一个加载了一个<genexpr>而第二个加载了一个<listcomp> - Right leg
1
@Rightleg 这就是我所想的。 - Ma0
1
@Chris_Rands 这个话题真的让我很困惑。为什么一个专门的结构比一个有些牵强和反直觉的结构要慢? - Right leg
@Rightleg 你必须意识到,你的时间显示仅显示了创建/设置生成器所花费的时间,而不是运行/消耗生成器所花费的时间。在我看来,后者是生成器更重要的性能特征。 - Leon
显示剩余12条评论
3个回答

4

以下是你应该做的:

g = (i for i in range(10))

这是一个生成器表达式,它等同于

def temp(outer):
    for i in outer:
        yield i
g = temp(range(10))

但是,如果你只想要一个包含range(10)元素的可迭代对象,你可以这样做:

g = range(10)

您不需要将任何内容包装在函数中。

如果您想了解要编写的代码,可以停止阅读。本文剩余部分是关于其他代码片段为何存在问题并不应使用的长篇技术性解释,包括为什么您的时间测量也有问题的解释。


这个:

g = [(yield i) for i in range(10)]

这是一个损坏的结构,应该在多年前就被移除。在 Python 3 中,8 年后问题 最初报告,删除它的进程 终于开始了。不要这样做。

虽然它仍在语言中存在,但与 Python 3 等效的方式是:

def temp(outer):
    l = []
    for i in outer:
        l.append((yield i))
    return l
g = temp(range(10))

列表推导式应该返回列表,但由于使用了yield,这个不会返回列表。它的行为有点像生成器表达式,它产生与您第一个片段相同的东西,但它构建了一个不必要的列表并将其附加到最后引发的StopIteration

>>> g = [(yield i) for i in range(10)]
>>> [next(g) for i in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: [None, None, None, None, None, None, None, None, None, None]

这很令人困惑,也浪费内存。不要这样做。(如果你想知道所有这些None是从哪来的,请阅读PEP 342。)
在Python 2中,g = [(yield i) for i in range(10)]会产生完全不同的结果。Python 2没有为列表推导式提供自己的作用域 - 特别是列表推导式,而不是字典或集合推导式 - 因此yield由包含此行的任何函数执行。在Python 2中,这个:
def f():
    g = [(yield i) for i in range(10)]

等同于

def f():
    temp = []
    for i in range(10):
        temp.append((yield i))
    g = temp

f 改成一个基于生成器的协程,按照预-异步概念进行操作。如果你的目标是获取生成器,那么构建无意义的列表会浪费大量时间。


以下是需要翻译的内容:

g = [(yield from range(10))]

这有点傻,但这次Python不应该受到责备。

这里完全没有理解或生成表达式。方括号不是列表推导;所有的工作都是由yield from完成的,然后你构建一个包含(无用的)yield from返回值的一元列表。你的f3:

def f3():
    g = [(yield from range(10))]

当去掉不必要的列表构建时,简化为
def f3():
    yield from range(10)

或者,忽略所有协程支持的内容,yield from实际上是:
def f3():
    for i in range(10):
        yield i

你的时间计算也有问题。

在你的第一个时间计算中,f1f2创建了生成器对象,可以在这些函数内部使用,尽管f2的生成器很奇怪。但是f3不是这样的;f3是一个生成器函数。在你的时间计算中,f3的主体没有运行,如果运行了,它的g会与其他函数的g非常不同。与f1f2实际可比较的时间计算应该是

def f4():
    g = f3()

在第二个时间点,f2并没有真正运行,原因与前一个时间点的f3出现问题相同。在第二个时间点,f2没有迭代生成器。相反,yield fromf2转换为生成器函数本身。

4
g = [(yield i) for i in range(10)]
这个结构体通过其send()方法积累传回生成器的数据并在迭代耗尽时通过StopIteration异常返回它们1
>>> g = [(yield i) for i in range(3)]
>>> next(g)
0
>>> g.send('abc')
1
>>> g.send(123)
2
>>> g.send(4.5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: ['abc', 123, 4.5]
>>> #          ^^^^^^^^^^^^^^^^^

普通的生成器推导式不会发生这种情况:

>>> g = (i for i in range(3))
>>> next(g)
0
>>> g.send('abc')
1
>>> g.send(123)
2
>>> g.send(4.5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> 

至于使用yield from的版本 - 在我使用的Python 3.5中,它不能在函数外部工作,因此示例有所不同:

>>> def f(): return [(yield from range(3))]
... 
>>> g = f()
>>> next(g)
0
>>> g.send(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 1, in f
AttributeError: 'range_iterator' object has no attribute 'send'

好的,send() 方法不能用于生成器函数中通过 yield from range() 语句生成的值,但是我们至少可以看一下迭代结束时的情况:

>>> g = f()
>>> next(g)
0
>>> next(g)
1
>>> next(g)
2
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: [None]
>>> #          ^^^^^^

1 请注意,即使您不使用send()方法,也会默认使用send(None),因此以这种方式构建的生成器始终比普通生成器理解式使用更多的内存(因为它必须累加yield表达式的结果直到迭代结束):

>>> g = [(yield i) for i in range(3)]
>>> next(g)
0
>>> next(g)
1
>>> next(g)
2
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: [None, None, None]

更新

关于三个变体之间的性能差异。 yield from 之所以胜过其他两种方法,是因为它消除了一个间接级别(据我理解,这是 yield from 被引入的两个主要原因之一)。然而,在这个例子中,yield from 本身是多余的 - g = [(yield from range(10))] 实际上几乎与 g = range(10) 相同。


3
如果你在函数内部,yield from版本就能够工作。 - Chris_Rands
@Chris_Rands 谢谢。已更新答案。 - Leon
我不确定要得出什么结论。yield from 变体只累积一次 None,是吗?如果是这样,我看不出使用该结构的任何缺点... - Right leg

1

这可能不会像你想象的那样工作。

def f2():
    for i in [(yield from range(10))]:
        print(i)

叫它:

>>> def f2():
...     for i in [(yield from range(10))]:
...         print(i)
...
>>> f2() #Doesn't print.
<generator object f2 at 0x02C0DF00>
>>> set(f2()) #Prints `None`, because `(yield from range(10))` evaluates to `None`.
None
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

因为 yield from 不在推导式中,它绑定到 f2 函数而不是隐式函数,将 f2 转换为生成器函数。
我记得有人指出它实际上并没有迭代,但我不记得在哪里看到的。当我重新发现这个问题时,我正在测试代码。我在邮件列表帖子错误跟踪器线程中没有找到来源。如果有人找到了来源,请告诉我或将其添加到帖子本身中,以便得到认可。

我刚刚从我的答案中删除了一个后续代码的怪异之处。原来对于循环有特殊的REPL处理,而我不知道这一点,因为我通常使用IPython的REPL。这个奇怪的问题与生成器无关。 - leewz

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