суббота, 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 нажимать. А то черновики в один клик публикуются :)

7 комментариев:

1 комментирует...

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

Дай авторитетную ссылочку на описание "fail fast, restart quickly", плиз. Не слышал раньше, но ознакомился бы... для общего развития, так сказать.

eao197 комментирует...

Не, транзакции хороши только когда работа идет с БД. В программе же организация транзакций -- тот еще геморрой (если не брать новомодные и экспериментальные тенденции вроде Software Transactional Memory). Или же я тебя не так понял.

Что до принципа "fail fast, restart quickly", то я о нем узнал из документации по HP NonStop-ам. Там на нем все построено. Где эта документация в on-line -- фиг знает, да и там описание принципов создания отказоустойчивого софта было размазано по нескольким документам. Так что, наверное, лучше мне отдельный пост на эту тему написать.

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

1 комментирует...

Спасибо за пояснения.

Насчёт примера, я сходу как-то подумал, что работа со справочниками должна вестись через БД, а БД автоматически должна поддерживать транзакции.

Тем не менее, транзакции в ПО - тоже уже широко известная вещь, но не знаю, как с этим в C++. Наверное, действительно, геморрой ещё тот.

eao197 комментирует...

Да что в C++, что в Java, разница не большая. В Java радует то, что GC есть и потерять какую-то ссылку не страшно, зато нет RAII (конструкторов-деструкторов) и поэтому организация транзакций там делается через обилие try/catch/finally.

Интересная идея была в D с конструкциями scope(failure) (http://www.digitalmars.com/d/1.0/exception-safe.html), но это не production quality язык, так что в расчет его можно не брать.

В следующей версии C++ будет проще с этим делом -- там лямбда-функции появятся. С их помощью транзакции будет проще выполнять.

jazzer комментирует...

В С++ уже сейчас можно через Boost.ScopeExit (аналог сабжа из D)

Насчет транзакционности - так любой контейнер можно рассматривать как БД :)
Это раз.
А во-вторых, во многих случаях (в подавляющем большинстве случаев) транзакционность изменения объектов поддерживать легко, если сразу писать с прицелом на это. Гораздо легче гарантировать тарнзакционность изменения какого-нть несчастного int, чем рестартовать процесс целиком.
Это два.
А три - если же "почти официальные" уровни безопасности кода по отношению к исключениям - слабая (нет утечек/крешей, но состояние может быть кривым), сильная (транзакционность), и отсутствие исключений. Об этом много где написано, у того же Саттера, если не ошибаюсь.

eao197 комментирует...

2jazzer:

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

Во-вторых, как я сказал, можно программировать с расчетом на basic/strong-гарантии. Но тогда трудоемкость существенно возрастает. И это оправдано, на мой взгляд, для универсальных библиотек. А слабая гарантия -- это как раз программирование с расчетом на рестарты. Поскольку вряд ли есть смысл продолжать работу программы с кривым состоянием.

Ну и в третьих, Boost.ScopeExit -- это не языковой механизм, довольно уродливый. Но предлагаю (анти)Boost-овскую карту здесь не разыгрывать.

jazzer комментирует...

Не, ну на bishop-it, безусловно, полный бред написан, и обсуждать его смысла нет, имхо.

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

Насчет того, что Boost.ScopeExit уродливый - ну так, батенька, язык такой :)
Конечно, здорово было бы иметь языковое средство, кто ж спорит, но его еще сколько ждать... а Boost.ScopeExit можно использовать уже сейчас, на старых компиляторах, в реальных проектах. И не сказать, что он делает код нечитабельным. Ну макрос, ну скобки лишние - это не так уж критично.
И буст тут ни при чем, сам понимаешь. У тебя же нет религиозной непереносимости слова Boost? :)