Préférer la composition à l'héritage

Lorsque nous souhaitons réutiliser du code déjà écrit, trois possibilités s’offrent à nous : soit nous recopions le code que nous voulons réutiliser, soit nous héritons d’une classe offrant les fonctionnalités souhaitées, soit nous composons notre nouvelle classe en utilisant les différents modules à notre disposition.

La première possibilité est bien entendu inutile - elle va même à l’encontre des philosophies de la programmation orientée objet, qui tendent à minimiser l’effort de codage en évitant les duplications de code.

Reste les deux autres possibilités : dans quels cas doit-on utiliser l’une plutôt que l’autre ?

1. Héritage et sous-types

Intéressons-nous d’abord à la première de ces deux possibilités : la réutilisation de code par héritage. Bien qu’à priori fort simple, cette technologie soit en fait complexe à maîtriser, et un certain nombre de règles se doivent d’être respectées.

La première d’entre elle est bien évidemment celle qui a donné naissance au principe de substitution de Liskov. : c’est la règle du respect de l’identité. Par définition, l’héritage n’est souhaitable que s’il existe une relation du type "is-a" entre les deux classes. Cette relation, plus que son aspect mathématique, s’attache à définir le comportement de la classe fille par rapport à la classe mère : un animal est un chien s’il en a les attributs et se comporte comme un chien ou, de manière plus technique, une instance d’une classe A est une instance d’une classe B si le comportement de cette instance ne peut être différencié du comportement d’une instance de B vu de l’extérieur.

Comment vérifier que cette règle, très importante, est respectée ?

On peut tenter de vérifier si l’architecture réalisée respecte les différents principes de programmation orientée objet, et en particulier le principe ouvert/fermé. Dans certains cas, cette technique est bien plus aisée – car il est aisé de se rendre compte si une modification va entraîner des changements dans d’autres parties du code, et en particulier si ces changements sont liés à une gestion du type de données. C’est souvent le cas lorsqu’une interface d’une classe dérivée est plus étendue que l’interface de sa classe de base.

Considérons cet exemple : pour un jeu vidéo, un programmeur encapsule l’API DirectX. DirectX se base sur une notion simple : un device (le point d’entrée du gestionnaire de périphérique de la carte graphique) gère un certain nombre de ressources (textures, vertex buffer, shaders) et permet d’exécuter un certain nombre de commandes utilisant ces ressources pour effectuer par exemple des opérations de rendu 3D. Dans certaines conditions, le device DirectX peut perdre la connexion avec le périphérique sous-jacent – dans ce cas, il prend l’état device lost et il doit être réinitialisé. Toutefois, il ne peut l’être que si les ressources placées dans la mémoire de la carte graphique ont été libérées. Bien évidemment, il faut ensuite les recréer.

On se situe dans le cas où chacune des classes dérivées de resource définit sa propre interface pour recréer les ressources détruites. On voit tout de suite le problème : si la destruction est effectuée par une méthode virtuelle, alors il est probable que cette destruction sera effectuée à bas niveau, à partir de l’interface de la classe resource. Cependant, la recréation de ces ressources ne peut être effectuée que si on connaît le type de la ressource. Le principe OCP est violé. Bien évidemment, ce cas de figure est relativement sensible - il est facile d’imaginer un cas très similaire qui lui ne viole pas le principe OCP : imaginons que la classe resource définissent une méthode virtuelle reset() qui permet de recréer la ressource, et qu’on définisse en outre la classe texture ainsi : Nous nous trouvons de nouveau dans un cas où l’interface de la classe a été étendue (par rapport à celle de sa classe mère). Toutefois, il est plus que probable que cette extension n’affecte pas les cas où la texture est utilisée via l’interface de resource. Dans ce cas, le principe OCP est toujours respecté. En conclusion, l’extension de l’interface peut être un des symptômes de non-respect d’un des principes de programmation orientée objet, mais elle ne peut à elle seul en être la cause. Mais revenons à notre argumentaire principal : d’autres problèmes sont à considérer dès lors qu’on utilise la notion d’héritage. L’un des plus important est cette notion implique une réutilisation en mode "boite blanche" : la classe dérivée a accès aux propriétés non privées de la classe mère, et peut par ce biais affecter son invariant, provoquant ainsi des bugs et autres comportements non souhaités.

2. Composition

On le voit, certaines subtilités ne peuvent être ignorées dès lors qu’on aborde la question de l’héritage. Au regard de ces problèmes, qu’en est-il de la composition ?

Le premier point est une directe opposition à cette réutilisation en mode "boite blanche", puisque la composition implique une réutilisation en mode "boite noire" où seules les informations publiques de la classe réutilisées sont connues et manipulables. L’invariant de celle-ci est donc protégé.

Le second point attrait à la relation qu’on crée entre la classe contenue et la classe contenante : au lieu d’implémenter une relation du type "is-a", c’est une relation du type "has-a" qui est créée. Cela a-t-il une importance ? Pas vraiment. Notre but est avant tout de pouvoir réutiliser proprement du code déjà écrit. Si une relation du type "is-a" s’impose et qu’aucune contre-indication n’est pressentie, il est inutile de tergiverser. Si une relation d’héritage n’est pas souhaitée ou serait plus dommageable qu’utile, seule la relation "has-a" est alors possible - là encore, il est inutile de tergiverser.

On remarque à ce propos que si la notion de sous-type n’est pas respectée, forcer l’héritage est une erreur, au regard des risques de non-respect des principes de POO déjà énoncés. Ainsi, même si la syntaxe du langage ne permet une réutilisation polymorphique d’un code, c’est la logique qui me dicte de ne pas le faire. Pourquoi dans ce cas ne pas utiliser une construction qui m’interdira ensuite de faire entrave à cette logique ? Il devient alors clair que la composition, outre le fait qu’elle sera un choix souvent judicieux, forme aussi un rempart à considérer contre des erreurs d’architecture qui pourrait déboucher sur des problèmes plus vastes.

3. Bilan

Alors, quelle construction dois-je préférer ? Dans la plupart des cas, la composition. L’héritage doit être réservé aux cas qui non seulement lui sont à priori acquis (c’est à dire les cas où transparaît une relation du type "is-a") mais qui en plus respecte la définition d’un sous-type telle qu’exprimée par Barbara Liskov. En outre, la composition permet souvent de simplifier les relations entre les objets, rendant le code moins interdépendant et donc plus versatile : en deux mots, plus réutilisable.