📜 ⬆️ ⬇️

Some C ++ template magic and CRTP to control the correctness of the programmer's actions in compile-time

Recently, while working on a new version of SObjectizer , it was possible to encounter the task of controlling developer actions in the compile-time. The bottom line was that previously a programmer could make calls of the form:


receive(from(ch).empty_timeout(150ms), ...); receive(from(ch).handle_n(2).no_wait_on_empty(), ...); receive(from(ch).empty_timeout(2s).extract_n(20).stop_on(...), ...); receive(from(ch).no_wait_on_empty().stop_on(...), ...); 

The receive () operation required a set of parameters, to indicate which chains of methods were used, such as those shown from(ch).empty_timeout(150ms) or from(ch).handle_n(2).no_wait_on_empty() . At the same time, calling the handle_n () / extract_n () methods, which limit the number of messages to be retrieved / processed, was optional. Therefore, all the chains shown above were correct.


But in the new version it was necessary to force the user to explicitly specify the number of messages for extraction and / or processing. Those. a chain of the form from(ch).empty_timeout(150ms) now becoming incorrect. It should have been replaced with from(ch).handle_all().empty_timeout(150ms) .


And I wanted to make it so that the compiler would beat the programmer’s hands if the programmer forgot to make a call to handle_all (), handle_n () or extract_n ().


Can C ++ help with this?


Yes. And if someone is interested in exactly how, then you are welcome under the cat.


There is not only a receive () function.


Above, the receive () function was shown, the parameters for which were set via a call chain (also known as the builder pattern ). But there was also a select () function, which received almost the same set of parameters:


 select(from_all().empty_timeout(150ms), case_(...), case_(...), ...); select(from_all().handle_n(2).no_wait_on_empty(), case_(...), case_(...), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...); select(from_all().no_wait_on_empty().stop_on(...), case_(...), case_(...), ...); 

Accordingly, I wanted to get one solution that would be suitable for both select () and receive (). Moreover, the parameters for select () and receive () were already presented in the code in such a way as to avoid copy-and-paste. But this will be discussed below.


Possible solutions


So, the problem is that the user must call handle_all (), handle_n () or extract_n () without fail.


In principle, this can be achieved without resorting to any complex solutions. For example, one could enter an additional argument for select () and receive ():


 receive(handle_all(), from(ch).empty_timeout(150ms), ...); select(handle_n(20), from_all().no_wait_on_empty(), ...); 

Or you could force the user to issue a call to receive () / select () in a different way:


 receive(handle_all(from(ch).empty_timeout(150ms)), ...); select(handle_n(20, from_all().no_wait_on_empty()), ...); 

But the problem here is that when switching to a new version of SObjectizer, the user would have to redo his code. Even if the code basically did not require rework. Let's say, in this situation:


 receive(from(ch).handle_n(2).no_wait_on_empty(), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...); 

And this, in my opinion, is a very serious problem. Which makes search another way. And this method will be described below.


So where is CRTP?


CRTP was mentioned in the title of the article. He is also Curiously Recurring Template Pattern (those who want to get acquainted with this interesting, but slightly outgoing brain technique can start with this series of posts on the Fluent C ++ blog).


CRTP was mentioned because we had implemented work with the parameters of the receive () and select () functions through CRTP. Since the lion's share of parameters for receive () and select () were the same, the code used something like this:


 template<typename Derived> class bulk_processing_params_t { ...; //     . Derived & self_reference() { return static_cast<Derived &>(*this); } ... public: auto & handle_n(int v) { to_handle_ = v; return self_reference(); } ... auto & extract_n(int v) { to_extract_ = v; return self_reference(); } ... }; class receive_processing_params_t final : public bulk_processing_params_t<receive_processing_params_t> { ...; //   receive . }; class select_processing_params_t final : public bulk_processing_params_t<select_processing_params_t> { ...; }; 

Why is CRTP here at all?


CRTP here had to be applied so that setter methods that were defined in the base class could return a reference not to the base type, but to the derived type.


That is, if it were not CRTP used, but normal inheritance, then we could only write something like this:


 class bulk_processing_params_t { public: //      bulk_processing_params_t, //     . bulk_processing_params_t & handle_n(int v) {...} bulk_processing_params_t & extract_n(int v) {...} ... }; class receive_processing_params_t final : public bulk_processing_params_t { public: //      //   bulk_processing_params_t,    // receive_processing_params_t. ... //       //  receive_processing_params_t. receive_processing_params_t & receive_payload(int v) {...} }; class select_processing_params_t final : public bulk_processing_params_t { public: //      //   bulk_processing_params_t,    // select_processing_params_t. ... }; 

But such a primitive mechanism will not allow us to use the same builder pattern, because:


 receive_processing_params_t{}.handle_n(20).receive_payload(0) 

will not compile. The handle_n () method will return a reference to bulk_processing_params_t, and there the receive_payload () method is not yet defined.


But with CRTP we have no problems with the builder pattern.


Final decision


The final solution is that the final types, like receive_processing_params_t and select_processing_params_t, also become the template types themselves. So that they are parametrized by the following scalar:


 enum class msg_count_status_t { undefined, defined }; 

And so that the final type can be converted from T <msg_count_status_t :: undefined> to T <msg_count_status_t :: defined>.


This will allow, for example, in the receive () function to receive receive_processing_params_t and check the Status value in compile-time. Sort of:

 template< msg_count_status_t Msg_Count_Status, typename... Handlers > inline mchain_receive_result_t receive( const mchain_receive_params_t<Msg_Count_Status> & params, Handlers &&... handlers ) { static_assert( Msg_Count_Status == msg_count_status_t::defined, "message count to be processed/extracted should be defined " "by using handle_all()/handle_n()/extract_n() methods" ); 

In general, everything is simple, as usual: take and do;)


Description of the decision


Let's look at a minimal example, untied from the specifics of SObjectizer, what it looks like.


So, we already have a type that determines whether the limit on the number of messages is set or not:


 enum class msg_count_status_t { undefined, defined }; 

Next, we need a structure in which all common parameters will be stored:


 struct basic_data_t { int to_extract_{}; int to_handle_{}; int common_payload_{}; }; 

Generally not the essence of what the content will be in the basic_data_t. For example, the minimum set of fields shown above is suitable.


For basic_data_t, it is important that for specific operations (be it receive (), select () or something else), you will create your own specific type that inherits basic_data_t. For example, for receive () in our abstracted example, it will be the following structure:


 struct receive_specific_data_t final : public basic_data_t { int receive_payload_{}; receive_specific_data_t() = default; receive_specific_data_t(int v) : receive_payload_{v} {} }; 

We assume that the basic_data_t structure and its heirs do not cause difficulties. Therefore, we turn to the more complex parts of the solution.


Now we need a wrapper around basic_data_t, which will provide getter methods. This will be a template class of the following form:


 template<typename Basic_Data> class basic_data_holder_t { private : Basic_Data data_; protected : void set_to_extract(int v) { data_.to_extract_ = v; } void set_to_handle(int v) { data_.to_handle_ = v; } void set_common_payload(int v) { data_.common_payload_ = v; } const auto & data() const { return data_; } public : basic_data_holder_t() = default; basic_data_holder_t(Basic_Data data) : data_{std::move(data)} {} int to_extract() const { return data_.to_extract_; } int to_handle() const { return data_.to_handle_; } int common_payload() const { return data_.common_payload_; } }; 

This class is a template so that it can contain any successor from basic_data_t, although it only implements getter methods for only those fields that are in basic_data_t.


Before we move on to even more complex parts of the solution, we should pay attention to the data () method in basic_data_holder_t. This is an important method and we will come across it later.


Now we can move on to the key template class, which can look pretty scary for people who are not very dedicated to modern C ++:


 template<typename Data, typename Derived> class basic_params_t : public basic_data_holder_t<Data> { using base_type = basic_data_holder_t<Data>; public : using actual_type = Derived; using data_type = Data; protected : actual_type & self_reference() { return static_cast<actual_type &>(*this); } decltype(auto) clone_as_defined() { return self_reference().template clone_if_necessary< msg_count_status_t::defined >(); } public : basic_params_t() = default; basic_params_t(data_type data) : base_type{std::move(data)} {} decltype(auto) handle_all() { this->set_to_handle(0); return clone_as_defined(); } decltype(auto) handle_n(int v) { this->set_to_handle(v); return clone_as_defined(); } decltype(auto) extract_n(int v) { this->set_to_extract(v); return clone_as_defined(); } actual_type & common_payload(int v) { this->set_common_payload(v); return self_reference(); } using base_type::common_payload; }; 

This basic_params_t is the main CRTP template. Only now it is parametrized by two parameters.


The first parameter is the data type that should be contained inside. For example, receive_specific_data_t or select_specific_data_t.


The second parameter is the successor type for CRTP. It is used in the self_reference () method to get a reference to the derived type.


The key point in the implementation of the basic_params_t template is its clone_as_defined () method. This method relies on the heir to implement the clone_if_necessary () method. And this clone_if_necessary () is precisely designed to transform an object T <msg_count_status_t :: undefined> into an object T <msg_count_status_t :: defined>. And such a transformation is initiated in the setter-ah methods of handle_all (), handle_n () and extract_n ().


And you can pay attention to the fact that clone_as_defined (), handle_all (), handle_n () and extract_n () define the type of their return value as decltype (auto). This is another trick, which we will talk about soon.


Now we can already have a look at one of the final types, for which all this was started:


 template< msg_count_status_t Msg_Count_Status > class receive_specific_params_t final : public basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> > { using base_type = basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> >; public : template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; } template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; } receive_specific_params_t(int receive_payload) : base_type{ typename base_type::data_type{receive_payload} } {} receive_specific_params_t(typename base_type::data_type data) : base_type{ std::move(data) } {} int receive_payload() const { return this->data().receive_payload_; } }; 

The first thing you should pay attention to here is the constructor, which accepts base_type :: data_type. By means of this constructor, the current values ​​of the parameters are transferred during the transformation from T <msg_count_status_t :: undefined> to T <msg_count_status_t :: defined>.


By and large, this receive_specific_params_t is something like this:


 template<typename V, int K> class holder_t { V v_; public: holder_t() = default; holder_t(V v) : v_{std::move(v)} {} const V & value() const { return v_; } }; holder_t<std::string, 0> v1{"Hello!"}; holder_t<std::string, 1> v2; v2 = v1; //   ,   v1  v2   . v2 = holder_t<std::string, 1>{v1.value()}; //    . 

And the just mentioned receive_specific_params_t constructor allows you to initialize receive_specific_params_t <msg_count_status_t :: defined> values ​​from receive_specific_params_t <msg_count_status_t :: undefined>.


The second important thing about receive_specific_params_t is the two methods clone_if_necessary ().


Why are there two of them? And what does all this SFINAE-all magic in their definition mean?


Two clone_if_necessary () methods are made in order to avoid unnecessary transformations. Suppose a programmer calls the handle_n () method and has already received receive_specific_params_t <msg_count_status_t :: defined>. And then I called extract_n (). This is allowed; handle_n () and extract_n () set slightly different restrictions. The call to extract_n () should also give us receive_specific_params_t <msg_count_status_t :: defined>. But we already have one. So why not reuse the existing one?


That's why here are two methods clone_if_necessary (). The first will work when the transformation is really needed:


  template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; } 

The compiler will select it, for example, when the status changes from undefined to defined. And this method will return a new object. And yes, in the implementation of this method, we pay attention to the data () call, which was defined in basic_data_holder_t.


The second method:


  template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; } 

It will be called when you do not need to change the status. And this method returns a reference to an already existing object.


Now it should be clear why in the basic_params_t for a number of methods, the return type was defined as decltype (auto). After all, these methods depend on which version of clone_if_necessary () will be called in a derived type, and there either the object or the link can be returned ... You cannot predict in advance. And here decltype (auto) comes to the rescue.


Small disclaimer


The described minimalistic example pursued the goal of the simplest and most understandable demonstration of the chosen solution. Therefore, there are no obvious things in it that suggest being included in the code.


For example, the method basic_data_holder_t :: data () returns a constant reference to data. Which leads to the copying of parameter values ​​during the transformation of T <msg_count_status_t :: undefined> into T <msg_count_status_t :: defined>. If copying parameters is expensive operations, then the move-semantics would be puzzled and the data () method could have the following form:


 auto data() { return std::move(data_); } 

Also now, every final type (like receive_specific_params_t and select_specific_params_t) has to include implementations of the clone_if_necessary methods. Those. in this place we still use copy-paste. Perhaps there would also be something to come up with to avoid duplication of the same type of code.


Well, yes, noexcept is included anywhere in the code in order to reduce the "syntactic overhead" (c).


That's all


The source code for the minimalistic example discussed here can be found here . And you can play in the on-line compiler, for example, here (you can comment out the call to handle_all () on line 163 and see what happens).


I do not want to say that the approach I have implemented is the only correct one. But, first of all, I saw an alternative except in copy-paste. And, secondly, it turned out to be not at all difficult and, fortunately, it did not take much time. But compiler blows to the hands greatly helped immediately, as old tests and examples were adapted to the new features of the new version of SObjectizer.


So, as for me, C ++ has once again confirmed that it is complicated. But not just like that, but in order to give more opportunities to the developer. Well and, I would not be surprised if it was possible to get all this in modern C ++ in an even simpler way than it did for me.


Ps. If one of the readers is following SObjectizer, then I can say that the new version 5.6, in which compatibility with the 5.5 branch was significantly impaired, was already quite breathing. You can find it on BitBucket . The release is still far away, but SObjectizer-5.6 has already become what it was intended to be. You can take, try and share your impressions.


')

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


All Articles