Обработка ошибок в програмировании: Исключения

Обработка ошибок в програмировании: Исключения

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

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

Почему исключения?

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

Для тех, кто не очень хорошо знаком с исключениями, напомню, что выбрасываем мы исключения с помощью throw. В качестве самого объекта-исключения может служить все, что угодно, даже примитивные типы, хотя лучше от этого подхода сразу отказаться и использовать классы. В данном случае мы использовали экземпляр класса RegistrationException. Равносильная запись выглядит следующим образом:

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

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

Проблема лишь в том, что синтаксис C++ не требует обязательного определения информации об исключениях, которые может возбудить функция. Более того, подобная конструкция не получила такого широкого распространения и используется не всегда и не всеми, чтобы можно было на него рассчитывать. В Java с этим намного лучше. Вы обязаны либо объявить о том, что метод может вернуть исключение, либо обеспечить его перехват в блоке try-catch. С другой стороны, в какой-то мере эту проблему решает хорошая документация, но она тоже есть не всегда. К тому же, стоит учитывать, что даже хорошая документация может вводить в заблуждение, поэтому старайтесь по возможности определять ограничения на уровне синтаксических структур самого языка, что упростит работу и вам, и тем, кто будет использовать ваши наработки.

Следующий вопрос, который у вас мог возникнуть: «А как организовать передачу информации о контексте, в котором произошла ошибка»? Когда мы использовали простые коды ошибок, у нас для этого было предусмотрено перечисление. Каждый элемент перечисления соответствовал тому или иному типу ошибки. Этот вариант широко распространен и неплохо работает, но он имеет свои ограничения. Например, с помощью одного лишь кода ошибки у нас нет возможности передать дополнительную информацию, чтобы уточнить причину ошибки. В случае же с исключениями у вас появляется выбор. Рассмотрим несколько наиболее очевидных вариантов.

Вариант 1. Коды ошибок. Опять

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

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

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

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

Мы еще раз переписали функцию onUserDataReady(), с которой неоднократно экспериментировали в первой части. Она стала еще короче. А все за счет применения исключений. С другой стороны, теперь у нас всего один блок для обработки всех возможных ошибок, которые могли случиться. Однако если набор кодов ошибок продуман, то это не должно стать серьезной проблемой. Хотя логика обработки все равно будет не самой простой, поскольку может появиться блок switch или какие-то условные ветвления.

Вариант 2. Иерархия исключений

Если уж мы пишем ООП-код, то почему бы и ошибки не сделать объектами? Переход от кодов ошибок к иерархии классов осуществить не так уж сложно:

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

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

Вариант 3. Текстовые сообщения

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

Важным моментом здесь является то, что деструктор нашего исключения и функцию-член what() мы были вынуждены объявить с явным указанием на то, что они не возбуждают никаких исключений. Это необходимо сделать по той причине, что именно с такой сигнатурой объявлены эти функции у базового класса std::exception, и не учесть это мы просто не можем, иначе получим ошибку компиляции. В самом же std::exception это сделано для того, чтобы дать гарантию на их безопасное использование в блоке catch, в противном случае мы вполне могли бы нарваться на еще одно исключение, а это было бы весьма неудобно.

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

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

При этом в сообщениях, предназначенных для пользователей, не пытайтесь делать слишком подробные объяснения о технических деталях ошибки. Скорее всего, если пользователь увидит непонятный для него термин, то он просто не станет дочитывать сообщение. Хотя, конечно, ситуации бывают разные, поэтому в приложениях, рассчитанных на подготовленных специалистов, вполне допустимо использование технических подробностей об ошибках, вплоть до вывода внутренних сообщений, формируемых в исключениях. Приемлемым компромиссом в этом случае может стать реализация кнопки «Подробнее». То есть по умолчанию вы будете выводить лишь информацию о том, что что-то случилось, а все детали будут скрыты, но доступны для опытных пользователей по нажатию кнопки. Например, по этой схеме реализовано большинство сообщений об ошибках в Windows.

На самом деле, чтобы просто передать сообщение об ошибке, нам не требуется наследовать std::exception. С другой стороны, всегда лучше следовать неким стандартам, чтобы лишний раз не усложнять восприятие кода для тех, кому в будущем может потребоваться работать с ним. Приятным бонусом к этому является то, что если по какой-то причине исключение окажется неперехваченным, то после аварийного завершения приложения вы увидите не только название пропущенного исключения, но и текст сообщения, который будет выведен вызовом what() для него.

Коды ошибок, иерархия или текстовые сообщения?

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

Это лишь один из возможных подходов к созданию подобного класса исключения, и его можно улучшить. Но даже представленная реализация вполне неплохо справляется с комбинированием лучших качеств из рассмотренных нами вариантов использования исключений. В случае необходимости, чтобы возбудить исключение, мы передаем предопределенный код ошибки, которому соответствует шаблон текстового сообщения. Сами коды ошибок в этом случае мы определили внутри класса исключения, поскольку они непосредственно с ним связаны. Значения кодов ошибок в перечислении начинаются явным образом с 1000. Это поможет нам в дальнейшем не запутаться, если для других классов исключений мы будем определять коды в других диапазонах. Например, ошибки работы с БД мы можем определить, начав с 2000, а для сетевых соединений — с 3000.

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

Кроме того, следует заметить, что сообщение об ошибке мы сформировали в конструкторе, а не в функции what(). Это связано с тем, что оно строится динамически и не предопределено в виде статической константы. Если бы мы создали его внутри what() и попытались вернуть, то получили бы неопределенное поведение, поскольку выделенная память для сообщения автоматически освободится.

Коротко об освобождении ресурсов

Важным моментом при обработке исключений становится вопрос освобождения выделенных ресурсов. В предыдущей части, посвященной кодам ошибок, мы даже использовали goto для перехода к метке FINALLY, чтобы избежать дублирования кода. В некоторых языках программирования (например, Java или Python) по той же причине блок try-catch может быть расширен до try-catch-finally. Но в C++ на момент написания этой заметки такая возможность не предусмотрена. Конечно, вы можете добавить ее самостоятельно с помощью макросов, но есть гораздо более удобное решение. Оно называется RAII — Resource Acquisition Is Initialization (получение ресурса есть инициализация). Наверняка вы знакомы с умными указателями. Они и есть пример использования RAII. Суть этой техники довольно проста — необходимо создать класс-обертку, которая возьмет на себя управление ресурсом вплоть до его освобождения в своем деструкторе.

В первой части заметки мы использовали шаблонный класс AutoMemory, который обеспечивал автоматическое освобождение памяти. Но мы его не реализовали. Так давайте сделаем это:

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

Заключение

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

Понравилась статья? Поделиться с друзьями:
Добавить комментарий