Генератор в Python
yield - одно ключевое слово, которое превращает обычную функцию в машину ленивых вычислений
Содержание
Коротко и чётко
Генератор - это объект, который производит значения по одному, только тогда, когда они нужны. Не хранит все данные в памяти сразу.
Создаётся двумя способами:
- Функция-генератор - обычная функция с
yieldвместоreturn - Генераторное выражение - как list comprehension, но в круглых скобках
Генератор - это итератор. У него автоматически есть __iter__ и __next__. Разница с ручным итератором - не нужно писать класс, yield делает всё сам.
Главные отличия от списка:
- Список держит все элементы в памяти. Генератор - только текущий
- Список можно итерировать много раз. Генератор - только один раз
- Список вычисляет всё сразу. Генератор - по требованию (lazy)
Генератор - это итератор, созданный через функцию с
yieldили генераторное выражение. При вызове функции-генератора код не выполняется - возвращается объект-генератор. Каждыйnext()выполняет код до следующегоyield, возвращает значение и замораживает состояние функции. При следующем вызове продолжает с того же места. Ключевое преимущество перед списком - память: генератор не хранит все элементы сразу. Перед ручным итератором - простота: не нужен класс с__iter__и__next__, достаточно одногоyield.
Простыми словами
Разберём на конкретном примере - шаг за шагом, без спешки.
Сначала вспомним обычную функцию
Обычная функция работает так: запустилась, выполнила весь код, вернула результат через return, завершилась. Всё. Она забыла всё что было внутри.
def get_numbers():
return [1, 2, 3] # создаёт весь список сразу и возвращает
numbers = get_numbers() # [1, 2, 3] - всё уже в памяти
Теперь смотри что делает yield
Замени return на yield - и функция превращается в генератор. Поведение меняется кардинально:
def get_numbers():
yield 1 # отдай 1 и ЗАМОРОЗЬ себя
yield 2 # потом отдай 2 и снова заморозь
yield 3 # потом отдай 3 и заморозь
Когда ты вызываешь эту функцию - она не выполняется. Вообще. Ни одна строчка кода не запускается. Вместо этого Python возвращает тебе объект-генератор - что-то вроде пульта управления.
gen = get_numbers() # код внутри ещё не запускался!
print(gen) # <generator object get_numbers at 0x...>
Что происходит при каждом next()
Только когда ты вызываешь next() - код начинает выполняться. До первого yield. Отдаёт значение. И замораживается - запоминает где остановился.
gen = get_numbers()
print(next(gen)) # запускается до первого yield -> 1, заморозился
print(next(gen)) # размораживается, идёт до второго yield -> 2, заморозился
print(next(gen)) # размораживается, идёт до третьего yield -> 3, заморозился
print(next(gen)) # размораживается, кода больше нет -> StopIteration
Представь кино на паузе. next() - это нажать «воспроизвести». yield - это автоматическая пауза. Функция просыпается, делает шаг, отдаёт значение, снова засыпает. И помнит, где была.
Почему это экономит память
Представь, что тебе нужны числа от 1 до миллиона.
# Список - создаёт ВСЕ миллион чисел сразу, все в памяти
numbers = [x for x in range(1_000_000)] # ~8 МБ в RAM
# Генератор - хранит только текущее число
numbers = (x for x in range(1_000_000)) # ~200 байт в RAM
Генератор не знает, каким будет следующее число, пока ты не спросишь. Он вычисляет их по одному, по требованию. Остальные просто не существуют в памяти до своей очереди.
Генераторное выражение - совсем коротко
Это как list comprehension, только в круглых скобках. Результат - не список, а генератор:
# List comprehension - список, всё в памяти
squares_list = [x**2 for x in range(10)] # [0, 1, 4, 9, ...]
# Generator expression - генератор, по требованию
squares_gen = (x**2 for x in range(10)) # объект-генератор
Код
yield в действии - пошагово
def countdown(n):
print(f"Старт с {n}")
while n > 0:
yield n # заморозиться и отдать n
print(f"Продолжаю после yield, n={n}")
n -= 1
print("Конец!")
gen = countdown(3) # ничего не напечаталось - код не запускался
print(next(gen))
# Старт с 3
# 3 <- yield отдал 3 и заморозился
print(next(gen))
# Продолжаю после yield, n=3 <- размороозился с того места
# 2 <- yield отдал 2 и заморозился
print(next(gen))
# Продолжаю после yield, n=2
# 1
print(next(gen))
# Продолжаю после yield, n=1
# Конец!
# StopIteration
Практичный пример - чтение большого файла
# ПЛОХО - загружает весь файл в память сразу
def read_file_bad(path):
with open(path) as f:
return f.readlines() # список из миллиона строк в RAM
lines = read_file_bad("huge.log")
for line in lines:
process(line)
# ХОРОШО - генератор, одна строка в памяти за раз
def read_file_good(path):
with open(path) as f:
for line in f:
yield line.strip() # отдаём по одной строке
for line in read_file_good("huge.log"):
process(line)
# Файл хоть 100 ГБ - память не кончится
Генераторное выражение vs list comprehension
import sys
# Список - все данные в памяти
squares_list = [x**2 for x in range(10_000)]
print(sys.getsizeof(squares_list)) # ~87 624 байт
# Генератор - только логика вычисления
squares_gen = (x**2 for x in range(10_000))
print(sys.getsizeof(squares_gen)) # 208 байт
# Результат одинаковый - способ хранения разный
print(sum(squares_list)) # 333283335000
print(sum(squares_gen)) # 333283335000
Генератор с yield в цепочке обработки данных
def read_numbers(data):
"""Читаем числа по одному"""
for n in data:
yield n
def filter_even(numbers):
"""Пропускаем только чётные"""
for n in numbers:
if n % 2 == 0:
yield n
def multiply(numbers, factor):
"""Умножаем каждое на factor"""
for n in numbers:
yield n * factor
# Цепочка генераторов - данные текут по конвейеру
# Ни один шаг не создаёт промежуточного списка
data = range(1_000_000)
pipeline = multiply(filter_even(read_numbers(data)), 10)
# Вычисления происходят только здесь, по одному элементу
for value in pipeline:
print(value)
break # напечатает 20 - первое чётное * 10
Бесконечный генератор
def infinite_counter(start=0):
"""Считает бесконечно - со списком это невозможно"""
n = start
while True: # нет StopIteration - генератор бесконечный
yield n
n += 1
counter = infinite_counter()
print(next(counter)) # 0
print(next(counter)) # 1
print(next(counter)) # 2
# Берём первые 5 чисел Фибоначчи из бесконечного генератора
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
first_five = [next(fib) for _ in range(5)]
print(first_five) # [0, 1, 1, 2, 3]
Глубже
Генератор - это синтаксический сахар над итератором. Под капотом Python превращает функцию с yield в класс с __iter__ и __next__. Писать такой класс вручную - можно, но зачем, если есть yield. Результат одинаковый, генератор просто в разы короче.
yield vs yield from. Обычный yield отдаёт одно значение. yield from делегирует итерацию другому итерируемому объекту - разворачивает его и отдаёт элементы по одному. Удобно когда генератор вызывает другой генератор:
def inner():
yield 1
yield 2
def outer_bad():
for item in inner(): # руками разворачиваем inner
yield item
yield 3
def outer_good():
yield from inner() # делегируем inner - то же самое, короче
yield 3
print(list(outer_good())) # [1, 2, 3]
Генераторы одноразовые. Как и любой итератор - исчерпанный генератор нельзя перемотать. StopIteration брошен - всё, генератор мёртв. Нужно создать новый. Это важно помнить когда передаёшь генератор в несколько функций.
send() - двусторонняя связь с генератором. Через send() можно не только получать значения из генератора, но и передавать значения внутрь. yield в этом случае становится выражением с результатом. Это основа корутин - асинхронного кода на генераторах, который предшествовал async/await.
def accumulator():
total = 0
while True:
value = yield total # получаем снаружи через send()
if value is None:
break
total += value
acc = accumulator()
next(acc) # запускаем генератор до первого yield
acc.send(10) # total = 10
acc.send(20) # total = 30
print(acc.send(5)) # 35