Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Генераторы и итераторы в Python

Генераторы и итераторы: ленивые вычисления на практике

Как думать генераторами: yield и ленивость, экономия памяти на больших данных, itertools-комбинаторы, yield from, подводные камни с одноразовостью и закрытием.

Прямой ответ

Генератор — функция с yield: вызов не исполняет тело, а создаёт объект-итератор; каждое чтение (next) исполняет код до следующего yield и приостанавливает с полным сохранением состояния — ленивый поток значений вместо готового списка. Два выигрыша: память (файл на 10 ГБ обрабатывается построчно с О(1) в памяти — обработка логов вся на этом) и композиция (цепочки генераторов собираются в конвейер: чтение → фильтр → трансформация — каждый этап ленив, данные текут, не накапливаясь — ETL-пайплайны). Итераторный протокол (__iter__/__next__) делает генераторы совместимыми со всем языком: for, распаковка, in, comprehensions, sum/join/any. Три подводных камня, которые стоит знать: одноразовость (исчерпанный генератор пуст навсегда — повторный проход требует нового вызова; передача генератора в два места — классический баг «второй цикл ничего не выдал» — materialize в list, если нужен повтор); ленивость ошибок (исключение выбросится при итерации, не при вызове — «функция отработала без ошибок» не значит ничего до потребления); закрытие (GeneratorExit при раннем выходе из for — cleanup в finally исполняется, но незакрытый генератор держит ресурсы до сборки — с файлами/соединениями внутри — contextlib/явное close). Инструментарий: itertools (chain, islice, groupby, batched — готовые кирпичи конвейеров) и генераторные выражения (x*2 for x in data) — синтаксический сахар того же.

Модель выполнения: что происходит при yield

Механика, которую полезно представлять: gen = f() — тело НЕ исполняется (частая ошибка: «вызвал функцию с print — тишина»: print сработает на первом next); next(gen) — исполнение до первого yield, значение возвращено, кадр функции заморожен (локальные переменные, позиция — живы); следующий next — продолжение с того же места; конец функции/return — StopIteration (for перехватывает и завершается; return value в генераторе — значение уходит в StopIteration.value, из for недоступно — тонкость для yield from). Приостановка делает генераторы корутинами до async (yield из исторически — точка обмена значениями: value = yield result — двунаправленный обмен через send — основа, на которой выросли asyncio-кортины — async-практика); для прикладного кода достаточно однонаправленного yield, но понимание родства объясняет yield from (делегирование подгенератору с прозрачной передачей send/throw/return — композиция конвейеров без ручных циклов). Проверка понимания — ленивость в действии: [print(x) for x in gen] печатает по одному при итерации; list(gen) — всё сразу; память — только текущий элемент.

Конвейеры данных: паттерны и itertools

Архетип обработки: def read_lines(path): with open(path) as f: for line in f: yield line → def parse(lines): for l in lines: yield json.loads(l) → def keep_errors(records): for r in records: if r["level"]=="error": yield r — сборка keep_errors(parse(read_lines("app.log"))) — файл любого размера, память постоянна, каждый этап тестируется отдельно (pytest-фикстуры на маленьких итераторах). Кирпичи itertools: chain(a, b) — склейка потоков; islice(gen, 1000) — «первая тысяча» без чтения остального; batched(gen, 500) (3.12+) — пачки для пакетной записи/загрузки (ETL-батчи); groupby(sorted_data, key) — группы подряд (требует предварительной сортировки по ключу — несортированные данные дают несколько групп одного ключа — частый грабель); takewhile/dropwhile — по условию; count/cycle/repeat — бесконечные источники (с islice — ограниченные). Когда генераторов мало: нужен произвольный доступ/длина/повтор — list; данные не помещаются и нужен повторный проход — два прохода источника или временный файл; агрегация по всему — sum/max по генератору работают лениво (память О(1)).

Подводные камни: одноразовость, ошибки, ресурсы

Одноразовость: results = query() (генератор) → if not results: ... (проверка съела первый элемент! — if not list(results)) и «for results дважды» — второй пуст; диагностика — inspect.isgenerator/протокол итератора в API-контракте (функция возвращает генератор — документировать «одноразовый»; нужен повтор — возвращать list или фабрику генератора). Ленивость исключений: records = (parse(l) for l in lines) — битый JSON в строке 50000 упадёт на 50000-й итерации, не при создании (стектрейс укажет comprehension — но «создалось без ошибок» обманчиво; валидация входа — на этапе потребления, тесты — на реальных данных). Ресурсы: генератор с открытым файлом внутри, брошенный на полпути (break/исключение у потребителя) — файл закроется на GeneratorExit/сборке мусора — не детерминированно немедленно; надёжно — contextlib-обвязка у потребителя или генератор как контекст (contextlib.closing), а лучше — with внутри генератора (finally/with исполняются при close — CPython закрывает брошенные генераторы при сборке, но полагаться на своевременность в PyPy/альтернативах — нельзя). Производительность: генератор экономит память, но чуть медленнее списка на итерацию (вызовы next); «генератор ради генератора» на 10 элементах — стиль, не оптимизация; выигрыш начинается там, где список не помещается или чтение дорого (файлы/сеть/БД-курсоры — SQLAlchemy yield_per — стриминг строк вместо загрузки всех).

Практический сценарий: экспорт миллиона записей

Задача: выгрузка таблицы заказов (1.2M строк) в CSV для аналитиков; первая реализация rows = session.query(Order).all() + csv.writer — OOM на воркере с лимитом 512 МБ (память-диагностика показала пик 2+ ГБ). Переписываем конвейером: (1) источник — серверный курсор с yield_per(5000) (БД отдаёт пачками, Python держит одну); (2) трансформация — генератор форматирования (datetime → ISO, enum → label — построчно); (3) потребитель — csv.writer поверх генератора, буферизованная запись; (4) обвязка — фоновая задача (Celery, HTTP-запрос только ставит задачу и возвращает ссылку на результат в хранилище — S3-паттерн). Результат: память постоянна ~40 МБ (батч 5000 + буферы), время 3.5 минуты на миллион (упор в БД/диск, не в Python); побочные находки: if not rows в старом коде проверки «есть ли заказы» съедало первый элемент генератора после механического .all()→генератор (одноразовость проявилась тестом — фикс на next(rows, None) is None); groupby по менеджеру без сортировки давал дубли групп — добавлен ORDER BY в запрос. Экспорт стал штатной операцией — тот же конвейерный скелет переиспользован для ещё трёх отчётов (источник и формат меняются, структура та же).

FAQ

Генератор против списка — как выбрать быстро? Один проход + потенциально большой объём → генератор; нужен len/index/повтор/несколько потребителей → list; сомнение на малых данных → list (проще, ленивость не нужна на 100 элементах); «данные из файла/БД/сети одним проходом» → почти всегда генератор.

Что делает yield from простыми словами? Делегирует итерацию подгенератору (вложенный for не нужен) и прозрачно прокидывает send/throw/return — композиция «генератор из генераторов» читается как последовательность шагов; без него — ручной цикл с next/StopIteration — шум и баги.

Можно ли перезапустить генератор? Нет — исчерпанный итератор не сбрасывается (состояние consumed); варианты: новый вызов функции-генератора (фабрика), materialize в list/tee (itertools.tee — два независимых прохода с буферизацией — память растёт при расхождении потребителей, осторожно).

Асинхронные генераторы — это то же самое? Концептуально да, с await-источниками: async for/yield внутри async def — поток значений из асинхронных источников (стримы API, вебсокеты — realtime); правила те же (ленивость, одноразовость, aclose вместо close), добавляется событийный цикл как контекст исполнения.

Источники: Functional Programming HOWTO, itertools (Python docs), PEP 342 — ссылки в шапке.