суббота, 8 августа 2009 г.

[comp.prog.cpp] Несколько слов об исключениях

Будучи еще не очень матерым блогером, я с интересом читаю чужие блоги, на которые попадаю совершенно случайно и в которых сразу же вижу интересные материалы. Пока я наблюдал две тенденции – либо мне нравится то, что пишет человек в блоге, либо не нравится. Но иногда происходят и странные вещи – что-то из написанного в блоге кажется мне здравым и разумным, а что-то – совершенно наоборот. Поводом к написанию данной заметки оказался недавний пост Exceptions and Goto в блоге bishop-it. Мне казалось, что об исключениях, кодах возврата и goto в C++ было сказано уже столько разумных и правильных слов, что подобных сравнений exceptions и goto не должно возникать в принципе. Однако ж.

Так вот об исключениях. Я совсем не гуру в C++ и не сенсей исключений. Но почему бы и мне не сказать о них пару слов? Путь банальных и повторяющих идеи гораздо более умных C++ных монстров (включая Страуструпа, Саттера, Александреску и пр.). Может я сам через год-другой перечитаю их и в очередной раз удивлюсь тому, каким я был наивным и неопытным.

Итак, в языках C++ного семейства (C++, Java, C#) есть всего три основных способа информирования об ошибках:

  1. Возврат признака успешности/неуспешности операции и, в случае неудачи, помещение кода ошибки в какую-то глобальную переменную. В C – это errno, а функции в случае ошибки возвращают отрицательные значения или NULL. В ACE, например, принято возвращать –1 в случае ошибки и выставлять errno. В WinAPI многие функции возвращают FALSE или NULL, а код ошибки доступен через GetLastError().
  2. Возврат кода ошибки функцией. Т.е. все функции конкретной библиотеки возвращают, скажем, int или какой-то специальный тип. По возвращенному значению можно определить, успешно выполнена функция или неудачно. А если неудачно, то в чем причина. Без каких-либо глобальных переменных, просто по возвращенному значению. Такой подход, к примеру, использован в нашем SObjectizer.
  3. В случае ошибки порождаются исключения.

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

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

Но все равно и у первого, и у второго способа есть три фатальных недостатка:

  • эти подходы не могут использоваться с перегруженными операторами;
  • программа может легко проигнорировать возникшую ошибку. Достаточно просто забыть проверить код возврата. Или написать проверку неправильно;
  • программирование с использованием подобных подходов приводит к написанию функций в виде широкой “лесенки” из if-ов. Что пагубно сказывается на читабельности и сопровождабельности программы.

Всех этих недостатков лишены исключения. Поэтому лично я предпочитаю их остальным способам информирования об ошибках.

Нужно сказать, что мой путь к восприятию исключений был долгим и извилистым. Сначала у меня не было возможности использовать исключения, т.к. их поддержка появилась где-то в Borland C++ 4.*. Потом возможность появилась и я начал ей пользоваться. Наверное, с изрядным over use. Поскольку где-то в районе 2000-2001 годов я старался их не использовать (следствием чего как раз и стали коды ошибок вместо исключений в SObjectizer). Может в этом свою роль сыграл и небольшой опыт программирования на Java, в которых исключения довели до маразма. Но в конце-концов я опять вернулся к тому, что исключения это отличная штука и им нужно отдавать предпочтение.

Почему я в итоге пришел к такому выводу? Потому, что прерывание программы, которая столкнулась с непредвиденной ситуацией, гораздо лучше продолжения ее работы в неправильном (неопределенном) состоянии. Я пробовал отказаться от исключений чтобы писать “надежный” софт. Но оказалось, что исключения способствуют надежности гораздо больше, чем коды ошибок и “простой” код из множества if-ов лесенкой. Не в последнюю роль в этом деле сыграло знакомство с принципом “fail fast, restart quickly”. Но это уже совсем другая история.

Но если исключения так хороши, то почему на них так упорно катят бочки? Потому, что программирование с исключениями может требовать от программиста больше внимания и усилий (и иногда требует, что характерно). Исключения могут возникать, по сути, в любом месте и оставлять программу в несогласованном состоянии. Скажем, мы обрабатываем подключение нового клиента к серверу и нужно сохранить описание этого клиента в трех словарях. Успешно проделали вставку описания в первые два, а на третьей вставке возникло исключение. Вот и все, появилась несогласованность. Что в этой ситуации делать? Возможных ответа здесь два.

Первый ответ сложный. Он касается гарантий кода по отношению к исключениям (т.н. basic и strong гарантии). По хорошему, нужно изъять описание клиента из двух первых словарей. Т.е. фрагмент кода по обработке подключения нового клиента должен быть написан так, чтобы контролировать, какие словари были модифицированы. А затем, при возникновении исключения, отменить эти модификации. Звучит уже сложно, не правда ли? В реальности ситуация еще хуже. Нужно обладать изрядной долей параноидальности и педантичности, а так же большим опытом, чтобы анализировать свой код на предмет наличия потенциально опасных в случае возникновения исключения мест. Поэтому написание кода, обеспечивающего strong-гарантии, действительно трудоемко и черевато ошибками.

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

Хитрость заключается в том, что ни первый, ни второй ответы не являются однозначно верными. Каждый из них имеет право на жизнь в определенных ситуациях и не имеет такого права в иных ситуациях. Например, при написании универсальных и общеупотребительных библиотек предпочтение следует отдавать первому способу. Ведь будет неприятно узнать, например, что std::vector.push_back оставляет весь вектор в несогласованном состоянии при возникновении bad_alloc. С другой стороны, при написании сложного прикладного кода обеспечение даже basic-гарантий может привести к столь сложному коду, что ни завершить его в срок, ни поддерживать в дальнейшем, не будет никакой возможности.

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

  • использование иерархий классов-исключений, вершиной которой является std::exception (или std::runtime_error/logic_error). Используемая вами библиотека может иметь сотни/тысячи классов конкретных исключений, но это не будет вас волновать, если в вершине будет какой-то один класс, да еще производный от std::exception;
  • отказ от перехвата всех исключений и, особенно, от их подавления. Перехватывать нужно только те исключения, которые мы ожидаем в конкретном месте по конкретным причинам. И для которых мы знаем, как исправить ситуацию. Если мы не ждем чего-то и не знаем, что с этим делать – то лучше исключение не ловить вообще. Ну либо перехватить, залогировать, и отправить дальше. Ну и совсем в крайнем случае можно перехватывать исключения, чтобы оборачивать их в собственный тип исключения (это прием не очень хорош для C++, зато отлично работает в языках со сборкой мусора);
  • разделение функций/методов по принципу command (выполняет действие) и query (делает запрос). Методы-command-ы способны генерировать исключения и пользователь к этому готов. Тогда как методы-query, как правило, не бросают исключений, а информируют об успешности своего завершения с помощью кодов возврата.

Много проблем может доставлять неуёмное и непродуманное использование исключений в коде. Когда любой метод генерирует исключения на любой чих. Или же когда код завален try/catch-ами по самое нехочу. Но тут уж нужен опыт и здравый смысл. К сожалению, если речь идет о программистах вообще, то здравый смысл оказывается редким явлением. Чему свидетельством обилие говнокода, в который мы все рано или поздно вляпываемся (наверное потому, что сами его периодически и производим). Говнокод лучше переписывать. Или менять место работы, чтобы попасть в лучшие условия. Но, в любом случае, исключения здесь не причем – кривые руки способны превратить в кошмар любую здравую идею.

Вот, вкратце, что я хотел сказать по поводу исключений.

Disclaimer. Я не в состоянии в блог-посте затронуть другие аспекты этой проблемы. Как то: условия, к которых исключения использовать нельзя (real-time и embedded), интеграция с кодом на других языках, интеграция со сторонним бинарным кодом (от простейших LoadLibrary/dlopen и GetProcAddress/dlsym до таких порождений злого гения, как COM).

PS. Однако, осторожно нужно на кнопки в Windows Live Writer нажимать. А то черновики в один клик публикуются :)

Отправить комментарий