Лекция 10. Обработка исключений: основы и иерархия
Введение
Обработка ошибок — неотъемлемая часть создания надёжного программного обеспечения. Ни одна реальная программа не работает в идеальных условиях: файл, который мы пытаемся открыть, может отсутствовать; сеть может оборваться; пользователь введёт буквы там, где ожидалось число; внешний сервис вернёт пустой ответ. Вопрос не в том, случатся ли ошибки, а в том, как программа на них отреагирует.
Механизм исключений (exceptions) — это способ структурированно сообщать об ошибочных ситуациях и обрабатывать их. Главная идея исключений в том, что они позволяют отделить логику обработки ошибок от основного («счастливого») сценария. Благодаря этому код становится чище, читаемее и проще в сопровождении.
Исторически обработка ошибок прошла путь от простых кодов возврата к структурированным исключениям:
- В языке C использовались коды возврата функций и глобальная переменная
errno. - В C++ появились исключения как полноценная языковая конструкция.
- Python унаследовал и развил эту концепцию, добавив динамическую типизацию и богатую иерархию встроенных исключений.
В Python исключение — это объект, экземпляр некоторого класса, представляющий ошибочное состояние. Это особенно важно в контексте ООП: с исключениями можно работать как с обычными объектами, выстраивать их в иерархии наследования и расширять собственными типами (об этом — в следующей лекции).
1. Зачем нужны исключения: сравнение с кодами возврата
Рассмотрим типичную задачу — поделить два числа. Без исключений мы вынуждены придумывать «специальное» возвращаемое значение, сигнализирующее об ошибке.
Подход с кодами возврата
def divide_with_code(a, b): if b == 0: return None # "код ошибки" — деление невозможно return a / b
result = divide_with_code(10, 0)if result is None: print("Ошибка: деление на ноль")else: print(result)У этого подхода есть серьёзные недостатки:
- Ошибку легко проигнорировать. Если забыть проверку
if result is None, программа спокойно продолжит работать с некорректным значением, и баг проявится далеко от места возникновения. None(или-1) — двусмысленны. ИногдаNone— валидный результат, и тогда отличить ошибку от нормального значения невозможно.- Проверки засоряют код. После каждого вызова приходится писать
if, и основной алгоритм тонет в обработке ошибок. - Ошибки не «всплывают» автоматически. Их нужно вручную передавать вверх по цепочке вызовов через дополнительные
return.
Подход с исключениями
def divide(a, b): return a / b # при b == 0 Python сам возбудит ZeroDivisionError
try: result = divide(10, 0) print(result)except ZeroDivisionError: print("Ошибка: деление на ноль")Преимущества:
- Основная логика (
return a / b) не замусорена проверками. - Ошибку нельзя случайно проигнорировать: непойманное исключение остановит программу и выведет трассировку (traceback).
- Исключение автоматически распространяется вверх по стеку вызовов, пока его кто-нибудь не перехватит. Обрабатывать ошибку можно там, где это уместно, а не обязательно в месте её возникновения.
Вывод: коды возврата подходят для «ожидаемых» бизнес-исходов (например, «пользователь не найден» как штатная ситуация), а исключения — для сигнализации об ошибках, которые нарушают нормальный ход выполнения.
2. Синтаксис: try / except / else / finally
Полная форма конструкции обработки исключений состоит из четырёх блоков. Обязателен только try вместе хотя бы с одним except (или finally).
try: # код, который может вызвать исключение value = int(input("Введите число: ")) result = 100 / valueexcept ValueError: # выполняется, если в try возникло ValueError print("Это не целое число")except ZeroDivisionError: # выполняется, если возникло ZeroDivisionError print("На ноль делить нельзя")else: # выполняется, только если в try НЕ было исключений print(f"Результат: {result}")finally: # выполняется ВСЕГДА: и при ошибке, и без неё print("Блок try завершён")Разберём роль каждого блока:
try— содержит «защищаемый» код, в котором потенциально может возникнуть исключение.except— перехватывает исключение определённого типа и описывает, как на него реагировать. Блоковexceptможет быть несколько.else— выполняется только в том случае, если блокtryзавершился без исключений. Сюда выносят код, который логически продолжает успешный сценарий, но сам по себе не должен «защищаться» этимtry.finally— выполняется в любом случае, независимо от того, было исключение или нет, было ли оно обработано, и даже если внутриtryвстретилсяreturn. Используется для освобождения ресурсов: закрытия файлов, соединений, разблокировки.
Доступ к объекту исключения: as
С помощью as можно получить сам объект исключения и извлечь из него информацию:
try: int("abc")except ValueError as e: print(f"Тип ошибки: {type(e).__name__}") # ValueError print(f"Сообщение: {e}") # invalid literal for int()...Пример с finally для освобождения ресурса
def read_first_line(filename): f = None try: f = open(filename, "r") return f.readline() except FileNotFoundError: print(f"Файл {filename} не найден") return "" finally: if f is not None: f.close() # файл закроется в любом случае print("Файл закрыт")Обратите внимание: finally сработает даже после return внутри try или except. Это делает его идеальным местом для гарантированной очистки. (На практике для файлов чаще используют менеджер контекста with, который делает то же самое автоматически.)
Порядок блоков except имеет значение
Python проверяет блоки except сверху вниз и выполняет первый подходящий. Поскольку перехват по базовому типу ловит и все его подтипы, более общие исключения нужно ставить ниже более конкретных.
# ПРАВИЛЬНО: от частного к общемуtry: risky_operation()except FileNotFoundError: print("Конкретный случай: файл не найден")except OSError: print("Любая другая ошибка ввода-вывода")except Exception: print("Любая прочая ошибка")Если поменять порядок и поставить OSError выше FileNotFoundError, то блок для FileNotFoundError никогда не выполнится — ведь FileNotFoundError является подклассом OSError и будет перехвачен раньше:
# ОШИБКА ПРОЕКТИРОВАНИЯ: недостижимый блокtry: risky_operation()except OSError: print("Перехватит всё, включая FileNotFoundError")except FileNotFoundError: print("Этот блок недостижим!")Перехват нескольких типов одним блоком
Если на разные исключения нужна одинаковая реакция, их можно перечислить в кортеже:
try: value = compute()except (TypeError, ValueError) as e: print(f"Ошибка входных данных: {e}")3. Иерархия встроенных исключений
Все исключения в Python являются экземплярами классов, наследующихся от BaseException. Понимание этой иерархии критически важно: именно она определяет, какие исключения поймает тот или иной блок except.
Структура иерархии (фрагмент)
BaseException├── SystemExit # вызов sys.exit()├── KeyboardInterrupt # нажатие Ctrl+C├── GeneratorExit # закрытие генератора└── Exception # базовый класс для ОБЫЧНЫХ ошибок ├── StopIteration ├── ArithmeticError │ ├── FloatingPointError │ ├── OverflowError │ └── ZeroDivisionError ├── AssertionError ├── AttributeError ├── EOFError ├── ImportError │ └── ModuleNotFoundError ├── LookupError │ ├── IndexError │ └── KeyError ├── NameError │ └── UnboundLocalError ├── OSError │ ├── FileNotFoundError │ ├── PermissionError │ └── TimeoutError ├── RuntimeError │ ├── NotImplementedError │ └── RecursionError ├── TypeError ├── ValueError │ └── UnicodeError └── Warning ├── DeprecationWarning └── UserWarningКлючевой узел здесь — Exception. Подавляющее большинство «нормальных» ошибок (неверный тип, отсутствующий ключ, деление на ноль, файл не найден) наследуются именно от него.
Промежуточные («групповые») классы
Обратите внимание на классы вроде ArithmeticError, LookupError, OSError. Они не возбуждаются напрямую, а служат общими родителями для семейств похожих ошибок:
LookupError— родительIndexError(неверный индекс списка) иKeyError(отсутствующий ключ словаря). Логично: оба — про неудачный поиск элемента.ArithmeticError— родительZeroDivisionError,OverflowErrorи др.OSError— родительFileNotFoundError,PermissionError,TimeoutErrorи других ошибок взаимодействия с операционной системой.
Эти промежуточные классы позволяют ловить целое семейство ошибок одним except (см. раздел 6).
4. Почему не стоит «ловить всё подряд»
Заманчиво написать except Exception или даже except BaseException и «перестраховаться». Но это распространённая и опасная ошибка.
Главное правило
Перехватывайте исключения, наследующиеся от
Exception, и никогда не перехватывайтеBaseExceptionцеликом без крайней необходимости.
Причина в том, что три важных исключения находятся в иерархии рядом с Exception, а не внутри него:
KeyboardInterrupt— возбуждается, когда пользователь нажимаетCtrl+C, чтобы прервать программу.SystemExit— возбуждается функциейsys.exit()для штатного завершения программы.GeneratorExit— связан с закрытием генераторов.
Все они наследуются напрямую от BaseException, минуя Exception. Это сделано намеренно: эти события означают «программа должна завершиться», и перехватывать их как обычные ошибки нельзя.
# ПЛОХО: перехватываем даже Ctrl+C — программу не остановить!try: while True: passexcept BaseException: print("Это поймает даже KeyboardInterrupt — пользователь в ловушке!")
# ХОРОШО: ловим только обычные ошибки, Ctrl+C проходит свободноtry: result = 10 / 0except Exception as e: print(f"Ошибка: {e}")Чем плох даже except Exception без меры
Перехват Exception законен, но злоупотреблять им тоже не стоит. «Глухой» блок
try: do_something()except Exception: pass # молча проглатываем любую ошибкускрывает баги: опечатка в имени переменной (NameError), неверный тип (TypeError) и реальная бизнес-ошибка будут одинаково «проглочены», и вы никогда не узнаете, что что-то пошло не так. Перехватывайте максимально конкретный тип, который вы действительно умеете обработать. Широкий except Exception оправдан лишь на «границах» программы — например, в главном цикле, чтобы залогировать неожиданную ошибку и не уронить весь сервис, причём обычно с обязательным логированием, а не с pass.
5. Возбуждение исключений: raise
До сих пор исключения за нас возбуждал интерпретатор. Но мы и сами можем сигнализировать об ошибке оператором raise.
def set_age(age): if not isinstance(age, int): raise TypeError("Возраст должен быть целым числом") if age < 0: raise ValueError("Возраст не может быть отрицательным") if age > 150: raise ValueError("Возраст не может превышать 150") return ageЗдесь мы выбираем подходящий встроенный тип: TypeError — когда не тот тип данных, ValueError — когда тип правильный, но значение недопустимо. Грамотный выбор типа делает ошибку информативной для того, кто будет её ловить.
Повторный проброс исключения
Иногда нужно среагировать на ошибку (например, записать в лог), но не «гасить» её, а позволить распространиться дальше. Для этого используют raise без аргументов внутри блока except — он перебрасывает текущее исключение, сохраняя исходную трассировку.
def process(data): try: return int(data) * 2 except ValueError: print("Лог: не удалось преобразовать данные") # реагируем... raise # ...и пробрасываем ошибку дальше вызывающему кодуЭто частый и полезный паттерн: «частично обработать и отпустить». Вызывающий код получит исходное исключение со всей информацией о месте его возникновения.
Связывание исключений: raise ... from ... (вводно)
Часто одна ошибка возникает «из-за» другой: низкоуровневое исключение мы хотим заменить на более понятное для нашего слоя приложения, но при этом не потерять первопричину. Для явного связывания используется конструкция raise НовоеИсключение from исходное:
class ConfigError(Exception): """Ошибка чтения конфигурации приложения"""
def load_config(path): try: with open(path) as f: return f.read() except FileNotFoundError as e: # Превращаем техническую ошибку в доменную, сохраняя первопричину raise ConfigError(f"Не удалось загрузить конфиг: {path}") from eПри этом в трассировке появится строка вида «The above exception was the direct cause of the following exception», и вы увидите обе ошибки: и исходную FileNotFoundError, и нашу ConfigError. Это бесценно при отладке. Подробно механизм связывания исключений, атрибуты __cause__ / __context__ и подавление контекста (from None) мы разберём в лекции 11.
6. Гибкая обработка через иерархию
Самое сильное следствие иерархического устройства исключений: перехват по базовому типу ловит все его подтипы. Это позволяет обрабатывать целое семейство ошибок одним блоком.
# Ловим конкретный случай отдельно, а остальное семейство — общим типомdef get_element(container, key): try: return container[key] except KeyError: print("Нет такого ключа в словаре") return None except LookupError: # Поймает IndexError и любые другие подтипы LookupError print("Элемент не найден (неверный индекс)") return NoneАналогично, один блок except OSError перехватит и FileNotFoundError, и PermissionError, и TimeoutError — все ошибки работы с системой:
def read_safely(path): try: with open(path) as f: return f.read() except OSError as e: # FileNotFoundError, PermissionError и т.д. — всё сюда print(f"Не удалось прочитать {path}: {type(e).__name__}") return NoneСтратегия: от частного к общему
На практике комбинируют конкретные и общие блоки. Сначала — точечная обработка особых случаев, затем — «сеть» для остального семейства:
import json
def parse_config(filename): """Чтение конфигурации с обработкой различных типов ошибок""" try: with open(filename, "r") as f: return json.load(f) except FileNotFoundError: print(f"Файл {filename} не найден") return {} except PermissionError: print(f"Нет прав доступа к {filename}") return {} except json.JSONDecodeError as e: print(f"Ошибка разбора JSON: {e}") return {} except OSError as e: # Прочие ошибки ввода-вывода — общим блоком print(f"Ошибка ввода-вывода: {e}") return {}Эта же логика прекрасно работает и с собственными иерархиями исключений. Если все ошибки нашего модуля наследуются от общего базового класса, вызывающий код может выбирать уровень детализации: ловить конкретные ошибки там, где нужна особая реакция, и базовый тип — чтобы поймать «всё семейство».
class BankError(Exception): """Базовая ошибка банковских операций"""
class InsufficientFundsError(BankError): """Недостаточно средств"""
class AccountFrozenError(BankError): """Счёт заморожен"""
def withdraw(balance, amount, frozen): if frozen: raise AccountFrozenError("Счёт заморожен") if amount > balance: raise InsufficientFundsError("Недостаточно средств") return balance - amount
# Вызывающий код выбирает уровень детализации сам:try: new_balance = withdraw(1000, 5000, frozen=False)except InsufficientFundsError: print("Не хватает денег — предложим пополнить счёт")except BankError as e: # Любая другая банковская ошибка — общая реакция print(f"Операция отклонена: {e}")Проектируя собственные исключения вокруг общего базового класса, вы даёте пользователям вашего кода удобную и гибкую точку перехвата. Как правильно строить такие иерархии и наделять исключения дополнительными данными — тема следующей лекции.
Краткие итоги
- Исключение — это объект, представляющий ошибочное состояние; механизм исключений отделяет обработку ошибок от основной логики.
- В отличие от кодов возврата, исключения нельзя случайно проигнорировать и они автоматически распространяются вверх по стеку вызовов.
- Полная конструкция —
try/except/else/finally:tryзащищает код,exceptловит ошибку,elseсрабатывает при успехе,finallyвыполняется всегда (для освобождения ресурсов). - Блоки
exceptпроверяются сверху вниз; более конкретные типы ставят выше более общих, иначе общий блок перехватит всё первым. - Все исключения наследуются от
BaseException; «обычные» ошибки — отException. - Не перехватывайте
BaseException: это поглотитKeyboardInterruptиSystemExit, которые должны завершать программу. Ловите как можно более конкретный тип. raiseвозбуждает исключение; пустойraiseвнутриexceptпробрасывает текущее дальше;raise ... from ...связывает новое исключение с первопричиной.- Перехват по базовому типу ловит все подтипы — это даёт гибкую обработку целых семейств ошибок (
LookupError,OSError, ваш собственный базовый класс).
Вопросы для самопроверки
- Чем подход с исключениями принципиально лучше подхода с кодами возврата? Назовите минимум два преимущества.
- В каком порядке Python проверяет блоки
except? Что произойдёт, если поставитьexcept Exceptionвышеexcept ValueError? - Когда выполняется блок
else, а когда —finally? Сработает лиfinally, если вtryестьreturn? - Почему
except BaseExceptionсчитается опасным? Какие три исключения наследуются отBaseException, минуяException? - От какого общего класса наследуются
IndexErrorиKeyError? Как этим воспользоваться, чтобы поймать обе ошибки одним блоком? - Что делает оператор
raiseбез аргументов внутри блокаexceptи зачем это может понадобиться? - В чём смысл конструкции
raise НовоеИсключение from исходное? Какую информацию она сохраняет? - Почему перехват по базовому типу собственной иерархии исключений делает код-клиент более гибким?