Un outil puissant
L’héritage est un concept du célèbre et populaire paradigme de programmation dénommé orienté objet. Ce concept est le moyen qui permet en orienté objet de supporter le polymorphisme, principe qui consiste simplement à faire en sorte d’avoir plusieurs comportements (implémentations) différents derrière un même objet (contrat).
Le polymorphisme est un outil redoutablement efficace en orienté objet. C’est d’ailleurs lorsqu’on travaille dans des langages qui ne disposent pas de ce concept qu’on se rend compte à quel point il est pratique.
Dans la suite de cet article, la notion d’héritage qui est utilisée est celle qui correspond à l’héritage d’état (donc au mot-clé extends sur des classes en Java). La notion d’héritage de comportement (implémentation d’une interface en Java) ne rentre donc pas dans le terme héritage. Certains langages ne distinguent pas ces deux notions mais dans les faits ces deux types d’héritage apparaissent aussi.
Usé et abusé
L’outil puissant qu’est l’héritage est malheureusement utilisé à toutes les sauces. C’est un peu la solution spontanée que choisissent les développeurs pour régler plus ou moins n’importe quel problème, et ce, sans doute, par facilité.
Il est par exemple souvent utilisé comme un moyen de partager du code (l’intention est cependant louable). Prenons un exemple avec deux classes B et C qui ont besoin d’utiliser du code commun. La solution à base d’héritage consiste à placer ce code commun dans une classe A et de faire hériter B et C de A. Les classes B et C ont dans ce cas tous les attributs et méthodes dont ils ont besoin, mais aussi ceux de A (dont ils n’ont pas besoin si ils héritent de A juste pour une question de partage de code). Il s’agit-là clairement un héritage abusif.
Il existe des cas où l’héritage est encore plus abusif. On peut citer par exemple l’utilisation de l’héritage pour contourner le système de portée des langages. Cela consiste à hériter ou implémenter des classes ou interfaces pour pouvoir utiliser leurs membres statiques (méthodes, attributs) sans avoir à les préfixer par le nom de la classe ou de l’interface. Nous sommes nombreux à l’avoir fait mais c’est clairement une mauvaise pratique. D’autant plus qu’il existe des vraies solutions. En Java par exemple, l’import statique sert justement à ne pas avoir à écrire de préfixe.
D’une manière générale, l’héritage est utilisé à bon escient si il sert à faire du polymorphisme. Si B hérite de A, est-ce que ça a vraiment du sens de dire que B est une sorte de A ? A est-il une généralisation de B ? De plus, si une autre classe C hérite aussi de A, peut-on remplacer B par C sans changer complètement la sémantique du code ? Si la réponse à ces questions n’est pas franchement positive, il ne faut sans doute pas utiliser l’héritage. Il s’agit du principe de substitution de Liskov.
Pourquoi ne faut-il pas utiliser l’héritage dans ces cas-là ?
Lorsque l’héritage sert à partager du code, la classe fille expose son état et son comportement mais aussi ceux de sa classe mère. Si l’héritage ne sert pas à faire du polymorphisme (en gros si le comportement de la classe fille n’est pas une simple redéfinition de celui de sa mère), ils ne sont pas les mêmes et cela va à l’encontre du principe de responsabilité unique. Cela conduit en plus à de gros objets (surtout quand il y a plusieurs niveaux d’héritage) dont le fonctionnement global est compliqué. Il faut du courage et de la concentration pour inspecter un tel objet dans un débugger…
L’héritage pose également un problème au point de vue de la testabilité. Considérons une classe A qui n’hérite de rien. Si elle est bien conçue, elle doit être testable. Supposons maintenant que B hérite de A. On ne peut pas tester seulement ce que B apporte à A, sauf en mettant en place des contournements (par exemple en exposant des méthodes qui ne devraient pas l’être). En testant B, on exécute aussi le code de A. Parfois ce n’est pas gênant mais des fois on aimerait bien pouvoir utiliser un mock de A plutôt que A, et l’héritage ne le permet pas.
Souvent, lorsqu’on fait de l’héritage, on a tendance utiliser le mot-clé protected pour exposer une partie de l’implémentation aux classes filles. Cela crée un lien fort au sein de tout l’arbre d’héritage. Cela pose un problème lorsque l’implémentation d’un classe mère change. Toutes ses classes filles doivent également changer. L’évolutivité et la maintenabilité du code prennent alors du plomb dans l’aile. Le futur refactoring qui affectera la classe mère sera certainement douloureux.
Que faire alors ?
L’orienté objet est un paradigme puissant et il dispose de bien d’autres atouts que l’héritage. Les design patterns sont des armes d’une efficacité redoutable pour adresser des situations comme l’héritage abusif (mais aussi bien d’autres). En fait (et c’est peut-être un scoop), ces design patterns ne servent pas que lorsqu’on passe un entretien d’embauche pour se faire bien voir. Il servent aussi dans la vraie vie. Un développeur qui code en orienté objet rencontre au quotidien des situations qui peuvent être parfaitement adressées par des design patterns. Beaucoup de développeurs les connaissent (au moins de nom) mais malheureusement peu s’en servent.
Quelles sont les armes que nous apportent les design patterns pour éviter d’abuser de l’héritage ? Plutôt que d’hériter d’une classe pour pouvoir partager son code, il vaut mieux faire de la composition, avoir une instance de cette classe comme attribut et de lui déléguer le travail. Au lieu d’hériter d’une classe pour surcharger son comportement, on peut la décorer en lui déléguant le comportement principal tout en rajoutant sa touche personnelle.
Le design pattern strategy est très puissant mais malheureusement rarement utilisé. Il fournit une solution pour les cas où on a du polymorphisme avec du code à partager entre les différentes implémentations. Plutôt que de faire une classe abstraite qui contient tout le code commun et une implémentation par classe qui en hérite, il propose de faire une classe qui contient l’ensemble du code commun et une interface qui décrit le comportement qui change entre les implémentations. La classe qui contient le code commun est construite et travaille avec une instance de l’interface qui décrit le comportement spécifique. Il ne reste alors plus qu’à implémenter les différents comportements à travers l’interface en question. Une telle conception permet de limiter le couplage entre le code commun et le code spécifique et surtout de pouvoir tester chaque partie de code indépendamment du reste.
Conclusion
L’héritage, bien que solution de facilité, n’est pas la solution à tous les problèmes en orienté objet. Il existe différentes astuces qui permettent de partager du code de manière bien plus appropriée. Les design patterns sont souvent une solution simple à un problème d’apparence compliqué. On retiendra qu’il faut se poser des questions avant de faire de l’héritage.
Il semble pertinent que certains langages objets comme Java ou PHP distinguent le concept d’héritage de celui de contrat ou d’interface qui lui n’a pas les vices de l’héritage. Dans un tel contexte, il est normal d’interdire l’héritage multiple, l’héritage unique n’ayant déjà pas forcément de sens.
L’image d’en-tête provient de Flickr. Elle représente un héritage historique dont il faut profiter !
Histoire vraie:
lors d’un de mes stages d’ingénieur, j’ai travaillé avec un développeur sénior qui m’a soutenu que comme la classe que je développais était une sorte de Map d’objets, je devais hériter de Map plutôt que d’avoir une Map en interne et de faire un delegate sur le put et le get (qui étaient les seules méthodes relatives à Map que j’avais besoin d’exposer).
Sur le coup, je n’y ai pas cru, ça me semblait tellement invraisemblable qu’un sénior puisse sortir une ânerie pareille
Dans l’exemple que tu cites, il s’agit comme tu le dis d’une ânerie. C’est évidemment un cas d’héritage abusif.
loic > Ce n’est pas le nombre d’années d’expérience qui fait la qualité d’un ingénieur. S’il suffisait de rester les fesses clouées sur une chaise à attendre que ça se passe pour devenir bon, ça se saurait. 😀
Les implementations par défaut de Java 8, et la sémantique de multi-héritage qu’on peut en ressortir abusivement doit te faire un peu peur alors, non?
J’avoue que je quand j’ai commencé à me renseigner sur les nouveautés de Java 8 je suis tombé sur un article parlant de l’arrivée de l’héritage multiple en Java. Sur le coup, j’ai été très surpris et un peu dépité. J’espérais que ce soit une blague.
Et puis je me suis un peu plus renseigné et j’ai compris que le but était simplement d’apporter des implémentations par défaut à des méthodes dans des interfaces. Tu peux alors interagir seulement avec ce que contient le contrat de l’interface. Ils ont apporté cette fonctionnalité pour résoudre un problème qui commençait à devenir pesant dans le JDK. Pour conserver la compatibilité pour ceux qui les implémentent, les interfaces ne pouvaient plus évoluer. Et ça commençait à être de plus gênant, notamment pour l’intégration de l’API stream de Java 8. C’est comme ça que sont nés les comportements par défaut.
Finalement, après y avoir pas mal réfléchi, je trouve que c’est pratique et que ça a du sens. En effet, comme je l’ai expliqué dans l’article, ce qui est gênant dans l’héritage c’est l’héritage d’état plus que l’héritage de comportement. Or, les implémentations par défaut se résument simplement à des questions de comportement par défaut et en aucun cas d’état. Cela permet d’éviter presque entièrement la question du diamant d’héritage, même si elle se pose lorsque deux méthodes ont exactement le même prototype, mais ça se cantonne à des cas très marginaux et ils ont fait le choix d’obliger le développeur à choisir quelle implémentation choisir.
Au final, ce concept a du sens dans certains cas mais je pense que certains développeurs un peu artistes dans l’âme réussiront à détourner ce concept pour faire des choses qui auront moins de sens. A consommer avec modération !
Dariusz Pasciak a publié un article mettant au défi de ne plus utiliser l’héritage que quand c’est vraiment nécessaire, c’est-à-dire presque jamais.