Faut-il interdire l'héritage d'implémentation dans les langages objets ? par Patrick Smacchia

 

 

Introduction. 1

Pré requis. 1

Les problèmes de l’héritage d’implémentation. 1

Un exemple. 1

Rupture de l’encapsulation. 2

Fragilité sémantique. 3

Fragilité syntaxique. 4

Incohérence sémantique. 5

Manque de flexibilité. 5

Code peu lisible et classes fortement couplées. 6

Complexité du flot d’appel 6

Doit-on alors arrêter d’utiliser l’héritage d’implémentation?. 6

Quelles sont les alternatives à l’héritage d’implémentation pour réutiliser du code ?. 7

Composition d’objet + interfaces. 7

Qui appelle qui ?. 7

Et si on devait réécrire notre exemple ?. 8

Une alternative avec les génériques. 10

Conclusion. 11

 

Introduction

Cet article se base sur le constat suivant : La plupart des développeurs considèrent l’héritage d’implémentation (i.e l’héritage de classes) comme la voie royale de la programmation orientée objet pour réutiliser du code. Nous allons donc montrer à l’aide d’un exemple que l’héritage d’implémentation contient de nombreux pièges qui sont difficiles à éviter. Nous allons ensuite nous intéresser à des solutions alternatives au problème de la réutilisation de code.

Pré-requis

Pour une bonne compréhension de cet article, il est nécessaire d'avoir une connaissance des notions suivantes:

Tous ces concepts sont notamment expliqués dans mon ouvrage Pratique de .NET et C# (O’Reilly 2003).

Les problèmes de l’héritage d’implémentation

Un exemple

Supposons que vous développiez un logiciel de monitoring qui effectue périodiquement toutes sortes de tâches, comme par exemple, le ping d’un serveur, le scanning de logs pour produire des statistiques, l’envoie d’une alerte… Supposons que le cahier des charges spécifie que chaque tâche :

·        peut être démarrée si elle n’est pas déjà en cours d’exécution (i.e active),

·        peut être avortée si elle est active,

·        peut renvoyer à tout moment son état d’activation.

Une approche à priori sensée est de faire une classe de base abstraite Task, de laquelle dérive une classe par sorte de tâche (TaskPing, TaskScanLog, TaskSendAlert…).
Voici donc à quoi pourrait ressembler le code des classes Task et TaskPing :

 

using System;

 

public abstract class Task

{

   private bool _bActive;

   public bool bActive{get{return _bActive;}}

   protected virtual void setActive(bool b){_bActive = b;}

   protected abstract void TaskBody();

   public void Execute()

   {

      if( !bActive )

      {

         setActive(true);

         TaskBody();

         setActive(false);

      }

   }

   public abstract void Abort();

}

 

public class TaskPing : Task

{

   public override void Abort(){throw new NotSupportedException();}

   protected override void setActive(bool b)

   {

      base.setActive(b);

      if(b)

      {

         // TODO

         // code pour initialiser la connexion avec le serveur

      }

      else

      {

         // TODO

         // code pour fermer la connexion avec le serveur (ouverture d'une socket...)

      }

   }

   protected override void TaskBody()

   {

      // TODO

      // code pour faire un aller/retour sur le serveur

   }

}


Deux petites remarques s’imposent :

·        La tâche ping a cette particularité qu’elle ne peut être avortée car un aller-retour réseau est considéré comme une opération atomique (d’où l’envoie de l’exception NotSupportedException).

·        Le langage C# ne nous permet pas de différencier le niveau de visibilité des deux accesseurs d’une propriété (d’où la méthode setActive() ). Cette fonctionnalité sera intégrée dans C#2.

 

Ces quelques lignes sont un condensé de ce qu’il ne faut pas faire!!

Et pourtant, quelles proportions d’applications industrielles contiennent ce genre de code ? Le cœur du problème est que l’héritage d’implémentation est souvent considérée comme le saint graal de la programmation objet. Analysons un à un les pièges que recèle l’utilisation de l’héritage d’implémentation.

 

Rupture de l’encapsulation

Au premier abord, on peut avoir l’impression que l’état d’une instance de la classe Task est correctement encapsulé puisqu’il se résume au champ _bActive qui est privé. Cependant l’accesseur d’écriture de _bActive (la méthode setActive()) est protégé, donc accessible hors de la classe Task. L’état de la classe Task n’est donc pas encapsulé. Il serait assez facile pour l’auteur de ce code de justifier ce fait :

·        Les implémentations de la méthode Abort() auront besoin de positionner _bActive à false une fois la tâche avortée.

·        Les classes dérivées de Task peuvent avoir besoin d’exécuter une logique spécifique sur le basculement d’état de la tâche (dans le cas de la tâche ping, initialisation ou fermeture d’une connexion avec le serveur à pinguer). Ainsi la méthode setActive() est virtuelle et par conséquent ne peut être privée.

Ces raisons sont toutes liées au fait que lorsque nous implémentons une classe dérivée, nous avons souvent accès au code de la classe de base. C’est pour cela que l’on qualifie parfois l’héritage d’implémentation de white-box reuse (voire de glass-box reuse si l’accès au code de la classe de base est en lecture seulement).

 

Il existe une autre raison qui nous pousse à casser l’encapsulation d’une classe de base en allant explorer son code. Il est souvent délicat de décider quand le code d’une méthode virtuelle d’une classe dérivée doit appeler l’implémentation de cette méthode au niveau de la classe de base. Concrètement, le développeur en charge de la méthode TaskPing.setActive() peut se demander quel est le moment le plus opportun pour appeler Task.setActive()? Il est alors probable qu’il aille analyser le code de Task.setActive() pour répondre à cette question. On remarque que ce problème ne se pose pas pour les méthodes abstraites. Ainsi, l’utilisation d’interfaces permet de préserver l’encapsulation.

 

Fragilité sémantique

Le sens de la phrase "les classes dérivées de la classe Task peuvent avoir besoin d’exécuter une logique spécifique sur le basculement d’état de la tâche" est flou. En effet, il ne répond ni explicitement ni implicitement à la question suivante : si une même tâche s’exécute plusieurs fois consécutivement, faut-il basculer l’état de la tâche entre chaque exécution ? Ce manque de précision peut entraîner des problèmes de maintenance. Imaginez que la méthode suivante soit rajoutée à la classe Task:

 

   public void MultiExecute(int N)

   {

      if( !bActive )

      {

         setActive(true);

         for(int i = 0; i<N; i++) TaskBody();

         setActive(false);

      }

   }

 

Il se peut que la connexion avec le serveur doive être réinitialisée entre chaque exécution de la tâche ping. Dans ce cas, la méthode MultiExecute() de la classe Task introduit un bug dans la classe TaskPing alors que le code de cette dernière n’a pas changé et, pire encore, n’a même pas été recompilé. Ce problème est connu sous le nom de Semantic Fragile Base Class. De nombreux rapports de recherche présentent diverses solutions qui en plus d’être partielles, sont toutes complexes. Voici quelques règles permettant un usage discipliné de l’héritage d’implémentation pour prévenir la fragilité sémantique, proposées par les chercheurs Clyde Ruby et Gary Leavens (nous énumérons ces règles plus pour vous faire prendre conscience qu’il n’y a pas de solution simple au problème de la fragilité sémantique des classes de bases, que dans l’espoir que vous les appliquiez réellement):

Zone de Texte: Règles s’appliquant à toutes les classes d’une bibliothèque :
·        Une méthode ne devrait jamais accéder directement à un champ d’une classe différente de la sienne.
·        Une méthode ne devrait pas invoquer de méthodes sur un objet dont l’état n’est pas cohérent.
·        La réécriture d’une méthode virtuelle dans une classe dérivée doit raffiner la sémantique de cette méthode.
Règles s’appliquant aux classes dont on peut dériver :
·        Il ne devrait pas y avoir d’appels récursifs entre méthodes.
·        Une méthode ne devrait pas passer en paramètre la référence this lorsqu’elle invoque une méthode.
·        Une méthode ne peut appeler que les méthodes publiques de sa propre classe, ou de ses classes de bases.
·        Une méthode non-publique ne doit pas appeler les méthodes publiques d’une classe différente de la sienne.
·        Dans une méthode, si une variable de type T ne peut prendre toutes les valeurs possibles du type T, alors son domaine de valeur doit être explicité dans un invariant.
Règles s’appliquant aux classes dérivées :
·        Les effets de bords non prévus doivent être évités lorsque l’on réécrit une méthode non publique qui modifie l’état d’un membre de la classe de base.
·        Il ne doit pas y avoir de cycle d’appels impliquant une méthode d’une classe de base.
·        Les invariants d’une classe dérivée doivent englober les invariants visibles des classes de base.
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Fragilité syntaxique

 

Chaque modification possible d’une classe de base se trouve dans une de ces trois catégories :

·        Les modifications qui entraînent des erreurs syntaxiques dans toutes les classes dérivées (Ex : Le nom de la méthode abstraite TaskBody() de la classe Task devient CorpsDeLaTache).

·        Les modifications qui peuvent potentiellement entraîner des erreurs syntaxiques dans une ou plusieurs classes dérivées (Ex : La méthode virtuelle setActive(bool b) se décline en deux méthodes virtuelles setActive() et setInactive()).

·        Les modifications qui n’entraînent aucune erreur syntaxique dans aucune classe dérivée (Ex : L’ajout de la méthode MultiExecute() décrit dans la section précédente).
 

Ainsi, une modification sur une classe de base peut entraîner des erreurs syntaxiques dans les classes dérivées. Il n’existe pas de solution parfaite à ce problème d’erreur syntaxique connu sous le nom de Syntactic Fragile Base Class. En revanche, l’utilisation à bon escient de l’attribut System.Obsolete() du Framework .NET peut en minimiser les nuisances. L’idée est de remplacer temporairement une modification d’une classe de base qui provoque des erreurs syntaxiques, par une ou plusieurs modifications qui n’entraînent pas d’erreur syntaxique. Par exemple : 

Zone de Texte: Modification de la classe Task en prenant en compte le problème de fragilité syntaxique:
 
Etat Initial:
protected virtual void setActive(bool b){_bActive = b;}
 
Etat transitoire et temporaire:
[Obsolete("Cette méthode sera supprimée à compter de la version 3.0. Utilisez les méthodes setActive() et setInactive()")]
protected virtual void setActive(bool b){_bActive = b;}
protected virtual void setActive(){_bActive = true;}
protected virtual void setInactive(){_bActive = false;}
 
Etat final:
protected virtual void setActive(){_bActive = true;}
protected virtual void setInactive(){_bActive = false;}
 

 

Notez que cette pratique ne convient pas à tous les types de modifications syntaxiques de la classe de base (comment l’appliquer au changement de nom de la méthode abstraite TaskBody() ?).

Notez aussi que Java présente un mécanisme sensiblement équivalent avec le tag @deprecated (deprecated = désapprouvé en français).

 

Incohérence sémantique

La mini arborescence constituée par les classes Task et TaskPing recèle une incohérence sémantique. En effet, lorsque la classe Task a été conçue, il semblait évident que toute les tâches pouvaient être avortées. Or, on s’aperçoit qu’une tâche ping ne peut être avortée car c’est une opération atomique. En conséquence, la méthode Abort() ne devrait pas être implémentée par la classe TaskPing. Cependant, le fait que la classe Task présente cette méthode comme abstraite oblige la classe TaskPing à l’implémenter. Nous avons pris ici le choix de lancer une exception. Un autre choix aurait pu être d’attendre la fin de l’exécution d’une éventuelle tâche ping courante.

 

Notez que ce problème est loin d’être réservé aux seuls novices puisqu’il se retrouve dans le design pattern Gof Composite, avec les méthode AddNoeudEnfant() ou RemoveNoeudEnfant() qui n’ont pas à être présentées par la class CNoeudFeuille (consultez votre exemplaire du Gof, il y a même une longue discussion autour des différentes approches pour cohabiter avec ce problème puisqu’il est inhérent à l’implémentation du Composite).

 

Manque de flexibilité

Un autre problème de l’héritage d’implémentation réside dans le fait que les classes dérivées sont couplées à jamais avec l’implémentation de la classe de base. Or, on pourrait imaginer d’autres implémentations pour cette classe de base, par exemple une classe de base qui gère les dates de début et de fin d’exécution de la tâche. Dans ce cas, à l’exécution de l’application, soit toutes les instances de la classe TaskPing hériteraient de ce comportement, soit elles hériteraient du comportement courant, à savoir la gestion d’un booléen représentant l’activité. Ce manque de flexibilité découle du fait que les diagrammes d’héritage de classes sont statiques (ils ne peuvent pas être modifiés à l’exécution de l’application). Ce problème n’est pas une fatalité. Par exemple, les design patterns Décorateur (Gof) ou Plug-In (Fowler) permettent de réutiliser du code tout en choisissant à l’exécution l’implémentation que l’on souhaite utiliser.

 

Notez qu’il existe des efforts dans le milieu de la recherche pour pouvoir rendre dynamique les diagrammes d’héritages d’implémentation mais les retombées industrielles sont (à ma connaissance) nulles.

 

Code peu lisible et classes fortement couplées

A priori, l’ensemble des membres que présente une classe de base à ses classes dérivées est l’ensemble des membres publiques union ensemble des membres protégés. Dans les langages .NET, cette règle est un peu plus complexe puisqu’il existe le niveau de visibilité interne protégé. Rappelons qu’un membre interne protégé d’une classe de base n’est visible d’une classe dérivée que si les deux classes se trouvent dans le même assemblage. Sans même prendre en compte cette dernière spécificité, il est clair que la syntaxe des langages orientés objets actuels ne permet pas une représentation claire de l’interface que présente une classe de base à ses classes dérivées (cette interface est souvent nommée interface spécialisée). Par exemple, il n’est pas évident que l’interface présentée par la classe Task à la classe TaskPing est :

 

bool bActive{get}

void setActive(bool b);

void TaskBody();

void Execute();

void Abort();

 

En outre, le manque de définition explicite de l’interface spécialisée induit un couplage accru entre une classe dérivée et sa classe de base. A l’instar du manque de flexibilité présenté dans la section précédente, ce problème n’est pas une fatalité. Ici aussi, le design pattern Gof Décorateur ne souffre pas de cette faiblesse puisque l’interface présentée pour manipuler du code à réutiliser est spécifiée d’une manière claire et explicite.

 

Complexité du flot d’appel

Enfin, un dernier problème courant de l’héritage d’implémentation est la complexité du flot d’appel des méthodes qu’il implique. Concrètement, il n’est pas évident que sur une instance  de TaskPing, l’appel à la méthode TaskBody() au sein de la méthode Execute() déclenche l’appel à l’implémentation de TaskBody() de la classe TaskPing. Ce problème vient du fait que le polymorphisme à tendance à complexifier les appels internes à une arborescence de classe. En effet, le polymorphisme permet d’effectuer implicitement des appels dans le sens : classes de base vers classes dérivées. Ce fait est connu sous le nom de implicit self-recursion. Notez que la ‘self-recursion implicite’ n’est pas toujours considérée comme problématique puisque par exemple, le design pattern Gof Template Method repose entièrement sur ce principe. Il n’en reste pas moins qu’un tel flot d’appels internes devient vite très difficile à maintenir et à déboguer.

Doit-on alors arrêter d’utiliser l’héritage d’implémentation ?

La rumeur dit que dans un newsgroup, James Gosling (l’inventeur de Java) aurait posté que s’il pouvait revoir un point de Java il enlèverait le mot-clé extends (et donc l’héritage d’implémentation). Néanmoins, le message véhiculé par cet article ne sera pas ‘Mort à l’héritage d’implémentation’. Notez au passage que le Framework .NET (tout comme le Framework Java d’ailleurs) utilise allégrement l’héritage d’implémentation. En outre, de nombreuses implémentations des design pattern Gof ne peuvent se passer de l’héritage d’implémentation (Abstract Factory, Factory, Prototype, Adapter, Bridge, Chain of responsability, Composite, Observer, Template Method, Visitor…). Ces deux arguments montrent bien que OUI, lorsque l’on a constaté que c’est la moins mauvaise des solutions, on doit utiliser l’héritage d’implémentation. Ce n’est pas parce que je peux me couper le doigt avec un couteau que je vais arrêter d’en utiliser ! J’apprend plutôt à m’en servir correctement.

 

On peut remarquer que les design patterns Gof utilisant l’héritage d’implémentation ont deux particularités :

·        Les classes de bases contiennent peu de logique.

·        Cette logique ne concerne pas l’aspect métier des classes, mais l’aspect structurel ou comportemental souhaité (comme quoi, on en revient souvent aux aspects !).

Pourrait-on déduire de ceci des règles telles que ? :

·        N’utilisez l’héritage d’implémentation que dans le cadre des design patterns Gof.

·        N’utilisez pas l’héritage d’implémentation pour réutiliser le code métier.

Ce serait alors ne pas tenir compte de l’expérience des concepteurs des Framework .NET et Java (pour ne citer qu’eux).

Quelles sont les alternatives à l’héritage d’implémentation pour réutiliser du code ?

Composition d’objet + interfaces

Les deux techniques alternatives à l’héritage d’implémentation pour réutiliser du code dans un programme écrit dans un langage orienté objet sont :

·        La composition d’objets : un objet (l’objet encapsulant ou outer object en anglais) en encapsule un autre (l’objet encapsulé ou inner object en anglais). L’objet encapsulant ne doit jamais communiquer à l’extérieur une référence vers l’objet encapsulé. Une conséquence est que l’objet encapsulant est responsable du cycle de vie de l’objet encapsulé. Une autre conséquence est que différents objets encapsulant, éventuellement instances de classes différentes, peuvent utiliser une même implémentation pour leurs objets encapsulés. A l’inverse, une même implémentation d’objets encapsulant peut utiliser différentes implémentations d’objets encapsulés. Il y a donc bien ici possibilité de réutiliser du code, soit du coté de l’implémentation de l’objet encapsulé, soit du coté de l’implémentation de l’objet encapsulant.

·        Les interfaces : Lorsque les interfaces sont utilisées conjointement avec la composition d’objets, on peut alors réutiliser du code tout en bénéficiant du polymorphisme que l’on avait avec l’héritage d’implémentation (i.e, on ne connaît pas exactement l’implémentation que l’on utilise, on sait juste quelle respecte un certain contrat, matérialisé par l’interface en l’occurrence). En outre si une interface est suffisamment explicite, il n’est pas nécessaire d’aller analyser le code de ses implémentations pour l’utiliser correctement. Ce phénomène est connu sous le nom de black-box reuse, en opposition au terme white-box reuse déjà cité.

Notez que ce n’est pas un hasard si lors de la litanie des problèmes de l’héritage d’implémentation, nous avons précisé plusieurs fois que le design pattern Gof Décorateur était une bonne alternative. Le point clé du Décorateur est qu’un objet encapsulant manipule un objet encapsulé au travers d’une interface.

 

Qui appelle qui ?

A ce stade, une question intéressante se pose : le code à réutiliser doit-il être l’implémentation de l’objet encapsulé ou l’implémentation de l’objet encapsulant ? Autrement dit, si l’on devait recoder notre petit exemple, le code de la classe Task serait il l’implémentation de l’objet encapsulé ou de l’objet encapsulant ?

 

La question mérite d’être posée puisqu’à priori, c’est toujours l’objet encapsulant qui manipule l’objet encapsulé. Or, nous l’avons vu, dans le cas de l’héritage d’implémentation, parfois la classe dérivée appelle la classe de base (pensez aux méthodes de la classe de base non virtuelles), parfois la classe de base appelle la classe dérivée (pensez à l’implicit self-recursion). Nous allons le voir dans la section suivante, les deux cas sont possibles.

 

La figure suivante illustre le fait que le flot d’appels s’effectue toujours dans le même sens dans le cas de la composition d’objets, ce qui n’est pas le cas lorsque l’on utilise l’héritage d’implémentation :