Язык C++ очень часто обвиняют в неоправданной сложности. Конечно же, язык C++ сложен. И с каждым новым стандартом становится все сложнее. Парадокс, однако, состоит в том, что постоянно усложняясь, C++ последовательно и поступательно упрощает жизнь разработчикам. В том числе и обычным программистам, которые пишут код попроще, чем разработчики Boost-а или Folly. Чтобы не быть голословным, попробую показать это на небольшом примере «из недавнего»: как в результате адаптации к различным условиям тривиальный класс превратился в легкий хардкор с использованием policy-based design.
Итак, появилась задача модифицировать набор неких классов, добавив в них сбор статистики о потраченном в процессе работы времени. Классов не так, чтобы уж мало, с десяток, некоторые далеко не простые по своей логике. Наружу они выставляют один и тот же интерфейс, а вот внутри каждый работает по-своему, хотя какие-то похожие куски в реализациях каждого из них, конечно же, найти можно.
В процессе реализации этой задачи быстро выяснилось, что каждый из модифицируемых классов обзаведется вот таким набором приватных методов:
В каких-то классах вместо work_started()/work_finished()/take_work_stats() будут методы wait_started()/wait_finished()/take_wait_stats(). А в каких-то и те, и другие. Но код внутри этих методов будет практически 1-в-1 совпадать.
Понятное дело, что дублировать одно и то же не хотелось, поэтому все детали были вынесены во вспомогательный класс stats_collector_t, после чего основной код стал выглядеть приблизительно вот так:
Класс stats_collector_t поначалу выглядел совсем просто:
Все вроде бы хорошо. Но обнаружилась первая засада: в ряде случаев у stats_collector_t не должно было быть собственного lock-а. Например, в каких-то классах-performer-ах есть несколько экземпляров stats_collector_t, каждый stats_collector_t считает статистику по разным видам работ, но работа с ними выполняется под одним и тем же lock-ом. Т.е. выяснилось, что в каких-то местах stats_collector_t должен иметь собственный lock, в других местах должен уметь использовать чужой lock.
Ну не проблема. Преобразуем stats_collector_t в шаблон, параметр которого и будет говорить, используется ли внутренний или внешний lock-объект:
Где в качестве LOCK_HOLDER-ов должны были использоваться вот такие классы:
Соответственно, в класса-performer-ов экземпляры stats_collector_t начали инициализироваться одним из двух возможных способов:
Правда, здесь так же обнаружилась засада. Оказалось, что тип внешнего lock-объекта не всегда будет activity_tracking::lock_t. Иногда нужно использовать другой тип lock-объекта, который, тем не менее, пригоден для работы с std::lock_guard.
Поэтому вспомогательный класс external_lock_t так же стал шаблоном:
В результате чего использование stats_collector_t стало выглядеть вот так:
Но, как оказалось, это были еще цветочки. Ягодки пошли когда выяснилось, что в некоторых случаях в методах 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 было изменено:
Теперь тип LOCK_HOLDER должен определить два имени типа: start_stop_lock_t (как блокировка выполняется в методах start() и stop()) и take_stats_lock_t (как блокировка выполняется в методе take_stats()). А уже класс stats_collector_t и их помощью делает или не делает блокировку lock-объекта у себя в коде.
Простой класс internal_lock_t определяет эти имена тривиальным образом:
А вот шаблон external_lock_t потребовалось расширить и добавить еще один параметр – политику блокировки:
Ну и реализация классов для политик блокировки выглядит так:
Получается, что в случае 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 в разных случаях стало выглядеть вот так:
При этом в классах-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++ продолжается. Что сделает использование этого языка в будущем еще проще. Если, конечно, к тому времени кто-то еще будет продолжать им пользоваться…)
Итак, появилась задача модифицировать набор неких классов, добавив в них сбор статистики о потраченном в процессе работы времени. Классов не так, чтобы уж мало, с десяток, некоторые далеко не простые по своей логике. Наружу они выставляют один и тот же интерфейс, а вот внутри каждый работает по-своему, хотя какие-то похожие куски в реализациях каждого из них, конечно же, найти можно.
В процессе реализации этой задачи быстро выяснилось, что каждый из модифицируемых классов обзаведется вот таким набором приватных методов:
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++ продолжается. Что сделает использование этого языка в будущем еще проще. Если, конечно, к тому времени кто-то еще будет продолжать им пользоваться…)
