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

ОГЛАВЛЕНИЕ

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

3.1 Исключения нарушают структуру кода, создавая множество скрытых точек выхода, что затрудняет чтение и изучение кода.

Это звучит прямо противоположно тому, что говорилось в 2.1. Как исключения могут делать код одновременно трудночитаемым и легкочитаемым? Чтобы понять это мнение, вспомните, что путем использования исключений нам удалось отобразить только "нормальный ход выполнения". Код обработки ошибок все же генерируется компилятором и имеет свой собственный ход выполнения, независимый от хода выполнения написанного нами кода. По сути, нормальный ход выполнения кода более чистый, разборчивый и понятный с исключениями, но это лишь часть вопроса. Теперь нам надо знать, что в любой вызов функции мы вводим невидимое ветвление, которое сразу же существует в случае исключения (или переходы\а к обработчику, если он находится в той же функции). Это выглядит хуже, чем goto(переход) и сравнимо с longjmp, но на деле исключения C++ гораздо безопасней использовать, чем longjmp: как было показано в разделе 2.1, компилятор генерирует код, раскручивающий стек.

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

3.2 Исключения легко вызывают утечки ресурсов, особенно в языке, не имеющем встроенного сборщика мусора и блоков finally(в конце).

Чтобы понять, о чем здесь говорится, пока забудем объектно-ориентированное программирование с его классами, деструкторами и исключениями и вернемся к старому доброму C. Многие функции принимают следующий вид:

// пример 9: получение и освобождение ресурсов

void func()
{
    acquire_resource_1();
    acquire_resource_2();

    use_resources_1();
    use_resources_2();
    use_resources_3();

    release_resource_2();
    release_resource_1();
}

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

Так, где проблема? Функция из примера 9 - образец хорошо структурированного кода с одним входом и одним выходом, и ресурсы всегда освобождаются в конце функции. Вообразите, что нечто может дать сбой в use_resources_1(). В этом случае мы не выполняем остальные функции use_resources_..., а сообщаем об ошибке и сразу завершаем выполнение. Ну, не сразу – сначала надо освободить ресурсы. Как лучше всего это сделать с помощью языка C? Данная тема вызывает более горячие споры, чем дилемма "исключения по сравнению с кодами ошибок": некоторые люди копируют и вставляют код освобождения ресурсов и вызывают его всегда при завершении выполнения; другие создают макрос для этой цели по возможности; есть разработчики, сохраняющие структуру "SESE" функции и вводящие ветвления if-else для каждой функции, способной отказать. Такие функции похожи на закрывающую угловую скобку ">", и иногда это называют "стреловидный антишаблон". Многие разработчики C используют goto для перехода к части функции, освобождающей ресурсы.

В любом случае, это беспорядок даже без исключений. Что если мы переключимся на C++, и use_resources_1() сгенерирует исключение в случае ошибки? При такой манере написания функции часть освобождения ресурсов не выполнится, и произойдет утечка. Как бы здесь помогли сборщик мусора и блоки finally? Притворимся, что Java имеет поддержку работы с сетью в стандартной библиотеке, и что мы используем нечто похожее на пример 1 на Java:

// пример 10: версия примера 1 на Java
public String getHtml(String url, int port) throws (Socket.Exception)
{
    Socket client = null;
    try {
        client = new Socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
        client.connect(url, port);

        StringBuffer requestStream = new StringBuffer("GET / HTTP/1.1\r\nHost: ");
        requestStream.append(url).append("\r\nConnection: Close\r\n\r\n");

        client.send(requestStream.toString());

        return client.receive();
    }
    finally {
        if (client != null)
            client.close();
    }
}

В методе getHtml выше было получено несколько ресурсов: сокет, строковый буфер и пара временных строковых объектов. Сборщик мусора следит за объектами, которые в итоге должны освободить только память системы, и сокет закрывается в блоке finally, выполняющемся при выходе из блока try(попытка) – нормальном или через исключение.

C++ не имеет встроенного сборщика мусора и не поддерживает блоки finally; почему же функция из примера 1 (или примера 2 в связи с этим) не дает утечку? Ответ обычно называется RAII (получение ресурса является инициализацией) и означает, что объекты, объявленные как локальные переменные, разрушаются после выхода за пределы области видимости, независимо от того, как они выходят из области видимости. Когда они разрушаются, сначала выполняются деструкторы, а затем память возвращается системе. Деструкторы освобождают все ресурсы, полученные объектами – по сути, деструктор класса Socket, использованного в примере 1, похож на тело блока finally в примере 10. Плюс в том, что в C++ приходится писать код освобождения ресурсов лишь единожды, и он выполняется автоматически всякий раз, когда объект выходит за пределы области видимости. Еще один плюс RAII в том, что он обращается со всеми ресурсами одинаково – нет необходимости различать собираемые сборщиком мусора ресурсы и неуправляемые ресурсы, оставляя первые сборщику мусора и освобождая вторые в блоках finally: освобождение ресурсов скрыто, но надежно осуществляется, сродни оповещению об ошибках с помощью исключений.

Отсутствие сборщика мусора и блоков finally – не повод не использовать исключения в C++, поскольку RAII – лучший метод управления ресурсами? Да и нет. RAII лучше, чем комбинация сборщика мусора и блоков finally, и является очень простой и легкой в использовании идиомой. Но чтобы получить преимущества RAII, его надо использовать последовательно. Существует поразительное количество кода на C++, выглядящего так:

// пример 11: пример кода, не защищенного от исключений

string get_html(const char* url, int port)
{
    Socket client = new Socket(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());

    string html = client->receive();

    delete client;
    return html;
}

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

Но насколько распространен такой вид кода? Создание объектов в стеке при наличии возможности не только безопасней, но и легче, чем спаривание new и delete – можно было бы ожидать, что большинство программистов использует RAII всегда. К сожалению, это не так. Есть много кода, выглядящего как пример 11. Например, найдите официальный пример кода для парсера SAX2 популярной библиотеки Xerces-C – он создает объекты в куче и удаляет их после того, как они использованы – в случае исключения есть утечка. В начале 1990-х считалось "более объектно-ориентированным" создавать объект в куче, даже если не было оснований так поступать. Сейчас многие молодые программисты на C++ сначала изучают Java в университетах и пишут код на C++ в Java-подобной манере, а не на идиоматическом C++. Так или иначе, существует много кода, не полагающегося на определенные деструкторы в автоматическом освобождении ресурсов. Если вы имеете дело с такой базой кода, используйте коды ошибок и выключите исключения.

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

3.3 Тяжело научиться писать безопасный код исключений.

Обычное возражение на данное заявление заключается в том, что обработка ошибок вообще трудная, и исключения ее облегчают, а не затрудняют. Хотя это во многом верно, остается проблема кривой обучения: сложные средства языка часто помогают опытным программистам и упрощают их код, но новички чаще всего видят дополнительные трудности по изучению. Чтобы использовать исключения в коде готового программного продукта, программист C++ должен не только изучить механизм языка, но и лучшие способы программирования с исключениями. Как быстро новичок научится вообще не генерировать исключение из деструктора? Или использовать исключения только для исключительных условий, и никогда для нормального хода выполнения кода? Или перехватывать по ссылке, а не по значению или указателю? Как насчет полезности (или ее отсутствия) спецификаций исключений? Гарантий защиты от исключений? Темные углы, такие как полиморфная генерация исключений?

С позиции "стакан наполовину полон", с исключениями или без них, C++ всегда был языком для специалистов, обеспечивающим мало поддержки новичкам. Вместо того, чтобы полагаться на ограничения языка, хороший программист C++ обращается к сообществу, чтобы оно научило его лучшим способам использования языка. Указания сообщества меняются быстрее, чем сам язык: некто, перешедший с C++ на Java в 1997 году, часто теряется, сталкиваясь с современным кодом C++ - он выглядит иначе, но сам язык не сильно изменился. Вернемся к исключениям. Сообщество C++ узнало много о том, как эффективно их использовать, с тех пор как они были впервые введены в язык; некто, читающий новые публикации и группы новостей, должен научиться лучшим способам с определенными усилиями. Хороший стандарт кодирования, отвечающий рекомендациям сообщества, очень помогает с кривой обучения.

3.4 Исключения дорогостоящие и нарушают обещание платить лишь за используемое.

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

Чтобы понять влияние обработки исключений на производительность в целом, настоятельно рекомендуется прочитать главу 5.4 Технического отчета по производительности C++. В нем довольно подробно разобраны источники потерь производительности: код, добавленный в блоки try-catch компилятором, влияние на регулярные функции и затраты на фактическую генерацию исключения. Он также сравнивает два самых распространенных подхода к реализации: подход "код" –  когда код добавляется в каждый блок try-catch; и подход "таблица" – когда компилятор генерирует статические таблицы.

После ознакомления с вариантами влияния исключений на производительность в целом изучите тему для вашей конкретной платформы и компилятора. Очень хорошую и относительно новую презентацию, созданную Кевином Фреем из Microsoft, можно найти в Северо-западной группе пользователей C++. Она охватывает компилятор Visual C++ для 32- и 64- битной платформы Windows. Другой очень интересный текст, касающийся компилятора GNU C++ на Itanium, - Itanium C++ ABI(двоичный интерфейс приложений): обработка исключений.

Текущие реализации C++ не дают гарантий предсказуемости для исключений, что делает их непригодными для систем жёсткого реального времени.

3.5 Исключения тяжело ввести в устаревший код.

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

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

3.6 Исключения неверно используются для выполнения задач, относящихся к нормальному алгоритму программы.

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

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

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

Как насчет функциональности, зависящей от ввода данных пользователем? Если пользователь вводит неверные данные, лучше генерировать исключение или нет? Введенные пользователем данные лучше считать неверными, если не доказано обратное, и желательно иметь функцию, проверяющую правильность введенных данных перед их обработкой. Для функции проверки правильности неверные данные являются ожидаемым результатом, поэтому не нужно генерировать исключение. Проблема в том, что часто невозможно проверить правильность введенных данных до их фактической обработки (например, парсеры); в таких случаях использование исключений для прерывания обработки и сообщения об ошибке часто является хорошим подходом. HtmlParser из примера 5 генерирует исключение, если не может разобрать неверный HTML.

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

4. Вывод

Нет простого ответа на вопрос "исключения или коды ошибок". Решение должно приниматься исходя из конкретной ситуации, с которой сталкивается группа разработчиков. Примерные рекомендации следующие:
•    Если у вас есть хороший процесс разработки и стандарты написания кода, которые действительно соблюдаются, если вы пишете код C++ в современном стиле, основанном на освобождении ресурсов с помощью RAII, если ваша база кода модульная –  желательно использовать исключения.
•    Если вы работаете с кодом, написанным без учета защиты от исключений, если в вашей группе разработчиков отсутствует порядок, или если вы разрабатываете системы жёсткого реального времени –  не стоит использовать исключения.