web-dev-qa-db-fra.com

"Préférez la composition à l'héritage" - Est-ce la seule raison de se défendre contre les changements de signature?

Cette page préconise la composition plutôt que l'héritage avec l'argument suivant (reformulé dans mes mots):

Un changement dans la signature d'une méthode de la superclasse (qui n'a pas été remplacée dans la sous-classe) provoque des changements supplémentaires à de nombreux endroits lorsque nous utilisons l'héritage. Cependant, lorsque nous utilisons Composition, le changement supplémentaire requis ne se fait qu'à un seul endroit: la sous-classe.

Est-ce vraiment la seule raison de privilégier la composition à l'héritage? Parce que si c'est le cas, ce problème peut être facilement résolu en appliquant un style de codage qui préconise de remplacer toutes les méthodes de la superclasse, même si la sous-classe ne change pas l'implémentation (c'est-à-dire, en mettant des substitutions factices dans la sous-classe). Est-ce que j'ai râté quelque chose?

13
Utku

J'aime les analogies, en voici une: avez-vous déjà vu un de ces téléviseurs avec lecteur de magnétoscope intégré? Que diriez-vous de celui avec un magnétoscope et un lecteur DVD? Ou celui qui a un Blu-ray, un DVD et un magnétoscope. Oh et maintenant tout le monde est en streaming donc nous devons concevoir un nouveau téléviseur ...

Très probablement, si vous possédez un téléviseur, vous n'en avez pas comme celui ci-dessus. La plupart d'entre eux n'ont probablement jamais existé. Vous avez très probablement un moniteur ou un ensemble qui a de nombreuses entrées afin que vous n'ayez pas besoin d'un nouvel ensemble chaque fois qu'il y a un nouveau type de périphérique d'entrée.

L'héritage est comme le téléviseur avec le magnétoscope intégré. Si vous avez 3 types de comportements différents que vous souhaitez utiliser ensemble et que chacun a 2 options pour l'implémentation, vous avez besoin de 8 classes différentes afin de représenter tous ces comportements. Au fur et à mesure que vous ajoutez d'autres options, les chiffres explosent. Si vous utilisez plutôt la composition, vous évitez ce problème combinatoire et la conception aura tendance à être plus extensible.

27
JimmyJames

Non, ça ne l'est pas. D'après mon expérience, la composition sur l'héritage est le résultat de penser votre logiciel comme un tas d'objets avec des comportements qui se parlent / objets se fournir des services au lieu des classes et données .

De cette façon, vous avez des objets qui fournissent des serviecs. Vous les utilisez comme blocs de construction (vraiment comme Lego) pour activer d'autres comportements (par exemple, un UserRegistrationService utilise les services fournis par un EmailerService et un UserRepository ).

Lors de la construction de systèmes plus grands avec cette approche, j'ai naturellement utilisé l'héritage dans très peu de cas. À la fin de la journée, l'héritage est un très outil puissant qui doit être utilisé avec prudence et uniquement lorsque cela est approprié.

4
marstato

"Préférer la composition à l'héritage" est juste une bonne heuristique

Vous devriez considérer le contexte, car ce n'est pas une règle universelle. Ne le prenez pas pour signifier ne jamais utiliser l'héritage quand vous pouvez faire de la composition. Si tel était le cas, vous le corrigeriez en interdisant l'héritage.

J'espère clarifier ce point le long de ce post.

Je n'essaierai pas de défendre le mérite de la composition par lui-même. Ce que je considère hors sujet. Au lieu de cela, je parlerai de certaines situations où le développeur peut envisager un héritage qui serait préférable d'utiliser la composition. Sur cette note, l'héritage a ses propres mérites, que je considère également hors sujet.


Exemple de véhicule

J'écris sur des développeurs qui essaient de faire les choses stupidement, à des fins narratives

Partons pour une variante des exemples classiques que certains cours OOP utilisent… Nous avons une classe Vehicle, puis nous dérivons Car, Airplane, Balloon et Ship.

Remarque : Si vous avez besoin de fonder cet exemple, faites comme si ce sont des types d'objets dans un jeu vidéo.

Ensuite, Car et Airplane peuvent avoir un code commun, car ils peuvent tous les deux rouler sur terre. Les développeurs peuvent envisager de créer une classe intermédiaire dans la chaîne d'héritage pour cela. Pourtant, en fait, il existe également du code partagé entre Airplane et Balloon. Ils pourraient envisager de créer une autre classe intermédiaire dans la chaîne d'héritage pour cela.

Ainsi, le développeur serait à la recherche d'héritage multiple. Au moment où les développeurs recherchent l'héritage multiple, la conception a déjà mal tourné.

Il est préférable de modéliser ce comportement sous forme d'interfaces et de composition, afin de pouvoir le réutiliser sans avoir à exécuter l'héritage de plusieurs classes. Si les développeurs, par exemple, créent la classe FlyingVehicule. Ils diraient que Airplane est un FlyingVehicule (héritage de classe), mais nous pourrions plutôt dire que Airplane a un composant Flying (composition) et Airplane est un IFlyingVehicule (héritage d'interface).

En utilisant des interfaces, si nécessaire, nous pouvons avoir plusieurs héritages (d'interfaces). De plus, vous n'êtes pas couplé à une implémentation particulière. Augmenter la réutilisabilité et la testabilité de votre code.

N'oubliez pas que l'héritage est un outil de polymorphisme. De plus, le polymorphisme est un outil de réutilisation. Si vous pouvez augmenter la réutilisabilité de votre code en utilisant la composition, faites-le. Si vous n'êtes pas sûr que la composition offre ou non une meilleure réutilisabilité, "Préférez la composition à l'héritage" est une bonne heuristique.

Tout cela sans mentionner Amphibious.

En fait, nous n'avons peut-être pas besoin de choses qui décollent. Stephen Hurn a un exemple plus éloquent dans ses articles "Favoriser la composition sur l'héritage" partie 1 et partie 2 .


Substituabilité et encapsulation

A doit-il hériter ou composer B?

Si A est une spécialisation de B qui devrait remplir le principe de substitution Liskov , l'héritage est viable, voire souhaitable. S'il y a des situations où A n'est pas une substitution valide pour B alors nous ne devrions pas utiliser l'héritage.

Nous pourrions être intéressés par la composition comme une forme de programmation défensive, pour défendre la classe dérivée . En particulier, une fois que vous commencez à utiliser B à d'autres fins, il peut y avoir une pression pour la modifier ou l'étendre afin qu'elle convienne mieux à ces fins. S'il existe un risque que B expose des méthodes pouvant entraîner un état non valide dans A, nous devrions utiliser la composition au lieu de l'héritage. Même si nous sommes l'auteur de B et A, c'est une chose de moins à s'inquiéter, donc la composition facilite la réutilisation de B.

Nous pouvons même affirmer que s'il existe des fonctionnalités dans B dont A n'a pas besoin (et nous ne savons pas si ces fonctionnalités pourraient entraîner un état non valide pour A, soit dans la mise en œuvre actuelle ou dans le futur), c'est une bonne idée d'utiliser la composition au lieu de l'héritage.

La composition présente également les avantages de permettre la mise en œuvre des commutations et de faciliter les moqueries.

Remarque : il y a des situations où nous voulons utiliser la composition malgré la substitution étant valide. Nous archivons cela substituabilité en utilisant des interfaces ou des classes abstraites (dont l'une à utiliser quand est un autre sujet), puis nous utilisons la composition avec injection de dépendance de l'implémentation réelle.

Enfin, bien sûr, il y a l'argument selon lequel nous devrions utiliser la composition pour défendre la classe parent car l'héritage rompt l'encapsulation de la classe parent:

L'héritage expose une sous-classe aux détails de l'implémentation de son parent, on dit souvent que "l'héritage rompt l'encapsulation"

-- Design Patterns: Elements of Reusable Object-Oriented Software, Gang of Four

Eh bien, c'est une classe parent mal conçue. C'est pourquoi vous devriez:

Concevez pour l'héritage, ou interdisez-le.

-- Java efficace, Josh Bloch


Problème Yo-Yo

Un autre cas où la composition aide est le problème Yo-Yo . Ceci est une citation de Wikipedia:

Dans le développement de logiciels, le problème yo-yo est un anti-modèle qui se produit lorsqu'un programmeur doit lire et comprendre un programme dont le graphe d'héritage est si long et compliqué qu'il doit continuer à basculer entre de nombreuses définitions de classes différentes afin de suivre le flux de contrôle du programme.

Vous pouvez résoudre, par exemple: Votre classe C n'héritera pas de la classe B. Au lieu de cela, votre classe C aura un membre de type A, qui peut ou non être (ou avoir) un objet de type B. De cette façon, vous ne programmerez pas contre le détail d'implémentation de B, mais contre le contrat que propose l'interface (de) A.


Exemples de compteur

De nombreux cadres favorisent l'héritage plutôt que la composition (ce qui est le contraire de ce que nous avons soutenu). Le développeur peut effectuer cette opération car il a mis beaucoup de travail dans sa classe de base que son implémentation avec la composition aurait augmenté la taille du code client. Parfois, cela est dû aux limitations de la langue.

Par exemple, un framework ORF PHP peut créer une classe de base qui utilise des méthodes magiques pour autoriser l'écriture de code comme si l'objet avait des propriétés réelles. Au lieu de cela, le code géré par les méthodes magiques ira à la base de données, recherchez le champ particulier demandé (peut-être en cache pour une demande future) et renvoyez-le. Pour le faire avec la composition, le client devra soit créer des propriétés pour chaque champ, soit écrire une version du code des méthodes magiques.

Addendum : Il y a d'autres façons de permettre l'extension des objets ORM. Ainsi, je ne pense pas que l'héritage soit nécessaire dans ce cas. C'est moins cher.

Pour un autre exemple, un moteur de jeu vidéo peut créer une classe de base qui utilise du code natif en fonction de la plate-forme cible pour effectuer le rendu 3D et la gestion des événements. Ce code est complexe et spécifique à la plate-forme. Il serait coûteux et sujet à erreur pour l'utilisateur développeur du moteur de gérer ce code, en fait, cela fait partie de la raison d'utiliser le moteur.

De plus, sans la partie de rendu 3D, c'est ainsi que de nombreux frameworks de widgets fonctionnent. Cela vous évite de vous soucier de la gestion des messages du système d'exploitation… en fait, dans de nombreuses langues, vous ne pouvez pas écrire un tel code sans une forme d'enchère native. De plus, si vous le faisiez, cela restreindrait votre portabilité. Au lieu de cela, avec héritage, à condition que le développeur ne casse pas la compatibilité (trop); vous pourrez facilement porter votre code sur toutes les nouvelles plates-formes qu'ils prennent en charge à l'avenir.

De plus, considérez que plusieurs fois nous ne voulons remplacer que quelques méthodes et laisser tout le reste avec les implémentations par défaut. Si nous avions utilisé la composition, nous aurions dû créer toutes ces méthodes, ne serait-ce que pour déléguer à l'objet encapsulé.

Par cet argument, il y a un point où la composition peut être pire pour la maintenabilité que l'héritage (lorsque la classe de base est trop complexe). Cependant, rappelez-vous que la maintenabilité de l'héritage peut être pire que celle de la composition (lorsque l'arbre d'héritage est trop complexe), ce à quoi je fais référence dans le problème du yo-yo.

Dans les exemples présentés, les développeurs ont rarement l'intention de réutiliser le code généré via l'héritage dans d'autres projets. Cela atténue la réutilisabilité réduite de l'utilisation de l'héritage au lieu de la composition. De plus, en utilisant l'héritage, les développeurs de framework peuvent fournir beaucoup de code facile à utiliser et facile à découvrir.


Dernières pensées

Comme vous pouvez le voir, la composition a un certain avantage sur l'héritage dans certaines situations, pas toujours. Il est important de considérer le contexte et les différents facteurs impliqués (tels que la réutilisabilité, la maintenabilité, la testabilité, etc.) pour prendre la décision. Revenons au premier point: "Préférez la composition à l'héritage" est une bonne juste bonne heuristique.

Vous pouvez également remarquer que la plupart des situations que je décris peuvent être résolues dans une certaine mesure avec Traits ou Mixins. Malheureusement, ce ne sont pas des caractéristiques communes dans la grande liste des langues, et elles ont généralement un coût de performance. Heureusement, leurs méthodes d'extension et implémentations par défaut populaires de leurs cousins ​​atténuent certaines situations.

J'ai un article récent où je parle de certains des avantages des interfaces dans pourquoi avons-nous besoin d'interfaces entre l'interface utilisateur, les entreprises et l'accès aux données en C # . Il aide au découplage et facilite la réutilisabilité et la testabilité, cela pourrait vous intéresser.

4
Theraot

Normalement, l'idée directrice de Composition-Over-Inheritance est de fournir une plus grande flexibilité de conception et de ne pas réduire la quantité de code que vous devez modifier pour propager un changement (que je trouve douteux).

L'exemple sur wikipedia donne un meilleur exemple de la puissance de son approche:

En définissant une interface de base pour chaque "morceau de composition", vous pourriez facilement créer divers "objets composés" avec une variété qui pourrait être difficile à atteindre avec l'héritage seul.

3
lbenini

La ligne: "Préférer la composition à l'héritage" n'est rien d'autre qu'un coup de pouce dans la bonne direction. Cela ne vous sauvera pas de votre propre stupidité et vous donnera en fait une chance d'aggraver les choses. C'est parce qu'il y a beaucoup de pouvoir ici.

La principale raison pour laquelle cela doit même être dit est que les programmeurs sont paresseux. Paresseux, ils prendront toute solution qui signifie moins de frappe au clavier. C'est le principal argument de vente de l'héritage. Je tape moins aujourd'hui et quelqu'un d'autre se fait baiser demain. Alors quoi, je veux rentrer à la maison.

Le lendemain, le patron insiste pour que j'utilise la composition. N'explique pas pourquoi mais y insiste. Je prends donc une instance de ce dont je héritais, expose une interface identique à ce dont je héritais et implémente cette interface en déléguant tout le travail à la chose dont je héritais. C'est tout un typage cérébral qui aurait pu être fait avec un outil de refactoring et qui me semble inutile mais le patron le voulait donc je le fais.

Le lendemain, je demande si c'est ce que l'on voulait. Que pensez-vous que le patron dit?

À moins qu'il ne soit nécessaire de pouvoir modifier dynamiquement (au moment de l'exécution) ce qui était hérité de (voir modèle d'état), c'était une énorme perte de temps. Comment le patron peut-il dire cela et être toujours en faveur de la composition plutôt que de l'héritage?

En plus de ne rien faire pour empêcher la casse due aux changements de signature de méthode, cela manque complètement le plus grand avantage de la composition. Contrairement à l'héritage, vous êtes libre de faire un interface différente. Un plus approprié à la façon dont il sera utilisé à ce niveau. Vous savez, l'abstraction!

Il y a quelques avantages mineurs à remplacer automatiquement l'héritage par la composition et la délégation (et certains inconvénients), mais garder le cerveau éteint pendant ce processus manque une énorme opportunité.

L'indirection est très puissante. Fais-en bon usage.

2
candied_orange

Voici une autre raison: dans de nombreux langages OO (je pense à ceux comme C++, C # ou Java ici), il n'y a aucun moyen de changer la classe d'un objet une fois que vous l'avez créé.

Supposons que nous créons une base de données des employés. Nous avons une classe d'employés de base abstraite et commençons à dériver de nouvelles classes pour des rôles spécifiques - ingénieur, gardien de sécurité, chauffeur de camion, etc. Chaque classe a un comportement spécialisé pour ce rôle. Tout est bon.

Puis, un jour, un ingénieur est promu ingénieur. Vous ne pouvez pas reclasser un ingénieur existant. Au lieu de cela, vous devez écrire une fonction qui crée un gestionnaire d'ingénierie, copiant toutes les données de l'ingénieur, supprime l'ingénieur de la base de données, puis ajoute le nouveau gestionnaire d'ingénierie. Et vous devez écrire une telle fonction pour chaque changement de rôle possible.

Pire encore, supposons qu'un chauffeur de camion part en congé de maladie de longue durée et qu'un garde de sécurité propose de faire la conduite de camion à temps partiel pour le remplacer.

Il est tellement plus facile de créer une classe Employee concrète et de la traiter comme un conteneur auquel vous pouvez ajouter des propriétés, telles que le rôle du travail.

0
Simon B

L'héritage fournit deux choses:

1) Réutilisation du code: peut être accompli facilement grâce à la composition. Et en fait, est mieux réalisé grâce à la composition car il préserve mieux l'encapsulation.

2) Polymorphisme: peut être accompli via des "interfaces" (classes où toutes les méthodes sont purement virtuelles).

Un argument solide pour utiliser l'héritage non-interface est lorsque le nombre de méthodes requises est important et que votre sous-classe ne souhaite en modifier qu'un petit sous-ensemble. Cependant, en général, votre interface devrait avoir de très petites empreintes si vous souscrivez au principe "responsabilité unique" (vous devriez), donc ces cas devraient être rares.

Le fait d'avoir le style de codage nécessitant de remplacer toutes les méthodes de classe parente n'évite pas de toute façon d'écrire un grand nombre de méthodes d'intercommunication, alors pourquoi ne pas réutiliser le code via la composition et obtenir l'avantage supplémentaire de l'encapsulation? De plus, si la super-classe devait être étendue en ajoutant une autre méthode, le style de codage nécessiterait l'ajout de cette méthode à toutes les sous-classes (et il y a de fortes chances que nous violions alors "interface segregation" =). Ce problème se développe rapidement lorsque vous commencez à avoir plusieurs niveaux d'héritage.

Enfin, dans de nombreux langages, le test unitaire des objets basés sur la "composition" est beaucoup plus facile que le test unitaire des objets basés sur "l'héritage" en remplaçant l'objet membre par un objet factice/test/factice.

0
dlasalle

Est-ce vraiment la seule raison de privilégier la composition à l'héritage?

Non.

Il existe de nombreuses raisons de privilégier la composition à l'héritage. Il n'y a pas une seule bonne réponse. Mais je vais jeter une autre raison de privilégier la composition à l'héritage dans le mix:

Favoriser la composition plutôt que l'héritage améliore la lisibilité de votre code, ce qui est très important lorsque vous travaillez sur un grand projet avec plusieurs personnes.

Voici comment j'y pense: quand je lis le code de quelqu'un d'autre, si je vois qu'il a étendu une classe, je vais demander quel comportement dans la super classe change-t-il?

Et puis je vais parcourir leur code, à la recherche d'une fonction remplacée. Si je n'en vois pas, je le classe comme une mauvaise utilisation de l'héritage. Ils auraient dû utiliser la composition à la place, ne serait-ce que pour me sauver (et toutes les autres personnes de l'équipe) de 5 minutes de lecture de leur code!

Voici un exemple concret: dans Java la classe JFrame représente une fenêtre. Pour créer une fenêtre, vous créez une instance de JFrame puis appelez des fonctions sur cette instance pour y ajouter des éléments et les afficher.

De nombreux didacticiels (même officiels) recommandent d'étendre JFrame pour que vous puissiez traiter les instances de votre classe comme des instances de JFrame. Mais à mon humble avis (et l'avis de beaucoup d'autres), c'est une assez mauvaise habitude à prendre! Au lieu de cela, vous devez simplement créer une instance de JFrame et appeler des fonctions sur cette instance. Il n'est absolument pas nécessaire d'étendre JFrame dans ce cas, car vous ne modifiez aucun des comportements par défaut de la classe parente.

D'un autre côté, une façon d'effectuer une peinture personnalisée consiste à étendre la classe JPanel et à remplacer la fonction paintComponent(). C'est une bonne utilisation de l'héritage. Si je regarde votre code et que je vois que vous avez étendu JPanel et remplacé la fonction paintComponent(), je saurai exactement quel comportement vous changez.

Si c'est juste vous qui écrivez du code par vous-même, alors il pourrait être aussi facile d'utiliser l'héritage que d'utiliser la composition. Mais faites comme si vous étiez dans un environnement de groupe et que d'autres personnes liront votre code. Favoriser la composition plutôt que l'héritage rend votre code plus facile à lire.

0
Kevin Workman