Любая программа рано или поздно встречает то, чего не ожидала: файл не открылся, сеть отвалилась, на вход пришёл мусор. В 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. - Инструменты разработчика — линтеры и форматтеры, которые ловят проглоченные исключения и прочие огрехи.