Récemment, alors que je travaillais sur une nouvelle version de SObjectizer , j'ai été confronté à la tâche de contrôler les actions du développeur pendant la compilation. L'essentiel était qu'avant, un programmeur pouvait faire des appels de la forme:
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(...), ...);
L'opération receive () a nécessité un ensemble de paramètres, pour lesquels une chaîne de méthodes a été utilisée, comme celles indiquées ci-dessus à from(ch).empty_timeout(150ms)
ou from(ch).handle_n(2).no_wait_on_empty()
. Dans le même temps, l'appel des méthodes handle_n () / extract_n (), qui limitent le nombre de messages à extraire / traiter, était facultatif. Par conséquent, toutes les chaînes illustrées ci-dessus étaient correctes.
Mais dans la nouvelle version, il fallait obliger l'utilisateur à indiquer explicitement le nombre de messages à extraire et / ou traiter. C'est-à-dire une chaîne du formulaire from(ch).empty_timeout(150ms)
maintenant devenue incorrecte. Il doit être remplacé par from(ch).handle_all().empty_timeout(150ms)
.
Et je voulais faire en sorte que le compilateur batte le programmeur à la main si le programmeur oubliait d'appeler handle_all (), handle_n () ou extract_n ().
Le C ++ peut-il aider à cela?
Oui Et si quelqu'un s'intéresse exactement à comment, alors vous êtes les bienvenus sous cat.
Il y a plus qu'une fonction receive ()
La fonction receive () a été montrée ci-dessus, dont les paramètres ont été définis à l'aide d'une chaîne d'appels (également appelée modèle de générateur ). Mais il y avait aussi une fonction select (), qui recevait presque le même ensemble de paramètres:
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_(...), ...);
En conséquence, je voulais obtenir une solution qui conviendrait à la fois à select () et à receive (). De plus, les paramètres pour select () et receive () eux-mêmes étaient déjà représentés dans le code afin d'éviter le copier-coller. Mais cela sera discuté ci-dessous.
Solutions possibles
Ainsi, la tâche consiste pour l'utilisateur à invoquer handle_all (), handle_n () ou extract_n () sans échec.
En principe, cela peut être réalisé sans recourir à des décisions complexes. Par exemple, vous pouvez entrer un argument supplémentaire pour select () et receive ():
receive(handle_all(), from(ch).empty_timeout(150ms), ...); select(handle_n(20), from_all().no_wait_on_empty(), ...);
Ou il serait possible de forcer l'utilisateur à effectuer l'appel de réception () / sélection () différemment:
receive(handle_all(from(ch).empty_timeout(150ms)), ...); select(handle_n(20, from_all().no_wait_on_empty()), ...);
Mais le problème ici est que lors du passage à une nouvelle version de SObjectizer, l'utilisateur devrait refaire son code. Même si le code, en principe, ne nécessitait pas de retouche. Dites, dans cette situation:
receive(from(ch).handle_n(2).no_wait_on_empty(), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...);
Et cela, à mon avis, est un problème très grave. Ce qui vous fait chercher une autre façon. Et cette méthode sera décrite ci-dessous.
D'où vient donc le CRTP?
Le titre de l'article mentionnait le CRTP. Il est également un modèle de modèle curieusement récurrent (ceux qui veulent se familiariser avec cette technique intéressante, mais légèrement tolérante au cerveau, peuvent commencer par cette série de messages sur le blog Fluent C ++).
Le CRTP a été mentionné car grâce au CRTP, nous avons implémenté le travail avec les paramètres de fonction receive () et select (). Étant donné que la part du lion des paramètres pour receive () et select () était la même, le code a utilisé quelque chose comme ceci:
template<typename Derived> class bulk_processing_params_t { ...;
Pourquoi le CRTP est-il ici?
Nous avons dû utiliser CRTP ici pour que les méthodes de définition définies dans la classe de base puissent renvoyer une référence non pas au type de base, mais au type dérivé.
Autrement dit, si ce n'était pas CRTP qui était utilisé, mais l'héritage ordinaire, alors nous ne pourrions écrire que comme ceci:
class bulk_processing_params_t { public:
Mais un tel mécanisme primitif ne nous permettra pas d'utiliser le même modèle de générateur, car:
receive_processing_params_t{}.handle_n(20).receive_payload(0)
non compilé. La méthode handle_n () renverra une référence à bulk_processing_params_t, et là la méthode receive_payload () n'est pas encore définie.
Mais avec CRTP, nous n'avons aucun problème avec le modèle de générateur.
Décision finale
La solution finale consiste à ce que les types finaux, tels que receive_processing_params_t et select_processing_params_t, deviennent eux-mêmes des types de modèle. Pour qu'ils soient paramétrés avec un scalaire de la forme suivante:
enum class msg_count_status_t { undefined, defined };
Et pour que le type final puisse être converti de T <msg_count_status_t :: undefined> en T <msg_count_status_t :: defined>.
Cela permettra, par exemple, dans la fonction receive () de recevoir receive_processing_params_t et de vérifier la valeur Status en temps de calcul. Quelque chose comme:
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" );
En général, tout est simple, comme d'habitude: prendre et faire;)
Description de la décision prise
Regardons un exemple minimal, détaché des spécificités de SObjectizer, tel qu'il se présente.
Donc, nous avons déjà un type qui détermine si la limite du nombre de messages est définie ou non:
enum class msg_count_status_t { undefined, defined };
Ensuite, nous avons besoin d'une structure dans laquelle tous les paramètres communs seront stockés:
struct basic_data_t { int to_extract_{}; int to_handle_{}; int common_payload_{}; };
En général, peu importe le contenu de basic_data_t. Par exemple, l'ensemble minimal de champs indiqué ci-dessus convient.
En ce qui concerne basic_data_t, il est important que pour des opérations spécifiques (que ce soit receive (), select () ou autre), son propre type concret sera créé qui hérite de basic_data_t. Par exemple, pour receive () dans notre exemple abstrait, ce serait la structure suivante:
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} {} };
Nous supposons que la structure basic_data_t et ses descendants ne causent pas de difficultés. Par conséquent, nous passons aux parties les plus complexes de la solution.
Maintenant, nous avons besoin d'un wrapper autour de basic_data_t, qui fournira des méthodes getter. Ce sera une classe modèle de la forme suivante:
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_; } };
Cette classe est passe-partout afin qu'elle puisse contenir tout héritier de basic_data_t, bien qu'elle implémente des méthodes getter uniquement pour les champs qui sont dans basic_data_t.
Avant de passer aux parties encore plus complexes de la solution, vous devez faire attention à la méthode data () dans basic_data_holder_t. Il s'agit d'une méthode importante et nous la rencontrerons plus tard.
Maintenant, nous pouvons passer à la classe de modèle clé, qui peut sembler assez effrayante pour les personnes qui ne sont pas très dédiées au C ++ moderne:
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; };
Ce basic_params_t est le principal modèle CRTP. Seulement maintenant, il est paramétré par deux paramètres.
Le premier paramètre est le type de données qui doit être contenu à l'intérieur. Par exemple, receive_specific_data_t ou select_specific_data_t.
Le deuxième paramètre est le type de successeur familier au CRTP. Il est utilisé dans la méthode self_reference () pour obtenir une référence à un type dérivé.
Le point clé de l'implémentation du modèle basic_params_t est sa méthode clone_as_defined (). Cette méthode attend de l'héritier qu'il implémente la méthode clone_if_necessary (). Et ce clone_if_necessary () est juste conçu pour transformer l'objet T <msg_count_status_t :: undefined> en l'objet T <msg_count_status_t :: defined>. Et une telle transformation est initiée dans les méthodes setter handle_all (), handle_n () et extract_n ().
De plus, vous pouvez faire attention au fait que clone_as_defined (), handle_all (), handle_n () et extract_n () déterminent le type de leur valeur de retour comme decltype (auto). Ceci est une autre astuce, dont nous parlerons bientôt.
Maintenant, nous pouvons déjà regarder l'un des types finaux, pour lesquels tout cela a été conçu:
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_; } };
La première chose à laquelle vous devez faire attention ici est le constructeur, qui prend base_type :: data_type. À l'aide de ce constructeur, les valeurs actuelles des paramètres sont transférées lors de la transformation de T <msg_count_status_t :: undefined> en T <msg_count_status_t :: defined>.
Dans l'ensemble, ce receive_specific_params_t est quelque chose comme ceci:
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;
Et juste le constructeur mentionné receive_specific_params_t permet d'initialiser receive_specific_params_t <msg_count_status_t :: defined> avec les valeurs de receive_specific_params_t <msg_count_status_t :: undefined>.
La deuxième chose importante dans receive_specific_params_t est les deux méthodes clone_if_necessary ().
Pourquoi y en a-t-il deux? Et que signifie toute cette magie SFINAE-vskaya dans leur définition?
Deux méthodes clone_if_necessary () ont été réalisées afin d'éviter des transformations inutiles. Supposons qu'un programmeur appelé la méthode handle_n () et déjà reçu receive_specific_params_t <msg_count_status_t :: defined>. Et puis il a appelé extract_n (). Ceci est autorisé, handle_n () et extract_n () définissent des restrictions légèrement différentes. L'appel à extract_n () devrait également nous donner receive_specific_params_t <msg_count_status_t :: defined>. Mais nous en avons déjà un. Alors pourquoi ne pas réutiliser un existant?
C'est pourquoi il existe deux méthodes clone_if_necessary () ici. Le premier fonctionnera lorsque la transformation sera vraiment nécessaire:
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() }; }
Le compilateur le sélectionne, par exemple, lorsque l'état passe de non défini à défini. Et cette méthode retournera un nouvel objet. Et oui, dans l'implémentation de cette méthode, nous prêtons attention à l'appel data (), qui était déjà défini dans basic_data_holder_t.
La deuxième méthode:
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; }
sera appelé lorsqu'il n'est pas nécessaire de modifier l'état. Et cette méthode renvoie une référence à un objet existant.
Maintenant, il devrait devenir clair pourquoi dans basic_params_t pour un certain nombre de méthodes, le type de retour a été défini comme decltype (auto). Après tout, ces méthodes dépendent de la version particulière de clone_if_necessary () qui sera appelée dans le type dérivé, et un objet ou un lien peut y être renvoyé ... Vous ne prédiserez pas à l'avance. Et ici le decltype (auto) vient à la rescousse.
Petit avertissement
L'exemple minimaliste décrit visait la démonstration la plus simple et la plus compréhensible de la solution choisie. Par conséquent, il n'a pas de choses bien évidentes qui demandent à être incluses dans le code.
Par exemple, la méthode basic_data_holder_t :: data () renvoie une référence constante aux données. Cela conduit à la copie des valeurs des paramètres lors de la transformation de T <msg_count_status_t :: undefined> en T <msg_count_status_t :: defined>. Si la copie des paramètres est une opération coûteuse, alors vous devriez être perplexe par la sémantique de déplacement et la méthode data () pourrait avoir la forme suivante:
auto data() { return std::move(data_); }
Maintenant aussi, dans chaque type final (comme receive_specific_params_t et select_specific_params_t), vous devez inclure des implémentations des méthodes clone_if_necessary. C'est-à-dire à cet endroit, nous utilisons toujours du copier-coller. Peut-être faudrait-il également trouver quelque chose pour éviter la duplication du même type de code.
Eh bien et oui, noexcept n'est pas inscrit dans le code afin de réduire les "frais généraux de syntaxe" (s).
C'est tout
Le code source de l'exemple minimaliste discuté ici peut être trouvé ici . Et vous pouvez jouer dans le compilateur en ligne, par exemple, ici (vous pouvez commenter l'appel à handle_all () sur la ligne 163 et voir ce qui se passe).
Je ne veux pas dire que l'approche que j'ai mise en œuvre est la seule correcte. Mais, tout d'abord, j'ai vu une alternative sauf en copier-coller. Et, deuxièmement, ce n'était pas du tout difficile à faire, et heureusement, cela n'a pas pris beaucoup de temps. Mais les poinçons du compilateur ont beaucoup aidé tout de suite, car les anciens tests et exemples se sont adaptés aux nouvelles fonctionnalités de la dernière version de SObjectizer.
Donc, pour moi, C ++ a une fois de plus confirmé qu'il est complexe. Mais pas seulement comme ça, mais pour donner plus d'opportunités au développeur. Eh bien, je ne serais pas surpris si tout cela pouvait être obtenu en C ++ moderne d'une manière encore plus simple que moi.
PS. Si l'un des lecteurs suit le SObjectizer, alors je peux dire que la nouvelle version 5.6, dans laquelle la compatibilité avec la branche 5.5 a été considérablement violée, a déjà beaucoup soufflé. Vous pouvez le trouver sur BitBucket . La sortie est encore loin, mais SObjectizer-5.6 est déjà ce qu'il était censé être. Vous pouvez prendre, essayer et partager vos impressions.