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

Генераторы и yield в Python: глубокое погружение

Константин Потапов
15 мин

Полное руководство по генераторам Python: от базовых концепций до продвинутых паттернов. Разбираем механику работы yield, итераторный протокол, корутины и практические кейсы для собеседований.

Генераторы — одна из самых мощных и недооценённых фич 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, происходит магия:

  1. Приостановка выполнения — функция «замораживается» в текущем состоянии
  2. Возврат значения — значение из yield возвращается вызывающему коду
  3. Сохранение контекста — все локальные переменные и позиция в коде сохраняются
  4. Возобновление — при следующем вызове 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():

  1. Отправляет значение в генератор
  2. Значение становится результатом выражения yield
  3. Генератор продолжает выполнение до следующего yield
  4. Возвращает новое значение из 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
# → Ждём данные...

Ключевая механика:

  1. throw() выбрасывает исключение в точке последнего yield
  2. Если генератор перехватывает исключение (try/except) — он продолжает работу
  3. Если не перехватывает — исключение пробрасывается наружу

Практический пример: парсер с 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():

  1. В точке yield выбрасывается исключение GeneratorExit
  2. Выполняется блок finally (если есть)
  3. Генератор больше нельзя использовать

Важно: если внутри генератора перехватить 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 делает три вещи:

  1. Перенаправляет все yield из вложенного генератора
  2. Прокидывает send() и throw() во вложенный генератор
  3. Возвращает значение из 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"
Характеристикаreturnyield
Что возвращаетЗначениеГенератор
Сколько разОдин разМного раз
Состояние функцииУничтожаетсяСохраняется
ПамятьВесь результат сразуПо одному элементу
# 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 000

2. Обработка большого файла порциями

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')  # Работает даже с файлами в GB

3. Параллельная обработка

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: 012345...

filter_gen (чётные): 024...

map_gen (x*2): 048...

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 в точке yieldNone

Визуализация 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 — и эта тема станет вашим преимуществом на интервью.