среда, 10 июня 2015 г.

[prog.flame] Пять копеек на тему error codes VS exceptions (С++ only!!!)

Специальный дисклаймер для читателей, которые пришли сюда по ссылке из DevZen Podcast №45. Все нижеследующее относится только к C++. Пожалуйста, не распространяйте написанные ниже высказывания на другие языки программирования.

К своему удивлению обнаружил в последнее время, что спор error codes или exception по накалу идиотии вполне может стоять в одном ряду с такими эпическими темами, как "tabs vs spaces", "canon vs nikon", "box vs karate" и т.д.

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

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

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

templatetypename IN, typename BROADCAST_IN >
stage_t< IN, void >
operator|(
   stage_t< IN, BROADCAST_IN > && prev,
   broadcast_sinks_t< BROADCAST_IN > && broadcasts )
{
   return stage_t< IN, void >{
      stage_builder_t{
         [prev, broadcasts]( agent_coop_t & coop, mbox_t ) -> mbox_t
         {
            vector< mbox_t > mboxes;
            mboxes.reserve( broadcasts.m_builders.size() );

            forconst auto & b : broadcasts.m_builders )
               mboxes.emplace_back( b( coop, mbox_t{} ) );

            auto broadcaster = coop.make_agent< a_broadcaster_t< BROADCAST_IN > >(
                  move(mboxes) );
            return prev.m_builder( coop, broadcaster->so_direct_mbox() );
         }
      }
   };
}

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

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

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

Если же используются только исключения, то работать не сильно сложнее, чем с обычными кодами ошибок. Аргумент о том, что мол нужно думать, откуда могут вылететь исключения -- это полная ерунда. Исключения могут вылететь откуда угодно. Все. Как только понимание этого входит в подкорку, жизнь становится проще. Тем более после пришествия C++11.

В C++11 появились две вещи, которые делают написание безопасного с точки зрений исключений кода заметно проще, чем это было в C++98/03. Первая вещь -- это лямбда-функции. Вторая -- это спецификатор noexcept. Сейчас покажу, как эти вещи упрощают разработку.

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

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

Представим, что для выполнения какой-то операции над объектом X нужно завершить три модифицирующих действия: A, B и C. Допустим, что действия A и B выполнены успешно, а действие C вернуло код ошибки. Можете ли вы просто пробросить код ошибки наверх, не отменив действий A и B?

Вот то-то и оно, что не можете. Если инварианты объекта X для вас не пустой звук, то откатить действия A и B вам все равно придется. Т.е. забота об инвариантах и откатах будет такая же, как и в случае с исключениями. Разница будет состоять лишь в том, в каком синтаксисе вы будете записывать эти операции.

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

error_code X::complex_action()
{
   error_code ec = do_A();
   if( OK == ec )
   {
      if( OK == (ec = do_B()) )
      {
         if( OK == (ec = do_C()) )
            return OK;

         undo_B();
      }

      undo_A();
   }

   return ec;
}

Против "идиоматического" кода с исключениями:

void X::complex_action()
{
   do_A();
   try
   {
      do_B();
      try
      {
         do_C();
      }
      catch( ... )
      {
         undo_B();
         throw;
      }
   }
   catch( ... )
   {
      undo_A();
      throw();
   }
}

Такой код для работы с исключениями не был "идиоматическим" даже во времена C++98/03, т.к. даже тогда посредством RAII написать откаты операций A и B можно было компактнее. А уж с пришествием C++11 все стало еще проще. Достаточно просто чуть-чуть подумать и сделать для себя несложный helper. После чего код будет выглядеть, например, вот таким образом:

void X::complex_action()
{
   do_A();
   do_with_rollback_on_exception( [=] {
         do_B();
         do_with_rollback_on_exception(
               [=] { do_C(); },
               [=] { undo_B(); } );
      },
      [=] { undo_A(); } );
}

Если вам кажется, что использование такой вспомогательной функции выглядит громоздко, то вот пример из реального кода:

void
agent_core_t::next_coop_reg_step__update_registered_coop_map(
   const agent_coop_ref_t & coop_ref,
   agent_coop_t * parent_coop_ptr )
{
   m_registered_coop[ coop_ref->query_coop_name() ] = coop_ref;
   m_total_agent_count += coop_ref->query_agent_count();

   // In case of error cooperation info should be removed
   // from m_registered_coop.
   so_5::details::do_with_rollback_on_exception(
      [&] {
         next_coop_reg_step__parent_child_relation(
               coop_ref,
               parent_coop_ptr );
      },
      [&] {
         m_total_agent_count -= coop_ref->query_agent_count();
         m_registered_coop.erase( coop_ref->query_coop_name() );
      } );
}

Выглядит почти как if-then-else с проверкой кода возврата. Но при этом ошибку просто так не проигнорируешь.

Лямбды в C++11 упростили работу с исключениями. Однако, в C++11 сделали еще одну очень важную штуку -- модификатор noexcept. Он очень важен для случаев, когда нам нужно выполнить операцию или серию операций вообще без исключений.

Это легко сказать, но как это сделать, если исключения могут вылететь откуда угодно?

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

К моему большому сожалению, в C++ пока не сделали возможность пометить через noexcept блок кода внутри функции/метода. Модификатор noexcept можно повесить только на функцию/метод. Но и здесь C++ные лямбды приходят на помощь:

void
event_queue_proxy_t::switch_to_actual_queue(
   event_queue_t & actual_queue,
   agent_t * agent,
   demand_handler_pfn_t start_demand_handler )
   {
      std::lock_guard< std::mutex > lock{ m_lock };

      // All exceptions below would lead to unpredictable
      // application state. Because of that an exception would
      // lead to std::abort().
      so_5::details::invoke_noexcept_code( [&] {
         m_actual_queue = &actual_queue;

         // The first demand for the agent must be evt_start demand.
         m_actual_queue->push(
               execution_demand_t(
                     agent,
                     message_limit::control_block_t::none(),
                     0,
                     typeid(void),
                     message_ref_t(),
                     start_demand_handler ) );

         if( m_tmp_queue )
            {
               move_tmp_queue_to_actual_queue();
               m_tmp_queue.reset();
            }

         m_status = status_t::started;
      } );
   }

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


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

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

Без таких объективных показаний беспристрастного профайлера споры о скорости кода есть ни что иное, как пустая форумная болтовня.


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

Тут да. Предсказывать поведение кода в присутствии и отсутствии исключений -- это две большие разницы. Но...

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

Продолжение последовало.

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