Контекстные менеджеры: два метода, одна возвращаемая истина и одна тихая потеря данных
Протокол — ровно два метода на типе. Но судьбу исключения после успешного входа определяет истинность одного значения: того, что вернул __exit__. Вернул истину — исключение исчезло вместе с остатком блока, и в логе не осталось ничего.
Полное техническое изложение
TL;DR
with — это гарантия того, что выход из блока будет обработан. Что бы ни
случилось внутри — обычное завершение, return из середины, исключение, —
менеджер получит управление на выходе и сделает то, ради чего его писали:
закроет файл, отпустит блокировку, вернёт соединение. Протокол — два метода:
один вызывается на входе в блок, другой на выходе.
Отсюда главное следствие: метод выхода решает не только «убрать за собой».
as связывает то, что вернул __enter__, а не сам объект: метод без return
отдаёт None. А судьбу исключения после успешного входа определяет
истинность значения, которое вернул __exit__: ложь — исключение летит
дальше, истина — исчезает вместе с остатком блока. Отсюда тихая потеря данных:
suppress вокруг цикла вместо suppress вокруг строки даёт 100 вместо
175, без единого исключения в логе. А если __enter__ бросил исключение,
__exit__ не вызывается вовсе — гарантия справочника начинается только после
успешного входа.
Дальше — то, что отличает знающего от читавшего. Оба метода ищутся на
типе, а не на экземпляре: p.__enter__ = ... даёт TypeError. Подавить группу
исключений целиком и вырезать из неё одну ветку — разные операции, и вторую
suppress умеет только с 3.12. Цена входа в блок: with на классе — 96 нс
против 13 нс у пустого вызова, @contextmanager — 431 нс (3.13.7); на 3.14
форма с классом подешевела до 65 нс, генераторная — до 417.
- функция может закончиться не только
return, но и ошибкой, и тогда строки после неё не выполнятся; - у программы есть вещи, которые надо не просто взять, а обязательно вернуть: открытый файл, соединение, блокировку;
- запись
with open(…) as f:вы уже видели, даже если не задумывались, что за ней стоит.
__enter__,__exit__,contextlib,@contextmanager,ExitStack;- группы исключений и
except*,async with, байт-код иLOAD_SPECIAL.
База: зачем понадобился with
У программы есть вещи, которые надо не просто получить, а потом обязательно вернуть: открытый файл — закрыть, соединение — отдать, блокировку — отпустить. Вся сложность в слове «обязательно». Строка, которая освобождает ресурс, стоит в конце работы, а до конца можно и не дойти: по дороге случится ошибка, и управление уйдёт наружу мимо этой строки. Ресурс останется занятым — файл незакрытым, блокировка невозвращённой.
Поэтому освобождение пишут не «после работы», а в блоке, который выполняется в любом случае:
handle = open("data.txt")
try:
process(handle)
finally:
handle.close() # выполнится и при удаче, и при ошибкеЭто верно, но многословно, и повторять это приходится в каждом месте, где
ресурс берут. with — та же конструкция, свёрнутая в одну строку:
with open("data.txt") as handle:
process(handle)Здесь open сам знает, что нужно сделать на выходе, и делает это — при
обычном завершении блока, при return из середины, при исключении. Для
читателя with означает ровно одно: выход из этого блока будет обработан,
чем бы блок ни кончился.
Отсюда и протокол. Чтобы объект годился для with, он должен уметь две вещи:
что-то сделать на входе в блок и что-то сделать на выходе из него. В Python эти
две вещи называются __enter__ и __exit__; as получает то, что вернул
первый, а второй вызывается на выходе.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, где эта гарантия кончается, и про то, что метод выхода умеет не только убирать за собой: он же решает судьбу исключения, которое летело наружу. Именно на этом втором умении и теряются данные.
Механизм 1: два метода, и оба — на типе
with ищет __enter__ и __exit__ на ТИПЕ, а не на экземпляре. Справочник формулирует это прямо.Справочник описывает исполнение with семью шагами. Первые пять — про вход:
вычислить выражение, загрузить __enter__, загрузить __exit__, вызвать
__enter__, присвоить результат цели после as.
Слово «загрузить» здесь не украшение. Оба метода ищутся неявным поиском специального метода — то есть на типе, а не на экземпляре. Присвоить их объекту нельзя:
class Plain:
pass
p = Plain()
p.__enter__ = lambda: None
p.__exit__ = lambda *a: None
with p: # TypeError: 'Plain' object does not
pass # support the context manager protocolНа 3.14 к этому сообщению добавится (missed __exit__ method): там __exit__
загружается первым, поэтому именно его имя и попадает в текст ошибки.
Второе, что видно из шага 5: as получает возвращаемое значение
__enter__, а не сам менеджер. Метод, забывший return, отдаёт None:
class ReturnsNothing:
def __enter__(self): pass
def __exit__(self, *a): pass
with ReturnsNothing() as v:
print(v) # NoneСовпадение объекта и as-переменной — не правило языка, а решение автора
менеджера. open() возвращает из __enter__ сам файл, поэтому там они
совпадают; contextlib.suppress возвращает себя; @contextmanager возвращает
то, что стоит после yield.
Механизм 2: судьбу исключения решает истинность одного значения
PEP 343 записывает перевод with в явный try. Стоит прочитать его целиком —
дальше в уроке нет ни одного утверждения, которого не было бы видно прямо
здесь:
mgr = (EXPR)
exit = type(mgr).__exit__
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value
BLOCK
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)Строка if not exit(...): raise — это и есть весь механизм подавления.
Справочник формулирует то же нормативно: If the return value was true, the
exception is suppressed, and execution continues with the statement following
the
(Если возвращённое значение истинно, исключение подавляется, и выполнение продолжается с инструкции, следующей за with statementwith).
Значит, __exit__ может закончиться тремя разными способами. Первые два
различает истинность возвращённого значения — именно она, а не конкретный
объект: годится любая истина, будь то True, единица или непустая строка.
Третий способ — метод сам бросил исключение.
У этого правила есть граница, и она названа в самой записи PEP: exit
вызывается после того, как __enter__ вернул управление. До успешного входа
решать нечего — __exit__ не позовут вовсе (об этом ниже отдельный раздел).
что вернул __exit__ | что происходит с исключением |
|---|---|
None (или любая ложь) | летит наружу, как будто with не было |
| истина | исчезает; выполнение идёт со следующей после with строки |
| сам бросил исключение | наружу летит новое, старое становится его контекстом |
Метод
__exit__, который заканчиваетсяreturn True«чтобы не падало», глушит не ту ошибку, ради которой его писали, а все.
Механизм 3: ошибка, которая не падает
Как и с генераторами, интересна не та ошибка, что роняет программу. Вот разбор строк с одной битой записью:
ROWS = [
{"id": 1, "amount": "100"},
{"id": 2, "amount": "no data"}, # битая
{"id": 3, "amount": "50"},
{"id": 4, "amount": "25"},
]Правильный вариант — suppress вокруг одной строки:
total = 0
for row in ROWS:
with suppress(ValueError):
total += int(row["amount"])Неправильный отличается положением двух строк — suppress оказывается вокруг
цикла:
total = 0
with suppress(ValueError):
for row in ROWS:
total += int(row["amount"])Замер (одинаково на 3.13.7 и 3.14.7):
| вариант | результат |
|---|---|
suppress на строку | 175 |
suppress на цикл | 100 |
| верная сумма | 175 |
Битая запись при этом действительно пропущена — отдельный счётчик в замере
получает ровно одну. Потеряно 75 из 175. Ни одного исключения, ни одной строки в логе: suppress
не «пропускает ошибку», он выходит из блока целиком — ровно как записано
в PEP 343, где подавление означает переход к строке после with.
Та же ошибка в виде собственного менеджера страшнее, потому что в месте
вызова нет слова suppress:
class Quiet:
def __enter__(self): return self
def __exit__(self, *a): return True # «чтобы не падало»С ним та же сумма — 100.
Механизм 4: подавить группу и вырезать ветку — разные операции
Слово «подавить» в предыдущем разделе означало одно: __exit__ вернул истину,
и блок кончился. С группой исключений операций становится две, и они дают
разные результаты (bench/ctxmgr-async/exceptiongroup_suppress.py).
Первая — старая. __exit__ -> True над ExceptionGroup глотает группу
целиком, со всеми ветками (здесь и ниже вывод скрипта приведён сокращённо —
только относящиеся к делу строки):
1) __exit__ -> True над ExceptionGroup
__exit__ увидел вложенные: ['ValueError', 'TypeError']
выполнение продолжилось: группа проглочена целиком, обе ветки
Вторая появилась в 3.12 и работает тоньше: suppress вырезает свою ветку и
пересобирает остаток. На 3.11 то же самое не подавляло вообще ничего:
3.11.15
2) with suppress(ValueError): raise ExceptionGroup('eg', [ValueError])
наружу вышло ExceptionGroup 'eg (1 sub-exception)' вложенные: ['ValueError']
3) ... ExceptionGroup('mix', [ValueError, TypeError])
наружу вышло 'mix (2 sub-exceptions)' вложенные: ['ValueError', 'TypeError']
это тот же объект, что бросали: True
3.12.3 и новее
2) РЕЗУЛЬТАТ: подавлено, выполнение продолжилось
3) наружу вышло 'mix (1 sub-exception)' вложенные: ['TypeError']
это тот же объект, что бросали: False
Строка про то, что наружу вышел уже не тот объект, который бросали, — это и есть весь механизм. Документация описывает его дословно:
If the code within the with block raises a BaseExceptionGroup, suppressed
exceptions are removed from the group. Any exceptions of the group which are not
suppressed are re-raised in a new group which is created using the original
group's derive method.
Если код внутри блока with возбуждает BaseExceptionGroup, подавляемые исключения удаляются из группы. Те исключения группы, которые не подавлены, возбуждаются заново в новой группе, созданной методом derive исходной группы.
Работает это и вглубь: ValueError, лежавший во вложенной группе, на 3.12
из неё вырезан, а на 3.11 остался на месте.
Тут стоит снять частое заблуждение о причине. BaseExceptionGroup.split,
которым это сделано, существует с 3.11 — замер вызывает его напрямую и на
3.11, и результат тот же. Изменился не split, а suppress, который начал его
звать: до 3.12 его __exit__ умещался в одну строку —
return exctype is not None and issubclass(exctype, self._exceptions), — и
группа под это условие просто не подходила.
Рядом с suppress полезно помнить except*. Он делит группу тем же способом —
split возвращает the pair
(пару (match, rest), где match — это subgroup(condition), а rest — оставшаяся несовпавшая часть), —
и справочник описывает его так:(match, rest) where match is subgroup(condition) and rest is the remaining non-matching part
The exception type for matching is interpreted as in the case of except, but in
the case of exception groups we can have partial matches when the type matches
some of the exceptions in the group. This means that multiple except* clauses
can execute, each handling part of the exception group.
Тип исключения для сопоставления понимается так же, как в случае except, но в случае групп исключений возможны частичные совпадения, когда тип совпадает с частью исключений группы. Это значит, что выполниться может несколько предложений except*, каждое из которых обработает свою часть группы исключений.
Практический вывод для кода, который пользуется TaskGroup или любой другой
структурной конкурентностью: suppress(ValueError) там означает не «глушить
всё», а «вынуть из группы ValueError и отдать наружу остальное». Начиная с 3.12.
Механизм 5: если __enter__ бросил исключение, уборки не будет
Гарантия справочника сформулирована с условием, и условие легко потерять при
чтении: if the
(если метод __enter__() method returns without an error, then
__exit__() will always be called__enter__() вернул управление без ошибки, то __exit__() будет вызван обязательно).
То есть до успешного входа гарантии нет никакой:
class FailsOnEnter:
def __enter__(self):
self.acquire() # первая половина удалась
raise RuntimeError # вторая — нет
def __exit__(self, *a):
self.release() # НЕ БУДЕТ ВЫЗВАНПроверено запуском: __enter__ пишет в
журнал до raise, и после падения там остаётся только эта запись — __exit__
не добавил ничего. Практический
вывод — __enter__ должен либо захватить всё, либо не захватить ничего:
собственный try/except внутри него, а не надежда на __exit__.
Справочник отдельно говорит, что with A(), B(): равносильно вложенным
with. Сложив это с правилом выше, получаем: если B.__enter__ упал,
A.__exit__ отработает, а B.__exit__ — нет. Проверено запуском, на классах:
вход A | вход B | выход A
У @contextmanager с try/finally та же строка выглядит иначе — там уборка
для B всё-таки выполнится. Почему — в следующем разделе.
Механизм 6: @contextmanager — где живёт исключение
Генераторная форма короче, и в ней не видно ни __exit__, ни трёх его
аргументов. Строка после yield читается как обычная уборка — а её пропустит
любое исключение:
@contextmanager
def naive():
resource = acquire()
yield resource
release(resource) # при исключении сюда не придутДокументация contextlib объясняет, почему: If an unhandled exception occurs
in the block, it is reraised inside the generator at the point where the
yield occurred
(Если в блоке возникает необработанное исключение, оно возбуждается заново внутри генератора в той точке, где произошёл yield). Исключение не «происходит снаружи» — оно бросается в
точку yield, и дальше ведёт себя как обычное исключение внутри функции:
разматывает кадр, минуя строки после yield.
Проверено запуском:
| менеджер | без исключения | с исключением |
|---|---|---|
без try/finally | открыли, закрыли | открыли |
с try/finally | открыли, закрыли | открыли, закрыли |
Из того же свойства следует и способ подавить исключение в генераторной форме:
не return True, а обычный except, из которого не перебрасывают:
@contextmanager
def catches():
try:
yield
except ValueError:
pass # исключение подавленоДокументация предупреждает об этом прямо: If an exception is trapped merely
in order to log it… the generator must reraise that exception
(Если исключение перехвачено лишь ради того, чтобы его записать в журнал… генератор обязан возбудить это исключение заново).
Есть и место, где генераторная форма ведёт себя лучше класса. Если код до
yield обёрнут в try/finally, а генератор упал ещё до yield, finally
всё равно отработает: исключение разматывает кадр генератора и проходит через
него. У класса в той же ситуации не вызывается ничего — это и есть та разница,
из-за которой строка вход A | вход B | выход A выше помечена «на классах».
Механизм 7: ExitStack — вложенность, которой нет в коде
Когда менеджеров заранее неизвестное число — открыть все файлы из списка, все
соединения из конфигурации, — вложенных with написать нельзя. Для этого есть
ExitStack, и вести себя он обязан ровно так, как вели бы себя вложенные with:
Since registered callbacks are invoked in the reverse order of registration,
this ends up behaving as if multiple nested with statements had been used with
the registered set of callbacks. This even extends to exception handling - if an
inner callback suppresses or replaces an exception, then outer callbacks will be
passed arguments based on that updated state.
Поскольку зарегистрированные обработчики вызываются в порядке, обратном регистрации, это ведёт себя так, будто использовалось несколько вложенных инструкций with с зарегистрированным набором обработчиков. Это распространяется даже на обработку исключений: если внутренний обработчик подавляет или заменяет исключение, внешним обработчикам будут переданы аргументы, отражающие это обновлённое состояние.
Второе предложение — это не оговорка, а то, ради чего абзац стоит читать. Оно
проверяется прямо (bench/ctxmgr-async/exitstack.py, вывод одинаков на
3.11–3.14):
4) inner вернул True — блок завершился без исключения:
exit inner (видит ValueError)
exit outer (видит None)
outer увидел None, потому что inner уже подавил
5) наружу вышло: KeyError 'подмена'
exit inner (видит ValueError)
exit outer (видит KeyError)
outer увидел уже KeyError, а не исходный ValueError
То есть внешний __exit__ видит не то исключение, которое случилось, а то,
которое осталось после внутреннего. Логирование «что упало» на внешнем уровне
поэтому врёт, если внутри кто-то подменяет.
Остальное поведение — то же, что у вложенных with, и потому предсказуемо:
падение третьего __enter__ закрывает первые два в обратном порядке и не
открывает четвёртый; enter_context(object()) даёт TypeError сразу, а не
позже. Отдельно стоит pop_all: он переносит зарегистрированную уборку в другой
объект, и тогда выход из with ничего не закрывает — закрывает тот, кому уборку
передали. Это штатный способ вернуть наружу уже открытый ресурс, не закрыв его
на выходе из функции, которая его открыла.
Механизм 8: async with — тот же протокол, другие имена
Асинхронный вариант не добавляет ни одного нового правила. Модель данных описывает оба метода одной формулировкой:
Semantically similar to __enter__(), the only difference being that it must
return an awaitable.
Семантически похож на __enter__(), с единственным отличием: должен вернуть ожидаемый объект.
Отсюда всё остальное: as получает возвращённое значение, истина из __aexit__
подавляет исключение, порядок тот же. Проверено запуском
(bench/ctxmgr-async/async_protocol.py), и вывод одинаков на 3.11–3.13.
Практически важны две вещи, которых у синхронного with быть не может.
Первая: между входом и выходом цикл событий отдаёт управление другим задачам. Это не «может быть», а наблюдаемо:
4) чередование с фоновой задачей: ['фон-0', 'тело начало', 'фон-1', 'тело кончило', 'фон-2']
Блокировка, взятая на входе в такой менеджер, держится через await — и всё, что о
блокировках сказано в уроке про async, начинается здесь.
Вторая: протоколы не взаимозаменяемы. Синхронный менеджер под async with
не работает и наоборот:
3a) async with над синхронным менеджером -> TypeError: 'SyncOnly' object does not support the asynchronous context manager protocol
3b) with над асинхронным менеджером -> TypeError: 'AsyncOnly' object does not support the context manager protocol
На 3.14 эти же две ошибки стали подсказывать выход — прямое следствие того, что в этой версии оба метода ищутся по отдельности:
3a) ... (missed __aexit__ method) but it supports the context manager protocol. Did you mean to use 'with'?
3b) ... (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'?
Для смешанного случая есть AsyncExitStack: он держит и синхронные, и
асинхронные менеджеры в одном стеке и закрывает их в обратном порядке
регистрации, не разделяя по виду. У него нет close — только aclose, и это
записано в документации, а не выведено из поведения.
Глубже: один объект — один вход
Документация называет результат @contextmanager однократным и приводит
пример, который заканчивается так:
RuntimeError: generator didn't yield
На всех четырёх версиях получается другое. Пример из документации, запущенный дословно на 3.11.15, 3.12.3, 3.13.7 и 3.14.7, даёт:
AttributeError: '_GeneratorContextManager' object has no attribute 'args'
Причина видна в исходниках. Lib/contextlib.py на теге v3.13.7, строки
118–125:
def __enter__(self):
# do not keep args and kwds alive unnecessarily
# they are only needed for recreation, which is not possible anymore
del self.args, self.kwds, self.func
try:
return next(self.gen)
except StopIteration:
raise RuntimeError("generator didn't yield") from NoneУдаление атрибутов стоит до next(self.gen), поэтому второй вход падает
раньше, чем дело доходит до генератора. Задокументированный RuntimeError
живёт в ветке except StopIteration — до неё второй вход просто не доходит.
Практического вывода это не меняет: повторно входить надо не в объект, а
вызывать функцию заново — with careful(): каждый раз. Но знать стоит: если
в чужом коде встретился AttributeError: ... has no attribute 'args', это не
загадка, а повторный вход в уже использованный менеджер.
Глубже: что изменилось в 3.14
Время двух версий нельзя ставить рядом: сборки 3.13.7 и 3.14.7
различаются не только версией языка — у 3.14 включён --with-tail-call-interp — тот самый флаг, которому «Что нового в
3.14» приписывает a geometric mean of 3-5% faster
(в среднем геометрическом на 3–5 % быстрее).
Поэтому колонки в таблице ниже нельзя читать друг против друга напрямую.
Читать надо относительно первой строки: в ней with не участвует вовсе,
и насколько сдвинулась она — настолько сдвинулась сборка.
| форма | 3.13.7 | 3.14.7 |
|---|---|---|
пустой вызов, без with | 12,5 нс | 12,7 нс |
класс с __enter__/__exit__ | 95,5 нс | 65,2 нс |
@contextmanager | 430,7 нс | 417,2 нс |
Эталон не сдвинулся вовсе — 12,5 против 12,7 нс, то есть разница в пределах
разброса, и общего ускорения сборки в этой паре нет. Генераторная форма тоже
входит в with, поэтому вторым эталоном быть не может: её 3,1 % — смесь
вклада сборки и вклада изменения байт-кода. А форма с классом подешевела на
31,7 %. Приписать эти тридцать два процента сборке нечему: эталон,
измеренный тем же процессом в тех же кругах, остался на месте. Причина видна
в байт-коде: на
3.13 вход в with — это одна инструкция BEFORE_WITH, на 3.14 её нет вовсе,
а вместо неё стоят обычные загрузки и вызов:
3.13: CALL 0 / BEFORE_WITH / STORE_NAME x
3.14: CALL 0 / COPY 1 / LOAD_SPECIAL __exit__ / SWAP 2 / SWAP 3
/ LOAD_SPECIAL __enter__ / CALL 0 / STORE_NAME x
Инструкций стало больше, а времени — меньше. Мотив изменения записан в
CPython gh-120507, и он не про скорость: they are bulky and won't optimize
well in tier 2. Instead, we should lower them to attribute lookups and calls
which can then be optimized
(они громоздки и плохо оптимизируются на втором уровне. Вместо этого их следует свести к поиску атрибутов и вызовам, которые уже поддаются оптимизации). Ускорение здесь — следствие, а не цель, и
никакого числа ни в задаче, ни в документации не обещано.
У перестановки есть видимый побочный эффект. На 3.14 __exit__ загружается
первым, и об этом сообщают ошибки:
| объект | 3.11 – 3.13 | 3.14 |
|---|---|---|
есть __enter__, нет __exit__ | (missed __exit__ method) | (missed __exit__ method) |
есть __exit__, нет __enter__ | без уточнения | (missed __enter__ method) |
| нет обоих | без уточнения | (missed __exit__ method) |
Последняя строка — не опечатка: когда нет обоих методов, 3.14 называет
__exit__, потому что до __enter__ дело не доходит.
Глубже: история версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 2.5 | PEP 343 вводит with и пару __enter__/__exit__. Перевод в try/finally, записанный в PEP, с тех пор не менялся по смыслу — менялось только его представление в байт-коде. | |
| 3.11 | Появляется инструкция BEFORE_WITH (документация dis: Added in version 3.11 (Добавлено в версии 3.11)). Вход в with — одна инструкция. | |
| 3.12 | suppress учится разбирать группы исключений: Changed in version 3.12: suppress now supports suppressing exceptions raised as part of a BaseExceptionGroup (Изменено в версии 3.12: suppress теперь поддерживает подавление исключений, возбуждённых в составе BaseExceptionGroup). И тем же выпуском переписан @contextmanager: трёхаргументная форма throw(typ, value, tb) объявлена устаревшей, поэтому contextlib перешёл на throw(value). Замер видит это снаружи: число аргументов, с которым contextlib зовёт gen.throw, — 3 на 3.11 и 1 начиная с 3.12. | |
| 3.14 | BEFORE_WITH исчезает со страницы dis, появляется LOAD_SPECIAL (Added in version 3.14 (Добавлено в версии 3.14)). Замер: форма с классом 95,5 → 65,2 нс. Побочный эффект — TypeError при отсутствии обоих методов теперь называет __exit__. |
Чем измерено
Наблюдения этой статьи открываются отсюда — вместе с записью прогона:
bench/context-managers/bytecode.pybench/context-managers/cost.pybench/context-managers/generator_cm.pybench/context-managers/protocol.pybench/context-managers/silent.pybench/context-managers/suppress.pybench/context-managers/traps.pybench/ctxmgr-async/exceptiongroup_suppress.pybench/ctxmgr-async/exitstack.pybench/ctxmgr-async/async_protocol.pybench/ctxmgr-async/what_changed_312.py
Последние четыре времени не меряют вовсе: они печатают поведение на четырёх интерпретаторах — 3.11.15, 3.12.3, 3.13.7 и 3.14.7 — и различия между ними.
Как отвечать на собеседовании
Короткий ответ: with — это гарантия того, что выход из блока будет
обработан, чем бы блок ни кончился. Протокол — два метода на типе: __enter__
вызывается на входе, __exit__ на выходе, и as связывает то, что вернул
__enter__, а не сам объект.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Первое — назвать границу гарантии: __exit__ вызывается, только если
__enter__ вернул управление без ошибки. Если __enter__ бросил, уборки не
будет вовсе, и всё, что он успел захватить, придётся освобождать в нём же.
Второе — сказать, что после успешного входа __exit__ решает не только
«убрать за собой»: судьбу летящего исключения определяет истинность того,
что он вернул. Поэтому return True в __exit__ означает не «мы всё убрали
успешно», а «исключения больше нет», — и глушит любое исключение, а не только
ожидаемое. Отсюда и тихая потеря данных: suppress вокруг цикла вместо
suppress вокруг строки даёт 100 вместо 175, и в журнале при этом ни одного
исключения.
Дальше спросят
__exit__ вернул True. Что именно подавлено?
Ровно то исключение, которое до него дошло, — и подавление возвращает
управление в строку после with, а не внутрь блока. Весь with — это try,
записанный в PEP 343 явно, и всё поведение видно прямо в этой записи: другого
источника у урока нет.
А если __enter__ бросил исключение — кто закроет то, что он успел открыть?
Никто. Гарантия справочника сформулирована с условием, которое легко потерять
при чтении: __exit__ будет вызван обязательно, если __enter__ вернул
управление без ошибки. До успешного входа гарантии нет никакой, и всё, что
захвачено внутри __enter__ до броска, придётся освобождать в нём же.
Частые заблуждения
with просто гарантирует, что файл закроется
Гарантия условная, и условие в справочнике записано прямо: __exit__ вызывается, только если __enter__ вернулся без ошибки. Менеджер, упавший в середине __enter__, не получит уборки вовсе — проверено запуском, в журнал попадает только «__enter__ начался». Захватывать ресурсы надо так, чтобы __enter__ либо сделал всё, либо не сделал ничего.
as-переменная — это объект, который стоит после with
Это возвращаемое значение __enter__. Совпадение — решение автора менеджера, а не правило языка: open() возвращает файл, @contextmanager — то, что стоит после yield, а метод без return отдаёт None. Проверяется одной строкой: with obj as v: print(obj is v) печатает False у менеджера, который возвращает что-то своё.
return True в __exit__ значит «мы всё убрали успешно»
Это значит «исключение проглочено». В PEP 343 перевод записан дословно: if not exit(mgr, *sys.exc_info()): raise — истина отменяет raise. Замер: тот же разбор строк даёт 100 вместо 175, без единой записи в логе. Метод, которому нечего сказать, не должен возвращать ничего.
suppress — это короткая запись для try/except вокруг строки
Он выходит из блока целиком. В замере блок with suppress(ZeroDivisionError): вокруг двух строк выполнил только первую: подавление означает переход к строке после with, а не к следующей строке внутри него. Отсюда и потеря 75 из 175 в примере с циклом.
@contextmanager с yield посередине эквивалентен try/finally
Только пока нет исключений. Документация contextlib: исключение is reraised inside the generator at the point where the yield occurred
(возбуждается заново внутри генератора в той точке, где произошёл yield) — то есть строки после yield пропускаются так же, как пропускался бы остаток любой функции. Замер: без try/finally при исключении в журнале остаётся только «открыли».
объект от @contextmanager можно использовать повторно
Нельзя, и ошибка при этом не та, что в документации. Документация показывает RuntimeError: generator didn't yield, а на 3.11, 3.12, 3.13 и 3.14 получается AttributeError: '_GeneratorContextManager' object has no attribute 'args' — потому что __enter__ начинается с del self.args, self.kwds, self.func (Lib/contextlib.py, v3.13.7, строки 118–125). Вызывать надо функцию заново, а не входить в тот же объект.
with A(), B() открывает оба менеджера независимо
Это вложение: справочник называет такую запись semantically equivalent to
(семантически эквивалентно) вложенным with. Замер с упавшим B.__enter__ на классах даёт «вход A | вход B | выход A» — у B уборки нет, у A есть. То же и с закрытием: сначала B, потом A.
with почти ничего не стоит
На 3.13.7 пустой вызов — 12,5 нс, тот же код в with на классе — 95,5 нс, через @contextmanager — 430,7 нс. В обработчике с походом в базу это шум; в цикле на миллион итераций генераторная форма добавляет больше четырёх десятых секунды. На 3.14 форма с классом подешевела до 65,2 нс, генераторная — до 417,2.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
from contextlib import suppress
ROWS = [
{"id": 1, "amount": "100"},
{"id": 2, "amount": "no data"},
{"id": 3, "amount": "50"},
{"id": 4, "amount": "25"},
]
total = 0
added = []
with suppress(ValueError):
for row in ROWS:
total += int(row["amount"])
added.append(row["id"])
print(total)
print(added)Практика · оцените
Проверка знаний
__enter__ захватил блокировку, а потом бросил исключение. Что вызовет with?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
with— это гарантия того, что выход из блока будет обработан. Что бы ни случилось внутри — обычное завершение,returnиз середины, исключение, — менеджер получит управление на выходе и сделает то, ради чего его писали: закроет файл, отпустит блокировку, вернёт соединение. Протокол — два метода: один вызывается на входе в блок, другой на выходе.- Отсюда главное следствие: метод выхода решает не только «убрать за собой».
asсвязывает то, что вернул__enter__, а не сам объект: метод безreturnотдаётNone. А судьбу исключения после успешного входа определяет истинность значения, которое вернул__exit__: ложь — исключение летит дальше, истина — исчезает вместе с остатком блока. Отсюда тихая потеря данных:suppressвокруг цикла вместоsuppressвокруг строки даёт 100 вместо 175, без единого исключения в логе. А если__enter__бросил исключение,__exit__не вызывается вовсе — гарантия справочника начинается только после успешного входа. - Дальше — то, что отличает знающего от читавшего. Оба метода ищутся на типе, а не на экземпляре:
p.__enter__ = ...даётTypeError. Подавить группу исключений целиком и вырезать из неё одну ветку — разные операции, и вторуюsuppressумеет только с 3.12. Цена входа в блок:withна классе — 96 нс против 13 нс у пустого вызова,@contextmanager— 431 нс (3.13.7); на 3.14 форма с классом подешевела до 65 нс, генераторная — до 417.
На самом деле
- Гарантия условная, и условие в справочнике записано прямо:
__exit__вызывается, только если__enter__вернулся без ошибки. Менеджер, упавший в середине__enter__, не получит уборки вовсе — проверено запуском, в журнал попадает только «__enter__начался». Захватывать ресурсы надо так, чтобы__enter__либо сделал всё, либо не сделал ничего. - Это возвращаемое значение
__enter__. Совпадение — решение автора менеджера, а не правило языка:open()возвращает файл,@contextmanager— то, что стоит послеyield, а метод безreturnотдаётNone. Проверяется одной строкой:with obj as v: print(obj is v)печатаетFalseу менеджера, который возвращает что-то своё. - Это значит «исключение проглочено». В PEP 343 перевод записан дословно:
if not exit(mgr, *sys.exc_info()): raise— истина отменяетraise. Замер: тот же разбор строк даёт 100 вместо 175, без единой записи в логе. Метод, которому нечего сказать, не должен возвращать ничего. - Он выходит из блока целиком. В замере блок
with suppress(ZeroDivisionError):вокруг двух строк выполнил только первую: подавление означает переход к строке послеwith, а не к следующей строке внутри него. Отсюда и потеря 75 из 175 в примере с циклом. - Только пока нет исключений. Документация contextlib: исключение is reraised inside the generator at the point where the yield occurred — то есть строки после
yieldпропускаются так же, как пропускался бы остаток любой функции. Замер: безtry/finallyпри исключении в журнале остаётся только «открыли». - Нельзя, и ошибка при этом не та, что в документации. Документация показывает
RuntimeError: generator didn't yield, а на 3.11, 3.12, 3.13 и 3.14 получаетсяAttributeError: '_GeneratorContextManager' object has no attribute 'args'— потому что__enter__начинается сdel self.args, self.kwds, self.func(Lib/contextlib.py, v3.13.7, строки 118–125). Вызывать надо функцию заново, а не входить в тот же объект. - Это вложение: справочник называет такую запись semantically equivalent to вложенным
with. Замер с упавшимB.__enter__на классах даёт «вход A | вход B | выход A» — уBуборки нет, уAесть. То же и с закрытием: сначалаB, потомA. - На 3.13.7 пустой вызов — 12,5 нс, тот же код в
withна классе — 95,5 нс, через@contextmanager— 430,7 нс. В обработчике с походом в базу это шум; в цикле на миллион итераций генераторная форма добавляет больше четырёх десятых секунды. На 3.14 форма с классом подешевела до 65,2 нс, генераторная — до 417,2.
По версиям
- 2.5
- PEP 343 вводит
withи пару__enter__/__exit__. Перевод вtry/finally, записанный в PEP, с тех пор не менялся по смыслу — менялось только его представление в байт-коде.< - 3.11
- Появляется инструкция
BEFORE_WITH(документация dis: Added in version 3.11). Вход вwith— одна инструкция.< - 3.12
suppressучится разбирать группы исключений: Changed in version 3.12: suppress now supports suppressing exceptions raised as part of a BaseExceptionGroup. И тем же выпуском переписан@contextmanager: трёхаргументная формаthrow(typ, value, tb)объявлена устаревшей, поэтомуcontextlibперешёл наthrow(value). Замер видит это снаружи: число аргументов, с которымcontextlibзовётgen.throw, — 3 на 3.11 и 1 начиная с 3.12.<- 3.14
BEFORE_WITHисчезает со страницы dis, появляетсяLOAD_SPECIAL(Added in version 3.14). Замер: форма с классом 95,5 → 65,2 нс. Побочный эффект —TypeErrorпри отсутствии обоих методов теперь называет__exit__.<
Что разобрано
- База: зачем понадобился `with`
- Механизм 1: два метода, и оба — на типе
- Механизм 2: судьбу исключения решает истинность одного значения
- Механизм 3: ошибка, которая не падает
- Механизм 4: подавить группу и вырезать ветку — разные операции
- Механизм 5: если `__enter__` бросил исключение, уборки не будет
- Механизм 6: `@contextmanager` — где живёт исключение
- Механизм 7: `ExitStack` — вложенность, которой нет в коде
- Механизм 8: `async with` — тот же протокол, другие имена
- Глубже: один объект — один вход
- Глубже: что изменилось в 3.14
- Глубже: история версий
- Чем измерено
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
9 ИСТОЧНИКОВ
- PEP 343 — The "with" StatementPEP. Гвидо ван Россум и Алисса Кофлан, 13 мая 2005, статус Final, Python 2.5. Отсюда дословный перевод with в try/finally, на который опирается весь урок: `exit = type(mgr).__exit__` берётся ДО вызова `__enter__`, а истинный результат `exit(...)` означает, что исключение «swallowed».https://peps.python.org/pep-0343/
- Справочник языка — инструкция withОфициальная документация. Семь пронумерованных шагов исполнения. Оттуда же два правила, которые в уроке проверены запуском: «The with statement guarantees that if the __enter__() method returns without an error, then __exit__() will always be called» (Инструкция with гарантирует: если метод __enter__() вернул управление без ошибки, то __exit__() будет вызван обязательно) и «If the return value was true, the exception is suppressed» (Если возвращённое значение истинно, исключение подавляется). Там же — что несколько менеджеров в одной строке равносильны вложенным with.https://docs.python.org/3.14/reference/compound_stmts.html#the-with-statement
- contextlib — утилиты для withОфициальная документация. «If an unhandled exception occurs in the block, it is reraised inside the generator at the point where the yield occurred» (Если в блоке возникает необработанное исключение, оно возбуждается заново внутри генератора в той точке, где произошёл yield) — фраза, объясняющая, зачем в генераторном менеджере нужен try/finally. Там же про однократность: «Context managers created using contextmanager() are also single use context managers» (Контекстные менеджеры, созданные через contextmanager(), тоже одноразовые) — на этой фразе держится раздел про повторный вход.https://docs.python.org/3.14/library/contextlib.html
- dis — BEFORE_WITH (документация 3.13)Официальная документация. «This opcode performs several operations before a with block starts… Added in version 3.11» (Эта инструкция выполняет несколько действий перед началом блока with… Добавлено в версии 3.11). На странице 3.14 этой инструкции уже нет; что её нет и в самом байт-коде, показывает дизассемблер в
bench/context-managers/bytecode.py.https://docs.python.org/3.13/library/dis.html - dis — LOAD_SPECIAL (документация 3.14)Официальная документация. «Performs special method lookup on STACK[-1]… Added in version 3.14» (Выполняет поиск специального метода у STACK[-1]… Добавлено в версии 3.14). Инструкция, на которую разложился BEFORE_WITH. Никакого утверждения о скорости в документации нет — измеренное ускорение в уроке приводится как наблюдение, а не как обещание.https://docs.python.org/3.14/library/dis.html
- CPython gh-120507 — Lower BEFORE_WITH and BEFORE_ASYNC_WITH to attribute lookups and callsИсточник. Марк Шеннон, 14 июня 2024. Заявленный мотив — не скорость, а пригодность к оптимизации: «they are bulky and won't optimize well in tier 2. Instead, we should lower them to attribute lookups and calls which can then be optimized» (они громоздки и плохо оптимизируются на втором уровне. Вместо этого их следует свести к поиску атрибутов и вызовам, которые уже поддаются оптимизации). Ни одного числа в задаче нет.https://github.com/python/cpython/issues/120507
- Lib/contextlib.py — _GeneratorContextManager.__enter__Исходный код CPython. Строки 118–125 на теге v3.13.7. Первая же строка метода — `del self.args, self.kwds, self.func`, и именно поэтому повторный вход в тот же объект даёт AttributeError, а не задокументированный RuntimeError. Проверено на 3.11, 3.12, 3.13 и 3.14: во всех четырёх метод одинаков.https://github.com/python/cpython/blob/v3.13.7/Lib/contextlib.py
- Модель данных — __aenter__ и __aexit__Официальная документация. Формулировка, из которой следует, что асинхронный протокол не добавляет ни одного нового правила: «Semantically similar to `__enter__()`, the only difference being that it must return an *awaitable*» (Семантически похож на `__enter__()`, с единственным отличием: должен вернуть ожидаемый объект), и такая же фраза про `__aexit__`. Перевод инструкции `async with` в `try/finally` стоит в справочнике языка и повторяет синхронный дословно, с добавленным `await` перед каждым вызовом.https://docs.python.org/3.14/reference/datamodel.html#object.__aenter__
- Что нового в Python 3.12 — раздел DeprecatedОфициальная документация. Причина, по которой `@contextmanager` в 3.12 переписан: «The 3-arg signatures (type, value, traceback) of `coroutine throw()`, `generator throw()` and `async generator throw()` are deprecated and may be removed in a future version of Python» (Трёхаргументные сигнатуры (тип, значение, трассировка) методов `throw()` у корутины, генератора и асинхронного генератора объявлены устаревшими и могут быть удалены в будущей версии Python). Слова `contextlib` в «Что нового в 3.12» нет вовсе — изменение `suppress` записано только в документации `contextlib` пометкой versionchanged. Тег CPython 3.13.7.https://docs.python.org/3.12/whatsnew/3.12.html#deprecated