Исключения: try/except/else/finally и кастомные exceptions
Исключения: try/except/else/finally и кастомные exceptions
В отличие от Go, где ошибки возвращаются как значения, Python работает через исключения. Это другая парадигма с своими сильными и слабыми сторонами. В этом уроке - как ловить и бросать исключения идиоматично, что такое EAFP, как создавать собственные классы исключений и почему except: без типа - плохо.
Базовая структура
try:
result = risky_operation()
except SomeException as e:
handle(e)
try - блок, в котором может произойти ошибка. except - обработчик конкретного типа исключения. as e - присваивает объект исключения в переменную для доступа к деталям.
Несколько обработчиков
try:
data = json.loads(input_str)
process(data)
except json.JSONDecodeError as e:
print(f"Невалидный JSON: {e}")
except KeyError as e:
print(f"Отсутствует ключ: {e}")
except (ValueError, TypeError) as e:
print(f"Ошибка данных: {e}")
Порядок имеет значение - первый подходящий except сработает. Кортеж типов в скобках (ValueError, TypeError) - обработка нескольких типов одной веткой.
Иерархия исключений
Все исключения наследуются от BaseException:
except Exception: ловит почти всё, кроме SystemExit и KeyboardInterrupt. Никогда не пиши except: без указания типа - поймаешь и Ctrl+C, и аварийный выход.
Правильно: ловить конкретные типы. except Exception: - только в логирующих ловушках самого верхнего уровня.
else и finally
try:
f = open("data.txt")
except FileNotFoundError:
print("Файла нет")
else:
# выполняется ЕСЛИ try прошёл без исключений
data = f.read()
finally:
# выполняется ВСЕГДА - и при ошибке, и без
if 'f' in locals():
f.close()
- else - блок «всё прошло хорошо», между try и finally. Полезно когда нужно отделить «может бросить» от «уже точно работает».
- finally - cleanup, который должен выполниться независимо от исхода. Часто используется для освобождения ресурсов (хотя
withлучше для этого - см. урок про контекстные менеджеры).
EAFP vs LBYL
В Python принят стиль EAFP - «It's Easier to Ask Forgiveness than Permission»:
# EAFP - пытайся, лови ошибку
try:
value = data["key"]
except KeyError:
value = default
# LBYL - проверь сначала (Look Before You Leap)
if "key" in data:
value = data["key"]
else:
value = default
У словаря для LBYL-стиля есть готовый dict.get(). EAFP идиоматичнее в Python по нескольким причинам:
- Без race conditions (между check и use ничего не изменится)
- Не дублирует логику доступа
- Быстрее в happy path (исключения редкие)
LBYL уместен когда:
- Проверка очень дешёвая
- Исключение по бизнес-логике не ожидается
raise - бросаем исключение
def divide(a, b):
if b == 0:
raise ValueError("Деление на ноль не определено")
return a / b
Можно бросить как класс (raise ValueError), так и инстанс (raise ValueError("msg")). Второй вариант лучше - даёт сообщение.
С Python 3 нельзя raise "string" - аргумент должен быть исключением.
raise from - цепочки исключений
Когда ловишь одно исключение и бросаешь другое, важно сохранить контекст:
def load_config(path):
try:
with open(path) as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ConfigError(f"Невалидный конфиг {path}") from e
from e сохраняет оригинальное исключение в __cause__. В трейсбэке будет:
JSONDecodeError: ...
The above exception was the direct cause of the following exception:
ConfigError: ...
Без from исключение всё равно попадёт в __context__, но менее явно. Используй from для явных причин, from None чтобы спрятать оригинал:
try:
risky()
except InternalError:
raise PublicError("что-то пошло не так") from None
Кастомные исключения
class APIError(Exception):
"""Базовый класс для всех ошибок API."""
class NotFoundError(APIError):
"""Ресурс не найден."""
class ValidationError(APIError):
"""Невалидные данные."""
def __init__(self, field: str, message: str):
super().__init__(f"{field}: {message}")
self.field = field
Принципы:
- Создавай базовый класс для своей области (APIError) - удобно ловить всё одним
except APIError - Наследуйся от Exception, не от BaseException
- Передавай в
super().__init__информативное сообщение - Дополнительные атрибуты - для программного доступа
Использование:
try:
api.get_user(user_id)
except NotFoundError as e:
return Response(status=404)
except ValidationError as e:
return Response(status=400, json={"field": e.field, "error": str(e)})
except APIError as e: # ловит всё что осталось
log.exception("API error")
return Response(status=500)
Group exceptions (3.11+)
С Python 3.11 появилось ExceptionGroup и except* для нескольких одновременных исключений (полезно в asyncio с asyncio.gather):
try:
asyncio.gather(task1(), task2(), task3())
except* ValueError as eg:
for exc in eg.exceptions:
log.error(exc)
except* TypeError as eg:
for exc in eg.exceptions:
log.error(exc)
Это нишевая фича, нужна для конкуррентного кода - см. урок про паттерны asyncio. В синхронном Python редко требуется.
Антипаттерны
1. Молчаливое подавление.
try:
do_something()
except Exception:
pass # очень плохо
Без логирования или re-raise баги невозможно отладить. Минимум - залогируй.
2. Bare except.
try:
...
except: # плохо - ловит и Ctrl+C
...
Минимум - except Exception:. Лучше - конкретный тип.
3. Использование исключений для управления потоком.
# Плохо - исключения для нормальной логики
def get_first_or_default(items, default):
try:
return items[0]
except IndexError:
return default
# Лучше
def get_first_or_default(items, default):
return items[0] if items else default
Исключения дороже обычных проверок. Используй их только для исключительных случаев.
4. Слишком широкая ловля.
# Плохо
try:
user = get_user(id)
send_email(user.email)
log_activity(user)
except Exception:
print("ошибка")
Какая именно ошибка? У какого вызова? Уточняй try-блок до минимального участка, который действительно может бросить ожидаемое исключение.
Логирование исключений
import logging
try:
risky_operation()
except SomeError:
logging.exception("Что-то пошло не так") # ВКЛЮЧАЕТ traceback
logging.exception() автоматически добавляет stack trace. Это лучше чем logging.error(str(e)) - последнее теряет место возникновения. Настройка логгера - в уроке про logging.
Распространённые встроенные исключения
| Исключение | Когда |
|---|---|
ValueError | Тип верный, значение некорректное (например, int("abc")) |
TypeError | Неверный тип аргумента ("a" + 1) |
KeyError | Несуществующий ключ dict (d["missing"]) |
IndexError | Индекс за границами (l[100]) |
AttributeError | Несуществующий атрибут (obj.missing_attr) |
FileNotFoundError | Файл не найден |
PermissionError | Нет прав доступа к ресурсу |
TimeoutError | Превышен таймаут |
RuntimeError | Общая runtime-проблема, когда другое не подходит |
NotImplementedError | Метод/функция не реализована (заглушка) |
StopIteration | Конец итератора (обычно не ловишь руками) |
Мини-задание
- Перепиши LBYL в EAFP:
# LBYL
if "name" in data and "age" in data:
user = User(data["name"], data["age"])
else:
user = None
# EAFP
try:
user = User(data["name"], data["age"])
except KeyError:
user = None
- Создай свою иерархию исключений:
class ShopError(Exception):
pass
class OutOfStockError(ShopError):
pass
class InsufficientFundsError(ShopError):
def __init__(self, balance, price):
super().__init__(f"Недостаточно: {balance} < {price}")
self.balance = balance
self.price = price
def buy(user, product):
if not product.in_stock:
raise OutOfStockError(f"{product.name} закончился")
if user.balance < product.price:
raise InsufficientFundsError(user.balance, product.price)
user.balance -= product.price
return product
- Используй цепочки исключений:
def load_user_settings(path):
try:
with open(path) as f:
return parse_settings(f.read())
except FileNotFoundError as e:
raise SettingsError(f"Файл настроек не найден: {path}") from e
except json.JSONDecodeError as e:
raise SettingsError(f"Невалидный JSON в {path}") from e
Что дальше
- PHP - Ошибки и исключения (try/catch) - иерархия исключений и finally в PHP: те же концепции, другие детали типизации
Освоили обработку исключений. В следующем уроке - контекстные менеджеры: with-statement, contextlib.contextmanager, ExitStack. Это идиоматичный способ работы с ресурсами (файлы, соединения, локи), завершающий Модуль 3.