Perché dovrei preferire la composizione rispetto all'eredità?


109

Ho sempre letto che la composizione è da preferire all'eredità. Un post sul blog su tipi diversi , ad esempio, sostiene l'uso della composizione rispetto all'eredità, ma non riesco a vedere come si ottiene il polimorfismo.

Ma ho la sensazione che quando le persone dicono preferiscono la composizione, intendono davvero preferire una combinazione di composizione e implementazione dell'interfaccia. Come hai intenzione di ottenere il polimorfismo senza eredità?

Ecco un esempio concreto in cui utilizzo l'ereditarietà. Come sarebbe cambiato per usare la composizione e cosa avrei guadagnato?

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

Class Circle: public Shape
{
    void draw(/*draw circle*/);
}

57
No, quando le persone dicono che preferiscono la composizione, in realtà significano preferire la composizione, non usare mai l'eredità . Tutta la tua domanda si basa su una premessa errata. Usa l'ereditarietà quando è appropriato.
Bill the Lizard,

2
Potresti anche dare un'occhiata a programmers.stackexchange.com/questions/65179/…
vjones

2
Sono d'accordo con il disegno di legge, ho visto l'uso dell'ereditarietà una pratica comune nello sviluppo della GUI.
Prashant Cholachagudda,

2
Vuoi usare la composizione perché un quadrato è solo la composizione di due triangoli, in effetti penso che tutte le forme diverse dall'ellisse siano solo composizioni di triangoli. Il polimorfismo riguarda gli obblighi contrattuali e viene rimosso al 100% dall'eredità. Quando il triangolo prende è un mucchio di stranezze aggiunte perché qualcuno voleva renderlo capace di generare piramidi, se erediti da Triangolo otterrai tutto ciò anche se non genererai mai una piramide 3d dal tuo esagono .
Jimmy Hoffa,

2
@BilltheLizard Penso che molte persone che lo affermano davvero non abbiano mai usato l'eredità, ma si sbagliano.
immibis,

Risposte:


51

Il polimorfismo non implica necessariamente ereditarietà. Spesso l'ereditarietà viene utilizzata come mezzo semplice per implementare il comportamento polimorfico, poiché è conveniente classificare oggetti comportamentali simili come aventi struttura e comportamento delle radici completamente comuni. Pensa a tutti quegli esempi di codici di auto e cani che hai visto negli anni.

Ma per quanto riguarda gli oggetti che non sono gli stessi. Modellare un'auto e un pianeta sarebbe molto diverso, e tuttavia entrambi potrebbero voler implementare il comportamento di Move ().

In realtà, hai praticamente risposto alla tua domanda quando hai detto "But I have a feeling that when people say prefer composition, they really mean prefer a combination of composition and interface implementation.". Comportamento comune può essere fornito attraverso interfacce e un composito comportamentale.

Per quanto riguarda ciò che è meglio, la risposta è in qualche modo soggettiva e si riduce davvero al modo in cui vuoi che il tuo sistema funzioni, che cosa ha senso sia dal punto di vista contestuale che architettonico e da quanto sarà facile testare e mantenere.


In pratica, con quale frequenza viene visualizzato "Polimorfismo tramite interfaccia", ed è considerato normale (anziché essere uno sfruttamento dell'espressività delle lingue). Scommetterei che il polimorfismo attraverso l'ereditarietà è stato progettato con cura, non una conseguenza del linguaggio (C ++) scoperto dopo la sua specifica.
Samis,

1
Chi chiamerebbe move () su un pianeta e una macchina e lo considererebbe lo stesso ?! La domanda qui è in quale contesto dovrebbero essere in grado di muoversi? Se entrambi sono oggetti 2D in un semplice gioco 2D, potrebbero ereditare la mossa, se fossero oggetti in una simulazione di dati di massa potrebbe non avere molto senso lasciarli ereditare dalla stessa base e di conseguenza è necessario utilizzare un interfaccia
NikkyD

2
@SamusArin Si mostra fino ovunque , ed è considerato perfettamente normale in lingue che supportano interfacce. Cosa intendi con "uno sfruttamento dell'espressività di una lingua"? Ecco a cosa servono le interfacce .
Andres F.

@AndresF. Mi sono appena imbattuto in "Polymorphism via Interface" in un esempio mentre leggevo "Object Oriented Analysis and Design with Applications" che ha dimostrato il modello di Observer. Ho quindi realizzato la mia risposta.
Samis,

@AndresF. Immagino che la mia visione sia stata un po 'accecata al riguardo a causa di come ho usato il polimorfismo nel mio ultimo (primo) progetto. Avevo 5 tipi di record che derivavano tutti dalla stessa base. Comunque grazie per l'illuminazione.
Samis,

79

Preferire la composizione non riguarda solo il polimorfismo. Anche se questo fa parte di questo, e hai ragione (almeno nei linguaggi nominati tipicamente) ciò che la gente intende veramente è "preferire una combinazione di composizione e implementazione dell'interfaccia". Ma i motivi per preferire la composizione (in molte circostanze) sono profondi.

Il polimorfismo riguarda una cosa che si comporta in diversi modi. Pertanto, generici / modelli sono una funzionalità "polimorfica" in quanto consentono a un singolo pezzo di codice di variare il suo comportamento con i tipi. In effetti, questo tipo di polimorfismo è davvero il comportamento migliore e viene generalmente definito polimorfismo parametrico perché la variazione è definita da un parametro.

Molte lingue forniscono una forma di polimorfismo chiamata "sovraccarico" o polimorfismo ad hoc in cui più procedure con lo stesso nome sono definite in un modo ad hoc e in cui si è scelti dal linguaggio (forse il più specifico). Questo è il tipo di polimorfismo meno ben educato, dal momento che nulla collega il comportamento delle due procedure tranne la convenzione sviluppata.

Un terzo tipo di polimorfismo è il polimorfismo del sottotipo . Qui una procedura definita su un determinato tipo, può funzionare anche su un'intera famiglia di "sottotipi" di quel tipo. Quando si implementa un'interfaccia o si estende una classe, in genere si dichiara l'intenzione di creare un sottotipo. I veri sottotipi sono regolati dal Principio di sostituzione di Liskov, che dice che se puoi provare qualcosa su tutti gli oggetti in un supertipo puoi provarlo su tutte le istanze in un sottotipo. La vita diventa pericolosa, tuttavia, poiché in linguaggi come C ++ e Java, le persone generalmente hanno ipotesi non rinforzate e spesso prive di documenti sulle classi che possono o meno essere vere riguardo alle loro sottoclassi. Cioè, il codice è scritto come se sia dimostrabile più di quello che è realmente, il che produce una serie di problemi quando si sottotipa con noncuranza.

L'ereditarietà è in realtà indipendente dal polimorfismo. Dato qualcosa "T" che ha un riferimento a se stesso, l'ereditarietà accade quando si crea una nuova cosa "S" da "T" sostituendo il riferimento "T" a se stesso con un riferimento a "S". Tale definizione è intenzionalmente vaga, poiché l'ereditarietà può verificarsi in molte situazioni, ma la più comune è la sottoclasse di un oggetto che ha l'effetto di sostituire il thispuntatore chiamato da funzioni virtuali con il thispuntatore al sottotipo.

L'ereditarietà è pericolosa come tutte le cose molto potenti che l'eredità ha il potere di provocare il caos. Ad esempio, supponete di avere la precedenza su un metodo quando ereditate da una classe: tutto va bene fino a quando un altro metodo di quella classe assume il metodo che ereditate per comportarvi in ​​un certo modo, dopo tutto è come l'autore della classe originale lo ha progettato . Puoi parzialmente proteggerti da questo dichiarando tutti i metodi chiamati da un altro dei tuoi metodi privati ​​o non virtuali (final), a meno che non siano progettati per essere sovrascritti. Anche questo però non è sempre abbastanza buono. A volte potresti vedere qualcosa del genere (in pseudo Java, si spera sia leggibile dagli utenti C ++ e 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
       ...
     }
 }

pensi che sia adorabile e hai il tuo modo di fare "cose", ma usi l'eredità per acquisire la capacità di fare "più cose",

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

E tutto va bene. WayOfDoingUsefulThingsè stato progettato in modo tale che la sostituzione di un metodo non cambi la semantica di nessun altro ... tranne aspettare, no, non lo era. Sembra che fosse, ma ha doThingscambiato lo stato mutevole che contava. Quindi, anche se non ha chiamato alcuna funzione abilitata per l'override,

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

ora fa qualcosa di diverso dal previsto quando lo passi a MyUsefulThings. Quel che è peggio, potresti anche non sapere che ha WayOfDoingUsefulThingsfatto quelle promesse. Forse dealWithStuffviene dalla stessa libreria WayOfDoingUsefulThingse getStuff()non viene nemmeno esportato dalla libreria (pensate alle classi di amici in C ++). Peggio ancora, hai sconfitto i controlli statici della lingua senza rendertene conto: hai dealWithStuffpreso un WayOfDoingUsefulThingsgiusto per assicurarti che avrebbe una getStuff()funzione che si comportava in un certo modo.

Usando la composizione

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

riporta la sicurezza di tipo statico. In generale, la composizione è più facile da usare e più sicura dell'eredità durante l'implementazione del sottotipo. Ti consente anche di ignorare i metodi finali, il che significa che dovresti sentirti libero di dichiarare tutto finale / non virtuale tranne che nelle interfacce la maggior parte delle volte.

In un mondo migliore, le lingue inseriscono automaticamente la caldaia con una delegationparola chiave. La maggior parte no, quindi un aspetto negativo sono le classi più grandi. Tuttavia, puoi ottenere il tuo IDE per scrivere l'istanza di delega per te.

Ora, la vita non riguarda solo il polimorfismo. Non è necessario sottotipare continuamente. L'obiettivo del polimorfismo è generalmente il riutilizzo del codice, ma non è l'unico modo per raggiungere tale obiettivo. Spesso ha senso usare la composizione, senza polimorfismo di sottotipo, come modo di gestire la funzionalità.

Inoltre, l'eredità comportamentale ha i suoi usi. È una delle idee più potenti nell'informatica. È solo che, il più delle volte, si possono scrivere buone applicazioni OOP usando solo l'ereditarietà dell'interfaccia e le composizioni. I due principi

  1. Divieto di eredità o design per esso
  2. Preferisce la composizione

sono una buona guida per i motivi sopra indicati e non comportano costi sostanziali.


4
Bella risposta. Riassumerei che cercare di ottenere il riutilizzo del codice con l'ereditarietà è chiaramente un percorso sbagliato. L'ereditarietà è un vincolo molto forte (imho aggiungere "potere" è un'analogia sbagliata!) E crea una forte dipendenza tra le classi ereditate. Troppe dipendenze = codice errato :) Quindi l'ereditarietà (alias "si comporta come") brilla tipicamente per le interfacce unificate (= nascondere la complessità delle classi ereditate), per qualsiasi altra cosa pensare due volte o usare la composizione ...
MaR

2
Questa è una buona risposta +1. Almeno ai miei occhi, sembra che la complicazione in più possa essere un costo sostanziale, e questo mi ha reso personalmente un po 'titubante nel fare un grande affare preferendo la composizione. Soprattutto quando stai provando a creare un sacco di codice per testare l'unità attraverso interfacce, composizione e DI (so che questo sta aggiungendo altre cose), sembra molto facile far passare attraverso diversi file diversi per cercare molto pochi dettagli. Perché questo non viene menzionato più spesso, anche quando si tratta solo della composizione rispetto al principio del design ereditario?
Panzercrisis,

27

Il motivo per cui la gente dice questo è che i programmatori OOP principianti, appena usciti dalle loro lezioni sul polimorfismo attraverso l'ereditarietà, tendono a scrivere grandi classi con molti metodi polimorfici, e poi da qualche parte lungo la strada, finiscono con un pasticcio non mantenibile.

Un esempio tipico viene dal mondo dello sviluppo del gioco. Supponi di avere una classe base per tutte le tue entità di gioco - l'astronave del giocatore, i mostri, i proiettili, ecc .; ogni tipo di entità ha la sua sottoclasse. L'approccio eredità userebbe alcuni metodi polimorfi, ad esempio update_controls(), update_physics(), draw(), ecc, e attuarli per ogni sottoclasse. Tuttavia, questo significa che stai abbinando funzionalità non correlate: è irrilevante l'aspetto di un oggetto per spostarlo e non hai bisogno di sapere nulla sulla sua IA per disegnarlo. L'approccio compositivo definisce invece diverse classi di base (o interfacce), ad esempio EntityBrain(le sottoclassi implementano AI o input del giocatore), EntityPhysics(le sottoclassi implementano la fisica del movimento) e EntityPainter(le sottoclassi si occupano del disegno) e una classe non polimorficaEntityche contiene un'istanza di ciascuno. In questo modo, puoi combinare qualsiasi aspetto con qualsiasi modello fisico e qualsiasi intelligenza artificiale, e poiché li tieni separati, anche il tuo codice sarà molto più pulito. Inoltre, problemi come "Voglio un mostro che assomiglia al mostro palloncino nel livello 1, ma si comporta come il clown pazzo nel livello 15" scompaiono: devi semplicemente prendere i componenti adatti e incollarli insieme.

Si noti che l'approccio compositivo utilizza ancora l'ereditarietà all'interno di ciascun componente; sebbene idealmente useresti solo le interfacce e le loro implementazioni qui.

"Separazione delle preoccupazioni" è la frase chiave qui: rappresentare la fisica, implementare un'intelligenza artificiale e disegnare un'entità, sono tre preoccupazioni, combinarle in un'entità è una quarta. Con l'approccio compositivo, ogni preoccupazione è modellata come una classe.


1
Tutto questo va bene e sostenuto nell'articolo collegato alla domanda originale. Mi piacerebbe (ogni volta che ne avrai il tempo) se potessi fornire un semplice esempio che coinvolga tutte queste entità, pur mantenendo il polimorfismo (in C ++). O indicami una risorsa. Grazie.
MustafaM,

So che la tua risposta è molto antica, ma ho fatto la stessa cosa che hai menzionato nel mio gioco e ora vado per composizione sull'approccio ereditario.
Sneh,

"Voglio un mostro che assomigli ..." come ha senso questo? Un'interfaccia non fornisce alcuna implementazione, dovresti copiare l'aspetto e il codice di comportamento in un modo o nell'altro
NikkyD

1
@NikkyD Prendi MonsterVisuals balloonVisualse MonsterBehaviour crazyClownBehaviourcrea un'istanza a Monster(balloonVisuals, crazyClownBehaviour), insieme alle istanze Monster(balloonVisuals, balloonBehaviour)e Monster(crazyClownVisuals, crazyClownBehaviour)che sono state istanziate al livello 1 e livello 15
Caleth

13

L'esempio che hai dato è quello in cui l'ereditarietà è la scelta naturale. Non credo che qualcuno affermerebbe che la composizione sia sempre una scelta migliore dell'eredità - è solo una linea guida che significa che spesso è meglio assemblare diversi oggetti relativamente semplici piuttosto che creare molti oggetti altamente specializzati.

La delega è un esempio di un modo di usare la composizione anziché l'eredità. La delega consente di modificare il comportamento di una classe senza sottoclasse. Prendi in considerazione una classe che fornisce una connessione di rete, NetStream. Potrebbe essere naturale sottoclassare NetStream per implementare un protocollo di rete comune, quindi potresti trovare FTPStream e HTTPStream. Invece di creare una sottoclasse HTTPStream molto specifica per un singolo scopo, ad esempio UpdateMyWebServiceHTTPStream, spesso è meglio usare una semplice vecchia istanza di HTTPStream insieme a un delegato che sa cosa fare con i dati che riceve da quell'oggetto. Uno dei motivi per cui è meglio è che evita una proliferazione di classi che devono essere mantenute ma che non sarai mai in grado di riutilizzare.


11

Vedrai molto questo ciclo nel discorso sullo sviluppo del software:

  1. Alcune funzionalità o pattern (chiamiamolo "Pattern X") vengono scoperti come utili per uno scopo particolare. I post sul blog sono scritti esaltando le virtù di Pattern X.

  2. L'hype porta alcune persone a pensare che dovresti usare Pattern X ogni volta che è possibile .

  3. Altre persone si arrabbiano nel vedere Pattern X usato in contesti in cui non è appropriato, e scrivono post sul blog affermando che non dovresti sempre usare Pattern X e che è dannoso in alcuni contesti.

  4. Il contraccolpo fa credere ad alcune persone che il modello X sia sempre dannoso e non debba mai essere usato.

Vedrai che questo ciclo di hype / backlash si verifica con quasi tutte le funzionalità da GOTOpattern a SQL, NoSQL e, sì, ereditarietà. L'antidoto è considerare sempre il contesto .

Avendo Circlediscendono da Shapeè esattamente come dovrebbe eredità per essere utilizzato in linguaggi OO che supportano l'ereditarietà.

La regola empirica "preferisce la composizione all'eredità" è davvero fuorviante senza contesto. Dovresti preferire l'ereditarietà quando l'ereditarietà è più appropriata, ma preferisci la composizione quando la composizione è più appropriata. La frase è indirizzata alle persone nella fase 2 del ciclo pubblicitario, che pensano che l'eredità dovrebbe essere usata ovunque. Ma il ciclo è andato avanti e oggi sembra che alcune persone pensino che l'eredità sia in qualche modo cattiva in sé.

Pensalo come un martello contro un cacciavite. Preferisci un cacciavite a un martello? La domanda non ha senso. Dovresti usare lo strumento appropriato per il lavoro, e tutto dipende da quale compito devi svolgere.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.