📜 ⬆️ ⬇️

An example of using policy-based design in C ++ instead of copy-paste and creating OOPs of hierarchies

C ++ is often blamed for unjustified complexity. Of course, the C ++ language is complicated. And with each new standard it becomes more difficult. The paradox, however, is that by constantly complicating, C ++ consistently and progressively simplifies the life of developers. Including ordinary programmers who write code easier than the developers of Boost-a or Folly. In order not to be unfounded, I will try to show it with a small example “from the recent one”: as a result of adaptation to various conditions, the trivial class turned into an easy hardcore using policy-based design .

So, there was a task to modify a set of certain classes, adding to them a collection of statistics about the time spent in the process of work. There are not so many classes, about a dozen, some are far from simple according to their logic. Outside, they expose the same interface, but inside each works in its own way, although some similar pieces in the implementations of each of them, of course, can be found.

In the process of implementing this task, it quickly became clear that each of the modified classes would acquire such a set of private methods:

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; ... }; 

In some classes, instead of work_started () / work_finished () / take_work_stats () there will be methods wait_started () / wait_finished () / take_wait_stats (). And in some, and others. But the code inside these methods will almost 1-in-1 match.
')
It’s clear that I didn’t want to duplicate the same thing, so all the details were moved to the auxiliary class stats_collector_t, after which the main code began to look something like this:

 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; ... }; 

The stats_collector_t class initially looked quite simple:

 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{}; }; 

Everything seems to be good. But the first ambush came to light: in some cases, stats_collector_t should not have its own lock. For example, in some performer classes there are several instances of stats_collector_t, each stats_collector_t counts statistics for different types of work, but work with them is performed under the same lock. Those. it turned out that in some places the stats_collector_t should have its own lock, in other places it should be able to use someone else's lock.

Well, not a problem. Let's transform stats_collector_t into a template whose parameter will say whether an internal or external lock object is used:

 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{}; }; 

Where such classes were to be used as LOCK_HOLDERs:

 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(); } }; 

Accordingly, in the performer class, the stats_collector_t instances began to be initialized in one of two possible ways:

 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{}; ... }; 

True, there also showed up an ambush. It turned out that the type of an external lock object will not always be activity_tracking :: lock_t. Sometimes you need to use a different type of lock object, which, however, is suitable for working with std :: lock_guard.

Therefore, the auxiliary class external_lock_t also became a template:

 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(); } }; 

As a result, the use of stats_collector_t began to look like this:

 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 }; ... }; 

But as it turned out, it was still flowers. The berries went off when it turned out that in some cases the lock object cannot be captured in the start () and stop () methods, since these methods are called in a context where the external lock object is already captured.

The first thought was to make a pair of methods start_no_lock () / start () and stop_no_lock () / stop (). But this is a so-so idea. In particular, such a division may make it difficult to use stats_collector in any template. In the template code, it may not be clear whether start_no_lock () should be called or just start (). And in general, the presence of start_no_lock () along with start () looks ugly and complicates the use of stats_collector.

Therefore, the behavior of the stats_collector_t template has been changed:

 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 }; ... } ... } ... }; 

Now the LOCK_HOLDER type must define two type names: start_stop_lock_t (how locking is performed in the start () and stop () methods) and take_stats_lock_t (how locking is performed in the take_stats () method). And already the class stats_collector_t and with their help does or does not lock the lock object in its code.

The simple class internal_lock_t defines these names in a trivial way:

 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(); } }; 

But the external_lock_t template needed to be expanded and another parameter added - the lock policy:

 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(); } }; 

Well, the implementation of classes for blocking policies looks like this:

 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 >; } 

It turns out that in the case of default_lock_policy_t, the start_stop_lock_t classes are the std :: lock_guard classes and the lock-objects are actually locked in the start () / stop () methods. But when the no_lock_at_start_stop_policy_t policy is used, start_stop_lock_t is an empty type no_actual_lock_t, which does nothing either in the constructor or in the destructor. Therefore, there is no lock in start () / stop (). And the start_stop_lock_t instance itself (also known as no_actual_lock_t) will most likely be simply thrown away by the optimizing compiler.

Well, the use of stats_collector_t in different cases began to look like this:

 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 }; ... }; 

At the same time, in the preformer classes, both the same start () / stop () / take_stats () methods were called on the objects of the stats_collectors, and continued to be called. In this regard, nothing has changed for performers; all differences in behavior are explicitly indicated when the corresponding stats_collector object is declared. Those. we got to configure the behavior of a particular stats_collector in compile-time without any additional overhead in run-time.

What could be the alternatives? Probably, it was possible to write several variants of stats_collectors, differing in details of the behavior of start () / stop (), but mostly duplicating each other. Or, it would be possible to make stats_collector an abstract class (interface) from which concrete implementations will be inherited, redefining the behavior of the start () / stop () methods. Just do not think that in the end it would have turned out shorter and simpler. Rather, it would be the opposite. So use of policy-based design in this case looks quite appropriate.

What is the moral of this whole story? That the C ++ language is difficult, but it is justified complexity. C ++ without templates was much easier. But programming on it was more difficult.

Patterns have appeared, new approaches have become available, such as the policy-based design used in this example. This simplified the reuse of the code without losing its effectiveness. Those. the programmer has become easier to live.

Then came the variadic patterns. Which, of course, made the language even more difficult. But programming on it has become even easier. Just look at the constructor class stats_collector_t. Which is just one and easy to understand. Without variadics, several constructors for different numbers of arguments would have to be hardcoded (or resort to macros).

Well and that can not but rejoice, the process of developing C ++ continues. Which will make using this language even easier in the future. If, of course, by that time someone else will continue to use it ...)

Source: https://habr.com/ru/post/308594/


All Articles