with — протокол из двух методов, и второй из них умеет то, чего не умеет ни один деструктор: подавить исключение. Отсюда и suppress, и способ незаметно съесть ошибку, и цепочки, по которым восстанавливается настоящая причина сбоя.
Зафиксируй вывод и причину до того, как откроешь ответ.
class Sup:
def __enter__(self): return self
def __exit__(self, *a): return True
with Sup():
raise ValueError("и что?")
print("дошли сюда")дошли сюдаИсключение брошено и никуда не полетело.class Bad:
def __enter__(self): return self
def __exit__(self, *a): raise RuntimeError("из exit")
try:
with Bad():
raise ValueError("исходное")
except Exception as e:
print(type(e).__name__, e)
print("context:", type(e.__context__).__name__)RuntimeError из exit
context: ValueErrorНаружу вышло не то исключение, которое бросили. Но исходное не пропало.def g():
try:
raise ValueError("потеряно")
finally:
return "съедено"
print(g())съеденоИсключение было. Его нет, и никто не узнает.def f():
try:
1 / 0
except ZeroDivisionError:
raise ValueError("обёрнуто")
try:
f()
except ValueError as e:
print(type(e.__context__).__name__, e.__cause__)ZeroDivisionError NoneПро исходную ошибку никто не писал from, а она всё равно сохранилась.Протокол здесь предельно скромный — два метода, — но второй из них наделён полномочием, которого нет больше нигде в языке.
__enter__ вызывается на входе; то, что он вернул, попадает в as.
__exit__(exc_type, exc, tb) вызывается на выходе всегда — и при обычном завершении, и при исключении, и при return, и при break. Если тело завершилось нормально, все три аргумента None.
Истинное значение из __exit__ подавляет исключение. Возврат None — пропустить дальше.
Фрагмент A. __exit__ вернул True, исключение остановлено, выполнение продолжается со следующей строки после блока. Это не хак: язык специально даёт менеджеру право решать судьбу исключения, потому что иначе нельзя было бы написать contextlib.suppress.
И тут же ловушка. Метод, случайно возвращающий что-то истинное — например, заканчивающийся на return self.close(), где close отдаёт число, — начинает глотать все исключения в блоке. Ошибок нет, тесты зелёные, данные тихо теряются. Отсюда правило: __exit__ либо явно возвращает None, либо возвращает булево осознанно.
__exit__ замещает исходноеФрагмент B. Если __exit__ сам упал, наружу пойдёт его исключение — исходное вытесняется. Это логично: обработчик сломался, доверия к нему нет.
Но исходное не исчезает. Python записывает его в __context__ нового исключения и печатает в трейсбеке строкой «During handling of the above exception, another exception occurred». Механизм называется неявным связыванием и работает всегда, когда исключение возбуждается во время обработки другого.
Практическая ценность прямая: увидев такой двойной трейсбек, читать нужно верхнюю часть — там причина, а нижняя лишь то, что сломалось при уборке.
finally с return уничтожает исключениеФрагмент C — самый опасный в уроке. finally выполняется при выходе из блока в любом случае, включая раскрутку стека при исключении. И return внутри него завершает функцию нормально, отменяя раскрутку: исключение не подавляется обработчиком, а просто перестаёт существовать.
То же делают break и continue внутри finally. Никакого предупреждения нет; ошибка исчезает бесследно, вместе с трейсбеком. В отличие от следствия 1, здесь даже __context__ не остаётся.
Вывод без исключений: в finally не место передаче управления. Только уборка.
Фрагмент D. У исключения есть два поля для связи с предыдущим. __context__ заполняется автоматически, когда новое исключение возникает при обработке старого — именно это и произошло, хотя from никто не писал. __cause__ заполняется явно через raise ... from e и означает «вот настоящая причина».
Различие видно в трейсбеке: неявная связь печатается как «another exception occurred», явная — как «The above exception was the direct cause». Первое читается как «а ещё сломалась уборка», второе — как «вот почему».
Отсюда правило для своих обёрток над чужими ошибками: всегда raise MyError(...) from e. Стоит два слова, а разница в том, восстановит ли следующий человек причину сбоя за минуту или за час.
__exit__ вызывается, но не обязан успеть. Аварийное завершение процесса, os._exit, убийство сигналом обходят его. Гарантия распространяется на нормальную раскрутку стека, а не на любой конец программы.
Менеджер, сделанный из генератора, — одноразовый. @contextlib.contextmanager оборачивает генератор, а тот исчерпывается после первого прохода. Повторный вход в тот же объект даст RuntimeError; для многоразового нужен класс либо вызов фабрики заново.
Группы исключений — отдельный синтаксис. С 3.11 ExceptionGroup переносит несколько ошибок сразу, и ловятся они через except*, а не обычным except. Обычный except ValueError группу с ValueError внутри не поймает 🔒 — это то, обо что спотыкаются при переходе на TaskGroup.
Корень R4: поведение задаётся протоколом. with не знает ни про файлы, ни про блокировки — он знает два имени и вызывает их. Отсюда и то, что менеджером может быть что угодно, включая объект, у которого нет никакого ресурса.
Появился протокол в 2005 году (PEP 343) с очень конкретной мотивацией: код вида try/finally для захвата и освобождения повторялся везде, писался с ошибками и не читался. Решение сознательно сделали протоколом, а не оператором с фиксированной семантикой — и именно поэтому with сегодня используется для вещей, о которых в 2005 не думали: замер времени, подмена в тестах, транзакции, ограничение точности вычислений, перенаправление вывода.
Право подавлять исключение добавили в тот же момент и не без спора: оно делает возможным и suppress, и молчаливую потерю ошибок. Выбрали выразительность.
with — это RAII с явной областью видимости. В C++ ресурс освобождается, когда объект покидает область; в Python — когда исполнение покидает блок. Обе конструкции существуют, чтобы освобождение нельзя было забыть, и обе срабатывают при раскрутке стека.
Расходятся в двух местах, и оба показательны. Первое: RAII привязан к типу, а with — к месту использования; поэтому в C++ нельзя забыть освободить, но и нельзя решить не освобождать, а в Python можно и то и другое. Второе: деструктор не имеет доступа к летящему исключению и не может его отменить, а __exit__ получает его целиком, с типом и трейсбеком, и решает его судьбу.
Второе различие и есть причина, по которой contextlib.suppress существует в Python и не имеет аналога в C++.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
except: — плохо, если он ловит всё?Именно поэтому: он ловит и то, что ловить нельзя.
Голый except: эквивалентен except BaseException:, а от BaseException наследуются три вещи, которые обязаны проходить насквозь: KeyboardInterrupt, SystemExit и GeneratorExit.
while True:
try:
do_work()
except: # съедает Ctrl-C
continue
Этот цикл невозможно остановить с клавиатуры: каждое нажатие ловится и игнорируется. То же с sys.exit() внутри блока — процесс не завершится. А проглоченный GeneratorExit ломает закрытие генераторов и даёт RuntimeError: generator ignored GeneratorExit.
Правильная граница — except Exception: он ловит всё прикладное и пропускает управляющие сигналы. И даже он уместен только на верхнем уровне — в обработчике запроса, в главном цикле воркера, — где задача «залогировать и продолжить с следующим элементом».
for item in queue:
try:
handle(item)
except Exception: # не BaseException
log.exception("упало на %s", item) # с трассировкой, а не str(e)
И отдельно: log.exception вместо log.error(str(e)). Строка исключения без трассировки — самая частая причина, по которой инцидент невозможно разобрать: видно что, не видно где.
raise ... from, если контекст и так сохраняется?Затем, что автоматический контекст говорит «случилось во время», а from говорит «случилось из-за». Это разные утверждения.
Python хранит две разные ссылки. __context__ проставляется сам, когда одно исключение возникло внутри обработки другого. __cause__ проставляется только явным from и означает причинно-следственную связь.
try:
try: 1 / 0
except ZeroDivisionError as e:
raise ValueError("плохие данные")
except ValueError as e:
print(e.__context__, "|", e.__cause__)
# → division by zero | None
В трассировке разница видна словами: без from печатается «During handling of the above exception, another exception occurred», с from — «The above exception was the direct cause».
И третья форма, про которую забывают: raise ... from None — она подавляет цепочку целиком. Нужна там, где внутреннее исключение — деталь реализации, которую незачем показывать пользователю библиотеки:
def get_config(d, key):
try:
return d[key]
except KeyError:
raise ConfigError(f"нет ключа {key}") from None # KeyError не всплывёт
Правило: from e — когда переупаковываешь чужую ошибку в свою и хочешь сохранить след. from None — когда чужая ошибка была способом узнать факт, а не самой проблемой. Ничего — когда исключение возникло случайно и цепочка честно это отражает.
return в finally проглатывает исключение — это не баг?Не баг, а прямое следствие того, что finally обязан выполниться при любом выходе из блока.
Если в finally есть return, break или continue, они задают новый способ выхода из функции — и он замещает тот, который был в процессе. Летящее исключение просто перестаёт лететь.
def f():
try:
raise ValueError("важная ошибка")
finally:
return "всё хорошо"
print(f()) # → всё хорошо исключение исчезло бесследно
Ни трассировки, ни записи в лог, ни следа в __context__. С точки зрения вызывающего функция отработала успешно.
Как это выглядит в реальном коде. Не так явно: обычно это finally, в котором делают уборку и на всякий случай возвращают статус, или цикл, где в finally стоит continue ради «пропустим битую запись». Второй вариант особенно коварен — он глотает и KeyboardInterrupt.
В 3.14 на такую конструкцию добавили предупреждение компилятора (SyntaxWarning), но поведение оставили — R9: менять семантику нельзя, на неё мог опереться существующий код. Так что диагностика есть, а защиты нет.
Правило: в finally только уборка. Всё, что меняет поток управления, — в else или после блока.
with или try/finally — есть разница, кроме краткости?Есть, и существенная: __exit__ видит исключение и может его подавить, а finally — нет.
finally выполняется «вслепую»: он не знает, вышли из блока нормально или с ошибкой, и какой именно. __exit__ получает три аргумента — тип, значение, трассировку — и его возвращаемое значение решает судьбу исключения.
class Suppress:
def __enter__(self): return self
def __exit__(self, exc_type, exc, tb):
print("поймал:", exc_type)
return exc_type is ValueError # True → исключение подавлено
with Suppress():
raise ValueError("тихо")
print("дошли сюда")
# → поймал: <class 'ValueError'>
# → дошли сюда
На этом стоит contextlib.suppress, на этом же — транзакции, которые откатываются при ошибке и коммитятся при успехе, различая случаи.
Второе отличие — переиспользование. try/finally надо повторять в каждом месте; менеджер пишется один раз и получает имя. Плюс contextlib.contextmanager позволяет описать его генератором, где yield и есть граница блока.
Где try/finally всё-таки уместен: одноразовая уборка, ради которой заводить класс избыточно, и случаи, где вход и выход не парные — например ресурс захвачен выше по стеку.
Важное общее: полномочие подавлять исключение — то, чего нет у деструктора в C++. Там из деструктора исключение вообще нельзя выпустить, и решение о судьбе ошибки принимается вне RAII. Здесь наоборот.
with — протокол из двух методов: __enter__ отдаёт значение в as, __exit__ вызывается всегда и получает летящее исключение целиком. Возврат истинного значения из __exit__ подавляет исключение — это штатное полномочие, на котором стоит suppress, и одновременно способ незаметно проглотить ошибку, если метод случайно вернул что-то истинное. Исключение, возникшее внутри __exit__, замещает исходное, но не уничтожает его: оно сохраняется в __context__, и в двойном трейсбеке читать надо верхнюю часть. А вот return внутри finally уничтожает исключение бесследно, вместе с трейсбеком — поэтому в finally место только уборке. И две цепочки различаются смыслом: __context__ заполняется автоматически и означает «сломалось при обработке», __cause__ ставится явным raise ... from и означает «вот причина» — два слова, экономящие час расследования.__exit__ можетC++ RAII — та же задача, решённая раньше и жёстче. Деструктор вызывается при выходе из области видимости, включая раскрутку стека, и это надёжнее with: не нужно ничего писать в месте использования, освобождение привязано к типу. Но деструктор не может подавить исключение — а бросив своё, во время раскрутки он и вовсе завершает программу через std::terminate. Сравни с фрагментом B, где Python спокойно заменяет одно исключение другим и сохраняет исходное в __context__.
Отсюда и то, что в C++ нет finally: он не нужен, потому что уборка живёт в деструкторах. Python пошёл наоборот — дал явную конструкцию, а RAII-подобное поведение получил из счётчика ссылок (урок 09), но только как деталь реализации.
Go с его defer ближе к finally: отложенный вызов при выходе из функции, и через recover он тоже умеет останавливать панику. Java сделала try-with-resources — узкий частный случай with, работающий только с AutoCloseable и без права подавления.
Четыре языка, одна задача, и только два из них разрешают уборщику отменить аварию.
__exit__, возвращающий не тоМетод заканчивается вызовом, отдающим истинное значение — и блок перестаёт пропускать ошибки наружуМенеджер транзакции. Написан аккуратно, ревью прошёл, ошибки перестали доходить до логов.
class Transaction:
def __enter__(self):
self.conn = pool.acquire()
return self.conn
def __exit__(self, exc_type, exc, tb):
if exc_type is None:
self.conn.commit()
else:
self.conn.rollback()
return pool.release(self.conn) # ← вот здесь
Автор написал return, чтобы «вернуть соединение в пул» — по смыслу фразы, а не по смыслу протокола. А pool.release возвращает, скажем, число свободных соединений. Число ненулевое, значит истинное, значит каждое исключение внутри блока подавляется.
with Transaction() as conn:
process(conn) # падает
# сюда попадаем как ни в чём не бывало, откат сделан,
# наверх ничего не ушло, в логах пусто
Худшее в этом баге — что откат работает. Данные консистентны, а вызывающий код считает операцию успешной и идёт дальше. Расхождение всплывёт много позже и в другом месте.
def __exit__(self, exc_type, exc, tb):
...
pool.release(self.conn)
return None # или просто ничего не возвращать
Правило из следствия 1: из __exit__ ничего не возвращают, если не подавляют осознанно. Проверить существующие менеджеры в кодовой базе можно грепом по def __exit__ — любой return с непустым выражением заслуживает взгляда.
contextlib вместо своих классовГенератор, suppress и ExitStack закрывают почти все случаи без единого классаКласс с двумя дандерами пишут по привычке. В большинстве случаев хватает декоратора над генератором.
from contextlib import contextmanager
@contextmanager
def timed(label):
t = time.perf_counter()
try:
yield
finally:
log.info("%s: %.3f c", label, time.perf_counter() - t)
Читается как обычная функция: до yield — вход, после — выход, try/finally гарантирует уборку при исключении. Восемь строк вместо класса, и вся логика на виду по порядку исполнения, а не разнесена по двум методам.
suppress — то самое следствие 1 в готовом виде, и оно честнее пустого except:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove(path)
# вместо
try:
os.remove(path)
except FileNotFoundError:
pass
ExitStack решает то, что классом решается неудобно: заранее неизвестное число ресурсов. Закрываются они в обратном порядке, как и положено.
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
process(files) # все закроются, сколько бы их ни было
Когда всё-таки класс: менеджер должен быть многоразовым или переиспользуемым (генератор одноразов, см. «Границы модели»), нужно состояние между входами, или объект и так существует и логично дать ему протокол.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
from contextlib import ExitStack
with ExitStack() as st:
st.callback(lambda: print("1"))
st.callback(lambda: print("2"))
# В каком порядке и почему именно так?
with A() as a, B() as b:
...
# Если B.__enter__ упадёт — вызовется ли A.__exit__?
# А если упадёт A.__enter__ — вызовется ли B.__enter__?
try:
raise ExceptionGroup("g", [ValueError("a")])
except ValueError:
print("поймали")
# Поймает? А через except* ValueError?
@contextmanager
def cm():
yield 1
c = cm()
with c: pass
with c: pass
# Что произойдёт на втором входе?
Третий вопрос — практически важный при переходе на TaskGroup: обычный except группу не ловит, и код, работавший с gather, перестаёт обрабатывать ошибки.
Lib/contextlib.py, класс _GeneratorContextManager. Стоит прочитать целиком — там видно, как генератор превращается в менеджер: __enter__ делает next(gen) и возвращает выданное значение, а __exit__ при исключении вызывает gen.throw(exc), то есть бросает исключение внутрь генератора в точку yield. Поэтому try/finally вокруг yield и работает так, как ожидается.
Там же видно, откуда берётся подавление: если после throw генератор завершился без исключения, значит он его поглотил, и __exit__ возвращает True. Следствие 1 в тридцати строках кода.
Lib/decimal.py, localcontext. Пример with без всякого ресурса: менеджер подменяет глобальные настройки точности на время блока и возвращает прежние на выходе. Тот же приём — во всех менеджерах, временно меняющих состояние: unittest.mock.patch, numpy.errstate, torch.no_grad. Понимание, что with — это просто пара вызовов, сразу объясняет их все.
Из L1 ты знаешь протокол. Здесь — во что он превращается и сколько стоит.
Компилятор разворачивает with в последовательность: получить __exit__ типа (именно типа — это протокол, урок 11), вызвать __enter__, выполнить тело, вызвать __exit__ с тремя аргументами. Оба метода берутся до входа в блок, поэтому подмена __exit__ на экземпляре внутри тела ни на что не влияет.
Ответ на вопрос из «Проверь» про with A() as a, B() as b следует отсюда же: это в точности вложенные блоки, поэтому падение B.__enter__ вызовет A.__exit__, а падение A.__enter__ оставит B нетронутым — до него не дошли.
Про цену исключений. До 3.11 вход в try стоил инструкции: на стеке заводился блок обработчика. С 3.11 (та же работа, что дала инлайн-кеши) переход на таблицу исключений: диапазоны байткода и соответствующие обработчики лежат в отдельной структуре объекта кода, и вход в try не стоит ничего — платит только тот путь, где исключение действительно возникло.
def f():
try: pass
except: pass
f.__code__.co_exceptiontable # b'' если обработчик не нужен
Практическое следствие: старый совет «не оборачивай горячий код в try» устарел. Обёртывание бесплатно; дорого только само возбуждение исключения — и вот его в горячем цикле по-прежнему стоит избегать, предпочитая проверку.
Тег v3.11.15.
Lib/contextlib.py — весь модуль на Python, читается за двадцать минут и лучше любого объяснения показывает, что with это просто два вызова.Python/ceval.c, инструкции BEFORE_WITH и WITH_EXCEPT_START — что именно генерирует компилятор.Objects/exceptions.c — поля __context__, __cause__, __suppress_context__ и логика их заполнения при возбуждении.with; в мотивации собраны все повторяющиеся try/finally, ради которых он делался. PEP 654 — группы исключений и except*.with обязателен, хотя объект и так разрушитсяdefer в Go и try-with-resources в Java — два более узких решения той же задачиTaskGroup требует except*throw — это R5, урок 13Lib/contextlib.py — читать целиком. Редкий модуль стандартной библиотеки, который одновременно короткий, полностью на Python и объясняет механизм лучше документации.try/finally.TaskGroup.