Code de grande personne – un sélecteur de classe parent

Cet article sera inspiré d'une des stratégies typiques de la bibliothèque Boost. Des thématiques passionnantes et stimulantes pour l'esprit, mais définitivement du code de grande personne.

Je présumerai dans cet article que vous avez au préalable lu l'article portant sur le truc de Barton-Nackman et l'idiome CRTP.

Certaines techniques de programmation générique (le truc Barton-Nackman en est un exemple bien connu) permettent à un enfant d'injecter son nom dans la définition de son parent dans la mesure où ce parent est un type générique (en C++, un template). Le parent devient alors un générateur de fonctionnalités s'ajoutant à la barrière d'abstraction de ses enfants.

Cette technique est communément utilisée pour ajouter spontanément des opérateurs à un enfant qui définit au préalable un ensemble minimal d'opérations. Par exemple, si une classe enfant de type T expose l'opérateur == permettant de comparer deux instances constantes de T, alors le parent peut aisément, s'il connaît le type T, définir le sens de != pour deux instances de T nommées a et b en créant une fonction globale operator!= implémentée sous la forme return !(a==b).

Le problème avec cette technique telle quelle est qu'elle n'est pas très flexible. Par exemple, si un type Entier expose, à fins de comparaison avec ==, une méthode d'instance bool operator==(const Entier &) const;, alors :

En retour, si Entier expose l'opérateur == sous forme de fonction globale selon trois combinaisons distinctes :

bool operator==(const Entier&, int);
bool operator==(int, const Entier&);
bool operator==(const Entier&, const Entier&);

alors un parent qui ne définirait que l'opérateur != entre deux instances de Entier pourrait ne pas couvrir le cas où quelqu'un souhaite comparer un Entier et un int. Le problème est que la technique mise en place à l'aide du truc Barton-Nackman, lorsque prise seule, laisse alors des trous conceptuels et génère un opérateur == acceptant plus de combinaisons de types que l'opérateur !=.

Il faudrait donc mettre en place deux catégories de parents: la catégorie générant l'opérateur != seulement pour deux opérandes du type de l'enfant et la catégorie générant l'opérateur != pour le type de l'enfant et pour un type auxiliaire.

L'irritant sera de faire reposer le fardeau sur les épaules de l'enfant. On voudrait que le bon parent soit choisi par le compilateur en fonction des seuls types impliqués.

Voilà pour la mise en situation. Allons-y maintenant pour la technique.

Exemple concret

Imaginons que nous ayons une classe nombre exploitant le truc de Barton-Nackman pour générer les opérateurs requis pour déterminer une relation d'équivalence efficace et élégante en ne définissant en soi que l'opérateur <. Le code pour ce faire suit (pour des explications, référez-vous à l'article d'où cette illustration est tirée).

namespace relation
{
   //
   // equivalence <T> définit une relation d'équivalence
   // pour T si T::operator< (const T&) const existe.
   //
   template <class T>
      struct equivalence
      {
         friend bool operator>(const T &a, const T &b)
            { return b < a; }
         friend bool operator<=(const T &a, const T &b)
            { return !(b < a); }
         friend bool operator>= (const T &a, const T &b)
            { return !(a < b); }
      };
}

#include <iosfwd>
class nombre
   : relation::equivalence<nombre>
{
   int valeur_;
public:
   nombre(int n = 0)
      : valeur_{n}
   {
   }
   int valeur() const
      { return valeur_; }
   bool operator<(const nombre &n) const
      { return valeur() < n.valeur(); }
   friend std::ostream& operator<<(std::ostream &os, const nombre &n)
   {
      return os << n.valeur();
   }
};

#include <iostream>
int main()
{
   using namespace std;
   nombre n0{4}, n1{4};
   if (n0 <  n1)
      cout << n0 << "< " << n1 << endl;
   if (n0 <= n1)
      cout << n0 << "<=" << n1 << endl;
   if (n0 >  n1)
      cout << n0 << "> " << n1 << endl;
   if (n0 >= n1)
      cout << n0 << ">=" << n1 << endl;
}

Cet exemple fonctionne bien et est fort élégant dans la mesure où les seuls opérateurs requis sont ceux pour lesquels les deux opérandes sont de même type et où ce type est nombre.

Si l'envie nous prenait d'utiliser ce code pour comparer un nombre avec un int, à tout hasard, notre stratégie fonctionnerait toujours mais seulement grâce à la construction implicite d'un nombre à partir d'un int.

int main()
{
   // ...
   nombre n{4};
   int val = 3;
   if (n < val) // nécessite la construction implicite nombre(val)
      cout << n << "< " << val << endl;
}
class nombre
   : relation::equivalence<nombre>
{
   int valeur_;
   // ...
public:
   explicit nombre(int n = 0)
      : valeur_{n}
   {
   }
   // ...
};

La construction d'une variable temporaire et son éventuelle destruction sont des opérations susceptibles d'être coûteuses, et il arrive fréquemment qu'une classe déclare les constructeurs paramétriques comme étant explicites (voir l'exemple à droite) pour éviter que le moteur d'inférence de types de C++ ne génère des instances temporaires à l'insu du programmeur.

L'insertion du mot clé explicit devant un constructeur empêche l'action du moteur d'inférence de types de C++ et rend de ce fait illégale l'opération de comparaison d'un nombre avec un int, ci-dessus.

Dans de le cas où une classe expose un constructeur paramétrique implicite, il est probable que cette classe expose aussi une gamme plus sophistiquée d'opérations de manière à tirer profit de diverses particularités des types susceptibles d'être utilisés en conjonction avec elle. Une std::string, par exemple, pourrait offrir des méthodes appropriées à la manipulation de const char * dans le but de limiter les besoins en construction d'instances temporaires de std::string tout en maintenant un niveau acceptable de convivialité dans le code utilisant des objets et des chaînes de caractères primitives du langage C.

Présumons que nous soyons dans une telle situation ici avec la classe nombre et que nous désirions, pour chaque opération de comparaison générique, exposer un trio de méthodes :

Nous ne nous occuperons évidemment pas du cas où les deux paramètres sont de type int.

Notre schéma de classe parent générique définissant des opérations pour ses enfants ne conviendrait alors plus du tout dans sa forme actuelle. Cette classe convient pourtant très bien à la plupart des types pour lesquels la conversion implicite à l'aide d'un constructeur paramétrique est définie et accessible implicitement.

Idéalement, nous voudrions que l'enfant n'ait qu'à dériver du parent désiré (ici, le type relation::equivalence) en indiquant le ou les types pour lesquels des opérations doivent être définies (un seul type ou deux types seront les deux seuls cas visés ici) et que le code généré soit correct dans chaque cas :

La classe exprimant une relation d'équivalence à un seul type

La relation d'équivalence pour un seul type sera décrite exactement comme nous le faisions jusqu'ici, à ceci près que nous allons en modifier le nom pour clarifier le fait qu'il s'agit bel et bien d'une relation d'équivalence ne mettant en jeu que les relations entre instance du type T et aucun autre.

template <class T>
   struct equivalence_un_type
   {
      friend bool operator>(const T &a, const T &b)
         { return b < a; }
      friend bool operator<=(const T &a, const T &b)
         { return !(b < a); }
      friend bool operator>=(const T &a, const T &b)
         { return !(a < b); }
   };

Si vous avez lu et compris l'article préalable à la lecture de celui-ci, cette implémentation devrait vous sembler simple et évidente.

La classe exprimant une relation d'équivalence à deux types

Passer d'opérateurs de comparaison sur deux instances d'un seul type aux mêmes opérateurs sur deux instances de types distincts demande, tel qu'indiqué à quelques reprises plus haut, un trio d'opérations pour chacune des opérations originales.

Le code, en soi, reste simple dans la mesure où le trio original de méthodes (l'opérateur < dans ce cas-ci) a été défini correctement sur les deux types en cause. Pour faciliter la lecture, considérez T comme étant nombre et U comme étant int dans la lecture du code ci-dessous (il est important que U soit le primitif puisque nous définissons ici les opérateurs sur T et T, et il se trouve que pour des primitifs, ces opérateurs sont définis a priori; une solution plus complète tiendrait compte de ces détails).

template <class T, class U>
   struct equivalence_deux_types
   {
      friend bool operator>(const T &a, const U &b)
         { return b < a; }
      friend bool operator>(const U &a, const T &b)
         { return b < a; }
      friend bool operator>(const T &a, const T &b)
         { return b < a; }
      friend bool operator<=(const T &a, const U &b)
         { return !(b < a); }
      friend bool operator<=(const U &a, const T &b)
         { return !(b < a); }
      friend bool operator<=(const T &a, const T &b)
         { return !(b < a); }
      friend bool operator>=(const T &a, const U &b)
         { return !(a < b); }
      friend bool operator>=(const U &a, const T &b)
         { return !(a < b); }
      friend bool operator>=(const T &a, const T &b)
         { return !(a < b); }
   };
Rien de bien complexe, manifestement, mais une extension nécessaire pour certaines classes.

Les sélecteurs d'équivalence

Le vrai noeud de l'histoire est de mettre en place les outils permettant au compilateur de choisir correctement et automatiquement entre la classe equivalence_un_type et la classe equivalence_deux_types à titre de parent pour la classe nombre.

Concrètement, si nombre devait exposer un constructeur paramétrique implicite et une seule version de chacun des opérateurs relationnels, alors il devrait suffire à nombre de dériver (de manière privée, puisqu'il ne s'agit là que d'un détail d'implémentation) de relation::equivalence<nombre>. Si nombre devait plutôt exposer un constructeur paramétrique explicite et avait ainsi besoin de trois versions de chaque opérateur relationnel, alors il devrait lui suffire de dériver (de manière privée) de relation::equivalence<nombre,int>.

La technique permettant au compilateur de générer la bonne classe parent pour le bon type demande de bien comprendre l'idée de types et d'alias (à travers l'opérateur typedef). Notez que l'essentiel de ce qui suit ne concerne pas les programmeurs de classes utilisant l'outil que nous cherchons à mettre en place (une génération implicite de versions efficaces de tous les opérateurs relationnels requis pour un type donné s'il expose correctement l'un d'entre eux, traditionnellement l'opérateur <) et peut ne pas être accessible à plusieurs d'entre eux. La complexité est plus à sa place côté serveur que côté client.

J'exposerai la stratégie pas à pas, du plus abstrait au plus concret. Tout d'abord, nous définirons une classe absolument vide et n'ayant d'autre rôle à jouer que celui d'exister. J'ai nommé cette classe Vide dans le but d'éviter que d'autres n'aient de projets pour son nom, mais il s'agit d'un choix personnel et arbitraire.

class Vide {};

La classe Vide aura pour rôle de servir de deuxième type (quasi fictif) dans le cas où le code client voudrait que sa classe hérite d'une relation d'équivalence à un type. Son rôle est donc celui d'un concept utilitaire pour la mécanique de sélection de la bonne version du parent pour l'enfant défini dans le code client.

Le classe (le struct) selecteur_equivalence<T> définit une classe (un struct) interne générique nommé type<U> dont le type interne valeur correspond à la relation d'équivalence à deux types <U,T> (notez l'inversion de l'ordre des types). Ainsi, selecteur_equivalence<int>::type<nombre>::valeur équivaut à equivalence_deux_types<nombre,int>.

La spécialisation de selecteur_equivalence<T> pour T==Vide définit quant à elle une classe (un struct) interne générique nommé type<U> dont le type interne valeur correspond à la relation d'équivalence à un type pour le type U. Ainsi, selecteur_equivalence<Vide>::type<nombre>::valeur équivaut à equivalence_un_type<nombre>.

Notez donc que si le type T est Vide, alors le type auquel équivaut le type interne valeur définit la relation d'équivalence à un seul type. En retour, que si le type T est autre que Vide, alors le type auquel équivaut le type interne valeur définit la relation d'équivalence à deux types.

Nous venons de mettre en place une mécanique permettant de choisir un type à partir d'un autre type et ce, à la compilation. L'information quant à ces types n'est d'ailleurs utile qu'à la compilation et n'entraîne pas de surcharge à l'exécution.

template <typename T>
   struct selecteur_equivalence
   {
      template <typename U>
         struct type
         {
            using valeur = equivalence_deux_types<U,T>;
         };
   };
template <>
   struct selecteur_equivalence<Vide>
   {
      template <typename U>
         struct type
         {
            using valeur = equivalence_un_type<U>;
         };
   };

Pour simplifier le code, bien que ce ne soit pas absolument nécessaire, nous simplifierons la correspondance de types définie ci-dessus à l'aide d'un type supplémentaire. Ainsi, plutôt que d'écrire selecteur_equivalence<U>::type<T>::valeur, nous pourrons nous limiter à écrire selecteur_equivalence_impl<T,U>::type, ce que la plupart des gens trouveront moins lourd à avaler.

template <typename T, typename U>
   struct selecteur_equivalence_impl
   {
      using type = typename selecteur_equivalence<U>::type<T>::valeur;
   };

Finalement, la classe equivalence<T,U> (où U devient optionnel parce que remplacé, par défaut, par Vide pour indiquer une sélection implicite à la compilation de la version à un seul type) peut être simplement définie comme un dérivé vide et privé du parent approprié.

template <typename T, typename U = Vide>
   class equivalence
      : selecteur_equivalence_impl<T,U>::type
   {
   };

Ce travail est subtil, soit, mais les gains sont considérables : le coût à l'exécution est nul, en temps comme en espace, alors que le gain de souplesse dans l'écriture de code client est important.

Le code client

Le code client ne change pratiquement pas des versions précédentes, ce qui est gage de succès.

#include <iostream>
// ...
class nombre
   : relation::equivalence<nombre,int>
{
   int valeur_;
public:
   nombre()
      : valeur_{}
   {
   }
   explicit nombre(int n)
      : valeur_{n}
   {
   }
   int valeur() const
      { return valeur_; }
   friend bool operator<(const nombre &n, int val)
      { return n.valeur() < val; }
   friend bool operator<(int val, const nombre &n)
      { return val < n.valeur(); }
   friend bool operator<(const nombre &n0, const nombre &n1)
      { return n0.valeur() < n1.valeur(); }
   friend std::ostream& operator<<(std::ostream &os, const nombre &n)
   {
      return os << n.valeur();
   }
};

int main()
{
   using namespace std;
   nombre no{4};
   int val = 3;
   if (n0 < val)
      cout << n0 << "< " << val << endl;
   if (n0 <= val)
      cout << n0 << "<=" << val << endl;
   if (n0 > val)
      cout << n0 << "> " << val << endl;
   if (n0 >= val)
      cout << n0 << ">=" << val << endl;
}

Le code complet

Pour être en mesure d'apprécier le code complet, en voici une version assemblée.

#include <iostream>

namespace relation
{
   //
   // equivalence_deux_types <T,U> définit une relation d'équivalence
   // pour T et U si operator< (const T&, const U&) existe.
   //
   template <class T, class U>
      struct equivalence_deux_types
      {
         friend bool operator>(const T &a, const U &b)
            { return b < a; }
         friend bool operator>(const U &a, const T &b)
            { return b < a; }
         friend bool operator>(const T &a, const T &b)
            { return b < a; }
         friend bool operator<=(const T &a, const U &b)
            { return !(b < a); }
         friend bool operator<=(const U &a, const T &b)
            { return !(b < a); }
         friend bool operator<=(const T &a, const T &b)
            { return !(b < a); }
         friend bool operator>=(const T &a, const U &b)
            { return !(a < b); }
         friend bool operator>=(const U &a, const T &b)
            { return !(a < b); }
         friend bool operator>=(const T &a, const T &b)
            { return !(a < b); }
      };
   //
   // equivalence_un_type <T> définit une relation d'équivalence
   // pour T  si operator< (const T&, const T&) existe.
   //
   template <class T>
      struct equivalence_un_type
      {
         friend bool operator>(const T &a, const T &b)
            { return b < a; }
         friend bool operator<=(const T &a, const T &b)
            { return !(b < a); }
         friend bool operator>=(const T &a, const T &b)
            { return !(a < b); }
      };
   //
   // classe vide, servant à titre d'outil pour reconnaître
   // l'absence de second type générique lors de la compilation
   // pour faire pencher le compilateur vers la version la plus
   // appropriée de la classe equivalence
   //
   class Vide {};
   //
   // concept général de classe menant à l'instanciation du
   // modèle de relation d'équivalence à deux types
   //
   template <typename T>
      struct selecteur_equivalence
      {
         template <typename U>
            struct type
            {
               using valeur = equivalence_deux_types<U,T>;
            };
      };
   //
   // spécialisation quand les deux types n'en font qu'un
   // modèle de relation d'équivalence à deux types
   //
   template <>
      struct selecteur_equivalence<Vide>
      {
         template <typename U>
            struct type
            {
               using valeur = equivalence_un_type<U>;
            };
      };
   //
   // Alias pour le type dont nous avons vraiment besoin
   //
   template <typename T, typename U>
      struct selecteur_equivalence_impl
      {
         using type = typename
            selecteur_equivalence<U>::type<T>::valeur;
      };
   //
   // concept de relation d'équivalence complet: si on l'instancie pour
   // un seul type T, il ne générera que les opérateurs pour T, alors
   // que si on l'instancie pour deux types T et U (ici nombre et int)
   // alors il générera les paires d'opérateurs pour T et U, puis pour
   // U et T, puis pour T et T (ce qui peut être nettement plus économique
   // que de se fier sur le moteur d'inférence de types et sur la
   // construction paramétrique implicite.
   //
   template <typename T, typename U = Vide>
      class equivalence
         : selecteur_equivalence_impl <T,U>::type
      {
      };
}

class nombre
   : relation::equivalence<nombre,int>
{
   int valeur_;
public:
   nombre() noexcept
      : valeur_{}
   {
   }
   explicit nombre(int n) noexcept
      : valeur_{n}
   {
   }
   int valeur() const noexcept
      { return valeur_; }
   friend bool operator<(const nombre &n, const int val)
      { return n.valeur() < val; }
   friend bool operator<(const int val, const nombre &n)
      { return val < n.valeur(); }
   friend bool operator<(const nombre &n0, const nombre &n1)
      { return n0.valeur() < n1.valeur(); }
   friend std::ostream& operator<<(std::ostream &os, const nombre &n)
   {
      return os << n.valeur();
   }
};

int main()
{
   using namespace std;
   nombre n0{4};
   int val = 3;
   if (n0 < val)
      cout << n0 << "< " << val << endl;
   if (n0 <= val)
      cout << n0 << "<=" << val << endl;
   if (n0 > val)
      cout << n0 << "> " << val << endl;
   if (n0 >= val)
      cout << n0 << ">=" << val << endl;
}

Cette technique est applicable à beaucoup d'autres cas, tout comme la classe relation::equivalence (dans ses deux déclinaisons) est applicable à une vaste gamme d'enfants potentiels.


Valid XHTML 1.0 Transitional

CSS Valide !