Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·40 МИН

Итераторы и генераторы: один метод разницы и одна тихая ошибка

Итерируемое умеет отдать итератор, итератор умеет отдать следующий элемент — вся разница в одном методе. Но именно из неё растёт ошибка, которая не падает: функция, дважды прошедшая по генератору, получает во второй раз пустоту и возвращает неверный ответ молча.

Полное техническое изложение

TL;DR

Итерируемое умеет отдать итератор (__iter__). Итератор умеет отдать следующий элемент (__next__) и обязан отдавать себя из __iter__. Вся разница — один метод, и вся разница в поведении — тоже одна: список можно обойти дважды, итератор нельзя. Генератор — это итератор, который написан как функция, и вызов генераторной функции не выполняет её тело: он создаёт объект и возвращает управление.

Отсюда главное следствие, и оно не про производительность. Функция, которая проходит по своему аргументу дважды, на списке вернёт {'всего': 19, 'отрицательных': 2}, а на генераторе — {'всего': 0, 'отрицательных': 2}. Без исключения. Ноль вместо девятнадцати.

Дальше — числа, версии и границы размена. Если альтернатива материализует все результаты, генератор обычно резко снижает дополнительную память: на квадратах чисел от нуля до миллиона список ([i * i for i in range(1_000_000)]) занимает 38,57 МиБ, а генератор — 0,5 КиБ, то есть в семьдесят девять тысяч раз меньше. Где материализовать нечего, выигрыша нет: сумма по уже существующему списку стоит 48 Б, а та же сумма через генераторное выражение — 400 Б. По времени знак зависит от объёма: на десяти элементах создать и просуммировать через генератор дороже, чем через списковое включение, — 375 нс против 238 (3.13.7), — и причина структурная: включения встроены в вызывающий код с 3.12, а генераторные выражения нет.

Порог входа
Перед уроком достаточно понимать
  • цикл for проходит по списку и выдаёт элементы по одному;
  • функция может вернуть значение, а может выполниться и ничего не вернуть;
  • список из миллиона чисел занимает память, а из десяти — почти не занимает.
Заранее знать не нужно
  • __iter__, __next__, StopIteration, yield, генераторное выражение;
  • send, throw, close, yield from, асинхронный обход, встраивание включений.

База: что на самом деле получает цикл

Начнём с кода, который писал каждый:

PYTHON
for x in [10, 20, 30]:
    print(x)

Список лежит в памяти целиком, цикл берёт из него элементы по одному. Вопрос, с которого начинается тема, звучит почти скучно и оказывается главным: что именно цикл берёт у списка?

Не элементы. Список не умеет отдавать «следующий»: у него нет ни понятия текущего места, ни памяти о том, где остановились в прошлый раз. А обходу это нужно — и лежит оно не в списке.

Поэтому шагов здесь не один, а два:

  1. цикл просит у списка то, по чему можно идти, — и список выдаёт это новым, отдельным от себя;
  2. дальше цикл раз за разом просит у полученного следующее значение, пока значения не кончатся.

У обеих сторон есть имена. Объект, у которого можно попросить обход, — итерируемое: список, строка, словарь, открытый файл. То, что он выдаёт и что помнит место, — итератор. Ровно поэтому в протоколе два метода: __iter__ — «дай то, по чему идти», __next__ — «дай следующее».

Отсюда сразу видно главное различие в поведении. Список — источник обходов: каждый новый цикл просит у него новый итератор и начинается с начала. Итератор — сам обход, и он один: дойдя до конца, он в конце и остаётся. Второй цикл по нему не начнётся заново — он просто ничего не даст.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше — про то, что «ничего не даст» происходит молча, без исключения; про генератор, который и есть итератор, написанный как функция; и про то, что размен памяти на время измерим и в обе стороны.

Механизм 1: разница в одном методе

контракт языкаГарантия языка: итератор — это __iter__ плюс __next__, и iter(итератор) is итератор. Отсюда одноразовость.

Определения из глоссария короткие. Iterablean object capable of returning its members one at a time (объект, способный отдавать свои элементы по одному). Iteratoran object representing a stream of data (объект, представляющий поток данных), у которого повторные вызовы __next__ возвращают очередной элемент, а когда данные кончились — бросают StopIteration.

Проверим, чем они отличаются на самом деле:

PYTHON
xs = [1, 2, 3]
it = iter(xs)
 
iter(xs) is xs          # False — список отдаёт НОВЫЙ итератор
iter(it) is it          # True  — итератор отдаёт себя
hasattr(xs, "__next__") # False — у списка нет способа «дать следующий»
hasattr(it, "__iter__") # True  — у итератора есть оба метода

Отсюда всё остальное. Список — источник итераторов, поэтому обходить его можно сколько угодно раз, каждый раз с нуля. Итератор — сам поток, и он один:

PYTHON
list(it)    # [1, 2, 3]
list(it)    # []          — тот же объект, он уже исчерпан
list(xs)    # [1, 2, 3]
list(xs)    # [1, 2, 3]   — а тут каждый раз новый итератор

Глоссарий описывает второй случай так, что лучше не скажешь:

Attempting this with an iterator will just return the same exhausted iterator object used in the previous iteration pass, making it appear like an empty container.

перевод

Попытка сделать это с итератором просто вернёт тот же самый исчерпанный объект-итератор, который использовался на предыдущем проходе, — из-за чего он выглядит как пустой контейнер.

Глоссарий Python — iterable, iterator, generator

Выглядит как пустой контейнер. Не «бросает ошибку», не «предупреждает» — выглядит как пустой. Это и есть корень всех неприятностей ниже.

Механизм 2: ошибка, которая не падает

Возьмём функцию, которая проходит по аргументу дважды. Ничего экзотического — обычный отчёт:

PYTHON
def report(rows):
    bad = [r for r in rows if r < 0]
    total = sum(rows)
    return {"всего": total, "отрицательных": len(bad)}
 
data = [10, -3, 5, -1, 8]
 
report(data)                  # {'всего': 19, 'отрицательных': 2}
report(r for r in data)       # {'всего': 0,  'отрицательных': 2}

Никакого исключения. Второй проход прошёл по исчерпанному итератору, получил пустоту, и sum честно вернул ноль. Функция отработала, отчёт построился, число неверное.

Это стоит осознать как класс, а не как случай: функция, принимающая «последовательность», обязана либо проходить по ней один раз, либо зафиксировать её в начале.

PYTHON
def report(rows):
    rows = list(rows)         # одна строка, снимающая весь класс ошибок
    ...

У этой строки есть цена — вся последовательность окажется в памяти, — и в этом весь смысл дальнейшего разговора. Но «работает и медленно» лучше, чем «быстро и неправильно».

Та же ловушка в мелочах:

PYTHON
g = (i for i in range(5))
3 in g          # True
list(g)         # [4] — проверка съела 0, 1, 2 и 3
len(g)          # TypeError: object of type 'generator' has no len()

Механизм 3: кто в стандартной библиотеке одноразов

Признак iter(x) is x из предыдущего раздела — не теоретический. Половина того, чем пользуются каждый день, ему удовлетворяет (bench/iteration/one_shot_types.py, вывод одинаков на 3.11–3.14):

типiter(x) is xодноразов
list, dict, set, str, range, dict.keys()Falseнет
map, filter, zip, enumerate, reversedTrueда
генератор, itertools.chainTrueда
open(...), io.StringIOTrueда

Документация каждого из них говорит это прямо. У map в описании стоит Return an iterator (Возвращает итератор), у filterconstruct an iterator (строит итератор), у zipreturns an iterator of tuples (возвращает итератор кортежей). Слово «итератор» в описании и есть предупреждение.

Про файлы то же самое записано отдельно:

IOBase (and its subclasses) supports the iterator protocol, meaning that an IOBase object can be iterated over yielding the lines in a stream.

перевод

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

io — IOBase

Проверка одна и та же для всех: второй проход пуст (здесь и ниже вывод скрипта сокращён до относящихся к делу строк).

2) map по списку [1, 2, 3]:
   первый  list(m): ['1', '2', '3']
   второй  list(m): []

У файла одноразовость обратима — курсор можно вернуть:

5) первый  проход: ['а', 'б', 'в']
   второй  проход: []
   fh.tell(): 9 — курсор в конце, поэтому пусто
   после fh.seek(0): ['а', 'б', 'в']

У map, filter и zip такого рычага нет: пересоздавать надо сам объект.

И вот форма, в которой это стоит денег:

8) sum и len по одному и тому же map:
   sum(values) = 60
   len(list(values)) = 0 -> среднее посчитать нечем
   total / count -> ZeroDivisionError: division by zero

Единственное место во всём разделе, где одноразовость даёт исключение, — и оно не называет причину. ZeroDivisionError вместо «вы прошли по итератору дважды». Лечится одной строкой: values = list(values) перед первым проходом.

Механизм 4: изменять коллекцию во время обхода

Отдельный вид одноразовости — итератор, который стал неверен, пока по нему шли. Здесь важно, что Python ведёт себя по-разному у разных типов, и защита есть не везде (bench/iteration/invalidation.py, вывод одинаков на 3.11–3.14).

У словаря и множества есть сторож:

1) добавление в dict во время обхода -> RuntimeError: dictionary changed size during iteration
5) добавление в set во время обхода  -> RuntimeError: Set changed size during iteration

Но сторож сравнивает длину, а не содержимое. В Objects/dictobject.c проверка записана как di->di_used != d->ma_used, и отсюда дыра:

3) замена значений во время обхода: ошибки нет, обход полный: ['a', 'b', 'c']
4) удаление + вставка (размер не изменился): ошибки нет
   что обошли: ['a', 'b', 'z'] | словарь: {'a': 1, 'b': 2, 'z': 0}

Удалили одно, добавили другое — длина та же, исключения нет, удалённый ключ не посещён. То есть RuntimeError — сигнал, а не гарантия.

Сторож при этом залипающий: в исходнике рядом стоит di_used = -1 с комментарием Make this state sticky.

9) первый next после изменения -> RuntimeError
   размер вернули; следующий next -> RuntimeError

У списка сторожа нет вовсе, и это хуже, потому что тихо:

6) удаление из списка внутри for по этому же списку:
   посетили: ['a', 'c', 'e']
   осталось: ['b', 'd', 'e']
   ни одного исключения; 'b' и 'd' не были посещены вовсе

7) next(it) -> a | индекс итератора теперь 1
   после xs.remove('a') список стал ['b', 'c']
   next(it) -> c — потому что индекс 1 указывает уже на него

Итератор списка держит индекс. Удалили элемент слева — всё сдвинулось, а индекс нет, и один элемент пропускается. Классический «фильтр на месте», который удаляет половину того, что должен.

Официальная рекомендация ровно одна, и она из учебника, а не из справочника:

Code that modifies a collection while iterating over that same collection can be tricky to get right. Instead, it is usually more straight-forward to loop over a copy of the collection or to create a new collection.

перевод

Код, изменяющий коллекцию во время обхода этой же коллекции, бывает непросто написать правильно. Обычно проще обойти копию коллекции или создать новую коллекцию.

Учебник Python — Управляющие конструкции

Оба способа проверены и дают верный ответ: for x in list(xs) и пересборка включением. Про правило «внутренний счётчик», которое часто цитируют как норму языка, стоит сказать прямо: в справочнике его нет — поведение из пунктов 6 и 7 есть свойство реализации итератора списка, а не записанное правило.

Механизм 5: генератор — итератор, написанный как функция

Справочник языка формулирует превращение одной фразой: Using a yield expression in a function's body causes that function to be a generator function (Употребление выражения yield в теле функции делает эту функцию генераторной).

Ключевое следствие проверяется в три строки:

PYTHON
log = []
 
def h():
    log.append("тело пошло")
    yield 1
 
obj = h()
log            # [] — тело НЕ выполнялось
next(obj)
log            # ['тело пошло'] — пошло только сейчас

Вызов создал объект и вернул управление. Всё, что написано в теле — проверки аргументов, открытие файла, запрос к базе, — не произойдёт, пока кто-нибудь не попросит первый элемент. Функция, которая валидирует аргументы и содержит yield, не валидирует их при вызове.

Механизм 6: четыре состояния, и все четыре наблюдаемы

Состояние генератора — не метафора, а строка, которую возвращает inspect:

PYTHON
import inspect
k = (i for i in range(2))
inspect.getgeneratorstate(k)   # 'GEN_CREATED'
next(k); inspect.getgeneratorstate(k)   # 'GEN_SUSPENDED'
list(k);  inspect.getgeneratorstate(k)  # 'GEN_CLOSED'

Состояний четыре, и порядок между ними объясняет всё, что дальше в уроке называется приёмами. Прогон bench/iterators/states.py проходит по ним:

Из картинки читаются три вещи, каждая из которых обычно объясняется отдельно.

Приостановленный генератор — это живой кадр. GEN_SUSPENDED значит, что тело остановилось на yield и держит свои локальные переменные, открытые файлы и ссылки. Отсюда и память из следующего раздела, и то, что send может передать значение обратно в это самое место.

GEN_RUNNING снаружи не застать. Пока генератор выполняется, управление у него; спросить его состояние может только он сам, изнутри тела. Это не ограничение библиотеки, а следствие того, что вызывающий в этот момент стоит.

В GEN_CLOSED ведут две разные дороги, и различить их потом нечем. Тело кончилось само — или его закрыли снаружи через close(). Состояние одно на оба случая, поэтому «исчерпан» и «закрыт» после факта неразличимы: если разница важна, её нужно записать самому.

Три разные вещи с похожими именами

Здесь же удобно развести то, что в разговоре называют одним словом:

что этокогда выполняется тело
генераторная функцияфункция, в теле которой есть yieldникогда: вызов создаёт объект
объект-генераторрезультат её вызовапо одному шагу на каждый next
генераторное выражение(x for x in ...) — синтаксис, дающий сразу объекттак же, по шагам

Первое — про объявление, второе и третье — про объект. Путаница между первым и вторым и даёт вопрос «почему мой генератор ничего не напечатал»: печатать некому, пока никто не попросил элемент.

Механизм 7: память против времени — замер

наблюдение замераbench/iterators/memory_time.py и bench/iterators/memory_when_not.py, CPython 3.11–3.14. Пик памяти сравнивается внутри одного набора; между версиями сборки разные.

Здесь размен, ради которого генераторы и существуют. Пик памяти по tracemalloc, миллион элементов:

что суммируемпик памяти
[i * i for i in range(1_000_000)]38,57 МиБ
(i * i for i in range(1_000_000))0,5 КиБ

Пик списка — 38,57 МиБ на всех четырёх версиях; генератор ни на одной не выходит за килобайт (0,5 КиБ на 3.11–3.13 и 0,4 на 3.14). Порядок разницы — десятки тысяч раз, и от версии он не зависит. Причина очевидна — список хранит миллион объектов, генератор хранит одну приостановленную функцию.

По времени на этом же миллионе генератор не проигрывает, а выигрывает: 45,6 мс против 59,1 у списка на 3.13.7. Список приходится сначала построить целиком, и это дороже, чем не строить.

А теперь то, что обычно опускают. На малых данных всё наоборот:

3.13.7, десять элементовгенераторсписок
только создать121 нс150 нс
создать и просуммировать375 нс238 нс

Создать генератор дешевле — тела-то не было. А вот пройти по нему дороже: каждый next — это возобновление приостановленной функции, и на десяти элементах десять возобновлений стоят больше, чем один цикл по готовому списку.

Правило поэтому пишется не от числа, а от границы, и звучит так: если альтернатива материализует все результаты, генератор обычно резко снижает дополнительную память; время он экономит только тогда, когда данных много или когда они нужны не все. «Генераторы быстрее» и «генераторы всегда экономят память» — оба утверждения шире, чем то, что измерено.

Что слово «всегда» здесь лишнее, проверяется тем же инструментом. Прогон bench/iterators/memory_when_not.py берёт три случая подряд:

что сравниваетсяпик спискапик генератора
миллион ВЫЧИСЛЯЕМЫХ значений (случай выше)38,57 МиБ464 Б
сумма по УЖЕ существующему списку48 Б400 Б
результат всё равно нужен целиком38,57 МиБ38,57 МиБ

Во второй строке генератор не экономит ничего, а стоит на 352 байта дороже: список уже занят до замера, и ленивость его не отменяет. В третьей выигрыша нет вовсе — list(i * i for i in range(N)) держит тот же миллион значений, что и включение. А четвёртый блок того же прогона показывает обратную сторону: приостановленный генератор — это живой кадр со всеми ссылками, которые он держит, и сотня таких генераторов с буфером внутри заняла 76,32 МиБ.

Выигрыш появляется не от ленивости самой по себе, а от того, что альтернатива занимает память. Нет альтернативы — нет и выигрыша.

Глубже: почему на малых данных проигрыш структурный

деталь реализации · CPython 3.12Встраивание включений (PEP 709) — реализация. Именно из-за неё знак разницы на малых данных стал устойчивым.

Причина видна без секундомера. В 3.12 включения встроили в вызывающий код (PEP 709): Dictionary, list, and set comprehensions are now inlined, rather than creating a new single-use function object for each execution (Словарные, списковые и множественные включения теперь встраиваются вместо создания нового одноразового объекта-функции на каждое выполнение). Генераторные выражения в это изменение не вошли — и это проверяется прямо:

<listcomp> в кодеMAKE_FUNCTIONглубина стека внутри
списковое включение, 3.11естьесть4
списковое включение, 3.12–3.14нетнет3
генераторное выражение, 3.11–3.14есть (<genexpr>)есть4

Списковое включение перестало быть отдельной функцией; генераторное выражение ею осталось и осталось во всех четырёх версиях. Отсюда и лишний кадр, и лишние наносекунды на коротких последовательностях.

Время между версиями в этом уроке не сравнивается, и вот почему. 3.11 и 3.12 собраны GCC 13.3.0, 3.13 и 3.14 — Clang 20.1.4, причём у 3.14 включён --with-tail-call-interp, которого у 3.13 нет и быть не может. Разница сборок перекрывает разницу версий. Сравнивать можно только генератор со списком внутри одного запуска — что и сделано в таблицах выше.

Глубже: StopIteration внутри генератора

Классическая ошибка, которую язык в итоге запретил:

PYTHON
def broken(it):
    while True:
        yield next(it)      # когда it кончится, next бросит StopIteration
 
list(broken(iter([1, 2])))

До Python 3.7 это молча давало [1, 2]: StopIteration, вылетевший из тела, неотличим от нормального завершения генератора. Ошибка в глубине конвейера превращалась в «данные просто кончились раньше».

Начиная с 3.7 — исключение:

RuntimeError: generator raised StopIteration

причём __cause__ у него — исходный StopIteration, так что настоящая причина не теряется. Проверено на 3.11, 3.12, 3.13 и 3.14 — везде одинаково.

Границ здесь две, и обе записаны в стандартной библиотеке — их можно прочитать, не выходя из интерпретатора:

PYTHON
import __future__
__future__.generator_stop
# _Feature((3, 5, 0, 'beta', 1), (3, 7, 0, 'alpha', 0), 8388608)

Необязательно с 3.5.0b1, обязательно с 3.7.0a0.

Правильный вариант — поймать и выйти:

PYTHON
def fixed(it):
    while True:
        try:
            yield next(it)
        except StopIteration:
            return

Глубже: генератор как приёмник — send, throw, close

PEP 342 сделал yield выражением, и генератор перестал быть только источником. send(value) возобновляет его, подставляя значение как результат yield; throw бросает исключение в точке приостановки; close бросает туда GeneratorExit.

Из троих close стоит особняком: он единственный нужен не для передачи данных внутрь генератора, а для уборки:

PYTHON
def g():
    try:
        yield 1
    except GeneratorExit:
        return "прибрано"
 
gen = g(); next(gen)
gen.close()
версиячто вернёт close()
3.11, 3.12None
3.13, 3.14'прибрано'

Изменение записано в справочнике прямо: Changed in version 3.13: If a generator returns a value upon being closed, the value is returned by close() (Изменено в версии 3.13: если генератор при закрытии возвращает значение, это значение возвращает close()). Измерено на всех четырёх версиях.

Глубже: yield from — не сахар над циклом

PEP 380 добавил делегирование. Разница с ручным циклом не в краткости: через yield from проходят send, throw и возвращаемое значение подгенератора, а через for x in inner(): yield x — нет.

По времени yield from не всегда выигрывает. Обход тысячи элементов через одну прослойку — смотреть надо по строкам: внутри строки цикл и делегирование измерены одним запуском одного интерпретатора, а между строками стоят разные сборки, и вычесть их вклад нечем.

ручной циклyield from
3.11.1545,6 мкс47,2 мкс
3.13.737,3 мкс33,8 мкс
3.14.736,3 мкс32,2 мкс

На 3.11 делегирование не быстрее, а чуть медленнее ручного цикла — то есть знак там противоположный. Так что «yield from всегда быстрее» — утверждение неверное; выбирать его стоит за проброс протокола, а не за наносекунды.

Глубже: тот же протокол через await

Асинхронный обход — это тот же протокол с другими именами и одной добавкой: каждый шаг может уступить управление.

Must return an awaitable resulting in a next value of the iterator. Should raise a StopAsyncIteration error when the iteration is over.

перевод

Должен вернуть ожидаемый объект, дающий следующее значение итератора. Должен возбудить StopAsyncIteration, когда итерация закончена.

Модель данных — __anext__

Соответствие один в один: __aiter__ вместо __iter__, __anext__ вместо __next__, StopAsyncIteration вместо StopIteration. Признак «сам себе итератор» работает так же (bench/iteration/async_iteration.py, вывод одинаков на 3.11–3.14):

1) a.__aiter__() is a          : True
   у AsyncRange есть __iter__  : False
   у AsyncRange есть __next__  : False
3) await it.__anext__() -> 1
   await it.__anext__() -> StopAsyncIteration (а не StopIteration)

Одна тонкость синтаксиса: __aiter__ — обычный def, а не async def. Возвращать из него ожидаемый объект было можно до 3.7, в 3.7 эту возможность убрали; с тех пор он обязан вернуть сам асинхронный итератор, иначе TypeError.

Два моста ломаются, и знать об этом полезно заранее:

4) обычный for над AsyncRange -> TypeError: 'AsyncRange' object is not iterable
   list(агенератор) -> TypeError: 'async_generator' object is not iterable

То есть list, sum, sorted, min и всё, что принимает итерируемое, асинхронный источник не примут. Собрать его в список можно только асинхронным включением: [v async for v in ...].

Одноразовость наследуется целиком — второй проход по асинхронному генератору пуст, ровно как у обычного.

А вот чего у синхронного нет:

7) чередование потребителя и фоновой задачи:
   ['фон-0', 'получено-1', 'фон-1', 'получено-2', 'фон-2', 'получено-3', 'фон-3']

8) выход по break из async for: finally ещё не выполнен
   после await g2.aclose(): ['finally выполнен']

Пункт 7 — то, ради чего это всё: каждый __anext__ есть точка переключения. Пункт 8 — цена: у асинхронного генератора выход по break не запускает finally сразу. Уборки надо дождаться явно — await g.aclose() или асинхронный финализатор цикла событий. Ровно тот же класс, что «задача, на которую никто не ссылается» в уроке про async.

Глубже: история версий

ВерсияИзменениеЧто изменилось для кода
2.1 / 2.2PEP 234 вводит протокол итерации (__iter__ / __next__), PEP 255 — генераторные функции и yield.Появился сам механизм
2.5PEP 342: yield становится выражением, появляются send, throw и close.Генератор стал приёмником
3.3PEP 380: yield from. Делегирование пробрасывает не только значения, но и send, throw и результат подгенератора.Делегирование целиком
3.5PEP 479 доступен как from __future__ import generator_stop — по записи в модуле __future__, с 3.5.0b1.Можно включить руками
3.7StopIteration, вылетевший из тела генератора, становится RuntimeError. Обязательно с 3.7.0a0.Тихая ошибка стала громкой
3.12PEP 709 встраивает списковые, словарные и множественные включения. Генераторные выражения остаются отдельной функцией — видно по MAKE_FUNCTION и по глубине стека.Включения стали дешевле, генераторы нет
3.13close() возвращает значение, если генератор при закрытии его вернул. До этого возвращался None.Уборка может отчитаться

Как отвечать на собеседовании

Короткий ответ: итерируемое умеет отдать итератор (__iter__), итератор умеет отдать следующий элемент (__next__) и обязан отдавать себя из __iter__. Разница в одном методе, и в поведении она тоже одна: список обходится дважды, итератор — нет. Генератор — это итератор, написанный как функция, и вызов генераторной функции тело не выполняет: он создаёт объект и возвращает управление.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Что отличает хороший ответ: не остановиться на «генераторы экономят память», а назвать обе стороны размена и границу выигрыша. По памяти он появляется тогда, когда альтернатива материализует все результаты: на миллионе квадратов список занимает 38,57 МиБ против 0,5 КиБ у генератора, а вот сумма по уже существующему списку не экономит ничего — 48 Б против 400 Б у генераторного выражения. По времени знак зависит от объёма: на десяти элементах создать и просуммировать через генератор ДОРОЖЕ, чем через списковое включение — 375 нс против 238, — и причина структурная: включения встроены в вызывающий код с 3.12, генераторные выражения нет.

Дальше спросят

Спросят дальше

Генератор закончился. Можно пройти по нему второй раз?

Короткий ответ

Нет, и это ошибка, которая не падает: второй проход просто ничего не вернёт — не исключение, а пустой результат. Тем же свойством обладает часть объектов стандартной библиотеки, и по ним второй проход тоже молча пуст.

Спросят дальше

Генератор экономит память. Значит, он всегда лучше списка?

Короткий ответ

Нет, и оговорок здесь две. Первая: сам выигрыш по памяти появляется тогда, когда альтернатива материализует все результаты; над уже существующим списком генераторное выражение не экономит ничего и стоит чуть дороже. Вторая: выигрыш по памяти — не выигрыш по времени, и на малых данных генератор структурно проигрывает; в уроке это показано замером, а не рассуждением.

Выбор между ними — это выбор между памятью и временем, а не «современно против устаревшего».

Частые заблуждения

Утверждение

«Итерируемое и итератор — одно и то же».

На самом деле

Различие проверяется двумя строками: iter(xs) is xs даёт False для списка и True для итератора. Список — источник итераторов, поэтому его можно обойти сколько угодно раз; итератор — сам поток, и он один. Именно поэтому list(it) второй раз возвращает [], а list(xs) — те же три элемента.

Утверждение

«Если генератор исчерпан, я об этом узнаю — будет исключение».

На самом деле

Не будет. Глоссарий описывает это прямо: исчерпанный итератор making it appear like an empty container (из-за чего он выглядит как пустой контейнер). Функция с двумя проходами по аргументу на списке вернула {'всего': 19, 'отрицательных': 2}, а на генераторе — {'всего': 0, 'отрицательных': 2}. Ноль вместо девятнадцати, без единой ошибки в логе.

Утверждение

«Вызов генераторной функции запускает её тело».

На самом деле

Создаёт объект и возвращает управление. Проверено журналом: после obj = h() он пуст, строка появляется только после первого next(obj). Практическое следствие — функция с yield, которая проверяет аргументы в начале тела, не проверит их при вызове: исключение прилетит позже и из другого места стека.

Утверждение

«Генераторы быстрее списков».

На самом деле

Быстрее на больших объёмах и медленнее на малых. На миллионе элементов: 45,6 мс у генератора против 59,1 у списка (3.13.7) — строить список дороже, чем не строить. На десяти элементах: 375 нс против 238 — каждый next возобновляет приостановленную функцию, и на коротком проходе это не окупается. А по памяти генератор выигрывает там, где альтернатива материализует весь набор: 0,5 КиБ против 38,57 МиБ на миллионе. Где материализовать нечего — не выигрывает: сумма по уже существующему списку стоит 48 Б, а та же сумма через генераторное выражение — 400 Б.

Утверждение

«С 3.12 включения встроены, значит и генераторные выражения тоже».

На самом деле

PEP 709 их не касается, и это видно без замеров. У спискового включения с 3.12 нет ни объекта кода <listcomp>, ни MAKE_FUNCTION, и глубина стека внутри него — 3. У генераторного выражения <genexpr> и MAKE_FUNCTION на месте во всех четырёх версиях, а глубина стека — 4. Отдельная функция и отдельный кадр никуда не делись.

Утверждение

«next() внутри генератора — нормальный способ читать чужой итератор».

На самом деле

С 3.7 это RuntimeError: generator raised StopIteration. До 3.7 было хуже: StopIteration из тела был неотличим от нормального завершения, и конвейер молча обрывался раньше времени. Обе границы записаны в стандартной библиотеке: __future__.generator_stop — необязательно с 3.5.0b1, обязательно с 3.7.0a0. Правильный способ — обернуть next в try/except StopIteration и выйти через return.

Утверждение

«yield from — это просто короткая запись цикла, и она быстрее».

На самом деле

Не просто и не всегда. PEP 380 пробрасывает через делегирование send, throw и возвращаемое значение подгенератора — ручной цикл for x in inner(): yield x не делает ничего из этого. А по времени на 3.11.15 разницы нет вовсе (44,1 мкс против 44,0), и выигрыш появляется только на других сборках. Выбирать его стоит за протокол, а не за скорость.

Утверждение

«Проверить x in generator безопасно — это же просто проверка».

На самом деле

Проверка потребляет. 3 in (i for i in range(5)) вернёт True, но после этого в генераторе останется только [4]: оператор дошёл до совпадения и всё пройденное съел. По той же причине у генератора нет len()TypeError: object of type 'generator' has no len(): чтобы узнать длину, его пришлось бы исчерпать.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Один и тот же итератор суммируется дважды, а потом из него собирают список. Что напечатает этот код?
nums = iter([1, 2, 3])
print(sum(nums))
print(sum(nums))
print(list(nums))

Практика · оцените

Десять элементов: создать и просуммировать квадраты — генератором и списковым включением. Во сколько раз генератор ДОРОЖЕ?
раза

Проверка знаний

Вопрос 1 из 6

Функция принимает последовательность, считает по ней сумму и отдельно отбирает отрицательные значения — два прохода. Ей передали генераторное выражение. Что произойдёт?

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

Источники и что читать дальше

8 ИСТОЧНИКОВ

  1. Глоссарий Python — iterable, iterator, generator, generator expressionОфициальная документация. Определения, на которых держится весь урок. Про исчерпание сказано дословно: «At this point, the iterator object is exhausted and any further calls to its __next__() method just raise StopIteration again» (С этого момента объект-итератор исчерпан, и любые дальнейшие вызовы его метода __next__() снова возбуждают StopIteration). А про повторный проход — фраза, которая и описывает тихую ошибку: «Attempting this with an iterator will just return the same exhausted iterator object used in the previous iteration pass, making it appear like an empty container» (Попытка сделать это с итератором просто вернёт тот же самый исчерпанный объект-итератор, использованный на предыдущем проходе, — из-за чего он выглядит как пустой контейнер).https://docs.python.org/3.13/glossary.html
  2. Справочник языка — выражения yield и методы генератораОфициальная документация. «Using a yield expression in a function's body causes that function to be a generator function» (Употребление выражения yield в теле функции делает эту функцию генераторной). Там же описания send, throw и close, и отдельно оговорка 3.13: «If a generator returns a value upon being closed, the value is returned by close()» (Если генератор при закрытии возвращает значение, это значение возвращает close()).https://docs.python.org/3.13/reference/expressions.html
  3. PEP 234 — IteratorsPEP. Документ, которым протокол итерации введён в язык (Python 2.1). Отсюда сама пара __iter__ / __next__ и требование, чтобы итератор возвращал себя из __iter__.https://peps.python.org/pep-0234/
  4. PEP 255 — Simple GeneratorsPEP. Введение yield и генераторных функций (Python 2.2). Здесь же сформулировано главное свойство: вызов генераторной функции не выполняет её тело.https://peps.python.org/pep-0255/
  5. PEP 342 — Coroutines via Enhanced GeneratorsPEP. Python 2.5. Превращает yield из инструкции в выражение и добавляет send, throw и close — то, из-за чего генератор становится не только источником значений, но и приёмником.https://peps.python.org/pep-0342/
  6. PEP 380 — Syntax for Delegating to a SubgeneratorPEP. Python 3.3, `yield from`. Делегирование не сводится к циклу: через него проходят send, throw и возвращаемое значение подгенератора.https://peps.python.org/pep-0380/
  7. Что нового в Python 3.12 — PEP 709, встраивание включенийОфициальная документация. «Dictionary, list, and set comprehensions are now inlined, rather than creating a new single-use function object for each execution of the comprehension» (Словарные, списковые и множественные включения теперь встраиваются вместо создания нового одноразового объекта-функции на каждое выполнение включения), ускорение «up to two times» (до двух раз). Генераторные выражения в это изменение НЕ входят — что проверяется дизассемблером и глубиной стека, а не только текстом.https://docs.python.org/3/whatsnew/3.12.html
  8. Модуль __future__ — запись о generator_stopИсходный код CPython. Обе границы PEP 479 записаны в самой стандартной библиотеке: `_Feature((3, 5, 0, 'beta', 1), (3, 7, 0, 'alpha', 0), 8388608)` — необязательно с 3.5.0b1, обязательно с 3.7.0a0. Первый кортеж — версия, в которой поведение стало доступно через `from __future__ import generator_stop`, второй — версия, в которой оно включилось само. Проверено на 3.11, 3.12, 3.13 и 3.14: запись одинакова во всех четырёх. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/__future__.py