Итераторы и генераторы: один метод разницы и одна тихая ошибка
Итерируемое умеет отдать итератор, итератор умеет отдать следующий элемент — вся разница в одном методе. Но именно из неё растёт ошибка, которая не падает: функция, дважды прошедшая по генератору, получает во второй раз пустоту и возвращает неверный ответ молча.
Полное техническое изложение
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, асинхронный обход, встраивание включений.
База: что на самом деле получает цикл
Начнём с кода, который писал каждый:
for x in [10, 20, 30]:
print(x)Список лежит в памяти целиком, цикл берёт из него элементы по одному. Вопрос, с которого начинается тема, звучит почти скучно и оказывается главным: что именно цикл берёт у списка?
Не элементы. Список не умеет отдавать «следующий»: у него нет ни понятия текущего места, ни памяти о том, где остановились в прошлый раз. А обходу это нужно — и лежит оно не в списке.
Поэтому шагов здесь не один, а два:
- цикл просит у списка то, по чему можно идти, — и список выдаёт это новым, отдельным от себя;
- дальше цикл раз за разом просит у полученного следующее значение, пока значения не кончатся.
У обеих сторон есть имена. Объект, у которого можно попросить обход, —
итерируемое: список, строка, словарь, открытый файл. То, что он выдаёт и
что помнит место, — итератор. Ровно поэтому в протоколе два метода:
__iter__ — «дай то, по чему идти», __next__ — «дай следующее».
Отсюда сразу видно главное различие в поведении. Список — источник обходов: каждый новый цикл просит у него новый итератор и начинается с начала. Итератор — сам обход, и он один: дойдя до конца, он в конце и остаётся. Второй цикл по нему не начнётся заново — он просто ничего не даст.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше — про то, что «ничего не даст» происходит молча, без исключения; про генератор, который и есть итератор, написанный как функция; и про то, что размен памяти на время измерим и в обе стороны.
Механизм 1: разница в одном методе
__iter__ плюс __next__, и iter(итератор) is итератор. Отсюда одноразовость.Определения из глоссария короткие. Iterable — an object capable of
returning its members one at a time
(объект, способный отдавать свои элементы по одному). Iterator — an object representing a
stream of data
(объект, представляющий поток данных), у которого повторные вызовы __next__ возвращают очередной
элемент, а когда данные кончились — бросают StopIteration.
Проверим, чем они отличаются на самом деле:
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 — у итератора есть оба методаОтсюда всё остальное. Список — источник итераторов, поэтому обходить его можно сколько угодно раз, каждый раз с нуля. Итератор — сам поток, и он один:
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.
Попытка сделать это с итератором просто вернёт тот же самый исчерпанный объект-итератор, который использовался на предыдущем проходе, — из-за чего он выглядит как пустой контейнер.
Выглядит как пустой контейнер. Не «бросает ошибку», не «предупреждает» — выглядит как пустой. Это и есть корень всех неприятностей ниже.
Механизм 2: ошибка, которая не падает
Возьмём функцию, которая проходит по аргументу дважды. Ничего экзотического — обычный отчёт:
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 честно вернул ноль. Функция отработала, отчёт построился,
число неверное.
Это стоит осознать как класс, а не как случай: функция, принимающая «последовательность», обязана либо проходить по ней один раз, либо зафиксировать её в начале.
def report(rows):
rows = list(rows) # одна строка, снимающая весь класс ошибок
...У этой строки есть цена — вся последовательность окажется в памяти, — и в этом весь смысл дальнейшего разговора. Но «работает и медленно» лучше, чем «быстро и неправильно».
Та же ловушка в мелочах:
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, reversed | True | да |
генератор, itertools.chain | True | да |
open(...), io.StringIO | True | да |
Документация каждого из них говорит это прямо. У map в описании стоит
Return an iterator
(Возвращает итератор), у filter — construct an iterator
(строит итератор),
у zip — returns 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 можно обходить, получая строки потока.
Проверка одна и та же для всех: второй проход пуст (здесь и ниже вывод скрипта сокращён до относящихся к делу строк).
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.
Код, изменяющий коллекцию во время обхода этой же коллекции, бывает непросто написать правильно. Обычно проще обойти копию коллекции или создать новую коллекцию.
Оба способа проверены и дают верный ответ: 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 в теле функции делает эту функцию генераторной).
Ключевое следствие проверяется в три строки:
log = []
def h():
log.append("тело пошло")
yield 1
obj = h()
log # [] — тело НЕ выполнялось
next(obj)
log # ['тело пошло'] — пошло только сейчасВызов создал объект и вернул управление. Всё, что написано в теле — проверки
аргументов, открытие файла, запрос к базе, — не произойдёт, пока кто-нибудь не
попросит первый элемент. Функция, которая валидирует аргументы и содержит
yield, не валидирует их при вызове.
Механизм 6: четыре состояния, и все четыре наблюдаемы
Состояние генератора — не метафора, а строка, которую возвращает inspect:
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: память против времени — замер
Здесь размен, ради которого генераторы и существуют. Пик памяти по
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 МиБ.
Выигрыш появляется не от ленивости самой по себе, а от того, что альтернатива занимает память. Нет альтернативы — нет и выигрыша.
Глубже: почему на малых данных проигрыш структурный
Причина видна без секундомера. В 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 внутри генератора
Классическая ошибка, которую язык в итоге запретил:
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 — везде одинаково.
Границ здесь две, и обе записаны в стандартной библиотеке — их можно прочитать, не выходя из интерпретатора:
import __future__
__future__.generator_stop
# _Feature((3, 5, 0, 'beta', 1), (3, 7, 0, 'alpha', 0), 8388608)Необязательно с 3.5.0b1, обязательно с 3.7.0a0.
Правильный вариант — поймать и выйти:
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 стоит особняком: он единственный нужен не для передачи данных внутрь генератора, а для уборки:
def g():
try:
yield 1
except GeneratorExit:
return "прибрано"
gen = g(); next(gen)
gen.close()| версия | что вернёт close() |
|---|---|
| 3.11, 3.12 | None |
| 3.13, 3.14 | 'прибрано' |
Изменение записано в справочнике прямо: Changed in version 3.13: If a
generator returns a value upon being closed, the value is returned by
(Изменено в версии 3.13: если генератор при закрытии возвращает значение, это значение возвращает close()close()). Измерено на всех четырёх версиях.
Глубже: yield from — не сахар над циклом
PEP 380 добавил делегирование. Разница с ручным циклом не в краткости: через
yield from проходят send, throw и возвращаемое значение подгенератора, а
через for x in inner(): yield x — нет.
По времени yield from не всегда выигрывает. Обход тысячи элементов через одну
прослойку — смотреть надо по строкам: внутри строки цикл и делегирование
измерены одним запуском одного интерпретатора, а между строками стоят разные
сборки, и вычесть их вклад нечем.
| ручной цикл | yield from | |
|---|---|---|
| 3.11.15 | 45,6 мкс | 47,2 мкс |
| 3.13.7 | 37,3 мкс | 33,8 мкс |
| 3.14.7 | 36,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, когда итерация закончена.
Соответствие один в один: __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.2 | PEP 234 вводит протокол итерации (__iter__ / __next__), PEP 255 — генераторные функции и yield. | Появился сам механизм |
| 2.5 | PEP 342: yield становится выражением, появляются send, throw и close. | Генератор стал приёмником |
| 3.3 | PEP 380: yield from. Делегирование пробрасывает не только значения, но и send, throw и результат подгенератора. | Делегирование целиком |
| 3.5 | PEP 479 доступен как from __future__ import generator_stop — по записи в модуле __future__, с 3.5.0b1. | Можно включить руками |
| 3.7 | StopIteration, вылетевший из тела генератора, становится RuntimeError. Обязательно с 3.7.0a0. | Тихая ошибка стала громкой |
| 3.12 | PEP 709 встраивает списковые, словарные и множественные включения. Генераторные выражения остаются отдельной функцией — видно по MAKE_FUNCTION и по глубине стека. | Включения стали дешевле, генераторы нет |
| 3.13 | close() возвращает значение, если генератор при закрытии его вернул. До этого возвращался 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))
Практика · оцените
Проверка знаний
Функция принимает последовательность, считает по ней сумму и отдельно отбирает отрицательные значения — два прохода. Ей передали генераторное выражение. Что произойдёт?
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
bench/iterators/inlining.pybench/iterators/memory_time.pybench/iterators/memory_when_not.pybench/iterators/protocol.pybench/iterators/states.pybench/iterators/silent.pybench/iterators/traps.pybench/iterators/versions.pybench/iteration/one_shot_types.pybench/iteration/invalidation.pybench/iteration/async_iteration.py
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Итерируемое умеет отдать итератор (
__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, а генераторные выражения нет.
На самом деле
- Различие проверяется двумя строками:
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 Б. - PEP 709 их не касается, и это видно без замеров. У спискового включения с 3.12 нет ни объекта кода
<listcomp>, ниMAKE_FUNCTION, и глубина стека внутри него — 3. У генераторного выражения<genexpr>иMAKE_FUNCTIONна месте во всех четырёх версиях, а глубина стека — 4. Отдельная функция и отдельный кадр никуда не делись. - С 3.7 это
RuntimeError: generator raised StopIteration. До 3.7 было хуже:StopIterationиз тела был неотличим от нормального завершения, и конвейер молча обрывался раньше времени. Обе границы записаны в стандартной библиотеке:__future__.generator_stop— необязательно с 3.5.0b1, обязательно с 3.7.0a0. Правильный способ — обернутьnextвtry/except StopIterationи выйти черезreturn. - Не просто и не всегда. PEP 380 пробрасывает через делегирование
send,throwи возвращаемое значение подгенератора — ручной циклfor x in inner(): yield xне делает ничего из этого. А по времени на 3.11.15 разницы нет вовсе (44,1 мкс против 44,0), и выигрыш появляется только на других сборках. Выбирать его стоит за протокол, а не за скорость. - Проверка потребляет.
3 in (i for i in range(5))вернётTrue, но после этого в генераторе останется только[4]: оператор дошёл до совпадения и всё пройденное съел. По той же причине у генератора нетlen()—TypeError: object of type 'generator' has no len(): чтобы узнать длину, его пришлось бы исчерпать.
По версиям
- 2.1 / 2.2
- PEP 234 вводит протокол итерации (
__iter__/__next__), PEP 255 — генераторные функции иyield.< - 2.5
- PEP 342:
yieldстановится выражением, появляютсяsend,throwиclose.< - 3.3
- PEP 380:
yield from. Делегирование пробрасывает не только значения, но иsend,throwи результат подгенератора.< - 3.5
- PEP 479 доступен как
from __future__ import generator_stop— по записи в модуле__future__, с 3.5.0b1.< - 3.7
StopIteration, вылетевший из тела генератора, становитсяRuntimeError. Обязательно с 3.7.0a0.<- 3.12
- PEP 709 встраивает списковые, словарные и множественные включения. Генераторные выражения остаются отдельной функцией — видно по
MAKE_FUNCTIONи по глубине стека.< - 3.13
close()возвращает значение, если генератор при закрытии его вернул. До этого возвращалсяNone.<
Что разобрано
- База: что на самом деле получает цикл
- Механизм 1: разница в одном методе
- Механизм 2: ошибка, которая не падает
- Механизм 3: кто в стандартной библиотеке одноразов
- Механизм 4: изменять коллекцию во время обхода
- Механизм 5: генератор — итератор, написанный как функция
- Механизм 6: четыре состояния, и все четыре наблюдаемы
- Механизм 7: память против времени — замер
- Глубже: почему на малых данных проигрыш структурный
- Глубже: StopIteration внутри генератора
- Глубже: генератор как приёмник — send, throw, close
- Глубже: `yield from` — не сахар над циклом
- Глубже: тот же протокол через `await`
- Глубже: история версий
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
- Чем измерено
Источники и что читать дальше
8 ИСТОЧНИКОВ
- Глоссарий 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
- Справочник языка — выражения 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
- PEP 234 — IteratorsPEP. Документ, которым протокол итерации введён в язык (Python 2.1). Отсюда сама пара __iter__ / __next__ и требование, чтобы итератор возвращал себя из __iter__.https://peps.python.org/pep-0234/
- PEP 255 — Simple GeneratorsPEP. Введение yield и генераторных функций (Python 2.2). Здесь же сформулировано главное свойство: вызов генераторной функции не выполняет её тело.https://peps.python.org/pep-0255/
- PEP 342 — Coroutines via Enhanced GeneratorsPEP. Python 2.5. Превращает yield из инструкции в выражение и добавляет send, throw и close — то, из-за чего генератор становится не только источником значений, но и приёмником.https://peps.python.org/pep-0342/
- PEP 380 — Syntax for Delegating to a SubgeneratorPEP. Python 3.3, `yield from`. Делегирование не сводится к циклу: через него проходят send, throw и возвращаемое значение подгенератора.https://peps.python.org/pep-0380/
- Что нового в 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
- Модуль __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