Исключения в Python: try, except, raise и своя иерархия

Исключения в Python: try, except, raise и своя иерархия Полезное

Исключение (exception) в Python — это объект, который интерпретатор создает и «поднимает», когда во время выполнения нарушается нормальный ход программы: деление на ноль, обращение к несуществующему ключу, чтение отсутствующего файла. Если исключение не перехватить, программа аварийно завершится с трассировкой стека. Ниже разбираю механизм try/except/else/finally, оператор raise, иерархию классов, свои исключения, менеджеры контекста и группы исключений в Python 3.14.

Ошибка и исключение — не одно и то же

Термины часто путают, разведу их сразу.

  • Синтаксическая ошибка (SyntaxError) ловится еще до запуска, на этапе разбора кода. Программа не стартует вообще.
  • Исключение времени выполнения возникает, когда код синтаксически корректен, но при работе наступает «особое» условие. Именно такие ситуации и обрабатывают через try/except.
  • Логическая ошибка — код работает и не падает, но выдает неверный результат. Ее исключение не поймает, ее ищут тестами.

Синтаксическую ошибку исправляют в редакторе, исключение — обрабатывают в рантайме. Дальше речь только про них.

Минимальный рабочий пример: try/except/else/finally

Начну с законченного примера, который можно скопировать и запустить. Он показывает все четыре блока сразу.

def read_age(raw):
    try:
        age = int(raw)          # опасная операция
    except ValueError:
        print("нужно целое число")
        return None
    else:
        print(f"возраст принят: {age}")   # только если try прошел без ошибок
        return age
    finally:
        print("проверка завершена")       # выполняется всегда

read_age("30")
read_age("тридцать")

Результат:

возраст принят: 30
проверка завершена
нужно целое число
проверка завершена

Разберу по блокам:

  • try содержит опасный код, который может «поднять» исключение (здесь int(raw)).
  • except ValueError срабатывает, только если внутри try возникло именно это исключение. Для строки «тридцать» int поднимает ValueError.
  • else выполняется, если try прошел без исключений. Сюда выносят продолжение, которое не должно попадать под except.
  • finally выполняется всегда: и при успехе, и при ошибке, и даже при return. Отсюда закрывают ресурсы.

Обратите внимание на порядок: для «30» строка из else печатается раньше строки из finally — finally отрабатывает перед фактическим возвратом значения.

Перехват конкретного типа против голого except

Частая ошибка новичка — написать except: без типа. Такой «голый» except ловит вообще все, включая опечатки в коде и служебные сигналы вроде остановки с клавиатуры. Баг маскируется под штатную обработку.

Неверно (голый except прячет опечатку в ключе):

data = {"count": "5"}
try:
    value = int(data["cout"])   # опечатка: cout вместо count
except:
    value = 0
print(value)

Результат:

0

Программа напечатала 0 и не подала виду: на самом деле сработал KeyError из-за опечатки, а не проблема с числом. Логика молча сломалась. Исправление — перехватывать конкретные типы:

data = {"count": "5"}
try:
    value = int(data["count"])
except (KeyError, ValueError):
    value = 0
print(value)

Результат:

5

Правило простое: перехватывайте самый узкий тип, который реально ожидаете. Несколько типов — кортежем в скобках или отдельными блоками except (частные случаи раньше, общие позже). except Exception допустим как осознанный «последний рубеж» с логированием, голый except: без типа — антипаттерн.

raise: как поднять исключение самому

Оператор raise возбуждает исключение вручную — когда данные формально корректны для интерпретатора, но нарушают правила вашей задачи.

def withdraw(balance, amount):
    if amount > balance:
        raise ValueError(f"нельзя снять {amount} при балансе {balance}")
    return balance - amount

print(withdraw(100, 30))    # 70
print(withdraw(100, 500))   # ValueError: нельзя снять 500 при балансе 100

Результат:

70

Первый вызов вернул 70 и продолжил работу, второй поднял ValueError и оборвал программу с трассировкой (как ее читать — в следующем разделе). Внутри except исключение можно перевозбудить через raise без аргументов (сохранит трассировку) или обернуть в другое через raise NewError(...) from err — тогда будет видна цепочка причин.

Как читать traceback

Traceback — это отчет о том, где и почему упала программа. Читают его снизу вверх: последняя строка называет тип и текст исключения, а выше идет путь вызовов — от места запуска до конкретной строки, где произошел сбой.

print(10 / 0)

Результат:

Traceback (most recent call last):
  File "main.py", line 1, in <module>
    print(10 / 0)
          ~~~^~~
ZeroDivisionError: division by zero

Начиная с Python 3.11 трассировка подчеркивает точное выражение-виновник (стрелки ~~~^~~), поэтому даже в длинной строке видно, какая именно операция упала. Тип здесь — ZeroDivisionError, текст — division by zero.

Иерархия встроенных исключений

Все исключения — это классы в дереве наследования. На вершине стоит BaseException; от него отходят системные сигналы и обычные ошибки.

Класс Роль Ловить через except Exception?
BaseException Корень всей иерархии Нет, слишком широко
SystemExit Выход через sys.exit() Нет
KeyboardInterrupt Прерывание с клавиатуры (Ctrl+C) Нет
Exception База для прикладных ошибок Да
ValueError, KeyError, TypeError Частые ошибки данных Да, наследники Exception

Ключевой момент: except Exception перехватывает прикладные ошибки, но НЕ трогает SystemExit и KeyboardInterrupt — они наследуются напрямую от BaseException, в обход Exception. Поэтому Ctrl+C прерывает программу, даже если внутри стоит except Exception: пользователь всегда должен иметь возможность остановить процесс. Родство можно проверить в коде — issubclass(KeyError, Exception) вернет True, а issubclass(KeyboardInterrupt, Exception) вернет False.

Свои классы исключений

Для доменных ошибок заводят свой класс — наследника Exception. Так вызывающий код сможет перехватывать именно вашу ошибку, не путая с чужими.

class NegativeAgeError(Exception):
    """возраст не может быть отрицательным"""


def set_age(value):
    if value < 0:
        raise NegativeAgeError(f"получено {value}")
    return value


try:
    set_age(-5)
except NegativeAgeError as e:
    print(f"ошибка домена: {e}")

Результат:

ошибка домена: получено -5

Практика такая: наследуйтесь от Exception (не от BaseException), давайте классу говорящее имя с суффиксом Error, а текст причины передавайте в конструктор. Для крупного проекта заводят один базовый класс модуля (например AppError) и от него частные, чтобы вызывающий мог поймать все ошибки приложения одним except.

Менеджеры контекста: with вместо ручного finally

Закрывать ресурсы через finally надежно, но многословно. Менеджер контекста и оператор with делают это автоматически: ресурс освобождается при выходе из блока — и при нормальном завершении, и при исключении внутри.

with open("data.txt", "w", encoding="utf-8") as f:
    f.write("привет")
# здесь файл уже закрыт, даже если бы write поднял исключение

Конструкция with эквивалентна try/finally с закрытием файла в finally, но короче и надежнее — невозможно забыть освободить ресурс. Тот же механизм используют блокировки, соединения с БД, сетевые сессии. Свой менеджер контекста делают через класс с методами __enter__ и __exit__.

Группы исключений и except* (Python 3.11+)

Иногда операция порождает сразу несколько независимых ошибок — например при параллельной обработке нескольких задач. Для этого с Python 3.11 появились ExceptionGroup и синтаксис except* (со звездочкой), актуальные и в Python 3.14.

def process():
    raise ExceptionGroup(
        "две проблемы",
        [ValueError("плохое значение"), KeyError("нет ключа")],
    )

try:
    process()
except* ValueError as eg:
    print(f"поймал ValueError: {eg.exceptions}")
except* KeyError as eg:
    print(f"поймал KeyError: {eg.exceptions}")

Результат:

поймал ValueError: (ValueError('плохое значение'),)
поймал KeyError: (KeyError('нет ключа'),)

Различие терминов важно: обычный except берет первое подходящее одиночное исключение, а except* работает с группой и может выполнить несколько веток — по одной на каждый встреченный тип. Обычный код по-прежнему пишут через except; except* нужен там, где ошибки приходят пачкой (типичный сценарий — asyncio.TaskGroup).

Выводы

  • Исключение — объект-сигнал о нарушении хода программы во время выполнения; синтаксическая и логическая ошибки — другие сущности, их try/except не покрывает.
  • Механизм: try (опасный код), except (конкретный тип), else (продолжение без ошибок), finally (всегда). Голый except: без типа — антипаттерн, прячет баги.
  • raise поднимает исключение вручную; traceback читают снизу вверх, тип и причина — в последней строке.
  • Иерархия начинается с BaseException; except Exception ловит прикладные ошибки, но не SystemExit и KeyboardInterrupt.
  • Свои ошибки наследуют от Exception; ресурсы освобождают через with; пачку ошибок обрабатывают через ExceptionGroup и except* (с 3.11, актуально в 3.14).

Где применяется / связь с практикой

Обработка исключений — обязательный навык для любого backend- и скриптового кода на Python: валидация ввода, работа с файлами и сетью, обращения к БД, парсинг данных. Умение перехватывать нужный тип, поднимать свои ошибки и освобождать ресурсы отличает надежный код от того, что падает на первом нестандартном вводе.

Освойте тему на практике

Системно эти темы разбирают на курсе Python Basic: от синтаксиса до работы с ошибками и стандартной библиотекой. Посмотреть формат занятий можно на открытых уроках Otus — они бесплатные и проходят вживую.

FAQ

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

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

Когда заводить свой класс исключения, а не брать ValueError?
Когда вызывающий код должен отличать вашу доменную ошибку от стандартных и реагировать на нее отдельно. Для простой проверки значения достаточно встроенного ValueError или TypeError.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)