Язык 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