Когда вы пишете for item in something, Python внутри использует общий механизм — протокол итератора. Понимание этого механизма открывает доступ к генераторам: способу обрабатывать огромные или бесконечные последовательности данных, не загружая всё в память сразу.
Зачем это вообще нужно
Представьте, что нужно обработать файл логов на 10 ГБ. Если прочитать его целиком в список строк, программа упадёт по нехватке памяти. Но если читать и обрабатывать строки по одной, память почти не расходуется — в каждый момент в ней живёт ровно одна строка.
Короткая формула: итератор отдаёт элементы по одному, по запросу, а не все сразу.
Этот принцип называется ленивыми вычислениями (lazy evaluation): значение вычисляется в тот момент, когда оно действительно понадобилось, а не заранее. Генераторы — главный инструмент ленивости в Python.
Протокол итератора
В Python есть два связанных понятия, которые легко спутать.
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
Все они работают лениво и не создают промежуточных коллекций — это та же идея экономии памяти, упакованная в готовые функции.
Коротко
- Iterable можно перебрать в
for(есть__iter__); iterator отдаёт элементы по одному (есть__next__) и бросаетStopIteration, когда они кончаются. - Генератор — функция с
yield; Python сам делает из неё итератор, сохраняя состояние между вызовами. - Вызов генератора не выполняет тело сразу — код идёт лениво, по запросу следующего элемента; так можно описывать даже бесконечные последовательности.
- Генератор одноразовый: пройти по нему повторно нельзя, нужно создавать заново.
- Генераторное выражение
(... for ...)— ленивый аналог спискового включения; экономит память на больших объёмах. - Цепочки генераторов дают потоковую обработку: в памяти один элемент за раз, что позволяет обрабатывать данные больше доступной памяти.
itertools— готовые ленивые инструменты (islice,chain,countи другие).
Что почитать дальше
- Структуры данных — списки, словари и множества, которые чаще всего перебирают итераторами.
- Исключения и контекстные менеджеры — как устроен
withи обработкаStopIterationи других исключений. - Аннотации типов — как типизировать генераторы и итераторы через
IteratorиIterableизtyping.