Пример использования policy-based design в С++ вместо копипасты и создания ООП-шых иерархий

в 9:14, , рубрики: c++, c++11, ооп, Программирование

Язык C++ очень часто обвиняют в неоправданной сложности. Конечно же, язык C++ сложен. И с каждым новым стандартом становится все сложнее. Парадокс, однако, состоит в том, что постоянно усложняясь, C++ последовательно и поступательно упрощает жизнь разработчикам. В том числе и обычным программистам, которые пишут код попроще, чем разработчики Boost-а или Folly. Чтобы не быть голословным, попробую показать это на небольшом примере «из недавнего»: как в результате адаптации к различным условиям тривиальный класс превратился в легкий хардкор с использованием policy-based design.

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

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

class some_performer_t
   {
      ...
      void
      work_started()
         {
            std::lock_guard< activity_tracking::lock_t > lock{ m_stats_lock };

            m_is_in_working = true;
            m_work_started_at = activity_tracking::clock_type_t::now();
            m_work_activity.m_count += 1;
         }

      void
      work_finished()
         {
            std::lock_guard< activity_tracking::lock_t > lock{ m_stats_lock };

            m_is_in_working = false;
            activity_tracking::update_stats_from_current_time(
                  m_work_activity,
                  m_work_started_at );
         }

      activity_tracking::stats_t
      take_work_stats()
         {
            activity_tracking::stats_t result;
            bool is_in_working{ false };
            activity_tracking::clock_type_t::time_point work_started_at;

            {
               std::lock_guard< activity_tracking::lock_t > lock{ m_stats_lock };

               result = m_work_activity;
               if( true == (is_in_working = m_is_in_working) )
                  work_started_at = m_work_started_at;
            }

            if( is_in_working )
               activity_tracking::update_stats_from_current_time(
                     result,
                     work_started_at );

            return result;
         }
      ...
      activity_tracking::lock_t m_stats_lock;
      bool m_is_in_working;
      activity_tracking::clock_type_t::time_point m_work_started_at;
      activity_tracking::stats_t m_work_activity;
      ...
   };

В каких-то классах вместо work_started()/work_finished()/take_work_stats() будут методы wait_started()/wait_finished()/take_wait_stats(). А в каких-то и те, и другие. Но код внутри этих методов будет практически 1-в-1 совпадать.

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

class some_performer_t
   {
      ...
      void
      work_started()
         {
            m_work_stats.start();
         }

      void
      work_finished()
         {
            m_work_stats.stop();
         }

      activity_tracking::stats_t
      take_work_stats()
         {
            return m_work_stats.take_stats();
         }
      ...
      activity_tracking::stats_collector_t m_work_stats;
      ...
   };

Класс stats_collector_t поначалу выглядел совсем просто:

class stats_collector_t
   {
   public :
      void
      start() { /* как в первоначальном work_started */ }

      void
      stop() { /* как в первоначальном work_finished */ }

      stats_t
      take_stats() { /* как в первоначальном take_work_stats */ }

   private :
      lock_t m_lock;

      bool m_is_in_working{ false };
      clock_type_t::time_point m_work_started_at;
      stats_t m_work_activity{};
   }; 

Все вроде бы хорошо. Но обнаружилась первая засада: в ряде случаев у stats_collector_t не должно было быть собственного lock-а. Например, в каких-то классах-performer-ах есть несколько экземпляров stats_collector_t, каждый stats_collector_t считает статистику по разным видам работ, но работа с ними выполняется под одним и тем же lock-ом. Т.е. выяснилось, что в каких-то местах stats_collector_t должен иметь собственный lock, в других местах должен уметь использовать чужой lock.

Ну не проблема. Преобразуем stats_collector_t в шаблон, параметр которого и будет говорить, используется ли внутренний или внешний lock-объект:

template< LOCK_HOLDER >
class stats_collector_t
   {
   public :
      // Тут нам нужен уже конструктор, который будет передавать
      // какие-то значения в конструктор LOCK_HOLDER-а.
      // Что это будут за значения и сколько их будет знает только
      // LOCK_HOLDER, но не знает stats_collector_t.
      template< typename... ARGS >
      stats_collector_t( ARGS && ...args )
         :  m_lock_holder{ std::forward<ARGS>(args)... }
         {}

      void
      start()
         {
            std::lock_guard< LOCK_HOLDER > lock{ m_lock_holder };
            ... /* остальные действия как показано выше */
         }

      void
      stop()
         {
            std::lock_guard< LOCK_HOLDER > lock{ m_lock_holder };
            ... /* остальные действия как показано выше */
         }

      stats_t
      take_stats() {...}

   private :
      LOCK_HOLDER m_lock_holder;

      bool m_is_in_working{ false };
      clock_type_t::time_point m_work_started_at;
      stats_t m_work_activity{};
   }; 

Где в качестве LOCK_HOLDER-ов должны были использоваться вот такие классы:

class internal_lock_t
   {
      lock_t m_lock;
   public :
      internal_lock_t() {}

      void lock() { m_lock.lock(); }
      void unlock() { m_lock.unlock(); }
   };

class external_lock_t
   {
      lock_t & m_lock;
   public :
      external_lock_t( lock_t & lock ) : m_lock( lock ) {}

      void lock() { m_lock.lock(); }
      void unlock() { m_lock.unlock(); }
   };

Соответственно, в класса-performer-ов экземпляры stats_collector_t начали инициализироваться одним из двух возможных способов:

using namespace activity_tracking;
class one_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внешний lock-объект.
      lock_t m_common_lock;

      stats_collector_t< external_lock_t > m_work_stats{ m_common_lock };
      stats_collector_t< external_lock_t > m_wait_stats{ m_common_lock };
      ...
   };
class another_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внутренний lock-объект.
      stats_collector_t< internal_lock_t > m_work_stats{};
      ...
   };

Правда, здесь так же обнаружилась засада. Оказалось, что тип внешнего lock-объекта не всегда будет activity_tracking::lock_t. Иногда нужно использовать другой тип lock-объекта, который, тем не менее, пригоден для работы с std::lock_guard.

Поэтому вспомогательный класс external_lock_t так же стал шаблоном:

template< typename LOCK = lock_t >
class external_lock_t
   {
      LOCK & m_lock;
   public :
      external_lock_t( LOCK & lock ) : m_lock( lock ) {}

      void lock() { m_lock.lock(); }
      void unlock() { m_lock.unlock(); }
   }; 

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

using namespace activity_tracking;
class one_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внешний lock-объект.
      lock_t m_common_lock;

      stats_collector_t< external_lock_t<> > m_work_stats{ m_common_lock };
      stats_collector_t< external_lock_t<> > m_wait_stats{ m_common_lock };
      ...
   };
class tricky_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внешний lock-объект
      // какого-то другого типа.
      mpmc_queue_traits::lock_t m_common_lock;

      stats_collector_t<
            external_lock_t< mpmc_queue_traits::lock_t > >
         m_work_stats{ m_common_lock };

      stats_collector_t<
            external_lock_t< mpmc_queue_traits::lock_t > >
         m_wait_stats{ m_common_lock };
      ...
   }; 

Но, как оказалось, это были еще цветочки. Ягодки пошли когда выяснилось, что в некоторых случаях в методах start() и stop() нельзя захватывать lock-объект, т.к. эти методы вызываются в контексте, где внешний lock-объект уже захвачен.

Первая мысль была в том, чтобы сделать пары методов start_no_lock()/start() и stop_no_lock()/stop(). Но это так себе идея. В частности, такое деление может затруднить использование stats_collector-а в каком-нибудь шаблоне. В коде шаблона может быть непонятно, должен ли вызываться start_no_lock() или же просто start(). Да и вообще наличие start_no_lock() вместе со start() выглядит некрасиво и усложняет использование stats_collector-а.

Поэтому поведение шаблона stats_collector_t было изменено:

template< typename LOCK_HOLDER >
class stats_collector_t
   {
      using start_stop_lock_t = typename LOCK_HOLDER::start_stop_lock_t;
      using take_stats_lock_t = typename LOCK_HOLDER::take_stats_lock_t;

   public :
      ...
      void
      start()
         {
            start_stop_lock_t lock{ m_lock_holder };
            ...
         }

      void
      stop()
         {
            start_stop_lock_t lock{ m_lock_holder };
            ...
         }

      stats_t
      take_stats()
         {
            ...
            {
               take_stats_lock_t lock{ m_lock_holder };
               ...
            }
            ...
         }
      ...
   };

Теперь тип LOCK_HOLDER должен определить два имени типа: start_stop_lock_t (как блокировка выполняется в методах start() и stop()) и take_stats_lock_t (как блокировка выполняется в методе take_stats()). А уже класс stats_collector_t и их помощью делает или не делает блокировку lock-объекта у себя в коде.

Простой класс internal_lock_t определяет эти имена тривиальным образом:

class internal_lock_t
   {
      lock_t m_lock;
   public :
      using start_stop_lock_t = std::lock_guard< internal_lock_t >;
      using take_stats_lock_t = std::lock_guard< internal_lock_t >;

      internal_lock_t() {}

      void lock() { m_lock.lock(); }
      void unlock() { m_lock.unlock(); }
   }; 

А вот шаблон external_lock_t потребовалось расширить и добавить еще один параметр – политику блокировки:

template<
   typename LOCK_TYPE = lock_t,
   template<class> class LOCK_POLICY = default_lock_policy_t >
class external_lock_t
   {
      LOCK_TYPE & m_lock;
   public :
      using start_stop_lock_t =
            typename LOCK_POLICY< external_lock_t >::start_stop_lock_t;
      using take_stats_lock_t =
            typename LOCK_POLICY< external_lock_t >::take_stats_lock_t;

      external_lock_t( LOCK_TYPE & lock ) : m_lock( lock ) {}

      void lock() { m_lock.lock(); }
      void unlock() { m_lock.unlock(); }
   };

Ну и реализация классов для политик блокировки выглядит так:

template< typename L >
struct no_actual_lock_t
   {
      no_actual_lock_t( L & ) {} /* Принипиально ничего не делаем */
   };

template< typename LOCK_HOLDER >
struct default_lock_policy_t
   {
      using start_stop_lock_t = std::lock_guard< LOCK_HOLDER >;
      using take_stats_lock_t = std::lock_guard< LOCK_HOLDER >;
   };

template< typename LOCK_HOLDER >
struct no_lock_at_start_stop_policy_t
   {
      using start_stop_lock_t = no_actual_lock_t< LOCK_HOLDER >;
      using take_stats_lock_t = std::lock_guard< LOCK_HOLDER >;
   } 

Получается, что в случае default_lock_policy_t в качестве start_stop_lock_t выступают классы std::lock_guard и в методах start()/stop() происходит реальная блокировка lock-объектов. А вот когда используется политика no_lock_at_start_stop_policy_t, то start_stop_lock_t – это пустой тип no_actual_lock_t, который ничего не делает ни в конструкторе, ни в деструкторе. Поэтому блокировки в start()/stop() нет. Да и сам экземпляр start_stop_lock_t (он же no_actual_lock_t) скорее всего будет просто выброшен оптимизирующим компилятором.

Ну а использование stats_collector_t в разных случаях стало выглядеть вот так:

using namespace activity_tracking;
class one_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внешний lock-объект.
      lock_t m_common_lock;

      stats_collector_t< external_lock_t<> > m_work_stats{ m_common_lock };
      stats_collector_t< external_lock_t<> > m_wait_stats{ m_common_lock };
      ...
   };
class tricky_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внешний lock-объект
      // какого-то другого типа.
      mpmc_queue_traits::lock_t m_common_lock;

      stats_collector_t<
            external_lock_t< mpmc_queue_traits::lock_t > >
         m_work_stats{ m_common_lock };

      stats_collector_t<
            external_lock_t< mpmc_queue_traits::lock_t > >
         m_wait_stats{ m_common_lock };
      ...
   }; 
class very_tricky_performer_t
   {
      ...
   private :
      // Для случая, когда должен использоваться внешний lock-объект
      // какого-то другого типа, да еще и захватывать его в операциях
      // start() и stop() не нужно.
      complex_task_queue_t::lock_t m_common_lock;

      stats_collector_t<
            external_lock_t< complex_task_queue_t::lock_t, no_lock_at_start_stop_policy_t > >
         m_wait_stats{ m_common_lock };
      ...
   };

При этом в классах-preformer-ах как вызывали одинаковые методы start()/stop()/take_stats() у объектов stats_collector-ов, так и продолжили вызывать. В этом плане для performer-ов ничего не изменилось, все различия в поведении явным образом указываются при декларации соответствующего stats_collector-объекта. Т.е. мы получили настройку поведения конкретного stats_collector-а в compile-time без каких-либо дополнительных накладных расходов в run-time.

Какими могли бы быть альтернативы? Наверное, можно было написать несколько вариантов stats_collector-ов, отличающихся деталями поведения start()/stop(), но в основном дублирующих друг друга. Или же можно было бы сделать stats_collector абстрактным классом (интерфейсом), от которого будут наследоваться конкретные реализации, переопределяющие поведение методов start()/stop(). Только не думаю, что в итоге получилось бы короче и проще. Скорее было бы наоборот. Так что использование policy-based design в этом случае выглядит вполне уместно.

В чем же мораль всей этой истории? В том, что язык C++ сложен, но это оправданная сложность. С++ без шаблонов был намного проще. Но программировать на нем было сложнее.

Появились шаблоны, стали доступны новые подходы, вроде использованного в данном примере policy-based design. А это упростило переиспользование кода без потери его эффективности. Т.е. программисту стало жить проще.

Потом появились variadic-шаблоны. Что, безусловно, сделало язык еще сложнее. Но программировать на нем стало еще проще. Достаточно посмотреть на конструктор класса stats_collector_t. Который всего один и прост для понимания. Без variadic-ов пришлось бы хардкодить несколько конструкторов для разного количества аргументов (либо же прибегать к макросам).

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

Автор: eao197

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js