Итераторы, генераторы и контекстные менеджеры
Итераторы, генераторы и контекстные менеджеры
Итератор — это объект, который выдаёт элементы по одному и «помнит», на каком месте остановился. Генератор — самый простой способ такой итератор создать.
Оба инструмента помогают работать с большими объёмами данных, не загружая всё в память: элементы вычисляются лениво — только в момент запроса.
Как пагинация. Представим себе книгу вместо стопки распечатанных страниц:
- Стопка страниц (список) — сразу занимает место на столе, нужно знать все страницы заранее.
- Итератор как чтение книги — в руках одна страница, остальные — в источнике. Ты не знаешь заранее, сколько всего страниц, но можешь читать по одной.
Читаем файл в 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хранит ленивый пайплайн, а не готовый список. - Память расходуется экономно, потому что обработка идёт по одной строке.
Код обрабатывает данные по одной строке и не загружает весь файл в память.
Частые ошибки
- Повторный проход по исчерпанному итератору/генератору —
for x in genвторой раз ничего не выдаст. Создайте новый генератор или сохраните данные в список. - Путаница Iterable и Iterator — класс с
__iter__возвращающимselfи__next__одноразовый. Для многоразового обхода возвращайте новый итератор или используйтеyield. - Списковое включение вместо генераторного выражения —
[x for x in huge_data]съест всю память;(x for x in huge_data)— нет. - «Генератор списка» — разговорное название list comprehension. Это не генератор в смысле
yield/(...). - List comprehension ради побочных эффектов —
[print(x) for x in items]работает, но читается как ошибка. Используйте обычныйfor. - Отсутствие
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]
Куда продолжить
- Про условия и циклы, которые питают итерации: Управляющие конструкции
- Про коллекции как источники и приёмники данных: Коллекции
- Про производительность и внутреннее выполнение: Архитектура выполнения и сборка мусора