Обработка ошибок с помощью try/except

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

  • пользователь отправляет заполенную форму по сети, а Wi-Fi не работает
  • пользователь выбрал место на диске, а места не хватает

С одной стороны это действительно ошибка, с другой дожны быть механизмы которые позволяют программе продолжить работу. Для этих целей в Python есть еще одна блочная структура try/except, она позволяет выполнить блок кода и если он завершится ошибкой, то обработать ее и продолжить работу.

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

Попытаемся выполнить следующий код содержащий синтаксическую ошибку в интерактивном режиме:

>>> for x in range(10) print x
  File "<stdin>", line 1
    for x in range(10) print x
                           ^
SyntaxError: invalid syntax

Когда интерпретатор загружает код, то сначал запускается парсер который пытается понять текст и превратить ее во внутренний байт-код с которым потом будет работать виртуальная машина. Если у парсера не получится понять код, то он выдаст сообщение и прекратит дальнейшую обработку программы. Форма вывода сообщения об всех ошибках похожа. Она называется traceback. Давайте разберем этот вывод подробнее:

1
2
3
4
5
>>> for x in range(10) print x
  File "<stdin>", line 1
    for x in range(10) print x
                           ^
SyntaxError: invalid syntax

Во второй строке мы видим, что ошибка происходит в файле "", строке 1. stdin это стандартное обозначение интерфейса ввода, в нашем случае это говорит что источником был не файл, а ручной ввод в программу. Если вы бы пробовали запустить этот же код с диска, то получили бы ссылку на файл и номер строки строку. Вот содержимое файла broken.py:

1
2
# Файл с ошибкой
for x in range(10) print x

Если запустить его командой python broken.py то получим следующий ответ:

  File "broken.py", line 2
    for x in range(10) print x
                           ^
SyntaxError: invalid syntax

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

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

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/xen/.pyenv/versions/3.7.2/lib/python3.7/urllib/request.py", line 222, in urlopen
    return opener.open(url, data, timeout)
  File "/Users/xen/.pyenv/versions/3.7.2/lib/python3.7/urllib/request.py", line 516, in open
    req.timeout = timeout
AttributeError: 'NoneType' object has no attribute 'timeout'

В реальных проектах полный стек может занимать страницу и больше.

Исключения

Вывод traceback заканчивается строкой которая обозначает какая ошибка произошла. В тот момент когда происходит ситуация которая требует обработки создается специальный объект, он называется исключение или Exception. В предыдущем примере таким объектом было SyntaxError: invalid syntax. Все исключения являются объектами типа Exception, название этого объекта и есть код ошибки. В каком-то смысле их поведение похоже на True и False. Но с чуть большим количеством свойств, например исключение может содержать комментарий или дополнительные свойства. В Python по умолчанию встроено много исключений (несколько десятков), для работы ваших програм вы будете создавать свои исключения.

Когда вы создаете программу, то скорее всего ожидаете, что могут происходит непредвиденные ситуации и попытаетесь обработать их, или другими словами обработать исключения. Конструкция языка которая позволяет обрабатывать исключения выглядит так:

try:
    # код будет выполняться построчно пока не возникнет ситуация с ошибкой
    # после этого выполнение прекратится в строке с ошибкой и управление перейдет
    # к обработчику исключений (или обработчикам если их несколько)
    <код>
except <исключение1 или список исключений1> as err:
    # в переменной err хранится объект типа Exception, можно
    # не создавать эту переменную

    # этот блок выполнится если в коде произойдет ошибка
    <блок реакции на исключение1 или список исключений1>
except <исключение2 или список исключений2> as err:
    <блок реакции на исключение2 или список исключений2>
...
except Exception:
    <блок реакции на исключение верхнего уровня>
else:
    <блок кода если никаких ошибок не произойдет>
finally:
    <завершающий блок, он будет выполнен в любом случае>

Примеры исключений

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

total_bill = float(input("Сумма счета: "))
guests = int(input("Количество гостей: "))
print(f"Общие чаевые {total_bill*tips:.2f}")
print(f"Сколько чаевых с человека {(total_bill*tips)/guests:.2f}")

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

Если попытаться сэмитировать такие ошибки в интерактивном режиме, то мы получим разные сообщения об ошибках:

>>> float("большая")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: could not convert string to float: 'большая'
>>> total_bill*tips
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'tips' is not defined
>>> 117.0*0.1/0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: float division by zero

Первая ошибка ValueError возникла в тот момент когда мы попытались ввести неправильное число. Вторая NameError произошла из-за того что мы попытались использовать переменную tips но забыли ее создать, последняя ошибка ZeroDivisionError произошла из-за того что пользователь ввел неправильно количество гостей.

Конечно неприятно когда пользователь вводит неправильные данные, но точно программа не должна вываливать на пользователя огромный traceback и заканчиваться ошибкой.

Обработка исключений

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

1
2
3
4
5
tips = 0.1
total_bill = float(input("Сумма счета: "))
guests = int(input("Количество гостей: "))
print(f"Общие чаевые {total_bill*tips:.2f}")
print(f"Сколько чаевых с человека {(total_bill*tips)/guests:.2f}")

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
tips = 0.1
while True:
    try:
        total_bill = float(input("Сумма счета: "))
        break
    except ValueError:
        print("Введите правильные данные")

guests = int(input("Количество гостей: "))
print(f"Общие чаевые {total_bill*tips:.2f}")
print(f"Сколько чаевых с человека {(total_bill*tips)/guests:.2f}")

Вызов break произойдет если в строке 4 не произойдет ошибки, что прекратит выполнение цикла и даст нам правильно заполненную переменную total_bill.

Теперь то же самое сделаем и для количества гостей:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
tips = 0.1
while True:
    try:
        total_bill = float(input("Сумма счета: "))
        break
    except ValueError:
        print("Введите правильную сумму чека")

while True:
    try:
        guests = int(input("Количество гостей: "))
        break
    except ValueError:
        print("Введите целое число гостей")

print(f"Общие чаевые {total_bill*tips:.2f}")
print(f"Сколько чаевых с человека {(total_bill*tips)/guests:.2f}")

Эта ситуация нас все еще не спасает от того что пользователь введет 0 в поле количество гостей. Мы можем обработать эту ситуацию несколькими способами:

  • С помощью проверки условия, например if guest < 1: continue
  • В этом же цикле выполнить вычисление и поймать ошибку деления, но это сделает код не таким красивым

Но вместо этого давайте создадим свое исключение и попробуем его инициировать.

Создание собственных исключений

Мы подробнее будем говорить о классах в будущих главах, поэтому пока просто обратите внимание, что мы не использовали подобный синтаксис. Для того чтобы создать новый тип исключений надо создать класс наследующий от другого класса ошибки, или от глобального класса Exception. В данном случае мы создадим свой класс ошибки от исключения ValueError.

class ValidateError(ValueError):
    pass

Для того чтобы вызвать исключение самостоятельно есть ключевое слово raise, используется оно так:

raise ValidateError("Неправильный ввод")

Теперь можем дописать калькулятор с использованием всех возможностей:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ValidateError(ValueError):
    pass

tips = 0.1
while True:
    try:
        total_bill = float(input("Сумма счета: "))
        break
    except ValueError:
        print("Введите правильную сумму чека")

while True:
    try:
        guests = int(input("Количество гостей: "))
        if guests < 1:
            raise ValidateError("Гостей меньше 1")
        break
    except ValueError:
        print("Введите целое число гостей")
    except ValidateError as err:
        print(f"Ошибка валидации ввода: {err}")

print(f"Общие чаевые {total_bill*tips:.2f}")
print(f"Сколько чаевых с человека {(total_bill*tips)/guests:.2f}")

К сожалению этот код все еще содержит важную логическую ошибку!

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

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

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

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

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

Получается, что если в иерархии исключение выше, то оно объединяет в себя все ошибки более низкого уровня. Например:

BaseException
+-- Exception
    +-- LookupError
    |    +-- IndexError
    |    +-- KeyError

В этом дереве видно, что если вы попытаетесь отловить исключение LookupError, то оно поймает и IndexError и KeyError. Поэтому когда вы пишете несколько except блоков для одного try то располагайте сначала самые узкоспециализированные исключения и потом самого высокого уровня.

Поэтому в коде нашей программы надо поменять местами ValueError и ValidateError, потому что ValidateError является более узким случаем ValueError.

Окончательный текст программы tips_calc.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ValidateError(ValueError):
    pass

tips = 0.1
while True:
    try:
        total_bill = float(input("Сумма счета: "))
        break
    except ValueError:
        print("Введите правильную сумму чека")

while True:
    try:
        guests = int(input("Количество гостей: "))
        if guests < 1:
            raise ValidateError("Гостей меньше 1")
        break
    except ValidateError as err:
        print(f"Ошибка валидации ввода: {err}")
    except ValueError:
        print("Введите целое число гостей")

print(f"Общие чаевые {total_bill*tips:.2f}")
print(f"Сколько чаевых с человека {(total_bill*tips)/guests:.2f}")