Генератор в Python — это функция, которая отдаёт значения по одному через yield, а не собирает их разом в список. Практический смысл для аналитика простой: файл на 20 ГБ или бесконечный поток событий можно обрабатывать построчно, держа в памяти один элемент вместо миллионов. Там, где df = pd.read_csv(...) на большом файле роняет ядро с MemoryError, генератор спокойно проходит те же данные за постоянную память. Дальше — как это работает, где я реально этим пользуюсь и что за это спрашивают на собеседовании.
Что такое генератор и чем он отличается от списка?
Список хранит все элементы сразу. Когда я пишу [x * x for x in range(10_000_000)], интерпретатор считает все десять миллионов квадратов и кладёт их в память — это несколько сотен мегабайт, даже если мне нужна была только сумма.
Генератор хранит рецепт, а не результат. Он помнит, на каком элементе остановился, и вычисляет следующий только когда его попросят.
# Список — все элементы в памяти сразу
squares_list = [x * x for x in range(10_000_000)] # ~400 МБ в RAM
# Генератор — одно значение за раз
squares_gen = (x * x for x in range(10_000_000)) # несколько сотен байт
Разница видна через sys.getsizeof: список занимает мегабайты, сам объект-генератор — постоянные ~100–200 байт независимо от того, сколько элементов он отдаст. Это и есть ключевая мысль: у генератора память не растёт вместе с размером данных.
Плата за это — генератор одноразовый и не поддерживает индексацию. Нельзя взять squares_gen[500] или пройти его дважды. Об этом ниже отдельно.
Как yield превращает функцию в генератор?
Как только в теле функции появляется yield, функция перестаёт быть обычной. Её вызов больше не выполняет код — он возвращает объект-генератор. Тело начинает работать только когда я начинаю по нему итерироваться.
def read_orders(path):
with open(path, encoding="utf-8") as f:
next(f) # пропускаем строку заголовка
for line in f:
order_id, user_id, amount = line.rstrip("\n").split(",")
yield int(order_id), int(user_id), float(amount)
yield работает как пауза. Функция доходит до него, отдаёт значение наружу, замораживается — с сохранением всех локальных переменных и позиции в файле — и ждёт следующего запроса. При следующей итерации выполнение продолжается со строки после yield, а не с начала функции. В этом отличие от return, который завершает функцию насовсем.
Отсюда приятное следствие: with open(...) внутри генератора держит файл открытым ровно столько, сколько я его читаю. Как только итерация закончилась, файл корректно закрывается сам.
Проверить понимание паузы-возобновления удобно руками: вставьте функцию с yield в Python-песочницу, оберните вызов в next() несколько раз подряд и посмотрите, где именно она каждый раз останавливается.
Как читать большой лог-файл построчно, не загружая в память?
Сам объект файла в Python уже итератор — проход по нему отдаёт строки по одной, а не читает файл целиком. На этом строится вся обработка логов.
Первый раз я это прочувствовал, когда pd.read_csv на годовой выгрузке событий положил мне ядро Jupyter: 12 ГБ CSV в 8 ГБ памяти не лезли никак. А построчный генератор прошёл тот же файл, не заметив его размера.
Допустим, из access-лога на несколько гигабайт нужно вытащить все запросы, вернувшие 500-ю ошибку:
def server_errors(path):
with open(path, encoding="utf-8") as f:
for line in f:
if ' 500 ' in line:
yield line.rstrip("\n")
for line in server_errors("access.log"):
print(line)
В любой момент в памяти живёт одна строка. Файл может быть больше всей оперативки машины — это не мешает.
По-настоящему генераторы раскрываются, когда я собираю из них конвейер. Каждый шаг — отдельный генератор, данные текут через них лениво, без промежуточных списков:
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def only_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
def to_fields(lines):
for line in lines:
yield line.split("\t")
lines = read_lines("app.log")
errors = only_errors(lines)
rows = to_fields(errors)
for row in rows:
# вот здесь файл наконец начинает реально читаться
handle(row)
Пока я не запущу финальный for, с диска не прочитается ни байта. Три генератора связаны, но не сделано ничего — это и есть ленивые вычисления. Дальше данные проходят через всю цепочку по одной строке за раз, без единого полного списка в памяти.
Чем генераторное выражение отличается от list comprehension?
Синтаксически — только скобками. Квадратные дают список, круглые — генератор.
# list comprehension — материализует весь список
amounts = [float(row[2]) for row in rows]
# генераторное выражение — ленивое, заранее не считает ничего
amounts = (float(row[2]) for row in rows)
Разница становится огромной, когда результат сразу уходит в функцию-потребитель вроде sum, max, min, any, all. Тогда список не нужен вообще:
# Плохо на больших данных: сначала собираем весь список в память
total = sum([amount for _, _, amount in read_orders("orders.csv")])
# Хорошо: значения текут по одному, память постоянная
total = sum(amount for _, _, amount in read_orders("orders.csv"))
Во втором варианте скобки генератора даже не приходится дублировать: когда генераторное выражение — единственный аргумент функции, внешние круглые скобки убираются. sum съедает числа по мере поступления и не держит ни одного лишнего.
Правило, которым я пользуюсь: если результат нужен целиком и не раз — беру список. Если он проходится один раз и уходит в агрегат или цикл — беру генератор. Больше примеров с обоими вариантами разобрано в справочнике по Python.
Как обрабатывать данные чанками, если построчно неудобно?
Построчное чтение — не единственный режим. Иногда удобнее резать данные на куски по N строк: например, чтобы считать агрегаты через pandas, но не тянуть весь файл. pd.read_csv с параметром chunksize возвращает не датафрейм, а итератор по чанкам.
import pandas as pd
revenue_by_city = {}
for chunk in pd.read_csv("events.csv", chunksize=500_000):
grouped = chunk.groupby("city")["revenue"].sum()
for city, rev in grouped.items():
revenue_by_city[city] = revenue_by_city.get(city, 0) + rev
Каждый chunk — обычный датафрейм на 500 тысяч строк. Я считаю по нему частичную сумму, подмешиваю в общий словарь и отпускаю чанк — сборщик мусора освобождает память до следующей итерации. Файл на 200 млн строк так проходится на ноутбуке с 8 ГБ памяти, хотя целиком туда бы не влез.
Свой генератор чанков пишется в пару строк, если данные приходят не из pandas, а, скажем, из курсора базы или из другого генератора:
def chunks(iterable, size):
batch = []
for item in iterable:
batch.append(item)
if len(batch) == size:
yield batch
batch = []
if batch: # не забываем последний неполный кусок
yield batch
Такой chunks я использую для батчевой вставки в БД и для отправки данных пачками в API — по 1000 записей за раз вместо строки-по-строке. Похожие задачи на обработку выгрузок разобраны в тестовых заданиях.
Почему генератор можно пройти только один раз?
Потому что он не хранит элементы — он их производит. Как только генератор дошёл до конца, внутри не осталось состояния, чтобы начать заново.
gen = (x for x in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # [] — уже исчерпан
Это ловушка, на которой спотыкаются, когда генератор случайно проходят дважды. Классика — посчитать длину, а потом попытаться проитерировать те же данные:
rows = (line.split(",") for line in open("orders.csv"))
n = sum(1 for _ in rows) # прошли генератор до конца
for row in rows: # тут уже пусто, цикл не сделает ни одной итерации
process(row)
Если данные реально нужны несколько раз — либо материализуйте их в список через list(...) и смиритесь с памятью, либо перечитайте источник заново, создав новый генератор. Универсального «перемотать назад» у генератора нет, и это осознанная плата за экономию. Списку такое не грозит: его можно переиспользовать сколько угодно, он же держит всё внутри. Вот и весь размен — память против переиспользования.
Что спрашивают про генераторы на собеседовании?
Вопрос про разницу между списком и генератором — почти обязательный на позициях аналитика и дата-инженера. Что хотят услышать:
- генератор ленивый и отдаёт элементы по одному, список считает и хранит всё сразу;
- генератор экономит память и годится для потоков и больших файлов, но одноразовый и без индексации;
yieldзамораживает функцию с сохранением состояния, а не завершает её, какreturn;- генераторное выражение в круглых скобках — тот же принцип, что функция с
yield, только компактнее.
Любимый практический вопрос: «как посчитать сумму по колонке в файле, который не помещается в память?». Правильный ответ — не pd.read_csv целиком, а построчный генератор или chunksize. Ещё спрашивают, что выведет двойной проход по генератору (во второй раз — пустоту): так проверяют, поняли ли вы одноразовость.
Разбор таких вопросов с формулировками ответов лежит в базе вопросов с собеседований, а потренировать код на реальных задачах можно в Python-тренажёре. Если готовитесь под конкретного работодателя — загляните на страницы компаний с реальными вопросами. А понять, на какую вилку по грейдам рассчитывать, помогает обзор зарплат аналитика — генераторы там, конечно, не главный фактор, но умение не ронять пайплайн на больших данных ценят на грейд выше.
Что запомнить
Генераторы — это не «продвинутый Python», а базовая экономия памяти, которая нужна аналитику каждый раз, когда данных больше, чем оперативки. Держите в голове три вещи: yield ставит функцию на паузу и превращает её в генератор, круглые скобки вместо квадратных дают ленивое выражение, а пройти генератор можно ровно один раз. Всё остальное — чтение логов построчно, нарезка на чанки, конвейеры из генераторов — строится на этих трёх идеях.
Дальше стоит копнуть модуль itertools (islice, chain, groupby) и связку генераторов с pandas.read_csv(chunksize=...) на своих выгрузках — там они окупаются быстрее всего.
Если хотите закрыть Python и SQL под собеседование системно, а не по кусочкам, — в плане подготовки с Pro открыты все 400+ Python-задач, тренажёр с проверкой и разборы вопросов от реальных компаний. Начать можно бесплатно.
Связанная тема — List comprehension в Python простыми словами.