yield from 和在 for 循环中使用 yield 的区别

30

我对yield from的理解是它类似于从可迭代对象中yield每个项目。然而,在以下示例中,我观察到了不同的行为。

我有一个Class1

class Class1:
    def __init__(self, gen):
        self.gen = gen
        
    def __iter__(self):
        for el in self.gen:
            yield el

还有一个 Class2,唯一不同的是在 for 循环中将 yield 替换为 yield from

class Class2:
    def __init__(self, gen):
        self.gen = gen
        
    def __iter__(self):
        yield from self.gen

以下代码从给定类的实例中读取第一个元素,然后在 for 循环中读取其余元素:
a = Class1((i for i in range(3)))
print(next(iter(a)))
for el in iter(a):
    print(el)

这会为Class1Class2产生不同的输出。对于Class1,输出是:

0
1
2

而对于Class2,输出是

0

实时演示

yield from背后的机制是什么,它产生了不同的行为?


1
不是针对你的问题的特定答案,但是 https://dev59.com/lmkw5IYBdhLWcg3ws8tj#26109157 提供了更多使用 yield from 不同于循环 yield 的方式。 - joanis
非常奇怪的是,对于Class2,如果你将iter(a)提取到一个变量中(b = iter(a); print(next(b))),这将与Class1相同,即打印所有数字。这很令人困惑,也很有趣。 - Yevhen Kuzmovych
是的,如果您执行 del b,它只会打印第一个。@YevhenKuzmovych - erzya
@YevhenKuzmovych,已经有太多虚假问题了,最好去讨论区“Python帮助”发问。 - Kelly Bundy
2个回答

27

发生了什么?

当您使用next(iter(Class2的实例))时,iter()在迭代器(而不是生成器!)超出作用域(并被删除)时调用内部生成器上的.close(), 而使用Class1时,iter()仅关闭其实例。

>>> g = (i for i in range(3))
>>> b = Class2(g)
>>> i = iter(b)     # hold iterator open
>>> next(i)
0
>>> next(i)
1
>>> del(i)          # closes g
>>> next(iter(b))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

这种行为在PEP 342中分为两部分进行描述

当出现多个生成器委托时,发生的情况会更清晰一些(尽管可能令人惊讶);仅在删除其包装iter时才关闭被委托的生成器。

>>> g1 = (a for a in range(10))
>>> g2 = (a for a in range(10, 20))
>>> def test3():
...     yield from g1
...     yield from g2
... 
>>> next(test3())
0
>>> next(test3())
10
>>> next(test3())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

修复Class2

有哪些选项可以使Class2的行为更符合您的期望?

值得注意的是,其他策略虽然没有yield from的视觉上令人愉悦的sugar或其潜在好处之一,但它们提供了一种与值交互的方式,这似乎是主要的好处。

  • 尽量不要创建这样的结构(“不要这么做!”)
    如果您不与生成器交互,也不打算保留对迭代器的引用,那么干嘛还要将其包装起来呢?(请参见上面关于交互的评论)
  • 在内部自己创建迭代器(这可能是您期望的)
    >>> class Class3:
    ...     def __init__(self, gen):
    ...         self.iterator = iter(gen)
    ...         
    ...     def __iter__(self):
    ...         return self.iterator
    ... 
    >>> c = Class3((i for i in range(3)))
    >>> next(iter(c))
    0
    >>> next(iter(c))
    1
    
  • 将整个类作为“proper”生成器
    测试时,它可能会突显一些iter()的不一致性-请参见下面的注释(即为什么没有关闭e?)
    使用 itertools.chain.from_iterable 传递多个生成器的机会。
    >>> class Class5(collections.abc.Generator):
    ...     def __init__(self, gen):
    ...         self.gen = gen
    ...     def send(self, value):
    ...         return next(self.gen)
    ...     def throw(self, value):
    ...         raise StopIteration
    ...     def close(self):          # optional, but more complete
    ...         self.gen.close()
    ... 
    >>> e = Class5((i for i in range(10)))
    >>> next(e)        # 注意:不需要iter!
    0
    >>> next(e)
    1
    >>> next(iter(e))  # 但仍然有效
    2
    >>> next(iter(e))  # 没有关闭e??(应该吗?)
    3
    >>> e.close()
    >>> next(e)
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "/usr/lib/python3.9/_collections_abc.py", line 330, in __next__
        return self.send(None)
      File "<stdin>", line 5, in send
    StopIteration
    

追寻谜团

更好的线索是,如果您直接再次尝试,next(iter(instance))会引发StopIteration,表明生成器已经永久关闭(通过耗尽或.close()),并且为什么使用for循环遍历它不会产生更多的值。

>>> a = Class1((i for i in range(3)))
>>> next(iter(a))
0
>>> next(iter(a))
1
>>> b = Class2((i for i in range(3)))
>>> next(iter(b))
0
>>> next(iter(b))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

然而,如果我们对迭代器进行命名,它会按预期工作。

>>> b = Class2((i for i in range(3)))
>>> i = iter(b)
>>> next(i)
0
>>> next(i)
1
>>> j = iter(b)
>>> next(j)
2
>>> next(i)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

在我看来,这意味着当迭代器没有名称时,它会在超出范围时调用.close()
>>> def gen_test(iterable):
...     yield from iterable
... 
>>> g = gen_test((i for i in range(3)))
>>> next(iter(g))
0
>>> g.close()
>>> next(iter(g))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

拆解结果后,我们发现内部有些不同。

>>> a = Class1((i for i in range(3)))
>>> dis.dis(a.__iter__)
  6           0 LOAD_FAST                0 (self)
              2 LOAD_ATTR                0 (gen)
              4 GET_ITER
        >>    6 FOR_ITER                10 (to 18)
              8 STORE_FAST               1 (el)

  7          10 LOAD_FAST                1 (el)
             12 YIELD_VALUE
             14 POP_TOP
             16 JUMP_ABSOLUTE            6
        >>   18 LOAD_CONST               0 (None)
             20 RETURN_VALUE
>>> b = Class2((i for i in range(3)))
>>> dis.dis(b.__iter__)
  6           0 LOAD_FAST                0 (self)
              2 LOAD_ATTR                0 (gen)
              4 GET_YIELD_FROM_ITER
              6 LOAD_CONST               0 (None)
              8 
             10 POP_TOP
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

值得注意的是,yield from版本具有GET_YIELD_FROM_ITER

如果TOS是生成器迭代器或协程对象,则保持不变。否则,实现TOS = iter(TOS)

(微妙的是,YIELD_FROM关键字似乎在3.11中被删除)

因此,如果给定的可迭代对象(传递给类)生成器迭代器,则将直接传递,从而产生我们(可能)期望的结果。


额外内容

传递一个不是生成器的迭代器(iter()在两种情况下每次都会创建一个新的迭代器)

>>> a = Class1([i for i in range(3)])
>>> next(iter(a))
0
>>> next(iter(a))
0
>>> b = Class2([i for i in range(3)])
>>> next(iter(b))
0
>>> next(iter(b))
0

明确地关闭 Class1 的内部生成器

>>> g = (i for i in range(3))
>>> a = Class1(g)
>>> next(iter(a))
0
>>> next(iter(a))
1
>>> a.gen.close()
>>> next(iter(a))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

只有在实例被弹出时,iter 才会关闭生成器。

>>> g = (i for i in range(10))
>>> b = Class2(g)
>>> i = iter(b)
>>> next(i)
0
>>> j = iter(b)
>>> del(j)        # next() not called on j
>>> next(i)
1
>>> j = iter(b)
>>> next(j)
2
>>> del(j)        # generator closed
>>> next(i)       # now fails, despite range(10) above
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

你也可以查看PEP,在那里你可以看到close调用。 - Kelly Bundy
这很有解释性,但仍然相当不透明,因为它没有说明原因!但从那里看来,PEP 342似乎引入了新的close方法,并悄悄地加入了5.添加支持以确保在垃圾回收生成器迭代器时调用close() - ti7
3
@ti7 如果生成器迭代器仍被self.gen引用,为什么会被垃圾回收? - erzya
并不是没有剩余的引用(事实上是有的,这也是为什么它会抛出 StopIteration 而不是 AttributeErrorNameError 等异常),而是当收集 iter() 并显式调用包装该迭代器的生成器的 .close() 时会发生这种情况。 - ti7
4
这只是我的主观看法,但尽管它符合规范,但感觉像是一个 bug。如果规范要求这样做,那么我会认为规范规定了错误的行为。话虽如此,我并不100%确定规范是否要求这种行为,因为这取决于对象何时被垃圾回收,据我所知,规范并没有明确说明应该在什么时候进行垃圾回收。按照规范,应该一直推迟垃圾回收,直到生成器以正常方式耗尽。 - kaya3
对我来说很难选择一个立场——老实说,我大多倾向于“不要这样做”的阵营,即使这可能是一个十年前就存在的 bug。在这里,行为几乎肯定应该更一致,但也许iter(generator)应该引发RuntimeError和/或在某些保留 dunder 方法中yield from(或简单地yield)应该引发SyntaxError,从而强制使用内部的return并且更喜欢next()(或await)。PEP 525 针对异步生成器还暗示了其有用性,在不实现委托并建议使用async for的情况下 https://peps.python.org/pep-0525/#asynchronous-yield-from - ti7

2

更新

我认为这并不复杂,而且结果的行为可以被看作是不出所料的。

当迭代器超出范围时,Python会在(最内层)生成器中抛出“GeneratorExit”异常。

在“经典”的for形式中,异常发生在用户编写的__iter__方法中,不会被捕获,并且在生成器机制上升时被抑制。

yield from形式中,相同的异常在内部的self.gen中抛出,因此“杀死”它,并冒泡到用户编写的__iter__中。

编写另一个中间生成器可以使这一点很容易地可见:


def inner_gen(gen):
    try:
        for item in gen:
            yield item
    except GeneratorExit:
        print("Generator exit thrown in inner generator")

class Class1:
    def __init__(self, gen):
        self.gen = inner_gen(gen)
        
    def __iter__(self):
        try:
            for el in self.gen:
                yield el
        except GeneratorExit:
            print("Generator exit thrown in outer generator for 'classic' form")
            
    
class Class2(Class1):
    def __iter__(self):
        try:
            yield from self.gen
        except GeneratorExit as exit:
            print("Generator exit thrown in outer generator for 'yield from' form" )
        
first = lambda g:next(iter(g))

现在:

In [324]: c1 = Class1((i for i in range(2)))

In [325]: first(c1)
Generator exit thrown in outer generator for 'classic' form
Out[325]: 0

In [326]: first(c1)
Generator exit thrown in outer generator for 'classic' form
Out[326]: 1

In [327]: c2 = Class2((i for i in range(2)))

In [328]: first(c2)
Generator exit thrown in inner generator
Generator exit thrown in outter generator for 'yield from' form
Out[328]: 0

In [329]: first(c2)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[329], line 1
(...)

StopIteration: 


更新 我之前的回答中猜测了调用close的方式,跳过了中间的生成器 - 但是关于close并不那么简单:Python总是会调用__del__ - 而不是close,后者只有在用户调用时或在某些难以确定的情况下才会被调用。但它总是会在生成器函数体中抛出GeneratorExit异常(不在具有显式__next__throw的类中,我们可以将其留给另一个问题 :-D)


非常好的简单解释,谢谢! - joanis
1
尽管我的回答仅限于文本,但我在交互式环境中进行了一些测试。我将复制它们并粘贴到这里。 (可能我没有明确测试此绕过,但我现在会这样做)。 - jsbueno
测试这个东西的正确性甚至更加复杂 :-),而且似乎不可能一次性“适应所有”。但最终的发现可能会更简单:当Python超出作用域并让它传播时,在最内部的生成器中抛出 GeneratorExit。在“for”形式中,这是在用户编写的 __iter__ 方法中。 - jsbueno
@joanis:最终结果变得更简单了,但我不得不完全重写它。 - jsbueno
我在这里添加了用于检查每种情况下 __del____close__ 调用的代码:https://gist.github.com/jsbueno/e4378521ead8f9dbb40565fb5cacd0b9 - jsbueno

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