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

  • 5
    Поделились

В этой теме 0 ответов, 1 участник, последнее обновление  questioner 2 года/лет, 1 месяц назад.

Просмотр 1 сообщения - с 1 по 1 (всего 1)
  • Автор
    Сообщения
  • #2993

    questioner
    Участник

    Исключения (исключительные ситуации, exceptions) являются механизмом обработки ошибок, который пришел на смену возврату кода ошибки функциями. Тем не менее, многие разработчики до сих пор используют возврат кода ошибки вместо исключений — в частности очень многие участники конференции C++ Russia 2015 говорили об этом, включая разработчиков Яндекса.

    В заметке мы рассмотрим:

    1. обработку каких ошибок можно выполнять с использованием исключений;
    2. синтаксические конструкции, введенные в языки для обработки исключений (примерно одинаковые в разных языках программирования — Java, C++, C# и др.).;
    3. рекомендации по использованию исключений (в т.ч. по книгам о чистоте кода);

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

    Что следует понимать под ошибкой?

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

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

    Способы обработки ошибок

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

    1. возврат кода ошибки. Например, если при отправке сообщения по сети выяснится, что соединение разорвано, функция отправки может вернуть единицу, а при успешной отправке — ноль;
    2. вернуть заведомо некорректный результат. Например, функция malloc, выделяющая память в языке Си при ошибке (невозможности выделить память) возвращает ноль, а в остальных случаях — начальный адрес выделенного фрагмента;
    3. вернуть любое допустимое значение и выставить глобальный флаг ошибки (в языке С++ для этого может использоваться глобальная переменная errno.
    4. аварийно завершить работу (в С++ для этого используются функции abort или exit);
    5. выработать исключение (подробнее написано ниже);

    Конечно, помимо завершения работы, вы всегда можете записать описание сложившейся ситуации в log-файл (поток cerr в С++) или вывести на экран.
    Аварийное завершение работы — это худший способ обработки ошибки, т.к. программист фактически расписывается в своей неспособности что-то исправить.
    С точки зрения последовательности выполнения команд, возврат кода ошибки ничем не отличается от возврата некорректного значения или выставления флага ошибки в глобальный объект. Во всех этих случаях функция, которая не знает как корректно обработать входные данные передает эти обязанности непосредственно коду, который ее вызвал. Чаще всего это работает хорошо, однако:

    1. если вызывающий код не обрабатывает код ошибки (он ведь не обязан это делать), то он продолжает вычисления, но уже с некорректными данными. Такая ситуация выявится не сразу, ведь вы можете получить некорректный результат или аварийный останов совсем с другом месте. Например, в библиотеке Qt есть функция преобразования строки в целое число:
      int QString::toInt(bool * ok = 0, int base = 10) const
      В случае ошибки она вернет ноль и присвоит значение false аргументу ok (который вообще не является обязательным). Если наш код вызывает эту функцию для строки, не являющейся целым число — то он продолжит выполнение, получив в качестве результата ноль (вполне корректное, но неверное значение);
    2. далеко не всегда удается правильно обработать ошибку непосредственно в коде, вызвавшем функцию. В результате получается примерно следующая ситуация:
      bool foo(QString str) {
        bool ok;
        string.toInt(&ok);
        if (false == ok) {
          return false;
        }
        // ... some actions
      }
      bool bar() {
        QString str;
        // ... some actions
        if (false == foo(str)) 
          return fasle;
        }
      }

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

    В этом контексте, исключения можно рассматривать как механизм доставки информации об ошибки до точки программы, где эту ошибку наиболее естественно обрабатывать. Доставка ошибки при этом будет выполняться точно также, как и серия операторов return в приведенном выше фрагменте кода — каждая функция получившая исключение передает управление «наверх» (раскручивая стек), процесс продолжается до тех пор, пока исключение не попадет в блок trt{}, содержащий обработчик, совместимый с типом исключения.
    Исключением также называют объект, некоторого класса, являющийся представлением ошибки (исключительного случая).

    Синтаксические конструкции механизма обработки исключений

    Фрагмент кода, внутри которого могут вырабатываться исключения должен быть помещен в блок try{}, к которому могут быть добавлены обработчики — блоки catch(ExceptionType) {}. Функция может вырабатывать исключения при помощи оператора throw. Например:

    try {
      socket = connect_to_server(host); // throw BadHostException or ServerNotAvailableException
      send_message(socket, message); // throw ServerNotAvailableException or BadMsgException
    }
    catch(BadHostException exception) {
      // BadHostException hadler
      // can charge other host from user
    }
    catch(ServerNotAvailableException exception) {
      // ServerNotAvailableException handler
      // can reconnect for example
    }
    catch(BadMsgException exception) {
      // BadMsgException hadler
      // can notify user by window
    }
    catch(...) {
      // other types of exceptions hadler
    }
    

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

    Исключения могут организовываться в иерархии посредством наследования. Например, в стандартной библиотеке C++ определен базовый класс std::exception, от которого наследуется std::logic_error (класс логических ошибок), являющий базовым для std::out_of_range. Для фрагмента кода, рассмотренного выше, мог быть выделен класс NetworkException, базовый для BadHostException и ServerNotAvailableException. При этом, если при обработке нам не важно какие именно проблемы с сетью произошли — мы можем написать обработчик для базового класса.

    К блоку try{} может быть написано несколько обработчиков, совместимых с одним и тем же типом исключения. Блоки catch обрабатываются в порядке их перечисления, поэтому будет выбран тот, который описан выше. Например:

    catch(BadHostException exception) {
      // BadHostException hadler
    }
    catch(NetworkException exception) {
      // all network exceptions, but not BadHostException
    }
    catch(ServerNotAvailableException exception) {
      // never call, because ServerNotAvailableException was hanled as NetworkException
    }

    Рекомендации по использованию исключений

    Выше описаны общие (подходящие для большинства языков) принципы обработки исключений. Однако, в каждом языке есть свои тонкости. Так, например, в языке Java есть ключевое слово finally, задающее фрагмент кода, который будет выполнен при возникновении исключения любого вида. В языке C++, например: надо учитывать то, что при обработке throw создается копия исключения; в обработчике исключения запись throw; позволяет передать текущее исключение дальше без копирования; и множество других тонкостей [3]. Во многих языках можно задать спецификацию исключений для функции (используйте спецификации исключений), т.е. список исключений, которые могут выходить из нее — если вдруг какой-либо участок кода начнет вырабатывать новый тип исключения — вы получите ошибку на этапе компиляции. Изучите особенности реализации механизма исключений для своего языка программирования.

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

    Роберт Мартин рекомендует начинать написание функции с try-catch-finally. Это заставит программиста еще раз задумать о том, какие ошибки могут произойти — на самом деле, почти любая функция может столкнуться с ситуациями, в которых не сможет продолжить вычисления [2].

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

    Пишите код, безопасный с точки зрения исключений (exception-safe)

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

    1. Все ресурсы, выделенные внутри блока try до генерации исключения, должны быть корректно освобождены;
    2. Все объекты, созданные внутри блока try до генерации исключения, должны быть корректно уничтожены;
    3. Должен произойти полный откат всех изменений в системе, внесенных кодом, который выполнился от начала блока try до момента генерации исключения.

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

    Транзакционность и «бизнес-логика»

    Блоками try следует охватывать те участки кода, которые должны иметь транзакционное поведение с точки зрения «бизнес-логики» самой программы.

    Рассмотрим в качестве примера графический редактор, имеющий multi-dialog интерфейс, и следующий сценарий работы: пользователь выбирает у главном меню «Open file(s)», выделяет десять картинок и нажимает «Open». Восемь картинок загружаются корректно, загрузка девятой генерирует исключение.

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

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

    Обеспечение транзакционности

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

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

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

    Типы данных

    Практически во всех ситуациях наиболее естественный тип данных для пользовательских исключений с точки зрения C++ это тип, так или иначе производный от std::exception. Моя рекомендация состоит в том, чтобы в качестве типа данных для пользовательских исключений использовать std::runtime_error, или его наследников, если это необходимо.

    Некоторые люди (не буду показывать пальцем) используют оператор new для генерации исключений и перехватывают их по указателю. Такая конструкция в свое время была (а может быть и до сих пор есть) в макросах Visual Assist-а. Такой подход в C++ в корне неверен — применять его можно только в языках со сборщиком мусора (gc). Использование такого подхода в C++ говорит о полном непонимании механизма исключений. Генерировать исключения следует по значению, перехватывать — по константной ссылке.

    Классификация и оповещение

    Любая ошибка имеет причину и влечет следствие. Используйте виртуальную функцию std::exception::what() для того, чтобы получить человеческое описание причины возникновения ошибки.

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

    namespace network
    {
    
    class error : public std::runtime_error
    {
        virtual char const* what() const;
    };
    
    } // namespace network
    
    namespace ui
    {
    
    class error : public std::runtime_error
    {
        virtual char const* what() const;
    };
    
    } // namespace ui
    
    int main()
    {
        application app;
    
        while(app.alive())
        {
            try
            {
                app.run();
            }
            catch(network::error const& e)
            {
                std::cout << "Network exception: " << e.what() << std::endl;
                mailto("admin@microsoft.com", e.what());
            }
            catch(ui::error const& e)
            {
                std::cout << "UI exception: " << e.what() << std::endl;
                message_box(e.what());
            }
            catch(std::exception const& e)
            {
                std::cout << "Exception: " << e.what() << std::endl;
            }
        }
        return 0;
    }

    Следствие ошибки напрямую зависит от того места, где исключение было перехвачено. Следствие любого перехваченного исключения в коде, безопасном с точки зрения исключений, это откат системы на блок try, в котором это исключение было сгенерированно. То есть, говоря по-простому, следствие это «что не получилось сделать», в то время как причина это «почему это не получилось сделать». Место генерации исключения и его тип — это причина, место его перехвата — это следствие.

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

    Не могу загрузить изображение (следствие):
    недостаточно прав для чтения файла 001.jpg (причина)

    Подведем итоги

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

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

    Тут же, можно вспомнить, что исключения не используются во многих проектах по причинам недостаточной эффективности — например в Яндекс.Браузере. Кроме того, исключения не использовались в библиотеке Qt (по крайней мере 4.х версии) из-за сложности их реализации на некоторых архитектурах. Тот факт, что в настоящее время библиотека Qt сейчас использует механизм исключений означает, что его поддерживает подавляющее большинство компиляторов С++ под самые разные платформы.

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

    Пишите код, транзакционный с точки зрения исключений. Реализуйте транзакционность на уровне описания типов данных, максимально минимизируя ремонт программы в блоках catch. Сопоставляйте блокам try соответствующие транзакции с точки зрения «бизнес-логики». Используйте std::runtime_error или его наследников в качестве типов данных для исключений. Генерируйте исключения по значению, перехватывайте по константной ссылке. Классифицируйте ошибки, если это необходимо. Предоставляйте понятное человеку сообщение об ошибке; указывайте как причину, так и следствие. Оповещайте об исключении способом, не генерирующим исключения.

    Литература по теме обработки исключений:

    1. Б. Страуструп Язык программирования С++. Специальное издание. Пер. с англ. – М.: Издательство Бином, 2011 г. – 1136 с.
    2. Мартин Р. Чистый код. Создание, анализ и рефакторинг. Библиотека программиста. – СПб.: Питер, 2014. – 464 с.
    3. Мейерс С. Эффективное использование С++. 35 новых рекомендаций по улучшению ваших программ и проектов. – М.: ДМК Пресс, 2014. – 294с.

Просмотр 1 сообщения - с 1 по 1 (всего 1)

Для ответа в этой теме необходимо авторизоваться.