Генераторы в Python: yield, итераторы и генераторы списков и словарей
Генератор в Python — это объект, который выдаёт значения по одному и по запросу, не храня их все в памяти; его создают функцией с ключевым словом yield или генераторным выражением в круглых скобках. Генераторы списков, словарей и множеств (comprehensions) — короткая запись для сборки готовой коллекции: [x * 2 for x in xs], {k: v for k, v in pairs}, {x for x in xs}.
Генератор в Python — это объект, который выдаёт значения по одному, только когда их попросят, и не держит всю последовательность в памяти. Создают его двумя способами: функцией с ключевым словом yield или генераторным выражением в круглых скобках. Рядом живут «генераторы списков», словарей и множеств — comprehensions, короткая запись для сборки готовой коллекции в одну строку. Разберём, как работает python yield, чем генератор отличается от итератора и как не превратить comprehension в нечитаемую строку.
Итераторы: на чём стоит цикл for
Чтобы понять генераторы, нужно сначала разобраться с итераторами. Итерируемый объект (iterable) — то, по чему можно пройти циклом: список, строка, словарь, файл. Итератор — объект, который помнит текущую позицию и по вызову next() отдаёт следующий элемент, а когда элементы кончились, выбрасывает StopIteration.
nums = [10, 20, 30]
it = iter(nums) # получить итератор из списка
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
next(it) # StopIteration
Цикл for делает ровно это: вызывает iter(), потом next() до StopIteration. Свой итератор можно написать классом с методами __iter__ и __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
print(list(Countdown(3))) # [3, 2, 1]
Работает, но много шаблонного кода. Генераторы делают то же самое в три строки.
Что такое генератор в Python и как работает yield
Функция-генератор — обычная функция, в теле которой есть yield. Её вызов не выполняет код, а возвращает объект-генератор. Каждый next() запускает тело до ближайшего yield, отдаёт значение и замораживает функцию вместе с локальными переменными. Следующий next() продолжает ровно с того же места.
def countdown(start):
while start > 0:
yield start
start -= 1
gen = countdown(3)
print(gen) # <generator object countdown at 0x...>
print(next(gen)) # 3
print(list(gen)) # [2, 1] — продолжили с места остановки
Когда функция доходит до конца (или до return), генератор сам выбрасывает StopIteration, и цикл for аккуратно завершается. Отсюда главное отличие yield от return: return завершает функцию и возвращает одно значение, yield приостанавливает её и может отдать сколько угодно значений.
Генератор и итератор: отличия
Любой генератор — итератор: у него есть __iter__ и __next__. Но не любой итератор — генератор. Разница в способе создания и в возможностях.
| Итератор (класс) | Генератор | |
|---|---|---|
| Как создать | класс с __iter__ и __next__ | функция с yield или (x for x in ...) |
| Состояние | вручную в атрибутах | сохраняется автоматически в локальных переменных |
| StopIteration | выбрасываете сами | появляется сам при выходе из функции |
| Дополнительно | что напишете | send(), throw(), close() |
Общее у них одно важное свойство: пройти можно только один раз. Повторный for по исчерпанному генератору ничего не выдаст — нужно создать новый.
Бесконечный генератор и чтение больших файлов
Генератор не обязан заканчиваться. Бесконечная последовательность безопасна, пока вы берёте из неё ограниченное число элементов:
from itertools import islice
def ids():
n = 1
while True:
yield f"ID-{n:04d}"
n += 1
print(list(islice(ids(), 3))) # ['ID-0001', 'ID-0002', 'ID-0003']
Самый практичный сценарий — обработка большого файла построчно. Генераторы можно соединять в конвейер, и в памяти в каждый момент будет одна строка, а не весь файл:
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def only_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
for line in only_errors(read_lines("app.log")):
print(line)
yield from и send
yield from передаёт все значения другого итерируемого объекта, избавляя от вложенного цикла:
def flatten(groups):
for g in groups:
yield from g
print(list(flatten([[1, 2], [3], [4, 5]]))) # [1, 2, 3, 4, 5]
Метод send() отправляет значение внутрь генератора: оно становится результатом выражения yield. Перед первым send генератор нужно «запустить» через next(). Так пишут, например, накопитель среднего:
def averager():
total, count = 0, 0
avg = None
while True:
value = yield avg
total += value
count += 1
avg = total / count
a = averager()
next(a) # дойти до первого yield
print(a.send(10)) # 10.0
print(a.send(20)) # 15.0
В прикладном коде send встречается редко, но о нём часто спрашивают на собеседованиях.
range — это генератор?
Нет. range — ленивая последовательность: он тоже не хранит все числа, но поддерживает индексацию, len(), проверку in и проход сколько угодно раз. Генератор ничего из этого не умеет.
r = range(5)
print(r[2], len(r), 3 in r) # 2 5 True
print(list(r), list(r)) # оба раза [0, 1, 2, 3, 4]
Генератор списков: list comprehension
Генератор списков (list comprehension) — запись [выражение for x in коллекция]. Она создаёт новый список и не меняет исходный. Читается слева направо: сначала «что положить», потом «откуда брать».
amounts = [100, 250, 400]
# обычный цикл
with_markup = []
for a in amounts:
with_markup.append(a * 1.2)
# то же через comprehension
with_markup = [a * 1.2 for a in amounts]
print(with_markup) # [120.0, 300.0, 480.0]
Несмотря на русское название «генератор списков», результат — обычный готовый список, а не генератор: все элементы вычисляются сразу.
Генератор списка с условием if и if else
Тут путаются чаще всего. Условие-фильтр ставится в конец и отбрасывает элементы. Условие-выбор (тернарный оператор) ставится в начало и превращает каждый элемент в одно из двух значений, ничего не выбрасывая.
amounts = [100, 250, 400]
big = [a for a in amounts if a > 200] # [250, 400]
labels = ["много" if a > 200 else "мало" for a in amounts]
# ['мало', 'много', 'много']
# фильтр и выбор вместе
odd_marks = ["чёт" if n % 2 == 0 else "нечёт" for n in range(6) if n > 2]
# ['нечёт', 'чёт', 'нечёт']
Запись [a for a in amounts if a > 200 else 0] — синтаксическая ошибка: else бывает только у условия в начале.
Вложенные генераторы списков и двумерный список
Двойной for пишется в том же порядке, что вложенные циклы, сверху вниз. Это помогает не перепутать порядок при склейке списка списков:
groups = [[1, 2], [3], [4, 5]]
flat = [x for g in groups for x in g]
# эквивалентно:
# for g in groups:
# for x in g:
# flat.append(x)
print(flat) # [1, 2, 3, 4, 5]
Двумерный список (матрицу) создают comprehension внутри comprehension. Классическая ошибка — умножение списка: строки окажутся одним и тем же объектом.
bad = [[0] * 3] * 2
bad[0][0] = 1
print(bad) # [[1, 0, 0], [1, 0, 0]] — обе строки изменились
grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0]]
# транспонирование
m = [[1, 2, 3], [4, 5, 6]]
print([[row[i] for row in m] for i in range(3)]) # [[1, 4], [2, 5], [3, 6]]
Генератор словаря и множества
Тот же приём работает для других коллекций. Фигурные скобки с двоеточием собирают словарь (dict comprehension), без двоеточия — множество (set comprehension).
spent = [("еда", 500), ("такси", 300), ("еда", 200)]
categories = {cat for cat, _ in spent}
print(categories) # {'еда', 'такси'} — порядок у множества не гарантирован
last = {cat: amount for cat, amount in spent}
print(last) # {'еда': 200, 'такси': 300}
squares = {n: n ** 2 for n in range(4)} # {0: 0, 1: 1, 2: 4, 3: 9}
swapped = {v: k for k, v in squares.items()}
Обратите внимание на last: при повторе ключа побеждает последнее значение, сумма 500 потерялась. Если нужно сложить, comprehension не подходит — пишите цикл или используйте collections.Counter / defaultdict.
А генератор кортежей?
Отдельного «tuple comprehension» в Python нет. Круглые скобки дают генераторное выражение, а кортеж получают, передав его в tuple():
t = tuple(n * 2 for n in range(3)) # (0, 2, 4)
Генераторное выражение: не хранить, а выдавать
Если заменить квадратные скобки на круглые, получится генераторное выражение. Оно ничего не считает заранее и выдаёт значения по одному. Для миллиона элементов разница в памяти огромная:
import sys
as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list)) # около 8 МБ
print(sys.getsizeof(as_gen)) # пара сотен байт
Чаще всего генераторные выражения живут внутри функций-агрегатов. Если генератор — единственный аргумент, лишние скобки можно не писать. any() и all() к тому же останавливаются на первом же ответе и не перебирают остаток.
spent = [("еда", 500), ("такси", 300), ("еда", 200)]
total = sum(a for _, a in spent) # 1000
has_big = any(a > 400 for _, a in spent) # True, дальше 500 не смотрит
biggest = max(spent, key=lambda p: p[1]) # ('еда', 500)
Правило выбора простое: нужен результат несколько раз, индексация или len() — берите список. Нужно один раз пройти и что-то посчитать — генератор.
Область видимости: переменная не утекает
Вопрос, на котором спотыкаются даже опытные:
x = 10
squares = [x for x in range(3)]
print(x) # 10
Переменная цикла внутри comprehension живёт в своей области и не затирает внешнюю (так в Python 3; в Python 2 было иначе). В обычном цикле for после него x равнялся бы 2. С версии 3.12 (PEP 709) comprehensions встраиваются в окружающую функцию и работают быстрее, но правило видимости осталось прежним.
Распаковка * и ** в comprehensions: новое в Python 3.15 (PEP 798)
Звёздочку для распаковки давно можно писать в вызове функции и в литерале списка, а вот внутри comprehension это было SyntaxError. В Python 3.15 запрет сняли: *элемент означает «вставь сюда все его элементы». Работает в списках, множествах и генераторах, включая асинхронные.
# Python 3.15+
groups = [[1, 2], [3], [4, 5]]
flat = [*g for g in groups] # [1, 2, 3, 4, 5]
uniq = {*g for g in groups} # множество
lazy = (*g for g in groups) # ленивый генератор
Двойная звёздочка делает то же для словарей. Это удобно для слоёных настроек «по умолчанию → файл → флаги»: каждый следующий слой перекрывает ключи предыдущего.
# Python 3.15+
layers = [{"debug": False, "port": 80}, {"port": 8080}, {"debug": True}]
config = {**layer for layer in layers}
# {'debug': True, 'port': 8080}
Две оговорки из курса. Звёздочку нельзя совмещать с условным выражением без скобок: [*w if w else 0 for w in weeks] — ошибка синтаксиса, условие нужно обернуть в скобки. И такой код не запустится на 3.14 и ниже: если библиотека поддерживает старые версии, оставьте двойной for или itertools.chain.from_iterable.
Асинхронные генераторы
Если в функции async def есть yield, получается асинхронный генератор. По нему проходят async for, а внутри можно ждать сеть или диск через await. Есть и асинхронные comprehensions.
import asyncio
async def ticks(n):
for i in range(n):
await asyncio.sleep(0.1)
yield i
async def main():
result = [i async for i in ticks(3)]
print(result) # [0, 1, 2]
asyncio.run(main())
Типичные ошибки
- Comprehension на три этажа. Два
for, дваifи тернарник в одной строке никто не прочитает через неделю. Не помещается в строку — пишите обычный цикл: PEP 8 ставит читаемость выше краткости. - Повторный проход по генератору. Второй
sum(gen)вернёт 0, потому что генератор уже исчерпан. - Comprehension ради побочного эффекта.
[print(x) for x in xs]строит ненужный список изNone. Для действий — циклfor. - Ожидание списка от генераторного выражения.
(x for x in xs)[0]—TypeError: у генератора нет индексов.
Задачи на генераторы для практики
- Напишите генератор
fib(), бесконечно выдающий числа Фибоначчи, и возьмите первые 10 черезislice. - Одной строкой получите словарь «слово → длина» для слов длиннее трёх букв.
- Посчитайте сумму квадратов чётных чисел до миллиона, не создавая список.
- Напишите генератор
chunks(seq, n), выдающий куски списка поnэлементов.
Итог
Итератор отдаёт элементы по одному через next(), генератор — самый простой способ его написать: функция с python yield или выражение в круглых скобках. Генераторы списков, словарей и множеств собирают готовую коллекцию в одну строку; фильтр if ставится в конец, выбор if else — в начало. Нужно экономить память или обработать поток данных один раз — берите генератор, нужен результат многократно — список. Comprehensions, генераторы и новая распаковка из Python 3.15 разобраны на практике в курсе на примере трекера расходов.
Частые вопросы
Можно ли использовать return в генераторе?
Да. return без значения просто завершает генератор, а return со значением кладёт его в атрибут value исключения StopIteration; это значение получает выражение yield from.
Как превратить генератор в список?
Передайте его в list(): list(gen). Учтите, что генератор после этого исчерпан, а все значения окажутся в памяти.
Что быстрее: list comprehension или цикл с append?
Обычно comprehension немного быстрее, потому что не вызывает метод append на каждой итерации. Но выбирать стоит по читаемости, разница редко бывает решающей.
Как узнать, что объект — генератор?
Используйте inspect.isgenerator(obj) для объекта или inspect.isgeneratorfunction(f) для функции; проверить, что объект итератор, можно через isinstance(obj, collections.abc.Iterator).
Чем генераторное выражение отличается от list comprehension?
Квадратные скобки сразу строят весь список в памяти, круглые создают генератор, который вычисляет элементы по запросу и проходится только один раз.
Эта тема подробно разобрана с практикой в курсе «Python 3.15: от новичка до профи».
Перейти к курсу