История вопроса
Прежде чем Python 2.5 ввел оператор with через PEP 343, управление ресурсами требовало явных блоков try/finally, разбросанных по всему коду. Хотя эта схема работала, она была многословной и подверженной ошибкам для простых сценариев приобретения и освобождения ресурсов. Модуль contextlib был введен, чтобы уменьшить этот шаблон, позволяя разработчикам писать менеджеры контекста как функции-генераторы, используя декоратор @contextmanager, чтобы преобразовать последовательно выглядящие генераторы в объекты, удовлетворяющие протоколу управления контекстом.
Проблема
Функция-генератор по своей природе реализует протокол итератора (__iter__, __next__), а не протокол менеджера контекста (__enter__, __exit__). Основная проблема заключается в том, чтобы преодолеть различия между этими протоколами: при входе в блок with код настройки до yield должен выполняться; при выходеCleanup-код после yield должен быть выполнен независимо от исключений. Кроме того, исключения, возникающие внутри блока with, должны быть инъектированы обратно в генератор в точке приостановки yield, что позволяет логике обработки исключений генератора выполнять операции очистки.
Решение
Декоратор оборачивает функцию-генератор в класс GeneratorContextManager (реализованный на C в современном CPython). Каждое вызов создает новый итератор-генератор. Метод __enter__ вызывает next() на этом итераторе, выполняя функцию до оператора yield, и возвращает возвращаемое значение для привязки к переменной as. Метод __exit__ получает детали исключения; если никаких исключений не произошло, он снова вызывает next(), чтобы возобновить и исчерпать генератор. Если произошло исключение, он вызывает метод throw() генератора, инъектируя исключение в приостановленную точку yield. Это позволяет блокам except или finally генератора обрабатывать очистку. Если throw() возвращает нормально (исключение поймано), __exit__ возвращает True, чтобы подавить исключение; в противном случае оно передается дальше.
from contextlib import contextmanager @contextmanager def managed_connection(): conn = create_connection() try: print("Соединение установлено") yield conn except NetworkError: conn.rollback() raise finally: conn.close() print("Соединение закрыто") with managed_connection() as c: c.query("SELECT * FROM data")
Описание проблемы: Служба обработки данных с высоким пропускным能力 должна была обрабатывать временные файлы сброса, когда объем памяти превышал лимиты. Устаревшая реализация дублировала логику создания и удаления файлов в 12 различных модулях обработки, что приводило к утечкам дескрипторов файлов в условиях крайних ошибок и усложняло обслуживание.
Рассматриваемые решения:
Ручные блоки try/finally были первоначальным подходом. Каждый сайт использования оборачивал операции с файлами в явные try/finally, чтобы гарантировать вызов os.unlink(). Это предлагало явный поток управления без дополнительной накладной, но оказалось многословным на восемь строк на сайт использования и сильно подверженным ошибкам. Разработчики иногда помещали логику очистки в неправильный блок finally, и модификация поведения последовательно по всем модулям была сложной, когда добавлялись требования к логированию.
Был рассмотрен менеджер контекста на основе класса как многоразовая альтернатива. Класс TempSpillFile реализовал бы __enter__, чтобы создать файл, и __exit__, чтобы удалить его. Хотя он был многоразовым и следовал стандартному протоколу, определение класса визуально отделяло настройку от очистки многими строками, ухудшая читаемость. Также требовалось пятнадцать строк стандартного кода для того, что концептуально было простым жизненным циклом ресурса, затемняя фактическую логику.
Подход с генератором и @contextmanager был окончательным вариантом. Функция-генератор temp_spill_file() создала бы файл, возвратила его через yield и использовала бы try/finally для удаления. Это снизило бы дублирование кода и держало бы логики настройки и очистки рядом в исходном коде, используя знакомый синтаксис обработки исключений. Однако это налагало ограничение на одно использование и точка приостановки yield могла бы смутить разработчиков, ожидающих синхронного выполнения.
Выбранное решение и результат: Подход с @contextmanager был выбран, потому что он минимизировал дублирование кода при максимизации ясности во время обзоров кода. Соседство логики приобретения и освобождения сделало жизненный цикл ресурса немедленно очевидным. Рефакторинг уменьшил код управления ресурсами с девяноста шести строк до двенадцати строк по всему коду. Статический анализ подтвердил отсутствие утечек дескрипторов файлов в течение следующего квартала производственногоuse.
Как GeneratorContextManager обрабатывает исключения, возникающие в фазе настройки (до yield) и в фазе очистки (после yield)?
Если исключение происходит до yield в генераторе, генератор никогда не приостанавливается; __enter__ немедленно передает это исключение и __exit__ никогда не вызывается. Если исключение возникает внутри блока with (после yield), генератор приостанавливается. Затем __exit__ вызывает generator.throw(exc_type, exc_val, exc_tb), которое возобновляет генератор на строке yield с активным исключением. Это позволяет блокам except или finally генератора выполняться. Кандидаты часто упускают из виду, что throw() фактически возобновляет выполнение и что исключение считается происходящим на выражении yield с точки зрения генератора.
Почему декорированный contextmanager-генератор накладывает ограничение на одну точку yield, и какая конкретная ошибка возникает, если это ограничение нарушается?
Протокол менеджера контекста предполагает один вход и выход. Если генератор возвращает второй раз через yield — потому что __exit__ вызывает next() (без исключения) и генератор возвращает снова вместо возврата, или потому что throw() вызывается, и генератор обрабатывает исключение, затем возвращает снова — GeneratorContextManager вызывает RuntimeError с сообщением "генератор не остановился". Это происходит потому, что конечный автомат ожидает, что генератор исчерпан после очистки. Кандидаты часто путают это со стандартной итерацией, где несколько возвращений допустимы, не осознавая, что yield действует как граница приостановки/возобновления для контекста, а не как последовательность производства значений.
При каких обстоятельствах метод __exit__ GeneratorContextManager подавляет исключение, возникшее в блоке with, и как это взаимодействует с обработкой исключений генератора?
__exit__ подавляет исключение (возвращает True), только если инъецированное исключение через throw() поймано в генераторе, и генератор достигает своего конца (вызывает StopIteration) без повторного возбуждения исключения или возбуждения нового. Если генератор ловит исключение и позволяет вызову throw() завершиться нормально, __exit__ интерпретирует это как успешную обработку и возвращает True. Если генератор не ловит исключение, throw() передает его дальше, и __exit__ возвращает None (ложное), позволяя исключению снова передаться. Кандидаты часто упускают из виду, что простое наличие try/except внутри генератора недостаточно; исключение должно быть поймано конкретно из вызова throw() и не повторно возбуждено, и что явный return или выход в конце после ловли необходимы для подавления.