Один протокол из двух методов — и на нём стоят циклы, включения, распаковка, with из contextlib и вся асинхронность. Генератор оказывается приостановленным кадром, а yield from — не сахаром для цикла, а каналом связи в обе стороны.
Зафиксируй вывод и причину до того, как откроешь ответ.
it = iter([1, 2, 3])
print(list(it))
print(list(it))[1, 2, 3]
[]Второй обход по тому же объекту пуст. Список при этом цел.def g():
print("тело пошло")
yield 1
x = g()
print("создан")
print(next(x))создан
тело пошло
1Вызов функции не выполнил ни строки её тела.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. И возвращённый «итог» никуда не попал.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 байт независимо от числа элементов.Итерируемое умеет отдать итератор: у него есть __iter__.
Итератор умеет отдать следующий элемент: у него есть __next__, и он сообщает о конце исключением StopIteration. Свой __iter__ он тоже имеет и возвращает себя.
Генератор — способ получить итератор из функции. Функция с yield при вызове не исполняется, а создаёт объект с приостановленным кадром: локальные переменные, позиция исполнения, стек значений.
Фрагмент A. Итератор хранит позицию внутри себя; дойдя до конца, он в этом состоянии и остаётся. Второй list() честно спрашивает следующий элемент и сразу получает StopIteration.
Разделение на два понятия существует ровно ради этого. Список — итерируемое: на каждый for он отдаёт новый итератор, поэтому по нему можно ходить сколько угодно. Сам итератор одноразов, потому что он и есть позиция.
Отсюда практическое: функция, принимающая «последовательность» и обходящая её дважды, сломается на генераторе — молча, вторым пустым проходом. Если нужен повторный обход, его надо либо материализовать list(), либо требовать в контракте именно последовательность.
Фрагмент B. g() создаёт объект и возвращает управление немедленно. Первая строка тела выполнится только при первом next.
Это ловушка для проверок аргументов: raise ValueError в начале генератора сработает не в момент вызова, а когда кто-то начнёт обход — возможно, в другой функции и с бесполезным трейсбеком. Приём против этого известен: обычная функция проверяет аргументы и возвращает внутренний генератор.
Зато отсюда же берётся главное свойство: работа выполняется по требованию. Генератор, читающий файл, не прочитает ни байта, пока у него не попросят строку.
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, и цепочка корутин работает потому, что канал прокидывается насквозь до цикла событий.
Фрагмент D. Объект генератора хранит кадр, а не данные: 200 байт независимо от того, миллион элементов впереди или бесконечность.
| миллион квадратов | память |
|---|---|
| списковое включение | 40.4 МБ |
| генераторное выражение | 0.0004 МБ |
Разница в сто тысяч раз — и это ровно арифметика урока 03: список держит указатели и объекты, генератор не держит ничего, кроме позиции. Отсюда и то, что генератор может быть бесконечным.
Генератор — не всегда выигрыш. Нужен повторный обход, длина, индекс или срез — генератор проигрывает, и попытка обойти это через list() возвращает исходный расход памяти. Плюс каждый шаг стоит возобновления кадра: на маленьких коллекциях список быстрее.
Итерируемость даётся не только __iter__. Старый протокол через __getitem__ с целыми индексами до сих пор работает — объект без __iter__ может итерироваться, и это не ошибка, а совместимость (урок 11).
StopIteration внутри генератора больше не всплывает. С 3.7 (PEP 479) она превращается в RuntimeError: раньше случайная StopIteration из вложенного вызова молча обрывала генератор, и это был источник неуловимых багов.
Корень 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 выросла из протокола обхода — не потому, что так задумывали, а потому, что приостановленный кадр с двусторонним каналом оказался ровно тем, что нужно для конкурентности.
Генератор — это корутина C++20, и совпадение почти дословное. В обоих случаях вызов функции с особым ключевым словом не исполняет тело, а создаёт объект, хранящий кадр: локальные переменные, точку возобновления, стек. В обоих случаях управление возвращается вызывающему, а тело продолжается только по явному возобновлению.
Различие в том, кто платит за размещение кадра. C++ даёт контроль — кадр можно разместить где угодно, компилятор при удаче вообще его устранит, — но требует написать тип-обёртку с promise_type. Python размещает кадр в куче безусловно, поэтому шаг генератора стоит возобновления кадра, зато писать ничего не нужно.
Второй полезный образ — ленивая последовательность как в потоках C++20 или Java Stream: элементы не существуют, пока их не попросили. Отсюда и сто тысяч раз разницы по памяти из следствия 4.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Можно, и это ровно то, чем является итерируемое. Их два разных понятия, и путают именно это.
Итерируемое (__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++ до 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 — тогда типизатор поймает подстановку генератора заранее.
Типичная обработка данных пишется цепочкой списковых включений. Замена скобок на круглые меняет класс потребления памяти.
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 всё равно материализует, — или элементов мало и читаемость важнее. И отдельный случай: если внутри шага идёт обращение к сети или диску, ленивость размазывает эти обращения по всему обходу, что может быть хуже одного пакетного запроса.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
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.
Lib/contextlib.py. Возвращаемся к нему уже во второй раз, теперь с другой стороны. @contextmanager работает потому, что генератор умеет принимать исключение внутрь: __exit__ вызывает gen.throw(exc), и оно возникает в точке yield, где его ловит try/finally из тела. Следствие 3 — не теоретическая возможность, а несущая конструкция целого модуля.
Modules/itertoolsmodule.c и его документация. Раздел «itertools recipes» — три десятка коротких генераторов, собранных из базовых: скользящее окно, группировка, чередование, разбиение на батчи. Ценность не в самих рецептах, а в том, что видно принцип: сложный обход собирается композицией простых, и ни один шаг не материализует данные.
Заметь tee — он решает ровно задачу из раздела «На практике»: делает из одного итератора несколько независимых. И честно предупреждает в документации, что буферизует всё, что один потребитель уже прочитал, а другой ещё нет. Проблема не исчезает, она становится явной.
Из 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.
Тег v3.11.15.
Objects/genobject.c — весь тип генератора: gen_send_ex (возобновление кадра), gen_throw, gen_close. Три функции, закрывающие следствия 2 и 3.Python/ceval.c, инструкции SEND и YIELD_VALUE — как yield from компилируется в цикл передачи управления без питоновского фрейма-посредника.yield: именно здесь генератор стал корутиной по возможностям.yield from; в мотивации построчно разобрано, что теряет наивный цикл, — фрагмент C урока взят оттуда.finally в брошенном генератореawait как прямой потомок yield fromyield from отличается от цикла, с полным списком того, что теряется.