← назад к разделу

Когда вы пишете 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.