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

Ревьюер видит в коде filter, map, генераторное выражение или функцию с yield и ставит апрув: выглядит аккуратно, память не жрёт. Через две недели джоба падает с пустым результатом на втором проходе по данным, а логи молчат, потому что исключения тоже стали ленивыми. Ниже — набор конкретных мест, которые стоит подсвечивать на code review в Python 3.11+, с формулировками замечаний, которые можно копировать в комментарий к PR.
Речь про ленивость в стандартном Python: генераторы, генераторные выражения, ленивые представления dict.keys()/values()/items(), объекты из itertools, map, filter, zip, range. Библиотеки с отложенным исполнением (Dask, Polars lazy, SQLAlchemy) — отдельная тема, здесь про чистый язык.
Одноразовость: генератор, который читают дважды
Самая частая ошибка, которую пропускают на ревью. Генератор исчерпывается за один проход. Второй цикл по нему получает пустоту — без ошибки, без предупреждения.
def parse_lines(path):
with open(path) as f:
for line in f:
yield line.strip()
rows = parse_lines("data.txt")
total = sum(1 for _ in rows) # генератор исчерпан здесь
for row in rows: # тело цикла не выполнится ни разу
handle(row)
Опасность в том, что rows визуально выглядит как список. Ревьюер читает две строки по отдельности, каждая корректна. Проблема — в их сочетании.
Если по результату итерации проходят больше одного раза — это должен быть материализованный контейнер (список, кортеж), а не генератор. Ленивость экономит память ровно до момента, когда её начинают читать повторно.
Формулировка замечания в PR: «rows — одноразовый генератор, а ниже он итерируется дважды. Обернуть в list() при создании либо считать total внутри основного цикла.»
Where это особенно коварно
- Функция вернула генератор, а вызывающий код ждёт список.
len(result)упадёт сTypeError: object of type 'generator' has no len()— это ещё повезло, ошибка заметна. Хуже, когда результат просто уходит вif result:— генератор всегда истинный, даже пустой. - Результат кладут в кеш или возвращают из property. Первый обращающийся заберёт данные, остальные получат пусто.
- Генератор передают в две функции подряд. Первая его исчерпает.
Ленивость исключений: ошибка всплывает не там, где написана
Тело генератора не выполняется в момент вызова функции. Оно начинает работать при первой итерации. Значит, и исключения возникают при итерации, а не при создании.
def read_config(path):
data = json.load(open(path)) # упадёт не здесь
for key in data["items"]:
yield key
try:
gen = read_config("missing.json") # тут исключения нет
except FileNotFoundError:
gen = []
for item in gen: # FileNotFoundError вылетит здесь
process(item)
Блок try/except вокруг создания генератора не ловит ничего. Исключение прилетит из цикла, где его никто не ждёт.
Замечание: «try/except вокруг вызова генераторной функции бесполезен — тело исполняется при итерации. Либо сделать read_config обычной функцией, возвращающей список, либо обернуть сам цикл.»
Побочные эффекты в ленивом коде
Генераторные выражения и map откладывают не только вычисление значения, но и любой побочный эффект внутри.
# Автор думал, что все файлы записаны. Не записан ни один.
written = (save(item) for item in items)
log.info("Сохранено %d элементов", len(items))
Генераторное выражение здесь не выполнило ни одного save. Если written дальше никто не итерирует — записи не произойдёт вообще. Побочный эффект внутри ленивой конструкции — почти всегда баг.
Правило для ревью: если внутри (... for ...), map или filter вызывается функция ради её эффекта (запись, отправка, мутация), а не ради возвращаемого значения — это красный флаг. Такое место переписывают обычным циклом for.
Замыкание переменной цикла в генераторе
Классическая ловушка позднего связывания. Переменная захватывается по ссылке, а её значение читается в момент итерации — когда цикл уже закончился.
funcs = (lambda: i for i in range(3))
print([f() for f in funcs]) # [2, 2, 2], а не [0, 1, 2]
К моменту вызова f() все лямбды видят одно и то же финальное i. Лечится связыванием через аргумент по умолчанию: lambda i=i: i. На ревью подсвечивайте любое замыкание переменной цикла внутри ленивой конструкции.
Ресурсы: файл закрылся раньше, чем его дочитали
Если генератор открывает файл или соединение, время жизни ресурса теперь равно времени жизни итерации, а не времени жизни функции.
def rows():
with open("data.csv") as f:
yield from f # файл открыт, пока генератор не исчерпан
gen = rows()
first = next(gen) # файл открыт
# ... если больше не итерировать, файл закроется только
# при сборке генератора мусора, момент недетерминирован
Пока gen не дочитан до конца или не закрыт явно через gen.close(), файловый дескриптор занят. Под нагрузкой это утечка дескрипторов. Замечание: «генератор держит открытый файл до полного исчерпания. Если вызывающий код может прерваться раньше — гарантируйте закрытие через with вокруг итерации или explicit close.»
Сравнение конструкций: где что уместно
| Конструкция | Когда уместна | Что ловить на ревью |
|---|---|---|
Генераторное выражение (x for x in ...) | Один проход, экономия памяти на большом объёме | Повторная итерация, побочные эффекты, передача туда, где ждут len() |
Списковое включение [x for x in ...] | Нужен повторный доступ, len, индексация, срезы | Материализация огромного объёма в память без нужды |
Функция с yield | Потоковая обработка, конвейеры, бесконечные последовательности | Ленивые исключения, открытые ресурсы, одноразовость |
map/filter | Простое преобразование одного итерируемого | В Python 3 возвращают ленивый объект, не список |
Чек-лист ревьюера
- Найдите каждый
yield, генераторное выражение,map,filter,zip. По каждому спросите: результат читают один раз или больше? - Если больше одного — требуйте материализацию в список при создании.
- Проверьте
try/exceptвокруг вызовов генераторных функций — они почти всегда стоят не в том месте. - Побочный эффект внутри ленивой конструкции — переписать на явный цикл.
- Генератор открывает ресурс (файл, сокет, курсор БД) — проверьте гарантию закрытия при раннем прерывании.
- Замыкание переменной цикла внутри
lambdaили вложенного генератора — связать через аргумент по умолчанию. if generator:— почти всегда баг: непустой объект-генератор всегда истинный, даже если выдаёт ноль элементов.
Что смотреть дальше
Материализацию видно и без ручного ревью. Настройте линтер: правила flake8-bugbear (B-серия) ловят часть таких паттернов, а len() по генератору отсекает mypy на этапе проверки типов. Для конвейеров данных заведите правило команды: публичные функции возвращают либо явно типизированный Iterator[T] с документированной одноразовостью, либо list[T] — но не «как получилось». Дальше стоит разобрать itertools.tee и его цену по памяти для случаев, когда один поток действительно нужно читать дважды — это отдельная тема.
Генератор планировок: Набросок комнаты карандашом → 3D-рендер дизайна.
Спросить за 20 ₽Источники: Python Language Reference — Yield expressions, Python itertools — Functions creating iterators
Частые вопросы
Как быстро понять на ревью, что перед вами генератор, а не список?
А если у меня Python 3.9, что-то из описанного работает иначе?
Разве материализация в список не убивает весь смысл ленивости?
Как в проде отловить исчерпанный генератор, если ошибки нет?
itertools.tee решает проблему двойного чтения?
Стоит ли вообще запрещать генераторы командным гайдлайном?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.