L’héritage multiple : nécessaire (peut-être) mais compliqué

L’héritage est l’une des grandes avancées des langages orientés objet. Mais il y a plusieurs formes d’héritages entre classes, dont l’héritage multiple, qui continue de susciter des commentaires passionnés, parfois indignés, dans les milieux spécialisés du codage.

Selon le principe de l’héritage multiple, une classe hérite des propriétés et des méthodes d’autres classes. C’est comme si un enfant avait plusieurs pères ou mères. SI ces classes ne se recouvrent pas, il n’y a pas, a priori, de problème. Mais si certaines de leurs méthodes et propriétés sont présentes dans chacun des parents, il peut y avoir ambiguïté et entraîner les programmeurs dans des abîmes de perplexité. D’autant que cette ambiguïté pourrait ne pas être évidente et n’être détectée qu’à l’exécution, plusieurs mois après…

Avec le déferlement des nouveaux langages, il nous a semblé utile de braquer notre projecteur sur ce concept, car avec la surcharge d’opérateur et la gestion des pointeurs, il constitue souvent un critère de diversité, de ceux qui font la différence, entre les « bons » et les « mauvais ». Ce n’est évidemment pas un cours, simplement une incitation à aller plus loin.

 

L’intérêt de l’héritage multiple

Les avis sont très partagés. Sur le principe, c’est pourtant évident. Si une classe hérite de deux classes-mères, elle en récupère les « contenus », qu’il n’est pas nécessaire de redéclarer. C’est une facilité d’écriture. Mais c’est aussi très logique, car il ne fait que traduire notre processus de pensée, quand nous disons qu’un tout est constitué d’éléments, d’où d’ailleurs la comparaison que l’on fait très souvent entre héritage multiple et composition, au sens codage du terme. Et effectivement, les programmeurs qui ont goûté à la mécanique, sont souvent très frustrés et ils le disent, quand ils doivent passer sur un autre langage qui n’en est (apparemment) pas pourvu.

Mais il y aussi les opposants, ceux qui estiment que c’est un artefact inutile, qui ne fait que provoquer des situations ambigües, telles que le célèbre « diamant de la mort ».

Pour ce qui nous concerne, nous y sommes très favorables, car de notre point de vue, il permet d’écrire un code harmonieux et élégant. Voire simple, ce qui est le « Graal » du programmeur.

 

Le « diamant de la mort »

Le dilemme de l’héritage multiple est symbolisé par le problème du « diamant de la mort ». Ce jeu de l’esprit met en scène 2 classes B et C, qui héritent toutes les deux de A et une autre classe D, qui hérite simultanément de B et de C. Or, si B et C redéfinissent pour leurs besoins, une méthode M de A, en M1 et M2, quelle sera la version de M qui sera retenue pour D. M1 ou M2 ? Le problème se posant de la même manière pour les données (paramètres, variables).

Ce problème est vieux comme le monde et les concepteurs de langages lui ont donné mille solutions et interprétations. Sans vouloir les passer toutes en revue, il peut être utile d’en regarder quelques-unes, histoire de prendre un peu de recul…ce qui ne peut pas nous faire de mal. D’autant que nous sommes dans le train et que nous avons le temps…

Pour résoudre l’incertitude du diamant, Bjarne Stroustrup a introduit dans C++, le concept de classe virtuelle.

 

C++ de Bjarne Stroustrup

Le premier compilateur C++ remonte à 1983. On ne peut donc pas dire que Bjarne Stroustrup, son concepteur, ait été influencé par ce qui se faisait ailleurs, si ce n’est par le Smalltalk d’Adèle Goldberg et Alan Kay, qui lui remonte à 1969.

C++ autorise l’héritage multiple et une classe D peut hériter de B et C, qui elles-mêmes héritent de A, la classe mère, super-classe, grand-mère, comme vous voudrez. Et il peut y avoir confusion sur des valeurs et méthodes implémentées dans A, puis modifiées par B et C. On pourrait décrire le cheminement pour atteindre une propriété ou méthode par l’opérateur de résolution de portée, mais la technique est considérée comme lourde et peu pratique.

La solution qu’a apportée Bjarne est d’utiliser le concept de classe virtuelle. Si A est décrite comme une classe virtuelle dans notre diamant, D va effectivement hériter de B et de C, mais si A est décrite comme une classe virtuelle, D n’héritera pas d’elle. Ce que montre cet exemple (Wikibooks) :

class A {

protected :

      int Data; //Data est une donnée de la classe de base

};

class B : virtual public A

{

protected :

      int Autre_Data; //B hérite de Data et Autre_Data est nouveau

};

class C : virtual public A

{

protected :

      int New_Data ; //C hérite de Data et New_Data est nouveau

};

class D : public B, public C //D n’hérite plus de Data

{…} ;

Ce mécanisme introduit des restrictions supplémentaires, le fait par exemple qu’une classe dérivée d’une classe virtuelle doit en appeler le constructeur explicitement, si celui-ci utilise des paramètres.

Le diagramme de classe UML qui caractérise le problème du diamant et l’obligation de redéfinir avec le JDK 8, une méthode identique héritée de deux interfaces, elles-mêmes implémentées d’une interface « mère ».

 

Java

En Java il n’y a pas d’héritage multiple, mais on peut par contre hériter d’autant d’interfaces que l’on veut, puisque ce sont des classes vides. Ce qui n’est d’ailleurs plus vrai avec Java 8 et 9.

Jusque-là, il n’y avait pas de problème puisqu’une interface, par définition, ne comportait que des contenus vides, qui ne « prenaient » vie qu’à l’implémentation dans une classe réelle. Mais avec java 8, une interface pouvant comporter des méthodes réelles, dites « default », le même problème du diamant va se reposer.

Pour l’éviter, Java 8 a introduit une règle simple, que d’autres langages tels que Kotlin vont reprendre à leur compte.

C’est ainsi que si une interface B implémente une interface A, qui comporte une « default » méthode, il en hérite. De même si une interface C implémente la même interface A, il hérite de la même « default » méthode. Les deux interfaces du SDK 8 sont bien réelles et le programmeur peut parfaitement les redéfinir. De sorte que l’on aura deux implémentations distinctes de la même méthode. On est bien dans le contexte du diamant.

Si une classe D implémente B et C, le JDK 8 (et 9) oblige le développeur à choisir l’interface qui lui conviendra et à redéfinir la méthode en question.

Avec le JDK 8, un autre cas pouvait se produire, celui d’une classe qui implémente plusieurs interfaces, ainsi qu’une classe, dans lesquelles la même méthode est implémentée, dont deux fois en mode « default ». Dans ce cas, c’est la méthode de la classe qui l’emporte. Mais on est sauvés, l’ambiguïté est levée.

On précisera qu’avec le JDK 9, le problème est devenu un peu plus délicat à résoudre, dans la mesure où une interface comportera les mêmes « default » méthodes, mais aussi des méthodes privées. Mais comme une classe ne peut pas hériter une méthode privée appartenant à une interface, il sera vite circonscrit.

 

Comment s’y prend Scala ?

Scala est un langage fonctionnel qui utilise des classes, mais sans interfaces, remplacées par les « Traits ». En fait, il n’y a pas de grandes différences avec les interfaces Java d’avant le JDK 8, si ce n’est que les Traits peuvent comporter des propriétés et des méthodes concrètes.

Scala ne permet pas l’héritage multiple pour les classes, mais par contre une classe peut hériter d’une autre classe et d’autant de « Traits » qu’on le souhaite (comme Java). Toujours comme Java, un « Trait » ne peut pas être instancié.

Pour éviter le problème du diamant, Scala a prévu la notion de « super Trait ». De sorte que si un « Trait » A est défini avec une méthode m, qu’un autre « Trait » B hérite de A dont il redéfinit m et qu’un troisième « Trait » hérite également de A et redéfinit lui-aussi m, on tombe dans le problème du diamant, si une classe D hérite des Traits B et C et ne sait pas choisir entre les m redéfinis.

En fait, c’est très simple. Il suffit de préciser dans D quel est le « Trait » que vous choisissez, que vous faites précéder par « extends », les autres « Traits » se contentant du mot clé « with ».

Ce qui nous donne ce pseudo-code très simple :

trait A avec méthode m

trait B avec m redéfini

trait C avec m redéfini

class D extends C with B {…}

De cette manière, vous précisez que vous voulez que D hérite de la méthode m telle qu’elle a été redéfinie dans C. Il n’y a plus d’ambiguïté.

Rien ne vous empêche ensuite de redéfinir m une nouvelle fois dans la classe D.

 

Comment fonctionne Kotlin ?

On ne peut pas dire que les concepteurs de Jetbrains se soient vraiment cassé la tête pour traiter le problème, car ils ont simplement cherché à ce que leur code soit lisible et compréhensible, même s’il est un peu lourd.

Avec Kotlin, l’héritage multiple de classes est interdit et on retrouve les interfaces, dont certaines méthodes peuvent être implémentées concrètement. On est donc dans le même cas de figure que Java 8, avec un traitement du « diamant » quasi-identique, Kotlin fonctionnant également par « recouvrement explicite » (« explicit overridding »),

 

interface Exemple {

      fun m() {…}

}  //interface Exemple qui implémente la fonction m

interface Exemple_new1 : Exemple {

      override fun m() {…}

} // nouvelle interface qui hérite de Exemple et redéfinit m

interface Exemple_new2 : Exemple {

      override fun m() {…}

} // nouvelle interface qui hérite de Exemple et redéfinit m

class Heritage : Exemple_new1, Exemple_new2 {

      override fun m() {…}

} // il faut redéfinir la méthode héritée pour lever l’ambiguïté

 

C’est effectivement plus lourd, mais efficace.  N’importe qui ayant déjà codé en Java, comprenant en un seul coup d’œil, la syntaxe et sa finalité.

 

Le cas particulier de Golang

Comme nous l’expliquons dans le chapitre qui lui est consacré, Golang n’est pas un langage objet. Sa cible n’est pas le remplacement de Java ou de C++, mais bien de C. Il n’y a donc pas de classes, mais des structures et des interfaces, les objets (pas au sens OO) étant étendus par composition et non pas par héritage. Mais sur le fond, tous ces mécanismes sont un peu cousins. Et il ne faut pas pousser des cris d’orfraie, si on dit que finalement une interface Golang ressemble à une interface Java, puisqu’il s’agit d’une structure générique.

Pour ce qui est du problème du diamant, il peut aussi se poser avec Golang, puisqu’une structure peut être issue de la composition de plusieurs autres structures, qui chacune peut avoir implémenté une même fonction.

Pour lever l’ambiguïté, Google s’est contenté de dire qu’il fallait alors préciser l’identité de la structure dont on va récupérer la fonction.

Si B et C intègrent la même interface, qui comporte une fonction de même nom, celle-ci va faire hurler le compilateur (sélection ambigüe), qui va exiger du développeur qu’il précise la filiation exacte de sa fonction.

A est une interface au sens Golang

B intègre A et une fonction f(), qui est redéfinie

C intègre A et la même fonction f(), qui est redéfinie

D intègre B et C

Pour appeler la fonction f, le développeur doit préciser la filiation : D.B.f() ou D.C.f().

Ce n’est sans doute la solution la plus « fine », car elle oblige le programmeur à bien connaître la cartographie de ses structures, mais elle est relativement efficace.

 

Mixins et Traits à la rescousse de JavaScript et PHP

En devenant un véritable langage objet, PHP en a aussi récupéré quelques-uns de ses inconvénients, dont celui du diamant. Avant la version 7 du langage, l’héritage multiple était interdit. Avec les nouvelles versions, il l’est toujours, mais on peut le simuler en utilisant un artefact très pratique, le « Trait ». Le même que pour Scala.

Depuis Ecmascript 6 (ou 2015), javaScript est devenu, quant à lui, sinon un langage objet, du moins un langage qui permet de manipuler des classes. JavaScript comporte une fonctionnalité d’héritage mais pas d’héritage multiple.

Il existe là aussi, un mécanisme dit de mixin, qui permet de s’en approcher, qui ressemble beaucoup à ce que fait PHP.

Le mixin est une classe destinée à être recomposée avec une autre, pour lui apporter des fonctionnalités supplémentaires, méthodes et propriétés, mais qui n’a pas d’existence propre et ne peut pas être instanciée.

C’est aussi le cas d’un Trait, sauf que les deux notions présentent un certain nombre de différences. En particulier, avec les Traits de PHP, la résolution des conflits se fera de manière explicite (dans le code), grâce au mot clé insteadof, alors qu’avec les mixins, elle se fera de manière implicite.

< ?php

trait Cible

{

public function fonction1() {

      echo "vient de Cible" ;

}

}

trait Autre_Cible

{

public function fonction1(){

      echo "vient de Autre_cible" ;

}

}

class  Cible, Autre_cible

{

//cette classe appelle la fonction fonction1 depuis Cible

//Il faut expliciter cette appartenance

Cible ::fonction1 insteadof Autre_cible ;

//cette instruction précise que fonction1 est prise dans le Trait Cible

?>

 

En JavaScript, on va retrouver à peu près le même mécanisme, sauf que la résolution des conflits de noms est résolue de manière implicite par la liste d’appel des mixins.

 

De la haute voltige

Avec la diversité des langages, il devient difficile de s’y retrouver et d’appliquer les bonnes méthodes.

Pour les langages à héritage simple et rien de plus, le problème est résolu, puisqu’il ne peut y avoir de conflit.

Pour les langages à héritage simple, mais qui font tout pour le simuler, des techniques plus ou moins évidentes, sont introduites, les « protocoles », tels que ceux que nous avons mentionnés et qui nécessitent un peu de pratique, avec les mixins, interfaces et autres Traits.

Ce sera le cas de C#, Java, Smalltalk, Swift d’Apple et donc de PHP. Qui à chaque fois résolvent le problème du « diamant de la mort », avec plus ou moins de légèreté.

Il y a enfin les langages qui nativement acceptent l’héritage multiple, comme C++, beaucoup moins nombreux, ce qui semble montrer que cette pratique n’était peut-être pas la meilleure que l’on puisse imaginer.

Car reconnaissez que parfois c’est de la haute voltige sans filet.