Генераторы: yield, generator expressions, lazy evaluation

В прошлом уроке мы вручную писали итераторы через __iter__ и __next__. Это многословно. Генераторы упрощают - функция с yield автоматически становится итератором. Это один из самых элегантных инструментов Python для обработки данных лениво и компактно.

Базовый генератор

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

for x in countdown(5):
    print(x)   # 5, 4, 3, 2, 1

Функция с yield это генератор-функция. Её вызов возвращает генератор-объект (iterator), не выполняет тело сразу. Каждый yield приостанавливает выполнение и возвращает значение. Следующий next() продолжает с того же места.

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

def gen():
    print("start")
    yield 1
    print("after 1")
    yield 2
    print("after 2")
    yield 3
    print("done")

g = gen()         # ничего не печатается - функция не запущена
print(next(g))    # start, потом 1
print(next(g))    # after 1, потом 2
print(next(g))    # after 2, потом 3
print(next(g))    # done, потом StopIteration

Состояние сохраняется между вызовами - локальные переменные, позиция выполнения. Это магия yield - функция «приостанавливается» и продолжается.

Generator expression - compact синтаксис

Как list comprehension, но в круглых скобках:

squares = (x ** 2 for x in range(10))   # generator
print(next(squares))   # 0
print(next(squares))   # 1

# С фильтром
even_squares = (x ** 2 for x in range(20) if x % 2 == 0)

# В функции
total = sum(x ** 2 for x in range(1_000_000))   # без промежуточного списка

Generator expression выгодно когда:

  • Промежуточная коллекция не нужна
  • Данные большие или бесконечные
  • Передаём в функцию которая может работать с iterator

Бесконечные генераторы

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

# Берём только первые 10
import itertools
first_10 = list(itertools.islice(naturals(), 10))
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Бесконечный генератор не упадёт - значения генерируются лениво. Главное - не делать list(naturals()) без limit, иначе вечный цикл и переполнение памяти.

yield from - делегирование

Если хочешь yield-ить значения из другого iterable:

def chain(*iterables):
    for it in iterables:
        for x in it:
            yield x

# С yield from короче
def chain(*iterables):
    for it in iterables:
        yield from it

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

yield from iterable эквивалентно циклу for x in iterable: yield x, но короче и обрабатывает send/throw корректно для co-routines (был важен до asyncio). Через yield from удобно обходить рекурсивные структуры: вложенные списки, дерево каталогов, JSON произвольной глубины.

Pipeline - композиция генераторов

Главное преимущество генераторов - возможность собирать конвейеры обработки данных (тот же приём, что pipeline на каналах в Go, только в одном потоке):

def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.rstrip()

def filter_errors(lines):
    for line in lines:
        if "ERROR" in line:
            yield line

def parse_timestamp(lines):
    for line in lines:
        parts = line.split()
        if parts:
            yield parts[0], " ".join(parts[1:])

# Pipeline - lazy от начала до конца
lines = read_lines("server.log")
errors = filter_errors(lines)
parsed = parse_timestamp(errors)

for timestamp, message in parsed:
    print(f"{timestamp}: {message}")

Каждый этап - генератор. Данные обрабатываются по одному элементу, без материализации промежуточных результатов. Этот стиль идеален для ETL-задач и обработки логов.

return в генераторе

return в генераторе останавливает итерацию (как StopIteration), значение можно передать:

def gen():
    yield 1
    yield 2
    return "done"   # необязательное значение
    yield 3   # недостижимо

g = gen()
print(next(g))   # 1
print(next(g))   # 2
try:
    print(next(g))
except StopIteration as e:
    print(e.value)   # 'done'

Чаще return без значения - просто завершить генератор раньше.

send - двусторонняя коммуникация

yield может возвращать значение через send():

def echo():
    while True:
        received = yield
        print(f"Получил: {received}")

g = echo()
next(g)             # prime: запуск до первого yield
g.send("hello")     # Получил: hello
g.send("world")     # Получил: world

send(value) отправляет value в генератор - это становится результатом текущего yield. Первый раз нужен next(g) (или send(None)) для «разогрева» до первого yield.

Это основа корутин в Python (до появления async/await). Сейчас редко используется напрямую - asyncio предоставляет удобный синтаксис.

throw и close

def gen():
    try:
        yield 1
        yield 2
    except ValueError:
        yield "error"
    finally:
        print("cleanup")

g = gen()
print(next(g))         # 1
print(g.throw(ValueError))   # error
g.close()              # cleanup
  • throw(exc) - бросает исключение внутрь генератора (на месте yield)
  • close() - бросает GeneratorExit, генератор должен завершиться

finally в генераторе всегда выполняется при close - полезно для cleanup ресурсов.

Lazy evaluation - экономия памяти

import sys

# List comprehension - всё в памяти
big_list = [x ** 2 for x in range(10_000_000)]
print(sys.getsizeof(big_list))   # ~80 МБ

# Generator expression - почти ничего
big_gen = (x ** 2 for x in range(10_000_000))
print(sys.getsizeof(big_gen))    # ~200 байт

# Можно одинаково суммировать
print(sum(big_gen))

Для одноразовой обработки больших данных genераторы лучше во всех аспектах. Для многократного прохода - нужен список (или регенерация).

Lazy file reading

def read_csv_lazy(path):
    """Читает CSV построчно, не загружая весь файл."""
    with open(path) as f:
        header = next(f).rstrip().split(",")
        for line in f:
            values = line.rstrip().split(",")
            yield dict(zip(header, values))

for row in read_csv_lazy("huge_file.csv"):
    process(row)   # обрабатываем по одной строке

С файлом в гигабайты обычное чтение через f.read() приведёт к out of memory. Генератор обрабатывает построчно.

Composing с itertools

Генераторы отлично работают с itertools:

import itertools

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Первые 10 чётных чисел Фибоначчи
even_fibs = itertools.islice(
    (x for x in fibonacci() if x % 2 == 0),
    10
)
print(list(even_fibs))
# [0, 2, 8, 34, 144, ...]

fibonacci() бесконечен, фильтр оставляет чётные, islice берёт первые 10. Всё ленивое - вычислится ровно столько, сколько нужно.

tee для разветвления

Если нужно пройти генератор несколько раз:

import itertools

def expensive_gen():
    for x in range(10):
        print(f"computing {x}")
        yield x * x

# Без tee - повторное вычисление
a = list(expensive_gen())
b = list(expensive_gen())   # снова computing 0..9

# С tee - буферизация
g = expensive_gen()
a_iter, b_iter = itertools.tee(g, 2)
a = list(a_iter)
b = list(b_iter)   # без повторных computing

tee буферизует значения - если потребитель далеко позади, буфер растёт. На неограниченных генераторах опасно.

Async generators

С Python 3.6 есть async def с yield - асинхронные генераторы:

async def fetch_pages(urls):
    for url in urls:
        page = await fetch(url)
        yield page

async for page in fetch_pages(urls):
    process(page)

Подробно про asyncio в следующих уроках 35-36.

Распространённые ошибки

1. Reuse истощённого генератора

gen = (x ** 2 for x in range(5))
list(gen)   # [0, 1, 4, 9, 16]
list(gen)   # [] - истощён

То же что с iterators. Создавай новый генератор для повторного прохода.

2. Side effects в генераторе

results = []
def gen():
    for x in range(10):
        results.append(x)   # SIDE EFFECT
        yield x

# Если генератор не исчерпан, side effects неполные
g = gen()
next(g)   # results = [0]
# забыли пройти до конца
print(results)   # [0] - не все 10

Генераторы для чистых вычислений. Side effects через генератор - источник багов.

3. Mutable defaults в yield

def bad():
    items = []
    while True:
        x = yield items
        items.append(x)   # клиент видит модификации!

Возвращаемый mutable объект может быть изменён клиентом. Если хочешь immutable вид - возвращай tuple или копию.

4. close без cleanup

def gen_with_resource():
    f = open("data.txt")
    for line in f:
        yield line
    # f.close() может не выполниться если потребитель закроет генератор

# Правильно через try/finally
def gen_with_resource():
    f = open("data.txt")
    try:
        for line in f:
            yield line
    finally:
        f.close()

# Или через context manager
def gen_with_resource():
    with open("data.txt") as f:
        yield from f

Сравнение с Go и PHP

В Go нет ключевого слова yield - используются channels или функции-итераторы:

func countdown(start int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := start; i > 0; i-- {
            ch <- i
        }
    }()
    return ch
}

for x := range countdown(5) {
    fmt.Println(x)
}

В PHP с PHP 5.5 появилась поддержка yield:

function countdown($start) {
    while ($start > 0) {
        yield $start;
        $start--;
    }
}

foreach (countdown(5) as $x) {
    echo $x;
}

Семантика похожая. Python и PHP - примерно одно поведение, Go использует другую модель через каналы и горутины.

Мини-задание

  1. Базовый генератор:
def fibonacci_n(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

print(list(fibonacci_n(10)))   # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
  1. Pipeline:
def read_numbers():
    for n in range(1, 21):
        yield n

def square(numbers):
    for n in numbers:
        yield n * n

def evens(numbers):
    for n in numbers:
        if n % 2 == 0:
            yield n

pipeline = evens(square(read_numbers()))
print(list(pipeline))
# [4, 16, 36, 64, 100, 144, 196, 256, 324, 400]
  1. Бесконечный генератор + islice:
import itertools

def primes():
    """Бесконечный генератор простых чисел."""
    n = 2
    while True:
        is_prime = all(n % d != 0 for d in range(2, int(n ** 0.5) + 1))
        if is_prime:
            yield n
        n += 1

print(list(itertools.islice(primes(), 10)))
# [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

Что дальше

Освоили генераторы. В следующем уроке - asyncio: основы асинхронного программирования, event loop, async/await. Это переход к concurrent коду в Python.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.