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

PYTHON · ITERATORS Фаза 2. Основы языка: Python

Обязательно

Итераторы и генераторы: ленивая обработка данных

Python · yield · itertools

По одному элементу за раз, без загрузки всей последовательности в память.

1. Итератор отдаёт элементы по запросу

1 / 5

__iter__ возвращает итератор, __next__ - следующий элемент, StopIteration завершает цикл. For использует этот протокол.

for item__next__()one item at a timeStopIteration

2. Генератор хранит состояние

2 / 5

yield возвращает значение и приостанавливает функцию. При следующем вызове выполнение продолжается с того же места.

yield 1Pause + resumestate preservedyield 2

3. Память не зависит от длины

3 / 5

Файл на 10 ГБ читается строка за строкой. В памяти находится текущая строка, а не весь список.

line 1line 2line 3Processbounded memoryOutput

4. Выражение создаёт генератор

4 / 5

(x * x for x in items) не создаёт list. Значения вычисляются только при итерации.

[1, 2, 3]x * x for xlazy expression1 → 4 → 9

5. itertools ограничивает и связывает

5 / 5

islice берёт часть бесконечного потока, chain соединяет последовательности, groupby группирует подряд.

islicebounded windowchainjoin streamsgroupbyconsecutive groups

Когда вы пишете for item in something, Python внутри использует общий механизм — протокол итератора. Понимание этого механизма открывает доступ к генераторам: способу обрабатывать огромные или бесконечные последовательности данных, не загружая всё в память сразу.

Зачем это вообще нужно

Представьте, что нужно обработать файл логов на 10 ГБ. Если прочитать его целиком в список строк, программа упадёт по нехватке памяти. Но если читать и обрабатывать строки по одной, память почти не расходуется — в каждый момент в ней живёт ровно одна строка.

Короткая формула: итератор отдаёт элементы по одному, по запросу, а не все сразу.

Этот принцип называется ленивыми вычислениями (lazy evaluation): значение вычисляется в тот момент, когда оно действительно понадобилось, а не заранее. Генераторы — главный инструмент ленивости в Python.

Протокол итератора

В Python есть два связанных понятия, которые легко спутать.

for x in items цикл зовёт __iter__ it = iter(items) на каждом витке __next__ x = next(it) элементы кончились: цикл выходит StopIteration

Цикл 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 ловит его автоматически и просто завершается. Поэтому обычный код этого исключения не видит.

Можно написать свой итератор как класс — это показывает, что внутри нет магии:

live example

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
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Это работает, но кода много: нужно вручную хранить состояние (self.current) и бросать StopIteration. Генераторы делают то же самое в разы короче.

Генераторы и yield

Генератор — это функция, которая вместо return использует ключевое слово yield. Каждый yield отдаёт одно значение и «замораживает» функцию до следующего запроса.

Тот же счётчик в виде генератора:

live example

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
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Никакого класса, __next__ и StopIteration — Python создаёт их за вас. Состояние (current) сохраняется между вызовами автоматически.

Ключевая особенность: вызов count_up(1, 4) не выполняет тело функции. Он возвращает объект-генератор. Код начинает работать только когда вы запрашиваете первый элемент:

gen = count_up(1, 4)     # тело ещё не выполнялось
print(next(gen))         # 1  — выполнилось до первого yield
print(next(gen))         # 2  — продолжилось с места остановки

Именно поэтому генераторы умеют описывать бесконечные последовательности — они не пытаются вычислить всё сразу:

live example

def naturals():
    n = 1
    while True:           # бесконечный цикл — это нормально для генератора
        yield n
        n += 1

gen = naturals()
print(next(gen))          # 1
print(next(gen))          # 2
# можно брать значения сколько нужно, последовательность «не кончается»
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Важное ограничение: генератор одноразовый. Пройдя по нему один раз, второй раз вы получите пустоту — состояние уже дошло до конца. Если нужно перебрать заново, создайте генератор снова.

Генераторные выражения

Для простых случаев есть компактный синтаксис — генераторное выражение. Оно похоже на списковое включение (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:

live example

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]
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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 режет поток на пачки.

Что почитать дальше