# Глава 19. Обработка исключений
> Easier to ask forgiveness than permission.
Admiral Grace Hopper
Рассмотрим два подхода к обработке ошибок в коде: LBYL и EAFP. Выясним, почему в проектах на питоне лучше прижился подход EAFP. Разберем, как бросать и обрабатывать исключения, как написать свое исключение и что такое `ExceptionGroup`.
## Обработка ошибок в программировании
Обработка ошибочных ситуаций при разработке — обычное дело. Ошибки могут возникнуть из-за некорректных данных или невозможности выполнить определенную операцию. Существует два способа обработки ошибок:
- Предотвратить появление исключительной ситуации.
- Обработать исключительную ситуацию.
Первый подход считается наиболее классическим для таких языков программирования, как C, Go. В них нет исключений на уровне языка, поэтому необходимо делать проверки после каждого выполнения операции об ее успешном окончании:
```go
// golang
result, err := DoSomething(arg)
if err != nil {
// Handle the error here...
}
```
Такой подход известен под названием «look before you leap» (LBYL). Для него характерно большое количество проверок `if/else` с загромождением кода.
Альтернативным считается подход под названием «easier to ask forgiveness than permission» (EAFP). Он подразумевает ожидание выполнения программы таким образом, как будто ошибки произойти не может. Но при этом, если ошибка произошла, для ее обработки существует отдельный блок кода, который будет заниматься ее обработкой. Примечательно то, что обработка ошибки не перемешивается с основной логикой работы программы. {#block-eafp}
В питоне больше прижился подход EAFP:
- Код более читаемый.
- Уменьшается количество проверок.
- Нет возможности для race condition.
- В питоне очень эффективно реализована работа с исключениями.
В данной главе внимание уделим обработке исключений.
## Конструкция try/except
Исключительной считается ситуация, в которой синтаксически корректная программа приводит к ошибке времени выполнения.
```python
l = [] {.example_for_playground}
l[42]
```
```
IndexError: list index out of range
```
На строке 2 выполнение прервется из-за необработанного исключения, и скрипт вернет код ошибки 1 вместо кода штатного завершения 0.
В питоне, как и во многих других языках (C++, Java), существует механизм обработки исключительных ситуаций для продолжения работы программы. Для этого используется пара ключевых слов `try/except`.
Чтобы программа продолжила свое выполнение, достаточно обернуть код, который может бросить исключение, следующим образом:
```python {.example_for_playground}
try:
l = []
l[42]
except Exception as e:
print(e)
print("Exception is processed!")
```
```
list index out of range
Exception is processed!
```
Программа успешно завершилась!
В этом примере перехватывается базовый класс `Exception`, от которого наследуются все типы исключений. То есть будет обработано любое исключение-наследник класса `Exception`.
Текст исключений в питоне принято начинать со строчной, а не заглавной буквы.
```python {.example_for_playground}
a = 1 / 0
```
```
ZeroDivisionError: division by zero
```
Это правило строго соблюдается в стандартной библиотеке, и его следует придерживаться при реализации собственных классов исключений.
Обработайте исключение `1 / 0` с выводом на экран сообщения `"division by zero is forbidden"`. {.task_text}
```python {.task_source #python_chapter_0190_task_0010}
```
Внутри блока `try` требуется вызвать деление на ноль. А в блоке `except ZeroDivisionError` вывести в консоль строку `"division by zero is forbidden"`. {.task_hint}
```python {.task_answer}
try:
1 / 0
except ZeroDivisionError:
print("division by zero is forbidden")
```
Можно ловить сразу несколько видов исключений:
```python
def several_catch():
try:
with open("file.log") as file:
read_data = file.read()
except FileNotFoundError as fnf_error:
print(fnf_error)
except AssertionError as error:
print(error)
```
Причем порядок отлова исключений важен! Представим, что код выше ожидает тип `Exception` в первую очередь, а ниже по коду `FileNotFoundError`. Несмотря на то, что фактический тип исключения `FileNotFoundError`, отработает блок `Exception`. Так как он тоже подходит.
Хорошей практикой считается как можно конкретнее специфицировать тип ожидаемого исключения.
Этот подход имеет несколько плюсов:
- Автор аккуратнее пишет код.
- Читатель быстрее вникает.
- Отладка упрощается.
Синтаксически корректным считается написать следующую конструкцию:
```python
try:
print("Hello, Exceptions")
# Так писать нельзя! Нужно бить по рукам!
except:
pass
```
Но за `except` без конкретного типа исключения (или с общим типом `Exception`) по головке не погладят, а на ревью появятся вопросы.
Проблема в том, что автор кода может рассчитывать на несколько конкретных сценариев возникновения исключения. А по факту может быть выброшено неожиданное исключение, которое бы требовало совершенно иной обработки. Например, вместо `ValueError` и `RuntimeError` внезапно возникло `KeyError`.
Это [один из самых страшных](https://realpython.com/the-most-diabolical-python-antipattern/) и трудноотлавливаемых багов.
Если в вашем коде или коде коллег есть места с пустым `except` или общим `Exception`, стоит к ним присмотреться повнимательнее.
### Генерация исключений через raise
Бывают ситуации, когда необходимо намеренно выбросить исключение. Для этого существует ключевое слово `raise`:
```python
def raise_exception():
raise Exception("example of exception")
```
Напишите функцию `check_str()`, которая ожидает на вход строку. Выбросите исключение `TypeError`, если в нее передано что-то отличное от строки. {.task_text}
```python {.task_source #python_chapter_0190_task_0020}
```
Для проверки, является ли объект строкой, используйте функцию `isinstance()`: первым аргументом передайте в нее объект, вторым тип строки. {.task_hint}
```python {.task_answer}
def check_str(obj):
if not isinstance(obj, str):
raise TypeError
```
Помимо этого, `raise` позволяет «перебросить» только что пойманное исключение:
```python {.example_for_playground}
try:
raise Exception("my raise!")
except Exception as e:
print(e)
raise
```
Это может потребоваться для логирования перед дальнейшей обработкой. Переброшенное исключение будет лететь либо пока его не поймают, либо вплоть до функции `main()`.
При этом, если вызвать `raise` без активного исключения, будет сгенерировано другое исключение:
```
RuntimeError: no active exception to reraise
```
Напишите функцию `negative_odd_sum(first, second)`, которая принимает два параметра и возвращает их сумму. Если один из параметров не является целым числом, нужно вызвать исключение `TypeError`. Если один из параметров не является отрицательным нечетным, вызвать исключение `ValueError`. {.task_text}
```python {.task_source #python_chapter_0190_task_0030}
```
Для проверки типа используйте `isinstance()`. Для проверки на нечетность используйте условие `val % 2 != 0`. {.task_hint}
```python {.task_answer}
def is_negative_odd(val):
return val < 0 and val % 2 != 0
def negative_odd_sum(first, second):
if not isinstance(first, int) or not isinstance(second, int):
raise TypeError
if not is_negative_odd(first) or not is_negative_odd(second):
raise ValueError
return first + second
```
## Блоки else и finally в конструкции try/except
По аналогии с циклом `for` при работе с исключениями можно использовать ключевое слово `else`. Программа будет выполнять этот блок в том случае, если исключения не произошло:
```python {.example_for_playground}
try:
a = 42
except Exception as e:
pass
else:
print("Else block")
```
```
Else block
```
Также существует блок `finally`. Выполнение будет передано в этот блок независимо от того, возникло исключение или нет:
```python {.example_for_playground}
def foo():
try:
pass
except:
pass
else:
print("Please have mercy!")
return None
finally:
print("Finish him!")
```
Интересный момент: так как исключения не произошло, сначала выполнится блок `else`. Несмотря на то, что в этом блоке происходит выход из функции с помощью `return`, блок `finally` все равно будет выполнен:
```
Please have mercy!
Finish him!
```
Что выведет этот код? {.task_text}
В случае не обработанного исключения напишите `error`. {.task_text}
```python {.example_for_playground}
try:
raise ValueError
except ValueError as e:
print(1)
else:
print(2)
```
```consoleoutput {.task_source #python_chapter_0190_task_0050}
```
В блоке `try` было брошено исключение, а в блоке `except` оно перехвачено. Поэтому блок `else` не выполнился. {.task_hint}
```python {.task_answer}
1
```
## Инструкция assert
В ряде случаев вместо обработки исключений через `try/except` уместнее использовать инструкцию `assert`. Это условная проверка, которая при истинном условии продолжает выполнение программы, а при ложном генерирует исключение `AssertionError`.
```python {.example_for_playground}
assert "linux" in sys.platform, "this code works only on linux"
```
В случае, если платформа, на которой запускается код, отлична от `linux`, будет выброшено исключение с приготовленным текстом:
```
AssertionError: this code works only on linux
```
Когда стоит применять `assert`, а когда — `try/except`?
Следует помнить, что область применения `assert` — разработка и отладка программы. Конструкция `assert` не является средством для обработки ошибок. Он нужен для уведомления об ошибках во время разработки.
Самый популярный вариант применения `assert` — написание тестов. Это отличный способ проверки ожидаемого поведения программы.
Не менее популярное применение `assert` — это отладка. Очень удобно делать проверки на входе функции (precondition) и при выходе из нее (postcondition).
Ни в коем случае нельзя применять `assert` в продовом коде во время обработки или проверки данных. Потому что в питоне есть возможность запустить скрипт в режиме `Optimization`. В этом случае все инструкции `assert` будут выброшены из кода. Пример запуска скрипта в режиме `Optimization`:
```
python -O main.py
```
## Типы исключений
В питоне существует около 60 классов встроенных исключений. Самые популярные с примерами причин их возникновения:
- `ZeroDivisionError` — деление на ноль.
- `AssertionError` — проверка внутри assert не выполнена.
- `EOFError` — в ходе считывания данных обнаружен EOF.
- `ImportError` — не получилось импортировать модуль.
- `IndexError` — обращение к элементу вне диапазона.
- `KeyboardInterrupt` — пользователь прервал процесс комбинацией клавиш.
- `KeyError` — обращение к отсутствующему ключу в контейнере.
- `MemoryError` — исчерпание свободной памяти.
- `NameError` — глобальное или локальное имя не найдено.
- `RuntimeError` — ошибка времени выполнения (в том числе использование не объявленной переменной).
- `SyntaxError` — синтаксическая ошибка в коде программы.
- `SystemError` — ошибка работы интерпретатора.
- `TypeError` — прибавление числа к строке.
- `ValueError` — передача в функцию аргумента ожидаемого типа, но с неправильным значением.
## Пользовательские типы исключений
Хорошей практикой считается использовать встроенные типы исключений. Они позволяют покрыть очень много различных сценариев. Но иногда все же удобнее написать свой класс исключения.
```python
class MyError(Exception):
...
def raise_my_error():
raise MyError("this is my exception!")
```
```
MyError: this is my exception!
```
Зачастую название типа — это основное, что требуется от собственных исключений. Тело класса при этом может быть абсолютно пустым.
Важным правилом при написании своих типов исключений является наличие суффикса `Error` у имени класса. Это необходимо, потому что:
- Читателю понятно, что он встретил класс пользовательского исключения.
- Не все, что является исключением, стоит обрабатывать как ошибку.
Например, исключение `StopIteration` возникает при работе с генераторами или корутинами, если были исчерпаны доступные значения и не является ошибкой (об этом в следующих главах).
Создайте тип исключения с названием `UpperCaseError`. Напишите функцию `upper_case_check()`, которая принимает строку и проверяет, что первый символ — заглавная буква. В противном случае она выбрасывает исключение `UpperCaseError` {.task_text}
```python {.task_source #python_chapter_0190_task_0040}
```
Обратиться к первому символу строки можно через оператор взятия по индексу `[]`. Для проверки, является ли буква заглавной, можно использовать метод `isupper()`. {.task_hint}
```python {.task_answer}
class UpperCaseError(Exception):
...
def upper_case_check(s):
if not s or not s[0].isupper():
raise UpperCaseError
```
## Цепочки исключений
Возможны ситуации, когда при обработке одного исключения происходит другое исключение. И возникает дилемма:
- Сохранить информацию о последнем исключении.
- Сохранить всю цепочку.
Для сохранения цепочки исключений в питоне существует ключевое слово `from`:
```python
def raise_from():
try:
result = 42 / 0
except ZeroDivisionError as error:
raise ValueError("operation not allowed") from error
```
```
...
ZeroDivisionError: division by zero
...
The above exception was the direct cause of the following exception:
...
ValueError: operation not allowed
```
В случае, когда нужна информация только о последнем исключении и причина (цепочка) не важна, нужно использовать ключевое слово `None`:
```python
def raise_from():
try:
result = 42 / 0
except ZeroDivisionError as error:
raise ValueError("operation not allowed") from None
```
```
ValueError: operation not allowed
```
А вот если в примере выше опустить `from None`, то вывод будет как при `from error`.
## Группы исключений ExceptionGroup
Начиная с версии 3.11 питона появилась возможность использовать группы исключений. Она полезна, когда необходимо обработать одновременно несколько независимых ошибок.
В отличие от конструктора `Exception`, `ExceptionGroup` принимает дополнительный список исключений, а ловить эти исключения нужно с использованием нового синтаксиса:
```python
try:
raise ExceptionGroup(
"several exceptions",
[
ValueError("invalid value"),
TypeError("invalid type"),
KeyError("missing key"),
],
)
except* ValueError as ver:
print(ver.exceptions)
except* TypeError as ter:
print(ter.exceptions)
except* KeyError as ker:
print(ker.exceptions)
```
```
(ValueError('invalid value'),)
(TypeError('invalid type'),)
(KeyError('missing key'),)
```
В этом случае можно попасть сразу в несколько блоков `except` и выполнить их обработчики в порядке перечисления.
Так как `ExceptionGroup` является довольно нишевым и редко используемым инструментом, мы на нем не будем останавливаться. Более подробно почитать про `ExceptionGroup` можно в [PEP 654.](https://peps.python.org/pep-0654/)
## Stack trace
При наличии необработанного исключения в вывод программы попадет последовательность вызовов, которая к нему привела. Но иногда возникает необходимость обработать исключение и при этом вывести на экран (или в файл) эту последовательность. Последовательность этих вызовов называется `stack trace`. Получить его можно следующим образом:
```python
import logging
try:
raise ValueError
except ValueError as e:
logging.exception(e)
print("Following program execution")
```
```
ERROR:root:
Traceback (most recent call last):
File "example.py", line 4, in main
raise ValueError
ValueError
Following program execution
```
Несмотря на то, что исключение обработано, мы получили информацию о месте его возникновения. Это может помочь при дальнейшей отладке.
## Резюмируем
- В питоне можно применять как LBYL, так и EAFP подходы для обработки ошибок. Но наиболее предпочтительным считается EAFP.
- Для обработки исключений используется конструкция `try/except/else/finally`.
- `raise` позволяет выбросить/перебросить исключение.
- Предпочитайте стандартные типы исключений пользовательским.
- При создании своих типов исключений используйте суффикс Error.
- При отлове исключения желательно максимально точно его классифицировать.
- Документируйте функции и методы: какие исключения могут быть выброшены.
- Для сохранения информации о цепочке исключений нужно использовать `from`.
- `ExceptionGroup` предназначены для обработки разных типов не связанных между собой исключений.
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!