Примеры идут от простого к сложному: сначала механика приостановки, затем практические паттерны (ленивая обработка, конвейеры, делегирование), и в конце — сравнение с альтернативами. Весь вывод в комментариях получен запуском кода.
Пример 1. Вызов не выполняет тело функции
Функция с yield при вызове не запускает код — она возвращает объект-генератор. Тело начинает выполняться только на первом next().
def simple_generator():
print("Старт")
yield 1
print("Середина")
yield 2
print("Финиш")
gen = simple_generator()
print(gen) # <generator object simple_generator at 0x...>
print(type(gen)) # <class 'generator'>
print(next(gen))
# Старт
# 1
print(next(gen))
# Середина
# 2
try:
print(next(gen))
except StopIteration:
print("StopIteration")
# Финиш
# StopIteration
Что происходит: строка gen = simple_generator() не печатает «Старт» — тело откладывается до первого next(). Третий next() допечатывает «Финиш» (код после последнего yield) и бросает StopIteration, потому что новых значений больше нет.
Пример 2. next() со значением по умолчанию
Если ловить StopIteration каждый раз неудобно, у next() есть второй аргумент — значение, которое вернётся вместо исключения.
def limited():
yield "a"
yield "b"
g = limited()
print(next(g)) # a
print(next(g)) # b
print(next(g, "конец данных")) # конец данных
print(next(g, "конец данных")) # конец данных — можно звать сколько угодно раз
Что происходит: после второго yield генератор исчерпан. Без запасного значения третий вызов бросил бы StopIteration; с ним — тихо возвращает переданную строку, и код можно писать без try/except.
Пример 3. Генератор одноразовый
Генератор с параметрами создаётся как обычная функция, но пройти по нему можно только один раз — дошли до конца, и повторный обход пуст.
def my_range(start, end):
current = start
while current < end:
yield current
current += 1
generator = my_range(1, 4)
print(list(generator)) # [1, 2, 3]
print(list(generator)) # [] — уже исчерпан
print(list(my_range(1, 4))) # [1, 2, 3] — новый вызов = новый генератор
Что происходит: второй list(generator) возвращает пустой список — не ошибку, а молча пустоту, поэтому баг легко пропустить. Если данные нужны дважды, создавайте генератор заново или храните результат в списке.
Пример 4. Зачем вообще ленивость: генератор против списка по памяти
Главная причина использовать генератор вместо списка — память. Список хранит все элементы сразу, генератор — только текущее состояние.
import sys
list_comp = [x * x for x in range(1000)]
gen_expr = (x * x for x in range(1000))
print(sys.getsizeof(list_comp)) # 8856
print(sys.getsizeof(gen_expr)) # 208
print(sum(list_comp)) # 332833500
print(sum(gen_expr)) # 332833500 — тот же результат
Что происходит: список из 1000 квадратов занимает 8856 байт, генераторное выражение — 208 байт независимо от того, сколько элементов оно в итоге отдаст. При миллионах строк файла или элементов потока разница становится решающей: список может не поместиться в память, генератор — почти всегда поместится.
Пример 5. Бесконечный генератор и управляемая остановка
Бесконечный генератор — обычный паттерн (счётчики, потоки событий). Останавливать его — обязанность вызывающего кода. itertools.islice берёт ровно нужное число элементов, не трогая остальные.
from itertools import islice
def infinite_counter(start=1):
while True:
yield start
start += 1
counter = infinite_counter()
print(list(islice(counter, 5))) # [1, 2, 3, 4, 5]
print(next(counter)) # 6 — счётчик не сбросился, продолжает с того же места
Что происходит: islice(counter, 5) читает пять значений и останавливается сама — цикл for _ in range(5) с break из теории делает то же самое вручную. После islice генератор не исчерпан: следующий next() продолжает с 6, потому что состояние хранится внутри самого объекта-генератора.
itertools.islice выходит за рамки лекции — модуль itertools целиком стоит открыть отдельно: itertools.islice.
Пример 6. close() завершает генератор досрочно
def sensor_reader(values):
for value in values:
yield value
sensor = sensor_reader([10, 20, 30, 40, 50])
collected = []
for value in sensor:
collected.append(value)
if value >= 30:
sensor.close()
break
print(collected) # [10, 20, 30]
try:
next(sensor)
except StopIteration:
print("StopIteration after close") # StopIteration after close
Что происходит: close() бросает GeneratorExit внутрь генератора в точке остановки на yield; если генератор не перехватывает это исключение (обычный случай), он просто завершается. После close() генератор ведёт себя как уже исчерпанный — любой next() даёт StopIteration.
Пример 7. yield from: делегирование и рекурсивный «расплющиватель»
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
print(list(flatten([1, [2, [3, 4]], 5]))) # [1, 2, 3, 4, 5]
def letters():
yield from "AB" # yield from работает с любым iterable, не только с генератором
yield from [1, 2]
print(list(letters())) # ['A', 'B', 1, 2]
Что происходит: yield from flatten(item) рекурсивно раскрывает вложенные списки любой глубины — без него пришлось бы вручную писать вложенный цикл for x in flatten(item): yield x. Второй пример показывает, что источником для yield from может быть строка или список — не обязательно другой генератор.
Пример 8. send(): значения можно передавать внутрь генератора
Генератор — не только источник значений наружу. Через send(value) можно передать значение внутрь в точку, где генератор стоит на yield.
def running_total():
total = 0
while True:
value = yield total
total += value
acc = running_total()
print(next(acc)) # 0 — запускаем генератор до первого yield
print(acc.send(10)) # 10
print(acc.send(5)) # 15
print(acc.send(-3)) # 12
Что происходит: первый next(acc) обязателен — без него генератор ещё не дошёл до yield, и send() сразу после создания бросит ошибку. Каждый следующий send(value) возвращает value из выражения yield total, прибавляет его к сумме и снова замирает на yield — так строятся простые корутины и накопители состояния.
send() и throw() — продвинутая техника, выходящая за рамки лекции; см. generator.send.
Пример 9. Генератор против класса-итератора: тот же результат, меньше кода
Класс с __iter__/__next__ и функция-генератор решают одну задачу. Сравнение из теории — вживую.
class RangeIterator:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current >= self.end:
raise StopIteration
value = self.current
self.current += 1
return value
print(list(RangeIterator(1, 4))) # [1, 2, 3]
print(list(my_range(1, 4))) # [1, 2, 3] — тот же результат, 6 строк вместо 11
Что происходит: оба варианта дают одинаковый список. Класс явно хранит состояние в self.current и сам поднимает StopIteration; генератор из примера 3 делает то же самое короче — Python сохраняет состояние автоматически. Класс оправдан, когда обходу нужны дополнительные методы или атрибуты помимо перебора.
Пример 10. Конвейер из нескольких генераторов
Практическое применение из вступления к уроку — обработка текста по шагам, где каждый шаг ленивый и не хранит промежуточный результат целиком.
def read_lines(text):
for line in text.splitlines():
yield line
def non_empty(lines):
for line in lines:
stripped = line.strip()
if stripped:
yield stripped
def numbered(lines):
for index, line in enumerate(lines, start=1):
yield f"{index}: {line}"
report = """Alice: 90
Bob: 75
Carol: 88"""
pipeline = numbered(non_empty(read_lines(report)))
for row in pipeline:
print(row)
# 1: Alice: 90
# 2: Bob: 75
# 3: Carol: 88
Что происходит: ни на одном шаге не строится промежуточный список — значения текут по цепочке генераторов по одному, и пустые строки отфильтровываются до нумерации. Тот же приём годится для чтения большого файла: read_lines достаточно заменить на построчное чтение файла, а non_empty/numbered останутся без изменений.
Пример 11. Ловушка: один и тот же генератор нельзя скормить двум потребителям
Ошибка одноразовости из примера 3 особенно незаметна, когда генератор один раз сохранили в переменную и случайно передали в два места кода.
def make_range():
return my_range(1, 4)
shared = my_range(1, 4)
first_pass = list(shared)
second_pass = list(shared)
print(first_pass, second_pass) # [1, 2, 3] []
fresh_first = list(make_range())
fresh_second = list(make_range())
print(fresh_first, fresh_second) # [1, 2, 3] [1, 2, 3]
Как избежать: если данные нужны больше одного раза, передавайте функцию-фабрику (make_range) вместо готового генератора (shared) — каждый вызов фабрики создаёт свежий объект. Альтернатива — сразу materialize-ить результат в список, если объём данных это позволяет.
Что дальше
Практика на генераторы — в заданиях. Если пример не совпал с ожиданием — сверьтесь с типичными ошибками; тема продолжается в уроке 57.