← назад к разделу

Любая программа рано или поздно встречает то, чего не ожидала: файл не открылся, сеть отвалилась, на вход пришёл мусор. В Python такие ситуации описываются исключениями (exceptions), а освобождение ресурсов после них — контекстными менеджерами и оператором with. Разберёмся, как пользоваться и тем и другим, чтобы ошибки не терялись, а ресурсы не утекали.

Зачем вообще исключения

Можно было бы возвращать из каждой функции код ошибки и проверять его на каждом шаге. Так делают в некоторых языках, и код быстро тонет в проверках, а одну забытую проверку легко не заметить.

Python идёт другим путём. Когда что-то идёт не так, функция возбуждает (raise) исключение — особый объект, который прерывает обычный ход выполнения и «всплывает» вверх по стеку вызовов, пока его кто-нибудь не перехватит (except). Если не перехватил никто — программа падает с трассировкой (traceback), и это правильное поведение по умолчанию: лучше громко упасть, чем тихо продолжить с испорченными данными.

Короткая формула: исключения — для ситуаций, которые мешают функции выполнить свою работу. Не для обычного ветвления логики.

try / except: перехват

Базовая конструкция — try с одним или несколькими except:

def parse_age(raw: str) -> int:
    try:
        return int(raw)            # может бросить ValueError
    except ValueError:
        return 0                   # подставляем безопасное значение

Можно ловить несколько типов — по отдельности или группой:

try:
    data = load_config(path)
except FileNotFoundError:
    data = default_config()        # файла нет — берём дефолт
except (PermissionError, IsADirectoryError) as err:
    # одна ветка на несколько типов; err — сам объект исключения
    raise RuntimeError(f"не удалось прочитать {path}") from err

Несколько важных мелочей:

  • as err даёт доступ к объекту исключения — его сообщению, атрибутам.
  • Порядок except важен: интерпретатор берёт первый подходящий. Более конкретные типы ставь выше более общих.
  • raise ... from err сохраняет исходную причину в цепочке (в трассировке будет видно «The above exception was the direct cause»). Это помогает не терять контекст при «переупаковке» ошибки.

else и finally

У try есть ещё две ветки, про которые часто забывают.

else выполняется, только если в try не было исключения. Туда выносят код, который должен идти после успешной операции, но сам не должен попадать под except:

try:
    conn = open_connection(url)
except ConnectionError:
    log.warning("нет связи")
else:
    # сюда попадаем, только если соединение реально открылось
    use(conn)

finally выполняется всегда — и при успехе, и при исключении, и даже если внутри try сработал return. Это место для гарантированной уборки:

f = open("data.txt")
try:
    process(f)
finally:
    f.close()                      # закроется в любом случае

Короткая формула: else — «успех», finally — «в любом случае».

Иерархия исключений

Все исключения — это классы, и они выстроены в дерево наследования. Когда ты пишешь except SomeError, перехватываются и SomeError, и все его потомки.

Упрощённо вершина дерева выглядит так:

BaseException
├── SystemExit          # sys.exit()
├── KeyboardInterrupt   # Ctrl+C
└── Exception           # ← почти всё «обычное» отсюда
    ├── ValueError
    ├── TypeError
    ├── KeyError
    ├── OSError
    │   ├── FileNotFoundError
    │   └── PermissionError
    └── ...

Практический вывод: лови Exception, а не BaseException. KeyboardInterrupt (нажатие Ctrl+C) и SystemExit наследуются напрямую от BaseException — их перехват ломает нормальное завершение программы. Обычный код их трогать не должен.

И ещё: лови как можно более узкий тип. except Exception ловит вообще всё подряд, включая ошибки, которых ты не ждал (опечатку в имени переменной выдаёт NameError — тоже потомок Exception). Чем уже тип, тем меньше шанс случайно проглотить чужую ошибку.

Не глотай ошибки

Самый частый и опасный антипаттерн:

# так делать НЕ надо
try:
    risky()
except Exception:
    pass                           # ошибка исчезла бесследно

Такой except ... : pass молча проглатывает всё. Программа продолжает работать с непредсказуемым состоянием, а причину сбоя потом не найти — трассировки нет.

Если перехватываешь исключение — сделай с ним что-то осмысленное:

import logging

log = logging.getLogger(__name__)

try:
    send_metrics(payload)
except ConnectionError:
    # метрики не критичны — логируем и идём дальше осознанно
    log.warning("метрики не отправлены", exc_info=True)

exc_info=True добавит в лог полную трассировку. Если же ты не знаешь, что делать с ошибкой, — не лови её. Пусть всплывёт к тому, кто знает. Молчаливое подавление допустимо только тогда, когда ты сознательно решил, что эта ошибка здесь не важна, и оставил комментарий почему.

Свои исключения

Когда стандартных типов не хватает, заводят свои — наследуясь от Exception:

class OrderError(Exception):
    """Базовый класс для всех ошибок модуля заказов."""


class OrderNotFound(OrderError):
    def __init__(self, order_id: int):
        self.order_id = order_id
        super().__init__(f"заказ {order_id} не найден")

Зачем это нужно:

  • Общий базовый класс (OrderError) позволяет вызывающему коду одним except OrderError поймать все ошибки твоего модуля, не перечисляя их.
  • Собственные атрибуты (order_id) передают данные об ошибке, а не только текст.

Возбуждают исключение оператором raise:

def get_order(order_id: int) -> Order:
    order = repo.find(order_id)
    if order is None:
        raise OrderNotFound(order_id)
    return order

Короткая формула: свои исключения = свой словарь ошибок предметной области. Они делают код-вызыватель чище и устойчивее.

Контекстные менеджеры и with

Вернёмся к finally с f.close(). Это настолько частый случай, что в Python есть отдельный синтаксис — оператор with:

with open("data.txt") as f:
    process(f)
# здесь файл уже гарантированно закрыт — даже при исключении внутри

with берёт контекстный менеджер (объект, который умеет «входить» в блок и «выходить» из него), вызывает его настройку на входе и уборку на выходе. Выход срабатывает при любом исходе — нормальном завершении блока, return или исключении. По сути это try/finally, упакованный в одну строку.

Файлы — самый известный пример, но так же работают блокировки потоков, соединения с БД, транзакции:

import threading

lock = threading.Lock()

with lock:                         # lock.acquire() на входе
    update_shared_state()
# lock.release() на выходе — даже если update_shared_state бросит

Можно открыть несколько менеджеров сразу:

with open("in.txt") as src, open("out.txt", "w") as dst:
    dst.write(src.read())

Как написать свой контекстный менеджер

Контекстным менеджером объект делают два метода: __enter__ (вызывается на входе в with, его результат попадает в as-переменную) и __exit__ (вызывается на выходе):

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self                # это значение попадёт в `as`

    def __exit__(self, exc_type, exc_value, traceback):
        elapsed = time.perf_counter() - self.start
        print(f"заняло {elapsed:.3f} c")
        return False               # False — исключение не подавляем

with Timer():
    do_heavy_work()

В __exit__ приходят три аргумента с информацией об исключении (если блок завершился ошибкой) — или три None, если всё прошло чисто. Если __exit__ вернёт True, исключение считается обработанным и не всплывает дальше; False (или None) — оно пробрасывается как обычно. Подавлять стоит только осознанно.

Часто писать класс ради этого избыточно. В стандартной библиотеке есть contextlib.contextmanager — декоратор, который превращает генератор в контекстный менеджер. Код до yield — это «вход», код после — «выход»:

from contextlib import contextmanager

@contextmanager
def opened(path: str):
    f = open(path)
    try:
        yield f                    # значение для `as`; здесь идёт тело with
    finally:
        f.close()                  # гарантированная уборка

with opened("data.txt") as f:
    process(f)

yield тут ровно один. Обрати внимание на try/finally вокруг него — он и обеспечивает уборку при исключении внутри блока. Это самый компактный способ написать одноразовый менеджер ресурса.

Коротко

  • Исключения — для ситуаций, которые мешают функции сделать работу; не для обычного ветвления.
  • try/except перехватывает; else — ветка успеха; finally — выполняется всегда, место для уборки.
  • Лови узкий тип и Exception, но не BaseException — иначе перехватишь Ctrl+C и SystemExit.
  • except ...: pass — антипаттерн: либо обработай и залогируй (exc_info=True), либо не лови вообще.
  • Свои исключения с общим базовым классом и атрибутами дают чистый «словарь ошибок» предметной области; возбуждаются через raise, причину сохраняет raise ... from err.
  • with гарантирует уборку ресурса при любом исходе; свой менеджер — это __enter__/__exit__ или генератор под @contextmanager.

Что почитать дальше

  • Функции и модули — как организовать код, в котором живут твои исключения и менеджеры.
  • Итераторы и генераторы — тот самый yield, на котором держится @contextmanager.
  • Инструменты разработчика — линтеры и форматтеры, которые ловят проглоченные исключения и прочие огрехи.