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.
17
Embed
La POO et le secret de l’héritage · La POO et le secret de l’héritage Julien E. Harbulot | [email protected] Février 2015 Depuis la popularisation du paradigme de programmation
This document is posted to help you gain knowledge. Please leave a comment to let me know what you think about it! Share it to your friends and learn new things together.
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 :