Генераторы: 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 использует другую модель через каналы и горутины.
Мини-задание
- Базовый генератор:
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]
- 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]
- Бесконечный генератор + 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.