Генераторы в Python — yield, ленивые вычисления и работа с большими данными

Что такое генераторы

Генератор — это функция, которая отдаёт значения по одному вместо того, чтобы вернуть весь результат сразу. Обычная функция с return создаёт весь список в памяти и возвращает его. Генератор с yield вычисляет следующее значение только когда его попросят — это называется ленивые (lazy) вычисления.

Для аналитика это важно, когда данных много: файл на 10 ГБ, миллионы строк из API, потоковая обработка логов. Генератор обработает данные, не загружая всё в память.

yield vs return

# Обычная функция — возвращает список
def get_squares_list(n):
    result = []
    for i in range(n):
        result.append(i ** 2)
    return result

# Генератор — отдаёт по одному
def get_squares_gen(n):
    for i in range(n):
        yield i ** 2

Ключевые различия:

return yield
Результат Весь объект сразу Одно значение за раз
Память Весь результат в RAM Только текущий элемент
Повторный проход Да Нет (одноразовый)
Тип результата То, что вернули (list, dict, ...) Объект-генератор
squares_list = get_squares_list(1_000_000)  # ~8 МБ в памяти
squares_gen = get_squares_gen(1_000_000)    # почти 0 МБ

Генератор не вычисляет ничего при создании. Значения появляются при итерации:

gen = get_squares_gen(5)
print(next(gen))  # 0
print(next(gen))  # 1
print(next(gen))  # 4

# Или в цикле
for sq in get_squares_gen(5):
    print(sq)  # 0, 1, 4, 9, 16

Когда значения заканчиваются, генератор выбрасывает StopIteration. Цикл for перехватывает это автоматически.

Как работает генератор изнутри

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

def counter(start, end):
    current = start
    while current < end:
        print(f"  (вычисляем {current})")
        yield current
        current += 1

gen = counter(1, 4)
print(next(gen))
# (вычисляем 1)
# 1
print(next(gen))
# (вычисляем 2)
# 2

Видно, что код выполняется порционно — только до следующего yield.

Генераторные выражения

Аналог list comprehension, но с круглыми скобками вместо квадратных:

# List comprehension — создаёт весь список
squares_list = [x ** 2 for x in range(1_000_000)]

# Generator expression — ленивый
squares_gen = (x ** 2 for x in range(1_000_000))

Генераторное выражение удобно передавать напрямую в функции:

# Сумма квадратов без промежуточного списка
total = sum(x ** 2 for x in range(1_000_000))

# Любое значение больше порога?
has_outlier = any(val > 1000 for val in measurements)

# Максимальная длина строки
max_len = max(len(line) for line in open("data.csv"))

В sum(), any(), max() — двойные скобки не нужны, достаточно одних.

Экономия памяти на практике

Допустим, нужно обработать CSV-файл на 5 ГБ — посчитать среднее значение столбца:

# Плохо: загружаем всё в память
def read_all_values(path, column_idx):
    values = []
    with open(path) as f:
        next(f)  # пропуск заголовка
        for line in f:
            values.append(float(line.split(",")[column_idx]))
    return values

avg = sum(read_all_values("huge.csv", 2)) / len(read_all_values("huge.csv", 2))
# Двойное чтение файла + весь список в памяти

# Хорошо: генератор
def read_values(path, column_idx):
    with open(path) as f:
        next(f)
        for line in f:
            yield float(line.split(",")[column_idx])

total = 0
count = 0
for val in read_values("huge.csv", 2):
    total += val
    count += 1
avg = total / count
# Один проход, минимум памяти

Файл читается построчно (объект файла в Python — уже итератор), а генератор не накапливает строки в списке.

Цепочки генераторов

Генераторы можно комбинировать — выход одного подаётся на вход другого:

def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.strip()

def parse_events(lines):
    for line in lines:
        parts = line.split(",")
        yield {"user_id": parts[0], "event": parts[1], "ts": parts[2]}

def filter_purchases(events):
    for event in events:
        if event["event"] == "purchase":
            yield event

# Пайплайн: читаем → парсим → фильтруем
lines = read_lines("events.csv")
events = parse_events(lines)
purchases = filter_purchases(events)

for p in purchases:
    process(p)  # обрабатываем по одному

Вся цепочка ленивая — данные не материализуются на промежуточных этапах.

itertools — библиотека для итераторов

Модуль itertools содержит готовые инструменты для работы с итераторами:

import itertools

# islice — срез итератора (первые N элементов)
first_100 = itertools.islice(read_values("huge.csv", 2), 100)

# chain — соединить несколько итераторов
all_data = itertools.chain(
    read_values("jan.csv", 2),
    read_values("feb.csv", 2),
    read_values("mar.csv", 2),
)

# groupby — группировка (данные должны быть отсортированы)
data = [("A", 10), ("A", 20), ("B", 30), ("B", 40)]
for key, group in itertools.groupby(data, key=lambda x: x[0]):
    print(key, list(group))
# A [('A', 10), ('A', 20)]
# B [('B', 30), ('B', 40)]

# product — декартово произведение
for combo in itertools.product(["A", "B"], [1, 2, 3]):
    print(combo)  # ('A', 1), ('A', 2), ('A', 3), ('B', 1), ...

# accumulate — накопленная сумма
cumsum = list(itertools.accumulate([10, 20, 30, 40]))
# [10, 30, 60, 100]

Часто используемые: islice, chain, groupby, product, combinations, accumulate, takewhile, dropwhile.

Когда использовать генераторы

Используйте генераторы, когда:

  • Данные не помещаются в память (большие файлы, потоки).
  • Нужен один проход по данным (ETL-пайплайн, подсчёт статистик).
  • Промежуточный результат не нужен целиком.

Используйте списки, когда:

  • Нужен произвольный доступ по индексу (data[42]).
  • Нужен повторный проход (генератор — одноразовый).
  • Данных немного и память не проблема.
  • Нужны len(), срезы, сортировка.

Вопросы с собеседований

Чем генератор отличается от списка? — Генератор вычисляет значения лениво — по одному при запросе. Список хранит все значения в памяти. Генератор одноразовый — после прохода нельзя вернуться к началу. Зато он может обрабатывать данные, не помещающиеся в RAM.

Что происходит при вызове функции с yield? — Python не выполняет код, а создаёт объект-генератор. Код выполняется порциями — от одного yield до следующего — при каждом вызове next(). Локальные переменные сохраняются между вызовами.

Можно ли в генераторе использовать return? — Да. return в генераторе завершает итерацию — выбрасывает StopIteration. Можно return значение — это значение попадёт в атрибут StopIteration.value, но на практике это используется редко (в основном в корутинах).

Что такое генераторное выражение? — Аналог list comprehension с круглыми скобками: (x**2 for x in range(10)). Создаёт ленивый генератор вместо списка. Удобно передавать в sum(), any(), max() без промежуточного списка.

FAQ

Можно ли пройти по генератору дважды?

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

Генераторы быстрее списков?

Не обязательно. По скорости итерации список и генератор сопоставимы. Выигрыш генераторов — в памяти, а не в скорости. Иногда генератор даже чуть медленнее из-за накладных расходов на yield. Но на больших данных выигрыш в памяти перевешивает: если данные не влезают в RAM, генератор — единственный вариант.

Как отладить генератор?

Генератор нельзя просто напечатать — print(gen) покажет <generator object ...>. Для отладки материализуйте часть данных: list(itertools.islice(gen, 10)) — первые 10 элементов. Или используйте print / logging внутри функции-генератора перед yield. Подробнее про работу с коллекциями — в гайдах по спискам и list comprehension.


Потренируйте вопросы по Python на реальных задачах — откройте тренажёр. 1500+ вопросов, которые спрашивают на собеседованиях аналитика. Бесплатно.