Code de grande personne – enchaînement de parents

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 les articles portant respectivement sur le truc de Barton-Nackman et l'idiome CRTP et sur les sélecteurs de classes parents.

EBCO, en bref

En C++, tout objet doit occuper au moins un byte d'espace mémoire. Tolérer des objets de taille zéro briserait totalement l'arithmétique de pointeurs et l'alignement des tableaux comme des objets.

Quand une classe dérive d'un parent vide, au sens où il ne contient aucun attribut d'instance (que des méthodes), alors le compilateur est autorisé par le standard à ne consommer aucun espace pour ce parent.

En situation d'héritage multiple, plusieurs compilateurs sont plus frileux dans l'application de cette optimisation du fait que donner une taille zéro à deux parents vides placés côte à côte par héritage multiple équivaut à leur donner la même adresse – et pourtant, ils tendent à accepter de faire l'optimisation, avec la même conséquence, lorsqu'un des deux parents dérive de l'autre.

Certains rejettent l'héritage multiple, que ce soit pour des raisons philosophiques (j'ai déjà fait partie de ce camp) ou pour des raisons techniques, en particulier parce qu'une optimisation très spécifique possible avec C++, le Empty Base Class Optimization, ou EBCO, est moins susceptible d'être appliquée quand une classe possède plusieurs parents, et ce même si l'un d'entre eux ne possède aucun attribut et pourrait se voir appliquer l'optimisation en question.

Il peut semble étrange de rejeter un mécanisme sur la base d'une optimisation potentielle très pointue, mais il faut comprendre que :

Que ce soit pour une raison technique ou philosophique, donc, il peut arriver que survienne une situation où l'héritage multiple serait recommandable (ou simplement utile, pour les tenants d'une position philosophique selon laquelle ce n'est jamais recommandable) sans qu'on veuille toutefois y avoir recours.

Si l'héritage multiple est envisagé, c'est que les deux (ou plus) parents prospectifs sont mutuellement indépendants l'un de l'autre. Toute technique permettant de passer d'un modèle à héritage multiple à un équivalent par héritage simple devrait respecter cette caractéristique et éviter de dénaturer l'un ou l'autre de ces parents. Après tout, le passage d'un modèle d'héritage multiple à un modèle d'héritage simple se veut un coup de pouce à l'enfant, pas un changement de fond des liens de dépendance dans le programme.

Le présent article présente donc une technique permettant à un enfant désireux d'obtenir les services d'au moins deux parents mutuellement indépendants sans avoir recours à l'héritage multiple peut déterminer une descendance entre eux mais qui ne s'appliquerait que pour lui – une technique permettant à l'enfant de choisir non pas deux parents mais bien un parent et un grand-parent.

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 <ostream>
class nombre
   : relation::equivalence<nombre>
{
   int valeur_;
public:
   nombre(int n = 0) noexcept
      : valeur_{n}
   {
   }
   int valeur() const noexcept
      { return valeur_; }
   bool operator<(const nombre &n) const noexcept
      { 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;
}

Imaginons maintenant que nous souhaitions aussi ajouter la relation comparable qui définit l'opérateur != en terme de l'opérateur ==. Avec ce que nous savons, cette mise à jour devrait être simple et évidente.

En effet, voici ce qu'on pourrait faire.

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); }
      };
   //
   // comparable <T> définit l'idée de « différent de »
   // pour T si T::operator== (const T&) const existe.
   //
   template <class T>
      struct comparable
      {
         friend bool operator!=(const T &a, const T &b)
            { return ! (a == b); }
      };
}

#include <ostream>

class nombre
   : relation::equivalence<nombre>,
     relation::comparable<nombre>
{
   int valeur_;
public:
   nombre(int n = 0)
      : valeur_{n}
   {
   }
   int valeur() const noexcept
      { return valeur_; }
   bool operator<(const nombre &n) const noexcept
      { return valeur() < n.valeur(); }
   bool operator==(const nombre &n) const noexcept
      { 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;
   if (n0 == n1)
      cout << n0 << "==" << n1 << endl;
   if (n0 != n1)
      cout << n0 << "!=" << n1 << endl;
}

Tout objet C++ occupe au moins un byte d'espace mémoire pour éviter des situations pathologiques comme par exemple celui où un type T ne contiendrait aucun attribut et où on déclarerait un tableau dont les éléments seraient de type T. La taille du tableau serait alors aussi nulle et l'arithmétique de pointeurs, qui permet d'itérer à travers ses éléments, serait faussée.

L'irritant de cette stratégie est qu'elle repose sur l'héritage multiple, que certains compilateurs ont de la difficulté à appliquer efficacement – ceci n'est pas un plaidoyer contre l'héritage multiple, que j'utilise moi-même beaucoup, je tiens à le souligner. Le standard officiel de C++ permet une optimisation bien connue et nommée le Empty Base Class Optimization (EBCO) qui permet, dans certaines situations où une classe parent ne contient aucun attribut (comme dans le cas d'une interface stricte), de considérer la taille de ce parent comme étant nulle (alors que des objets de taille zéro sont normalement interdits en C++).

Cette optimisation est banale sur un objet pris individuellement mais peut rapporter considérablement du point de vue de l'espace occupé par une structure de données (pensons par exemple à un vecteur) contenant plusieurs instances d'un type ainsi optimisé. Occuper moins d'espace signifie aussi une plus grande facilité à exploiter l'antémémoire du processeur et des gains de performance importants – l'article séminal mettant en relief cette optimisation et des techniques pour la réaliser efficacement mentionne un gain de vitesse de l'ordre de 30% pour STL, où plusieurs types sont sujets à en profiter.

Certains compilateurs (de plus en plus rares) ne réalisent pas (ou réalisent mal) l'optimisation EBCO. Il s'avère qu'il semble plus difficile de bien la réaliser en situation d'héritage multiple, du fait qu'il est alors important aux yeux du compilateur de considérer comme différentes les adresses de chacun des parents vides de l'objet optimisé. Ainsi, avec ce qui suit (j'utilise des struct pour éviter d'écrire public partout) :

struct B0
{
   int f() const
      { return 3; }
};
struct B1
{
   int g() const
      { return 4; }
};
struct D :
   B0, B1
{
};

...il y aurait le risque qu'une tentative de considérer un D comme un B0 ou comme un B1 mène à la même adresse, ce qui complique entre autres la gestion de méthodes virtuelles. Cette situation est moins criante dans un contexte d'héritage simple.

Notez bien entendu que dans une situation où les parents ont des attributs, procéder par héritage multiple ou par héritage simple devient une simple question de design.

Pourquoi l'enfant doit pouvoir choisir ses grands-parents

Nous voulons donc permettre à qui le souhaite de dériver par héritage simple plutôt que par héritage multiple lorsque les ancêtres n'ont aucun attribut. Si nous avions pu définir nombre comme dérivant de relation:equivalence<nombre> et relation::equivalence<nombre> comme dérivant de relation::comparable<nombre>, alors nous aurions obtenu une classe nombre profitant de l'héritage de deux strates d'abstraction sans payer une surtaxe à l'héritage multiple du fait que seul l'héritage simple aurait été appliqué.

Remarquez toutefois que les classes relation::equivalence<T> et relation::comparable<T> ne dépendent pas l'une de l'autre et que, dans une situation d'héritage simple à deux niveaux plutôt qu'une situation d'héritage multiple, l'ordre de parenté n'importe pas vraiment. Que nous déterminions que l'une est mère de l'autre ou que nous prenions l'option inverse, cela ne changerait rien au portrait... pour l'enfant qui souhaite dériver des deux.

En contrepartie, remarquez aussi que certaines classes, au sens opératoire, seront comparables sans être équivalentes et que d'autres seront équivalentes sans être comparables. Nous ne voudrons donc pas imposer une hiérarchie stricte entre les abstractions que sont relation::equivalence<T> et relation::comparable<T> puisque plusieurs classes voudront de l'une sans vouloir de l'autre.

Ce que nous voulons, en fait, est une stratégie permettant à certains enfants de choisir à la fois leur parent et leur grand-parent (et ainsi de suite, récursivement, si cela semble nécessaire). Cela permettrait le niveau de souplesse requis ici sans entraîner de conséquences néfastes sur l'ensemble des classes sujettes à dériver de l'un ou l'autre des parents.

L'argumentaire pour permettre à un enfant de choisir ses parents indirects comme directs est à peu près le même que celui en soutien à l'héritage multiple: cela permet une plus grande flexibilité dans le design des classes dérivées et mène à des structures plus appropriées et à plus faible couplage que des hiérarchies pensées strictement à partir d'héritage simple au sens strict.

La stratégie

La stratégie à appliquer pour permettre à un enfant de choisir non seulement un parent immédiat mais aussi le parent de ce parent (et ainsi de suite, récursivement) est fort simple et repose sur le Curiously Recurring Template Pattern (CRTP). Elle s'exprime comme suit :

namespace relation
{
   //
   // Une base vide pour arrêter (éventuellement) la
   // récursion de parents
   //
   class Vide {};
   //
   // equivalence <T> définit une relation d'équivalence
   // pour T si T::operator< (const T&) const existe.
   //
   template <class T, class B = Vide>
      struct equivalence : B
      {
         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); }
      };
   //
   // comparable <T> définit l'idée de « différent de »
   // pour T si T::operator==(const T&) const existe.
   //
   template <class T, class B = Vide>
      struct comparable: B
      {
         friend bool operator!=(const T &a, const T &b)
            { return !(a == b); }
      };
}

#include <ostream>

class nombre
   : relation::equivalence<nombre, relation::comparable<nombre> >
{
   int valeur_;
public:
   nombre(int n = 0) noexcept
      : valeur_{n}
   {
   }
   int valeur() const noexcept
      { return valeur_; }
   bool operator<(const nombre &n) const noexcept
      { return valeur() < n.valeur(); }
   bool operator== (const nombre &n) const noexcept
      { 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}, ni{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;
   if (n0 == n1)
      cout << n0 << "==" << n1 << endl;
   if (n0 != n1)
      cout << n0 << "!=" << n1 << endl;
}

Remarquez les vertus de cette approche :

Une stratégie à retenir.

Enchaînement de parents en tant que technique d'optimisation

Une technique fréquemment rencontrée pour spécialiser une méthode polymorphique est d'offrir chez un parent une méthode publique, destinée à être appelée par ses clients, et de faire en sorte que cette méthode délègue le travail à une méthode protégée qui sera spécialisée par les enfants. Cela permet de mieux encadrer le travail des enfants tout en offrant le dynamisme inhérent au polymorphisme.

//
// peu importe ce qu'elles font
//
void DireBonjour();
void DireAuRevoir();

//
// Coucou est une interface déléguant les
// invocations
//
class Coucou
{
protected:
   virtual void deleguer() = 0;
public:
   void exprimer()
      { deleguer (); }
};

class Debut
   : public Coucou
{
protected:
   void deleguer()
      { DireBonjour(); }
};

class Fin
   : public Coucou
{
protected:
   void deleguer()
      { DireAuRevoir(); }
};

void Salutations(Coucou &c)
{
   c.exprimer(); // appel polymorphique
}

int main()
{
   Debut d;
   Fin f;
   d.exprimer(); // appelle DireBonjour()
   f.exprimer(); // appelle DireAuRevoir()
   Salutations(d);
}

Le polymorphisme est un recours important (et nécessaire) dans un cas où les types qui seront éventuellement utilisés ne sont pas connus au moment de la compilation, mais il y a un coût aux invocations polymorphiques (une indirection supplémentaire à l'appel et l'impossibilité de réaliser certaines optimisations faute de savoir a priori quelles seront les méthodes véritablement appelées).

Dans certains cas, surtout si des stratégies de programmation générique sont appliquées, on peut obtenir un niveau de généricité semblable à celui obtenu par polymorphisme tout en évitant le coût en performance qui en découle. L'injection de parent nous montre la voie.

//
// peu importe ce qu'elles font
//
void DireBonjour();
void DireAuRevoir();

//
// Debut n'a plus ni parent, ni méthode virtuelle
//
class Debut
{
protected:
   void deleguer()
      { DireBonjour(); }
};

//
// Fin n'a plus ni parent, ni méthode virtuelle
//
class Fin
{
protected:
   void deleguer()
      { DireAuRevoir(); }
};

//
// Coucou est une interface déléguant les
// invocations, mais ce n'est plus un parent!
// Son parent doit toutefois exposer deleguer()
//
template <class Parent>
   class Coucou
      : public Parent
   {
   public:
      void exprimer()
         { deleguer(); }
   };

template <class T>
   void Salutations(T &c)
   {
      c.exprimer(); // appel direct
   }

int main()
{
   Coucou<Debut> d;
   Coucou<Fin> f;
   d.exprimer(); // appelle DireBonjour()
   f.exprimer(); // appelle DireAuRevoir()
   Salutations(d);
}

En procédant ainsi, il devient possible d'obtenir un niveau de généricité à la compilation qui soit semblable à celui obtenu par polymorphisme mais sans en payer le prix. Remarquez au passage l'inversion de responsabilité appliquée dans le design: ceux qui étaient enfants par polymorphisme sont projetés au rang de parent par généricité.


Valid XHTML 1.0 Transitional

CSS Valide !