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

    Язык 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++ продолжается. Что сделает использование этого языка в будущем еще проще. Если, конечно, к тому времени кто-то еще будет продолжать им пользоваться…)
    • +16
    • 12,5k
    • 6
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 6
    • 0
      Если классы предоставляют один и тот же интерфейс — почему для этой цели не использовать декоратор? Внутри которого уже можно замерять время до и после выполнения методов.
      • 0
        Не уверен, что понял вопрос. Если вы про классы-performer-ы, но наружу они не выставляют ничего похожего на work_started/work_finished. Там скорее что-то вроде start()/shutdown(), внутри которых может быть много задач обработано. А статистику нужно замерять как раз по задачам, которые внутри выполняются. Так что замерять время снаружи performer-ов нет смысла.
        • 0
          Ясно, спасибо. Действительно, я про тот случай, когда внешнего замера достаточно. Если нужно замерять время по внутренним задачам, то тут декоратор вероятно не подойдет.
      • 0

        Хм, мне кажется, я начинаю завидовать вашему code-style.
        Да-да, если кодовая база однородна и всё такое, то, вроде как, всё хорошо, но как же меня бесит этот camelCase..

        • 0
          Странно, но меня почему то наоборот бесит нижнее_подчеркивание.
          Каждому свое, наверное
        • +1
          Помню, делал свою систему навороченных умных указателей с возможностью использования разных аллокаторов и политик владения — тоже таким развлекался. И как не пытался сделать код проще — ничего не получалось, кроме возвращения к копи-пасте и комбинационному взрыву. Подобный подход — действительно вынужденное зло, которое, если пользоваться дозировано и к месту, делает код лучше.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.