воскресенье, 14 июня 2015 г.

[prog.c++] Продолжение про использование исключений в C++: где бросать, где не бросать

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

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

Итак, прежде чем обсуждать маркеры, еще раз вернемся к тому, почему, на мой взгляд, исключения в C++ предпочтительнее кодов возврата. И почему не корректно применять к C++ соображения, которые актуальны для других языков программирования, особенно модных и молодежных, с алгебраическими типами, сборкой мусора и тотальным контролем за каждым чихом пользователя в run-time.

Собственно говоря, вместо чтения этого поста лучше было бы прочитать соответствующий раздел "Языка программирования C++", в котором Бъёрн Страуструп очень подробно рассматривает несколько политик информирования об ошибках (глобальные флаги, коды возврата и исключения). Глубже этого вряд ли что-то можно найти. Есть еще в Интернетах хорошие статьи про уровни гарантий безопасности исключений. Гуглятся по ключевым словам C++ exception safety guarantees. Вот, например, статья от того же Страуструпа "Exception Safety: Concepts and Techniques" или "Lessons Learned from Specifying Exception-Safety for the C++ Standard Library" от известного boost-овода Девида Абрахамса.

Но если кому-то по непонятным для меня причинам интересно именно мое провинциальное мнение, то оно состоит в том, что C++ не просто не препятствует отстрелу ног разработчика, а еще и предоставляет все необходимые средства для этого. Да еще в таком количестве, что мало не кажется.

Как говорится в одной из интерпретаций известного анекдота про выстрел в ногу: если C -- это Кольт без предохранителя, из которого легко выстрелить себе в ногу даже не вынимая его из кобуры, то C++ -- это двуствольный дробовик, случайный выстрел из которого отрывает обе ноги нахрен! :)

Поэтому, если у вас есть возможность не программировать на C++ и беречь свое время/нервы, то не пишите на С++ и держитесь от описываемых ниже проблем подальше. Однако, если вам нужно программировать на C++...

Не важно по каким причинам: фанатеете от C++, за C++ больше платят, нет другой работы, вы больше ничего не знаете или просто жизнь заставила. Если вам нужно программировать на C++, то нужно делать это хорошо. А раз так, то засуньте куда подальше все то, что может вам помещать писать хороший C++ код -- нелюбовь к языку, опыт разработки на других языках (особенно на модных и молодежных), нежелание учиться и т.д. Смиритесь с тем, что здесь так. Вот здесь вот так. В других местах как-то по-другому, а здесь вот так.

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

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

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

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

Поэтому, если вы хотите писать нормальный, корректный, надежный, безопасный и сопровождабельный код на C++, то в первую очередь рассматривайте исключения как способ информирования об ошибках. В первую очередь. И только если есть серьезные препятствия к этому, то лишь тогда обращайте свое внимания на альтернативные (менее надежные и безопасные) техники.

Всегда ли у вас есть возможность использовать исключения?

Очень глупый вопрос, на самом-то деле. Конечно же, не всегда.

Может вам приходится заниматься разработкой safety-critical кода в соответствии с какими-то стандартизированными в вашей предметной области требованиями (например, MISRA). И исключения запрещены в соответствии с отраслевыми стандартами.

Может вы пишете код под специфические условия: скажем, для ядра ОС или встраиваемое ПО для какой-то экзотической железяки. И у вас попросту нет полноценного C++ного рантайма и нормальной стандартной библиотеки.

Может вам нужно сопровождать огромный объем legacy-кода на древних компиляторах, в которых-то и нормальной поддержки исключений-то нет и не будет.

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

Наконец, вы можете разрабатывать ПО в Google или вне Google, но взяв за основу Google-овский код-стайл.

Противники исключений в C++ очень часто используют этот аргумент: мол в Google исключения не используются. Аргумент на миллион долларов в буквальном смысле, если даже не на большую сумму. У всех ли контор, которые занимаются разработкой софта на C++, бюджеты на разработку хоть как-то соотносятся с таковыми у Google? Все ли конторы способны настолько же тщательно и качественно отбирать к себе лучших из лучших программистов? Все ли способны вести разработку собственных in-house продуктов, да еще и с применением таких вещей, как обязательный code review опытным разработчиком перед коммитом? Грубо говоря: есть ли у вас возможность тратить на разработку ПО столько же средств и времени, сколько позволяет себе Google? Если да, то можете рискнуть. Если нет, то может включить собственную голову и подумать, а есть ли смысл в Google-овских правилах в ваших условиях?

Короче говоря, сначала вам нужно понять, есть ли в принципе возможность использовать исключения в вашем проекте.

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

Меня особенно умиляют высказывания в духе: "я использую коды возврата вместо исключений для того, чтобы лучше понимать по какой ветке пойдет исполнение кода в моей программе". При этом пишут что-то вроде:
operation_result r = process_request( request, "normal_processing", &progress_indicator );
if( operation_result::ok == r )
   ...
else if( operation_result::temporary_busy == r )
   ...
else ...
А когда смотришь на прототип process_request и видишь там что-то вроде:
operation_result
process_request(
   protocol::request req,
   const std::string & processng_description,
   std::function< void(unsigned int) > progress_indicator );
понимаешь, что есть, как минимум, три потенциальных места для выброса исключения (того же std::bad_alloc):
  • при копировании значения аргумента req (аргумент ведь передается по значению, значит потребуется копия, а что там будет происходить при копировании...);
  • при конструировании аргумента processing_description из строкового литерала (если в std::string нет small string optimization или переданная строка превышает некий размер, то потребуется динамическая память);
  • при конструировании аргумента progress_indicator, т.к. std::function может использовать динамическую память для сохранения значения.
И не остается ничего другого, как признать, что человек обманывает сам себя и выдает желаемое за действительное.
Имхо, этим же страдают и функциональщики, которые, например, используют Scala поверх JVM. Они говорят, что мол исключения им не нужны, т.к. у них есть АлгТД и они могут возвращать Option[T] или Either[A,B]... Как будто возвращаемое значение создается не в хипе и JVM гарантирует, что памяти для этого возвращаемого значения хватит всегда... Фиг знает, давно для JVM ничего не писал. Может гарантирует. Может и хватает.

Данный пример очень характерен: если вы не работаете в условиях, когда исключения запрещены ключами компилятора, а STL-я нет, то исключения в вашем коде могут быть. Даже если у вас пока еще не хватает опыта, знаний, фантазии или зравого смысла, чтобы это осознать. Легко продекларировать на словах, что вы пишете код без использования исключений. Но на практике в современных условиях для гарантирования отсутствия исключений нужно приложить довольно-таки много усилий. Откуда напрашивается вопрос: а оно стоит того?

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

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

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

Например, возьмем C-шную функцию fopen. В случае неудачи она возвращает NULL. Что произойдет, если возвращенное значение не будет проверено и программист попытается использовать его в fread или fwrite?

В лучшем случае немедленный крах приложения. Но даже этот лучший случай будет неприемлем, если речь идет о встроенном ПО, которое должно просто работать и работать.

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

Третий маркер очень тесно связан со вторым: есть ли вообще возможность использовать коды возврата?

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

А когда вы переопределяете операторы, далеко не всегда у вас есть возможность возвратить какое-то специальное значение. Как и проверять errno после каждого вызова переопределенного оператора. Например, если у вас записано a[i][j]=b[i][j]*c[j][i], то это может означать целую цепочку переопределенных операторов, как operator[], так и operator*. И куда в эту цепочку вставлять проверку кодов возврата или errno?

Зато с исключениями никаких проблем.

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

Отсюда возникает четвертный маркер: насколько часто ожидаются исключения?

Например, метод std::map::find() может возвращать отрицательный результат поиска в map-е всегда. Ну нет искомого ключа в контейнере и ничего с этим не поделать.

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

Посему возврат специального значения из map::find() в случае неудачи -- это нормально, а вот код ошибки в операции seek -- нет.

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

Иногда в таких случаях могут делать два варианта API (или какой-то функции/метода в API): один бросающий исключения (вроде write_data), второй -- не бросающий (вроде try_write_data). Такой способ вполне имеет право на жизнь. Но не нужно забывать о том, что за это придется расплачиваться:
  • вам, как разработчику реализации API предстоит сделать больше работы. Вряд ли кого-то устроит try_write_data, который внутри дергает write_data, но оборачивает этот вызов в try-catch. Т.к. от исключений и связанных с ними накладными расходами вы не избавились, просто упрятали их под капот. Ну а если же внутри write_data вы используете try_write_data и порождаете исключение при ошибке, то есть вероятность, что везде внутри вы будете использовать коды возврата со всеми вытекающими отсюда сомнениями в надежности;
  • вы можете вызвать ложную уверенность у пользователя вашего API о том, что try_write_data не бросает исключений. Хотя, как в одном из примеров выше, try_write_data может получать на вход константную ссылку на std::string или std::vector, который может конструироваться прямо по месту вызова try_write_data и это конструирование способно бросить исключение (тот же std::bad_alloc). Более того, у себя в коде вы сами можете нарваться на такие же грабли и вместо кода ошибки из try_write_data вылетит какое-нибудь исключение, о котором вы не подумали или забыли, или вообще не знали.
Так что при всей внешней привлекательности этот способ не так прост и удобен, как может показаться на первый взгляд. Особенно, если API "развесистое" и требуется не один десяток функций/методов try_*.

Помимо частоты возникновения неудач при каких-то операциях есть еще один маркер: это "тяжесть" операции. Грубо говоря, вызов метода size() у std::vector -- это одно. А выполнения запроса "select count(*) from..." к РСУБД -- это совсем другое. И если вызов, который может завершиться неудачей, очень тяжел и дорог по сравнению с выбросом исключения, то проще выбрасывать исключения и не заниматься экономией на спичках.

Есть еще одна ниша, в которой пересекаются сразу несколько маркеров -- это DSL-естроение. Хоть C++ не является очень уж удобным языком для создания внутренних DSL-ей, тем не менее, в каких-то случаях это удобно. Яркие примеры -- это описание грамматик в Boost.Spirit (сам не сторонник такого подхода, но нельзя не отметить, что есть множество программистов, вполне довольных возможностями Spirit-а), expression-templates для оптимизации вычислений, сериализация/десериализация данных, взаимодействие с СУБД и т.д.

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

Отсюда получается еще один маркер: если вы строите DSL-и на C++, вроде вот таких

auto pipeline = make_pipeline( *this,
      src | stage(validation) | stage(conversion) | broadcast(
         src | stage(archivation),
         src | stage(distribution),
         src | stage(range_checking) | stage(alarm_detector{}) | broadcast(
            src | stage(alarm_initiator),
            src | stage( []( const alarm_detected & v ) { alarm_distribution( cerr, v ); } )
            )
         ),
      autoname );

или вот таких:

text = lexeme[+(char_ - '<')        [_val += _1]];
node = (xml | text)                 [_val = _1];

start_tag =
       '<'
   >>  !lit('/')
   >>  lexeme[+(char_ - '>')       [_val += _1]]
   >>  '>'
;

end_tag =
       "</"
   >>  string(_r1)
   >>  '>'
;

xml =
       start_tag                   [at_c<0>(_val) = _1]
   >>  *node                       [push_back(at_c<1>(_val), _1)]
   >>  end_tag(at_c<0>(_val))
;

то просто глупо пытаться делать это без исключений.


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

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

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

Комментариев нет: