20.8 – Опасности и недостатки исключений

Добавлено 12 сентября 2021 в 13:57

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

Освобождение ресурсов

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

try
{
    openFile(filename);
    writeFile(filename, data);
    closeFile(filename);
}
catch (const FileException &exception)
{
    std::cerr << "Failed to write to file: " << exception.what() << '\n';
}

Что произойдет, если writeFile() даст сбой и выбросит исключение FileException? На данный момент мы уже открыли файл, и теперь управление порядком выполнения программы переходит к обработчику FileException, который печатает сообщение об ошибке и завершается. Обратите внимание, что файл никогда не закрывается! Этот пример должен быть переписан следующим образом:

try
{
    openFile(filename);
    writeFile(filename, data);
    closeFile(filename);
}
catch (const FileException &exception)
{
    // Убедиться, что файл закрыт
    closeFile(filename);
    // И только теперь напечатать сообщение об ошибке
    std::cerr << "Failed to write to file: " << exception.what() << '\n';
}

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

try
{
    auto *john { new Person{ "John", 18, PERSON_MALE } };
    processPerson(john);
    delete john;
}
catch (const PersonException &exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

Если processPerson() выбрасывает исключение, управление порядком выполнения программы переходит к обработчику catch. В результате john никогда не удаляется! Этот пример немного сложнее, чем предыдущий – поскольку john является локальной переменной в блоке try, он выходит из области видимости, когда выполняется выход из блока try. Это означает, что обработчик исключений вообще не может получить доступ к john (он уже уничтожен), поэтому для него нет никакого способа освободить память.

Однако есть два относительно простых способа, чтобы это исправить. Во-первых, объявите john за пределами блока try, чтобы он не выходил из области видимости при выходе из блока try:

Person *john{ nullptr };
try
{
    john = new Person("John", 18, PERSON_MALE);
    processPerson(john);
    delete john;
}
catch (const PersonException &exception)
{
    delete john;
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

Поскольку john объявляется за пределами блока try, он доступен как в блоке try, так и в обработчиках catch. Это означает, что обработчик catch может правильно выполнить очистку.

Второй способ – использовать локальную переменную класса, который знает, как выполнить очистку после себя, когда он выходит из области видимости (классы часто называемые «умными указателями»). Для этой цели можно использовать класс, предоставляемый стандартной библиотекой, – std::unique_ptr. std::unique_ptr – это шаблонный класс, который хранит указатель и освобождает память, на которую тот указывает, когда выходит из области видимости.

#include <memory> // для std::unique_ptr

try
{
    auto *john { new Person("John", 18, PERSON_MALE) };
    std::unique_ptr<Person> upJohn { john }; // upJohn теперь владеет объектом john

    ProcessPerson(john);

    // когда upJohn выходит из области видимости, он удаляет объект john
}
catch (const PersonException &exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

Подробнее об умных указателях мы поговорим в следующей главе.

Исключения и деструкторы

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

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

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

Влияние на производительность

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

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

Итак, когда я должен использовать исключения?

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

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

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

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

Теги

C++ / CppException / ИсключениеLearnCppstd::unique_ptrДля начинающихОбработка ошибокОбучениеПрограммированиеУмные указатели

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.