"Preferire la composizione rispetto all'eredità" è solo una buona euristica
Dovresti considerare il contesto, poiché questa non è una regola universale. Non prenderlo nel senso che non usi mai l'eredità quando puoi fare composizione. In tal caso, lo risolveresti vietando l'ereditarietà.
Spero di chiarire questo punto lungo questo post.
Non proverò a difendere i meriti della composizione da solo. Che considero fuori tema. Parlerò invece di alcune situazioni in cui lo sviluppatore può considerare l'eredità che sarebbe meglio usare la composizione. In quella nota, l'eredità ha i suoi meriti, che considero anche fuori tema.
Esempio di veicolo
Sto scrivendo di sviluppatori che cercano di fare cose stupide, per scopi narrativi
Facciamo una variante degli esempi classici che alcuni corsi OOP usano ... Abbiamo a Vehicle
classe, allora noi deriviamo Car
, Airplane
, Balloon
e Ship
.
Nota : se devi fondare questo esempio, fai finta che si tratti di tipi di oggetti in un videogioco.
Poi Car
e Airplane
potrebbe avere un codice comune, perché entrambi possono rotolare sulla ruota a terra. A tale scopo, gli sviluppatori potrebbero prendere in considerazione la creazione di una classe intermedia nella catena di eredità. Tuttavia, in realtà esiste anche un codice condiviso tra Airplane
e Balloon
. Potrebbero prendere in considerazione la possibilità di creare un'altra classe intermedia nella catena dell'eredità.
Pertanto, lo sviluppatore sarebbe alla ricerca di ereditarietà multipla. Nel punto in cui gli sviluppatori sono alla ricerca di eredità multipla, il design è già andato storto.
È meglio modellare questo comportamento come interfacce e composizione, in modo da poterlo riutilizzare senza dover imbattersi in eredità di più classi. Se gli sviluppatori, ad esempio, creano la FlyingVehicule
classe. Direbbero che Airplane
è una FlyingVehicule
(eredità di classe), ma potremmo invece dire che Airplane
ha un Flying
componente (composizione) ed Airplane
è una IFlyingVehicule
(eredità dell'interfaccia).
Utilizzando le interfacce, se necessario, possiamo avere ereditarietà multiple (di interfacce). Inoltre, non si esegue l'accoppiamento a un'implementazione particolare. Aumentare la riusabilità e la testabilità del codice.
Ricorda che l'eredità è uno strumento per il polimorfismo. Inoltre, il polimorfismo è uno strumento per la riusabilità. Se puoi aumentare la riusabilità del tuo codice usando la composizione, allora fallo. Se non sei sicuro di quale sia la composizione che fornisca una migliore riusabilità, "Preferisci la composizione rispetto all'eredità" è una buona euristica.
Tutto ciò senza menzionare Amphibious
.
In effetti, potremmo non aver bisogno di cose che cadono da terra. Stephen Hurn ha un esempio più eloquente nei suoi articoli "Favor Composition Over Inheritance", parte 1 e parte 2 .
Sostituibilità e incapsulamento
Dovrebbero A
ereditare o comporre B
?
Se A
è una specializzazione di B
ciò che dovrebbe soddisfare il principio di sostituzione di Liskov , l'eredità è praticabile, persino desiderabile. Se ci sono situazioni in cuiA
non è una sostituzione valida per B
allora non dovremmo usare l'ereditarietà.
Potremmo essere interessati alla composizione come una forma di programmazione difensiva, per difendere la classe derivata . In particolare, una volta che inizi a utilizzarlo B
per altri scopi diversi, potrebbe esserci la pressione per modificarlo o estenderlo per renderlo più adatto a tali scopi. Se esiste il rischio che B
potrebbe esporre metodi che potrebbero determinare uno stato non valido A
, dovremmo utilizzare la composizione anziché l'ereditarietà. Anche se siamo l'autore di entrambi B
e A
, è una cosa in meno di cui preoccuparsi, quindi la composizione facilita la riusabilità diB
.
Potremmo anche sostenere che se ci sono funzionalità B
che A
non sono necessarie (e non sappiamo se tali funzionalità potrebbero risultare in uno stato non valido A
, sia nella presente implementazione che in futuro), è una buona idea usare la composizione invece dell'eredità.
La composizione ha anche i vantaggi di consentire la commutazione delle implementazioni e di facilitare la derisione.
Nota : ci sono situazioni in cui vogliamo usare la composizione nonostante la sostituzione sia valida. Archiviamo tale sostituibilità utilizzando interfacce o classi astratte (che una da usare quando è un altro argomento) e quindi usiamo la composizione con l'iniezione di dipendenza dell'implementazione reale.
Infine, ovviamente, c'è l'argomento secondo cui dovremmo usare la composizione per difendere la classe genitore perché l'eredità interrompe l'incapsulamento della classe genitore:
L'ereditarietà espone una sottoclasse ai dettagli dell'implementazione del genitore, si dice spesso che "l'ereditarietà rompe l'incapsulamento"
- Modelli di progettazione: elementi di software riutilizzabile orientato agli oggetti, Gang of Four
Bene, questa è una classe genitore mal progettata. Ecco perché dovresti:
Progettare per l'eredità o vietarlo.
- Efficace Java, Josh Bloch
Problema Yo-Yo
Un altro caso in cui la composizione aiuta è il problema Yo-Yo . Questa è una citazione da Wikipedia:
Nello sviluppo del software, il problema yo-yo è un anti-pattern che si verifica quando un programmatore deve leggere e comprendere un programma il cui grafico dell'ereditarietà è così lungo e complicato che il programmatore deve continuare a sfogliare tra molte diverse definizioni di classe per seguire il flusso di controllo del programma.
Puoi risolvere, ad esempio: La tua classe C
non erediterà dalla classe B
. Invece la tua classe C
avrà un membro di tipo A
, che può o meno essere (o avere) un oggetto di tipo B
. In questo modo non programmerai contro i dettagli di implementazione di B
, ma contro il contratto che l'interfaccia (di) A
offre.
Esempi contrari
Molti framework favoriscono l'ereditarietà rispetto alla composizione (che è l'opposto di ciò di cui abbiamo discusso). Lo sviluppatore può farlo perché ha messo molto lavoro nella sua classe di base che averlo implementato con la composizione avrebbe aumentato le dimensioni del codice client. A volte questo è dovuto alle limitazioni della lingua.
Ad esempio, un framework PHP ORM può creare una classe base che utilizza metodi magici per consentire la scrittura di codice come se l'oggetto avesse proprietà reali. Invece il codice gestito dai metodi magici andrà al database, eseguirà una query per il particolare campo richiesto (forse memorizzalo nella cache per richieste future) e lo restituirà. Farlo con la composizione richiederebbe al client di creare proprietà per ciascun campo o di scrivere una versione del codice dei metodi magici.
Addendum : Esistono altri modi per consentire l'estensione degli oggetti ORM. Pertanto, non credo che l'ereditarietà sia necessaria in questo caso. È più economico.
Per un altro esempio, un motore di videogioco può creare una classe base che utilizza codice nativo in base alla piattaforma di destinazione per eseguire il rendering 3D e la gestione degli eventi. Questo codice è complesso e specifico per la piattaforma. Sarebbe costoso e soggetto a errori per l'utente sviluppatore del motore gestire questo codice, in realtà, ciò è parte del motivo dell'utilizzo del motore.
Inoltre, senza la parte di rendering 3D, ecco come funzionano i framework di widget. Questo ti libera dal preoccuparti della gestione dei messaggi del sistema operativo ... in effetti, in molte lingue non puoi scrivere tale codice senza una qualche forma di supporto nativo. Inoltre, se dovessi farlo, restringerebbe la tua portabilità. Invece, con l'ereditarietà, purché lo sviluppatore non rompa la compatibilità (troppo); sarai in grado di trasferire facilmente il tuo codice su qualsiasi nuova piattaforma che supporteranno in futuro.
Inoltre, considera che molte volte vogliamo solo sostituire alcuni metodi e lasciare tutto il resto con le implementazioni predefinite. Se avessimo usato la composizione avremmo dovuto creare tutti quei metodi, anche se solo per delegare all'oggetto spostato.
Con questo argomento, c'è un punto in cui la composizione può essere peggiore per manutenibilità rispetto all'ereditarietà (quando la classe base è troppo complessa). Tuttavia, ricorda che la manutenibilità dell'eredità può essere peggiore di quella della composizione (quando l'albero dell'ereditarietà è troppo complesso), che è ciò a cui mi riferisco nel problema yo-yo.
Negli esempi presentati, gli sviluppatori intendono raramente riutilizzare il codice generato tramite ereditarietà in altri progetti. Ciò mitiga la riusabilità ridotta dell'uso dell'eredità anziché della composizione. Inoltre, utilizzando l'ereditarietà, gli sviluppatori del framework possono fornire molto codice facile da usare e da scoprire.
Pensieri finali
Come puoi vedere, la composizione presenta alcuni vantaggi rispetto all'ereditarietà in alcune situazioni, non sempre. È importante prendere in considerazione il contesto e i diversi fattori coinvolti (come riusabilità, manutenibilità, testabilità, ecc.) Per prendere la decisione. Torna al primo punto: "Preferisco la composizione per l'eredità" è un solo buona euristica.
Potresti anche notare che molte delle situazioni che descrivo possono essere risolte in qualche modo con Tratti o Mixin. Purtroppo, queste non sono caratteristiche comuni nella grande lista di lingue e di solito hanno un costo in termini di prestazioni. Per fortuna, i loro popolari metodi di estensione cugina e le implementazioni predefinite mitigano alcune situazioni.
Ho un post recente in cui parlo di alcuni dei vantaggi delle interfacce sul perché richiediamo interfacce tra UI, Business e accesso ai dati in C # . Aiuta a disaccoppiare e facilita la riusabilità e la testabilità, potresti essere interessato.