Генераторы — одна из самых мощных и недооценённых фич Python. На собеседованиях часто спрашивают про yield, но редко кто может объяснить, что происходит под капотом. Давайте разберёмся глубоко.
Что такое генератор?
Генератор — это функция, которая возвращает итератор. Звучит просто, но за этим скрывается мощная механика ленивых вычислений и управления состоянием.
def simple_generator():
yield 1
yield 2
yield 3
gen = simple_generator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
print(next(gen)) # StopIterationКлючевое отличие от обычной функции: генератор не выполняется сразу. Он возвращает объект-генератор, который можно итерировать.
Механика работы yield
Когда Python встречает yield, происходит магия:
- Приостановка выполнения — функция «замораживается» в текущем состоянии
- Возврат значения — значение из
yieldвозвращается вызывающему коду - Сохранение контекста — все локальные переменные и позиция в коде сохраняются
- Возобновление — при следующем вызове
next()функция продолжает с места остановки
def counter(start=0):
n = start
while True:
print(f"Перед yield: n = {n}")
yield n
print(f"После yield: n = {n}")
n += 1
gen = counter(10)
print(next(gen)) # Перед yield: n = 10
# 10
print(next(gen)) # После yield: n = 10
# Перед yield: n = 11
# 11Важно: код после yield выполняется только при следующем вызове next().
Итераторный протокол
Генераторы автоматически реализуют итераторный протокол. Чтобы понять, как это работает, сравним ручную реализацию итератора и генератор.
Ручной итератор
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
# Сохраняем текущее значение перед уменьшением
value = self.current
self.current -= 1 # Уменьшаем для следующей итерации
return value # Возвращаем сохранённое значение
# Использование
for num in Countdown(5):
print(num) # 5, 4, 3, 2, 1Эквивалентный генератор
def countdown(start):
while start > 0:
yield start
start -= 1
# Использование (идентично)
for num in countdown(5):
print(num) # 5, 4, 3, 2, 1Вывод: генератор — это синтаксический сахар для создания итераторов, но с меньшим количеством кода и автоматическим управлением состоянием.
Преимущества генераторов
1. Экономия памяти (Lazy Evaluation)
Классический пример на собеседованиях:
# Плохо: создаёт список из миллиарда элементов в памяти
def get_numbers_list(n):
return [i for i in range(n)]
numbers = get_numbers_list(1_000_000_000) # MemoryError!
# Хорошо: генератор создаёт элементы по требованию
def get_numbers_gen(n):
for i in range(n):
yield i
numbers = get_numbers_gen(1_000_000_000) # Мгновенно!Замер памяти:
import sys
# Список
lst = [i for i in range(1_000_000)]
print(sys.getsizeof(lst)) # ~8 МБ
# Генератор
gen = (i for i in range(1_000_000))
print(sys.getsizeof(gen)) # ~120 байт2. Бесконечные последовательности
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Можем взять сколько нужно
from itertools import islice
first_10 = list(islice(fibonacci(), 10))
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]3. Pipeline обработки данных
def read_large_file(path):
with open(path) as f:
for line in f:
yield line.strip()
def filter_comments(lines):
for line in lines:
if not line.startswith('#'):
yield line
def parse_data(lines):
for line in lines:
yield line.split(',')
# Композиция генераторов
pipeline = parse_data(filter_comments(read_large_file('data.csv')))
# Обработка по одной строке, без загрузки всего файла
for row in pipeline:
process(row)Продвинутые техники
Send() — двусторонняя коммуникация
Генераторы могут не только возвращать значения, но и принимать их:
def moving_average():
total = 0
count = 0
average = None
while True:
value = yield average
total += value
count += 1
average = total / count
avg = moving_average()
next(avg) # Прогреваем генератор
print(avg.send(10)) # 10.0
print(avg.send(20)) # 15.0
print(avg.send(30)) # 20.0Механика send():
- Отправляет значение в генератор
- Значение становится результатом выражения
yield - Генератор продолжает выполнение до следующего
yield - Возвращает новое значение из
yield
Throw() — обработка исключений
Метод throw() позволяет выбросить исключение внутрь генератора в точке, где он остановился на yield.
def resilient_processor():
print("Генератор запущен")
while True:
try:
print("→ Ждём данные...")
data = yield # ← Исключение выбрасывается ЗДЕСЬ
print(f"→ Получили: {data}")
result = data.upper()
print(f"→ Обработано: {result}")
except ValueError as e:
print(f"✗ Ошибка перехвачена: {e}")
# Цикл продолжается — возвращаемся к началу while True
# Пошаговое выполнение
gen = resilient_processor()
print("\n1. Запускаем генератор:")
next(gen)
# Вывод:
# Генератор запущен
# → Ждём данные...
print("\n2. Отправляем корректные данные:")
gen.send("hello")
# Вывод:
# → Получили: hello
# → Обработано: HELLO
# → Ждём данные...
print("\n3. Выбрасываем исключение:")
gen.throw(ValueError, "Некорректные данные!")
# Вывод:
# ✗ Ошибка перехвачена: Некорректные данные!
# → Ждём данные...
print("\n4. Продолжаем работу:")
gen.send("world")
# Вывод:
# → Получили: world
# → Обработано: WORLD
# → Ждём данные...Ключевая механика:
throw()выбрасывает исключение в точке последнегоyield- Если генератор перехватывает исключение (
try/except) — он продолжает работу - Если не перехватывает — исключение пробрасывается наружу
Практический пример: парсер с error recovery
def robust_parser():
"""Парсер, который продолжает работу даже после ошибок"""
while True:
try:
line = yield
# Парсим JSON
data = json.loads(line)
yield data # Возвращаем распарсенные данные
except json.JSONDecodeError as e:
yield {"error": str(e)} # Возвращаем ошибку как данные
# Продолжаем обработку следующих строк
parser = robust_parser()
next(parser)
next(parser) # Прогрев
result1 = parser.send('{"valid": "json"}')
print(result1) # {'valid': 'json'}
result2 = parser.send('{invalid json}')
print(result2) # {'error': '...'}
result3 = parser.send('{"more": "data"}')
print(result3) # {'more': 'data'}Close() — завершение генератора
Метод close() завершает генератор, выбрасывая внутрь него специальное исключение GeneratorExit. Это нужно для корректной очистки ресурсов.
def resource_handler():
print("→ Открываем ресурс (например, файл)")
try:
while True:
data = yield
print(f"→ Обработка: {data}")
except GeneratorExit:
print("→ Получен сигнал завершения (GeneratorExit)")
raise # Важно! Обязательно пробросить
finally:
print("→ Закрываем ресурс (cleanup)")
# Использование
gen = resource_handler()
next(gen)
# → Открываем ресурс (например, файл)
gen.send("data1")
# → Обработка: data1
gen.send("data2")
# → Обработка: data2
gen.close() # Завершаем работу
# → Получен сигнал завершения (GeneratorExit)
# → Закрываем ресурс (cleanup)
print("Генератор закрыт")Что происходит при вызове close():
- В точке
yieldвыбрасывается исключениеGeneratorExit - Выполняется блок
finally(если есть) - Генератор больше нельзя использовать
Важно: если внутри генератора перехватить GeneratorExit и не пробросить дальше — получите ошибку:
def bad_close_handler():
try:
while True:
yield
except GeneratorExit:
print("Пытаюсь проигнорировать close()")
pass # ❌ Плохо! Не пробрасываем исключение
gen = bad_close_handler()
next(gen)
gen.close() # RuntimeError: generator ignored GeneratorExitПрактический пример: контекстный менеджер на генераторе
from contextlib import contextmanager
@contextmanager
def database_transaction():
print("→ BEGIN TRANSACTION")
try:
yield # Здесь выполняется код пользователя
print("→ COMMIT")
except Exception as e:
print(f"→ ROLLBACK (ошибка: {e})")
raise
finally:
print("→ Закрываем соединение")
# Использование
with database_transaction():
print(" Выполняем SQL запросы...")
# При выходе из with автоматически вызовется close()
# Вывод:
# → BEGIN TRANSACTION
# Выполняем SQL запросы...
# → COMMIT
# → Закрываем соединениеYield from — делегирование генераторов
def sub_generator():
yield 1
yield 2
return "Done with sub"
def main_generator():
result = yield from sub_generator()
print(f"Subgen returned: {result}")
yield 3
gen = main_generator()
print(list(gen))
# Вывод:
# Subgen returned: Done with sub
# [1, 2, 3]yield from делает три вещи:
- Перенаправляет все
yieldиз вложенного генератора - Прокидывает
send()иthrow()во вложенный генератор - Возвращает значение из
returnвложенного генератора
Практический пример: рекурсивный обход дерева
yield from особенно полезен для рекурсивных структур данных.
class Node:
def __init__(self, value, children=None):
self.value = value
self.children = children or []
# Создаём дерево:
# 1
# / \
# 2 3
# / \
# 4 5
tree = Node(1, [
Node(2, [
Node(4),
Node(5)
]),
Node(3)
])Вариант 1: БЕЗ yield from (многословно)
def traverse_tree_manual(node):
# 1. Возвращаем значение текущего узла
yield node.value
# 2. Обходим детей
for child in node.children:
# Проблема: traverse_tree_manual(child) возвращает ГЕНЕРАТОР
# Нужно вручную извлечь из него все значения
for value in traverse_tree_manual(child):
yield value # И каждое значение передать наружу
# Использование
result = list(traverse_tree_manual(tree))
print(result) # [1, 2, 4, 5, 3]Вариант 2: С yield from (лаконично)
def traverse_tree(node):
yield node.value
for child in node.children:
yield from traverse_tree(child) # Делегируем всё подгенератору!
result = list(traverse_tree(tree))
print(result) # [1, 2, 4, 5, 3]Что делает yield from:
yield from some_generator()
# Полностью эквивалентно:
for item in some_generator():
yield itemПростой пример для понимания:
def numbers():
yield 1
yield 2
yield 3
# БЕЗ yield from
def wrapper_manual():
print("До генератора")
for num in numbers(): # Получаем каждое значение
yield num # И передаём его дальше
print("После генератора")
# С yield from
def wrapper_auto():
print("До генератора")
yield from numbers() # Автоматически передаём ВСЕ значения
print("После генератора")
# Оба работают одинаково:
print(list(wrapper_manual())) # До генератора
# [1, 2, 3]
# После генератора
print(list(wrapper_auto())) # До генератора
# [1, 2, 3]
# После генератораБолее сложный пример: фильтрация при обходе
def traverse_tree_filtered(node, predicate):
"""Обходит дерево, возвращая только узлы, удовлетворяющие условию"""
if predicate(node.value):
yield node.value
for child in node.children:
yield from traverse_tree_filtered(child, predicate)
# Получаем только чётные значения
even_values = list(traverse_tree_filtered(tree, lambda x: x % 2 == 0))
print(even_values) # [2, 4]Генераторные выражения
Компактная форма для простых случаев:
# Генераторное выражение
squares = (x**2 for x in range(1000000))
# Эквивалентная функция-генератор
def squares_gen():
for x in range(1000000):
yield x**2Использование в функциях:
# Эффективно: не создаёт промежуточный список
sum_of_squares = sum(x**2 for x in range(1000))
# Неэффективно: создаёт список, затем суммирует
sum_of_squares = sum([x**2 for x in range(1000)])Корутины (до Python 3.5)
До появления async/await генераторы использовались для асинхронного программирования:
def coroutine(func):
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
next(gen) # Прогрев
return gen
return wrapper
@coroutine
def grep(pattern):
print(f"Ищем: {pattern}")
while True:
line = yield
if pattern in line:
print(line)
# Использование
g = grep("python")
g.send("I love python") # Напечатает
g.send("I love java") # Не напечатаетВопросы с собеседований
Итератор — объект с методами __iter__() и __next__(). Требует явного управления состоянием.
Генератор — частный случай итератора, созданный через функцию с yield. Автоматически управляет состоянием.
Пример итератора:
class Counter:
def __init__(self, max):
self.max = max
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current >= self.max:
raise StopIteration
self.current += 1
return self.currentЭквивалентный генератор:
def counter(max):
current = 0
while current < max:
current += 1
yield currentНет! Генератор одноразовый — после исчерпания он не сбрасывается.
gen = (x for x in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # [] — генератор исчерпан!Решение: создавайте новый генератор для каждой итерации.
def make_gen():
return (x for x in range(3))
gen1 = make_gen()
gen2 = make_gen() # Независимый генератор
print(list(gen1)) # [0, 1, 2]
print(list(gen2)) # [0, 1, 2]def mystery():
x = yield 1
print(f"x = {x}")
y = yield 2
print(f"y = {y}")
gen = mystery()
a = next(gen)
b = gen.send(10)
c = gen.send(20)Ответ:
a = 1(первый yield)- Печатает:
x = 10 b = 2(второй yield)- Печатает:
y = 20 cвызоветStopIteration(генератор исчерпан)
Важно: значение из send() становится результатом выражения yield.
def gen_with_return():
yield 1
yield 2
return "Done"
gen = gen_with_return()
print(next(gen)) # 1
print(next(gen)) # 2
try:
print(next(gen))
except StopIteration as e:
print(e.value) # "Done"Важно: return в генераторе:
- Прекращает итерацию (выбрасывает
StopIteration) - Передаёт значение через
StopIteration.value - Используется с
yield fromдля возврата результата
Практическое применение:
def process_data():
yield "item1"
yield "item2"
return "Processed 2 items" # Метаданные о результате
def main():
result = yield from process_data()
print(result) # "Processed 2 items"| Характеристика | return | yield |
|---|---|---|
| Что возвращает | Значение | Генератор |
| Сколько раз | Один раз | Много раз |
| Состояние функции | Уничтожается | Сохраняется |
| Память | Весь результат сразу | По одному элементу |
# return — всё сразу
def get_all():
return [1, 2, 3] # Создаёт список в памяти
# yield — по требованию
def get_one_by_one():
yield 1
yield 2
yield 3 # Создаёт элементы ленивоОшибка! Генератор должен быть «прогрет» вызовом next() перед первым send().
def gen():
x = yield
print(f"Получено: {x}")
g = gen()
g.send(10) # ❌ TypeError: can't send non-None value to a just-started generatorПравильно:
g = gen()
next(g) # ✅ Прогреваем генератор (доходим до первого yield)
g.send(10) # ✅ Теперь можно отправлять данныеПаттерн: декоратор для автоматического прогрева
def coroutine(func):
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
next(gen) # Автоматический прогрев
return gen
return wrapper
@coroutine
def gen():
x = yield
print(f"Получено: {x}")
g = gen() # Уже прогрет!
g.send(10) # ✅ Работает сразуПроизводительность и паттерны
Паттерн: Chunked processing
Проблема: иногда эффективнее обрабатывать данные не по одному элементу, а пакетами (batch processing). Например, при вставке в базу данных или отправке по сети.
Решение: генератор, который группирует элементы в чанки фиксированного размера.
from itertools import islice
def chunked(iterable, size):
"""Разбивает итератор на чанки фиксированного размера"""
iterator = iter(iterable)
while True:
# Берём следующие size элементов
chunk = list(islice(iterator, size))
if not chunk: # Итератор исчерпан
break
yield chunk
# Пример использования
numbers = range(10)
for chunk in chunked(numbers, 3):
print(chunk)
# Вывод:
# [0, 1, 2]
# [3, 4, 5]
# [6, 7, 8]
# [9] ← последний чанк может быть меньшеКак это работает:
# islice(iterator, 3) берёт следующие 3 элемента:
# Итерация 1: [0, 1, 2]
# Итерация 2: [3, 4, 5]
# Итерация 3: [6, 7, 8]
# Итерация 4: [9] ← осталось меньше 3
# Итерация 5: [] ← пусто, выходим из циклаПрактические применения:
1. Batch-вставка в базу данных
def read_large_csv(path):
"""Читает CSV построчно"""
with open(path) as f:
for line in f:
yield parse_csv_line(line)
# ❌ Плохо: по одной записи (медленно!)
for record in read_large_csv('data.csv'):
db.insert(record) # 1 000 000 запросов к БД
# ✅ Хорошо: пакетами по 1000 (быстро!)
for chunk in chunked(read_large_csv('data.csv'), 1000):
db.bulk_insert(chunk) # 1000 запросов вместо 1 000 0002. Обработка большого файла порциями
def process_large_file(path):
"""Обрабатывает файл пакетами по 1000 строк"""
with open(path) as f:
for chunk in chunked(f, 1000):
# Обрабатываем пакет
processed = [transform(line) for line in chunk]
# Сохраняем результат
save_to_output(processed)
# Память освобождается после каждого чанка!
process_large_file('huge.txt') # Работает даже с файлами в GB3. Параллельная обработка
from multiprocessing import Pool
def process_chunk(chunk):
"""Обрабатывает один чанк данных"""
return [expensive_operation(item) for item in chunk]
# Распределяем работу по процессам
with Pool(4) as pool:
results = pool.map(
process_chunk,
chunked(huge_dataset, 100)
)4. Progress bar с чанками
from tqdm import tqdm
def process_with_progress(items, chunk_size=100):
chunks = list(chunked(items, chunk_size))
for chunk in tqdm(chunks, desc="Processing"):
process_batch(chunk)
# Прогресс обновляется после каждого чанкаВариант с фиксированным количеством чанков:
def chunks_count(iterable, n):
"""Разбивает на N чанков (не фиксированного размера)"""
lst = list(iterable)
chunk_size = len(lst) // n + (1 if len(lst) % n else 0)
for i in range(0, len(lst), chunk_size):
yield lst[i:i + chunk_size]
# Разбиваем на 4 части
for chunk in chunks_count(range(10), 4):
print(chunk)
# Вывод:
# [0, 1, 2]
# [3, 4, 5]
# [6, 7]
# [8, 9]Когда использовать:
- ✅ Batch-операции с БД
- ✅ Сетевые запросы (отправка пакетами)
- ✅ Параллельная обработка
- ✅ Ограниченная память (обработка по частям)
- ❌ Не нужно, если обработка действительно по одному элементу
Паттерн: Tee — множественные итераторы
Проблема: генератор можно итерировать только один раз. Что если нужно обработать данные двумя разными способами?
Решение: itertools.tee() создаёт несколько независимых итераторов из одного источника.
from itertools import tee
# ❌ Плохо: генератор исчерпается после первого прохода
def process_data_bad(items):
stats = calculate_stats(items) # Первый проход
results = transform(items) # Пустой! Генератор исчерпан
return stats, results
# ✅ Хорошо: tee создаёт независимые итераторы
def process_data(items):
# Создаём 2 независимые копии итератора
items1, items2 = tee(items, 2)
# Два независимых прохода
stats = calculate_stats(items1) # Первый итератор
results = transform(items2) # Второй итератор
return stats, results
# Пример использования
data = (x**2 for x in range(1000)) # Генератор
stats, results = process_data(data)Важно: tee кэширует данные в памяти, поэтому:
- Не используйте для огромных потоков данных
- Потребляйте итераторы примерно с одинаковой скоростью
- Если нужен только один проход — не используйте
tee
Практический пример:
from itertools import tee
def analyze_log_file(log_lines):
"""Анализирует логи: считает статистику и сохраняет ошибки"""
# Создаём 2 итератора из одного генератора
lines_for_stats, lines_for_errors = tee(log_lines, 2)
# Первый проход: собираем статистику
stats = {
'total': sum(1 for _ in lines_for_stats),
}
# Второй проход: находим ошибки
errors = [
line for line in lines_for_errors
if 'ERROR' in line
]
return stats, errors
# Использование
def read_log():
with open('app.log') as f:
for line in f:
yield line.strip()
stats, errors = analyze_log_file(read_log())
print(f"Total lines: {stats['total']}")
print(f"Errors: {len(errors)}")Паттерн: Pipeline с трансформацией
Идея: соединяем генераторы в цепочку (pipeline) для последовательной обработки данных. Каждый элемент проходит через все этапы, но без создания промежуточных списков.
Преимущества:
- Ленивое вычисление — обрабатываем по одному элементу за раз
- Экономия памяти — нет промежуточных коллекций
- Композируемость — легко добавлять новые этапы обработки
def map_gen(func, iterable):
"""Применяет функцию к каждому элементу"""
for item in iterable:
yield func(item)
def filter_gen(predicate, iterable):
"""Оставляет только элементы, удовлетворяющие условию"""
for item in iterable:
if predicate(item):
yield item
# ❌ Плохо: создаём промежуточные списки
numbers = range(100)
even = [x for x in numbers if x % 2 == 0] # Список 1
doubled = [x * 2 for x in even] # Список 2
result = list(doubled)
# ✅ Хорошо: композиция генераторов (pipeline)
numbers = range(100)
pipeline = map_gen(
lambda x: x * 2, # Этап 2: удваиваем
filter_gen(
lambda x: x % 2 == 0, # Этап 1: фильтруем чётные
numbers # Источник данных
)
)
result = list(pipeline) # Вычисляется лениво, по одному элементуКак это работает:
# Поток данных через pipeline:
numbers: 0 → 1 → 2 → 3 → 4 → 5 → ...
↓
filter_gen (чётные): 0 → 2 → 4 → ...
↓
map_gen (x*2): 0 → 4 → 8 → ...
↓
result: [0, 4, 8, 12, ...]
# Каждый элемент проходит через ВСЕ этапы сразу:
# 0 → чётное? да → *2 = 0 → добавляем в result
# 1 → чётное? нет → пропускаем
# 2 → чётное? да → *2 = 4 → добавляем в resultПрактический пример: обработка лог-файла
def parse_log_line(line):
"""Парсит строку лога"""
parts = line.split(' | ')
return {
'timestamp': parts[0],
'level': parts[1],
'message': parts[2]
}
def filter_errors(entries):
"""Оставляет только ошибки"""
for entry in entries:
if entry['level'] == 'ERROR':
yield entry
def extract_message(entries):
"""Извлекает только сообщения"""
for entry in entries:
yield entry['message']
def read_log_file(path):
"""Читает файл построчно"""
with open(path) as f:
for line in f:
yield line.strip()
# Создаём pipeline обработки
log_pipeline = extract_message(
filter_errors(
map_gen(
parse_log_line,
read_log_file('app.log')
)
)
)
# Обрабатываем по одной строке, не загружая весь файл в память!
for error_message in log_pipeline:
send_to_monitoring(error_message)Альтернативный синтаксис (более читаемый):
# Вместо вложенных вызовов используем переменные
lines = read_log_file('app.log')
parsed = map_gen(parse_log_line, lines)
errors = filter_gen(lambda e: e['level'] == 'ERROR', parsed)
messages = map_gen(lambda e: e['message'], errors)
# Теперь можно итерировать
for msg in messages:
print(msg)Сравнение с list comprehension:
# List comprehension — всё в памяти
with open('huge.log') as f:
errors = [
parse(line)['message']
for line in f
if parse(line)['level'] == 'ERROR'
] # MemoryError на больших файлах!
# Pipeline — по одному элементу
errors_gen = extract_message(
filter_errors(
map_gen(parse_log_line, read_log_file('huge.log'))
)
)
for error in errors_gen:
process(error) # Обрабатываем сразу, не храним всёПодводные камни
1. Генератор выполняется лениво (отложенное выполнение)
Проблема: генератор НЕ выполняется при создании — только при итерации. Это может привести к неожиданному поведению с побочными эффектами.
def side_effect_gen():
print("Start") # Побочный эффект 1
yield 1
print("Middle") # Побочный эффект 2
yield 2
print("End") # Побочный эффект 3
# ❌ Распространённая ошибка: ожидаем, что код выполнится
gen = side_effect_gen() # Ничего не напечатается!
print("Генератор создан")
# Вывод:
# Генератор создан ← только это!Код выполняется только при вызове next():
gen = side_effect_gen()
print("1. Создали генератор")
# Ничего не напечатано
print("2. Первый next()")
next(gen)
# Start ← выполнился код до первого yield
# 1
print("3. Второй next()")
next(gen)
# Middle ← выполнился код между yield'ами
# 2
print("4. Третий next()")
try:
next(gen)
except StopIteration:
pass
# End ← выполнился код после последнего yieldПрактическая проблема: валидация данных
def process_users(users):
"""Обрабатывает пользователей с валидацией"""
if not users:
raise ValueError("Users list is empty!") # ❌ НЕ выполнится!
for user in users:
yield process_user(user)
# Ошибка: валидация не сработала
gen = process_users([]) # ValueError НЕ выброшен!
# Ошибка возникнет только при first next()Решение: вынести валидацию за пределы генератора
def process_users(users):
"""Правильная валидация"""
# ✅ Выполняется сразу при вызове функции
if not users:
raise ValueError("Users list is empty!")
# Вложенный генератор выполняется лениво
def _generator():
for user in users:
yield process_user(user)
return _generator()
# Теперь валидация работает сразу
gen = process_users([]) # ✅ ValueError выброшен немедленно!Ещё один пример: логирование
# ❌ Плохо: лог напечатается только при первой итерации
def load_data():
print("Loading data...") # Выполнится при первом next()!
for item in database.fetch():
yield item
data_gen = load_data() # "Loading data..." НЕ напечатано
print("Генератор создан, но данные ещё не загружены")
# Логирование происходит только здесь:
first_item = next(data_gen) # ← Вот тут напечатается "Loading data..."
# Если мы создали генератор, но забыли его использовать — логи молчат!
# ✅ Хорошо: логи работают сразу, данные отдаём лениво
def load_data():
"""Инициализация сразу, отдача данных лениво"""
print("Starting data load...") # ← Выполнится сразу при вызове
# Вложенный генератор для ленивой отдачи
def _generator():
for item in database.fetch():
yield item
print("Data source prepared")
return _generator()
data_gen = load_data() # ✅ Логи напечатаны сразу!
# Вывод:
# Starting data load...
# Data source prepared
# Теперь можем итерировать
for item in data_gen:
process(item)Альтернатива: разделите на две функции
# ✅ Ещё лучше: явно разделяем загрузку и обработку
def load_data_from_db():
"""Загружает данные (не генератор)"""
print("Loading data...")
return list(database.fetch()) # Возвращаем список
def process_data_lazy(items):
"""Обрабатывает данные лениво (генератор)"""
for item in items:
yield expensive_transform(item)
# Использование
data = load_data_from_db() # Логи напечатаны сразу
# Loading data...
gen = process_data_lazy(data) # Создали генератор
for item in gen: # Обрабатываем лениво
save(item)Когда это полезно:
def expensive_computation():
"""Вычисления начинаются только при необходимости"""
# Это НЕ выполнится, пока не вызовем next()
result = very_slow_operation() # Не блокирует
yield result
# Создаём генератор мгновенно
gen = expensive_computation() # Быстро!
# Вычисления начнутся только здесь
if user_wants_data:
data = next(gen) # Вот тут начнётся slow_operation()Ключевое правило:
- Код до первого
yieldвыполняется при первомnext() - Код между
yieldвыполняется при последующихnext() - Код после последнего
yieldвыполняется передStopIteration
2. Замыкания и позднее связывание (late binding)
Проблема: когда создаёте генераторы или лямбды в цикле, они могут «захватить» не то значение переменной, которое вы ожидаете.
Пример 1: Генераторы в цикле (работает правильно ✅)
# Создаём 3 генератора, каждый должен умножать на своё число
gens = [(x * 2 for _ in range(2)) for x in range(3)]
# ↑ генератор ↑ цикл по x
# Проверяем результат
for gen in gens:
print(list(gen))
# Вывод (правильно!):
# [0, 0] ← первый генератор: x=0, [0*2, 0*2]
# [2, 2] ← второй генератор: x=1, [1*2, 1*2]
# [4, 4] ← третий генератор: x=2, [2*2, 2*2]Почему работает? Генераторные выражения захватывают значение x в момент создания.
Пример 2: Лямбды в цикле (НЕ работает! ❌)
# Создаём 3 функции, каждая должна возвращать своё число
funcs = [lambda: i for i in range(3)]
# ↑ лямбда ↑ цикл по i
# Проверяем результат
print([f() for f in funcs])
# [2, 2, 2] ← ВСЕ возвращают 2! Почему?Почему НЕ работает? Лямбды захватывают ссылку на переменную i, а не её значение. Когда цикл закончился, i = 2, и все лямбды видят это последнее значение.
Пошаговое объяснение:
funcs = [lambda: i for i in range(3)]
# Что происходит:
# Итерация 0: i=0, создаём lambda (которая смотрит на i)
# Итерация 1: i=1, создаём lambda (которая смотрит на i)
# Итерация 2: i=2, создаём lambda (которая смотрит на i)
# Цикл завершён: i=2 (последнее значение)
# Когда вызываем лямбды:
funcs[0]() # Смотрит на i → i=2 → возвращает 2
funcs[1]() # Смотрит на i → i=2 → возвращает 2
funcs[2]() # Смотрит на i → i=2 → возвращает 2Решение 1: Значение по умолчанию (классический трюк)
# ✅ Правильно: захватываем значение через аргумент по умолчанию
funcs = [lambda x=i: x for i in range(3)]
# ↑ важно! x=i вычисляется в момент создания
print([f() for f in funcs])
# [0, 1, 2] ← правильно!Как это работает:
lambda x=i: x
# ↑ значение i копируется в x СРАЗУ при создании лямбды
# ↑ x теперь локальная переменная со значением
# Итерация 0: i=0 → lambda x=0: x
# Итерация 1: i=1 → lambda x=1: x
# Итерация 2: i=2 → lambda x=2: xРешение 2: Используйте генераторы вместо лямбд
# Генераторы захватывают значения правильно
gens = (i for i in range(3))
result = list(gens)
print(result) # [0, 1, 2] ✅Практический пример с генераторами:
# ❌ Плохо: создаём генераторы с лямбдами в цикле
multipliers = []
for n in [2, 3, 4]:
multipliers.append(lambda x: x * n) # Захватывает ссылку на n!
print(multipliers[0](10)) # Ожидаем 20, получаем 40 (n=4)
print(multipliers[1](10)) # Ожидаем 30, получаем 40 (n=4)
print(multipliers[2](10)) # Ожидаем 40, получаем 40 (n=4)
# ✅ Хорошо: фиксируем значение
multipliers = []
for n in [2, 3, 4]:
multipliers.append(lambda x, n=n: x * n) # n захватывается по значению
print(multipliers[0](10)) # 20 ✅
print(multipliers[1](10)) # 30 ✅
print(multipliers[2](10)) # 40 ✅Ключевое правило:
- Генераторные выражения — захватывают значения правильно
- Lambda функции в циклах — захватывают ссылки, нужен трюк с
x=i - Обычные функции — ведут себя как лямбды
Когда это важно:
- Создание обработчиков событий в цикле
- Генерация функций-валидаторов
- Создание callback'ов
- Функциональное программирование с map/filter
3. StopIteration внутри генератора (PEP 479)
Проблема: начиная с Python 3.7, если StopIteration выбрасывается внутри генератора, он автоматически превращается в RuntimeError.
Почему это изменили? Раньше StopIteration внутри генератора молча завершал генератор, что приводило к скрытым багам.
Плохой код (до Python 3.7):
def buggy_gen():
iterator = iter([1, 2])
while True:
yield next(iterator) # StopIteration выбросится на третьей итерации
# В Python 2.x / 3.6:
gen = buggy_gen()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # StopIteration (генератор завершён)
# В Python 3.7+:
gen = buggy_gen()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # RuntimeError: generator raised StopIterationПочему это проблема — реальный пример:
Представьте, что вы пишете функцию, которая должна вернуть 5 элементов, но из-за бага StopIteration генератор завершается раньше.
def get_five_numbers():
"""Должна вернуть 5 чисел, но есть баг"""
numbers = iter([1, 2, 3]) # Список только из 3 элементов!
yield next(numbers) # 1
yield next(numbers) # 2
yield next(numbers) # 3
yield next(numbers) # ❌ StopIteration — список кончился!
yield next(numbers) # Не выполнится
# Python 3.6 и ранее:
result = list(get_five_numbers())
print(result) # [1, 2, 3] ← Молча вернул только 3! Баг незаметен
# Python 3.7+:
result = list(get_five_numbers()) # RuntimeError: generator raised StopIteration
# ✅ Ошибка явная, баг сразу заметен!Почему молчаливое завершение — плохо:
# Пример: обработка данных пользователей
def process_users(user_ids):
"""Обрабатывает пользователей и возвращает результаты"""
users_iter = iter(fetch_users(user_ids)) # Получаем итератор
for _ in range(10): # Ожидаем обработать 10 пользователей
user = next(users_iter) # ← Если пользователей меньше 10, StopIteration!
yield process(user)
# Python 3.6: если пользователей 7, вернёт только 7 результатов
# Код вызова ожидал 10, но получил 7 — непонятно, баг это или норма?
# Python 3.7+: явная ошибка RuntimeError
# Сразу понятно, что что-то не так!Проблема с вложенными генераторами:
def outer_gen():
"""Обрабатывает данные из другого генератора"""
for item in inner_gen(): # Используем вложенный генератор
yield item * 2
def inner_gen():
"""Генератор с багом"""
data = iter([1, 2, 3])
yield next(data) # 1
yield next(data) # 2
yield next(data) # 3
yield next(data) # ❌ StopIteration! Но outer_gen об этом не знает
# Python 3.6:
result = list(outer_gen())
print(result) # [2, 4, 6] ← Молча завершился, баг скрыт
# Python 3.7+:
result = list(outer_gen()) # RuntimeError
# ✅ Ошибка указывает на строку с багом в inner_gen!Суть проблемы:
До Python 3.7 нельзя было отличить:
- Нормальное завершение генератора (все данные обработаны)
- Баг (StopIteration внутри генератора)
Теперь любой StopIteration внутри генератора = ошибка кода, которую нужно исправить!
Решение 1: Обработайте StopIteration явно
def correct_gen():
iterator = iter([1, 2])
while True:
try:
value = next(iterator)
yield value
except StopIteration:
break # ✅ Явное завершение генератора
gen = correct_gen()
print(list(gen)) # [1, 2] — работает корректноРешение 2: Используйте for вместо while + next()
def better_gen():
"""for автоматически обрабатывает StopIteration"""
iterator = iter([1, 2])
for value in iterator: # ✅ for перехватывает StopIteration
yield value
gen = better_gen()
print(list(gen)) # [1, 2]Решение 3: Используйте return для завершения
def best_gen():
iterator = iter([1, 2])
while True:
try:
value = next(iterator)
except StopIteration:
return # ✅ Правильный способ завершить генератор
yield valueПрактический пример: чтение файла по чанкам
# ❌ Плохо: StopIteration может утечь
def read_chunks_bad(file, size=1024):
while True:
chunk = file.read(size)
if not chunk:
# Если кто-то вызовет next(iterator) внутри...
raise StopIteration # RuntimeError в Python 3.7+!
yield chunk
# ✅ Хорошо: используем return
def read_chunks_good(file, size=1024):
while True:
chunk = file.read(size)
if not chunk:
return # Правильное завершение
yield chunkПример реального бага:
def process_items(items):
"""Обрабатывает элементы до первого None"""
iterator = iter(items)
# ❌ Баг: если items пустой, next() выбросит StopIteration
first = next(iterator) # Может выбросить StopIteration!
yield first
for item in iterator:
if item is None:
break
yield item
# Python 3.7+: RuntimeError
gen = process_items([])
next(gen) # RuntimeError: generator raised StopIteration
# ✅ Правильно: обрабатываем StopIteration
def process_items_fixed(items):
iterator = iter(items)
try:
first = next(iterator)
except StopIteration:
return # Пустой генератор, если items пустой
yield first
for item in iterator:
if item is None:
break
yield itemКогда это важно:
- Работа с вложенными генераторами
- Использование
next()внутри генератора - Обёртки над сторонними итераторами
- Миграция кода с Python 2.x / 3.6 на 3.7+
Ключевое правило:
- ❌ Не позволяйте
StopIterationвыходить за пределы генератора - ✅ Используйте
returnдля завершения генератора - ✅ Оборачивайте
next()вtry/except - ✅ Предпочитайте
forвместоwhile + next()
Резюме: когда использовать генераторы
✅ Используйте генераторы когда:
- Обрабатываете большие объёмы данных
- Нужна ленивая вычисление
- Работаете с потоками данных (файлы, API, базы данных)
- Создаёте бесконечные последовательности
- Нужен pipeline обработки
❌ Не используйте генераторы когда:
- Нужен случайный доступ к элементам (используйте списки)
- Требуется многократная итерация (кэшируйте в список)
- Нужно знать длину последовательности заранее
- Логика слишком простая (list comprehension читабельнее)
Шпаргалка по методам генератора
| Метод | Что делает | Куда бросает | Возвращает |
|---|---|---|---|
next(gen) | Запускает/продолжает выполнение | — | Значение из yield |
gen.send(value) | Отправляет значение в генератор | value → результат yield | Следующее значение из yield |
gen.throw(exc) | Выбрасывает исключение в генератор | Исключение в точке yield | Следующее значение (если исключение обработано) |
gen.close() | Завершает генератор | GeneratorExit в точке yield | None |
Визуализация send():
def demo():
x = yield 1 # x получит значение из send()
y = yield x*2
yield y*3
gen = demo()
next(gen) # → 1 (дошли до первого yield)
gen.send(10) # → 20 (x=10, вернули 10*2)
gen.send(5) # → 15 (y=5, вернули 5*3)Визуализация throw():
def demo():
while True:
try:
x = yield # ← Здесь возникнет исключение
print(f"OK: {x}")
except ValueError:
print("Ошибка обработана")
gen = demo()
next(gen)
gen.send("data") # OK: data
gen.throw(ValueError) # Ошибка обработана
gen.send("продолжаем") # OK: продолжаемПрактическое задание
Попробуйте реализовать генератор для обхода вложенной структуры любой глубины:
def flatten(nested):
"""
Рекурсивно выравнивает вложенные итерируемые структуры
>>> list(flatten([1, [2, 3], [[4], 5]]))
[1, 2, 3, 4, 5]
"""
# Ваша реализация здесь
passРешение
def flatten(nested):
for item in nested:
if isinstance(item, (list, tuple)):
yield from flatten(item)
else:
yield itemПонимание генераторов — это мастхэв для любого Python-разработчика. Это не просто синтаксическая фича, а фундаментальный паттерн для эффективной работы с данными. На собеседованиях вопросы про генераторы часто отделяют джунов от мидлов, а мидлов — от синьоров.
Практикуйтесь, экспериментируйте с send(), throw(), yield from — и эта тема станет вашим преимуществом на интервью.