Справочник по Python · модуль 7 · итераторы и генераторы
Глава 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, который срабатывает при любом исходе.
def countdown(n): print('Количество всего', n) try: while n > 0: yield n n = n - 1 finally: print('Текущий элемент на позиции', n)
Здесь yield стоит внутри try. Это и есть ключ: что бы ни случилось с генератором — исчерпался, был прерван или уничтожен — блок finally выполнится.
§4Прерванный перебор
for n in countdown(10): if n == 2: break print("после цикла")
Количество всего 10 Текущий элемент на позиции 2 после цикла
Генератор не отработал до конца — до нуля он не досчитал, — но завершающая строка всё равно выполнилась, и видно, на каком элементе остановились.
§5Полный перебор для сравнения
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() — он завершает генератор явно и немедленно.
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.
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 — и это самый частый способ на практике.
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Чего нельзя делать при закрытии
Единственное жёсткое ограничение: закрываемый генератор не имеет права выдать ещё одно значение.
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 в блоке finally | RuntimeError: 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 fgen.close()except GeneratorExit:Итог главы
Генератор почти всегда потребляют частично, поэтому код после последнего yield — ненадёжное место для освобождения ресурсов. Надёжное — блок finally или менеджер контекста, внутри которых стоит сам yield: Python выполнит их и при исчерпании, и при прерывании, и при уничтожении генератора. Метод close() позволяет не ждать сборщика мусора: он возбуждает в точке паузы GeneratorExit, запускает уборку и завершает генератор. Единственное, чего при этом делать нельзя, — выдавать новые значения.