Итераторы в Python

Концепция итераторов никоим образом не специфична для Python. В самом общем виде это объект, который используется для перебора в цикле последовательности элементов. Однако разные языки программирования реализуют данную концепцию по-разному или не реализуют вовсе. В Python каждый цикл for использует итератор, в отличие от многих других языков. В данной статье мы поговорим про итераторы в Python. Кроме того, мы рассмотрим итерируемые объекты (англ. iterables) и т.н. nextables.

Итерируемые объекты

Итерируемый объект – это объект, который можно проитерировать, т.е. пройтись по элементам объекта в цикле. Например, итерируемым объектом может быть список или кортеж. В Python, чтобы объект был итерируемым, он должен реализовывать метод __iter__. Этот метод-болванка принимает в качестве входных данных только экземпляр объекта — self — и должен возвращать объект-итератор. Вы можете использовать встроенную функцию iter, чтобы получить итератор итерируемого объекта.

Обратите внимание, что итерируемый объект не обязательно является итератором. Поскольку на самом деле сам по себе он не выполняет итерацию. У вас может быть отдельный объект-итератор, который возвращается из итерируемого класса, а не класс, обрабатывающий свою собственную итерацию. Но об этом позже.

Итераторы

Перейдем к собственно итераторам, рабочей лошадке итерации (особенно в Python). Итераторы – это уровень абстракции, который инкапсулирует знания о том, как брать элементы из некоторой последовательности. Мы намеренно объясняем это в общем виде, поскольку «последовательность» может быть чем угодно, от списков и файлов до потоков данных из базы данных или удаленного сервиса. В итераторах замечательно то, что код, использующий итератор, даже не должен знать, какой источник используется. Вместо этого он может сосредоточиться только на одном, а именно: «Что мне делать с каждым элементом?».

[python_ad_block]

Итерация без итератора

Чтобы лучше понять преимущества итераторов, давайте кратко рассмотрим итерацию без итераторов. Примером итерации без итератора является классический цикл for в стиле C. Этот стиль существует не только в C, но и, например, в C++, go и JavaScript.

Пример того, как это выглядит в JavaScript:

let numbers = [1,2,3,4,5]
for(let i=0; i < numbers.length; i++){
    // Extract element
    const current_number = numbers[i]
    // Perform action on element
    const squared = current_number ** 2
    console.log(squared)
}

Здесь мы видим, что данный тип цикла for должен работать как с извлечением, так и с действиями для каждого элемента.

Все циклы for в Python используют итераторы

В Python нет циклов for в стиле C. А циклы for в Python-стиле напоминают циклы for each в других языках. Это тип цикла, в котором используются итераторы. То есть каждый цикл for, который вы пишете на Python, должен использовать итератор.

Сначала давайте посмотрим на Python-эквивалент предыдущего примера, наиболее близкий к нему синтаксически:

numbers = [1,2,3,4,5]
for i in range(len(numbers)):
    number = numbers[i]
    squared = number**2
    print(squared)

Да, очевидно, что нужно было просто перебирать numbers, но мы хотели приблизить синтаксис к циклу for в JavaScript. Здесь нам нужно выполнить извлечение самостоятельно, поэтому мы не используем итератор по числам. Вместо этого мы создаем диапазон, который проходится по индексам чисел (итератор). Это относительно близко к циклу for на JavaScript, но этот код все равно работает на более высоком уровне абстракции.

Если вы внимательно посмотрите на пример на JavaScript, вы увидите, что мы сообщаем циклу, когда нужно завершить (i < numbers.length), а также — как инкременировать (i++). Итак, чтобы приблизить код Python к такому уровню абстракции, нам нужно написать что-то вроде этого:

numbers = [1,2,3,4,5]
i = 0
while i < len(numbers):
    number = numbers[i]
    squared = number**2
    print(squared)
    i += 1

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

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

В документации Python итератор определяется как класс, реализующий __next__ и __iter__. По этому определению итератор также является итерируемым объектом (iterable), поскольку он реализует __iter__. Кроме того, можно сказать, что это nextable-объект, поскольку он реализует метод __next__.

Отметим, nextable – это не часто используемый термин, потому что его можно запросто превратить в итератор. Как видите, метод __iter__ для итераторов легко реализовать. Фактически, в определении итератора явно указано, что должен делать метод:

class MyABCIterator:
    ...
    def __iter__(self):
        return self

Вот и все, метод просто возвращает ссылку на сам итератор. Итак, если вы скопируете этот код __iter__ в nextable, вы получите итератор. Мы назвали класс MyABCIterator, поскольку мы встроим его в итератор, который выполняет итерацию по алфавиту.

Теперь давайте превратим это в итератор, сделав «некстабельным». Метод __next__ должен возвращать следующий объект в последовательности. Он также должен вызывать StopIteration при достижении конца последовательности (т.н. «исчерпание итератора»). То есть, в нашем случае — когда мы дошли до конца алфавита.

Воспользуемся удобной функцией ascii_lowercase, импортированной из модуля string стандартной библиотеки Python. Получаем строку, состоящую из строчных букв английского алфавита.

>>> from string import ascii_lowercase
>>> ascii_lowercase
'abcdefghijklmnopqrstuvwxyz'

Хорошо, теперь давайте посмотрим на код нашего класса, а затем мы объясним, как он работает:

from string import ascii_lowercase
class MyABCIterator:
    def __init__(self):
        self.index = 0
    def __next__(self):
        if self.index >= len(ascii_lowercase):
            raise StopIteration()
        char = ascii_lowercase[self.index]
        self.index +=1
        return char
    def __iter__(self):
        return self

Чтобы знать, какой символ возвращать при каждом вызове __next__, нам понадобится индекс. Поэтому мы добавляем __init__ в наш класс, где инициализируем self.index нулем. Далее при каждом вызове __next__ мы сначала проверяем, достигли ли мы конца алфавита. Если индекс выходит за пределы строки, мы вызываем StopIteration, как указано в документации Python. Затем мы извлекаем текущий символ и увеличиваем self.index. В противном случае мы бы начали с b вместо a. Наконец, мы увеличиваем индекс и возвращаем ранее извлеченный символ.

Теперь давайте попробуем сделать это через цикл for:

>>> for char in MyABCIterator():
...    print(char)
a   
b   
c   
d   
e   
...

Мы обрезали вывод, потому что алфавит сейчас не так интересен, не правда ли? Этот итератор, как и следовало ожидать, совершенно бесполезен. Мы могли бы просто перебирать ascii_lowercase напрямую. Но, надеемся, на этом примере вы лучше разобрались в итераторах.

Последнее замечание по протоколу итераторов. Обратите внимание, как цикл for выполняет всю работу по использованию протокола. Он автоматически получает итератор, используя __iter__, и многократно перебирает его, используя __next__. Это соответствует всем принципам Python, когда магические методы не используются напрямую, а скорее являются способом подключиться к синтаксису Python или функциям верхнего уровня.

Nextables

Теперь, когда мы правильно реализовали наш собственный итератор, давайте немного поэкспериментируем. Первое, что нам хотелось бы узнать, это как Python относится к идее цикла по nextable?

Для этого удалим метод __iter__ из предыдущего примера, в результате чего получим следующее:

class MyABCNextable:
    def __init__(self):
        self.index = 0
    def __next__(self):
        if self.index >= len(ascii_lowercase):
            raise StopIteration()
        char = ascii_lowercase[self.index]
        self.index +=1
        return char

Попытка создать цикл for с таким объектом выдает нам TypeError: 'MyABCNextable' object is not iterable. Что, знаете ли, не удивительно. Интерпретатор не может найти __iter__ для вызова и, следовательно, не может создать итератор.

Python отделяет итератор от последовательности

Мы начали экспериментировать со встроенными последовательностями и сделали небольшое забавное открытие. В Python последовательности сами по себе не являются итераторами. Скорее у каждой есть соответствующий класс-итератор, отвечающий за итерацию. Давайте посмотрим на диапазон в качестве примера:

>>> range_0_to_9 = range(10)
>>> type(range_0_to_9)
<class 'range'>

range() возвращает нам объект типа range. Теперь посмотрим, что произойдет, когда мы попытаемся использовать next для такого объекта:

>>> next(range_0_to_9)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'range' object is not an iterator

Мы получили ошибку TypeError: 'range' object is not an iterator. Итак, если объект типа range не является итератором, то что мы получим при использовании iter?

>>> range_0_to_9_iterator = iter(range_0_to_9)
>>> type(range_0_to_9_iterator)
<class 'range_iterator'>

Просто для проверки используем next с range_iterator:

>>> next(range_0_to_9_iterator)
0
>>> next(range_0_to_9_iterator)
1

Здесь мы возвращаем объект range_iterator, который фактически отвечает за выполнение итерации. Видимо, это относится ко всем встроенным типам, включая списки, словари и наборы. Похоже, что в CPython разработчики решили отделить итерацию объекта от самого объекта.

Создание отдельных Iterable и Nextable

Вооружившись этими новыми знаниями об отделении итерируемого объекта от итератора, мы придумали новую идею:

Можем ли мы вернуть nextable из iterable?

Итерация действительно проста:

class MyABCIterable:
    def __iter__(self):
        return MyABCNextable()

Это просто оболочка для нашей следующей таблицы из примера nextable. Затем пишем цикл:

for char in MyABCIterable():
    print(char)

Оно работает! Итак, хотя документация Python говорит, что метод __iter__ должен возвращать итератор (реализуя как __iter__, так и __next__), цикл for этого не требует.

Однако такая установка хрупкая. Возвращаясь к нашему правильно реализованному итератору, этот код будет работать:

my_abc = iter(MyABCIterator())
for char in my_abc:
    print(char)

А код с нашей комбинацией iterable + nextable — нет:

my_abc = iter(MyABCIterable())
for char in my_abc:
    print(char)

Мы получаем TypeError: 'MyABCNextable' object is not iterable. Вот поэтому протокол итератора определен так, как он определен. Это позволяет передавать итератор и по-прежнему перебирать его. Например, как если бы вы сперва создали итератор, а затем передали его в другую функцию. В таком случае наш хак не сработал бы.

Заключение

Давайте подведем итоги! Во-первых, теперь вы знаете, что все циклы for в Python используют итераторы! Кроме того, как мы увидели, итераторы в Python позволяют нам отделить код, выполняющий итерацию, от кода, работающего с каждым элементом. Мы также надеемся, что вы узнали немного больше о том, как работают итераторы в Python и что такое протокол итератора.

Последний вывод, к которому мы пришли, немного более абстрактный и не связан конкретно с итераторами или даже с Python. Может показаться, что код работает хорошо, пока вы не наткнетесь на случай, когда он сломается. Просто попробовав комбинацию iterable + nextable в цикле for, мы бы не обнаружили, что код ломается при передаче итератора.

На этом пока все, и мы надеемся, вам понравился более глубокий взгляд на протокол итераторов в Python!

Успехов в написании кода!

Перевод статьи «Exploring Python: Using and Abusing the Iterator Protocol».