Генераторы в Python

Генератор — это самый удобный способ создать итератор в Python. Вместо того чтобы писать класс с __iter__ и __next__, вы пишете обычную функцию, заменяя return на yield. Python сам сделает из неё итератор.

Генераторы — это про ленивые вычисления: значения не считаются заранее и не лежат в памяти все сразу. Они производятся по одному, когда их запрашивают. Поэтому генератор может обрабатывать миллиарды элементов, занимая в памяти ровно столько, сколько нужно для одного.


Самый простой генератор

def countdown(n):
    while n > 0:
        yield n
        n -= 1

for x in countdown(3):
    print(x)

Вывод:

3
2
1

Заметьте: при первом вызове countdown(3) тело функции не выполняется — возвращается объект-генератор. Код запускается только когда мы начинаем просить значения через next() или for.


Как работает yield

yield — это «пауза с возвращением значения». Когда исполнение доходит до yield, оно:

  1. Отдаёт значение наружу.

  2. Замораживает состояние функции (локальные переменные, точку выполнения).

  3. Ждёт следующего next().

  4. На следующем next() продолжает работу с того же места.

gen = countdown(3)

next(gen) --> входим в функцию, идём до yield, отдаём 3, замираем
next(gen) --> продолжаем, n=2, отдаём 2, замираем
next(gen) --> продолжаем, n=1, отдаём 1, замираем
next(gen) --> цикл завершился, выходим из функции
              --> StopIteration

Зачем нужны генераторы

Самое заметное преимущество — экономия памяти:

# Список — все 10 миллионов чисел в памяти сразу
squares_list = [x*x for x in range(10_000_000)]   # ~400 МБ

# Генератор — ни одного числа в памяти сверх текущего
squares_gen = (x*x for x in range(10_000_000))    # ~200 байт

Второе — ленивость: данные считаются только если понадобились. Можно описать бесконечную последовательность:

def naturals():
    n = 1
    while True:
        yield n
        n += 1

from itertools import islice
print(list(islice(naturals(), 5)))   # [1, 2, 3, 4, 5]

Генераторные выражения

Краткая форма — как list comprehension, но в круглых скобках:

squares = (x*x for x in range(10))
print(sum(squares))     # 285

Часто их даже не присваивают, а сразу передают в функцию:

total = sum(x*x for x in range(10))         # скобки можно опустить
has_big = any(x > 100 for x in numbers)
names = ', '.join(user['name'] for user in users)

Практический пример: чтение большого файла

Допустим, нам нужно посчитать сумму чисел из файла с миллионом строк, и каждая строка — это число.

def read_numbers(path):
    with open(path, encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if line:
                yield int(line)

total = sum(read_numbers('numbers.txt'))
print(total)

Файл читается потоково — в памяти одновременно живёт только одно число и одна текущая строка. То же самое через список заняло бы сотни мегабайт.


Практический пример: пайплайн обработки

Генераторы можно складывать в цепочку — каждый этап получает данные от предыдущего и отдаёт дальше. Это похоже на Unix-пайпы.

def read_lines(path):
    with open(path, encoding='utf-8') as f:
        for line in f:
            yield line.rstrip()

def only_errors(lines):
    for line in lines:
        if 'ERROR' in line:
            yield line

def extract_codes(lines):
    for line in lines:
        parts = line.split()
        if len(parts) >= 3:
            yield parts[2]

# Собираем пайплайн
lines = read_lines('app.log')
errors = only_errors(lines)
codes = extract_codes(errors)

for code in codes:
    print(code)

Ни на одном этапе данные не накапливаются — обработка идёт по одной строке.


yield from

Когда генератор делегирует работу другому итерируемому объекту, используют yield from. Это короче и быстрее, чем ручной цикл:

def flatten(nested):
    for sublist in nested:
        yield from sublist

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

Двусторонняя связь: send()

Генератор может не только выдавать, но и принимать значения через generator.send(value). Тогда yield становится выражением, возвращающим переданное значение.

def echo():
    while True:
        received = yield
        print('Получено:', received)

gen = echo()
next(gen)              # обязательно "прокрутить" до первого yield
gen.send('hello')      # Получено: hello
gen.send(42)           # Получено: 42

На этой механике построены сопрограммы и (исторически) asyncio.


Генератор vs функция со списком

функция со списком:                функция-генератор:
-----------------                  -----------------
def f(n):                          def g(n):
    result = []                        for i in range(n):
    for i in range(n):                     yield i*i
        result.append(i*i)
    return result

f(5) -> [0, 1, 4, 9, 16]            g(5) -> <generator object>
                                    list(g(5)) -> [0, 1, 4, 9, 16]

готовый список сразу               значения по требованию
занимает память                    почти не занимает память

Подводные камни

Предупреждение

  • Генератор одноразовый. Прошли по нему — он пуст. Чтобы пройти ещё раз — создайте новый.

  • ``return`` в генераторе не возвращает значение, а возбуждает StopIteration. Можно использовать как «ранний выход».

  • Не закрытые файлы. Если генератор открыл файл и его перестали использовать на середине, файл не закроется сразу. Используйте with внутри генератора или contextlib.closing.

  • ``list(gen)`` сводит на нет ленивость. Используйте только если правда нужен весь список.


Смотрите также


Примечание

Лицензия и источники

Часть материала адаптирована из официальной документации Python (https://docs.python.org/3/tutorial/classes.html#generators), доступной под Python Software Foundation License Version 2 (PSF License). Адаптация, переработка, оригинальные примеры и пояснения — © AlashEd Wiki.