Исключения C++: достоинства и недостатки - Доводы в пользу использования исключений

ОГЛАВЛЕНИЕ

2. Доводы в пользу использования исключений

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

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

// пример 1: функция, использующая исключения

string get_html(const char* url, int port)
{
    Socket client(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    client.connect(url, port);
   
    stringstream request_stream;
    request_stream << "GET / HTTP/1.1\r\nHost: " << url << "\r\nConnection: Close\r\n\r\n";

    client.send(request_stream.str());

    return client.receive();
}

Теперь изучим версию, использующую коды ошибок:

// пример 2: функция, использующая коды ошибок

Socket::Err_code get_html(const char* url, int port, string* result)
{
    Socket client;
    Socket::Err_code err = client.init(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (err) return err;
       
    err = client.connect(url, port);
    if (err) return err;
   
    stringstream request_stream;
    request_stream << "GET / HTTP/1.1\r\nHost: " << url << "\r\nConnection: Close\r\n\r\n";

    err = client.send(request_stream.str());
    if (err) return err;

    return client.receive(result);
}

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

Легко заметить, что пример с исключениями имеет более чистый и простой алгоритм, не прерываемый ни одним ветвлением if. Обработка ошибок полностью скрыта, и виден лишь "нормальный" алгоритм кода. Инфраструктура для распространения исключений генерируется компилятором: в случае исключения стек будет раскручиваться правильно, то есть локальные переменные во всех кадрах стека будут разрушены правильно – включая выполнение деструкторов.

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

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

2.2 Генерация исключения – единственный чистый способ сообщить об ошибке из конструктора.

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

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

Какие имеются альтернативы генерации исключения из отказавшего конструктора? Одна из самых популярных – идиома "двухэтапного создания", использованная в примере 2. Процесс создания объекта разбит на два этапа: конструктор выполняет только часть инициализации, не способную отказать (т.е. установка членов простых типов), а часть, способная отказать, выделяется в отдельную функцию, обычно имеющую такое имя, как init() или create(), и возвращающую код ошибки. Создание инварианта класса в данном случае требует не только создания объекта, но и вызова другой функции и проверки возвращенного кода ошибки. Недостатки такого подхода очевидны: правильная инициализация объекта требует больших усилий, и легко получить объект в неправильном состоянии, не зная об этом. Более того, копирующий конструктор нельзя реализовать таким образом – нет способа приказать компилятору вставить второй этап и проверить код ошибки. С другой стороны, эта идиома вполне эффективно используется во многих библиотеках, и при определенной тренировке она будет успешной.

Еще одной альтернативой генерации исключения из конструктора является сохранение "флага плохого состояния" в виде переменной члена, установка этого флага в конструкторе и предоставление функции для проверки флага. Стандартные потоки ввода/вывода используют этот подход:

// пример 3: использование флага плохого состояния

ifstream fs("somefile.txt");
if (fs.bad())
    return err_could_not_open;

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

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

2.3 Исключения трудно игнорировать, в отличие от кодов ошибок.

Чтобы проиллюстрировать этот довод, уберем проверку ошибок из примера 2 – он нормально скомпилируется и при условии отсутствия ошибок при выполнении будет работать столь же успешно. Однако представьте, что была ошибка в вызове init(): объект client будет в неправильном состоянии, и при вызове его функций-членов может произойти практически что угодно в зависимости от  внутренней реализации класса Socket, операционной системы, и т.д.; программа может сразу дать сбой, или она даже может выполнить все функции, но ничего не сделать и возвратиться без признаков произошедшей ошибки – за исключением результата. Зато, если исключение было сгенерировано из конструктора, неправильный объект вообще не был бы создан, и выполнение продолжилось бы в обработчике исключения. Обычная фраза такова: "мы бы получили исключение прямо нам в лицо".

Но действительно ли так трудно проигнорировать исключение? Поднимемся по стеку и рассмотрим вызывающий оператор get_html():

// пример 4: "Подавление исключений"

try {
    string html = get_html(url);
}
catch (...)
{}

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

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

2.4 Исключения легко передаются из глубоко вложенных функций.

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

Вернемся к примеру 1 с классом сокетов. Предположим, get_html() вызывается функцией get_title(), вызываемой обработчиком события пользовательского интерфейса. Нечто вроде:

// пример 5: функция, вызывающая get_html() примера 1

string get_title(const string& url)
{
    string markup = get_html(url);

    HtmlParser parser(markup);
   
    return parser.get_title();
}

Исключения обрабатываются на уровне обработчика события пользовательского интерфейса:

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

void AppDialog::on_button()
{
    try {
        string url = url_text_control.get_text();
        result_pane.set_text(
            get_title(url);
    }
    catch(Socket::Exception& sock_exc) {
        display_network_error_message();
    }
    catch(Parser::Exception& pars_exc) {
        display_parser_errorMessage();
    }
    catch(...) {
        display_unknown_error_message();
    }
}

В примере выше get_title() не содержит код для передачи информации об ошибке из get_html() в on_button(). Если бы использовались коды ошибок вместо исключений, get_title() пришлось бы проверять возвращаемое значение после вызова get_html(), сопоставлять это значение с его собственным кодом ошибки и передавать новый код ошибки обратно в on_button(). Использование исключений заставило бы get_title() выглядеть подобно примеру 1, а коды ошибок превратили бы его в нечто, похожее на пример 2. Нечто вроде:

// пример 7: get_title с кодами ошибок

enum Get_Title_Err {Get_Title_Err_OK, Get_Title_Err_NetworkError, Get_Title_Err_ParserError};
Get_Title_Err get_title(const string& url, string* title)
{
    string markup;
    Socket::Err_code sock_err = get_html(url.c_str(), &markup);
    if (sock_err) return Get_Title_Err_NetworkError;

    HtmlParser parser;
    HtmlParser::Err_code parser_err = parser.init(markup);
    if (parser_err) return Get_Title_Err_ParserError;
   
    return parser.get_title();
}

Как и пример 2, пример 7 запросто может еще больше усложниться, если попытаться передавать больше специфичных кодов ошибок. В таком случае ветвлениям if пришлось бы сопоставлять коды ошибок из библиотек с соответствующими значениями Get_Title_Err. Также заметьте, что показана лишь часть вложенной функции в этом примере – легко представить работу, требуемую для передачи кода ошибки из глубин кода парсера в функцию get_title.

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

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

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

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

2.6 Объекты исключений сопоставляются с обработчиками с помощью системы типов.

Немного дополним пример 6 и выведем специальное сообщение об ошибке, если ошибка произойдет при установлении соединения сокета:

// пример 8: обработчик исключения

void AppDialog::on_button()
{
    try {
        string url = url_text_control.get_text();
        result_pane.set_text(
            get_title(url));
    }
    catch(Socket::SocketConnectionException& sock_conn_exc) {
        display_network_connection_error_message();
    }
    catch(Socket::Exception& sock_exc) {
        display_general_network_error_message();
    }
    catch(Parser::Exception& pars_exc) {
        display_parser_errorMessage();
    }
    catch(...) {
        display_unknown_error_message();
    }
}

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

Если бы использовались коды ошибок вместо исключений, обработчик ошибок был бы оператором switch(выбора), и каждый отдельный case(вариант) обрабатывал бы значение для кода ошибки. default(по умолчанию) соответствовал бы catch(...). Это выполнимо, но не тождественно. В примере 8 используется обработчик для Socket::Exception, чтобы обрабатывать все исключения из библиотеки сокетов, кроме SocketConnectionException, обрабатываемого прямо над ней; в случае кодов ошибок пришлось бы явно перечислять все прочие коды ошибок. Если забыть один код ошибки или добавить новый код ошибки позже, но забыть обновить список кодов ошибок... вы понимаете, в чем дело.