Исключения

Исключения

Любая программа может выполняться с ошибками. Часть ошибок связана с самим кодом. А другая часть связана с ситуациями, которые возникают довольно редко, но означают, что дальнейшее выполнение программы невозможно, если возникшую проблему никак не решить. Такие ситуации исключительны. Поэтому и механизм языка, предназначенный для работы с исключительными ситуациями, называется системой исключений (exceptions).

Пример исключительной ситуации: ошибка IndexError. Эту ошибку вы видите, когда обращаетесь к строке, списку, кортежу по индексу, который выходит за границы коллекции. Как правило, с коллекциями вы работаете с помощью цикла for, или же не забываете следить за тем, чтобы индекс не достигал длины списка. А значит ситуация выхода индекса за допустимые границы — исключительная!

Когда вы открываете файл на запись, а место на диске внезапно заканчивается или у вас не оказывается прав на запись в этот файл — всё это тоже исключительные ситуации.

При этом вы не можете продолжать выполнять программу, если в файл не получается записать или элемента в списке по вычисленному индексу не окажется. Выполнение должно прерваться максимально быстро, чтобы программа не успела ухудшить ситуацию.

Возбуждение (raising) исключения в коде похоже на return, только на его глобальную версию, завершающую все функции в порядке, обратном тому, в котором они вызывались. Если исключение будет возбуждено, но не будет перехвачено, то есть как-то обработано, вся программа так и завершится и вы увидите распечатку трейсбэка (traceback). Там то и будет отображена та самая ошибка IndexError (или какая-то другая).

Иерархия исключений

Исключения в современном языке программирования с богатой системной библиотекой могут быть самыми разными и представлены во множестве. Однако почти всегда исключения объединяются в иерархию исключений. Так все "ошибка ввода-вывода"/IOError или ошибка "файл не найден"/FileNotFoundError наследуются от исключения OSError ("ошибки взаимодействия с Операционной Системой"), которое наследуется от Exception ("просто некое исключение"). IndexError и KeyError ("ключ (словаря) не найден") являются потомками LookupError ("ошибка поиска чего-то"), которое является потомком Exception.

Иерархия исключений, таким образом, представляет собой дерево, корнем которого является BaseException, стволом — Exception, а дальше происходит ветвление на виды исключений, а затем — на конкретные исключения.

Зачем же нужно было придумывать эту самую иерархию исключений? Чтобы можно было "ловить" исключения как по одному (ловить IndexError), так и перехватывать целые группы (OSError).

Иерархии классов, isinstance и issubclass

Функция isinstance уже упоминалась ранее в этом курсе: она служит для определения, является ли объект экземпляром некоего класса. Но функция на самом деле делает чуть больше: функция проверяет, не является ли класс предком класса, экземпляр которого мы исследуем. Пример:

>>> isinstance(7, object)
True

7 является экземпляром класса int, но int является потомком класса object, поэтому в какой-то мере семёрка, и правду, является экземпляром класса object!

У isinstance есть функция-близнец issubclass. Эта проверяет родство классов:

>>> issubclass(int, object)
True
>>> issubclass(IndexError, LookupError)
True
>>> issubclass(IndexError, Exception)
True

Последние два примера интересны в контексте данной темы: именно так и можно проверить, что некое исключение входит в нужную группу. Иерархия исключений во многом ради возможности делать такие проверки и создавалась!

Синтаксис, наконец-то!

Вот так выглядит возбуждение исключения:

>>> raise ValueError('Age too low!')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Age too low!

Ключевое слово raise принимает в качестве аргумента экземпляр какого-либо класса, являющегося потомком BaseException. Большинство исключений принимают в качестве параметра строковое сообщение, описывающее конкретную ситуацию.

А так выглядит перехват исключений:

>>> l = []
>>> try:
...     l[100500] = 42
... except IndexError:
...     print('Catched!')
...
Catched!

try: начинает блок, при выполнении которого могут возникать исключение. Следом идут одна или несколько веток except, которые описывают базовый класс исключений, которые будут перехватываться. Если возникшее исключение подошло — класс исключения оказался потомком от указанного базового класса или самим указанным классом, то будет выполнен код обработчика. В данном примере вместо ошибки мы видим печать сообщения.

Важно помнить, что если у вас указано несколько веток except, то первыми нужно указывать наиболее конкретные ветки. Иначе вы можете оказаться в ситуации вроде этой:

try:
    user = users[input('May I have your name? ')]
except Exception:
    sys.exit(1)  # молча завершаем программу
except (KeyError, IndexError):
    print('No users with such name found!')

Здесь ветка except Exception: отлавливает вообще все исключения, ведь любое конкретное исключение косвенно будет экземпляром Exception! Увы, вторая ветка except не имеет шанса хоть когда быть выполненной :(

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

Ветка finally

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

f = open('data.txt')
try:
    text = f.read()
    words = len(text.split())
finally:
    f.close()

В этом коде файл будет закрыт (f.close()) в любом случае, вне зависимости от того, произошла ошибка или нет. Если ошибка всё же произошла, то сразу после блока finally выполнение кода будет прервано и исключение "всплывёт" выше, но хотя бы по поводу незакрытого файла можно будет не волноваться!

Ветка finally может использоваться вместе с ветками except, но должна идти самой последней.

Получение экземпляра исключения и возбуждение уже пойманного

Часто вы хотите получить доступ к сообщению исключения или каким-то дополнительным данным: более специфичные для предметной области исключения могут иметь специальные атрибуты, полезные при отладке. Для этого нужно указать в ветке except имя переменной, которая получит ссылку на экземпляр исключения:

try:
    ...
except (SQLSelectError, SQLInsertError) as e:
    print("Query execution error: '{}'".format(e.query))
except DBConnectionError as e:
    print("Can't connect to DB: '{}'".format(e.status))
...

Но что делать, если вы перехватили исключение, сделали некие необходимые действия, а затем решили "пробросить" исключение выше. Большинству новичков приходит на ум строчка raise e. Но это плохая идея: так вы получите возбуждение нового исключения, пусть даже и представляющего старый объект, а значит в traceback местом возникновения исключения будет уже эта сама строчка raise e! Обычно вы не хотите терять информацию о месте возникновения изначальной исключительной ситуации, поэтому просто пишите raise — так будет заново возбуждено последнее перехваченное исключение!

Антипаттерны при перехвате исключений

Перехватывать исключения можно неправильно. Про неправильный порядок обработчиков я выше уже писал. Есть и другая типичная ошибка, которую, к счастью, обычно находит линтер: except Exception: (или просто except:). Чем же плох такой способ поймать сразу все исключения? Тем, что можно случайно поймать то, что ловить совсем даже не нужно, например ошибку "переменная не объявлена":

try:
    bla-bla
except:
    pass  # теперь мы не увидим, что переменная "bla" не была объявлена!

Совет: всегда перехватывайте только те исключения, которые ожидаете и собираетесь обработать именно в месте перехвата, а все остальные исключения пусть "всплывают" выше!

Единственный случай, в котором допустимо перехватывать Exception, это случай, когда в конце обработчика исключение возбуждается заново. Таким способом вы можете, например, протоколировать (логировать, записывать в log-файл) все ошибки, но никак их не обрабатывать, чтобы у вызывающей стороны была возможность отреагировать на ошибку.

Ещё один антипаттерн: огромные try-блоки. Очень редко встречается действительно большой участок кода, в котором могут произойти всего несколько ожидаемых исключений. Практически всегда лучше перехватывает конкретные исключения в небольших участках кода, а в других участках не перехватывать ничего. Так вы не окажитесь в режиме "защитного программирования", когда все исключения всегда ловятся максимально рано, из-за чего код становится очень сложно читать.

Совет: перехватывайте только те ошибки, обработка которых позволит продолжить выполнение текущего участка кода!

Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Javascript, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →