J'ai toujours lu que la composition devait être préférée à l'héritage. A article de blog sur les 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 atteint.
Mais j'ai le sentiment que lorsque les gens disent préférer la composition, ils veulent vraiment préférer une combinaison de composition et d'implémentation d'interface. Comment allez-vous obtenir le polymorphisme sans héritage?
Voici un exemple concret où j'utilise l'héritage. Comment cela serait-il changé 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*/);
}
Le polymorphisme n'implique pas nécessairement l'héritage. L'héritage est souvent utilisé comme un moyen facile d'implémenter un comportement polymorphe, car il est pratique de classer des objets se comportant similaires comme ayant une structure et un comportement racine entièrement communs. Pensez à tous ces 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 en disant "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 le mieux, la réponse est quelque peu subjective et se résume vraiment à la façon dont vous souhaitez que votre système fonctionne, à ce qui a du sens à la fois contextuellement et architecturalement, et à quel point il sera facile de tester et de maintenir.
La composition préférée n'est pas seulement une question de polymorphisme. Bien que cela en fasse partie, et vous avez raison de dire que (au moins dans les langues typiquement nominatives) ce que les gens veulent vraiment dire est "préférez une combinaison de composition et de mise en œuvre d'interface". Mais, les raisons de préférer la composition (dans de nombreuses circonstances) sont profondes.
Polymorphisme concerne une chose se comportant de plusieurs façons. Ainsi, les génériques/modèles sont une fonctionnalité "polymorphe" dans la mesure où ils permettent à un seul morceau de code de varier son comportement en fonction des 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 où plusieurs procédures portant le même nom sont définies de manière ad hoc, et où l'une est choisie par le langage (peut-être le plus spécifique ). C'est le type de polymorphisme le moins bien comporté, car rien ne relie le comportement des deux procédures, sauf la convention développée.
Un troisième type de polymorphisme est 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 Liskov , qui dit que si vous pouvez prouver quelque chose sur tous les objets d'un supertype, vous pouvez le prouver sur toutes les instances d'un sous-type. La vie devient cependant dangereuse, car dans des langages comme 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 ou non être vraies au sujet de leurs sous-classes. C'est-à-dire que le code est écrit comme s'il était plus prouvable qu'il ne l'est réellement, ce qui produit une multitude de problèmes lorsque vous sous-tapez négligemment.
Héritage est en fait indépendant du polymorphisme. Étant donné une chose "T" qui a une référence à elle-même, l'héritage se produit lorsque vous créez une nouvelle chose "S" à partir de "T" en remplaçant la référence de "T" à elle-même 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 qui a pour effet de remplacer le pointeur this
appelé par des fonctions virtuelles par le pointeur this
vers le sous-type.
L'héritage est dangereux comme toutes les choses très puissantes, l'héritage a le pouvoir de faire des ravages. Par exemple, supposons que vous remplaciez 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 héritez se comporte d'une certaine manière, après tout, c'est ainsi que l'auteur de la classe d'origine l'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), à moins qu'elles ne soient conçues pour être remplacées . Même si ce n'est pas toujours assez bon. Parfois, vous pouvez voir quelque chose comme ça (en pseudo Java, espérons-le 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 que vous 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. WayOfDoingUsefulThings
a été conçu de telle manière que le remplacement d'une méthode ne change pas la sémantique d'une autre ... sauf attendez, non. Il semble que ce soit le cas, mais doThings
a changé l'état mutable qui comptait. Donc, même s'il n'a appelé aucune fonction prioritaire,
void dealWithStuff(WayOfDoingUsefulThings bar){
bar.doThings()
use(bar.getStuff());
}
fait maintenant quelque chose de différent que prévu lorsque vous lui passez un MyUsefulThings
. Pire encore, vous ne savez peut-être même pas que WayOfDoingUsefulThings
a fait ces promesses. Peut-être que dealWithStuff
provient de la même bibliothèque que WayOfDoingUsefulThings
et getStuff()
n'est même pas exporté par la bibliothèque (pensez à classes d'amis en C++). Pire encore, vous avez vaincu les vérifications statiques du langage sans vous en rendre compte: dealWithStuff
a pris un WayOfDoingUsefulThings
juste pour vous assurer qu'il aurait une fonction getStuff()
qui se comporterait comme un certaine manière.
Utilisation de 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 du sous-typage. Il vous permet également de remplacer les méthodes finales, ce qui signifie que vous devriez vous sentir libre de déclarer tout final/non virtuel, sauf dans les interfaces la grande majorité du temps.
Dans un monde meilleur, les langues inséreraient automatiquement le passe-partout avec un mot clé delegation
. La plupart ne le font pas, donc un inconvénient, ce sont les classes plus importantes. Cependant, vous pouvez obtenir votre IDE pour écrire l'instance de délégation pour vous.
Maintenant, la vie ne se résume pas au polymorphisme. Vous n'avez pas besoin de sous-taper tout le temps. L'objectif du polymorphisme est généralement réutilisation du code mais ce n'est pas le seul moyen d'atteindre cet objectif. Souvent, il est logique d'utiliser la composition, sans polymorphisme de sous-type, comme moyen de gérer la fonctionnalité.
De plus, l'héritage comportemental a ses utilités. C'est l'une des idées les plus puissantes de l'informatique. C'est juste que, la plupart du temps, de bonnes applications OOP peuvent être écrites en utilisant uniquement l'héritage et les compositions d'interface. Les deux principes
sont un bon guide pour les raisons ci-dessus, et n'engagent pas de coûts substantiels.
La raison pour laquelle les gens disent cela est que les débutants OOP programmeurs, fraîchement sortis de leurs cours de polymorphisme par héritage, ont tendance à écrire de grandes classes avec beaucoup de méthodes polymorphes, puis quelque part plus tard, ils se retrouver avec un gâchis impossible à maintenir.
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 de l'héritage utiliserait quelques méthodes polymorphes, par exemple update_controls()
, update_physics()
, draw()
, etc., et implémentez-les pour chaque sous-classe. Cependant, cela signifie que vous couplez des fonctionnalités non liées: il n'est pas pertinent à quoi ressemble un objet pour le déplacer, et vous n'avez besoin de rien savoir de son IA pour le dessiner. L'approche compositionnelle définit à la place plusieurs classes de base (ou interfaces), par ex. EntityBrain
(les sous-classes implémentent l'IA ou l'entrée du lecteur), EntityPhysics
(les sous-classes implémentent la physique du mouvement) et EntityPainter
(les sous-classes s'occupent du dessin), et une classe non polymorphe Entity
qui contient une instance de chacun. De cette façon, vous pouvez combiner n'importe quelle apparence avec n'importe quel modèle physique et n'importe quelle IA, et puisque vous les gardez séparés, votre code sera également beaucoup plus propre. De plus, des problèmes tels que "Je veux un monstre qui ressemble au monstre ballon au niveau 1, mais se comporte comme un clown fou au 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 au sein de chaque composant; bien qu'idéalement, vous n'utiliseriez que des interfaces et leurs implémentations ici.
"Séparation des préoccupations" est la phrase clé ici: représenter la physique, implémenter une IA et dessiner une entité, sont trois préoccupations, les combiner en une entité en est une quatrième. Avec l'approche compositionnelle, chaque préoccupation est modélisée en une seule classe.
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 juste une directive qui signifie qu'il est souvent préférable d'assembler plusieurs objets relativement simples que de créer beaucoup d'objets hautement spécialisés.
La délégation est un exemple d'une manière d'utiliser 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érez une classe qui fournit une connexion réseau, NetStream. Il peut être naturel de sous-classer NetStream pour implémenter un protocole réseau commun, vous pouvez donc proposer 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 avec les données qu'il reçoit de cet objet. Une des raisons pour lesquelles il est préférable est qu'il évite une prolifération de classes qui doivent être maintenues mais que vous ne pourrez jamais réutiliser. Une autre raison est que l'objet qui sert de délégué peut également être responsable d'autres choses, telles que la gestion des données reçues du service Web.
Vous verrez beaucoup ce cycle dans le discours de développement logiciel:
Une fonction ou un motif (appelons-le "Motif X") s'avère utile dans un but particulier. Des articles de blog sont écrits pour vanter les vertus du modèle X.
Le battage médiatique amène certaines personnes à penser que vous devriez utiliser le modèle X dans la mesure du possible.
D'autres personnes sont ennuyées de voir le modèle X utilisé dans des contextes où cela n'est pas approprié, et écrivent des articles de blog indiquant que vous devriez pas toujours utiliser le modèle X et qu'il est nocif dans certains contextes.
Le contrecoup fait croire à certaines personnes que le motif X est toujours dangereux et devrait jamais être utilisé.
Vous verrez ce cycle de battage médiatique/de retour de bâton se produire avec presque toutes les fonctionnalités de GOTO
aux modèles de SQL à NoSQL et, oui, à l'héritage. L'antidote est de toujours considérer le contexte.
Avoir Circle
descendant de Shape
est exactement comment l'héritage est censé être utilisé dans OO langues supportant l'héritage.
La règle empirique "préfère 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érez 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, aujourd'hui, il semble que certaines personnes pensent que l'héritage est en quelque sorte mauvais en soi.
Pensez-y comme un marteau contre un tournevis. Devriez-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.