Перевод статьи Learning (not) to Handle Exceptions

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

Примечание переводчика. Многие примеры кода, которые используется в этой статье имеют цель продемонстрировать особенности использования механизма исключений в языке Python. Иногда для наглядности представления особенностей использования инструкций кода, в ущерб практической направленности, некоторые примеры кода довольно надуманы.

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

Инструкции Try/Except

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

f = open('my_file.dat')
data = f.readfile()
print('Data loaded')

И если вы выполните этот код и файла с именем my_file.dat в рабочей директории нет, то увидите следующее сообщение:

FileNotFoundError: [Errno 2] No such file or directory: 'my_file.dat'

То есть такого файла или директории нет и после появления ошибки код вашей программы не будет выполнен полностью — произойдет её аварийное завершение. Вот почему вы не увидите результата выполнения последней инструкции: сообщения Data loaded в консоли. Это простой пример, часто возникающих на практике ошибок.

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

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

try:
    f = open('my_file.dat')
    f.readfile()
    print('Loaded data')
except:
    print('Data not loaded')

Если теперь мы его запустим на выполнение, то увидим на экране сообщение: Data not loaded (Данные не загружены). И это замечательно! Теперь наша программа аварийно не завершает свою работу, и мы можем корректно закрыть соединение или прервать связь с нашим устройством. Однако мы всё ещё не знаем причину, по которой данные не были загружены.

Прежде чем продолжить, создадим пустой файл с именем my_file.dat в рабочей директории и снова запустим наш скрипт на исполнение. И увидим, что данные всё же не загружаются, независимо от того, существует файл или нет, так как наш файл пуст. Таким образом при разработке вашего проекта множество неопределенностей и неоднозначных ситуаций, которые возникают в коде блока except даже в таком простом примере. То есть мы не знаем почему данные не загружены: либо файла нет, либо же он просто пуст, ничего не содержит. Давайте рассмотрим следующий пример кода:

open('my_file.dat')
f.readfile()
print('Loaded data')

После его выполнения получим в консоли ошибку вида:

AttributeError: '_io.TextIOWrapper' object has no attribute 'readfile'

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

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

Чтобы вы имели представление о важности правильной обработки ошибок, я приведу вам пример, ситуации, возникшей при эксплуатации прикладного приложения, которое поставлялось вместе со сложным лабораторным оборудованием. Эта программа управляет прибором Nano Sight и имеет очень приятный пользовательский интерфейс. Однако при сохранении данных, если выбранное имя файла содержало точку, данные не сохранялись. К сожалению, в этом случае данные безвозвратно терялись, и пользователь никогда не узнал бы, что причина возникновения проблемы на самом деле проста: наличие символа . в имени файла.

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

Перехват исключений заданного типа

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

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except FileNotFoundError:
    print('This file doesn\'t exist')

Если вы запустите этот скрипт, а файла my_file.dat в рабочей директории нет, то он выведет на экран сообщение, что файл не существует, и программа продолжит работу. Однако, если файл существует, вы увидите исключение связанное с отсутствием у объекта file метода readfile (такого метода конечно же не существует — это преднамеренная ошибка).

На самом деле мы можем ограничиваться простой печатью сообщения, если происходит перехват исключения — в случае отсутствия файла его легко создать:

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except FileNotFoundError:
    file = open('my_file.dat', 'w')
    print('File created')
file.close()

Если вы запустите этот скрипт, то увидите, что теперь файл my_file.dat будет создан. Если вы запустите скрипт во второй раз, то будет возбуждено исключение из-за отсутствия метода readfile.

Теперь представьте, что вы не указываете, какое конкретно исключение вы собираетесь перехватывать, и у вас есть следующий код, что произойдет, когда вы его запустите?

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except:
    file = open('my_file.dat', 'w')
    print('File created')

Если вы его внимательно проанализируете, то поймете, что, даже если файла my_file.dat существует, будет возбуждено исключение, ввиду отсутствия метода readfile. Затем будет выполнен код в блоке except. В этом блоке наша программа создаст новый файл my_file.dat, даже если он уже существует, и, следовательно, вы потеряете все информацию, которая в нем до этого хранилась. Что же мы можем сделать для того, что бы избежать этого рассмотрим далее.

Повторное возбуждение исключений

На практике распространен следующий приём написания кода, когда сначала возбуждается некоторое исключение, далее вы выполняете определённые действия, а затем снова инициализируется это же исключение. Этот прием используется, например, если вы осуществляете запись информации в базу данных или в файл. Представим себе следующую ситуацию: вы храните информацию в двух файлах: в первом вы храните значения частотного спектра, а во втором — температуру, при этом записи в отдельных файлах записываются по порядку и их соответствующие значения снимаются с датчиков в один и тот же момент времени. Далее вы сначала сохраняете в один файл значения спектра, а затем значение температуры уже в другой файл. То есть каждая строка в любом из файлов соответствует в прямом порядке следования некоторой другой строке в другом.

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

#Данные сохранены. Начало нового цикла опроса датчиков
try:
    temp = instrument.readtemp() # пытаемся опросить датчик температуры
    
except:
    remove_last_line(data_file) 
# если датчик не отвечает то удалим последнюю запись в файле, записанную в текущем цикле

    raise 
# возбудим это исключение снова для того, чтобы пользователь был оповещен о возникшей ошибке  
     
save_temperature(temp) 
# если все нормально сохраняем показания датчика в файл

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

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

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

Рассмотрим следующий пример кода.

try:
# код, который мы проверяем
except:
# при перехвате любого типа исключений
# этот блок кода будет выполнен
    raise
except Exception_classname1:
# при перехвате исключения с классом Exception_name1
# этот блок кода будет выполнен
except Exception_classname2:
# при перехвате исключения с классом Exception_name2
# этот блок кода будет выполнен

Как видно первый блок except будет перехватывать абсолютно все типы исключений и соответственно при возникновении любой исключительной ситуации код внутри этого блока будет выполнен всегда. Следующие блоки except будут выполняться только при перехвате исключений соответствующих классов Exception_classname1 и Exception_classname2 .

А значит в первый блок except можно поместить код, который необходимо выполнять при возникновении исключительной ситуации любого вида. После чего инициируем возбуждение исключения того же типа с помощью инструкции raise.

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

С синтаксисом использования инструкции except вы можете ознакомиться по ссылке. С классами исключительных ситуаций по этой ссылке.

Использование исключений в исключениях

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

try:
    file = open(filename)
    data = file.readfile()
except FileNotFoundError:
    file = open(filename, 'w')

Чтобы запустить приведенный этот скрипт на выполнение, мы должны указать имя файла:

filename  =  'my_data.dat' 
try : 
    [ ... ]

Если вы запустите этот код, то заметите, что он ведет себя точно так, как ожидалось. Однако, если вы укажете пустое имя файла:

filename = ''
try:
    [...]

Вы увидите выведенное на экран сообщение с большим количеством информации о возникшей ошибке, содержащей лишь одну важную строку:

During handling of the above exception, another exception occurred:

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

Решить возникшую причину можно, вложив в наш блок except еще один блок инструкций try/except или же предварительно проверять валидность значения переменной filename перед вызовом инструкции open. Но самый правильный способ избежать возникновения этой ситуации мы рассмотрим ниже.

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

До сих пор мы рассматривали случаи возбуждения только одного типа исключений, например FileNotFoundError. Тем не менее, листинг кода, который мы уже рассматривали выше, вызывает появление двух исключений различных типов, а точнее FileNotFoundError и AttributeError. Рассмотрим приемы работы с несколькими типами исключений на практике.

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

file = open('my_data.dat', 'a')
file.readfile()

То получите сообщение:

AttributeError: '_io.TextIOWrapper' object has no attribute 'readfile'

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

Для решения нашей задачи необходимо перехватить исключения 2 типов: AttributeError и FileNotFound. Поэтому наш код будет иметь вид:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.readfile()
except FileNotFoundError:
    file = open(filename, 'w')
    print('Created file')
except AttributeError:
    print('Attribute Error')

Теперь мы знаем как можно обрабатывать в программе несколько типов исключений. Отметим, что если в какой-либо строке кода внутри блока try возникает исключение, то остальная часть кода в этом блоке не будет выполнена. То есть если файла my_data.dat не существует то, следующая строка кода, то есть data = file.readfile() не будет выполняться.

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

В нашем же случае при первом запуске кода файла my_data.dat не существует, то есть будет инициализироваться исключение типа — FileNotFoundError. Файл будет создан и в консоли мы получим сообщение Created file. При повторном запуске нашего кода будет возбуждено исключение класса AttributeError. Так файл существует, но созданный объект file не имеет метода readfile(), то попытка его вызвать завершится возбуждением соответствующего исключения.

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

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except FileNotFoundError:
    file = open(filename, 'w')
    print('Created file')
except AttributeError:
    print('Attribute Error')
except:
    print('Unhandled exception')

В случае если файл my_data.dat существует, но он пуст, у нас возникнет еще одна проблема при попытке доступа к содержимому data[0] (получить первый символ строки, в которую мы прочитали содержимое файла). Мы не готовы к обработке этого исключения и поэтому напечатаем лишь сообщение о необработанном исключении (Unhandled exception). Тем не менее, было бы более полезно сообщать пользователю, какое же исключение было возбуждено. Для этого мы можем использовать следующий приём:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    print('Unhandled exception')
    print(e)

Результатом выполнения этого кода будет следующее сообщение:

Unhandled exception
string index out of range

Так как исключения имеют свой определённый тип type, соответствующий наименованию предопределенных классов исключений (официальная документация), то его также можно использовать. Например, следующим образом:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    print('Unhandled exception')
    if isinstance(e, IndexError):
        print(e)
        data = 'Information'
        important_data = data[0]

print(important_data)

После запуска этого кода на выполнение, он напечатает в консоли первую букву строки Information, то есть I.

Отметим, что изучение этого примера кода имеет своей целью продемонстрировать возможности использования информации о возбужденном исключении, в ущерб практической значимости. Как мы можем видеть, его главный недостаток заключается в том, что переменная important_data будет в конечном итоге не определена. Это произойдет в случае если файл my_data.dat не существует. И поэтому мы получим соответствующее сообщение об ошибке:

NameError: name 'important_data' is not defined

Чтобы не допустить таких ситуаций мы можем добавить еще один блок в последовательность блоков-перехватчиков except, это блок с инструкцией finally.

Блок finally

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

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    if isinstance(e, IndexError):
        print(e)
        data = 'Information'
        important_data = data[0]
    else:
        print('Unhandled exception')
finally:
    important_data = 'A'

print(important_data)

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

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

И наконец, поведение блока finally, в частности и в нашем примере, будет разным, в зависимости от того сколько раз мы его запустим на исполнение. Давайте рассмотрим следующий пример:

filename = 'my_data.dat'

try:
    print('In the try block')
    file = open(filename)
    data = file.read()
    important_data = data[0]
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
finally:
    print('Finally, closing the file')
    file.close()
    important_data = 'A'

print(important_data)

Сначала запустим код, для ситуации когда файла my_data.dat не существует. И увидим следующий результат его выполнения:

In the try block
File not found, creating one
Finally, closing the file

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

In the try block
Finally, closing the file
Traceback (most recent call last):
  File "JJ_exceptions.py", line 7, in 
    important_data = data[0]
IndexError: string index out of range

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

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

Блок else

Изучая вопросы обработки и инициализации исключений, необходимо рассмотреть еще одну инструкцию, которую можно использовать совместно с уже рассмотренными — это инструкция else . Основная идея её использования заключается в том, что код внутри её блока выполняется, если в блоке try не возникло ошибок, значит не инициировалось возбуждение исключений . Из следующего примера, можно очень легко понять как это работает:

filename = 'my_data.dat'

try:
    file = open(filename)
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    data = file.read()
    important_data = data[0]

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

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

try:
    file = open(filename)
    new_filename = file.readline()
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    new_file = open(new_filename)
    data = new_file.read()

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

Теперь объединим всё, что мы узнали о приемах обработки и инициализации исключений вместе:

try:
    file = open(filename)
    new_filename = file.readline()
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    new_file = open(new_filename)
    data = new_file.read()
finally:
    file.close()

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

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

Работа с traceback (трассировка)

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

Traceback (most recent call last):
  File "P_traceback.py", line 13, in 
    file = open(filename)
FileNotFoundError: [Errno 2] No such file or directory: 'my_data.dat'

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

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

Во-вторых из нашего примера сообщения сразу можно определить путь к файлу, который стал причиной возникшей ошибки, а также номер строки исходного кода скрипта, выполнение которой привело к ошибке. Если затем вы откроете файл исходного кода в текстовом редакторе и перейдете к этой строке, то увидите, что это строка, содержащую следующие инструкции: file = open(filename). И наконец из сообщения, вы можете узнать тип возникшего исключения FileNotFoundError, также как мы это делали ранее.

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

import traceback

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
except FileNotFoundError:
    traceback.print_exc()

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

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

Применение пользовательских исключений

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

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

Начнем с определения нашей функции:

def average(x, y):
    return (x + y)/2

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

def average(x, y):
    if x<=0 or y<=0:
        raise Exception('Both x and y should be positive')
    return (x + y)/2

Если вы попробуете это вызвать нашу функцию с отрицательным параметром, в консоли вы увидите следующее:

Exception: Both x and y should be positive

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

class NonPositiveError(Exception):
    pass

def average(x, y):
    if x <= 0 or y <= 0:
        raise NonPositiveError('Both x and y should be positive')
    return (x + y) / 2

Объявляем класс нашего исключения, который будет наследовать от общего класса Exception. На данном этапе, мы просто будем выполнять инструкцию pass в теле нашего класса исключения.

Если мы запустим приведенный выше код с отрицательным значением входного параметра функции, то получим:

NonPositiveError: Both x and y should be positive

Теперь если вы захотите далее в коде перехватывать ваше пользовательское исключение типа NonPositiveError, то будете делать так как мы делали это ранее. Единственным отличием будет то, что пользовательские исключения по умолчанию недоступны, и их следует импортировать. Например, следующим образом:

from exceptions import NonPositiveError
from tools import average

try:
    avg = average(1, -2)
except NonPositiveError:
    avg = 0

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

Примеры хороших практик использования пользовательских исключений

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

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

class MyException(BaseException):
    pass

class NonPositiveIntegerError(MyException):
    pass

class TooBigIntegerError(MyException):
    pass

def average(x, y):
    if x<=0 or y<=0: raise NonPositiveIntegerError('Either x or y is not positive') if x>10 or y>10:
        raise TooBigIntegerError('Either x or y is too large')
    return (x+y)/2

try:
    average(1, -1)
except MyException as e:
    print(e)

try:
    average(11, 1)
except MyException as e:
    print(e)

try:
    average('a', 'b')
except MyException as e:
    print(e)

print('Done')

Сначала определим свой класс исключений MyException, которой станет нашим базовым классом. Затем определим два класса исключений NonPositiveIntegerError и TooBigIntegerError, которые наследуют от MyException . Далее определим функцию average, но на этот раз в ней будет инициализироваться два разных типа исключений соответствующих случаям: если одно или оба из чисел, передаваемых в качестве аргумента, отрицательны или больше 10.

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

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

TypeError: '<=' not supported between instances of 'str' and 'int'

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

Добавляем к исключениям аргументы

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

class MyException(BaseException):
    pass

class NonPositiveIntegerError(MyException):
    def __init__(self, x, y):
        super(NonPositiveIntegerError, self).__init__()
        if x<=0 and y<=0:
            self.msg = 'Both x and y are negative: x={}, y={}'.format(x, y)
        elif x<=0:
            self.msg = 'Only x is negative: x={}'.format(x)
        elif y<=0:
            self.msg = 'Only y is negative: y={}'.format(y)

    def __str__(self):
        return self.msg


def average(x, y):
    if x<=0 or y<=0:
        raise NonPositiveIntegerError(x, y)
    return (x+y)/2

try:
    average(1, -1)
except MyException as e:
    print(e)

Как видно из этого примера, инструкция возбуждения исключения принимает два аргумента, x и y, и генерирует на их основе форматированное сообщение. Отметим, что в нашем примере сообщение об ошибке может содержать информацию о том, что аргументы, переданные в функцию average, могут быть как оба отрицательны, так и о каждом непосредственно.

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

Ключевая часть в объявлении нашего пользовательского класса исключений находится в конце его объявления — это метод __str__. Этот метод отвечает непосредственно за то, что появляется на экране, когда вы используете инструкцию print(e) в блоке except . В нашем примере мы просто возвращаем сообщение, генерированное методом __init__, но многие разработчики предпочитают генерировать сообщение в методе __str__ на основе параметров, переданных в конструктор класса.

Выводы

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

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

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

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

Как и в любой другой теме, посвященной Python или другому языку программирования, лучший способ изучить его — внимательно посмотреть на код другого и проанализировать его. Не все пакеты определяют свои собственные исключения и не обрабатывают их одинаково. Если вы ищете вдохновение, вы можете посмотреть ошибки Pint , в его небольшом простом пакете, или исключения Django, как пример гораздо более сложного проекта.

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

Оставить комментарий