Генераторы в 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+ вопросов, которые спрашивают на собеседованиях аналитика. Бесплатно.