Итераторы: протокол 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 из него.
# Хорошо - отдельный 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 и удобную работу с любыми источниками.
Мини-задание
- Простой 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
- iter с sentinel:
# Чтение stdin до пустой строки
import sys
# Эмулируем: запрашиваем ввод через input(), останавливаемся на ""
for line in iter(input, ""):
print(f"Got: {line}")
- 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.