Генератор — функция, в теле которой есть 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, потом ленивый конвейер над строками файла.
В задаче на генератор пагинации этот принцип проверяется на порциях списка: результат должен оставаться ленивым, а последняя неполная страница — не теряться.