Генераторы в Python: yield, ленивые вычисления и итераторы

Python Автор: Среда и версия: CPython 3.14.5

Генератор — функция, в теле которой есть yield. Она не возвращает значение, а выдаёт их по одному, замирая между выдачами и помня, где остановилась. Главная выгода — память: список на 10 млн элементов занял в замере ниже 390 МБ, генератор с тем же результатом — полкилобайта. Всё проверено на Python 3.14.5.

Что происходит при вызове функции с yield

Ничего. Буквально: тело не запускается.

def countdown(n):
    print("тело стартовало")
    while n > 0:
        yield n
        n -= 1

gen = countdown(3)
print(type(gen).__name__)
print(next(gen), next(gen), next(gen))
generator
тело стартовало
3 2 1

Обрати внимание на порядок вывода: generator напечатался раньше, чем «тело стартовало». Вызов countdown(3) только создал объект-генератор, а тело пошло исполняться на первом next().

Когда значения закончились, генератор сообщает об этом исключением:

next(gen)
StopIteration

Цикл for ловит его сам, поэтому в обычном коде это исключение не видно.

Состояние живёт между выдачами

Обычная функция при выходе теряет всё. Генератор замораживает кадр целиком: локальные переменные, позицию в цикле, открытые файлы.

def steps():
    print("до первого yield")
    yield 1
    print("между yield")
    yield 2
    print("после второго")

s = steps()
next(s)     # до первого yield
next(s)     # между yield
next(s)     # после второго, затем StopIteration

Каждый next() продолжает с той строки, где стоял yield, и снова замирает на следующем. Это и отличает генератор от функции, которая возвращает список: тот считает всё сразу, генератор — по требованию.

Генераторное выражение против спискового включения

Разница в одних скобках, и это самая частая тема на собеседовании. Сами списковые включения — синтаксис, условия, вложенность — разобраны отдельно.

[x * x for x in range(3)]     # list      [0, 1, 4]
{x: x * x for x in range(3)}  # dict      {0: 0, 1: 1, 2: 4}
{x * x for x in range(3)}     # set       {0, 1, 4}
(x * x for x in range(3))     # generator

Первые три считают всё немедленно и кладут в память. Четвёртая возвращает генератор и не считает ничего, пока не попросят.

Замер на 10 млн элементов, пиковая память по tracemalloc:

выражениепик памятивремя
sum([i * i for i in range(N)])390.1 МБ0.42 c
sum(i * i for i in range(N))0.5 КБ0.40 c

Суммы совпадают, память различается почти в миллион раз. А вот времени генератор не экономит: 0.40 против 0.42 секунды — это шум. Работа-то одинаковая, отличается только, хранится ли результат целиком.

Отсюда правило выбора. Нужен результат один раз и подряд — генератор. Нужно обойти дважды, узнать длину или взять элемент по индексу — список.

Не путай размер объекта с потреблением памяти:

sys.getsizeof([i for i in range(1_000_000)])   # 8448728
sys.getsizeof(i for i in range(1_000_000))     # 200

Двести байт — это вес самого объекта-генератора, а не всех значений, которые он выдаст. getsizeof вообще плохой инструмент для такого сравнения, честную картину даёт tracemalloc, как в таблице выше.

Условие и вложенные циклы

Фильтр ставится после for, преобразование — до:

list(x for x in range(10) if x % 3 == 0)          # [0, 3, 6, 9]
list(x if x % 2 else -x for x in range(6))        # [0, 1, -2, 3, -4, 5]
list((a, b) for a in "ab" for b in (1, 2))        # [('a', 1), ('a', 2), ('b', 1), ('b', 2)]

Во второй строке if стоит в другом месте и работает иначе: это тернарный оператор внутри выражения, он не отбрасывает элементы, а выбирает значение. Первая форма фильтрует, вторая преобразует.

Скобки вокруг генераторного выражения можно опустить, если это единственный аргумент вызова: sum(x * x for x in range(5)) даёт 30. Как только аргументов больше, они обязательны:

SyntaxError: Generator expression must be parenthesized

Ленивость: считается только то, что попросили

Главный практический эффект. Ищем первый элемент больше трёх в миллионе и считаем, сколько элементов реально тронули:

touched = 0

def counted(n):
    global touched
    for i in range(n):
        touched += 1
        yield i

first = next(x for x in counted(1_000_000) if x > 3)
print(first, touched)
4 5

Пять элементов из миллиона. Со списком пришлось бы построить весь миллион, а потом выбросить 999 995 значений.

Из той же ленивости растёт конвейер: генераторы можно соединять цепочкой, и данные потекут по ней по одной штуке, ничего не накапливая.

rows   = ["10,paid", "x,paid", "20,cancelled", "30,paid"]
parsed = (r.split(",") for r in rows)
valid  = (p for p in parsed if p[0].isdigit())
paid   = (int(p[0]) for p in valid if p[1] == "paid")
print(sum(paid))     # 40

Три этапа обработки, ни одного промежуточного списка. Замени rows на файл в гигабайт, и код не изменится вообще.

Генератор одноразовый

Свойство, которое ловит всех.

squares = (i * i for i in range(5))
print(list(squares))     # [0, 1, 4, 9, 16]
print(list(squares))     # []

Второй проход пуст, и никакой ошибки при этом нет. Генератор дошёл до конца и остался исчерпанным. Если результат нужен дважды, сохрани его в список или создай генератор заново.

Отсюда же понятно, почему не работает len:

len(i for i in range(5))
TypeError: object of type 'generator' has no len()

Чтобы узнать длину, генератор пришлось бы исчерпать, и после подсчёта он оказался бы пустым. Питон отказывается делать это молча.

Итератор и итерируемое

Формально различие такое: итерируемое умеет отдать итератор через iter(), итератор умеет отдавать следующий элемент через next().

nums = [10, 20]
iter(nums) is nums              # False — список итерируемый, но не итератор
gen = (x for x in nums)
iter(gen) is gen                # True  — генератор и то и другое

Список каждый раз выдаёт новый list_iterator, поэтому его можно обходить сколько угодно. Генератор возвращает сам себя, поэтому обход у него один. Вот и вся причина одноразовости, без магии.

yield from

Пробрасывает наружу всё, что выдаёт вложенный генератор или любая последовательность:

def flatten(rows):
    for row in rows:
        yield from row

list(flatten([[1, 2], [3], [4, 5, 6]]))     # [1, 2, 3, 4, 5, 6]

Без него пришлось бы писать вложенный цикл с yield. На двух уровнях разница косметическая, на рекурсивном обходе дерева — уже нет.

return внутри генератора

return не возвращает значение вызывающему коду, а завершает генератор. Само значение прячется в исключении:

def parse(rows):
    good = 0
    for r in rows:
        if not r.isdigit():
            return good
        good += 1
        yield int(r)

list(parse(["1", "2", "x", "4"]))     # [1, 2]

Число 2, которое вернул return, в списке не появилось. Достать его можно только так:

p = parse(["1", "2", "x", "4"])
try:
    while True:
        next(p)
except StopIteration as exc:
    print(exc.value)     # 2

Приём редкий, но именно на нём стоит yield from: он возвращает как раз это значение.

send: генератор принимает данные

yield — выражение, и у него есть результат: то, что пришло через send().

def accumulator():
    total = 0
    while True:
        x = yield total
        total += x

acc = accumulator()
next(acc)                # доводим до первого yield
print(acc.send(10))      # 10
print(acc.send(5))       # 15
print(acc.send(1))       # 16

Первый next() обязателен: пока генератор не дошёл до yield, отправлять некуда. На этом механизме выросли корутины: до async/await их писали именно генераторами с send. Как это устроено сейчас — в разборе asyncio.

На чём ловят на собеседовании

«Генератор работает быстрее списка». Не работает. Он экономит память, а не время: в замере выше разница по времени 5%, по памяти — в миллион раз. Выигрыш во времени появляется только там, где обход прерывается досрочно и лишние элементы просто не считаются.

«Генератор — это то же самое, что итератор». Генератор — частный случай итератора, самый удобный способ его написать. Итератор можно сделать и классом с __iter__ и __next__, просто это втрое длиннее.

«Бесконечный генератор повесит программу». Только если попросить у него всё сразу. list(naturals()) действительно съест память, а itertools.islice(naturals(), 5) вернёт [1, 2, 3, 4, 5] и остановится.

«Генератор словаря — это {k: v for ...}». Это словарное включение, оно считается целиком и сразу. Генераторной формы у словаря нет: круглые скобки всегда дают генератор кортежей, из которого словарь собирают через dict(...).

«Файл лучше прочитать в список, так надёжнее». Файловый объект сам по себе итератор: for line in file читает по строке. Обёртывание в list() — самый частый способ уронить сервис на большом логе.

Частые вопросы

Как коротко объяснить, что такое генератор

Функция с yield, которая при вызове возвращает объект и выдаёт значения по одному, сохраняя состояние между выдачами. Нужен, чтобы не держать в памяти всю последовательность. Одноразовый, длины не имеет, по индексу не обращается.

Когда генератор не подходит

Когда данные нужны больше одного раза, когда нужен len() или индекс, когда коллекция и так маленькая. Для сотни элементов список проще и читается лучше.

Как превратить генератор в список

list(gen). Обратной операции нет: список в генератор превращают выражением (x for x in lst), но памяти это уже не сэкономит, данные-то в памяти.

Чем yield отличается от return

return завершает функцию и отдаёт значение. yield отдаёт значение и замораживает функцию до следующего запроса. В одной функции они уживаются: return внутри генератора работает как досрочная остановка.

Что учить дальше

itertools — там лежат готовые генераторы на все типовые случаи, от islice до groupby. Рядом стоят декораторы: вместе с генераторами это половина вопросов про устройство языка на техническом интервью. В пути «Python для продолжающих» на Koddo генераторы разобраны задачами с автопроверкой: сначала yield, потом ленивый конвейер над строками файла.

В задаче на генератор пагинации этот принцип проверяется на порциях списка: результат должен оставаться ленивым, а последняя неполная страница — не теряться.

Источники