воскресенье, 26 апреля 2015 г.

[prog.c++11] Ну такой сложный C++. Не в 1995-ом. И даже не в 2005-ом. А в 2015-ом. На примерах.

Язык C++ никогда не был простым языком. Это объективно и проистекает как из специализации языка, так и из истории его создания и развития. Однако, в последние годы лично я твердо уверен в том, что поддерживаемая в интернетах репутация C++ как очень сложного в изучении и использовании языка, базируется, в первую очередь, на сильно устаревшей информации (времен отсутствия стандарта языка, а так же времен отсутствия компиляторов с более-менее нормальной поддержкой C++98/03). А во вторую очередь на фольклоре, распространяемом теми, кто ни самого языка, ни систем на нем, не видел вообще.

C++11/14 стал еще более сложным, чем C++03. Но фокус в том, что писать на нем стало намного проще. Т.е. усложнение языка сильно упростило разработку на C++. Поэтому не нужно к C++11/14 применять ярлыки, заслуженные много лет назад C++98 (а то и более ранними версиями). Под катом пара примеров реального кода для демонстрации данного тезиса.

Первый пример -- преобразование содержимого двух контейнеров (std::map и std::unordered_map) в контейнер типа вектор (std::vector). Это преобразование выполняется с целью экспорта информации из некоторого хранилища наружу:

subscription_storage_common::subscr_info_vector_t
storage_t::query_content() const
   {
      using namespace std;
      using namespace subscription_storage_common;

      subscr_info_vector_t events;

      if( !m_hash_table.empty() )
         {
            events.reserve( m_hash_table.size() );

            transform( begin(m_hash_table), end(m_hash_table),
                  back_inserter(events),
                  [this]( const hash_table_t::value_type & i )
                  {
                     auto map_item = m_map.find( *(i.first) );

                     return subscr_info_t {
                           map_item->second,
                           map_item->first.m_msg_type,
                           *(map_item->first.m_state),
                           i.second.m_method,
                           i.second.m_thread_safety
                        };
                  } );
         }

      return events;
   }

Этот код не сложен. Он безопасен (в том числе и по типам, соответствие которых проверяется компилятором). Он эффективен (в векторе реализуется move semantic, поэтому возврат вектора из метода не приводит к поэлементному копированию его содержимого). В нем нет ручного управления памятью, а времена жизни всех объектов очевидны и детерминированы. Более того, это, фактически, написанный в ФП-стиле код, ведь transform -- это чистой воды операция map, а все действия выполняются внутри const-метода, что не дает просто так модифицировать состояние объекта, для которого вызывается query_content. И, что еще немаловажно, этот метод exception safe: при возникновении исключения ничего не испортится, а все, что уже было создано до исключения, будет автоматически подчищено.

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

А вот второй пример, который как раз непосредственно связан с обеспечением exception safety:

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() );
      } );
}

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

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

  • при добавлении нового элемента в контейнер m_registered_coop. Если здесь возникнет исключение, никаких отмен внутри метода next_coop_reg_step__update_registered_coop_map выполнять не нужно, т.к. еще ничего не модифицировано. Поэтому возникающие здесь исключения выпускаются наружу и пусть с ними разбираются где-то наверху;
  • при выполнении действий на следующей фазе, т.е. внутри метода next_coop_reg_step__parent_child_relation. Если оттуда вылетит исключение, то нужно будет отменить действия, которые были выполнены ранее на этом шаге (счетчик m_total_agent_count должен быть уменьшен, новый элемент из m_registered_coop должен быть удален).

В старом-добром C++98/03 для обеспечения exception safety потребовалось бы либо написать парочку try-catch, либо же определить вспомогательный класс, в деструкторе которого проводилась бы отмена выполненых на этом шаге операций. Вариант со вспомогательным классом, на мой взгляд, был бы более предпочтительным, т.к. в этом случае автоматически обеспечивается очень простая реакция на исключения, выбрасываемые при раскрутке стека из-за предыдущего исключения.

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

В С++11 ситуация ну очень простая -- вспомогательная шаблонная функция, получающая две лямбды. Первая выполняет нужные действия, вторая дергается при выбрасывании исключения из первой. Что упрощает написание exception safe кода на порядок. И все это с использованием только штатных средств, с соответствующей проверкой типов и т.д. Да и за эффективность переживать особо не стоит, т.к. все это на шаблонах, то в подавлающем большинстве случаев код этих лямбд будет просто-напросто встроен прямо по месту их использования.

Желающие могут попробовать представить, как такой же код мог бы выглядеть в Java или C# (где есть исключения, но нет детерминированного вызова деструкторов). Или в Go или Rust-е, где нет исключений, а нужно проверять коды ошибок. Что-то мне не кажется, что аналогичный код был бы компактнее, понятнее и проще в сопровождении.

В реализации do_with_rollback_on_exception нет совсем никакой магии, только штатные возможности языка C++11. Там единственный фокус -- специализированная версия шаблона executor для случая, когда первая лямбда возвращает void. Все остальное тривиально. Для того, чтобы написать такой код или разобраться в нем не нужно быть экспертом по шаблонному метапрограммированию, как это требовалось еще лет пять назад. Достаточно просто иметь представление о C++ных шаблонах.

А в 2015-ом году о C++ных шаблонах обязательно нужно иметь представление. Иначе смысла работать с C++ нет, вам вполне достаточно будет обычного plain old C, со всей его прямолинейностью и убогостью простотой :)

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