Приклад використання 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++ триває. Що зробить використання цієї мови в майбутньому ще простіше. Якщо, звичайно, до того часу хтось ще буде продовжувати ним користуватися… :)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.