Итераторы: протокол iter/next и iter()

В Python for x in collection это синтаксический сахар. Под капотом работает протокол итерации - стандартный интерфейс через __iter__ и __next__. Понимание этого протокола позволяет создавать собственные итерируемые объекты, работать с бесконечными последовательностями и эффективно обрабатывать большие данные.

Iterable vs Iterator

Это два разных понятия:

  • Iterable - объект, который можно «обойти». Имеет __iter__() возвращающий iterator. Примеры: list, tuple, str, dict, set, range.
  • Iterator - объект состояния итерации. Имеет __next__() возвращающий следующее значение или StopIteration. Получается из iterable через iter().
lst = [1, 2, 3]              # iterable
it = iter(lst)               # iterator

print(next(it))   # 1
print(next(it))   # 2
print(next(it))   # 3
print(next(it))   # StopIteration

iter() это __iter__(), next() это __next__(). for цикл делает это автоматически:

# Что делает for под капотом
it = iter(lst)
while True:
    try:
        x = next(it)
    except StopIteration:
        break
    # тело цикла с x

Создание iterator

Минимальный iterator реализует __iter__ (возвращает self) и __next__:

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        result = self.current
        self.current -= 1
        return result

for x in Countdown(3):
    print(x)   # 3, 2, 1

__iter__ возвращает self - сам объект становится своим итератором. Это типичный паттерн для собственных итераторов.

Iterator истощается

Iterator одноразовый - после прохода до конца повторно итерировать нельзя:

c = Countdown(3)
list(c)   # [3, 2, 1]
list(c)   # [] - уже истощён

Чтобы пройти снова, нужен новый iterator:

def make_countdown():
    return Countdown(3)

list(make_countdown())   # [3, 2, 1]
list(make_countdown())   # [3, 2, 1]

Это отличает iterator от iterable. Список (iterable) можно итерировать многократно - каждый раз создаётся новый iterator из него.

Если хочешь многократную итерацию, разделяй: iterable создаёт новый iterator каждый вызов `__iter__`. Объединение часто приводит к шаткому поведению.
# Хорошо - отдельный iterator
class CountdownIterable:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        return CountdownIterator(self.start)

class CountdownIterator:
    def __init__(self, current):
        self.current = current

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        result = self.current
        self.current -= 1
        return result

Но чаще проще через генератор.

iter() с двумя аргументами

iter() имеет интересную форму с двумя аргументами:

iter(callable, sentinel)

Возвращает iterator, который вызывает callable() без аргументов, пока тот не вернёт sentinel. Удобно для чтения из источника до маркера конца:

# Чтение файла по символу до пустой строки
with open("data.txt") as f:
    for chunk in iter(lambda: f.read(1024), ""):
        process(chunk)

# Чтение из socket до получения "QUIT"
for msg in iter(get_message, "QUIT"):
    handle(msg)

Это редкая, но мощная форма iter().

Реализация через getitem

Если у класса есть __getitem__(self, index), принимающий целые индексы 0, 1, 2... и бросающий IndexError за пределами, Python автоматически делает его итерируемым:

class Squares:
    def __getitem__(self, idx):
        if idx >= 10:
            raise IndexError
        return idx ** 2

for x in Squares():
    print(x)   # 0, 1, 4, 9, ..., 81

Это старый протокол (до появления __iter__). Сейчас предпочтителен __iter__, но getitem работает.

StopIteration - сигнал конца

__next__ должен бросать StopIteration когда значений больше нет:

class Limited:
    def __init__(self, items, limit):
        self.items = iter(items)
        self.limit = limit
        self.count = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.count >= self.limit:
            raise StopIteration
        self.count += 1
        return next(self.items)

for x in Limited([10, 20, 30, 40, 50], 3):
    print(x)   # 10, 20, 30

StopIteration ловится автоматически в for. Если ты сам вызываешь next(iterator), нужно обрабатывать:

it = iter([1, 2])
print(next(it))     # 1
print(next(it))     # 2
try:
    print(next(it))
except StopIteration:
    print("конец")

С Python 3.7 (PEP 479) StopIteration внутри генератора автоматически конвертируется в RuntimeError - чтобы избежать silent скрытия багов.

itertools - набор готовых iterators

Стандартная библиотека itertools даёт мощные iterators:

import itertools

# Бесконечные
itertools.count(start=0, step=1)        # 0, 1, 2, 3, ...
itertools.cycle([1, 2, 3])               # 1, 2, 3, 1, 2, 3, ...
itertools.repeat("x", times=3)           # 'x', 'x', 'x'

# Комбинаторика
itertools.product([1, 2], ["a", "b"])    # (1,'a'), (1,'b'), (2,'a'), (2,'b')
itertools.permutations([1, 2, 3], 2)     # (1,2), (1,3), (2,1), (2,3), ...
itertools.combinations([1, 2, 3], 2)     # (1,2), (1,3), (2,3)

# Группировка и фильтрация
itertools.chain([1, 2], [3, 4], [5])     # 1, 2, 3, 4, 5
itertools.compress([1, 2, 3], [1, 0, 1]) # 1, 3
itertools.takewhile(lambda x: x < 5, [1, 4, 6, 2])  # 1, 4
itertools.dropwhile(lambda x: x < 5, [1, 4, 6, 2])  # 6, 2
itertools.groupby([1, 1, 2, 3, 3, 3])    # (1, [1,1]), (2, [2]), (3, [3,3,3])

# Срезы
itertools.islice(range(10), 2, 8, 2)     # 2, 4, 6

Эти iterators ленивые - не создают всю последовательность в памяти. Отлично работают с большими данными.

Все ленивое - экономия памяти

import sys

# Список - вся последовательность в памяти
nums = [x ** 2 for x in range(1_000_000)]
print(sys.getsizeof(nums))   # ~8 МБ

# Итератор - почти ничего
it = (x ** 2 for x in range(1_000_000))
print(sys.getsizeof(it))     # ~200 байт

# Можно работать одинаково
sum_nums = sum(it)

Многие встроенные функции (sum, min, max, any, all, next) работают с iterators напрямую. Для одноразовой обработки это выгоднее списков.

Iterators в стандартной библиотеке

Функция/типВозвращает
range(n)Iterable (lazy seq из целых)
enumerate(seq)Iterator пар (index, value)
zip(*seqs)Iterator кортежей
map(f, seq)Iterator с применённой функцией
filter(pred, seq)Iterator с фильтрацией
reversed(seq)Iterator в обратном порядке
Generator expression (...)Iterator

В Python 3 это всё ленивые iterators. В Python 2 многие возвращали списки - изменение для экономии памяти.

itertools.tee - разветвление iterator

import itertools

source = iter([1, 2, 3, 4, 5])
a, b = itertools.tee(source, 2)

list(a)   # [1, 2, 3, 4, 5]
list(b)   # [1, 2, 3, 4, 5]

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

Лучше передавать iterable

# Плохо - функция привязана к list
def process(items: list):
    for x in items:
        ...

# Лучше - принимает любой iterable
def process(items: Iterable):
    for x in items:
        ...

Если функция только итерирует - принимай Iterable. Это работает с list, tuple, set, генераторами, итераторами - максимальная гибкость. По сути тот же structural typing, что у Protocol из прошлого урока: важно наличие __iter__, а не конкретный класс.

Распространённые ошибки

1. Использование исчерпанного iterator

it = iter([1, 2, 3])
list(it)   # [1, 2, 3]
list(it)   # [] - истощён, баг

2. Изменение коллекции во время итерации

nums = [1, 2, 3, 4, 5]
for n in nums:
    if n > 2:
        nums.remove(n)   # undefined behavior

Уже обсуждали в уроке 12. Iterate over copy or build new.

3. Множественный проход без перезапуска

def get_data():
    return iter(open("file.txt"))   # iterator

data = get_data()
for line in data: process(line)
for line in data: print(line)   # пусто - истощён

Решение: вернуть iterable (создающий iterator каждый раз) или функцию-factory.

Сравнение с Go

В Go нет общего iterator protocol - каждая коллекция итерируется через range:

nums := []int{1, 2, 3}
for i, v := range nums {
    fmt.Println(i, v)
}

Channels работают как «push-iterator»:

ch := make(chan int)
go func() {
    defer close(ch)
    for i := 1; i <= 3; i++ {
        ch <- i
    }
}()
for v := range ch {
    fmt.Println(v)
}

В Python iterators универсальные через единый protocol. Это даёт композируемость через itertools и удобную работу с любыми источниками.

Мини-задание

  1. Простой iterator:
class Range:
    def __init__(self, start, stop, step=1):
        self.current = start
        self.stop = stop
        self.step = step

    def __iter__(self):
        return self

    def __next__(self):
        if (self.step > 0 and self.current >= self.stop) or \
           (self.step < 0 and self.current <= self.stop):
            raise StopIteration
        result = self.current
        self.current += self.step
        return result

for x in Range(0, 10, 2):
    print(x)   # 0, 2, 4, 6, 8

for x in Range(10, 0, -2):
    print(x)   # 10, 8, 6, 4, 2
  1. iter с sentinel:
# Чтение stdin до пустой строки
import sys

# Эмулируем: запрашиваем ввод через input(), останавливаемся на ""
for line in iter(input, ""):
    print(f"Got: {line}")
  1. itertools для подсчёта:
import itertools

# Группируем элементы по модулю 3
data = sorted([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], key=lambda x: x % 3)
for key, group in itertools.groupby(data, key=lambda x: x % 3):
    print(f"mod 3 = {key}: {list(group)}")

Что дальше

Освоили iterators. В следующем уроке - генераторы: упрощённый способ создания iterators через yield, lazy chains, generator expressions.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.