Regrettant l'absence en C ++ d'une statique à part entière si ou ...

... comment remplir une classe de modèle avec un contenu différent en fonction des valeurs des paramètres du modèle?


Il était une fois, pendant un certain temps, le langage D a commencé à être fait comme "le bon C ++", en tenant compte de l'expérience acquise en C ++. Au fil du temps, D est devenu un langage non moins complexe et plus expressif que C ++. Et déjà C ++ a commencé à espionner D. Par exemple, est apparu en C ++ 17 if constexpr , à mon avis, est un emprunt direct à D, dont le prototype était si statique D-shny .


Malheureusement, if constexpr en C ++ n'a pas la même puissance que static if en D. Il y a des raisons à cela , mais il y a encore des cas où vous ne pouvez que regretter que if constexpr en C ++ ne vous permette pas de contrôler le contenu de C + + classe. Je voudrais parler d'un de ces cas.


Nous parlerons de la façon de créer une classe de modèle, dont le contenu (c'est-à-dire la composition des méthodes et la logique de certaines méthodes) changerait en fonction des paramètres transmis à cette classe de modèle. Un exemple est tiré de la vie réelle, de l'expérience de développement d'une nouvelle version de SObjectizer .


La tâche à résoudre


Il est nécessaire de créer une version astucieuse du pointeur intelligent pour stocker les objets de message. Pour que vous puissiez écrire quelque chose comme:


 message_holder_t<my_message> msg{ new my_message{...} }; send(target, msg); send(another_target, msg); 

L'astuce de cette classe message_holder_t est qu'il y a trois facteurs importants à considérer.


De quel type de message hérite-t-on?


Les types de messages qui paramètrent message_holder_t sont divisés en deux groupes. Le premier groupe est constitué des messages qui héritent du type de base spécial message_t . Par exemple:


 struct so5_message final : public so_5::message_t { int a_; std::string b_; std::chrono::milliseconds c_; so5_message(int a, std::string b, std::chrono::milliseconds c) : a_{a}, b_{std::move(b)}, c_{c} {} }; 

Dans ce cas, message_holder_t à l'intérieur de lui-même ne doit contenir qu'un pointeur vers un objet de ce type. Le même pointeur doit être renvoyé dans les méthodes getter. Autrement dit, pour le cas de l'héritier de message_t devrait message_t avoir quelque chose comme:


 template<typename M> class message_holder_t { intrusive_ptr_t<M> m_msg; public: ... const M * get() const noexcept { return m_msg.get(); } }; 

Le deuxième groupe est constitué des messages de types d'utilisateurs arbitraires qui ne sont pas hérités de message_t . Par exemple:


 struct user_message final { int a_; std::string b_; std::chrono::milliseconds c_; user_message(int a, std::string b, std::chrono::milliseconds c) : a_{a}, b_{std::move(b)}, c_{c} {} }; 

Les instances de ces types dans SObjectizer ne sont pas envoyées par elles-mêmes, mais sont enfermées dans un wrapper spécial, user_type_message_t<M> , qui est déjà hérité de message_t . Par conséquent, pour ces types, message_holder_t doit contenir un pointeur vers user_type_message_t<M> intérieur, et les méthodes getter doivent renvoyer un pointeur sur M:


 template<typename M> class message_holder_t { intrusive_ptr_t<user_type_message_t<M>> m_msg; public: ... const M * get() const noexcept { return std::addressof(m_msg->m_payload); } }; 

Immunité ou mutabilité des messages


Le deuxième facteur est la division des messages en immuables et mutables. Si le message est immuable (et par défaut, il est immuable), les méthodes getter doivent renvoyer un pointeur constant vers le message. Et s'ils sont mutables, les getters doivent renvoyer un pointeur non constant. C'est-à-dire devrait être quelque chose comme:


 message_holder_t<so5_message> msg1{...}; //  . const int a = msg1->a_; // OK. msg1->a_ = 0; //     ! message_holder_t<mutable_msg<user_message>> msg2{...}; //  . const int a = msg2->a_; // OK. msg2->a_ = 0; // OK. 

shared_ptr vs unique_ptr


Le troisième facteur est la logique du comportement de message_holder_t tant que pointeur intelligent. Une fois qu'il devrait se comporter comme std::shared_ptr , c'est-à-dire Vous pouvez avoir plusieurs détenteurs de message faisant référence à la même instance de message. Et une fois qu'il devrait se comporter comme std::unique_ptr , c'est-à-dire une seule instance de message_holder peut faire référence à une instance de message.


Par défaut, le comportement de message_holder_t doit dépendre de la mutabilité / immuabilité du message. C'est-à-dire avec des messages immuables, message_holder_t devrait se comporter comme std::shared_ptr , et avec des std::unique_ptr modifiables comme std::unique_ptr :


 message_holder_t<so5_message> msg1{...}; message_holder_t<so5_message> msg2 = msg; // OK. message_holder_t<mutable_msg<user_message>> msg3{...}; message_holder_t<mutable_msg<user_message>> msg4 = msg3; // !  ! message_holder_t<mutable_msg<user_message>> msg5 = std::move(msg3); // OK. 

Mais la vie est une chose compliquée, vous devez donc également pouvoir définir manuellement le comportement de message_holder_t . Pour que vous puissiez créer message_holder pour un message immuable qui se comporte comme unique_ptr. Et pour que vous puissiez créer message_holder pour un message modifiable qui se comporte comme shared_ptr:


 using unique_so5_message = so_5::message_holder_t< so5_message, so_5::message_ownership_t::unique>; unique_so5_message msg1{...}; unique_so5_message msg2 = msg1; // !  ! unique_so5_message msg3 = std::move(msg); // OK,   msg3. using shared_user_messsage = so_5::message_holder_t< so_5::mutable_msg<user_message>, so_5::message_ownership_t::shared>; shared_user_message msg4{...}; shared_user_message msg5 = msg4; // OK. 

Par conséquent, lorsque message_holder_t fonctionne comme shared_ptr, il doit avoir l'ensemble habituel de constructeurs et d'opérateurs d'affectation: à la fois copier et déplacer. De plus, il doit y avoir une méthode constante make_reference , qui retourne une copie du pointeur stocké dans message_holder_t .


Mais lorsque message_holder_t fonctionne comme unique_ptr, le constructeur et l'opérateur de copie doivent être interdits pour cela. Et la méthode make_reference doit prendre le pointeur de l'objet message_holder_t : après avoir appelé make_reference message_holder_t origine doit rester vide.


Un peu plus formel


Vous devez donc créer une classe de modèle:


 template< typename M, message_ownership_t Ownership = message_ownership_t::autodetected> class message_holder_t {...}; 

qui:


  • l'intérieur doit être stocké intrusive_ptr_t<M> ou intrusive_ptr<user_type_message_t<M>> selon que M est hérité de message_t ;
  • les méthodes getter doivent retourner soit const M* soit M* selon la mutabilité / immuabilité du message;
  • il doit y avoir soit un ensemble complet de constructeurs et d'opérateurs de copie / déplacement, soit uniquement un constructeur et un opérateur de déplacement;
  • La méthode make_reference() doit soit renvoyer une copie de l'intrusive_ptr stockée, soit prendre la valeur de intrusive_ptr et laisser le message_holder_t origine vide. Dans le premier cas, make_reference() doit être constant, dans le second - méthode non constante.

Les deux derniers éléments de la liste sont déterminés par le paramètre Propriété (ainsi que par la mutabilité du message si la autodetected utilisée pour la propriété).


Comment cela a été décidé


Dans cette section, nous considérerons tous les composants qui composent la solution finale. Eh bien, la solution résultante elle-même. Les fragments de code effacés de tous les détails gênants seront affichés. Si quelqu'un s'intéresse au vrai code, vous pouvez le voir ici .


Clause de non-responsabilité


La solution présentée ci-dessous ne prétend pas être belle, idéale ou un modèle. Il a été trouvé, mis en œuvre, testé et documenté en peu de temps, sous la pression des délais. Peut-être que s'il y avait plus de temps, et si plus était engagé dans la recherche d'une solution jeune sensible et compétent en développeur C ++ moderne, il se révélerait plus compact, plus simple et plus compréhensible. Mais, il s'est avéré que c'est arrivé ... "Ne tirez pas sur le pianiste", en général.


Séquence d'étapes et magie de modèle prête à l'emploi


Donc, nous devons avoir une classe avec plusieurs ensembles de méthodes. Le contenu de ces kits doit provenir de quelque part. D'où?


En D, nous pourrions utiliser static if et définir différentes parties de la classe en fonction de différentes conditions. Dans certains Ruby, nous pourrions mélanger des méthodes dans notre classe en utilisant la méthode include . Mais nous sommes en C ++, où jusqu'à présent nos possibilités sont très limitées: nous pouvons soit définir une méthode / attribut directement à l'intérieur de la classe, soit hériter de la méthode / attribut d'une classe de base.


Nous ne pouvons pas définir différentes méthodes / attributs à l'intérieur de la classe en fonction de certaines conditions, car C ++ if constexpr n'est pas un D static if . Par conséquent, seul l'héritage demeure.


Upd. Comme suggéré dans les commentaires, je devrais parler plus attentivement ici. Puisque C ++ a SFINAE, nous pouvons activer / désactiver la visibilité des méthodes individuelles dans la classe via SFINAE (c'est-à-dire, obtenir un effet similaire à static if ). Mais cette approche présente à mon avis deux lacunes graves. Premièrement, si ces méthodes ne sont pas 1-2-3, mais 4-5 ou plus, il est fastidieux de formater chacune d'elles à l'aide de SFINAE, ce qui affecte la lisibilité du code. Deuxièmement, SFINAE ne nous aide pas à ajouter / supprimer des attributs de classe (champs).

En C ++, nous pouvons définir plusieurs classes de base dont nous hériterons ensuite message_holder_t . Et le choix de l'une ou l'autre classe de base se fera déjà en fonction des valeurs des paramètres du template, en utilisant std :: conditional .


Mais l'astuce est que nous avons besoin non seulement d'un ensemble de classes de base, mais d'une petite chaîne d'héritage. Au début, il y aura une classe qui déterminera la fonctionnalité générale qui sera requise dans tous les cas. Viennent ensuite les classes de base qui détermineront la logique du comportement du "pointeur intelligent". Et puis il y aura une classe qui déterminera les getters nécessaires. Dans cet ordre, nous considérerons les classes implémentées.


Notre tâche est simplifiée par le fait que SObjectizer possède déjà une magie de modèle prête à l'emploi qui détermine si un message est hérité de message_t , ainsi que des moyens pour vérifier la mutabilité des messages . Par conséquent, dans la mise en œuvre, nous utiliserons simplement cette magie toute faite et ne plongerons pas dans les détails de son travail.


Base de stockage de pointeur commun


Commençons par un type de base commun qui stocke le intrusive_ptr correspondant, et fournit également un ensemble commun de méthodes dont toute implémentation message_holder_t besoin:


 template< typename Payload, typename Envelope > class basic_message_holder_impl_t { protected : intrusive_ptr_t< Envelope > m_msg; public : using payload_type = Payload; using envelope_type = Envelope; basic_message_holder_impl_t() noexcept = default; basic_message_holder_impl_t( intrusive_ptr_t< Envelope > msg ) noexcept : m_msg{ std::move(msg) } {} void reset() noexcept { m_msg.reset(); } [[nodiscard]] bool empty() const noexcept { return static_cast<bool>( m_msg ); } [[nodiscard]] operator bool() const noexcept { return !this->empty(); } [[nodiscard]] bool operator!() const noexcept { return this->empty(); } }; 

Cette classe de modèle a deux paramètres. Le premier, Payload, définit le type que les méthodes getter doivent utiliser. Alors que la seconde, Envelope, définit le type de intrusive_ptr. Dans le cas où le type de message est hérité de message_t ces deux paramètres auront la même valeur. Mais si le message n'est pas hérité de message_t , le type de message sera utilisé comme charge utile et user_type_message_t<Payload> sera user_type_message_t<Payload> comme enveloppe.


Je pense que fondamentalement le contenu de cette classe ne pose pas de question. Mais deux choses doivent être notées séparément.


Tout d'abord, le pointeur lui-même, c'est-à-dire l'attribut m_msg est défini dans la section protégée afin que les héritiers des classes y aient accès.


Deuxièmement, pour cette classe, le compilateur génère lui-même tous les constructeurs et opérateurs de copie / déplacement nécessaires. Et au niveau de cette classe, nous n'interdisons encore rien.


Bases distinctes pour le comportement shared_ptr et unique_ptr


Nous avons donc une classe qui stocke un pointeur sur un message. Nous pouvons maintenant définir ses héritiers, qui se comporteront soit en shared_ptr soit en unique_ptr.


Commençons par le cas du comportement shared_ptr, car voici le moins de code:


 template< typename Payload, typename Envelope > class shared_message_holder_impl_t : public basic_message_holder_impl_t<Payload, Envelope> { using direct_base_type = basic_message_holder_impl_t<Payload, Envelope>; public : using direct_base_type::direct_base_type; [[nodiscard]] intrusive_ptr_t< Envelope > make_reference() const noexcept { return this->m_msg; } }; 

Rien de compliqué: basic_message_holder_impl_t de basic_message_holder_impl_t , basic_message_holder_impl_t tous ses constructeurs et définissez une implémentation simple et non destructive de make_reference() .


Dans le cas du comportement unique_ptr, le code est plus volumineux, bien qu'il n'y ait rien de compliqué:


 template< typename Payload, typename Envelope > class unique_message_holder_impl_t : public basic_message_holder_impl_t<Payload, Envelope> { using direct_base_type = basic_message_holder_impl_t<Payload, Envelope>; public : using direct_base_type::direct_base_type; unique_message_holder_impl_t( const unique_message_holder_impl_t & ) = delete; unique_message_holder_impl_t( unique_message_holder_impl_t && ) = default; unique_message_holder_impl_t & operator=( const unique_message_holder_impl_t & ) = delete; unique_message_holder_impl_t & operator=( unique_message_holder_impl_t && ) = default; [[nodiscard]] intrusive_ptr_t< Envelope > make_reference() noexcept { return { std::move(this->m_msg) }; } }; 

Encore une fois, nous basic_message_holder_impl_t de basic_message_holder_impl_t et basic_message_holder_impl_t des constructeurs dont nous avons besoin (c'est le constructeur par défaut et le constructeur d'initialisation). Mais en même temps, nous définissons les constructeurs et les opérateurs de copie / déplacement conformément à la logique unique_ptr: nous interdisons la copie, nous implémentons le déplacement.


Nous avons également une make_reference() destructrice make_reference() .


C'est tout, en fait. Il ne reste plus qu'à réaliser le choix entre ces deux classes de base ...


Choix entre le comportement shared_ptr et unique_ptr


Pour choisir entre le comportement shared_ptr et unique_ptr, vous avez besoin de la métafonction suivante (métafonction car elle "fonctionne" avec les types au moment de la compilation):


 template< typename Msg, message_ownership_t Ownership > struct impl_selector { static_assert( !is_signal<Msg>::value, "Signals can't be used with message_holder" ); using P = typename message_payload_type< Msg >::payload_type; using E = typename message_payload_type< Msg >::envelope_type; using type = std::conditional_t< message_ownership_t::autodetected == Ownership, std::conditional_t< message_mutability_t::immutable_message == message_mutability_traits<Msg>::mutability, shared_message_holder_impl_t<P, E>, unique_message_holder_impl_t<P, E> >, std::conditional_t< message_ownership_t::shared == Ownership, shared_message_holder_impl_t<P, E>, unique_message_holder_impl_t<P, E> > >; }; 

Cette métafonction accepte les deux paramètres de la liste des paramètres message_holder_t et, par conséquent (c'est-à-dire la définition d'un type imbriqué), "renvoie" le type dont elle doit être héritée. C'est-à-dire soit shared_message_holder_impl_t ou unique_message_holder_impl_t .


À l'intérieur de la définition de impl_selector vous pouvez voir des traces de la magie mentionnée ci-dessus, et dans lesquelles nous ne sommes pas allés: message_payload_type<Msg>::payload_type , message_payload_type<Msg>::envelope_type et message_mutability_traits<Msg>::mutability .


Et afin d'utiliser la impl_selector était plus facile, alors nous allons lui définir un nom plus court:


 template< typename Msg, message_ownership_t Ownership > using impl_selector_t = typename impl_selector<Msg, Ownership>::type; 

Base pour getters


Ainsi, nous avons déjà la possibilité de sélectionner une base qui contient un pointeur et définit le comportement d'un "pointeur intelligent". Maintenant, nous devons fournir à cette base des méthodes getter. Pourquoi avons-nous besoin d'une classe simple:


 template< typename Base, typename Return_Type > class msg_accessors_t : public Base { public : using Base::Base; [[nodiscard]] Return_Type * get() const noexcept { return get_ptr( this->m_msg ); } [[nodiscard]] Return_Type & operator * () const noexcept { return *get(); } [[nodiscard]] Return_Type * operator->() const noexcept { return get(); } }; 

Il s'agit d'une classe de modèle qui dépend de deux paramètres, mais leur signification est complètement différente. Le paramètre Base sera le résultat de la impl_selector impl_selector illustrée ci-dessus. C'est-à-dire en tant que paramètre Base, la classe de base est définie à partir de laquelle hériter.


Il est important de noter que si l'héritage provient de unique_message_holder_impl_t , pour lequel le constructeur et l'opérateur de copie sont interdits, le compilateur ne pourra pas générer le constructeur et l'opérateur de copie pour msg_accessors_t . C'est ce dont nous avons besoin.


Le type du message, le pointeur / lien vers lequel seront renvoyés par les getters, agira comme paramètre Return_Type. L'astuce est que pour un message immuable de type Msg le paramètre Return_Type sera défini sur const Msg . Alors que pour un message mutable de type Msg paramètre Return_Type aura la valeur Msg . Ainsi, la méthode get() renverra const Msg* pour les messages immuables et juste Msg* pour les messages mutables.


En utilisant la fonction gratuite get_ptr() résolvons le problème de travailler avec des messages qui ne sont pas hérités de message_t :


 template< typename M > M * get_ptr( const intrusive_ptr_t<M> & msg ) noexcept { return msg.get(); } template< typename M > M * get_ptr( const intrusive_ptr_t< user_type_message_t<M> > & msg ) noexcept { return std::addressof(msg->m_payload); } 

C'est-à-dire si le message n'est pas hérité de message_t et stocké en tant que user_type_message_t<Msg> , alors la deuxième surcharge est appelée. Et s'il est hérité, alors la première surcharge.


Choisir une base spécifique pour les getters


Ainsi, le modèle msg_accessors_t nécessite deux paramètres. La première est calculée par la impl_selector impl_selector. Mais afin de former un type de base spécifique à partir de msg_accessors_t , nous devons déterminer la valeur du deuxième paramètre. Une autre métafonction est prévue à cet effet:


 template< message_mutability_t Mutability, typename Base > struct accessor_selector { using type = std::conditional_t< message_mutability_t::immutable_message == Mutability, msg_accessors_t<Base, typename Base::payload_type const>, msg_accessors_t<Base, typename Base::payload_type> >; }; 

Vous ne pouvez faire attention qu'au calcul du paramètre Return_Type. Un de ces rares cas où l'est const est utile;)


Eh bien, pour augmenter la lisibilité du code suivant, une option plus compacte pour travailler avec lui:


 template< message_mutability_t Mutability, typename Base > using accessor_selector_t = typename accessor_selector<Mutability, Base>::type; 

Successeur final message_holder_t


Vous pouvez maintenant regarder ce qu'est message_holder_t , pour l'implémentation dont toutes ces classes de base et métafonctions étaient nécessaires (une partie des méthodes de construction d'une instance du message stocké dans message_holder sont supprimées de l'implémentation):


 template< typename Msg, message_ownership_t Ownership = message_ownership_t::autodetected > class message_holder_t : public details::message_holder_details::accessor_selector_t< details::message_mutability_traits<Msg>::mutability, details::message_holder_details::impl_selector_t<Msg, Ownership> > { using base_type = details::message_holder_details::accessor_selector_t< details::message_mutability_traits<Msg>::mutability, details::message_holder_details::impl_selector_t<Msg, Ownership> >; public : using payload_type = typename base_type::payload_type; using envelope_type = typename base_type::envelope_type; using base_type::base_type; friend void swap( message_holder_t & a, message_holder_t & b ) noexcept { using std::swap; swap( a.message_reference(), b.message_reference() ); } }; 

En fait, tout ce que nous avons analysé ci-dessus était nécessaire pour enregistrer cet «appel» de deux métafonctions:


 details::message_holder_details::accessor_selector_t< details::message_mutability_traits<Msg>::mutability, details::message_holder_details::impl_selector_t<Msg, Ownership> > 

Parce que ce n'est pas la première option, mais le résultat de la simplification et de la réduction du code, je peux dire que les formes compactes de métafonctions réduisent considérablement la quantité de code et augmentent sa compréhensibilité (s'il est généralement approprié de parler ici de compréhensibilité).


Et que se passerait-il si ...


Mais si en C ++ if constexpr était aussi puissant que static if en D, alors vous pourriez écrire quelque chose comme:


Version hypothétique avec plus avancé si constexpr
 template< typename Msg, message_ownership_t Ownership = message_ownership_t::autodetected > class message_holder_t { static constexpr const message_mutability_t Mutability = details::message_mutability_traits<Msg>::mutability; static constexpr const message_ownership_t Actual_Ownership = (message_ownership_t::unique == Ownership || (message_mutability_t::mutable_msg == Mutability && message_ownership_t::autodetected == Ownership)) ? message_ownership_t::unique : message_ownership_t::shared; public : using payload_type = typename message_payload_type< Msg >::payload_type; using envelope_type = typename message_payload_type< Msg >::envelope_type; private : using getter_return_type = std::conditional_t< message_mutability_t::immutable_msg == Mutability, payload_type const, payload_type >; public : message_holder_t() noexcept = default; message_holder_t( intrusive_ptr_t< envelope_type > mf ) noexcept : m_msg{ std::move(mf) } {} if constexpr(message_ownership_t::unique == Actual_Ownership ) { message_holder_t( const message_holder_t & ) = delete; message_holder_t( message_holder_t && ) noexcept = default; message_holder_t & operator=( const message_holder_t & ) = delete; message_holder_t & operator=( message_holder_t && ) noexcept = default; } friend void swap( message_holder_t & a, message_holder_t & b ) noexcept { using std::swap; swap( a.m_msg, b.m_msg ); } [[nodiscard]] getter_return_type * get() const noexcept { return get_const_ptr( m_msg ); } [[nodiscard]] getter_return_type & operator * () const noexcept { return *get(); } [[nodiscard]] getter_return_type * operator->() const noexcept { return get(); } if constexpr(message_ownership_t::shared == Actual_Ownership) { [[nodiscard]] intrusive_ptr_t< envelope_type > make_reference() const noexcept { return m_msg; } } else { [[nodiscard]] intrusive_ptr_t< envelope_type > make_reference() noexcept { return { std::move(m_msg) }; } } private : intrusive_ptr_t< envelope_type > m_msg; }; 

, . C++ :(
( C++ "" ).


, , ++. , , , . , message_holder_t . , , if constexpr .


Conclusion


, C++. , . , , .


, .


, , ++ , . , . , , . , . C++98/03 , C++11 .

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


All Articles