Исключения: 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:

Иерархия исключений Python: BaseException → SystemExit, KeyboardInterrupt, Exception; Exception разветвляется на ValueError (UnicodeError), TypeError, LookupError (IndexError, KeyError), ArithmeticError (ZeroDivisionError), OSError (FileNotFoundError, PermissionError), RuntimeError

except Exception: ловит почти всё, кроме SystemExit и KeyboardInterrupt. Никогда не пиши except: без указания типа - поймаешь и Ctrl+C, и аварийный выход.

- `except:` - bare except, ловит ВСЁ включая Ctrl+C и SystemExit. Запрещённая практика. - `except Exception:` - ловит обычные ошибки, не системные сигналы. Слишком широко, но безопасно. - `except BaseException:` - то же что bare except, но явное. Используй только если действительно нужно.

Правильно: ловить конкретные типы. 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 по нескольким причинам:

  1. Без race conditions (между check и use ничего не изменится)
  2. Не дублирует логику доступа
  3. Быстрее в 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

Принципы:

  1. Создавай базовый класс для своей области (APIError) - удобно ловить всё одним except APIError
  2. Наследуйся от Exception, не от BaseException
  3. Передавай в super().__init__ информативное сообщение
  4. Дополнительные атрибуты - для программного доступа

Использование:

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Конец итератора (обычно не ловишь руками)

Мини-задание

  1. Перепиши 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
  1. Создай свою иерархию исключений:
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
  1. Используй цепочки исключений:
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

Что дальше

Освоили обработку исключений. В следующем уроке - контекстные менеджеры: with-statement, contextlib.contextmanager, ExitStack. Это идиоматичный способ работы с ресурсами (файлы, соединения, локи), завершающий Модуль 3.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.