Генератор в Python

yield - одно ключевое слово, которое превращает обычную функцию в машину ленивых вычислений

Генератор в Python.md

Содержание


Коротко и чётко

Генератор - это объект, который производит значения по одному, только тогда, когда они нужны. Не хранит все данные в памяти сразу.

Создаётся двумя способами:

  • Функция-генератор - обычная функция с 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

FAQ

Чем генератор отличается от итератора? Итератор - это протокол: объект с `__iter__` и `__next__`. Генератор - это способ создать итератор без написания класса. Функция с `yield` автоматически реализует протокол итератора. Любой генератор является итератором, но не наоборот - итератор можно написать вручную через класс без единого `yield`.
Чем генератор отличается от списка? Список хранит все элементы в памяти сразу, его можно итерировать много раз. Генератор хранит только текущий элемент, вычисляет следующий по требованию, итерируется только один раз. Список - когда нужен произвольный доступ по индексу или несколько проходов. Генератор - когда данных много и важна экономия памяти.
Можно ли узнать длину генератора? Нет. `len()` на генераторе упадёт с `TypeError`. Генератор не знает сколько элементов выдаст - особенно если он бесконечный. Если нужна длина - придётся пройтись по всем элементам: `sum(1 for _ in gen)`. Но после этого генератор будет исчерпан.
Что лучше - генераторное выражение или list comprehension? Зависит от задачи. Генераторное выражение - когда данных много, нужна экономия памяти, или результат будет использован один раз. List comprehension - когда нужен произвольный доступ по индексу, несколько проходов, или результат небольшой. Простое правило: если сразу передаёшь в `sum()`, `max()`, `any()` - бери генераторное выражение. Если присваиваешь переменной и используешь несколько раз - бери список.
Что происходит с return в функции-генераторе? `return` в генераторе допустим - он просто бросает `StopIteration`. Можно даже передать значение: `return "готово"` - оно попадёт в атрибут `value` исключения `StopIteration`. Обычно это используется внутри `yield from` для передачи финального результата. В обычном `for` цикле это значение теряется.
Когда использовать генератор, а когда написать итератор-класс? Генератор - в 95% случаев. Он короче, читабельнее и делает то же самое. Итератор-класс нужен когда: нужно несколько независимых состояний, нужны дополнительные методы кроме `__next__`, нужно наследование, или сложная логика которую трудно выразить линейно через `yield`.