📜 ⬆️ ⬇️

Use for simple tests inheritance, polymorphism and patterns? Why not…

C ++ language is complicated. But its complexity stems from the complexity of the tasks that are solved with C ++. Every feature that was added in C ++ was added for a reason, but in order to give an opportunity to cope with a problem. Well, the combination of existing features in C ++ makes the language an extremely powerful tool. This article is devoted to a specific example of how this happens in practice.

I would also add that one of the powerful incentives for writing this article was that very often voluminous flames of discussion on the topic “OOP is not needed” and, especially, “generic templates in practice are almost never needed.” I, as far from being a young programmer who started in the 1990s with tools, in which there was no OOP or generic templates, it is strange to encounter such points of view. But, the further, the more often you come across them. Especially from adherents of new programming languages, like Go or Rust.

It is difficult to say what caused it. Maybe people have overfed the PLO (and this is how it was) ... Maybe the tasks over the past several decades have changed a lot (and this is the case) ... Maybe it’s just "that’s the generation has grown” ... Anyway, you can try real life example to show that everything is not so straightforward ©.
')
So, what will be discussed?

Upd. Advance disclaimer. An article on C ++. Code examples are given in C ++. Therefore, if you do not transfer C ++ or do not know C ++ sufficiently, then please refrain from comments like “sheets of unreadable code”. Constructive such comments do not carry and they are unlikely to be useful to anyone.

The essence of the task


We recently released a new version of our OpenSource framework, where we added a new feature called deadletter handlers . And this new feature needed to be tested. Quite simple tests. In one test, it was necessary to check that the programmer could install the deadletter handler, in the other that the user could cancel the deadletter handler.

The tests are simple, but they have one difficulty: it was necessary to check the same functionality in different conditions. Well, for example, the deadletter handler can be hanged on a message from different types of mailboxes. The message on which the deadletter handler hangs can be a regular, immutable message, it can be a regular mutable message, it can be a signal (I will explain: regular messages are different from signals, therefore message delivery is different from signal delivery). The deadletter handler itself can be represented as a pointer to the class of the agent method, as well as the lambda function.

And since the code implementing deadletter handlers in SObjectizer makes extensive use of templates, in order to check the correctness of the templates, it was necessary to cover all reasonable combinations of these conditions in the tests.

Let me explain for those who are not very familiar with the features of C ++: if, for example, the template function is not called anywhere in the code, some compilers (like old versions of Visual C ++) do not always check the source code of this function for the presence of elementary errors. Therefore, in order to be sure that everything is normal with the template implementation, it is necessary that the template function or class be instantiated explicitly or implicitly. This is most reliably achieved through the explicit use of a template function / class in a test.

Possible solution "in the forehead"


Of course, there is a very simple solution, which is to create your own class for each test case and perform all the relevant actions within this class.

So, for a combination of MPMC-mbox +, a normal, immutable message + a pointer to a method would result in the following class:

class mpmc_message_pfn_test_case_t final : public so_5::agent_t { state_t st_test{this}; const so_5::mbox_t mbox_; class test_message final : public so_5::message_t {}; public: mpmc_message_pfn_test_case_t(context_t ctx) : so_5::agent_t{std::move(ctx)} , mbox_{so_environment().create_mbox()} {} virtual void so_define_agent() override { this >>= st_test; so_subscribe_deadletter_handler(mbox_, &mpmc_message_pfn_test_case_t::on_deadletter); } virtual void so_evt_start() override { so_5::send<test_message>(mbox_); } public: void on_deadletter(mhood_t<test_message>) { so_deregister_agent_coop_normally(); } }; 

And for the MPMC-mbox + combination, the usual immiable message + lambda function would require a very similar class, but with a few changes:

 class mpmc_message_lambda_test_case_t final : public so_5::agent_t { state_t st_test{this}; const so_5::mbox_t mbox_; class test_message final : public so_5::message_t {}; public: mpmc_message_lambda_test_case_t(context_t ctx) : so_5::agent_t{std::move(ctx)} , mbox_{so_environment().create_mbox()} {} virtual void so_define_agent() override { this >>= st_test; so_subscribe_deadletter_handler(mbox_, [this](mhood_t<test_message>) { so_deregister_agent_coop_normally(); }); } virtual void so_evt_start() override { so_5::send<test_message>(mbox_); } }; 

Whereas for the case of direct_mbox +, the usual mutable message + lambda, the change function would take more, but the code would still be quite similar:

 class direct_mutable_message_lambda_test_case_t final : public so_5::agent_t { state_t st_test{this}; class test_message final : public so_5::message_t {}; public: direct_mutable_message_lambda_test_case_t(context_t ctx) : so_5::agent_t{std::move(ctx)} {} virtual void so_define_agent() override { this >>= st_test; so_subscribe_deadletter_handler(so_direct_mbox(), [this](mutable_mhood_t<test_message>) { so_deregister_agent_coop_normally(); }); } virtual void so_evt_start() override { so_5::send<so_5::mutable_msg<test_message>>(*this); } }; 

I hope the idea is clear.

A total of such classes would need ten pieces. What immediately discouraged the desire to move in this direction. Since in such quantity copy-paste is very easy to make a mistake. By the way, when these tests just appeared, a couple of possible test cases were forgotten. But when they remembered this, the forgotten combinations were literally added in just a few lines to the corresponding tests. But if I had to create a separate class for each test case, then to eliminate the initial miscalculation would require more code and attention.

Another way


Since I have a hard point about copy-paste in the code and for good reason, I chose a different path. Through the use of templates. And, in one place, template templates. What then was added and inheritance with polymorphism. But let's start with a simple test, in which only templates are enough.

Simple test (only templates are used)


So, we have three factors that need to be combined with each other. Two of them — the mbox type and the message / signal type — are easily represented as a template parameter. Not just with the third: what exactly is the deadletter handler - a pointer to a function or lambda. Well, okay. The goal is not to get rid of copy-paste at all. The goal is to do the bare minimum necessary.

Therefore, two template classes were made. The first is for the case when the deadletter handler is implemented with a pointer to the method:

 template< typename Mbox_Case, typename Msg_Type > class pfn_test_case_t final : public so_5::agent_t { const Mbox_Case m_mbox_holder; state_t st_test{ this, "test" }; public: pfn_test_case_t( context_t ctx ) : so_5::agent_t( std::move(ctx) ) , m_mbox_holder( *self_ptr() ) {} virtual void so_define_agent() override { this >>= st_test; so_subscribe_deadletter_handler( m_mbox_holder.mbox(), &pfn_test_case_t::on_deadletter ); } virtual void so_evt_start() override { so_5::send<Msg_Type>( m_mbox_holder.mbox() ); } private: void on_deadletter( mhood_t<Msg_Type> ) { so_deregister_agent_coop_normally(); } }; 

The second is for the case of the lambda function:

 template< typename Mbox_Case, typename Msg_Type > class lambda_test_case_t final : public so_5::agent_t { const Mbox_Case m_mbox_holder; state_t st_test{ this, "test" }; public: lambda_test_case_t( context_t ctx ) : so_5::agent_t( std::move(ctx) ) , m_mbox_holder( *self_ptr() ) {} virtual void so_define_agent() override { this >>= st_test; so_subscribe_deadletter_handler( m_mbox_holder.mbox(), [this](mhood_t<Msg_Type>) { so_deregister_agent_coop_normally(); } ); } virtual void so_evt_start() override { so_5::send<Msg_Type>( m_mbox_holder.mbox() ); } }; 

You may notice that these template classes are very similar to those code examples that were shown above. Only for a simple test that checks the installation of a deadletter handler, now only two classes are enough. Not ten.

I hope with the parameter of the template called Msg_Type everything is clear. This will be the type of message or signal that the test agent should send and receive. In the test for this purpose the following definitions will be used:

 class test_message final : public so_5::message_t {}; class test_signal final : public so_5::signal_t {}; 

Well, when instantiating the pfn_test_case_t and lambda_test_case_t templates, test_message, so_5 :: mutable_msg <test_message> and test_signal will be used. It's all simple.

But with the Mbox_Case parameter a bit more complicated (although, if you know C ++ well, then there is nothing complicated there at all). This parameter determines which mbox should be used in the test case: MPMC-mbox, which should be created specially, or direct_mbox, which each agent already has.

In our tests, two very simple types are used as Mbox_Case:

 class direct_mbox_case_t { const so_5::agent_t & m_owner; public : direct_mbox_case_t( const so_5::agent_t & owner ) : m_owner(owner) {} const so_5::mbox_t & mbox() const noexcept { return m_owner.so_direct_mbox(); } }; class mpmc_mbox_case_t { const so_5::mbox_t m_mbox; public: mpmc_mbox_case_t( const so_5::agent_t & owner ) : m_mbox( owner.so_environment().create_mbox() ) {} const so_5::mbox_t & mbox() const noexcept { return m_mbox; } }; 

An instance of the class direct_mbox_case_t retains a link to the agent in order to return the direct_mbox of this agent in its mbox () method. And an instance of the class mpmc_mbox_case_t in its constructor creates an instance of MPMC-mbox-a and returns a reference to it in its mbox () method.

It turns out that when, for example, the pfn_test_case_t class is parameterized by direct_mbox_case_t, a reference to the pfn_test_case_t instance itself is stored in pfn_test_case :: m_mbox_holder and the agent itself is returned by calling m_mbox_holder.mbox ().

And when pfn_test_case_t is parametrized by mpmc_mbox_case_t, then in pfn_test_case_t :: m_mbox_holder there is an instance of a separate MPMC-mbox, which is created when constructing an instance of pfn_test_case_t.

Well, the same thing happens for lambda_test_case_t.

So, we get the opportunity to create such combinations for test cases:

pfn_test_case_t<direct_mbox_case_t, test_message>;
pfn_test_case_t<direct_mbox_case_t, so_5::mutable_msg<test_message>>;
pfn_test_case_t<direct_mbox_case_t, test_signal>;
pfn_test_case_t<mpmc_mbox_case_t, test_message>;
pfn_test_case_t<mpmc_mbox_case_t, test_signal>;
lambda_test_case_t<direct_mbox_case_t, test_message>;
lambda_test_case_t<direct_mbox_case_t, so_5::mutable_msg<test_message>>;
...


More about the Mbox_Case parameter. We use classes. Although it was possible to parameterize test classes and a function that would return mbox_t. Those. could be done like this:

 using mbox_maker_t = so_5::mbox_t (*)(const so_5::agent_t &); so_5::mbox_t mpmc_mbox_maker(const so_5::agent_t & agent) { return agent.so_environment().create_mbox(); } so_5::mbox_t direct_mbox_maker(const so_5::agent_t & agent) { return agent.so_direct_mbox(); } template< mbox_maker_t Mbox_Case, typename Msg_Type > class pfn_test_case_t final : public so_5::agent_t { const so_5::mbox_t m_mbox; state_t st_test{ this, "test" }; public: pfn_test_case_t( context_t ctx ) : so_5::agent_t( std::move(ctx) ) , m_mbox( Mbox_Case(*self_ptr()) ) {} ... }; ... pfn_test_case_t<direct_mbox_maker, test_message>; ... lambda_test_case_t<mpmc_mbox_maker, test_signal>; 

Basically, it would not change anything. But the first thing that came to mind was precisely the classes, and they went into implementation.

And where is the pattern template?


But the template templates, which was mentioned above, will be needed only to slightly simplify the procedure for creating test agents. In general, one could easily manage without him and write something like:

 env.introduce_coop([](so_5::coop_t coop) { coop.make_agent< pfn_test_case_t<direct_mbox_case_t, test_message>>(); }); env.introduce_coop([](so_5::coop_t coop) { coop.make_agent< pfn_test_case_t<direct_mbox_case_t, so_5::mutable_msg<test_message>>>(); }); env.introduce_coop([](so_5::coop_t coop) { coop.make_agent< pfn_test_case_t<direct_mbox_case_t, test_signal>>(); }); 

But it is better to introduce an auxiliary template function:

 template< typename Mbox_Case, typename Msg_Type, template <class, class> class Test_Agent > void introduce_test_agent( so_5::environment_t & env ) { env.introduce_coop( [&]( so_5::coop_t & coop ) { coop.make_agent< Test_Agent<Mbox_Case, Msg_Type> >(); } ); } 

And then reduce the amount of work and make the test creation code more readable:

  introduce_test_agent <direct_mbox_case_t, test_message, pfn_test_case_t> (env);
 introduce_test_agent <direct_mbox_case_t, so_5 :: mutable_msg <test_message>, pfn_test_case_t> (env);
 introduce_test_agent <direct_mbox_case_t, test_signal, pfn_test_case_t> (env); 

And now, create_test_agent is already the “template template”, i.e. template function, one of the template parameters of which is another template.

More difficult test in which inheritance with polymorphism is required


The test analyzed above was very simple, it was enough to send only one message, so the test agents in it were simple. But the next test, verifying that the user can cancel the deadletter handler, is already more complicated. In order to deal with it for a start, let's see how the agent should work for the test case (for simplicity, we take only the usual, immutable test_message message for now):


Again, to make it easier to deal with the subsequent code, let us show how an agent written “head on” for a particular test case would look like. The simplest option is with direct_mbox and the usual immutable test_message message:

 struct finish final : public so_5::signal_t {}; class test_case_specific_agent_t : public so_5::agent_t { state_t st_test{ this }; int m_deadletters{ 0 }; void deadletter_handler(mhood_t<test_message>) { ensure_or_die( 0 == m_deadletters, "m_deadletters must be 0" ); ++m_deadletters; so_unsubscribe_deadletter_handler<test_message>(so_direct_mbox()); so_5::send<test_message>(*this); so_5::send<finish>(*this); } public: test_case_specific_agent_t( context_t ctx ) : so_5::agent_t(std::move(ctx)) {} virtual void so_define_agent() override { this >>= st_test; so_subscribe_deadletter_handler( so_direct_mbox(), &test_case_specific_agent_t::deadletter_handler); st_test.event([this](mhood_t<finish>) { so_deregister_agent_coop_normally(); }); } virtual void so_evt_start() override { so_5::send<test_message>(*this); } }; 

Those. there is an entry count in the deadletter_handler (m_deadletters attribute with a zero initial value). Inside the deadletter_handler () this counter is checked for zero and incremented. If deadletter_handler () is called again, the test will fail.

The deadletter_handler method sends two messages. The first should be ignored. The second should lead to the completion of the test (subscription to the finish signal goes to so_define_agent).

Well, the very first test_message is sent to so_evt_start. Those. at the start of the agent.

However, this is not a template class. Yes, and sharpened for a specific test script. How to make a template out of it that could be parameterized with two parameters Mbox_Case and Msg_Type, as in the previous simple test?

The obvious solution (not the most interesting)


The obvious solution would be to take the classes pfn_test_case_t and lambda_test_case_t from a simple test and simply remake each of them into a new work logic.

But this solution cannot be called good for the obvious reason: too much duplicate code turns out to be in each of the classes. Accordingly, if we make a mistake in the first implementation, then it automatically extends to both classes, but its elimination requires modification of not one class, but both. Also, if it is necessary to change the logic of behavior, then it will also be necessary to modify both classes, and not one. (And the logic of the work had to be changed, because the more complex logic was originally used, but in the process of writing the article it became clear what could be done much simpler. This change in logic turned out to be elementary and there was no need to redo two classes independent from each other.)

Less obvious solution (with inheritance and polymorphism)


So, we need to move the common parts from pfn_test_case_t and lambda_test_case_t to some common class. And since agent classes in SObjectizer should be inherited from so_5 :: agent_t, then most likely this general class will be basic for both pfn_test_case_t and lambda_test_case_t.

But will it be one class? Let's get a look.

You can pay attention to the fact that in the demo test_case_specific_agent_t there are both pieces of code that depend on the template parameters, and pieces of code that do not depend on the template parameters. Let's say that the presence of the st_test state and the signal handler finish do not depend on the template parameters. But the installation and cancellation of the deadletter handler, sending a test message - depend.

This gives us the opportunity to break the common code into two parts. The first part will not be template. To implement this part, we need the following class:

 class nontemplate_basic_part_t : public so_5::agent_t { protected: state_t st_test{ this, "test" }; int m_deadletters{ 0 }; void actual_deadletter_handler() { ensure_or_die( 0 == m_deadletters, "m_deadletters must be 0" ); ++m_deadletters; do_next_step(); so_5::send<finish>(*this); } virtual void do_next_step() = 0; public: nontemplate_basic_part_t( context_t ctx ) : so_5::agent_t( std::move(ctx) ) {} virtual void so_define_agent() override { this >>= st_test; st_test.event( [this](mhood_t<finish>) { so_deregister_agent_coop_normally(); } ); } }; 

You can see that a large part of the applied logic of the test agent is concentrated here. And there is nothing that depends on the template parameter.

However, already in nontemplate_basic_part_t, you need to perform two actions that depend on Msg_Type - this is canceling the deadletter handler and sending another instance of Msg_Type. Inside nontemplate_basic_part_t, we know where and when these actions should be performed, but we cannot perform them.

Therefore, we delegate the execution of these actions to the heir through the pure virtual method do_next_step (), which must be redefined in one of the heir classes.

The heir of the same, in which do_next_step () is defined, will be a template class of the following form:

 template< typename Mbox_Case, typename Msg_Type > class template_basic_part_t : public nontemplate_basic_part_t { protected: const Mbox_Case m_mbox_holder; virtual void do_next_step() override { so_drop_deadletter_handler< Msg_Type >( m_mbox_holder.mbox() ); so_5::send< Msg_Type >( *this ); } public: template_basic_part_t( context_t ctx ) : nontemplate_basic_part_t( std::move(ctx) ) , m_mbox_holder( *self_ptr() ) {} virtual void so_evt_start() override { so_5::send<Msg_Type>( m_mbox_holder.mbox() ); } }; 

Here we already see the usual trick with the mbox_holder attribute of type Mbox_Case. And also we see the implementation of the virtual methods do_next_step (cancellation of the deadletter handler and sending the second instance of Msg_Type) and so_evt_start (sending the first instance of Msg_Type).

It turns out that nontemplate_basic_part_t and template_basic_part_t already contain 95% of the functionality needed by the test agent. All that remains is to do pfn_test_case_t and lambda_test_case_t in which the deadletter handler of the desired type would be installed.

This is how it will look like:

 template< typename Mbox_Case, typename Msg_Type > class pfn_test_case_t final : public template_basic_part_t< Mbox_Case, Msg_Type > { using base_type_t = template_basic_part_t< Mbox_Case, Msg_Type >; public: using base_type_t::base_type_t; virtual void so_define_agent() override { base_type_t::so_define_agent(); this->so_subscribe_deadletter_handler( this->m_mbox_holder.mbox(), &pfn_test_case_t::on_deadletter ); } private: void on_deadletter( so_5::mhood_t<Msg_Type> ) { this->actual_deadletter_handler(); } }; template< typename Mbox_Case, typename Msg_Type > class lambda_test_case_t final : public template_basic_part_t< Mbox_Case, Msg_Type > { using base_type_t = template_basic_part_t< Mbox_Case, Msg_Type >; public: using base_type_t::base_type_t; virtual void so_define_agent() override { base_type_t::so_define_agent(); this->so_subscribe_deadletter_handler( this->m_mbox_holder.mbox(), [this](so_5::mhood_t<Msg_Type>) { this->actual_deadletter_handler(); } ); } }; 

There is simply classical inheritance with overlapping of the virtual method of the ancestor in order to extend its behavior: in so_define_agent (), so_define_agent () is first called from the base class, after which the deadletter handler of the proper form is set.

So in the end it turns out the good old OOP, with inheritance (implementation) and polymorphism. Yes, and plentifully flavored with generalized programming.

Disclaimer


I do not want readers to get the feeling that the described approach is the only correct one in this situation. And that could not be done differently. Surely it was possible. And, for certain, even in this approach something could be made even simpler and more concise. In the end, what was shown in the article was written literally on the knee in half an hour, checked, corrected and forgotten. And then once again fixed and forgotten again. What personally convinces me is that this approach to the implementation of tests is quite justified.

The meaning of the article should have been to show how the possibilities, which some developers consider to be too complex and which they are trying to stay away from, can make their lives literally out of the blue.

If you have a desire to express your “phi” about the style of the code, please read this comment first . I hope this will allow you to understand that in the C ++ world, the attitude to the used notations is much more loyal than in other languages. And that this is more a dispute about tastes.

Something like a conclusion


OOP is just a tool. Not a religion, not a disease. Just a tool. Somewhere it is appropriate, somewhere not. Say, if you need to make a complex and large library, then OOP may be useful to you. If you make a small and simple application, it may not be useful. And maybe the opposite. It all depends on the subject area, and on your knowledge and experience. Well, from religious bias, of course.

Similarly with generalized programming. This is just a tool. No one forces you to use it when it is not needed.

But personally it is not very convenient for me when the set of tools available to me is intentionally limited. Either by removing the PLO (or turning the PLO into some kind of pathetic similarity). Either by removing the jeniric templates. Even worse when there is neither one nor the other. Since these tools were created to simplify the work of the programmer. It is strange to refuse them voluntarily.

Well, C ++, with all its flaws, is good because it allows you to use both. Yes, even in various combinations. Another question is how to learn how to use one and the other (and a bunch of C ++ features) in place and in moderation. But that's another story ... :)

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


All Articles