Upload
vukhue
View
221
Download
0
Embed Size (px)
Citation preview
La POO et le secret
de l’héritage
Julien E. Harbulot | [email protected]
Février 2015
Depuis la popularisation du paradigme de programmation fonctionnel, la programmation orientée
objet (POO) est de plus en plus critiquée. Ces critiques reposent toutefois sur une utilisation
inappropriée de la POO, et surtout de l'héritage, dont le rôle semble mal compris.
Cet article fait le point sur l'héritage et sur son rôle vis-à-vis du polymorphisme. Nous verrons que
la POO n'est pas morte, et qu'elle permet de réutiliser du code existant d'une façon inattendue.
Sommaire
Qu’est-ce que l’héritage ? 3
Une utilisation encore trop courante : l’héritage d’implémentation 4
Cas pratique 4
L’héritage d’implémentation en échec 7
La véritable raison d’être de l’héritage : l’héritage d’interface 8
Cas pratique 8
Le polymorphisme 9
L’héritage d’interface et le système de type 10
L’héritage d’interface tient ses promesses 11
L’héritage d’interface permet d’inverser les dépendances 11
L’héritage, au-delà des interfaces 13
Le principe de substitution de Liskov 13
Cas pratique 13
L’héritage n’exprime pas la relation « est un » 14
Pour aller plus loin 14
La bonne méthode pour réutiliser des objets existants : la composition 15
L’héritage d’implémentation est source de rigidité 15
La composition permet d’utiliser l’inversion de dépendance 15
Les méthodes de transfert et la composition 16
POO et réutilisation de code : en bref 17
La POO et le secret de l’héritage | Julien E. Harbulot
Page 3 sur 17
Qu’est-ce que l’héritage ?
L’héritage est un mécanisme de la programmation orientée objet (POO) qui permet de lier deux
classes, que l’on appelle classe-mère et classe-fille. Un tel lien entre deux classes a les
conséquences suivantes :
• la classe-fille dispose de toutes les méthodes et de tous les attributs de portée public et
protected de la classe-mère, et peut les utiliser comme s’il s’agissait des siennes,
• le type de la classe-fille peut être utilisé partout où celui de la classe-mère peut l’être,
• certaines méthodes de la classe-mère peuvent être ré-écrites dans la classe-fille. Ces
méthodes sont appelées méthodes virtuelles, et le mécanisme permettant une telle
modification s’appelle le polymorphisme.
L’héritage permet de réutiliser du code existant, de limiter la duplication, et donc de simplifier la
maintenance du code.
Nous verrons néanmoins que les méthodes pour y parvenir ne sont pas les premières auxquelles
l’on pense. En effet, il peut être utilisé de deux façons différentes :
• pour hériter de l’implémentation d’une classe existante, et ainsi réutiliser son code. On parle
alors d’héritage d’implémentation,
• pour hériter de l’interface d’une classe existante dans le but de permettre à la classe-fille d’être
substituée à la classe-mère de façon polymorphique. Cette utilisation s’appelle héritage
d’interface et rend possible la ré-utilisation de code d’une application qui manipule des
instances de la classe-mère en lui faisant manipuler des instances de la classe-fille, sans que
ce dernier n’ait besoin d’être modifié.
La POO et le secret de l’héritage | Julien E. Harbulot
Page 4 sur 17
Une utilisation encore trop courante : l’héritage d’implémentation
L’héritage est parfois utilisé comme outil pour factoriser du code entre les objets. Lorsque plusieurs
objets utilisent le même code, une solution possible pour éviter la duplication est de placer ce code
dans une classe-mère commune : c’est l’héritage d’implémentation.
Cas pratique
Voici un exemple où l’on a factorisé le code commun aux classes Peugeot et Renault dans une
classe-mère appelée Voiture.
class Voiture{ protected: Roue roue_avant_gauche; Roue roue_avant_droite; Roue roue_arriere_gauche; Roue roue_arriere_droite; public: void tourner_a_gauche() { roue_avant_gauche.pivoter_a_gauche(); roue_avant_droite.pivoter_a_droite(); } /* et autres méthodes similaires. . . */ void accelerer() { roue_avant_gauche.tourner_plus_vite(); roue_avant_droite.tourner_plus_vite(); }
La POO et le secret de l’héritage | Julien E. Harbulot
Page 5 sur 17
/* et autres méthodes similaires. . . */ }; class Peugeot : public Voiture{ public: string nom_du_modele() { return « Peugeot 204 »; } // etc. }; class Renault : public Voiture{ public: string version() { return « Renault 12 Gordini »; } // etc. };
Dans l’exemple ci-dessus, les classes Peugeot et Renault partagent du code qui a été factorisé
dans la classe-mère Voiture, et il est possible de faire appel à ce code de façon transparente : Peugeot ma_voiture; string mon_modele = ma_voiture.nom_du_modele(); ma_voiture.tourner_a_gauche(); // utilisation du code de la classe-mère ma_voiture.accelerer(); // utilisation du code de la classe-mère
Continuons notre exemple, et supposons désormais que l’on décide d’améliorer notre application
en ajoutant de nouveaux modèles : une Twingo à propulsion (arrière), et une Alpine A110 à
propulsion (arrière) également.
Comme nous sommes partis du principe que la classe Voiture était à traction (avant), il faut la
modifier pour extraire le code correspondant à cet aspect et créer deux classes distinctes
(VoitureTraction et VoiturePropulsion) afin de factoriser le code responsable de la gestion
de l’accélération.
class Voiture{ // Nous enlevons le code qui s’occupe d’accélérer. protected: Roue roue_avant_gauche; Roue roue_avant_droite; Roue roue_arriere_gauche; Roue roue_arriere_droite; public: void tourner_a_gauche() { roue_avant_gauche.pivoter_a_gauche(); roue_avant_droite.pivoter_a_droite(); } /* et autres méthodes similaires. . . */ }; class VoitureTraction : public Voiture { // à l’avant public: void accelerer() { roue_avant_gauche.tourner_plus_vite(); roue_avant_droite.tourner_plus_vite(); } /* et autres méthodes similaires. . . */ }; class VoitureTraction : public Voiture { // à l’arrière public: void accelerer() { roue_arriere_gauche.tourner_plus_vite(); roue_arriere_droite.tourner_plus_vite(); } /* et autres méthodes similaires. . . */ }; class Peugeot : public VoitureTraction{ public: string nom_du_modele() { return « Peugeot 204 »; } // etc. };
La POO et le secret de l’héritage | Julien E. Harbulot
Page 6 sur 17
class Renault : public VoitureTraction{ public: string version() { return « Renault 12 Gordini »; } // etc. }; class Twingo : public VoiturePropulsion{ // etc. }; class Alpine : public VoiturePropulsion{ // etc. };
Le code commun à Twingo et Alpine est bien factorisé dans la classe VoiturePropulsion,
tandis que le code partagé par Peugeot et Renault est dans la classe VoitureTraction.
Remarquons que le changement apporté à la classe Voiture nécessite une re-compilation
de toutes ses classes filles.
Notre application fonctionne correctement, et nous décidons maintenant de rajouter un modèle
supplémentaire : Le 4x4 Citroën C4 AirCross, qui est à traction et à propulsion…
Traction et Propulsion ? Mais alors de quelle classe devra-t-il hériter ? VoitureTraction ou
VoiturePropulsion ? Les deux ?
- Si le 4x4 hérite des deux classes, alors il sera doté de 8 roues ! En effet, 4 sont détenues par la
classe VoitureTraction, et 4 par la classe VoiturePropulsion.
Non seulement une telle situation gaspille de la mémoire car la moitié des roues sont superflues,
mais c’est aussi une source de complexité inutile qui oblige à jongler entre les attributs des deux
classes mères.
Cas particulier : si vous programmez en C++, vous savez peut-être que ce langage offre la
possibilité d’utiliser un héritage spécial entre la classe VoitureTraction et la classe Voiture,
que l’on appelle l’héritage virtuel. Néanmoins, l’utilisation de l’héritage virtuel est couteuse en
performance, et ne constitue pas une solution viable car cela diminuerait les performances de
toutes les classes qui héritent de VoitureTraction et pas seulement celles de notre classe 4x4.
- Plutôt que de faire hériter notre 4x4 des deux classes VoitureTraction et VoiturePropulsion,
nous pourrions ne le faire hériter que de la classe VoitureTraction, et réécrire les méthodes
d’accélération pour ajouter la gestion des roues arrières. Cependant, cette solution n’est pas
satisfaisante car cela revient à dupliquer le code de la classe VoiturePropulsion à la main, or
c’est justement ce que nous voulons éviter !
Dans tous les cas, nous sommes bloqués : l’héritage d’implémentation ne remplit pas ses
promesses et la duplication de code semble inévitable.
La POO et le secret de l’héritage | Julien E. Harbulot
Page 7 sur 17
L’héritage d’implémentation en échec
On voit ainsi que l’héritage d’implémentation est maladroitement utilisé dans le but de :
• réutiliser du code existant dans de nouvelles classes,
• factoriser du code commun à plusieurs classes dans une classe-mère commune…
…et que son utilisation entraine les inconvénients suivants :
• un couplage fort entre les deux classes qui oblige la classe-fille (et donc toute l’application
qui en dépend) à recompiler à chaque changement de la classe-mère,
• des hiérarchies complexes et impossibles à maintenir qui rendent la duplication de code
inévitable.
C’est justement pour limiter l’utilisation de l’héritage d’implémentation que l’adage « préférer la
composition à l’héritage » s’est répandu ; et nous verrons prochainement que la composition,
lorsqu’elle est utilisée de pair avec le polymorphisme, permet de réutiliser le code d’objets
existants de façon satisfaisante.
En clair, nombreux sont ceux qui voient dans l’héritage un moyen de réutiliser
des objets existants en s’épargnant d’avoir à écrire des méthodes de
transfert[1]. Mais c’est oublier que l’héritage a pour unique vocation de
permettre la réutilisation du code client via le polymorphisme, dont il est le
vassal, comme nous allons le voir.
Il est donc bien question d’une mauvaise compréhension du rôle de l’héritage,
dont l’utilisation dénaturée va à contresens des objectifs de la POO et produit
du code rigide, qui entraine :
• un couplage fort, inutile entre deux classes,
• des hiérarchies trop complexes,
• et à terme : de la duplication de code.
[1] voir chapitre sur la composition
La POO et le secret de l’héritage | Julien E. Harbulot
Page 8 sur 17
La véritable raison d’être de l’héritage : l’héritage d’interface
Comme nous l’avons vu dans la partie précédente, l’héritage ne permet pas de réutiliser des objets
existants. Il a en fait été conçu pour accompagner le polymorphisme dans le but de permettre la
réutilisation d’un autre type de code : le code d’application, c’est à dire le code qui utilise nos
objets.
Cas pratique
Afin d’illustrer l’intérêt du polymorphisme et de l’héritage d’interface, voici un exemple où nous
sommes chargés de l’écriture d’une fonction crypter_fichier capable d’encrypter un fichier,
selon un algorithme accessible depuis la fonction crypter_string : string crypter_string(string input); //fonction fournie void crypter_fichier(string adresse_du_fichier){ FILE fichier_source = ouvrir_fichier( adresse_du_fichier ); string contenu = lire_fichier( fichier_source ); string nouveau_contenu = crypter_string( contenu ); effacer_fichier( fichier_source ) ecrire_dans_fichier( fichier_source, nouveau_contenu ); }
La POO et le secret de l’héritage | Julien E. Harbulot
Page 9 sur 17
Quelques jours plus tard, nous sommes chargés d’écrire une nouvelle fonction capable de crypter des fichiers situés sur le réseau : void crypter_fichier_web(string adresse_web){ WEBFILE fichier_source = obtenir_fichier_distant( adresse_web ); string contenu = lire_fichier_web( fichier_source ); string nouveau_contenu = crypter_string( contenu ); WEBFILE nouveau_fichier = creer_avec_contenu( nouveau_contenu ); ecraser_fichier_distant( adresse_web , nouveau_fichier ); }
Remarquons que l’algorithme de cette seconde fonction est en tout point similaire à celui de la
première : ouvrir le fichier source ; calculer le contenu crypté ; remplacer le fichier source.
Malheureusement, à cause de détails liés à la nature des fichiers, il nous est impossible de
réutiliser notre fonction initiale.
Condamnés à réécrire la même fonction encore et encore ?
Heureusement non, grâce au polymorphisme, qui permet de réutiliser la même fonction dans les
deux cas…
Le polymorphisme
Le polymorphisme permet de manipuler deux objets qui se comportent de la même façon sans
avoir à les distinguer. Par exemple, nous aimerions pouvoir écrire une application capable de
crypter tout type de fichier, en faisant abstraction des détails nécessaires à leur manipulation.
Or, afin d’être en mesure de manipuler deux objets différents de la même façon, il est nécessaire
que ces deux objets comprennent, et soit capables d’exécuter, les mêmes instructions. Cela veut
dire que ces objets doivent mettre à disposition les mêmes méthodes dans leurs interfaces
respectives.
Par exemple, les objets WebFile et DiskFile pourraient proposer les méthodes suivantes :
• ouvrir( adresse )
• obtenir_contenu()
• ecraser_contenu_avec( nouveau_contenu )
class WebFile{ public: WebFile(string adresse); string obtenir_contenu(); void ecraser_contenu_avec( string nouveau_contenu ); }; class DiskFile{ public: DiskFile(string adresse); string obtenir_contenu(); void ecraser_contenu_avec( string nouveau_contenu ); };
Il serait alors très simple de réutiliser le même code d’application pour manipuler indifféremment
ces deux objets :
void crypter_fichier( UnType? fichier_source ){ string contenu = fichier_source.obtenir_contenu(); string nouveau_contenu = crypter_string( contenu ); fichier_source.ecraser_contenu_avec( nouveau_contenu ); }
La POO et le secret de l’héritage | Julien E. Harbulot
Page 10 sur 17
Cependant, un problème se pose pour les utilisateurs de langages statiquement typés tels C++ et
Java : quel type la fonction crypter_fichier doit-elle accepter en argument ? Si la fonction
demande un argument de type WebFile, il sera impossible de l’utiliser avec des instances de
DiskFile, et réciproquement.
La solution réside dans l’utilisation de l’héritage d’interface, qui définit un contrat, passé par un
objet vis-à-vis de ses utilisateurs.
L’héritage d’interface et le système de type
L’héritage d’interface est utilisé dans les langages statiquement typés (C++, Java, etc.) pour
satisfaire aux contraintes imposées par le système de type. Il n’est d’ailleurs a priori nécessaire
que dans ces systèmes.
Il permet en effet à différentes classes de déclarer qu’elles remplissent un même contrat vis-à-vis
de leurs utilisateurs, et donc qu’elles peuvent être interchangées.
L’héritage d’interface met en scène deux acteurs :
• une classe-mère responsable de définir le contrat, on l’appelle Interface,
• et une classe-fille qui s’engage à respecter le contrat énoncé.
Dans notre étude de cas, l’héritage d’interface est la clef qui permet de réutiliser la fonction
crypter_fichier avec différents types de fichiers :
class File{ // Déclaration de l’Interface public: virtual string obtenir_contenu() = 0; virtual void ecraser_contenu_avec( string nouveau_contenu ) = 0; }; class DiskFile : public File{ /* Implémentation spécifique pour les fichiers locaux */ }; class WebFile : public File{ /* Implémentation spécifique pour les fichiers web */ }; void crypter_fichier( File& fichier_source ){ string contenu = fichier_source.obtenir_contenu(); string nouveau_contenu = crypter_string( contenu ); fichier_source.ecraser_contenu_avec( nouveau_contenu ); }
Remarquons qu’il est très simple de faire évoluer ce code pour prendre en compte les fichiers
situés sur un DVD, puisqu’il suffit d’ajouter une classe DVDFile, que l’on pourra utiliser avec la
fonction crypter_fichier sans avoir besoin de la modifier.
Remarquons de plus que la relation « A hérite de B » devrait être lue comme « A spécialise B »,
ou encore comme « A injecte son code dans B ».
En effet, on peut considérer l’Interface File comme une classe :
• qui n’a pas d’implémentation au moment de la compilation,
• et qui utilise le code d’une de ses classes filles (DiskFile, WebFile, etc.) au moment de
l’exécution.
La POO et le secret de l’héritage | Julien E. Harbulot
Page 11 sur 17
L’héritage d’interface tient ses promesses
La réutilisation de code est ainsi rendue possible par la POO, qui propose des mécanismes
permettant à du code existant de manipuler de façon générique différents objets, à travers une
même interface.
Les avantages d’une telle approche sont multiples, car non seulement la POO limite la
duplication de code — comme nous l’avons vu avec l’exemple de la fonction crypter_fichier
— mais elle rend aussi possible l’extension des fonctionnalités du code existant en lui
permettant de manipuler de façon transparente de nouvelles classes (telles DVDFile).
Ces mécanismes s’utilisent de pair pour répondre à différentes contraintes :
• le polymorphisme permet l’utilisation générique de différents objets, et donc la
réutilisation d’un même code d’application avec ces objets ;
• en parallèle, l’héritage d’interface rend possible la mise en oeuvre du polymorphisme
dans les langages statiquement typés. Son utilisation permet d’informer le compilateur qu’une
classe respecte une Interface donnée, et donc qu’elle peut être utilisée de façon
polymorphique.
En résulte un code qui est à la fois :
• capable de bénéficier des vérifications apportées par le système de type ;
• ouvert à l’extension, et fermé à la modification.
L’héritage d’interface permet d’inverser les dépendances
Si la POO rend possible l’écriture d’un code souple, c’est parce qu’elle permet d’inverser les
dépendances de compilation et d’exécution ; c’est à dire que que le code qui manipule un objet
peut être compilé indépendamment de cet objet.
En d’autres termes, la POO permet de dissocier :
• le fait que la fonction crypter_fichier dépend des classes qu’elle manipule lors de
l’exécution (par exemple, elle peut manipuler la classe DVDFile),
• et le fait qu’elle puisse être compilée avant même que ces classes ne soient écrites (par
exemple, la classe DVDFile peut avoir été écrite et compilée après la fonction
crypter_fichier) !
Les schémas suivants illustrent l’inversion de dépendance rendue possible par l’utilisation
conjointe de l’héritage et du polymorphisme :
Schéma 1 : Sans polymorphisme, l’application (A) dépend de l’objet (B) qu’elle utilise.
En conséquence, toute modification apportée à (B) oblige une re-compilation de (A).
La POO et le secret de l’héritage | Julien E. Harbulot
Page 12 sur 17
Schéma 2 : Grâce au polymorphisme, l’application (A) ne dépend plus de l’objet (B) qu’elle utilise, mais seulement
d’une Interface (I). L’implémentation de (B) peut changer sans que (A) n’ait besoin d’être re-compilée.
Remarquons que l’inversion de dépendance n’a pas pour seul avantage de permettre l’extension
du code existant. Cette dernière permet aussi de modifier l’implémentation des objets
existants sans que cela n’impose une re-compilation du code qui les utilise.
En effet, étant donné que le code d’application ne dépend que d’une Interface, libre au
programmeur de modifier en secret la partie privée de ces objets dans laquelle résident les détails
de leur implémentation. Cette dichotomie entre interface publique d’une part et implémentation
privée d’autre part s’appelle l’encapsulation et constitue le troisième fer de lance de la POO.
En clair, la POO repose sur les trois notions fondamentales suivantes :
• l’héritage d’interface,
• le polymorphisme,
• et l’encapsulation,
qui, lorsqu’elles sont utilisées conjointement, rendent possible l’écriture de code :
• facilement réutilisable,
• ouvert à l’extension, mais fermé à la modification.
L’héritage est donc bien le vassal du polymorphisme, dont il rend l’utilisation possible au sein des
langages statiquement typés.
La relation « A hérite de B » permet non seulement d’informer le compilateur que A présente la
même interface que B, mais plus encore, il s’agit d’un contrat passé par la classe-fille A envers ses
utilisateurs.
Ce contrat établit un principe de substituabilité qui dépasse la simple déclaration d’interface
; c’est pourquoi, contrairement à la croyance populaire, la relation « A est un B » n’est pas
synonyme de « A hérite de B ».
La POO et le secret de l’héritage | Julien E. Harbulot
Page 13 sur 17
L’héritage, au-delà des interfaces
Le principe de substitution de Liskov
“What is wanted here is something like the following substitution property: If for each object o1 of
type S there is an object o2 of type T such that for all programs P defined in terms of T, the
behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.”
Barbara Liskov, Data Abstraction and Hierarchy, SIGPLAN Notices, 23,5 (1988).
L’héritage est un contrat bien plus fort qu’un simple engagement à définir une interface publique.
Il indique en effet qu’une classe (la classe-fille) peut être substituée à une autre (la classe-mère).
Par conséquent, la classe-fille doit non seulement proposer une implémentation des méthodes
définies par l’Interface, mais ces méthodes doivent aussi se comporter de façon similaire à ce qui
est attendu, c’est à dire respecter les mêmes pré- et post-conditions1 .
L’exemple classique d’illustration du principe de substitution de Liskov est celui du carré et du
rectangle.
Cas pratique
Supposons que l’on dispose d’une Interface définissant le comportement d’un rectangle :
class Rectangle{ public: virtual void remplacer_largeur_par (unsigned int nouvelle_largeur ) = 0; virtual void remplacer_longueur_par(unsigned int nouvelle_longueur) = 0; /* et autres méthodes . . .*/ };
Comme le stipulent les principes de responsabilité unique (SRP) et de moindre surprise (PoLA), la
méthode remplacer_largeur_par(…) n’a pour seule fonction que de modifier la largeur du
rectangle.
Il serait d’ailleurs absurde et totalement inattendu que cette méthode modifie la longueur du
rectangle, ou tout autre attribut du rectangle.
Supposons de plus que nous ayons à notre disposition une application capable de manipuler des
rectangles : unsigned int calculer_laire_de ( Rectangle& mon_rectangle ); void dessiner_sur_lecran ( Rectangle& mon_rectangle ); /* et autres fonctions . . .*/
Afin de pouvoir réutiliser cette application avec notre classe Carré, nous pourrions être tentés de
faire hériter la classe Carré de la classe Rectangle.
Néanmoins, il faudrait alors que la classe Carré fournisse une implémentation des méthodes
remplacer_largeur_par(…) et remplacer_longueur_par(…).
1 voir rubrique « Pour aller plus loin »
La POO et le secret de l’héritage | Julien E. Harbulot
Page 14 sur 17
Or, comme par définition un carré est un rectangle dont la largeur vaut la longueur, cela implique
que la méthode remplacer_largeur_par(…) devrait mettre à jour non seulement la largeur, mais
aussi la longueur du carré ! Alors même que cette méthode n’est pas censée modifier un
autre attribut que la largeur…
Un tel comportement ne respecte pas le principe de substitution et sera souvent source de bugs
insidieux dans l’application. Par conséquent, la classe Carré ne doit pas hériter de la classe
Rectangle, et ce en dépit de l’assertion vraie qui dit que : « un carré est un rectangle ».
L’héritage n’exprime pas la relation « est un »
Nous avons vu dans le cas pratique ci-dessus que l’héritage ne doit pas être utilisé pour
exprimer la relation « est un ». Par exemple, un carré est un rectangle, mais la classe Carré ne
doit pas hériter de la classe Rectangle.
Cela s’explique par le fait que généralement, les représentants n’entretiennent pas les mêmes
rapports que les objets qu’ils représentent.
Par exemple, deux personnes qui engagent une procédure de divorce seront chacune représentée
par un avocat. Ces deux personnes sont mariées, mais il est peu probable que leurs représentants
— c’est à dire les avocats — le soient aussi.
De la même façon, les classes peuvent parfois représenter des objets du monde réel, mais il est
peu probable qu’elles entretiennent les mêmes rapports que ces derniers.
Pour aller plus loin
En résumé, l’héritage rend possible l’utilisation polymorphique de différents objets en définissant
un contrat passé par ces derniers vis-à-vis de leurs utilisateurs. Non seulement ce contrat définit
explicitement une liste de méthodes publiques que ces objets doivent implémenter, mais il
implique aussi le respect implicite des pré- et post-conditions liées à ces méthodes afin de garantir
que ces objets puissent être substitués les uns aux autres.
Au sujet des pré-/post-conditions, l’on pourra consulter l’article Wikipédia suivant :
• [1] Programmation par contrat - http://fr.wikipedia.org/wiki/Programmation_par_contrat
Et pour approfondir la notion de substituabilité, l’on pourra se rapporter à l’article suivant :
• Liskov substitution principle - http://www.objectmentor.com/resources/articles/lsp.pdf
Rappelons de plus que l’héritage n’est conçu ni pour modéliser la relation « est un », ni pour
permettre la réutilisation du code d’objets existants.
Mais alors comment réutiliser correctement le code des objets existants ?
La POO et le secret de l’héritage | Julien E. Harbulot
Page 15 sur 17
La bonne méthode pour réutiliser des objets existants : la composition
L’héritage d’implémentation est source de rigidité
Nous avons déjà vu que l’héritage était souvent utilisé à tort pour factoriser du code commun à
plusieurs objets, ou pour réutiliser le code d’objets existants.
L’utilisation de l’héritage d’implémentation provoque la mise en place de hiérarchies complexes.
Elle induit en outre un couplage fort entre la classe-mère et la classe-fille, alors que ce couplage
pourrait être évité. En résulte un code difficile à changer et des re-compilations superflues.
Le couplage fort qui est induit par l’héritage s’explique par le fait que lorsqu’une classe-fille hérite
d’une classe-mère, elle est dotée de tout le code de la classe mère. Par conséquent, tout
changement apporté au code de la classe-mère entraine un changement implicite du code de la
classe-fille. C’est à dire que lorsque la classe-mère change, il faut re-compiler la classe-fille pour
prendre en compte ces changements.
Ce couplage fort est à contre-sens des objectifs de la POO, qui a pour but de favoriser la
réutilisation de code et de limiter la propagation des changements. C’est pourquoi l’héritage
d’implémentation ne peut pas être considéré comme une pratique orientée-objet.
La réutilisation d’objets existants reste cependant possible en POO grâce à la composition, qui
exprime au mieux cette intention. En effet, qu’est-ce que la composition, sinon le fait de fournir un
objet M à un objet F dans le but d’en permettre la (ré-)utilisation ?
La composition permet d’utiliser l’inversion de dépendance
D’une part, la composition modélise au mieux les intentions du programmeur qui souhaite réutiliser
un objet existant ; d’autre part, elle peut aussi être utilisée de pair avec le polymorphisme, dans le
but de limiter la propagation des changements.
En effet, nous avons vu que l’héritage d’interface permettait de découpler deux objets via
l’introduction d’une Interface.
La POO et le secret de l’héritage | Julien E. Harbulot
Page 16 sur 17
Pour rappel, voici une illustration de ce phénomène que l’on nomme inversion de dépendance :
Schéma 1 :
Sans polymorphisme, l’objet (A) dépend de l’objet (B) qu’il
utilise.
Schéma 2 :
Grâce au polymorphisme, l’objet (A) ne dépend plus de
l’objet (B) qu’il utilise, mais seulement d’une Interface (I).
La composition permet ainsi de réutiliser des objets existants sans introduire de couplage fort,
lorsqu’elle est utilisée conjointement avec le polymorphisme.
Les méthodes de transfert et la composition
Certains programmeurs préfèrent utiliser l’héritage d’implémentation plutôt que la composition car
cette dernière ne permet pas l’import automatique des méthodes publiques de l’objet utilisé dans
l’interface publique de l’objet qui l’utilise. Dans l’état actuel de nos langages de programmation,
son utilisation requiert en effet l’écriture de méthodes de transfert.
Voici un exemple de méthode de transfert :
class ObjetUtilise{ public: virtual void methode_interessante(Type argument1, AutreType argument2) = 0; }; class ObjetUtilisateur{ public: ObjetUtilisateur(ObjetUtilise& outil); void methode_interessante(Type argument1, AutreType argument2){ //<— Méthode de transfert outil.methode_interessante(argument1, argument2); } };
L’obligation d’écrire une méthode de transfert est un détail d’implémentation. Elle découle d’une
lacune des langages de programmation actuels — qui pêchent par manque de
fonctionnalités — et non d’une faiblesse de la POO.
En C++, par exemple, il est possible de pallier cette lacune via l’utilisation d’une Macro et des
type_traits, en attendant que cette fonctionnalité soit ajoutée au langage.
class ObjetUtilisateur{ public: ObjetUtilisateur(ObjetUtilise& outil);
USING( outil, methode_interessante ); };
La POO et le secret de l’héritage | Julien E. Harbulot
Page 17 sur 17
Pour plus d’information à ce sujet, l’on consultera utilement l’adresse suivante :
• https://bitbucket.org/Gauss_/method-forwarding-using-compositon-instead-of-inheritance
En conclusion, la composition est la meilleure façon du réutiliser des objets
car elle :
• renseigne immédiatement sur les intentions du programmeur,
• n’induit pas de couplage fort,
• favorise un design souple et maintenable.
Son utilisation va dans le sens des objectifs de la POO et ne constitue en rien
un aveu de faiblesse de cette dernière. Au contraire, l’utilisation de la
composition est une bonne pratique de programmation orientée-objet qui
favorise l’utilisation du polymorphisme.
POO et réutilisation de code : en bref
Réutilisation des objets existants
• mauvaise méthode : héritage d’implémentation
• bonne méthode : composition + méthode de transfert (forwarding)
Réutilisation du code client (celui qui utilise des objets)
• bonne méthode : héritage d’interface (donc polymorphisme)