Approche OO – Héritage et design

Ce qui suit est fortement inspiré des textes de Barbara Liskov et de Herb Sutter.

L'héritage, surtout dans sa déclinaison publique, est une relation à très fort couplage. Après tout, l'enfant est (au moins) de la même nature que ses parents, structurellement parlant. On entend par couplage la difficulté de changer quelque chose sans affecter le code client.

Un principe de design parmi les plus importants est que le couplage entre deux entités logicielles devrait être aussi fort que nécessaire, pas plus. La POO, bien appliquée, aide à appliquer ce principe. Si deux entités sont en relation l'une avec l'autre, un couplage plus faible facilite la modification de l'une entité sans affecter l'autre. Ceci allège l'entretien du code et réduit les coûts de développement.

Sachant cela, plusieurs considérations doivent être mises en valeur. Tout d'abord, examinons quelques types de couplage, du plus fort au plus faible.

Pour un objet, l'exposition publique d'un membre est le plus fort couplage envisageable, au sens où tout (tous les sous-programmes, tous les objets, vraiment tout) peut y accéder directement et de manière incontrôlée.

Qui dit accessible ne dit pas seulement modifiable mais bien utilisable. En ce sens, exposer même une constante de manière publique entraîne un cauchemar du point de vue de l'entretien (obligation de tenir à jour les noms et les types).

class X {
public:
   int f() const;
   int val; // hum
};

Il faut donc presque toujours éviter cette clause pour les attributs d'une classe, et la restreindre à certaines méthodes. De même, on voudra restreindre la surface publique d'un objet au minimum pour réduire ce qui sera accessible au code client.

Le mot « presque » ici mérite une explication : puisque le recours à l'encapsulation tient d'abord et avant tout à assurer le respect des invariants du type encapsulé, un type pour lequel aucun invariant ne doit être maintenu peut exposer publiquement des attributs sans que cela n'entraine de préjudice. Notez par contre qu'il faut alors s'assurer que ce choix de n'avoir aucun invariant à garantir doit être une décision de design qui soit pleinement assumée : une fois un attribut exposé publiquement, nous ne pouvons plus revenir en arrière sans briser le code client. Voir [hdAttPub] pour plus de détails.

L'amitié est la plus forte catégorie de couplage autre que l'exposition publique d'un membre, du fait qu'elle donne aux amis, qu'il s'agisse de sous programmes ou de classes, un droit d'accès à tous les membres d'une classe, peu importe leur qualification de sécurité.

Contrairement à certaines croyances dogmatiques, l'amitié peut améliorer la sécurité d'une classe si elle est appliquée avec sagesse.

Qu'il s'agisse d'une fonction ou d'une classe, une amie contribue, de manière extrusive, à l'interface d'une classe.

class X {
   friend class Y;
   friend int f(X);
};
class Y {
   // ...
};
int f(X);

L'exposition protégée d'un membre est, contrairement à ce que plusieurs croient, à peine préférable à l'exposition publique des membres. Les accès incontrôlés de l'extérieur sont alors éliminés mais les enfants (en nombre arbitrairement grand) de la classe lui causent les mêmes problèmes de gestion que le feraient les classes qui ne lui sont pas apparentées.

class X {
protected:
   int f() const;
   int val; // hum
};

En POO, un parent ne connaît pas plus ses enfants que ses clients. Exposer un attribut protégé est donc pratiquement toujours une très mauvaise idée. Il y a par contre plusieurs cas pour lesquels une méthode protégée est une stratégie raisonnable.

L'héritage public est une relation à très fort couplage, du fait que le code client peut tenir compte de la relation existant entre l'enfant et son parent… et, en pratique, le fera. En ce sens, l'enfant devient indissociable de son parent du fait que le monde entier peut tenir compte de leur relation.

class X : public Y {
   // ...
};

L'héritage protégé est une relation à moins fort couplage que l'héritage public mais qui entraîne son lot de problèmes dans l'entretien du code (comme toutes les relations protégées d'ailleurs), au sens où l'enfant devient foncièrement associé à son parent du fait que sa propre descendance est susceptible d'en tenir compte.

class X : protected Y {
   // ...
};

L'héritage privé est une relation à plus faible couplage que les autres héritages du fait que même la descendance de la classe enfant ne peut tenir compte de cette relation, qui devient un simple élément d'encapsulation. Puisque la relation parent/ enfant n'y est connue que de l'enfant, cette relation se rapproche d'une relation de composition (du point de vue du code client, les deux mènent au même résultat).

class X : Y { // privé
   // ...
};

Enfin, les relations de composition, d'agrégation et d'association sont à plus faible couplage encore que la relation d'héritage privé.

Cette affirmation ne s'avère bien entendu vraiment que dans la mesure où ces relations sont cachées dans les sections elles-mêmes marquées privées de l'objet.

class X {
   Y y;
   Z *z;
   // ...
};

Typiquement, lors de la présentation de l'héritage, l'accent est mis sur une vision un peu simplifiée des relations entre objets :

Cette vision simplifiée (pour ne pas dire simpliste) des relations entre objets nous fut raisonnablement utile. Elle peut être reproduite dans des langages commerciaux et répandus mais dont le système de types est sémantiquement moins riche que celui de C++ et couvre les cas les plus importants. Dans la pratique, pour réaliser des designs plus complets et plus solides, il nous faut pousser la réflexion plus loin.

Notez que les méthodes polymorphiques, normalement, ne devraient pas être exposées publiquement. Il est préférable de qualifier une méthode polymorphique de protégée et d'en encadrer les invocations par une méthode publique non polymorphique, tel que proposé par l'idiome NVI.

Ne pas abuser de l'héritage public

Règle générale, qui dit ami, dérivé public, membre public ou membre protégé dit en fait inclus dans la surface visible (dans l'interface) de l'objet, donc connu et utilisable par d'autres. L'héritage public insère donc en quelque sorte une information supplémentaire et visible dans l'interface de l'enfant, même s'il ne s'agit que du simple fait que l'enfant est un cas particulier du parent, car de ce fait, l'enfant devient susceptible d'être exploité comme s'il était un cas particulier de son parent.

Ce constat est porteur de sens. Entre autres :

L'héritage public, visiblement, se prête surtout aux spécialisations polymorphiques. Mêler héritage public et parent non polymorphique est une démarche suspecte

Une bonne maxime pour ne faire le choix de l'héritage public que dans les cas où de choix est judicieux est de penser l'héritage public un peu à l'envers des habitudes : D devrait dériver de B non pas parce que D veut utiliser sa partie B mais bien parce que D veut être utilisé comme un cas particulier de B.

J'ai lu cette sage recommandation à plusieurs endroits, en particulier dans Exceptional C++ par Herb Sutter, qui dit la tirer lui-même de Marshall Cline et Greg Lomow, C++ FAQs, Addison-Wesley, 1995

Penser l'héritage public au sens d'une relation Is-A tel que proposé par le principe de substitution de Barbara Liskov est une bonne pratique si :

Le problème de l'héritage public est qu'on le mêle souvent à des relations semblables mais pas tout à fait conformes (comme Is-Almost-A, ce qui pourrait se traduire par ressemble à un ou par est presque un). C'est là que la sauce se gâte. On peut dépister ces faux amis en se posant les deux questions suivantes :

Cas d'espèce

Le cas des classes Rectangle et Carre, souvent utilisé dans une discussion initiale de l'héritage à cause de son caractère visuel et simplet, est un cas où recourir l'héritage public n'est pas une bonne idée.

En effet, les critères de validité d'un Rectangle et d'un Carre peuvent entrer en conflit les uns avec les autres. Modifier la largeur d'un Carre se voulant cohérent entraîne un effet de bord susceptible d'être inattendu pour qui pense avoir affaire à un Rectangle. En effet :

Un exemple d'un tel bris d'invariant dans quelques langages suit, à titre illustratif :

C++ C# Java
class invalid_argument{};
class Rectangle {
   int largeur_, hauteur_;
   static int valider_dimension(int candidate) {
      return candidate <= 0 ? throw invalid_argument{} : candidate;
   }
public:
   void largeur(int lar) { // piarkkk
      largeur_ = valider_dimension(lar);
   }
   constexpr int largeur() const noexcept {
      return largeur_;
   }
   void hauteur(int hau) { // piarkkk
      hauteur_ = valider_dimension(hau);
   }
   constexpr int hauteur() const noexcept {
      return hauteur_;
   }
   constexpr Rectangle(int lar, int hau)
      : largeur_{ valider_dimension(lar) },
        hauteur_{ valider_dimension(hau) } {
   }
   void dessiner() const noexcept {
      for(int i = 0; i != hauteur(); ++i) {
         for(int j = 0; j != largeur(); ++j) {
            cout << '*';
         }
         cout << '\n';
      }
   }
};
class Rectangle
{
   private int largeur;
   public int Largeur
   {
      get { return largeur; }
      set
      {
         if (value <= 0) throw new ArgumentException();
         largeur = value;
      }
   }
   private int hauteur;
   public int Hauteur
   {
      get { return hauteur; }
      set
      {
         if (value <= 0) throw new ArgumentException();
         hauteur = value;
      }
   }
   public Rectangle(int largeur, int hauteur)
   {
      Largeur = largeur;
      Hauteur = hauteur;
  }
  public void Dessiner()
  {
     for(int i = 0; i != Hauteur; ++i)
     {
        for(int j = 0; j != Largeur; ++j)
        {
           Console.Write("*");
        }
        Console.WriteLine();
     }
  }
}
class Rectangle {
   private int lar, hau;
   public void setLargeur(int lar) {
      if (lar <= 0) throw new IllegalArgumentException();
      this.lar = lar;
   }
   public void setHauteur(int hau) {
      if (hau <= 0) throw new IllegalArgumentException();
      this.hau = hau;
   }
   public int getLargeur() {
      return lar;
   }
   public int getHauteur() {
      return hau;
   }
   public Rectangle(int lar, int hau) {
      setLargeur(lar);
      setHauteur(hau);
   }
   public void dessiner() {
      for(int i = 0; i != hau; ++i) {
         for(int j = 0; j != lar; ++j) {
            System.out.print("*");
         }
         System.out.println();
      }
   }
}
struct Carre : Rectangle {
   void largeur(int lar) { // piarkkk
      Rectangle::largeur(lar);
      Rectangle::hauteur(lar);
   }
   void hauteur(int hau) { // piarkkk
      Rectangle::largeur(hau);
      Rectangle::hauteur(hau);
   }
   constexpr Carre(int dim) : Rectangle{ dim, dim } {
   }
};
class Carré : Rectangle
{
   public Carré(int dim)
      : base(dim, dim)
   {
   }
   public new int Largeur
   {
      get { return base.Largeur; }
      set
      {
         base.Largeur = value;
         base.Hauteur = value;
      }
   }
   public new int Hauteur
   {
      get { return base.Hauteur; }
      set
      {
         base.Largeur = value;
         base.Hauteur = value;
      }
   }
}
class Carré extends Rectangle {
    public Carré(int dim) {
       super(dim, dim);
    }
    public void setLargeur(int lar) {
       super.setLargeur(lar);
       super.setHauteur(lar);
    }
    public void setHauteur(int hau) {
       super.setLargeur(hau);
       super.setHauteur(hau);
    }
}
void vilain(Rectangle &r) {
   r.largeur(r.largeur() * 10);
}
int main() {
   Carr c{ 3 };
   c.dessiner();
   vilain(c);
   c.dessiner(); // oups!
}
class Program
{
   static void Vilain(Rectangle r)
   {
      r.Largeur = r.Largeur * 10;
   }
   public static void Main(string [] args)
   {
      Carré c = new Carré(3);
      c.Dessiner();
      Vilain(c);
      c.Dessiner(); // oups!
   }
}
public class Oups {
   static void vilain(Rectangle r) {
      r.setLargeur(r.getLargeur() * 10);
   }
   public static void main(String ... args) {
      Carré c = new Carré(3);
      c.dessiner();
      vilain(c);
      c.dessiner(); // oups!
   }
}

L'héritage public, dans une situation où une dépendance entre deux classes existe mais ne se manifeste pas au sens opératoire, est une faute de design. Dans le cas du Carre et du Rectangle, il se peut qu'une relation d'héritage protégé ou privé existe, mais si du polymorphisme doit apparaître dans le portrait, alors il est préférable qu'il se situe à un autre niveau (p. ex. : un parent commun Forme, s'il y a une relation opératoire à ce niveau entre les classes Carre et Rectangle, ou encore un parent commun Dessinable si les deux doivent accepter de se dessiner par un appel polymorphique).

Relations plus intimes

Les relations structurelles sont mieux exprimées en termes d'héritage privé ou protégé, du fait que la dépendance entre enfant et parent est alors plus locale. Règle générale, une relation est implémenté en fonction de est mieux représentée par l'héritage privé ou protégé.

Les cas où l'héritage privé ou protégé sont envisagés méritent souvent qu'on se questionne à savoir si une relation à plus faible couplage encore (p. ex. : composition, agrégation) ne serait pas préférable. La réponse à cette question est assez simple : on conservera la relation d'héritage quand il est important que l'enfant puisse au moins se considérer lui-même comme un cas particulier de son parent, ou dans le cas où l'enfant a besoin, dans le cadre de ses fonctions, d'accéder aux membres protégés du parent.

Dans le doute, privilégier les relations à faible couplage (composition, agrégation, association) aux relations à plus fort couplage tend à donner de bons résultats.

Relations à très faible couplage

La programmation générique permet d'exprimer des relations à couplage très faible, soit des relations est implémentable en fonction de. En effet, le code générique n'exploite que des opérations exposées par les entités sur lesquelles il opère, sans nécessairement être conscientisé quant à la nature propre de ces entités.

Lorsque le contrat est plus riche, une option gagnante est de penser un tel algorithme en termes de traits. Ceci le rendra applicable à l'ensemble des types pour lesquels il est raisonnable de le faire.

Lorsque les opérations requises par le contrat d'un type générique ou d'un algorithme générique se limitent à des opérations de construction, de copie, à des opérateurs et à l'application d'opérations sur le type en question, il devient possible de manipuler de manière indifférenciée types primitifs et objets en tant que tels.

Les délégués, typiques du monde .NET, offrent ce type de relation à faible couplage de par leur polymorphisme basé sur la signature, mais à un niveau d'abstraction beaucoup moins élevé du fait qu'ils ne sont pas applicables à tout type. On trouve des forces et des faiblesses analogues chez std::function de C++ et chez les pointeurs de fonctions de C.

En C++, à partir de C++ 20, les concepts amèneront cette relation à très faible couplage – relation ne reposant que sur le respect des opérations contractuellement énoncées, de manière non-intrusive – à un tout autre niveau.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !