среда, 21 октября 2015 г.

[prog.c++11] Опыт добавления трассировки в SO-5.5.9. Часть I. Шаблоны и наследование вместо дублирования

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

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

void
local_mbox_t::do_deliver_message( ... ) const
{
   read_lock_guard_t< default_rw_spinlock_t > lock( m_lock );

   auto it = m_subscribers.find( msg_type );
   if( it != m_subscribers.end() )
      forauto a : it->second )
      {
         if( a.must_be_delivered( *(message.get()) ) )
            try_to_deliver_to_agent< invocation_type_t::event >( ...,
                  [&] {
                     agent_t::call_push_event( ...);
                  } );
      }
}

Нужно было сделать так, чтобы в случае, когда трассировка выключена, код оставался таким же. А в случае, когда трассировка включена, в нем появилось несколько дополнительных строчек (выделены жирным):

void
local_mbox_t::do_deliver_message( ... ) const
{
   read_lock_guard_t< default_rw_spinlock_t > lock( m_lock );

   auto it = m_subscribers.find( msg_type );
   if( it != m_subscribers.end() )
      forauto a : it->second )
      {
         if( a.must_be_delivered( *(message.get()) ) )
            try_to_deliver_to_agent< invocation_type_t::event >(
                  ...,
                  [&] {
                     m_trace.push_to_queue(...);

                     agent_t::call_push_event( ... );
                  } );
         else
            m_trace.message_rejected(...);
      }
   else
      m_trace.no_subscribers(...);
}

Причем такая трансформация должна была происходить в run-time, а не в compile-time, т.е. результирующая бинарная DLL должна была содержать обе версии кода.

По какому пути стоило пойти?

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

Поэтому самый тупой вариант даже не рассматривался. Более привлекательно выглядел вариант с элементарным if-ом. Т.е. в местах, где требуется трассировка, ставится if:

void
local_mbox_t::do_deliver_message( ... ) const
{
   read_lock_guard_t< default_rw_spinlock_t > lock( m_lock );

   auto it = m_subscribers.find( msg_type );
   if( it != m_subscribers.end() )
      forauto a : it->second )
      {
         if( a.must_be_delivered( *(message.get()) ) )
            try_to_deliver_to_agent< invocation_type_t::event >(
                  ...,
                  [&] {
                     if( m_tracer )
                        m_tracer->push_to_queue(...);

                     agent_t::call_push_event( ... );
                  } );
         else
            if( m_tracer )
               m_tracer->message_rejected(...);
      }
   else
      if( m_tracer )
         m_tracer->no_subscribers(...);
}

Очень соблазнительный вариант и в одном из мест что-то подобное и было сделано.

Однако, при таком подходе ситуация с выключенной трассировкой оказывается небесплатной. Помимо if-ов, в mbox-ах придется хранить еще и указатель на объект-tracer, даже если этот указатель будет нулевым. Итого, в самом распространном сценарии с отключенной трассировкой придется платить как увеличившимся в размере mbox-ом, так и ненужными if-ами в наиболее часто используемых операциях. Плата, конечно, небольшая, но если ее можно не платить, то лучше не платить.

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

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

Поэтому воспользовался небольшой шаблонной магией.

Суть в том, чтобы определить две вспомогательные структуры с однаковым набором методов. Только одна структура пустая, а вторая -- нет. Что-то вроде:

struct tracing_disabled_base_t
   {
      void
      no_subscribers( /*params*/ ) const {}

      void
      push_to_queue( /*params*/ ) const {}

      void
      message_rejected( /*params*/ ) const {}
   };

class tracing_enabled_base_t
   {
      tracer_t & m_tracer;
   public :
      tracing_enabled_base_t( tracer_t & tracer ) : m_tracer{tracer} {}

      void
      no_subscribers( /*params*/ ) const {
         ... /* some code */
      }

      void
      push_to_queue( /*params*/ ) const {
         ... /* some code */
      }

      void
      message_rejected( /*params*/ ) const {
         ... /* some code */
      }
   };

После чего классы mbox-ов были преобразованы в шаблоны, параметром которого был либо тип tracing_enabled_base_t, либо tracing_disabled_base_t. Что-то вроде:

templatetypename TRACING_BASE >
class local_mbox_template_t
   :  public abstract_message_box_t
   ,  private local_mbox_details::data_t
   ,  private TRACING_BASE // <-- The most important part!
   {
      ...
   };

Ну а при создании соответствующего mbox-а просто проверялось, включена ли трассировка или нет, и создавался объект соответствующего типа. Приблизительно вот так:

mbox_t create_local_mbox()
{
   auto id = ++m_mbox_id_counter;
   if( !m_tracer )
      return mbox_t{
         new local_mbox_template_t< tracing_disabled_base_t >{ id } };
   else
      return mbox_t{
         new local_mbox_template_t< tracing_enabled_base_t >{ id, *m_tracer } };
}

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

void
local_mbox_t::do_deliver_message( ... ) const
{
   read_lock_guard_t< default_rw_spinlock_t > lock( m_lock );

   auto it = m_subscribers.find( msg_type );
   if( it != m_subscribers.end() )
      forauto a : it->second )
      {
         if( a.must_be_delivered( *(message.get()) ) )
            try_to_deliver_to_agent< invocation_type_t::event >(
                  ...,
                  [&] {
                     this->push_to_queue(...);

                     agent_t::call_push_event( ... );
                  } );
         else
            this->message_rejected(...);
      }
   else
      this->no_subscribers(...);
}

Фокус здесь в том, что методы push_to_queue(), message_rejected() и no_subscribers() класс local_mbox_template_t наледует из своего базового класса (который, напомню, задается параметром шаблона). Соответственно, когда local_mbox_template_t наследуется от tracing_disabled_base_t, то эти методы пусты и их вызовы выбрасываются компилятором. Если же local_mbox_template_t наследуется от tracing_enabled_base_t, то он получает от своего базового класса не только полную реализацию этих методов, но и атрибут m_tracer.

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

Причем, что важно, методы push_to_queue() со товарищи, не виртуальные. Их вызовы внутри local_mbox_template_t разруливаются в compile-time, что гораздо лучше, чем если бы я попытался воспользоваться чистым ООП с виртуальными методами.

Да! Тут же еще есть одна маленькая, но важная деталька ;)

Легко увидеть, что конструктор local_mbox_template_t<T> в зависимости от параметра шаблона имеет разное количество аргументов в конструкторе. Достигается это за счет того, что local_mbox_template_t имеет шаблонный конструктор, да не простой, а с переменным количеством параметров ;)

templatetypename TRACING_BASE >
class local_mbox_template_t
   :  public abstract_message_box_t
   ,  private local_mbox_details::data_t
   ,  private TRACING_BASE
   {
   public:
      templatetypename... TRACING_ARGS >
      local_mbox_template_t(
         //! ID of this mbox.
         mbox_id_t id,
         //! Optional parameters for TRACING_BASE's constructor.
         TRACING_ARGS &&... args )
         :  local_mbox_details::data_t{ id }
         ,  TRACING_BASE{ std::forward< TRACING_ARGS >(args)... }
         {}

А поскольку переменное количество параметров может быть и нулевым, то это и дает возможность конструктору local_mbox_template_t<tracing_disabled_base_t> иметь другой набор аргументов, чем конструктор local_mbox_template_t<tracing_enabled_base_t>

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

Завтра постараюсь написать вторую часть, где покажу, как возможности C++11 снизили объем дублирующего кода при формировании теста трейсов. Upd. См.продолжение.

PS. В данном посте показа принципиальная схема задействованной в коде SO-5.5.9 идеи. Реальная реализация несколько отличается и далеко не везде удалось получить вариант с полностью нулевыми накладными расходами при отключенном трейсинге. Поэтому актуальная реализация несколько отличается от показанной выше, но не принципиально.

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