Yield from vs yield в цикле for

Насколько я понимаю yield from, это похоже на yield извлечение каждого элемента из итерируемого объекта. Тем не менее, я наблюдаю другое поведение в следующем примере.

у меня есть Class1

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

и Class2, которые отличаются только заменой yield в цикле for на 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)

Это дает разные результаты для Class1 и Class2. Для Class1 вывод

0
1
2

а для Class2 вывод

0

Живая демонстрация

Какой механизм стоит за yield from, который вызывает другое поведение?

Не совсем ответ на ваш вопрос, но stackoverflow.com/a/26109157/3216427 предоставляет больше способов, которыми yield from отличается от цикла над yield.

joanis 26.12.2022 18:11

Как ни странно, с Class2, если вы извлечете iter(a) в переменную (b = iter(a); print(next(b))), это будет работать так же, как Class1, то есть напечатать все числа. Это сбивает с толку и очень интересно.

Yevhen Kuzmovych 26.12.2022 18:30

Да, и если вы это сделаете del b, он напечатает только первый @YevhenKuzmovych

erzya 26.12.2022 18:32

@YevhenKuzmovych Уже слишком много фиктивных проблем, лучше спросите в вместо этого обсудите «Помощь по Python».

Kelly Bundy 26.12.2022 19:23
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
29
4
1 479
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Что случилось?

Когда вы используете next(iter(instance_of_Class2)), iter() вызывает .close() внутренний генератор, когда он (итератор, а не генератор!) выходит за пределы области видимости (и удаляется), а с Class1iter() только закрывает свой экземпляр

>>> 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 или некоторыми из его потенциальных преимуществ, дают вам возможность взаимодействовать со значениями, что кажется основным преимуществом.

  • вообще избегайте создания подобной структуры ("только не делайте этого!")
    если вы не взаимодействуете с генератором и не собираетесь хранить ссылку на итератор, зачем вообще его оборачивать? (см. выше комментарий о взаимодействии)
  • создайте итератор самостоятельно (это может быть то, что вы ожидали)
    >>> 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
    
  • сделать весь класс "правильным" генератором
    при тестировании это правдоподобно подчеркивает некоторые 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)        # NOTE iter is not necessary!
    0
    >>> next(e)
    1
    >>> next(iter(e))  # but still works
    2
    >>> next(iter(e))  # doesn't close e?? (should it?)
    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 26.12.2022 20:45

Это объяснимо, но все еще довольно непрозрачно, почему! .. но из этого PEP 342 выглядит так, как будто он приносит как новый метод close , так и пробирается 5. Добавьте поддержку, чтобы гарантировать, что close() вызывается, когда итератор генератора очищается от мусора.

ti7 26.12.2022 20:53

@ti7 Почему итератор генератора собирает мусор, если на него все еще ссылается self.gen?

erzya 27.12.2022 01:27

дело не в том, что нет остаточной ссылки (есть, вот как она вызывает StopIteration, а не AttributeError или NameError и т. д.), скорее это происходит, когда iter()' собирается и явно вызывает .close() на генераторе, который он обертывает

ti7 27.12.2022 02:26

Это всего лишь мое субъективное мнение, но это похоже на ошибку, хотя и соответствует спецификации. Если спецификация предписывает это, то я бы сказал, что спецификация предписывает неправильное поведение. Тем не менее, я не уверен на 100%, что спецификация предписывает такое поведение, потому что оно зависит от того, когда объект подвергается сборке мусора, и, насколько мне известно, спецификация не говорит, когда именно это должно произойти. В спецификации должна быть предусмотрена отсрочка сборки мусора до тех пор, пока генератор не будет исчерпан обычным образом.

kaya3 27.12.2022 10:17

мне трудно выбрать сторону - честно говоря, я в основном нахожусь в лагере "не делай этого", даже если это окажется ошибкой десятилетней давности... поведение здесь должно быть почти наверняка более последовательным , но, возможно, iter(generator) следует raise RuntimeError и/или yield from (или просто yield) повышать SyntaxError в некоторых зарезервированных методах дандера, форсируя return и предпочитая next() (или await) внутренне! PEP 525 для асинхронных генераторов также намекает на полезность imo, даже не реализуя делегирование и предлагая async forpeps.python.org/pep-0525/#asynchronous-yield-from

ti7 27.12.2022 22:53

обновлен

Я не вижу в этом ничего сложного, и результирующее поведение можно рассматривать как на самом деле неудивительное.

Когда итератор выходит за пределы области действия, 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 в теле функции-генератора (но не в классе с explict __next__ и throw — давайте пропустим это для другого вопроса :-D)

Отличное простое объяснение, спасибо за это!

joanis 04.01.2023 02:23

несмотря на то, что мой ответ здесь был только текстовым, я провел несколько тестов в интерактивной среде. Я скопирую и вставлю фрагменты сюда. (возможно, что я явно не тестировал этот обход, но сейчас сделаю это).

jsbueno 06.01.2023 15:20

Правильно протестировать этот материал еще сложнее :-) и, похоже, он не «укладывается в голове сразу» — но окончательный вывод может быть еще проще: Python бросает GeneratorExit в самый внутренний генератор, когда он выходит за пределы области видимости и пусть размножается. В форме «для» это написанный пользователем метод __iter__,

jsbueno 06.01.2023 16:22

@joanis: получилось еще проще, но мне пришлось полностью переписать.

jsbueno 06.01.2023 16:54

Я добавил код, который использовал для проверки вызовов __del__ и __close__ в каждом случае, здесь: gist.github.com/jsbueno/e4378521ead8f9dbb40565fb5cacd0b9

jsbueno 06.01.2023 17:00

Другие вопросы по теме