Перейти к основному содержимому

Исключения и обработка ошибок

Не каждую проблему удобно решать через if: если что-то может пойти не так глубоко внутри цепочки вызовов, а обработать это нужно совсем в другом месте, приходится либо пробрасывать код ошибки через каждую промежуточную функцию, либо звать на помощь исключения
Исключения решают эту задачу - поднимаются в месте проблемы и перехватываются там, где для них есть осмысленная реакция, минуя всё, что между ними

Теория​

Зачем исключения, а не код возврата​

Без исключений о неудаче обычно сигналят особым значением (None, -1, False) - и каждая функция на пути наверх обязана его проверить и передать дальше:

def find_user(user_id: int) -> dict | None:
return DATABASE.get(user_id) # None, если не нашли


def get_user_email(user_id: int) -> str | None:
user = find_user(user_id)
if user is None:
return None # пробрасываем "неудачу" ещё на уровень выше
return user["email"]

Забыть одну такую проверку - и None тихо просочится туда, где его никто не ждёт, упав с TypeError совсем в другом, не связанном с первопричиной месте
С исключением функция просто поднимает его в момент проблемы, а обработать можно там, где для этого есть контекст - хоть на самом верху, хоть вообще нигде, если это фатально

try/except/else/finally - анатомия​

def read_config(path: str) -> str:
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
print(f"{path} не найден, использую значения по умолчанию")
content = ""
else:
print(f"{path} успешно прочитан")
finally:
print("попытка чтения конфига завершена")
return content

Порядок выполнения:

  • try - код, который может кинуть исключение
  • except - выполняется, только если в try поднялось подходящее исключение
  • else - выполняется, только если try отработал БЕЗ исключений (реже используется, но явно отделяет "основной путь" от обработки ошибки)
  • finally - выполняется всегда: и при успехе, и при исключении, и даже если внутри try/ except встретился return - типичное место для закрытия ресурсов, которое не должно зависеть от того, как завершился блок

Какое исключение ловить и в каком порядке​

except ловит исключение указанного типа И любой его подкласс - except Exception перехватит и ValueError, и KeyError, и вообще всё, что не является ошибкой уровня интерпретатора
Отсюда правило: конкретные типы - ДО общих, иначе общий except перехватит всё раньше и до конкретного дело просто не дойдёт

try:
value = int(raw_input)
except ValueError:
print("не число")
except Exception:
print("что-то ещё пошло не так")

Несколько типов в одном блоке - через кортеж: except (TypeError, ValueError):

Голый except: без типа ловит буквально всё, включая KeyboardInterrupt (Ctrl+C) и SystemExit - это почти всегда ошибка, а не намерение, поэтому ruff отдельно предупреждает об этом (правило E722, часть уже включённой у нас группы E)

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

Все исключения - классы, и except работает через isinstance, поэтому иерархия имеет значение, а не только имена

  • BaseException - самый корень, напрямую почти никогда не ловится
    • SystemExit, KeyboardInterrupt - явно НЕ подклассы Exception, чтобы случайный except Exception не мешал программе завершиться по Ctrl+C или sys.exit()
    • Exception - практическая граница: свои и почти все стандартные ошибки - его подклассы
      • ValueError - значение правильного типа, но некорректное (int("abc"))
      • TypeError - операция применена не к тому типу
      • KeyError / IndexError - не нашли ключ в словаре / индекс вышел за границы
      • AttributeError - у объекта нет такого атрибута/метода
      • OSError - ошибки уровня ОС, FileNotFoundError - его подкласс
      • StopIteration - генератор/итератор закончился (её ловит for под капотом, подробности - в статье про функциональное программирование)

except Exception - разумная практическая граница для кода приложения: она не перехватывает SystemExit/KeyboardInterrupt, то есть не мешает штатному завершению программы

raise и цепочки исключений​

Поднять новое исключение - raise ValueError("сообщение")
Внутри except голый raise без аргументов заново поднимает ТО ЖЕ пойманное исключение целиком, с исходным traceback - удобно, когда нужно что-то сделать (залогировать, откатить) и пробросить ошибку дальше без изменений:

try:
risky()
except Exception:
log_failure()
raise # то же самое исключение летит дальше

Если вместо этого нужно поднять ДРУГОЕ исключение, не потеряв связь с первопричиной - raise NewError(...) from original:

try:
return DATABASE[user_id]
except KeyError as exc:
raise UserNotFoundError(user_id) from exc

from exc явно связывает новое исключение со старым (__cause__) - в traceback это покажет обе ошибки с пометкой "the above exception was the direct cause of the following exception", вместо того чтобы скрыть, что на самом деле произошло изначально
Без from Python всё равно неявно запомнит исходное исключение (__context__, раз оно случилось прямо во время обработки другого) - from exc просто делает эту связь явной и осмысленной, а не случайным побочным эффектом
raise NewError(...) from None - обратный случай: явно обрубает связь с предыдущим исключением, когда оно не несёт полезной информации и только шумит в выводе

Свои исключения - строим иерархию​

Своё исключение - обычный класс, унаследованный от Exception (или более узкого встроенного класса, если она точно подходит по смыслу)
Смысл строить именно ИЕРАРХИЮ, а не один плоский класс на всё - вызывающий код сам выбирает, насколько широко ловить: конкретную ошибку, целую группу через общий базовый класс, или вообще не трогать эту ветку

class AppError(Exception):
"""Базовая ошибка приложения - от неё наследуются остальные."""


class ValidationError(AppError):
def __init__(self, field: str, reason: str) -> None:
super().__init__(f"{field}: {reason}")
self.field = field
self.reason = reason


class NotFoundError(AppError):
pass


try:
validate(payload)
except ValidationError as exc:
print(f"неверное поле {exc.field}: {exc.reason}")
except AppError:
print("что-то своё пошло не так") # NotFoundError и любые будущие подклассы AppError

Собственный __init__ может нести дополнительные данные об ошибке (field/reason выше) - вызывающему коду они доступны через атрибуты объекта исключения, а не только через текст сообщения
Имя своего исключения должно оканчиваться на Error - это отдельно проверяет ruff (правило N818, часть уже включённой группы N), а не просто стилистическая договорённость

Примеры​

Бронирование места в кинозале - читатель выбирает место, если оно существует в зале, ещё не занято, и сеанс ещё не начался
Показываем на этом домене, а не на модели курсов и студентов из недель 3-4 - именно свою иерархию исключений для ТОЙ модели нужно будет построить самостоятельно в автогрейдер-задаче

class BookingError(Exception):
"""Базовая ошибка бронирования билета."""


class SeatError(BookingError):
"""Проблема с конкретным местом в зале."""


class SeatDoesNotExistError(SeatError):
def __init__(self, seat: str) -> None:
super().__init__(f"места {seat} не существует в этом зале")
self.seat = seat


class SeatTakenError(SeatError):
def __init__(self, seat: str) -> None:
super().__init__(f"место {seat} уже занято")
self.seat = seat


class ShowtimeStartedError(BookingError):
"""Сеанс уже начался - бронирование закрыто."""


def book_seat(
seat: str,
valid_seats: set[str],
taken_seats: set[str],
showtime_started: bool,
) -> None:
if showtime_started:
raise ShowtimeStartedError("сеанс уже начался, бронирование закрыто")
if seat not in valid_seats:
raise SeatDoesNotExistError(seat)
if seat in taken_seats:
raise SeatTakenError(seat)
taken_seats.add(seat)


valid_seats = {"A1", "A2", "A3"}
taken_seats: set[str] = set()

book_seat("A1", valid_seats, taken_seats, showtime_started=False) # ok, A1 занято

try:
book_seat("A1", valid_seats, taken_seats, showtime_started=False)
except SeatTakenError as exc:
print(f"место {exc.seat} уже занято, выберите другое")

try:
book_seat("Z9", valid_seats, taken_seats, showtime_started=False)
except SeatError as exc:
print(f"проблема с местом: {exc}") # ловит и SeatDoesNotExistError, и SeatTakenError

try:
book_seat("A2", valid_seats, taken_seats, showtime_started=True)
except BookingError as exc:
print(f"бронирование не удалось: {exc}") # ловит вообще любую ошибку бронирования

SeatError - промежуточный класс между корнем BookingError и двумя конкретными причинами (SeatDoesNotExistError, SeatTakenError) - он даёт вызывающему коду выбор шире одного конкретного подкласса, но уже всей иерархии целиком: «поймать любую проблему именно с местом»
ShowtimeStartedError висит прямо на BookingError, минуя SeatError - это не проблема с местом, а совсем другая причина отказа, и общий промежуточный класс ей не подходит

В автогрейдер-задаче та же техника строится поверх Course/Student/Registrar из недель 3-4: своя иерархия исключений на месте пар bool-результатов вроде enroll()/is_full(), и консьюмер внешнего API поверх неё - но набор классов, число уровней вложенности и условия там свои, копировать код этого примера один в один не получится

Проверь себя​

1. В каком порядке выполняются try/except/else/finally, если исключений не было?

try целиком, затем else (раз исключения не случилось), затем finally
Если бы исключение случилось и было поймано - вместо else выполнился бы подходящий except, а finally выполнился бы всё равно

2. Что делает finally, чего не делают try/except?

Выполняется всегда - и при успехе, и при необработанном исключении, и даже если внутри try/ except есть return
Типичное место для закрытия ресурсов, которое не должно зависеть от исхода

3. Почему конкретные типы исключений в except нужно писать раньше общих (например, Exception)?

except ловит указанный тип И любой его подкласс через isinstance
Если общий except Exception стоит первым, он перехватит всё раньше, и до более конкретного except ниже дело просто не дойдёт

4. Почему голый except: без типа - это почти всегда ошибка?

Он ловит буквально всё, включая KeyboardInterrupt и SystemExit - то есть может помешать программе штатно завершиться по Ctrl+C или sys.exit()
Ruff отдельно предупреждает об этом (правило E722)

5. Почему SystemExit и KeyboardInterrupt - НЕ подклассы Exception?

Чтобы обычный except Exception, написанный для обработки ошибок приложения, случайно не перехватывал попытку штатно завершить программу

6. Чем raise без аргументов внутри except отличается от raise NewError(...)?

Голый raise заново поднимает ТО ЖЕ самое пойманное исключение с исходным traceback
raise NewError(...) поднимает совсем другое исключение - используется вместе с from, чтобы не потерять связь с первопричиной

7. Что даёт from exc в raise NewError(...) from exc, чего нет без него?

Явно связывает новое исключение с исходным через __cause__ - в traceback будет видно оба исключения с пометкой, что первое - прямая причина второго
Без from связь всё равно сохранится неявно (__context__), но from делает её осознанной, а не случайной

8. Когда стоит использовать raise NewError(...) from None?

Когда исходное исключение - деталь реализации, не несущая пользы для того, кто будет разбираться с новым исключением
from None явно обрубает цепочку, чтобы она не шумила в выводе

9. Зачем строить ИЕРАРХИЮ своих исключений, а не один плоский класс на все ошибки?

Вызывающий код сам выбирает нужную ширину перехвата: конкретный подкласс, всю группу через общий базовый класс, или не трогать эту ветку вообще
Один плоский класс такого выбора не даёт - либо ловишь всё, либо ничего

10. Какое требование ruff предъявляет к именам своих исключений и каким правилом?

Имя должно оканчиваться на Error - это проверяет правило N818 (часть группы N, pep8-naming), а не просто договорённость по стилю

Итог / чек-лист​

  • Исключения поднимаются в месте проблемы и перехватываются там, где для них есть осмысленная реакция - в отличие от кода возврата, который приходится пробрасывать через каждую промежуточную функцию вручную
  • try - код, который может упасть; except - обработка; else - только если исключения не было; finally - всегда, независимо от исхода
  • except ловит указанный тип и любой его подкласс (isinstance) - конкретные типы пишутся раньше общих
  • Голый except: без типа перехватывает всё, включая KeyboardInterrupt/SystemExit - ruff предупреждает об этом (E722)
  • Exception - практическая граница для кода приложения; BaseException напрямую почти никогда не ловится
  • Голый raise внутри except пробрасывает то же исключение дальше с исходным traceback
  • raise NewError(...) from exc явно связывает новое исключение с первопричиной (__cause__); from None - явно обрубает связь
  • Своё исключение - подкласс Exception, имя оканчивается на Error (N818)
  • Иерархия своих исключений (общий базовый класс + подклассы) даёт вызывающему коду выбор ширины перехвата - конкретный случай или вся группа через базовый класс

Дополнительно​