Когда вы пишете for item in something, Python внутри использует общий механизм — протокол итератора. Понимание этого механизма открывает доступ к генераторам: способу обрабатывать огромные или бесконечные последовательности данных, не загружая всё в память сразу.
Зачем это вообще нужно
Представьте, что нужно обработать файл логов на 10 ГБ. Если прочитать его целиком в список строк, программа упадёт по нехватке памяти. Но если читать и обрабатывать строки по одной, память почти не расходуется — в каждый момент в ней живёт ровно одна строка.
Короткая формула: итератор отдаёт элементы по одному, по запросу, а не все сразу.
Этот принцип называется ленивыми вычислениями (lazy evaluation): значение вычисляется в тот момент, когда оно действительно понадобилось, а не заранее. Генераторы — главный инструмент ленивости в Python.
Протокол итератора
В Python есть два связанных понятия, которые легко спутать.
Цикл for это два дандер-метода: один раз __iter__, затем __next__ до StopIteration; итератор одноразовый, и второй for по нему ничего не даст.
Iterable (итерируемый объект) — то, что можно перебрать в цикле for: список, строка, словарь, файл. У него есть метод __iter__, возвращающий итератор.
Iterator (итератор) — объект, который непосредственно отдаёт элементы по одному. У него есть метод __next__, возвращающий следующий элемент.
Когда вы пишете цикл, Python делает примерно следующее:
nums = [10, 20, 30]
it = iter(nums) # вызывает nums.__iter__() — получаем итератор
print(next(it)) # 10 — вызывает it.__next__()
print(next(it)) # 20
print(next(it)) # 30
print(next(it)) # StopIteration — элементы кончились
Когда элементы заканчиваются, итератор бросает исключение StopIteration. Цикл for ловит его автоматически и просто завершается. Поэтому обычный код этого исключения не видит.
Можно написать свой итератор как класс — это показывает, что внутри нет магии:
живой пример
class CountUp:
"""Считает от start до stop (не включая stop)."""
def __init__(self, start: int, stop: int):
self.current = start
self.stop = stop
def __iter__(self):
return self # сам объект является итератором
def __next__(self):
if self.current >= self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
for n in CountUp(1, 4):
print(n) # 1, 2, 3
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Это работает, но кода много: нужно вручную хранить состояние (self.current) и бросать StopIteration. Генераторы делают то же самое в разы короче.
Генераторы и yield
Генератор — это функция, которая вместо return использует ключевое слово yield. Каждый yield отдаёт одно значение и «замораживает» функцию до следующего запроса.
Тот же счётчик в виде генератора:
живой пример
def count_up(start: int, stop: int):
current = start
while current < stop:
yield current # отдать значение и приостановиться
current += 1
for n in count_up(1, 4):
print(n) # 1, 2, 3
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Никакого класса, __next__ и StopIteration — Python создаёт их за вас. Состояние (current) сохраняется между вызовами автоматически.
Ключевая особенность: вызов count_up(1, 4) не выполняет тело функции. Он возвращает объект-генератор. Код начинает работать только когда вы запрашиваете первый элемент:
gen = count_up(1, 4) # тело ещё не выполнялось
print(next(gen)) # 1 — выполнилось до первого yield
print(next(gen)) # 2 — продолжилось с места остановки
Именно поэтому генераторы умеют описывать бесконечные последовательности — они не пытаются вычислить всё сразу:
живой пример
def naturals():
n = 1
while True: # бесконечный цикл — это нормально для генератора
yield n
n += 1
gen = naturals()
print(next(gen)) # 1
print(next(gen)) # 2
# можно брать значения сколько нужно, последовательность «не кончается»
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Важное ограничение: генератор одноразовый. Пройдя по нему один раз, второй раз вы получите пустоту — состояние уже дошло до конца. Если нужно перебрать заново, создайте генератор снова.
Генераторные выражения
Для простых случаев есть компактный синтаксис — генераторное выражение. Оно похоже на списковое включение (list comprehension), но в круглых скобках:
# списковое включение — создаёт весь список в памяти сразу
squares_list = [x * x for x in range(1_000_000)]
# генераторное выражение — вычисляет значения лениво, по запросу
squares_gen = (x * x for x in range(1_000_000))
Разница принципиальная. squares_list мгновенно занимает десятки мегабайт. squares_gen не занимает почти ничего — значения появляются по мере перебора.
Генераторные выражения удобно передавать прямо в функции, которые перебирают элементы:
total = sum(x * x for x in range(1_000_000)) # без промежуточного списка
Здесь sum забирает квадраты по одному и складывает — пиковое потребление памяти остаётся крошечным.
Память и потоковая обработка
Вернёмся к примеру с большим файлом. Объект файла в Python сам по себе итератор по строкам, поэтому ленивую обработку можно выстроить цепочкой генераторов:
def read_lines(path: str):
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
# собираем конвейер — ничего ещё не прочитано
lines = read_lines("app.log")
errors = only_errors(lines)
# данные текут через конвейер только сейчас, по одной строке
for line in errors:
print(line)
Это и есть потоковая обработка: данные «текут» через цепочку преобразований, и в памяти в каждый момент находится один элемент, а не весь набор. Так можно обработать файл, который физически не помещается в оперативную память.
itertools — готовые инструменты
В стандартной библиотеке есть модуль itertools — набор готовых ленивых итераторов. Несколько часто полезных:
import itertools
# islice — взять срез из итератора (в т.ч. бесконечного)
first_five = itertools.islice(naturals(), 5)
print(list(first_five)) # [1, 2, 3, 4, 5]
# chain — склеить несколько последовательностей в одну
combined = itertools.chain([1, 2], [3, 4])
print(list(combined)) # [1, 2, 3, 4]
# count — бесконечный счётчик
for i in itertools.islice(itertools.count(0, 10), 3):
print(i) # 0, 10, 20
Все они работают лениво и не создают промежуточных коллекций — это та же идея экономии памяти, упакованная в готовые функции.
Глубже: yield from, return в генераторе и уборка при closeрасширенное
Когда генератор отдаёт элементы другого итерируемого, цикл for ... yield заменяет
yield from:
живой пример
def flatten(groups):
for group in groups:
yield from group # то же, что for item in group: yield item
print(list(flatten([[1, 2], [3]]))) # [1, 2, 3]
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
return внутри генератора не запрещён: он завершает перебор, а его значение попадает в
StopIteration.value и становится результатом выражения yield from. Так генератор может
вернуть итог, например число обработанных строк.
Генератор может держать ресурс — открытый файл, соединение. Пока его читают, всё хорошо, но
если перебор прервали на середине, тело функции так и останется «замороженным». Метод
gen.close() возбуждает внутри него GeneratorExit, и блок finally или with внутри генератора отрабатывает. Сборщик мусора сделает это сам, когда объект исчезнет, но для долгоживущих генераторов лучше закрывать явно или перебирать до конца.
Два приёма напоследок. itertools.batched(rows, 500) (с версии 3.12) режет поток на пачки
заданного размера — ровно то, что нужно для вставки в базу порциями. И помните, что любая
функция, пробежавшая по генератору целиком — sum, list, in — исчерпывает его: второй
sum по тому же объекту вернёт ноль.
Коротко
- Iterable можно перебрать в
for(есть__iter__); iterator отдаёт элементы по одному (есть__next__) и бросаетStopIteration, когда они кончаются. - Генератор — функция с
yield; Python сам делает из неё итератор, сохраняя состояние между вызовами. - Вызов генератора не выполняет тело сразу — код идёт лениво, по запросу следующего элемента; так можно описывать даже бесконечные последовательности.
- Генератор одноразовый: пройти по нему повторно нельзя, нужно создавать заново.
- Генераторное выражение
(... for ...)— ленивый аналог спискового включения; экономит память на больших объёмах. - Цепочки генераторов дают потоковую обработку: в памяти один элемент за раз, что позволяет обрабатывать данные больше доступной памяти.
itertools— готовые ленивые инструменты (islice,chain,countи другие).yield fromделегирует вложенному итерируемому,returnв генераторе становится значениемStopIteration,close()запускаетfinally;itertools.batchedрежет поток на пачки.
Что почитать дальше
- Структуры данных — списки, словари и множества, которые чаще всего перебирают итераторами.
- Исключения и контекстные менеджеры — как устроен
withи обработкаStopIterationи других исключений. - Аннотации типов — как типизировать генераторы и итераторы через
IteratorиIterableизtyping.