Итератор и генератор

Один протокол из двух методов — и на нём стоят циклы, включения, распаковка, with из contextlib и вся асинхронность. Генератор оказывается приостановленным кадром, а yield from — не сахаром для цикла, а каналом связи в обе стороны.

L1 · 9 минL2 · 11 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
it = iter([1, 2, 3])
print(list(it))
print(list(it))
[1, 2, 3]
[]
Второй обход по тому же объекту пуст. Список при этом цел.
Фрагмент B
def g():
    print("тело пошло")
    yield 1

x = g()
print("создан")
print(next(x))
создан
тело пошло
1
Вызов функции не выполнил ни строки её тела.
Фрагмент C
def inner():
    got = yield 1
    print("inner получил:", got)
    return "итог"

def outer():
    for v in inner():
        yield v

o = outer()
next(o)
o.send("привет")
inner получил: None
Отправили «привет», а внутрь пришёл None. И возвращённый «итог» никуда не попал.
Фрагмент D
import tracemalloc, sys
# [i*i for i in range(1_000_000)]  против
# (i*i for i in range(1_000_000))
print(sys.getsizeof(i for i in range(10)))
200
Список из миллиона квадратов занимает 40.4 МБ. Генераторное выражение — 200 байт независимо от числа элементов.

2Механизм

Итерируемое умеет отдать итератор: у него есть __iter__.

Итератор умеет отдать следующий элемент: у него есть __next__, и он сообщает о конце исключением StopIteration. Свой __iter__ он тоже имеет и возвращает себя.

Генератор — способ получить итератор из функции. Функция с yield при вызове не исполняется, а создаёт объект с приостановленным кадром: локальные переменные, позиция исполнения, стек значений.

Следствие 1 · итератор одноразов, и это не недоработка

Фрагмент A. Итератор хранит позицию внутри себя; дойдя до конца, он в этом состоянии и остаётся. Второй list() честно спрашивает следующий элемент и сразу получает StopIteration.

Разделение на два понятия существует ровно ради этого. Список — итерируемое: на каждый for он отдаёт новый итератор, поэтому по нему можно ходить сколько угодно. Сам итератор одноразов, потому что он и есть позиция.

Отсюда практическое: функция, принимающая «последовательность» и обходящая её дважды, сломается на генераторе — молча, вторым пустым проходом. Если нужен повторный обход, его надо либо материализовать list(), либо требовать в контракте именно последовательность.

Следствие 2 · вызов генератора ничего не исполняет

Фрагмент B. g() создаёт объект и возвращает управление немедленно. Первая строка тела выполнится только при первом next.

Это ловушка для проверок аргументов: raise ValueError в начале генератора сработает не в момент вызова, а когда кто-то начнёт обход — возможно, в другой функции и с бесполезным трейсбеком. Приём против этого известен: обычная функция проверяет аргументы и возвращает внутренний генератор.

Зато отсюда же берётся главное свойство: работа выполняется по требованию. Генератор, читающий файл, не прочитает ни байта, пока у него не попросят строку.

Следствие 3 · yield двусторонний, и цикл этого не передаёт

Тут обычно и обнаруживается пробел. yield — не только «отдать значение наружу», но и точка приёма: выражение x = yield v получает то, что придёт снаружи через send. Генератор умеет ещё и принимать исключение внутрь через throw, и возвращать значение через return, которое приезжает в поле StopIteration.value.

Фрагмент C показывает, что обычный цикл for v in inner(): yield v прокидывает только значения наружу. Отправленное через send достанется внешнему генератору и внутрь не пойдёт; возвращённое return потеряется; исключение через throw убьёт внешний, не дав внутреннему прибраться.

def outer():
    r = yield from inner()     # вместо цикла
    print("получили:", r)

С yield from те же действия дают «inner получил: привет» и «получили: итог». То есть это не сокращение для цикла, а установление прямого канала между вызывающим и внутренним генератором на всё время его работы.

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

Следствие 4 · память не зависит от числа элементов

Фрагмент D. Объект генератора хранит кадр, а не данные: 200 байт независимо от того, миллион элементов впереди или бесконечность.

миллион квадратовпамять
списковое включение40.4 МБ
генераторное выражение0.0004 МБ

Разница в сто тысяч раз — и это ровно арифметика урока 03: список держит указатели и объекты, генератор не держит ничего, кроме позиции. Отсюда и то, что генератор может быть бесконечным.

3Границы модели

Где сказанное перестаёт держать

Генератор — не всегда выигрыш. Нужен повторный обход, длина, индекс или срез — генератор проигрывает, и попытка обойти это через list() возвращает исходный расход памяти. Плюс каждый шаг стоит возобновления кадра: на маленьких коллекциях список быстрее.

Итерируемость даётся не только __iter__. Старый протокол через __getitem__ с целыми индексами до сих пор работает — объект без __iter__ может итерироваться, и это не ошибка, а совместимость (урок 11).

StopIteration внутри генератора больше не всплывает. С 3.7 (PEP 479) она превращается в RuntimeError: раньше случайная StopIteration из вложенного вызова молча обрывала генератор, и это был источник неуловимых багов.

4Корень

Корень R5: обход выражен одним протоколом, и всё остальное надстроено над ним. Цикл for, включения, распаковка, in, zip, sum, sorted — все работают через два метода и ничего не знают о типе источника.

История развития — почти прямая линия, и её стоит держать в голове целиком. Протокол итератора появился в 2001 (PEP 234). Генераторы — в 2001 же (PEP 255), сначала только как «функция, отдающая значения». Двусторонний send добавили в 2005 (PEP 342), и генератор стал корутиной по возможностям. yield from — в 2012 (PEP 380), и стало возможно строить цепочки. И уже поверх этого в 2015 появился синтаксис async/await (PEP 492), который первое время был буквально надстройкой над генераторами.

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

5Аналогия

Генератор — это корутина C++20, и совпадение почти дословное. В обоих случаях вызов функции с особым ключевым словом не исполняет тело, а создаёт объект, хранящий кадр: локальные переменные, точку возобновления, стек. В обоих случаях управление возвращается вызывающему, а тело продолжается только по явному возобновлению.

Различие в том, кто платит за размещение кадра. C++ даёт контроль — кадр можно разместить где угодно, компилятор при удаче вообще его устранит, — но требует написать тип-обёртку с promise_type. Python размещает кадр в куче безусловно, поэтому шаг генератора стоит возобновления кадра, зато писать ничего не нужно.

Второй полезный образ — ленивая последовательность как в потоках C++20 или Java Stream: элементы не существуют, пока их не попросили. Отсюда и сто тысяч раз разницы по памяти из следствия 4.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему итератор одноразовый — нельзя было сделать перезапускаемым?

Можно, и это ровно то, чем является итерируемое. Их два разных понятия, и путают именно это.

Итерируемое (__iter__) — то, по чему можно пройти; каждый вызов даёт новый итератор. Итератор (__next__ плюс __iter__, возвращающий себя) — сама позиция в обходе. Позиция по определению одна, поэтому «перезапускаемый итератор» — противоречие: два одновременных прохода делили бы одно состояние.

l = [1, 2, 3]
print(iter(l) is iter(l))     # → False   список даёт новый итератор
it = iter(l)
print(iter(it) is it)         # → True    итератор возвращает себя

Разделение позволяет двум циклам идти по одному списку независимо — что и происходит во вложенном цикле for a in l: for b in l:.

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

def stats(xs):
    return sum(xs) / len(list(xs))     # второй проход по пустому

print(stats([1, 2, 3]))                # → 2.0
print(stats(x for x in [1, 2, 3]))     # → ZeroDivisionError

Лечится либо материализацией на входе (xs = list(xs)), либо однопроходным алгоритмом, либо честной аннотацией Sequence вместо Iterable — тогда об этом скажет mypy.

yield from против цикла с yield — зачем отдельный синтаксис?

Затем, что цикл пробрасывает только значения, а yield from строит двусторонний канал.

Генератор — не просто источник значений. В него можно послать значение (send), бросить исключение (throw), закрыть (close), и он может вернуть результат через return. Цикл теряет всё это:

def inner():
    got = yield 1
    print("inner получил:", got)
    return "результат"

def via_loop():
    for v in inner(): yield v          # send не дойдёт, return потеряется

def via_from():
    r = yield from inner()             # канал насквозь
    print("вернулось:", r)

g = via_from()
next(g)
try: g.send("привет")
except StopIteration: pass
# → inner получил: привет
# → вернулось: результат

С via_loop внутренний генератор получил бы None, а возвращённое значение исчезло бы.

Зачем это понадобилось. PEP 380 сделал возможным разбивать генератор на подгенераторы без потери семантики — то есть рефакторить их как обычные функции. А следом на этом же механизме построили await: корутина ждёт другую корутину ровно так же, как генератор делегирует подгенератору. Урок 14 про это.

Побочно yield from ещё и быстрее: делегирование идёт напрямую, без прохода значения через промежуточный кадр на каждом шаге.

Генератор экономит память — когда он всё-таки вредит?

Когда данные нужны больше одного раза, когда нужен размер, и когда работа мелкая, а накладные расходы на возобновление кадра — нет.

Экономия реальная и огромная:

import tracemalloc
tracemalloc.start(); lst = [i*i for i in range(1_000_000)]
print(tracemalloc.get_traced_memory()[0] / 1e6, "МБ"); tracemalloc.stop()
# → 40.4 МБ

tracemalloc.start(); gen = (i*i for i in range(1_000_000))
print(tracemalloc.get_traced_memory()[0], "байт"); tracemalloc.stop()
# → 408 байт

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

Где вредит. Нужен len — у генератора его нет и быть не может. Нужен второй проход — придётся материализовать, и тогда экономии не было. Нужен срез или доступ по индексу — тоже нет. Данные помещаются в память и по ним ходят многократно — список проще и быстрее.

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

def rows(path):
    with open(path) as fh:
        return (line.split(",") for line in fh)   # файл закроется до чтения

for r in rows("data.csv"): ...        # ValueError: I/O operation on closed file

Лечится заменой return на yield from — тогда with живёт столько же, сколько генератор.

Итог. Протокол обхода — два метода: __iter__ отдаёт итератор, __next__ отдаёт следующий элемент и сообщает о конце исключением. Итератор хранит позицию внутри себя, поэтому одноразов — а список отдаёт новый итератор на каждый for, поэтому обходится многократно; функция, проходящая аргумент дважды, ломается на генераторе молча. Функция с yield при вызове не выполняет ни строки, а создаёт объект с приостановленным кадром: работа идёт по требованию, а проверка аргументов внутри генератора срабатывает не там, где ожидается. yield двусторонний — принимает значение через send, исключение через throw и отдаёт результат через return, — и обычный цикл for v in inner(): yield v прокидывает только значения наружу, теряя остальное; yield from строит прямой канал насквозь. Именно это свойство и сделало возможным await: вся асинхронность Python выросла из протокола обхода.
Дальше — по желанию
контрфактуалC++ итераторы и корутины, Java, GoПара указателей против объекта с состоянием; и корутины C++20 как тот же приостановленный кадр

C++ до C++20 решал ту же задачу принципиально иначе: итератор — это обобщённый указатель, а обход задаётся парой итераторов, begin и end. Конца как исключения нет, есть сравнение с end. Отсюда и то, что итератор C++ копируемый и позволяет несколько независимых проходов, и то, что он может быть невалидирован изменением контейнера — в Python вместо этого получишь RuntimeError о модификации во время итерации.

А вот корутины C++20 — это ровно следствие 2 и 3: функция, содержащая co_yield, при вызове не исполняется, а создаёт объект с сохранённым кадром, и возобновляется извне. Разница в том, что C++ позволяет выбрать, где этот кадр разместить, и требует написать промежуточный тип; Python размещает в куче и даёт готовый протокол. Один механизм, две разные цены.

Java обошлась без генераторов вовсе: Iterator с hasNext/next, а ленивость появилась через Stream API. Go вместо генераторов даёт каналы и горутины — конкурентность как основной механизм, из которой ленивая последовательность получается частным случаем. Python пошёл ровно наоборот: сначала ленивая последовательность, из неё конкурентность.

🐛 багФункция, обходящая аргумент дваждыНа списке работает, на генераторе тихо возвращает пустой результат — без исключения и без предупреждения

Утилита считает статистику. Кто-то передал в неё генератор вместо списка, и отчёт стал нулевым.

def summarize(rows):
    total = sum(r.amount for r in rows)
    count = len([r for r in rows if r.amount > 0])   # второй проход
    return total, count

summarize(load_rows())      # load_rows — генератор
# (12345.0, 0)

Первый проход исчерпал итератор, второй прошёл по пустоте. total посчитан верно, count — ноль. Ни исключения, ни предупреждения: следствие 1 в чистом виде.

Ревью пропускает, потому что обе строки по отдельности корректны, а тесты почти наверняка передают список. Ломается на стыке, причём частично — и это хуже полного падения: отчёт выглядит правдоподобно и уходит дальше.

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

def summarize(rows):
    rows = list(rows)       # контракт: работаем со всем сразу

Либо пройти один раз, если данные могут быть большими:

def summarize(rows):
    total = count = 0
    for r in rows:
        total += r.amount
        if r.amount > 0:
            count += 1
    return total, count

Правило: если функция принимает итерируемое и обходит его больше одного раза, она обязана либо материализовать его первой строкой, либо объявить в аннотации Sequence, а не Iterable — тогда типизатор поймает подстановку генератора заранее.

⚡ 100 000×Конвейер генераторов вместо промежуточных списковОбработка файла в три шага: 40 МБ на миллион элементов против четырёхсот байт

Типичная обработка данных пишется цепочкой списковых включений. Замена скобок на круглые меняет класс потребления памяти.

rows   = [parse(l) for l in open(path)]        # весь файл в памяти
valid  = [r for r in rows if r.ok]             # ещё одна копия
result = [transform(r) for r in valid]         # и ещё одна
rows   = (parse(l) for l in open(path))
valid  = (r for r in rows if r.ok)
result = (transform(r) for r in valid)
for r in result: ...        # только здесь начинается работа
миллион элементовпамять
списковое включение40.4 МБ
генераторное выражение0.0004 МБ

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

Обрати внимание на следствие 2 в действии: первые три строки не читают файл вообще. Работа начинается на for, и это же означает, что исключение при разборе прилетит оттуда, а не из строки с parse.

Когда так не надо: данные нужны несколько раз (следствие 1), нужна длина или сортировка — sorted всё равно материализует, — или элементов мало и читаемость важнее. И отдельный случай: если внутри шага идёт обращение к сети или диску, ленивость размазывает эти обращения по всему обходу, что может быть хуже одного пакетного запроса.

💻 терминалПроверь в REPLЧетыре фрагмента плюс StopIteration внутри генератора, throw и close

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

def g():
    try:
        yield 1
    finally:
        print("уборка")
x = g(); next(x); del x
# Когда напечатается «уборка» и почему именно тогда?

def g2():
    yield next(iter([]))
list(g2())
# StopIteration внутри генератора. Что получится и почему не пустой список?

def g3():
    while True:
        try: yield 1
        except ValueError: print("поймал внутри")
x = g3(); next(x); x.throw(ValueError)
# Исключение брошено снаружи, поймано внутри. Где это используется в стандартной библиотеке?

sum(i for i in range(10))
sum([i for i in range(10)])
# Замерь оба на миллионе. Почему генератор может оказаться медленнее?

Третий вопрос ведёт прямо в урок 12: @contextmanager устроен на throw — исключение из блока with бросается внутрь генератора в точку yield.

исходникиcontextlib и itertoolsГде генератор используется как механизм, а не как удобство

Lib/contextlib.py. Возвращаемся к нему уже во второй раз, теперь с другой стороны. @contextmanager работает потому, что генератор умеет принимать исключение внутрь: __exit__ вызывает gen.throw(exc), и оно возникает в точке yield, где его ловит try/finally из тела. Следствие 3 — не теоретическая возможность, а несущая конструкция целого модуля.

Modules/itertoolsmodule.c и его документация. Раздел «itertools recipes» — три десятка коротких генераторов, собранных из базовых: скользящее окно, группировка, чередование, разбиение на батчи. Ценность не в самих рецептах, а в том, что видно принцип: сложный обход собирается композицией простых, и ни один шаг не материализует данные.

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

L2Что внутри объекта генератораКадр, состояние, стоимость шага и почему кадр в куче

Из L1 ты знаешь, что генератор хранит приостановленный кадр. Здесь — что в нём и сколько это стоит.

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

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

g = (i for i in range(1_000_000))
print(sys.getsizeof(g))
# → 200
g.gi_frame.f_lasti      # позиция исполнения
g.gi_running            # False, пока не внутри next

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

Про уборку. Генератор с try/finally, брошенный недоисполненным, обязан выполнить блок finally. Делается это вызовом close, который бросает внутрь GeneratorExit — а происходит он при разрушении объекта, то есть по правилам урока 09. Значит: в CPython уборка случится сразу при потере последней ссылки, а на другой реализации — когда-нибудь 🔧. Ровно та же логика, что с файлом без with.

L3Где это в исходниках CPythongenobject.c, PEP 342 и PEP 380

Тег v3.11.15.

  • Objects/genobject.c — весь тип генератора: gen_send_ex (возобновление кадра), gen_throw, gen_close. Три функции, закрывающие следствия 2 и 3.
  • Python/ceval.c, инструкции SEND и YIELD_VALUE — как yield from компилируется в цикл передачи управления без питоновского фрейма-посредника.
  • PEP 342 — двусторонний yield: именно здесь генератор стал корутиной по возможностям.
  • PEP 380 — yield from; в мотивации построчно разобрано, что теряет наивный цикл, — фрагмент C урока взят оттуда.
связиКуда это ведётL03, L11, L14 · контраст с парой итераторов C++ и каналами Go
корень R5 · Итерация как универсальный обход
← основа L11 · Протоколы — __iter__ и __next__ живут в слотах типа
← основа L03 · Контейнеры — арифметика памяти, из которой берутся 40 МБ против 400 байт
← основа L09 · Подсчёт ссылок — когда выполнится finally в брошенном генераторе
↔ контраст Пара begin/end в C++ против одноразового итератора с исключением
↔ контраст Каналы и горутины в Go: конкурентность как основа, ленивость как следствие — Python наоборот
→ дальше L14 · async/await — await как прямой потомок yield from
→ дальше L12 · Контекстные менеджеры — @contextmanager стоит на throw
источникиЧто почитатьPEP 380 🟥 · itertools 🟧 · PEP 234 🟦