В Python так много встроенных исключений, что нам редко приходится создавать и использовать собственные исключения. Или мы?

Должны ли мы использовать пользовательские исключения или встроенные? Это очень хороший вопрос, на самом деле. Некоторые говорят,

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

Другие говорят,

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

Deitel and Deitel (2019) утверждают, что следует использовать встроенные исключения. Капил (2019) рекомендует использовать настраиваемые исключения при создании интерфейса или библиотеки, поскольку это помогает диагностировать проблемы, возникшие в коде. То же самое делает Бадер (2017), объясняя, что пользовательские исключения помогают пользователям, когда код следует стратегии проще попросить прощения, чем разрешения.

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

Определение пользовательского исключения

Во-первых, давайте посмотрим, как определить пользовательское исключение в Python. Это простая задача, так как единственное, что вам нужно сделать, это создать класс, который наследуется от встроенного класса Exception:

Ниже я покажу, что вы можете сделать больше; но правда в том, что в большинстве случаев я использую просто пустой класс (на самом деле, он не пустой, так как наследуется от Exception), так как это все, что мне нужно. Часто я добавляю описательную строку документации:

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

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

  • NegativeValueToBeSquaredError против SquaredError
  • IncorrectUserNameError против InputError
  • OverloadedTruckError и NoLoadOnTruckError против LoadError

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

Это называется иерархия исключений. Встроенные ошибки также имеют свою иерархию. Иерархия исключений может служить важной цели: при создании такой иерархии пользователю не обязательно знать все конкретные исключения (Lutz 2013). Вместо этого достаточно знать и поймать общее исключение (в нашем примере это LoadError); это позволит перехватывать все исключения, которые унаследованы от него (OverloadTruckError и NoLoadOnTruckError). Bader (2017) подкрепляет эту рекомендацию, но предостерегает от чрезмерной сложности такой иерархии.

Иногда, однако, достаточно пойти на простоту:

Если вы думаете, что NoLoadOnTruckError не должно быть ошибкой, потому что у грузовиков иногда бывают пустые поездки, вы правы. Однако помните, что исключения не обязательно должны означать ошибки; они имеют в виду… ну, они имеют в виду исключения. Тем не менее, это правило Python заканчивать имя класса исключений на «Error», и все встроенные исключения называются так (например, ValueError или OSError).

Вызов пользовательского исключения

Пользовательские исключения вызываются так же, как и встроенные. Однако стоит помнить, что мы можем сделать это несколькими способами.

Проверьте условие, поднимите, если оно не выполнено

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

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

Здесь вместо повышения ZeroDivisionError мы поднимаем кастомное EmptyVariable Error. С одной стороны, такой подход может быть более информативным, так как говорит, в чем заключалась проблема. С другой стороны, это не говорит всей истории; иными словами, повышение EmptyVariableError само по себе не информирует пользователя о том, что переменная была пустой, и по этой причине при вычислении среднего значения с использованием get_mean() произошло деление на ноль. Потребности разработчика решить, должен ли пользователь знать такую ​​подробную информацию; иногда в этом нет необходимости, но в других случаях чем больше информации передает трассировка, тем лучше.

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

Поймать встроенное исключение и создать из него собственное

Здесь мы включаем в трассировку как EmptyVariableError, так и ZeroDivisionError; единственное, что мы изменили, это добавление as e в 10-й строке предыдущего фрагмента и from e в 11-й строке.

Эта версия гораздо более информативна, так как в ней больше подробностей: переменная была пустой, данных не было, и ZeroDivisionError было поднято из-за отсутствия данных, когда среднее вычислялось с использованием get_mean(). ZeroDivisionError: division by zero говорит это? Определенно не напрямую, но вам нужно тщательно проанализировать трассировку, чтобы увидеть это косвенно.

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

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

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

Таким образом, если вы не укажете truck_no, сообщение использоваться не будет. Когда вы это сделаете, NoLoadOnTruckError будет поднято с сообщением "The truck 12333 is empty". Это простой пример; подробнее на эту тему можно прочитать здесь.

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

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

Пример

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

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

Я решил использовать псевдонимы типов в подсказках типов. На мой взгляд, такие подсказки типов более читабельны, чем подсказки сложных типов, добавленные непосредственно в сигнатуру функции. Таким образом, у нас есть типы TimeSeriesDates и TimeSeriesValues, оба являются списками, первый из datetime.datetime.date объектов, а второй - чисел с плавающей запятой.

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

Затем определяется основная функция построения модели build_model(). Конечно, это упрощенная функция, и она делает только две вещи:

  • проверяет правильность данных: ts не пропущено, y не пропущено, а ts и y имеют одинаковую длину; и
  • строит модель (которая представлена ​​функцией run_model()).

Мы могли бы переместить код проверки в специальную функцию (например, check_data()), и я определенно сделал бы это, если код build_model() станет намного длиннее.

Если одна из проверок не пройдена, функция выдаст исключение IncorrectTSDataError с сообщением в зависимости от того, что пошло не так. В противном случае функция продолжает работу и вызывает функцию run_model(). Конечно, проверка данных здесь слишком упрощена, так как служит только цели презентации. Мы могли бы проверить, действительно ли данные представляют собой список datetime.datetime.date; мы могли бы проверить, достаточно ли количества точек для построения модели прогнозирования; и тому подобное.

Теперь посмотрите, как мы запускаем функцию run_model(): мы делаем это с помощью блока try-except, чтобы иметь возможность перехватывать любую ошибку, возникшую во время этого процесса. Когда мы ловим ошибку, мы не замалчиваем ее, а повторно поднимаем, поднимая из нее PredictionError: raise PredictionError from e. Для простоты я не включил сообщение в ошибку. Таким образом, исходная ошибка будет включена в трассировку.

Чтобы запустить это и посмотреть, как это работает, нам нужна функция run_model(). Давайте создадим его макет, который вызовет только ошибку (здесь ValueError).

макет объекта — это его искусственное представление, имитирующее поведение исходного объекта, в нашем случае — функции run_model(). Это означает, что нам не нужен весь объект (функция), только его макет, который будет делать то, что нам нужно для имитации. Здесь ему достаточно поднять ValueError.

Таким образом, всякий раз, когда мы запускаем функцию, она будет поднимать ValueError:

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

Полная трассировка из строк 23–25 выглядит так (с многоточием вместо путей, имен и т. д.):

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

Эта версия функции build_model(), на самом деле, не имеет особого смысла, потому что она просто вызывает run_model(). Однако он мог бы сделать больше; например, он может проверять данные или проводить предварительную обработку данных.

Давайте проверим трассировку в тех же сценариях, что и выше:

Посмотрите на график ниже, чтобы сравнить две трассировки:

Обратите внимание на следующее:

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

Согласны ли вы со мной, что трассировка с пользовательским исключением понятнее?

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

Заключение

Пользовательские исключения легко создать, особенно если вы не вникаете во всю возню с добавлением методов .__init__() и .__str__(). Это одна из тех довольно редких ситуаций, когда меньше кода означает больше функциональности. Чаще всего пустой класс, наследуемый от класса Exception, — это путь.

Но и с пустым классом нужно принять решение: писать строку документации или нет. (Решение о том, выбрать ли оператор pass или многоточие, вообще не имеет значения, поэтому мы можем его игнорировать.) И ответ таков: это зависит. Это зависит от того, что вы ожидаете от своего класса. Если это должен быть общий класс, который будет обслуживать разные исключения, вам может понадобиться строка документации, которая будет говорить об этом. Однако, если вы выберете этот вариант, вам нужно подумать, не будут ли лучше работать несколько более точных классов исключений. Я не говорю, что они будут, потому что часто вы не захотите использовать 50 пользовательских исключений; в отличие от 100-долларовых банкнот в вашем кошельке, иногда пять лучше, чем 50.

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

Что действительно здорово, так это raise from, который позволяет нам вызвать наше пользовательское исключение из исключения, которое было вызвано внутри кода. Эта функциональность позволяет нам включить в трассировку две части: одну, связанную с исходным исключением (хотя она не обязательно должна быть встроенной), которая показывает, что произошло и где это произошло; мы можем назвать это основным источником ошибки. Вторая часть связана с нашим пользовательским исключением, которое фактически объясняет пользователю, что на самом деле произошло, используя слова, относящиеся к проекту. Сочетание этих двух частей делает трассировку мощной.

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

  1. Пакет для использования другими. Такие пакеты часто выигрывают от пользовательских исключений. Их нужно хорошо спроектировать и использовать с умом, чтобы они могли точно показать, что и где пошло не так.
  2. Бизнес-проект. Пользовательские исключения обычно являются хорошим выбором. Встроенные исключения предоставляют информацию о проблемах, связанных с Python, а пользовательские исключения добавляют информацию о проблемах, связанных с проектом. Таким образом, вы можете спроектировать свой код (и трассировку, если возникнет исключение) таким образом, чтобы код Python сочетался с языком проекта.
  3. Легкий код, как в блокноте. Точно так же это может быть код скрипта или даже фрагмент, который можно использовать один или два раза. Чаще всего пользовательские исключения были бы излишним, излишне усложняя код. Ноутбуки обычно не нуждаются в такой сложной обработке исключений; поэтому в таких ситуациях вам редко потребуется создавать собственные исключения.

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

Надеюсь, вам понравилась эта статья. Почему-то многие книги молча обходят эту тему, даже не упоминая пользовательские исключения. Конечно, есть и контрпримеры, вроде книг, на которые я ссылался в тексте. Однако я чувствую, что большинство авторов не считают пользовательские исключения темой, интересной и/или достаточно важной для обсуждения. Мое мнение противоположное, по той простой причине, что я нашел пользовательские исключения полезными во многих проектах. Если вы никогда не пробовали использовать пользовательские исключения, возможно, пришло время сделать это и проверить на себе, как они работают.

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

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

Ресурсы