Код, который работает с файлами, соединениями и блокировками, почти всегда начинается одинаково: взять ресурс, сделать дело, вернуть ресурс. На бумаге три шага, на практике первый и третий расползаются по разным концам функции, между ними вырастают ветвления, и очистка переезжает в блок finally. Один ресурс, второй, третий, и вот уже четыре вложенных finally с обратным порядком закрытия, в котором легко перепутать, что за чем освобождать. Оператор with появился в языке, чтобы превратить протокол освобождения в часть синтаксиса, но за словом "синтаксический сахар" прячется настоящая механика: методы enter и exit получают исключение в руки и решают его судьбу. Ниже разбор того, как протокол работает на падающем коде, где try с finally действительно проигрывает, и где он остаётся честным выбором.

Как оператор with исполняет три шага от входа до гарантированного вызова exit

За строкой with Timer(): crunch() интерпретатор выполняет чёткую последовательность. Сначала вызывается метод enter, и то, что он вернул, привязывается к имени после as. Затем исполняется тело блока. В конце, штатном или аварийном, вызывается exit с тремя аргументами: тип исключения, его значение и объект трейсбека. Если тело отработало чисто, тройка состоит из None. Ключевое слово "гарантированно" здесь означает именно это: exit вызывается и при обычном завершении, и при исключении, и при return из середины блока, и даже когда исключение прилетело из вложенной функции на три кадра глубже. Простейший таймер собирается в шесть строк:

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"блок занял {self.elapsed:.3f} секунды")
        return None

with Timer():
    crunch_numbers()

Возврат None из exit означает одну простую вещь: менеджер посмотрел на произошедшее и не стал вмешиваться. Это умолчание, и оно же самое частое место ошибок у начинающих авторов менеджеров: случайно вернув True вместо None, легко погасить чужое падение молча и без следа.

Что метод exit получает при исключении и кто решает его дальнейшую судьбу

Настоящая работа протокола видна только на падающем коде. Если тело блока вызвало ValueError, то в exit приедет тройка: класс ValueError, экземпляр с сообщением и трейсбек. Дальше у метода три дороги. Первая, вернуть None или False, означает "я подметаю, но аварию не скрываю", и исключение продолжает лететь наружу. Вторая, вернуть True, означает "исключение считается обработанным", и интерпретатор молча продолжит выполнение со строки после блока. Третья дорога самая интересная: выбросить новое исключение прямо из exit, тогда исходное останется в цепочке контекста, и трейсбек покажет обе аварии, настоящую и возникшую уже при уборке. Развилка exc_type равна None против "что-то прилетело" лежит в основе всех транзакционных обёрток, где фиксация и откат решаются одной проверкой:

class Transaction:
    def __init__(self, conn):
        self.conn = conn

    def __enter__(self):
        self.conn.execute("BEGIN")
        return self.conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.conn.execute("COMMIT")
        else:
            self.conn.execute("ROLLBACK")
        return None

with Transaction(conn) as tx:
    tx.execute("UPDATE accounts SET total = total - 100 WHERE id = 1")
    tx.execute("UPDATE accounts SET total = total + 100 WHERE id = 2")

Блок отработал чисто, изменения фиксируются, любая авария между обновлениями приводит к откату, и вызывающему коду не нужно знать про саму механику. Продемонстрировать первую дорогу, наблюдение за исключением, можно так:

class Guard:
    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            print("блок отработал штатно")
        else:
            print(f"ловим падение {exc_type.__name__}")
        return None

with Guard():
    raise ValueError("контрольный сбой")
print("строка после блока")

На экране появится сообщение Guard, потом трейсбек ValueError, а строка после блока не исполнится никогда. Поменяйте последнюю строку метода на return True, и картина перевернётся: падение проглатывается, и строка после блока исполняется так, будто аварии не было. Вся мощь и весь риск протокола сидят именно в этой развилке, поэтому подавление чужих ошибок через True уместно разве что в осознанных сценариях вроде повтора операции.

Чем опасны исключения в самом enter и внутри метода exit

У протокола есть два глухих угла, о которых вспоминают обычно после первого ночного инцидента. Первый: если enter бросил исключение, exit этого менеджера не вызывается вообще. Оператор with считает, что входа не случилось и очищать нечего, поэтому подготовка внутри enter обязана либо доводить дело до конца, либо честно убирать за собой частично созданное. Второй угол: исключение, поднятое самим exit, подменяет исходное, наружу летит авария очистки, а настоящая причина уходит в цепочку контекста. Так прячутся самые неудобные дефекты: сообщение о нехватке прав на запись лога заслоняет ошибку, из-за которой лог вообще понадобился.

Отсюда прямые требования к дисциплине: exit обязан быть коротким, не бросать лишнего и не печатать в непонятных местах. Третий угол связан с повторным входом. Менеджер chdir прямо назван в документации реентерабельным, но полагаться на это свойство для произвольного менеджера опасно: объект файла после первого выхода закрыт, и повторный вход закончится ошибкой при первой же попытке чтения, а блокировка threading.Lock при втором входе из того же потока остановит его навсегда, для повторного входа существует RLock. Перед повторным использованием менеджера слово reentrant ищут в документации, а не угадывают в коде.

Чем вложенные блоки try и finally проигрывают при длинных цепочках очистки

Сторонники чистого try наконец объясняют позицию просто: он есть в каждом языке, он понятен без документации. Это правда, но у finally есть три свойства, которые портят жизнь на растущем коде. Первое: каждый ресурс требует собственной вложенности, и порядок освобождения приходится держать в голове, потому что закрывать нужно в обратном порядке приобретения. Второе: исключение, брошенное самим блоком finally, заменяет собой исходное, и настоящая причина падения теряется, оставшись лишь в цепочке контекста. Третье: стоит одному finally оказаться не на своём месте этажа, как ресурс живёт дольше нужного. Сравните две записи одной и той же логики:

conn = acquire()
try:
    session = open_session(conn)
    try:
        result = handle(session)
    finally:
        session.close()
finally:
    conn.release()
with acquire() as conn, open_session(conn) as session:
    result = handle(session)

Читатель второй версии видит порядок приобретения и освобождения за одну строку, не листая функцию глазами. Первая версия требует удерживать стек в голове и продолжает работать даже тогда, когда кто-то вставит return между try. Замыкание ресурса переехало из состояния "надо не забыть" в состояние "протокол делает сам", а освобождение из "в конце функции" в "в конце блока", то есть на минимальное возможное расстояние от точки использования.

Декоратор contextmanager и его жёсткое ограничение на один yield

Когда заводить класс ради одного менеджера лень, в дело вступает декоратор contextmanager. Под капотом у него генератор: всё, что написано до yield, исполняется на входе, значение yield уходит в блок, а код после yield работает как exit. Очистку внутри генератора держит привычный finally:

from contextlib import contextmanager

@contextmanager
def temp_setting(store, key, value):
    old = store[key]
    store[key] = value
    try:
        yield store
    finally:
        store[key] = old

Механика на падении изящна и неочевидна: если тело блока бросило исключение, декоратор вбрасывает его в генератор прямо в точке yield, и то, что летит до finally, исполняется уже "внутри" аварии. Отсюда жёсткое ограничение: yield в таком генераторе должен быть ровно один, попытка выдать второе значение оборачивается RuntimeError при входе. Второе следствие того же устройства: генератор, который поймал исключение и не перебросил его, молча погасит падение блока. Правило безопасности одно: в генераторе ловим, чтобы что-то сделать, и тут же даём исключению лететь дальше.

Готовые менеджеры стандартной библиотеки от suppress до стеков ExitStack

Писать собственный менеджер в 2026 году нужно редко, потому что стандартная библиотека закрыла типовые сценарии годы назад. Менеджер closing появился раньше всех и закрывает объект методом close, что полезно для старых классов без протокола. Пара suppress гасит указанный тип исключения в одну строку, заменяя идиому с пустым except:

import os
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("cache.tmp")

Семейство redirect_stdout подменяет поток вывода, удобно для тестов, которые ловят печатный шум. Менеджер chdir, появившийся в версии 3.11, временно меняет рабочую директорию, nullcontext из версии 3.7 служит заглушкой там, где менеджер опционален: функция принимает либо открытый файл, либо путь, и во втором случае открывает его сама, а в первом подставляет заглушку. Самый мощный инструмент семейства, ExitStack, появился ещё в версии 3.3 и умеет собирать стопку менеджеров заранее неизвестной длины:

from contextlib import ExitStack

with ExitStack() as stack:
    files = [stack.enter_context(open(name)) for name in names]
    process(files)

Каждый успешно открытый файл регистрируется в стеке, и при любом исходе закрытие пройдёт в обратном порядке, даже если третий файл не открылся из-за отсутствующих прав. Метод callback добавляет произвольную функцию очистки, а у стека есть асинхронный близнец AsyncExitStack для кода на async with, где с версии 3.5 живут свои методы aenter и aexit и декоратор asynccontextmanager. Заглушка aclosing появилась в версии 3.10 для закрытия асинхронных генераторов.

Протокол давно стал нормой вежливости всей экосистемы: объекты файлов, блокировки из threading и multiprocessing, сокеты, менеджер localcontext модуля decimal и ловушка catch_warnings для предупреждений работают в with без обёрток. Ещё со версии 3.2 существует класс ContextDecorator: наследник получает способность применяться декоратором функции, и менеджер срабатывает вокруг каждого вызова без единого with в теле. Так устроены, например, проверки ожидаемых ошибок в популярных тестовых фреймворках.

Практические правила выбора между протоколом with и ручным finally

Инженерный выбор между двумя конструкциями перестаёт быть спором о вкусе, если держаться короткого набора правил:

  1. Один ресурс обслуживает один менеджер, а не гигант с пятью обязанностями, композиция идёт через несколько with или через ExitStack;
  2. Переменная, державшая файл, соединение или блокировку, закрывается в том же блоке, где родилась, а не где-нибудь в конце функции;
  3. finally остаётся уместным, когда очистка тривиальна, не зависит от типа исключения и не связана с внешними ресурсами;
  4. Собственный менеджер обязан возвращать None из exit, если только он не задуман гасить ошибки сознательно;
  5. Подавление исключения через True или suppress сопровождается понятной причиной, иначе читатель кода примет погашенную аварию за успешный прогон;
  6. Менеджер не печатает внутри exit и не делает лишней работы, освобождение обязано быть коротким и предсказуемым, потому что исполняется и на аварийном пути.

Try и finally не ушли в историю, они остались базой, на которой сам протокол и написан. Просто у этой базы своя ниша: очистка локальной переменной в короткой функции, где городить класс или генератор накладнее, чем написать три строки. За пределами ниши выигрывает with: он держит порядок освобождения, не даёт исключению из очистки потерять оригинал и читается как предложение языка, а не как инструкция разработчику "не забудь закрыть". Последний аргумент самый весомый: протокол выполняется всегда, а человеческая память работает лишь до первого дедлайна.