Модуль 7 · Итераторы и генераторы

Справочник по Python · модуль 7 · итераторы и генераторы

Глава 7.4

7.4

Завершение генератора:
try-finally и close()

Генератор редко дочитывают до конца: цикл прерывают break, берут первые десять значений и уходят. Код после последнего yield тогда не выполняется — и открытый внутри файл рискует остаться незакрытым. Решение — try-finally или with внутри самого генератора.

Категория
Генераторы
Уровень
Продвинутый
Связанные темы
try-finally, менеджер контекста, работа с файлами
Зачем это знать
Освобождать ресурсы, даже если генератор прервали

§1Частичное потребление генератора

Генератор выдаёт значения по одному, и потребитель волен остановиться в любой момент: нашёл нужную строку — вышел из цикла, набрал сто записей — прекратил чтение. Такое частичное потребление — не редкость, а обычный сценарий; ради него генераторы во многом и существуют.

Но у него есть оборотная сторона. Функция-генератор остаётся навсегда приостановленной на yield, и всё, что было написано после этой точки, никогда не выполнится само по себе.

§2Код после yield может не выполниться

Схемавыход из цикла до конца последовательности
for n in countdown(10):
    if n == 2:
        break
    # команды

Цикл прерывается на значении 2, генератор countdown так и остаётся стоять на своём yield. Если внутри него был открыт файл, установлено соединение или взята блокировка — освободить это некому.

§3Блок try-finally внутри генератора

Чтобы завершающие действия выполнялись в любом случае, их помещают в finally — тот самый блок из главы 8.1, который срабатывает при любом исходе.

main.pyзавершающее действие защищено
def countdown(n):
    print('Количество всего', n)
    try:
        while n > 0:
            yield n
            n = n - 1
    finally:
        print('Текущий элемент на позиции', n)

Здесь yield стоит внутри try. Это и есть ключ: что бы ни случилось с генератором — исчерпался, был прерван или уничтожен — блок finally выполнится.

§4Прерванный перебор

main.pyберём значения до двойки
for n in countdown(10):
    if n == 2:
        break

print("после цикла")
Выводfinally сработал на позиции 2
Количество всего 10
Текущий элемент на позиции 2
после цикла

Генератор не отработал до конца — до нуля он не досчитал, — но завершающая строка всё равно выполнилась, и видно, на каком элементе остановились.

§5Полный перебор для сравнения

main.pyтот же генератор без break
for n in countdown(3):
    print(n)
Выводдосчитали до нуля
Количество всего 3
3
2
1
Текущий элемент на позиции 0

При обычном исчерпании finally тоже выполняется — просто уже на позиции 0. Один и тот же блок закрывает оба сценария.

§6Когда именно срабатывает finally

Момент выполнения зависит от того, как закончилась жизнь генератора.

Что произошлоКогда выполнится finally
генератор исчерпалсясразу, при выходе из тела функции
вызван gen.close()сразу, в момент вызова
перебор прерван, ссылок на генератор не осталоськогда объект уничтожается сборщиком мусора
внутри генератора возникло исключениепо пути наружу, до передачи исключения дальше

Третья строка требует внимания. Генераторы гарантированно выполняют блок finally, даже если не были потреблены полностью, — но происходит это при уничтожении объекта. В примере из §4 генератор был безымянным и перестал быть нужным сразу после break, поэтому уборка случилась немедленно. Если же генератор записан в переменную и переменная жива, момент отложится — до конца программы или до присваивания этому имени чего-то другого.

§7Метод close()

Не полагаться на сборщик мусора помогает метод close() — он завершает генератор явно и немедленно.

main.pyзакрываем генератор сами
gen = countdown(5)
print(next(gen))
print(next(gen))
gen.close()
print("после close")
Выводуборка произошла по команде
Количество всего 5
5
4
Текущий элемент на позиции 4
после close

После close() генератор считается завершённым: следующий next() вызовет StopIteration. Повторный close() при этом безопасен — он просто ничего не делает.

§8Исключение GeneratorExit

Изнутри закрытие выглядит как исключение: в точке, где генератор был приостановлен, возбуждается GeneratorExit. Именно оно и раскручивает блоки finally.

main.pyгенератор видит собственное закрытие
def demo():
    try:
        yield 1
        yield 2
    except GeneratorExit:
        print("генератор закрывают")
        raise

gen = demo()
print(next(gen))
gen.close()
Выводисключение пришло в точку паузы
1
генератор закрывают

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

§9Менеджер контекста внутри генератора

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

main.pyфайл закрывается вместе с генератором
def reader(path):
    with open(path, encoding="utf-8") as file:
        for line in file:
            yield line.rstrip()

gen = reader("demo.txt")
print(next(gen))
gen.close()
Выводпрочитали одну строку из трёх
первая

Прочитана только первая строка, но файл закрыт: close() возбудил внутри генератора GeneratorExit, тот вышел из блока with, а выход из with закрывает файл. Точно так же гарантируется выполнение любого завершающего кода с участием менеджера контекста.

§10Почему это важнее, чем кажется

Генераторная функция часто пишется как библиотечная: её сочиняет один человек, а вызывает другой — и вызывающий совершенно не обязан дочитывать её до конца. Автор генератора не может рассчитывать на дисциплину потребителя.

Ресурс внутри генератораЧто будет без finally или with
открытый файлостанется открытым; записанное может не попасть на диск из-за буфера
сетевое соединениевисит до конца программы, занимая порт и память
блокировкадругой участок кода будет ждать её бесконечно
временный файлостанется на диске

Правило простое: если генератор что-то открывает или захватывает, yield должен находиться внутри with или try-finally. Не после них — именно внутри.

§11Чего нельзя делать при закрытии

Единственное жёсткое ограничение: закрываемый генератор не имеет права выдать ещё одно значение.

main.pyyield в блоке finally
def bad():
    try:
        yield 1
    finally:
        yield 99

gen = bad()
next(gen)
gen.close()
Ошибкагенератор попытался продолжить работу
RuntimeError: generator ignored GeneratorExit

Смысл ограничения понятен: close() означает «работа окончена», и генератор не может в ответ предложить ещё одно значение. В завершающем блоке место только уборке.

§12Частые ошибки

Что написаноЧто произойдётКак правильно
уборка после цикла с yield, без finallyпри break она не выполнится никогдаперенести уборку в finally
open() в генераторе без withпрерванный генератор оставит файл открытымобернуть перебор в with
рассчитывать на немедленную уборкупока на генератор есть ссылка, finally не выполнитсявызвать gen.close() явно
yield в блоке finallyRuntimeError: generator ignored GeneratorExitв завершающем блоке только уборка
перехватить GeneratorExit без raiseтот же RuntimeError при закрытиивозбудить исключение снова или обойтись finally
next() после close()StopIteration: генератор завершёнсоздать генератор заново

§13Проверьте себя

Почему код после yield может не выполниться?

Потому что потребитель вправе прекратить перебор. Генератор остаётся приостановленным, и следующие за yield строки сами по себе не запускаются.

Как гарантировать завершающие действия?

Поместить yield внутрь try с блоком finally или внутрь with.

Что делает gen.close()?

Возбуждает внутри генератора GeneratorExit в точке паузы, отчего выполняются блоки finally, и завершает генератор.

Когда выполнится finally, если генератор просто бросили?

При уничтожении объекта сборщиком мусора — то есть не обязательно сразу. Точный момент задаёт close().

Можно ли выдать значение при закрытии генератора?

Нет. yield в блоке finally во время закрытия приводит к RuntimeError.

§14Шпаргалка

Гарантия уборкиtry: yield valuefinally: cleanup()
С ресурсомwith open(path) as f: yield from f
Закрыть явноgen.close()
Сигнал изнутриexcept GeneratorExit:

Итог главы

Генератор почти всегда потребляют частично, поэтому код после последнего yield — ненадёжное место для освобождения ресурсов. Надёжное — блок finally или менеджер контекста, внутри которых стоит сам yield: Python выполнит их и при исчерпании, и при прерывании, и при уничтожении генератора. Метод close() позволяет не ждать сборщика мусора: он возбуждает в точке паузы GeneratorExit, запускает уборку и завершает генератор. Единственное, чего при этом делать нельзя, — выдавать новые значения.

Глава 7.4 · Завершение генератора · Справочник по Python Автор: Макаров Максим Николаевич