Итераторы#

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

В отличие от других языков программирования в Python цикл for работает только с итерируемыми сущностями. Он не работает, например, с целочисленными счётчиками напрямую, как это происходит в C++, к примеру.

Important

Концепция итератора - это одна из ключевых особенностей Python.

Итерируемый объект - это любой объект, реализующий протокол итератора. Этот протокол лежит в основе любого итерируемого типа: списка, кортежа, словаря, множества, очереди,стека, генератора и др.

Important

В Python вы встретите как итерируемые (Iterable), так и объекты типа Sequence. Тип Sequence является производным от Iterable, расширяя последний поддержкой индексации, т.е. к элементам объекта типа Sequence можно обращаться по индексу. Классический пример - массивы. В остальном же для Sequence справедливо всё то, что справедливо и для Iterable. Пример ниже.

# Список поддерживает индексацию,
# т.к. имеет тип Sequence
a_list = [1, 2, 3, 4, 3, 1]
print("a_list[1] =", a_list[1])
a_list[1] = 2
# Подключим функцию cprint для цветного вывода ошибок
from termcolor import cprint

# Но множество индексацию не поддерживает
a_set = set(a_list)
try:
    print(a_set[1])
except Exception as ex:
    # Попытка обращения по индексу приводит к такому исключению
    cprint(f"{ex.__class__.__name__}:", "red", end=" ")
    print(ex, "(множество не индексируется)")
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[2], line 2
      1 # Подключим функцию cprint для цветного вывода ошибок
----> 2 from termcolor import cprint
      4 # Но множество индексацию не поддерживает
      5 a_set = set(a_list)

ModuleNotFoundError: No module named 'termcolor'
# Однако и список, и множество могут использоваться в цикле for,
# т.к. реализуют протокол итератора (унаследованы от Iterable)
print("Список:")
for x in a_list:
    print(x, end=" ")
print("\nМножество:")
for s in a_set:
    print(s, end=" ")
Список:
1 2 3 4 3 1 
Множество:
1 2 3 4 

Note

Заметьте, что set имеет исключительно уникальные элементы, поэтому последние два числа (3 и 1) из списка во множестве не присутствуют.

Примеры стандартных функций, возвращающих итерируемый объект#

Одна из наиболее часто используемых функций, которая возвращает итерируемый объект - это range:

for i in range(5):
    print(i, end=" ")
0 1 2 3 4 

Important

Это принципиально иной подход к реализации цикла for в языке. Хоть i в данном случае похож на простой счётчик - это не так. range возвращает итерируемый объект.

Ни с какими целыми числами цикл for в Python (напрямую) не работает, в отличие, например, от цикла for языка C++: for (int i = 0; i < 5; ++i). Целое же значение генерируется или получается при каждой итерации цикла при применении встроенной функции next к итерируемому объекту (подробности см. Протокол итератора).

Important

Главное преимущество такого подхода - гарантированное исключение ошибки выхода за границы итерируемой сущности (массива, списка и пр.), что является одной из серьёзнейших проблем в низкоуровневых языках.

С помощью range можно создать список, например, записав списковое выражение, по сути своей являющееся построением списка из генератора (см. раздел о генераторах):

# Либо так (это не путь Python)
a = []
for x in range(5):
    a.append(x*x)
# Либо списковым выражением (путь Python)
b = [x*x for x in range(5)]
print(f"a = {a}, b = {b}")
a = [0, 1, 4, 9, 16], b = [0, 1, 4, 9, 16]

Note

Второй способ, к тому же, гораздо быстрее выполняется.

Ещё один пример - функция enumerate. Будучи применённой к переданному итерируемому объекту, она возвращает новую итерируемую сущность, которая при итерировании возвращает пару (индекс, элемент). Для начала о том, как делать не стоит:

# Пусть есть некоторый список
cubes = [x**3 for x in range(5)]
# Способ C++, но не Python
for i in range(len(cubes)):
    print(f"cubes[{i}] = {cubes[i]}", end="; ")
cubes[0] = 0; cubes[1] = 1; cubes[2] = 8; cubes[3] = 27; cubes[4] = 64; 

А вот как делают в Python:

for i, x in enumerate(cubes):
    print(f"cubes[{i}] = {x}", end="; ")
cubes[0] = 0; cubes[1] = 1; cubes[2] = 8; cubes[3] = 27; cubes[4] = 64; 

Note

Обратите внимание, что enumerate возвращает кортеж из двух элементов. В данном случае этот кортеж сразу же распаковывался в цикле for в две переменные i и x.

Последний пример - функция zip, которая помогает в одном цикле обходить сразу несколько колекций. Допустим, есть три списка произвольной длины: a, b и c. Нам нужно вывести сумму соответствующих элементов. Вот способ низкоуровневых языков:

a = [1, 2]          # 2 элемента (самый короткий)
b = [-3, -7, 4, 0]  # 4 элемента
c = [2, -1, -4]     # 3 элемента
for i in range(len(a)):
    print(a[i] + b[i] + c[i])
0
-6

Но почему в range передана длина именно a, а не любого другого массива? В данном случае a оказался самым коротким из массивов. Другие массивы не обошлись полностью, но это не так страшно.

Important

Гораздо хуже было бы, окажись a самым длинным из всех. Тогда индекс i вышел бы за границы остальных (коротких) массивов, а это привело бы к исключению и концу работы интерпретатора. Вот пример, что будет, если в range передать длину большего списка b.

try:
    for i in range(len(b)):
        print(a[i] + b[i] + c[i])
except Exception as ex:
    cprint(f"{ex.__class__.__name__}:", "red", end=" ")
    print(ex)
0
-6
IndexError: list index out of range

Проверять равенство длин всех массивов перед циклом? Плохое решение. Почему? Функция len каждый раз пересчитывает длину, проходя массив полностью. Представьте, что наши три массива содержали бы по миллиону и более элементов, и вопрос отпадёт сам собой.

Функция zip лишена указаных недостатков. Если переданные ей массивы имеют разную длину, то возвращать результат она будет до тех пор, пока не закончится самый короткий список (кортеж, массив и др.). Вот пример:

for ai, bi, ci in zip(a, b, c):
    print(ai + bi + ci)
0
-6

Короткий список - a - имел длину 2, поэтому в результате вывелось 2 числа. При этом мы не задумывались, какой из массивов короткий, чья длина должна быть передана для обхода по индексу.

Note

Функция zip принимает неограниченное число аргументов.

Все рассмотренные функции возвращают итерируемые сущности,другими словами, объекты, реализующими протокол итератора. Так что же это за протокол такой?

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

Рассмотрим, как выполняется цикл for, шаг за шагом. В качестве примера возьмём следующий простой цикл:

for i in range(3):
    print(i)
0
1
2

Что скрыто от наших глаз:

# В начале инициализируется объект range
r = range(3)
# Затем инициализируется итератор по r,
# в результате получается итерируемый объект
r_iter = iter(r)
# Теперь в каждом шаге цикла for к итератору
# применяется стандартная функция next
# - i = 0
print(next(r_iter))
# - i = 1
print(next(r_iter))
# - i = 2
print(next(r_iter))
0
1
2

Мы достигли конца - значения 2. Как цикл for понимает, что пора завершаться? Если ещё раз применить next, то мы получит исключение StopIteration, говорящее о достижении итератором конца итерируемого объекта:

try:
    next(r_iter)
except Exception as ex:
    cprint(ex.__class__.__name__, "red")
StopIteration

Именно так и работает цикл for:

  1. Получает итерируемый объект a.

  2. Преобразует его в итератор a_iter = iter(a).

  3. Применяет к нему функцию next(a_iter) до тех пор, пока не инициируется исключение StopIteration.

В нашем случае объект a имел тип range, т.е. был объектом данного класса. Внутри этого класса определён метод __iter__ (или __getitem__, если необходимо поддерживать индексацию), который вызывается с помощью iter(a) и превращает объект a в итератор a_iter. Можно сказать, что iter(a) то же самое, что и a.__iter__().

Три приведённых выше пункта совместно с реализацией метода __iter__ или __getitem__ - это и есть протокол итератора или протокол итерации.

Пример собственного итерируемого класса#

Создадим класс Range, предназначенный для той же задачи, что и стандартный range:

class Range:
    # Конструктор (инициализатор) объекта данного класса
    def __init__(self, start, stop=None, step=1):
        if stop is None:
            stop = start
            start = 0
        # Внутри каждого объекта будет лежать
        # соответствующий генератор, который
        # в отличие от списка не требует выделения
        # памяти под каждый элемент коллекции
        self.values = range(start, stop, step)
    
    # Функция преобразования объекта данного класса
    # в итератор
    def __iter__(self):
        return iter(self.values)

Note

О генераторах см. главу Генераторы.

Работа с объектами нашего класса ничем не отличается от работы с range:

for i in Range(-3, 8, 2):
    print(i, end=" ")
-3 -1 1 3 5 7 

Таким образом мы и реализовали протокол итерации.

Упражнения#

  1. Опишите стандартные zip и enumerate собственными классами по аналогии с классом Range, рализовав протокол итерации.

См. также#

  1. Более подробно и строго об итераторах и протоколе итерации рассказано здесь.