Pourquoi devrais-je préférer la composition à l'héritage?

109

J'ai toujours lu que la composition doit être préférée à l'héritage. Un article de blog sur des types différents , par exemple, préconise l'utilisation de la composition plutôt que l'héritage, mais je ne vois pas comment le polymorphisme est obtenu.

Mais j’ai le sentiment que lorsque les gens préfèrent la composition, ils préfèrent vraiment combiner la composition et la mise en œuvre de l’interface. Comment allez-vous obtenir un polymorphisme sans héritage?

Voici un exemple concret où j'utilise l'héritage. Comment cela changerait-il pour utiliser la composition et que gagnerais-je?

Class Shape
{
    string name;
  public:
    void getName();
    virtual void draw()=0;
}

Class Circle: public Shape
{
    void draw(/*draw circle*/);
}
MustafaM
la source
57
Non, quand les gens disent préférer la composition, ils veulent dire préférer la composition, ne jamais utiliser l'héritage . Toute votre question est basée sur une prémisse erronée. Utilisez l'héritage quand c'est approprié.
Bill the Lizard
2
Vous voudrez peut-être aussi jeter un coup d'œil à programmers.stackexchange.com/questions/65179/…
vjones
2
Je suis d'accord avec le projet de loi, j'ai vu l'utilisation de l'héritage comme une pratique courante dans le développement d'une interface graphique.
Prashant Cholachagudda
2
Vous voulez utiliser la composition car un carré est juste la composition de deux triangles, en fait je pense que toutes les formes autres que l'ellipse ne sont que des compositions de triangles. Le polymorphisme concerne les obligations contractuelles et 100% retiré de l'héritage. Quand triangle obtient de nombreuses bizarreries parce que quelqu'un voulait le rendre capable de générer des pyramides, si vous héritez de Triangle, vous obtiendrez tout cela, même si vous ne générerez jamais de pyramide 3D à partir de votre hexagone. .
Jimmy Hoffa
2
@BilltheLizard Je pense que beaucoup de gens qui disent que cela signifie vraiment ne jamais utiliser l'héritage, mais ils se trompent.
user253751

Réponses:

51

Le polymorphisme n'implique pas nécessairement l'héritage. Souvent, l'héritage est utilisé comme un moyen facile d'implémenter un comportement polymorphe, car il est pratique de classer des objets similaires se comportant comme ayant une structure et un comportement de racine entièrement communs. Pensez à tous les exemples de codes de voiture et de chien que vous avez vus au fil des ans.

Mais qu'en est-il des objets qui ne sont pas les mêmes. Modéliser une voiture et une planète serait très différent, et pourtant les deux pourraient vouloir implémenter le comportement Move ().

En fait, vous avez essentiellement répondu à votre propre question quand vous avez dit "But I have a feeling that when people say prefer composition, they really mean prefer a combination of composition and interface implementation.". Un comportement commun peut être fourni via des interfaces et un composite comportemental.

Pour ce qui est de savoir lequel est le mieux, la réponse est quelque peu subjective et dépend de la manière dont vous voulez que votre système fonctionne, de ce qui a du sens tant sur le plan contextuel que sur le plan architectural, et de la facilité de test et de maintenance.

S.Robins
la source
En pratique, combien de fois le "Polymorphisme via Interface" apparaît-il et est-il considéré comme normal (au lieu d'être une exploitation de l'expression des langues). Je parierais que le polymorphisme par héritage a été conçu avec soin, pas comme une conséquence du langage (C ++) découvert après sa spécification.
Samis
1
Qui appellerait move () sur une planète et une voiture et le considérerait de la même manière?! La question ici est de savoir dans quel contexte devraient-ils pouvoir se déplacer? S'ils sont tous deux des objets 2D dans un jeu 2D simple, ils peuvent hériter du déplacement. S'ils étaient des objets dans une simulation de données volumineuse, il ne serait peut-être pas très logique de les laisser hériter de la même base et, par conséquent, vous devrez utiliser un interface
NikkyD
2
@SamusArin Il montre jusqu'à partout , et est considéré comme parfaitement normal dans les langues qui prennent en charge les interfaces. Que voulez-vous dire par "une exploitation de l'expressivité d'une langue"? C'est ce que les interfaces sont là pour .
Andres F.
@AndresF. Je viens de tomber sur "Polymorphism via Interface" dans un exemple en lisant "Analyse orientée objet et conception avec applications" qui présentait le modèle Observer. J'ai alors réalisé ma réponse.
Samis
@AndresF. J'imagine que ma vision était un peu aveugle à ce sujet à cause de la manière dont j'ai utilisé le polymorphisme dans mon dernier (premier) projet. J'ai eu 5 types d'enregistrement qui ont tous dérivé de la même base. En tout cas merci pour l'illumination.
Samis
79

Préférer la composition ne concerne pas seulement le polymorphisme. Bien que cela en fasse partie, et vous avez raison (du moins dans les langues nominalement typées), ce que les gens veulent vraiment dire, c'est "préférez une combinaison de composition et d'implémentation d'interface". Mais, les raisons de préférer la composition (dans de nombreuses circonstances) sont profondes.

Le polymorphisme consiste en une chose à se comporter de différentes manières. Ainsi, les génériques / modèles sont une fonctionnalité "polymorphe" dans la mesure où ils permettent à un seul morceau de code de faire varier son comportement avec les types. En fait, ce type de polymorphisme est vraiment le meilleur comportement et est généralement appelé polymorphisme paramétrique, car la variation est définie par un paramètre.

De nombreux langages fournissent une forme de polymorphisme appelée "surcharge" ou polymorphisme ad hoc, dans lequel plusieurs procédures portant le même nom sont définies de manière ad hoc et où l'une d'entre elles est choisie par le langage (peut-être la plus spécifique). Il s’agit du type de polymorphisme le moins bien comporté puisque rien ne relie le comportement des deux procédures à l’exception de la convention développée.

Un troisième type de polymorphisme est le polymorphisme de sous - type . Ici, une procédure définie sur un type donné peut également fonctionner sur toute une famille de "sous-types" de ce type. Lorsque vous implémentez une interface ou étendez une classe, vous déclarez généralement votre intention de créer un sous-type. Les vrais sous-types sont régis par le principe de substitution de Liskovqui dit que si vous pouvez prouver quelque chose à propos de tous les objets d’un supertype, vous pouvez le faire à propos de toutes les instances d’un sous-type. La vie devient cependant dangereuse, car dans des langages tels que C ++ et Java, les gens ont généralement des hypothèses non appliquées et souvent non documentées sur les classes qui peuvent être ou non vraies à propos de leurs sous-classes. Autrement dit, le code est écrit comme si plus de preuves que ce qui est réellement prouvable ne sont générées, ce qui produit toute une série de problèmes lorsque vous sous-tapez négligemment.

L'héritage est en réalité indépendant du polymorphisme. Étant donné quelque chose "T" qui a une référence à lui-même, l'héritage se produit lorsque vous créez une nouvelle chose "S" à partir de "T" en remplaçant "T" par une référence à "S". Cette définition est intentionnellement vague, car l'héritage peut se produire dans de nombreuses situations, mais la plus courante consiste à sous-classer un objet, ce qui a pour effet de remplacer le thispointeur appelé par des fonctions virtuelles par le thispointeur sur le sous-type.

L'héritage est dangereux, comme toutes les choses très puissantes. L'héritage a le pouvoir de causer des ravages. Par exemple, supposons que vous substituez une méthode lorsque vous héritez d'une classe: tout va bien jusqu'à ce qu'une autre méthode de cette classe suppose que la méthode dont vous avez hérité se comporte d'une certaine manière, après tout ce que l'auteur de la classe d'origine a conçue . Vous pouvez vous protéger partiellement contre cela en déclarant toutes les méthodes appelées par une autre de vos méthodes privées ou non virtuelles (final), sauf si elles sont conçues pour être remplacées. Même si cela ne suffit pas toujours. Parfois, vous pouvez voir quelque chose comme ceci (en pseudo Java, lisible pour les utilisateurs C ++ et C #)

interface UsefulThingsInterface {
    void doThings();
    void doMoreThings();
}

...

class WayOfDoingUsefulThings implements UsefulThingsInterface{
     private foo stuff;
     public final int getStuff();
     void doThings(){
       //modifies stuff, such that ...
       ...
     }
     ...
     void doMoreThings(){
       //ignores stuff
       ...
     }
 }

vous pensez que c'est beau et avez votre propre façon de faire des "choses", mais vous utilisez l'héritage pour acquérir la capacité de faire "plus de choses",

class MyUsefulThings extends WayOfDoingUsefulThings{
     void doThings {
        //my way
     }
}

Et tout va bien. WayOfDoingUsefulThingsa été conçu de manière à ce que le remplacement d’une méthode ne modifie pas la sémantique d’une autre ... sauf attendre, non. Cela ressemble à ça, mais a doThingschangé d'état mutable qui importait. Donc, même s'il n'appelle aucune fonction pouvant être remplacée,

 void dealWithStuff(WayOfDoingUsefulThings bar){
     bar.doThings()
     use(bar.getStuff());
 }

fait maintenant quelque chose de différent de ce à quoi vous vous attendez lorsque vous le passez MyUsefulThings. Quoi de pire, vous pourriez même ne pas savoir que WayOfDoingUsefulThingsfait ces promesses. Peut-être dealWithStuffprovient-il de la même bibliothèque WayOfDoingUsefulThingset getStuff()n'est-il même pas exporté par la bibliothèque (pensez aux classes d'amis en C ++). Pire encore, vous avez vaincu les vérifications statiques de la langue sans vous en rendre compte: vous avez dealWithStuffpris la WayOfDoingUsefulThingspeine de vous assurer qu’il aurait une getStuff()fonction qui se comporterait d’une certaine manière.

En utilisant la composition

class MyUsefulThings implements UsefulThingsInterface{
     private way = new WayOfDoingUsefulThings()
     void doThings() {
        //my way
     }
     void doMoreThings() {
        this.way.doMoreThings();
     }
}

ramène la sécurité de type statique. En général, la composition est plus facile à utiliser et plus sûre que l'héritage lors de la mise en œuvre de sous-typage. Il vous permet également de remplacer les méthodes finales, ce qui signifie que vous devriez pouvoir déclarer tout ce qui est final / non virtuel, sauf la plupart du temps dans les interfaces.

Dans un monde meilleur, les langues inséreraient automatiquement le passe-partout avec un delegationmot clé. La plupart ne le font pas, donc un inconvénient est les classes plus grandes. Cependant, vous pouvez demander à votre IDE d’écrire l’instance de délégation pour vous.

Maintenant, la vie ne concerne pas seulement le polymorphisme. Vous pouvez ne pas avoir besoin de sous-type tout le temps. L'objectif du polymorphisme est généralement de réutiliser le code, mais ce n'est pas le seul moyen d'atteindre cet objectif. Souvent, il est judicieux d’utiliser une composition, sans polymorphisme de sous-type, comme moyen de gestion des fonctionnalités.

En outre, l'héritage comportemental a ses utilisations. C'est l'une des idées les plus puissantes en informatique. C'est juste que, la plupart du temps, les bonnes applications POO peuvent être écrites en utilisant uniquement l'héritage et les compositions d'interface. Les deux principes

  1. Interdire l'héritage ou la conception pour cela
  2. Préfère la composition

sont un bon guide pour les raisons ci-dessus et n'engendrent pas de coûts substantiels.

Philip JF
la source
4
Bonne réponse. Je résumerais qu'essayer de réutiliser du code avec l'héritage est tout simplement un mauvais chemin. L'héritage est une contrainte très forte (ajouter le "pouvoir" n'est pas une bonne analogie!) Et crée une forte dépendance entre les classes héritées. Trop de dépendances = mauvais code :) Ainsi, l'héritage (alias "se comporte comme") brille généralement pour les interfaces unifiées (= cache la complexité des classes héritées), pour toute autre chose, réfléchissez à deux fois ou utilisez la composition ...
MaR
2
C'est une bonne réponse. +1 Au moins à mes yeux, il semble bien que la complication supplémentaire puisse représenter un coût substantiel, ce qui me rend personnellement un peu réticent à l'idée de préférer la composition. En particulier lorsque vous essayez de créer beaucoup de code convivial pour les tests unitaires via des interfaces, une composition et une ID (je sais que cela ajoute d’autres éléments), il semble très facile de forcer une personne à parcourir plusieurs fichiers peu de détails. Pourquoi n’en parle-t-on pas plus souvent, même s’il ne s’agit que du principe de la composition par rapport à la conception du patrimoine?
Panzercrisis
27

Les gens disent que c'est parce que les programmeurs débutants, récemment sortis de leurs conférences sur le polymorphisme par héritage, ont tendance à écrire de grandes classes avec beaucoup de méthodes polymorphes, puis aboutissent quelque part dans un fouillis intenable.

Un exemple typique vient du monde du développement de jeux. Supposons que vous ayez une classe de base pour toutes vos entités de jeu - le vaisseau spatial du joueur, les monstres, les balles, etc. chaque type d'entité a sa propre sous-classe. L'approche en matière d'héritage utiliser quelques méthodes polymorphes, par exemple update_controls(), update_physics(), draw(), etc., et de les mettre en œuvre pour chaque sous - classe. Toutefois, cela signifie que vous associez des fonctionnalités sans rapport entre elles: il est indifférent que l’objet d’un objet soit déplacé et vous n’avez pas besoin de connaître son intelligence artificielle pour la dessiner. L’approche compositionnelle définit à la place plusieurs classes de base (ou interfaces), par exemple EntityBrain(les sous-classes implémentent l’IA ou l’entrée du joueur), EntityPhysics(les sous-classes implémentent la physique du mouvement) et EntityPainter(les sous-classes se chargent de dessiner), et une classe non polymorphe.Entityqui contient une instance de chacun. De cette façon, vous pouvez combiner n'importe quel aspect avec n'importe quel modèle physique et n'importe quelle IA, et puisque vous les gardez séparés, votre code sera aussi beaucoup plus propre. En outre, des problèmes tels que "Je veux un monstre qui ressemble au monstre ballon du niveau 1, mais se comporte comme le clown fou du niveau 15" disparaissent: il vous suffit de prendre les composants appropriés et de les coller ensemble.

Notez que l'approche compositionnelle utilise toujours l'héritage dans chaque composant; bien qu'idéalement, vous n'utilisiez ici que les interfaces et leurs implémentations.

"Séparation des préoccupations" est la phrase clé: représenter la physique, mettre en œuvre une intelligence artificielle et dessiner une entité sont trois préoccupations; les combiner en une entité en est une quatrième. Avec l'approche compositionnelle, chaque problème est modélisé comme une classe.

tdammers
la source
1
Tout cela est bien, et préconisé dans l'article lié à la question initiale. J'aimerais (si vous en avez le temps) si vous pouviez fournir un exemple simple impliquant toutes ces entités, tout en maintenant le polymorphisme (en C ++). Ou me diriger vers une ressource. Merci.
MustafaM
Je sais que votre réponse est très ancienne, mais j’ai fait exactement la même chose que celle que vous avez mentionnée dans mon jeu. C’est maintenant l’approche composition par héritage.
Sneh
"Je veux un monstre qui ressemble à ..." comment cela a-t-il un sens? Une interface ne donne aucune implémentation, vous devrez copier le code de look et de comportement d'une manière ou d'une autre
NikkyD
1
@NikkyD Vous prenez MonsterVisuals balloonVisualset MonsterBehaviour crazyClownBehaviourinstanciez un Monster(balloonVisuals, crazyClownBehaviour), aux côtés de Monster(balloonVisuals, balloonBehaviour)et Monster(crazyClownVisuals, crazyClownBehaviour)instances qui ont été instanciées au niveau 1 et au niveau 15
Caleth
13

L'exemple que vous avez donné est celui où l'héritage est le choix naturel. Je ne pense pas que quiconque prétende que la composition est toujours un meilleur choix que l'héritage - c'est simplement une directive qui signifie qu'il est souvent préférable d'assembler plusieurs objets relativement simples plutôt que de créer de nombreux objets hautement spécialisés.

La délégation est un exemple d'utilisation de la composition au lieu de l'héritage. La délégation vous permet de modifier le comportement d'une classe sans sous-classement. Considérons une classe qui fournit une connexion réseau, NetStream. Il peut être naturel de mettre la sous-classe NetStream en œuvre pour mettre en œuvre un protocole réseau commun. Vous pouvez donc utiliser FTPStream et HTTPStream. Mais au lieu de créer une sous-classe HTTPStream très spécifique dans un seul but, par exemple UpdateMyWebServiceHTTPStream, il est souvent préférable d’utiliser une ancienne instance simple de HTTPStream avec un délégué qui sait quoi faire des données qu’il reçoit de cet objet. L'une des raisons pour lesquelles c'est mieux, c'est que cela évite une prolifération de classes qui doivent être maintenues mais que vous ne pourrez jamais réutiliser.

Caleb
la source
11

Vous verrez beaucoup ce cycle dans le discours sur le développement de logiciels:

  1. Certaines caractéristiques ou motifs (appelons-les "Motifs X") s’avèrent utiles pour un usage particulier. Des articles de blog sont écrits pour vanter les vertus de Pattern X.

  2. Le battage médiatique conduit certaines personnes à penser que vous devriez utiliser Pattern X chaque fois que cela est possible .

  3. D'autres personnes sont gênées de voir que Pattern X est utilisé dans des contextes où cela n'est pas approprié et écrivent des articles de blog déclarant qu'il ne faut pas toujours utiliser Pattern X et qu'il est nocif dans certains contextes.

  4. Certaines personnes croient que le contrecoup est toujours préjudiciable et qu’il ne doit jamais être utilisé.

Vous verrez ce cycle hype / backlash se produire avec presque toutes les fonctionnalités, allant GOTOde modèles allant de SQL à NoSQL en passant par, oui, l'héritage. L'antidote est de toujours considérer le contexte .

Après avoir Circledescendu de Shapeest exactement comment l' héritage est censé être utilisé dans les langues OO soutien héritage.

La règle empirique "préférer la composition à l'héritage" est vraiment trompeuse sans contexte. Vous devriez préférer l'héritage lorsque l'héritage est plus approprié, mais préférer la composition lorsque la composition est plus appropriée. La phrase s'adresse aux personnes au stade 2 du cycle de battage médiatique qui pensent que l'héritage devrait être utilisé partout. Mais le cycle a évolué et il semble aujourd'hui amener certaines personnes à penser que l'héritage est mauvais en soi.

Pensez-y comme un marteau contre un tournevis. Devez-vous préférer un tournevis à un marteau? La question n'a pas de sens. Vous devez utiliser l'outil approprié pour le travail, et tout dépend de la tâche à accomplir.

JacquesB
la source