$ sudo teach IT
МОДУЛЬ 8 · УРОК 2

Создание собственного итератора

В прошлом уроке мы разобрались, как работает протокол итератора изнутри. Теперь мы напишем свой итератор с нуля. Вы поймёте, что скрывается за методами __iter__ и __next__, увидите типичную ловушку, в которую попадают новички, и узнаете, как её обойти правильно.

⏱ ~45 минут 🎓 Для новичков 🐍 Python 3

🔧 Минимальная реализация: что нужно для собственного итератора

Чтобы Python считал ваш объект итератором, ему нужно ровно два метода:

🔁

__iter__(self)

Должен вернуть объект итератора. В простейшем случае — возвращает self (самого себя). Именно этот метод вызывает Python, когда пишешь for x in obj или iter(obj).

➡️

__next__(self)

Должен вернуть следующий элемент. Когда элементов больше нет — обязан поднять raise StopIteration. Именно этот метод вызывает Python при каждом шаге цикла for.

Напишем первый собственный итератор — простой счётчик от 0 до N:

class CounterIterator:
    """Счётчик от 0 до N (не включая N). Это — итератор."""

    def __init__(self, limit):
        self.limit = limit   # до какого числа считать
        self.current = 0     # текущая позиция, начинаем с 0

    def __iter__(self):
        # Итератор обязан иметь __iter__, возвращающий self.
        # Это позволяет использовать его там, где ожидается Iterable.
        return self

    def __next__(self):
        if self.current >= self.limit:
            # Элементов больше нет — поднимаем сигнал об окончании
            raise StopIteration

        value = self.current   # запоминаем текущее значение
        self.current += 1      # сдвигаем позицию вперёд
        return value           # возвращаем запомненное


# Используем как обычный range:
counter = CounterIterator(5)

for number in counter:
    print(number)

# Вывод:
# 0
# 1
# 2
# 3
# 4

💡 Разберём по шагам: когда Python встречает for number in counter, он вызывает counter.__iter__() — получает self (сам объект). Затем на каждом шаге вызывает counter.__next__(). Как только self.current достигает self.limit — метод поднимает StopIteration, и цикл завершается.

Можно также использовать функции iter() и next() вручную — всё работает так же:

counter = CounterIterator(3)

it = iter(counter)      # вызывает counter.__iter__() → возвращает self
print(next(it))         # 0
print(next(it))         # 1
print(next(it))         # 2
# print(next(it))       # StopIteration — счётчик исчерпан

🚫 Антипаттерн: один класс — и Iterable, и Iterator одновременно

Наш CounterIterator — это уже и Iterable (есть __iter__), и Iterator (есть __next__) одновременно. На первый взгляд кажется удобным: один класс, всё в одном месте. Но у такого подхода есть серьёзная проблема.

⚠️ Проблема: второй for-цикл будет пустым!

Итератор хранит состояние — текущую позицию. Как только он прошёл до конца, он «исчерпан». Если вы попробуете обойти его вторым циклом — он сразу получит StopIteration и выведет ноль элементов.

# АНТИПАТТЕРН — демонстрация проблемы

class BadRange:
    """Плохой пример: один класс — и Iterable, и Iterator."""

    def __init__(self, limit):
        self.limit = limit
        self.current = 0     # состояние — в самом объекте

    def __iter__(self):
        return self          # возвращаем СЕБЯ — вот в чём проблема

    def __next__(self):
        if self.current >= self.limit:
            raise StopIteration
        value = self.current
        self.current += 1
        return value


bad = BadRange(3)

# Первый цикл — работает отлично
print("Первый цикл:")
for x in bad:
    print(x)
# Вывод: 0, 1, 2

# Второй цикл — ПУСТОЙ! Итератор уже исчерпан.
print("Второй цикл:")
for x in bad:
    print(x)
# Вывод: (ничего)

# Почему? Потому что bad.current уже равен 3.
# Когда for вызывает iter(bad), получает ТOGO ЖЕ САМОГО bad.
# А bad.__next__() сразу бросает StopIteration — позиция не сброшена.
print(f"bad.current = {bad.current}")  # 3 — не сбросился в 0!

🔴 Это скрытый баг: код не падает с ошибкой — он просто тихо пропускает данные. Вы ожидаете вывода, а получаете пустоту. Такие ошибки очень трудно найти в большом проекте.

Та же проблема возникнет, если передать такой объект в несколько мест одновременно — например, в две функции, которые обе перебирают элементы: одна из них получит все данные, вторая — пустой результат.

✅ Правильный подход: разделить Iterable и Iterator

Решение простое и элегантное: один класс хранит данные (Iterable), а другой — отслеживает позицию (Iterator). Каждый раз, когда нужен новый обход, создаётся свежий итератор.

📚 Аналогия: книга и закладка

Книга (Iterable) — хранит все страницы. Она не помнит, где вы остановились. Её можно читать заново сколько угодно раз.
Закладка (Iterator) — помнит текущую страницу. Каждый читатель берёт свою закладку. Можно иметь несколько закладок в одной книге одновременно.

class NumberRangeIterator:
    """
    ИТЕРАТОР: хранит текущую позицию, умеет двигаться вперёд.
    Создаётся заново каждый раз, когда нужен новый обход.
    """

    def __init__(self, start, stop):
        self.current = start   # начальная позиция
        self.stop = stop       # до какого числа (не включая)

    def __iter__(self):
        # Итератор тоже должен быть итерируемым — возвращает себя.
        return self

    def __next__(self):
        if self.current >= self.stop:
            raise StopIteration
        value = self.current
        self.current += 1
        return value


class NumberRange:
    """
    ITERABLE: хранит параметры диапазона (start, stop).
    При каждом for — создаёт НОВЫЙ итератор.
    Можно обходить сколько угодно раз!
    """

    def __init__(self, start, stop):
        self.start = start
        self.stop = stop

    def __iter__(self):
        # Ключевой момент: создаём НОВЫЙ итератор каждый раз!
        return NumberRangeIterator(self.start, self.stop)


# Создаём Iterable-объект один раз
numbers = NumberRange(1, 4)

# Первый цикл — работает
print("Первый цикл:")
for x in numbers:
    print(x)
# Вывод: 1, 2, 3

# Второй цикл — тоже работает с начала!
print("Второй цикл:")
for x in numbers:
    print(x)
# Вывод: 1, 2, 3  — снова с начала, как будто первого цикла не было

# Параллельные итераторы — каждый независим:
it1 = iter(numbers)   # новый итератор
it2 = iter(numbers)   # ещё один новый итератор

print(next(it1))      # 1
print(next(it1))      # 2
print(next(it2))      # 1  — it2 начал с начала, не зависит от it1!
print(next(it1))      # 3
print(next(it2))      # 2

🎯 Ключевое отличие: в NumberRange.__iter__ мы пишем return NumberRangeIterator(...) — создаём новый объект. В плохом варианте мы писали return self — возвращали тот же объект. Это одно маленькое отличие — а разница в поведении огромная.

Именно так работают все встроенные коллекции Python: list, range, str — это Iterable-объекты. Когда вы пишете for x in my_list, Python создаёт новый list_iterator каждый раз.

🗂️ Практический пример: своя коллекция слов

Теперь применим паттерн к реальной задаче. Создадим класс Words — коллекцию слов, которую можно перебирать в цикле. Она будет правильным Iterable с отдельным Iterator.

class WordsIterator:
    """Итератор для коллекции Words. Хранит текущую позицию."""

    def __init__(self, words_list):
        self._words = words_list   # ссылка на список слов
        self._index = 0            # текущий индекс

    def __iter__(self):
        return self

    def __next__(self):
        if self._index >= len(self._words):
            raise StopIteration

        word = self._words[self._index]
        self._index += 1
        return word


class Words:
    """
    Коллекция слов — правильный Iterable.
    Поддерживает добавление слов и многократный обход.
    """

    def __init__(self):
        self._data = []   # внутренний список слов

    def add(self, word):
        """Добавить слово в коллекцию."""
        self._data.append(word)

    def __len__(self):
        """Количество слов (бонус: поддержка len())."""
        return len(self._data)

    def __iter__(self):
        """Создаём новый итератор при каждом обходе."""
        return WordsIterator(self._data)


# Создаём коллекцию и наполняем её
vocabulary = Words()
vocabulary.add("питон")
vocabulary.add("класс")
vocabulary.add("объект")
vocabulary.add("метод")
vocabulary.add("итератор")

print(f"Слов в коллекции: {len(vocabulary)}")  # 5

# Первый обход
print("\nПервый обход:")
for word in vocabulary:
    print(f"  - {word}")

# Второй обход — с начала!
print("\nВторой обход:")
for word in vocabulary:
    print(f"  - {word}")

# Ищем слово (используем итерацию)
print("\nПоиск слов с буквой 'о':")
for word in vocabulary:
    if "о" in word:
        print(f"  Найдено: {word}")

# Параллельные обходы независимы
print("\nПараллельные итераторы:")
it_a = iter(vocabulary)
it_b = iter(vocabulary)

print(next(it_a))   # питон
print(next(it_b))   # питон  — it_b начал независимо
print(next(it_a))   # класс
print(next(it_a))   # объект
print(next(it_b))   # класс  — it_b отстаёт, но независим

🌟 Обратите внимание: класс Words совсем не знает о том, как происходит итерация. Он просто хранит данные и делегирует итерацию классу WordsIterator. Это хорошее разделение ответственностей.

⚖️ Когда один класс — нормально, а когда нет

Теперь вы знаете оба подхода. Давайте чётко обозначим, когда какой применять:

Ситуация Один класс Два класса
Одноразовый поток данных (файл, сетевой поток) ✅ Подходит Избыточно
Коллекция, которую обходят несколько раз 🚫 Второй for пустой! ✅ Правильно
Два for-цикла по одному объекту одновременно 🚫 Один получит пустоту ✅ Независимы
Передача объекта в несколько функций 🚫 Опасно ✅ Безопасно

📌 Практическое правило: если вы делаете итератор для одноразового прохода (генератор данных, чтение файла) — один класс нормален. Если вы делаете коллекцию — всегда разделяйте на два класса. Сомневаетесь? Разделяйте — это никогда не сломает код.

🗺️ Полная картина: как Iterable и Iterator работают вместе

Посмотрим на полный процесс с нашим NumberRange и NumberRangeIterator:

# Схема взаимодействия при for x in NumberRange(1, 4):

# 1. Python вызывает iter(numbers)
#    → numbers.__iter__()
#    → создаётся NumberRangeIterator(1, 4), current=1

# 2. Python вызывает next(iterator)
#    → iterator.__next__()
#    → current=1, возвращает 1, current становится 2

# 3. Тело цикла выполняется: print(1)

# 4. Python вызывает next(iterator) снова
#    → current=2, возвращает 2, current становится 3

# 5. Тело цикла: print(2)

# 6. Python вызывает next(iterator) снова
#    → current=3, возвращает 3, current становится 4

# 7. Тело цикла: print(3)

# 8. Python вызывает next(iterator) снова
#    → current=4 >= stop=4 → raise StopIteration

# 9. Python ловит StopIteration → цикл завершён

# Если написать второй for — шаг 1 создаст НОВЫЙ итератор
# с current=1, и всё повторится сначала!
numbers = NumberRange(1, 4)

for x in numbers:          # итератор №1, current: 1→4
    print(x)               # 1, 2, 3

for x in numbers:          # итератор №2, свежий, current: 1→4
    print(x)               # 1, 2, 3  — всё работает!

✅ Итоги урока

  • Минимальный итератор требует двух методов: __iter__ (возвращает self) и __next__ (возвращает следующий элемент или поднимает StopIteration).
  • Антипаттерн: если один класс — и Iterable, и Iterator (возвращает self из __iter__), второй цикл for будет пустым — итератор уже исчерпан.
  • Правильный паттерн: два отдельных класса — Iterable хранит данные, Iterator хранит позицию. Метод __iter__ Iterable-класса создаёт новый экземпляр Iterator.
  • Это позволяет обходить коллекцию сколько угодно раз и иметь несколько независимых итераторов одновременно.
  • Именно так устроены все встроенные коллекции Python: list.__iter__() возвращает новый list_iterator, а не самого себя.

👉 В следующем уроке мы познакомимся с генераторами — особым видом функций, которые делают то же самое, что итераторы, но с несравнимо меньшим количеством кода. Слово yield полностью изменит то, как вы думаете о последовательностях в Python.