Перейти к основному содержимому

Итераторы, генераторы и контекстные менеджеры

Разработчику Архитектору

Итераторы, генераторы и контекстные менеджеры

Итератор — это объект, который выдаёт элементы по одному и «помнит», на каком месте остановился. Генератор — самый простой способ такой итератор создать.

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

Как пагинация. Представим себе книгу вместо стопки распечатанных страниц:

  • Стопка страниц (список) — сразу занимает место на столе, нужно знать все страницы заранее.
  • Итератор как чтение книги — в руках одна страница, остальные — в источнике. Ты не знаешь заранее, сколько всего страниц, но можешь читать по одной.

Читаем файл в 10 ГБ — итератор не загружает весь файл в память, а выдаёт строку за строкой. Список бы убил всю память.

Три термина, которые нельзя путать

ТерминЧто этоМетодыМожно пройти for дважды?
Iterable (итерируемый)Объект, по которому можно пройти циклом for__iter__()Да — если каждый раз создаётся новый итератор
Iterator (итератор)Объект, который сейчас перебирает элементы__iter__() + __next__()Нет — одноразовый, после исчерпания пуст
Generator (генератор)Итератор, созданный через yield или (...)те же, что у итератораНет — одноразовый

Каждый генератор — итератор, но не каждый итератор — генератор. Список, строка, кортеж — итерируемые, но не итераторы. Функция с yield — и итератор, и генератор.


Итераторы

Итератор реализует протокол итерации — два метода:

  • __iter__() — возвращает сам объект (self).
  • __next__() — возвращает следующий элемент. Если элементы закончились — выбрасывает StopIteration.

Итератор «помнит» позицию обхода. Именно поэтому он одноразовый: второй проход по тому же объекту ничего не даст.

class MyNumbers:
def __init__(self):
self.num = 1

def __iter__(self):
return self

def __next__(self):
if self.num <= 3:
val = self.num
self.num += 1
return val
raise StopIteration

my_iter = MyNumbers()
print(next(my_iter)) # 1
print(next(my_iter)) # 2
print(next(my_iter)) # 3
# next(my_iter) # StopIteration

Разбор примера:

  • __iter__ возвращает self — объект одновременно и итерируемый, и итератор.
  • __next__ отдаёт значение и сдвигает счётчик; при исчерпании — StopIteration.
  • next(my_iter) — ручной запрос следующего элемента, без цикла for.

Код ITЗагрузка примера кода…

Разбор примера CountDown:

  • __iter__ возвращает self — объект работает как собственный итератор.
  • __next__ выбрасывает StopIteration, когда current < 0.
  • До этого возвращается текущее значение, счётчик уменьшается на 1.
  • CountDownодноразовый итератор: второй for n in CountDown(3) сработает только если создать новый экземпляр.

Iterable и Iterator — главное различие

Итерируемый объект (Iterable) — любой объект, который можно перебрать в for. У него есть __iter__(), который возвращает новый итератор. Примеры: list, str, tuple, dict, set.

Итератор (Iterator) — объект с __iter__() (возвращает self) и __next__(). Он хранит состояние текущего обхода. После исчерпания повторные вызовы next() бросают StopIteration.

data = [10, 20, 30] # Iterable — можно обходить многократно
it = iter(data) # Iterator — одноразовый поток
print(next(it)) # 10
print(next(it)) # 20
# print(next(it)) # 30, затем StopIteration

for x in data: print(x) # работает снова — создаётся новый итератор
for x in it: print(x) # ничего не выведет — итератор уже пуст

Цикл for — синтаксический сахар

Привычный for x in collection Python разворачивает так:

_iterator = iter(collection) # вызов __iter__()
while True:
try:
x = next(_iterator) # вызов __next__()
except StopIteration:
break
# тело цикла

Именно StopIteration — сигнал «элементы закончились». Цикл for перехватывает его автоматически; при ручном next() вы получите исключение сами.

Встроенные функции sum, list, tuple, max и выражение x in collection внутри работают по тому же принципу: iter()next() в цикле.

Три правильных способа реализовать __iter__

Выбор зависит от цели: нужен многоразовый обход (Iterable) или одноразовый поток (Iterator).

Вариант 1 — yield внутри __iter__ (самый частый)

Если объект должен перебираться в for многократно, yield в __iter__ создаёт свежий генератор-итератор при каждом вызове:

class Garage:
def __init__(self):
self.cars = ["Tesla", "BMW", "Audi"]

def __iter__(self):
for car in self.cars:
yield car

my_garage = Garage()
for car in my_garage: print(car) # первый проход
for car in my_garage: print(car) # второй проход — работает

Вариант 2 — делегирование через iter() (самый быстрый)

Если класс оборачивает готовую коллекцию — не изобретайте велосипед:

class Team:
def __init__(self):
self.players = ["Иван", "Сергей", "Анна"]

def __iter__(self):
return iter(self.players)

our_team = Team()
for player in our_team:
print(player)

Вариант 3 — отдельный класс-итератор (классическое ООП)

Сложную логику обхода выносят в отдельный класс. Iterable при каждом __iter__ создаёт новый экземпляр:

class AlphabetIterator:
def __init__(self, letters):
self.letters = letters
self.index = 0

def __iter__(self):
return self

def __next__(self):
if self.index >= len(self.letters):
raise StopIteration
result = self.letters[self.index]
self.index += 1
return result

class Alphabet:
def __init__(self):
self.letters = ["A", "B", "C"]

def __iter__(self):
return AlphabetIterator(self.letters)

abc = Alphabet()
print(list(abc)) # ['A', 'B', 'C']
print(list(abc)) # ['A', 'B', 'C'] — новый итератор каждый раз

Главное правило:

  • Итератор — есть __iter__ (возвращает self) и __next__. Одноразовый.
  • Итерируемый — есть только __iter__, который возвращает другой, свежий объект с __next__. Многоразовый.

Генератор

Генератор — самый простой способ создать итератор. Не нужно вручную писать __iter__ и __next__ — Python делает это за вас, когда видит yield.

Вместо return (который завершает функцию) yield замораживает состояние — локальные переменные, позицию в коде — и отдаёт значение. Следующий next() продолжает с того же места.

Генераторы реализуют ленивую загрузку (lazy evaluation) — значения появляются по мере запроса, а не все сразу.

def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1

gen = count_up_to(3)
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
# next(gen) # StopIteration

Разбор примера:

  • yield возвращает значение и «замораживает» состояние функции.
  • Повторный next(gen) продолжает выполнение с места после yield.
  • Когда условие i <= n становится ложным, генератор завершается.
  • Следующий next(...) после завершения вызывает StopIteration.

Генераторы можно использовать в for:

for value in count_up_to(5):
print(value)

Преимущества ленивой загрузки — экономия памяти (не хранятся все элементы), работа с бесконечными последовательностями, ранний доступ к первому элементу.

Пример бесконечного генератора:

def endless_counter():
n = 0
while True:
yield n
n += 1

Два способа создать генератор

1. Функция-генератор (с yield) — показан выше. Python автоматически оборачивает такую функцию в объект-генератор с __iter__ и __next__.

2. Генераторное выражение (generator expression) — аналог спискового включения, но в круглых скобках. Создаёт генератор в одну строку:

gen_exp = (x * 2 for x in range(1, 4))
for num in gen_exp:
print(num) # 2, 4, 6

Разница между [...] и (...) принципиальна:

  • [x**2 for x in range(1_000_000)] — сразу создаст миллион объектов в памяти.
  • (x**2 for x in range(1_000_000)) — потребляет константный объём памяти.

Выражения-генераторы удобно передавать в функции, ожидающие итератор:

total = sum(x**2 for x in range(10))
data = [10, -5, 8, 3]
max_value = max(x for x in data if x > 0)

Списковые включения — это не генераторы

Списковое включение (list comprehension) — [expr for item in iterable if condition]сразу строит список в памяти. В разговорной речи его иногда называют «генератором списка», но технически это не генератор:

squares = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] — готовый список
evens = [x for x in range(10) if x % 2 == 0]

Разбор примера:

  • Квадратные скобки — результат материализуется сразу, все значения в памяти.
  • if x % 2 == 0 фильтрует элементы (необязательная часть).
  • Компактнее ручного цикла с append, но не ленивое.

Не используйте списковые включения ради побочных эффектов (вызов функций без сохранения результата) — это ухудшает читаемость.

Для сравнения — генераторное выражение:

squares_gen = (x**2 for x in range(5))
print(type(squares_gen)) # <class 'generator'>
print(next(squares_gen)) # 0 — только первый элемент вычислен

Ключевое слово yield from позволяет делегировать часть генерации другой функции-генератору.

def sub_generator():
yield "a"
yield "b"

def main_generator():
yield 1
yield from sub_generator() # вставляет значения
yield 2

for item in main_generator():
print(item) # 1, 'a', 'b', 2

Разбор примера:

  • yield from sub_generator() делегирует выдачу значений вложенному генератору.
  • main_generator сначала отдаёт 1, затем все значения из sub_generator, затем 2.
  • Это эквивалентно циклу for x in sub_generator(): yield x, но короче и чище.

yield from особенно полезен при работе с вложенными итерациями, деревьями, асинхронным кодом (где используется для await-подобного поведения).


Контекстный менеджер

Контекстный менеджер — это объект, определяющий поведение перед входом в блок кода и после его завершения, независимо от того, завершился ли блок нормально или с исключением.

Он реализует протокол:

  • __enter__() — вызывается при входе в блок with.
  • __exit__(exc_type, exc_value, traceback) — вызывается при выходе из блока, даже если произошло исключение.

Основная цель — гарантированное освобождение ресурсов:

  • закрытие файлов;
  • соединений с БД;
  • разблокировка мьютексов;
  • т.п.

Синтаксис with:

with open('file.txt') as f:
content = f.read()
# Файл автоматически закрывается здесь, даже если было исключение

Разбор примера:

  • open(...) возвращает объект файла, который выступает контекстным менеджером.
  • При входе в with вызывается __enter__, при выходе — __exit__.
  • Поэтому ресурс закрывается гарантированно даже при ошибке внутри блока.
  • Это безопаснее ручного open/close.

Эквивалентно:

f = open('file.txt')
try:
content = f.read()
finally:
f.close()

Но with делает код более читаемым и надёжным.


itertools

Модуль itertools предоставляет набор высокоэффективных инструментов для работы с итераторами. Его функции возвращают ленивые итераторы, что делает их идеальными для обработки больших или бесконечных потоков данных.

Бесконечные итераторы:

from itertools import count, cycle, repeat

for n in count(1): # 1, 2, 3, ...
if n > 3: break
print(n)

for c in cycle('AB'): # A, B, A, B, ...
pass

Терминирующие итераторы:

from itertools import takewhile, dropwhile, chain

list(takewhile(lambda x: x < 5, [1, 4, 6, 4, 1])) # [1, 4]
list(chain([1, 2], [3, 4])) # [1, 2, 3, 4]

Комбинаторные итераторы

from itertools import product, permutations, combinations

list(product('AB', repeat=2)) # [('A','A'), ('A','B'), ...]
list(combinations('ABC', 2)) # [('A','B'), ('A','C'), ('B','C')]

Практические шаблоны использования

Эти конструкции особенно полезны в production-коде, где важны память и надёжность:

  • Итераторы — потоковая обработка логов и больших файлов.
  • Генераторы — поэтапные ETL-пайплайны и lazy-преобразования.
  • Контекстные менеджеры — безопасная работа с файлами, сокетами, транзакциями и блокировками.

Пример ленивого пайплайна:

def read_lines(path):
with open(path, "r", encoding="utf-8") as f:
for line in f:
yield line.strip()

def only_errors(lines):
for line in lines:
if "ERROR" in line:
yield line

errors = only_errors(read_lines("app.log"))
for item in errors:
print(item)

Разбор примера:

  • read_lines читает файл построчно и отдаёт строки через yield.
  • only_errors принимает поток строк и пропускает дальше только те, где есть "ERROR".
  • Переменная errors хранит ленивый пайплайн, а не готовый список.
  • Память расходуется экономно, потому что обработка идёт по одной строке.

Код обрабатывает данные по одной строке и не загружает весь файл в память.


Частые ошибки

  1. Повторный проход по исчерпанному итератору/генераторуfor x in gen второй раз ничего не выдаст. Создайте новый генератор или сохраните данные в список.
  2. Путаница Iterable и Iterator — класс с __iter__ возвращающим self и __next__ одноразовый. Для многоразового обхода возвращайте новый итератор или используйте yield.
  3. Списковое включение вместо генераторного выражения[x for x in huge_data] съест всю память; (x for x in huge_data) — нет.
  4. «Генератор списка» — разговорное название list comprehension. Это не генератор в смысле yield / (...).
  5. List comprehension ради побочных эффектов[print(x) for x in items] работает, но читается как ошибка. Используйте обычный for.
  6. Отсутствие with при работе с файлами и ресурсами — «висящие» дескрипторы и утечки.
# Плохо: думаем, что пройдём дважды
gen = (x for x in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # [] — генератор исчерпан

# Хорошо: новый генератор или список
print(list(x for x in range(3))) # [0, 1, 2]

Куда продолжить