Генераторы и итераторы в Python — наглядное объяснение

Только начинаете изучать Python? Застряли на генераторах, не можете понять
отличие iterator от iterable? Пугаетесь страшного слова yield? Вам сюда. Здесь
мы наглядно разберём, что из себя представляют генераторы и итераторы,
используя простую и понятную аналогию.

Максимально упрощённая модель, или почему всё так просто

Для начала отвлечёмся от Python и в целом от программирования и рассмотрим
абстрактный пример, который позволит понять саму суть явления.
Представьте себе упаковку с теннисными мячами. Все видели такие?

Упаковка теннисных мячиков

Они представляют из себя цилиндрические контейнеры, в которые один за одним
сложены в стопку теннисные мячи. Это — базовый пример, которым можно описать всю картину работы и использования генераторов и итераторов.

Нам нужно только одно условие в дополнение к этой картине: коробку с мячами нельзя переворачивать, чтобы высыпать их оттуда. Это даёт сразу несколько важных ограничений, которые необходимы для понимания. Во-первых, поскольку диаметр коробки соответствует диаметру мячей, их нельзя оттуда достать так просто, поэтому мы будем использовать для этого специальный инструмент. Пусть он будет выглядеть вот так:

Инструмент для выемки

Во-вторых, мы не сможем достать из коробки все мячи за один раз. Чтобы достать мячик из середины коробки, нам придётся вынуть сначала все мячи, которые лежат на нём. Чтобы достать последний мяч, нужно вынуть все остальные.

Если Вы достаточно живо представили себе такую ситуацию, можете считать, что уже на интуитивном уровне понимаете, как работать с генераторами и итераторами. Осталось перевести всё это в термины программирования и связать синтаксис и термины Python с понятиями и действиями из этой аналогии.

Коробку с мячами можно считать итератором. Её смысл заключается в том, чтобы позволять доставать своё содержимое поочерёдно, в строгом порядке, по одному элементу (мячу) за одну операцию. Эту операцию вынимания мяча можно назвать итерацией. После того, как последний элемент будет извлечён, итератор становится бесполезен, так как больше не содержит ничего.

Генератором можно считать фабрику, которая производит такие коробки, наполняет их мячами и продаёт их вместе с инструментами для извлечения мячей; каждая коробка полностью идентична другой такой же в момент продажи, и из каждой можно извлечь один и тот же набор мячей в одном и том же порядке.

Время рассмотреть, как такие конструкции выглядят в коде.

def tennis_ball_factory():
    quantity = 4  # Количество мячей в упаковке
    while quantity > 0:
        yield "tennis ball"
        quantity -= 1

Эта функция является генератором. Когда мы её вызываем, мы получаем итератор — искомую упаковку теннисных мячей. Каждый вызов создаёт новую упаковку с полным набором. Подробный синтаксис функции и почему он выглядит именно так мы рассмотрим во второй части статьи, сейчас нам достаточно понимать, что генераторы в Python выглядят подобным образом (на самом деле не только, но об этом тоже будет рассказано ниже) — имеют в своём теле ключевое слово yield.

Давайте создадим упаковку с мячами и посмотрим, как мы с ней можем работать.

pack = tennis_ball_factory()
print(next(pack))  # tennis ball
print(next(pack))  # tennis ball
print(next(pack))  # tennis ball
print(next(pack))  # tennis ball
print(next(pack))  # Выбросит исключение StopIteration

Созданный объект pack представляет из себя итератор. В этом примере мы достали из коробки-итератора 4 теннисных мяча и попытались достать пятый, но получили закономерную ошибку: мячи в коробке закончились, и далее, сколько бы мы ни пытались достать что-то из этой коробки, мы будем получать ту же самую ошибку. Функция next(), как можно понять, это тот самый иструмент, с помощью которого можно что-то из коробки достать.

Есть ещё один сценарий работы с итераторами — цикл for. Посмотрим, как он выглядит:

second_pack = tennis_ball_factory()
for ball in second_pack:
    print(ball)

Здесь мы достаём мячики, пока можем. После этой операции итератор second_pack тоже останется пустым. Также стоит заметить, что нам потребовалось создать его, потому что первый итератор pack мы использовать уже не можем — он пуст.

С тем, как создание генераторов и итераторов и работа с ними выглядит внешне, мы разобрались. Однако программирование не было бы программированием, если бы нельзя было рассмотреть какой-то механизм ближе и подробнее и найти больше интересных деталей, и во второй части мы именно это и сделаем.

Заглядываем внутрь, или почему всё не так просто (немного)

На самом деле, условие невозможности переворачивания коробки выглядит несколько нелепо и только усложняет задачу. Странно было бы видеть нечто подобное в реальном мире.

Программирование, несмотря на некоторые тонкости, тоже не очень любит быссмысленные ограничения. Каждый механизм создавался с определённой целью, и генераторы — не исключение.

Задача, которую они решают — экономия памяти. Достигается это с помощью так называемых ленивых вычислений: на самом деле в каждый конкретный момент времени в итераторе содержится ровно один элемент (кроме тех случаев, когда итератор отдал все элементы, которые мог отдать, и в нём не находится ничего). Возвращаясь к аналогии с мячиками, представим непрозрачную упаковку. Таким образом мы можем видеть только верхний мячик, и лишь предполагаем, что под ним должен или не должен скрываться ещё один, определяя это по размеру упаковки. Давайте включим солипсизм и представим немного магии — предположим, что на самом деле существует только тот мяч, который мы непосредственно видим внутри упаковки, а если достав его мы увидели ещё один — значит, он появился ровно в момент изъятия предыдущего.

В реальной жизни, конечно, такое невозможно. Но программирование позволяет нам реализовывать очень много вещей, невозможных в реальной жизни, и именно так работает итератор. Вернёмся к коду функции-генератора из первой части и разберёмся, как именно она устроена внутри.

def tennis_ball_factory():
    quantity = 4  # Количество мячей в упаковке
    while quantity > 0:
        yield "tennis ball"
        quantity -= 1

В строке 2 переменной quantity мы указали максимальное количество итераций, которые сможет сделать наш итератор; говоря точнее, мы создали счётчик итераций, который при каждой итерации уменьшается в строке 5. Цикл while ответственен за итерирование, а также имеет ограничительное условие, которое разрешает циклу повторяться только пока количество мячей в упаковке больше нуля.

Всё дело заключается в том, что ключевое слово yield не прерывает выполнение функции, несмотря на внешнюю схожесть с return, хотя точно так же как и return возвращает из функции некое значение. После этого возврата выполнение функции приостанавливается, пока из итератора не будет запрошен следующий элемент — в тот момент выполнение продолжится ровно с того места, где прервалось, сохраняя состояние всех объектов и переменных внутри функции. Если же у итератора не запросят следующий шаг, функция не продолжит работу и просто не будет создавать объект который ей полагается вернуть; этим она отличается от обычной функции с return, которая в любом случае доведет своё выполнение до конца. Если из итератора с большим количеством элементов потребуется взять только первые два-три, остальные итерации не будут вычислены и не создадут в памяти возвращаемые объекты. Экономия памяти очевидна.

Python позволяет создавать итераторы не только с помощью функций-генераторов; есть и другие способы.

Мы можем быстро создать итератор из любой перечисляемой последовательности (списка, кортежа, словаря, множества и даже строки) с помощью вызова метода __iter__:

example_list = [1, 2, 3, 4, 5]
iterator = example_list.__iter__()
next(iterator)  # 1
next(iterator)  # 2
next(iterator)  # 3

Также мы можем создать итератор с помощью generator expression:

iterator = (i*2 for i in [1, 2, 3, 4])
next(iterator)  # 2
next(iterator)  # 4

Это очень похоже на list comprehensions, но вычисляет значения только при их запросе.

Наконец, мы можем сконструировать итератор как класс, определив у него методы __iter__ и __next__. Впрочем, об этом, как и подробнее о двух вышеперечисленных способах, Вы уже вполне можете прочитать более подробные статьи, написанные уже чисто техническим языком — теперь их понимание не должно составлять труда.

За иллюстрации огромное спасибо Maya Benz