Perché dovrei evitare l’ereditarietà multipla in C ++?

È un buon concetto utilizzare l’ereditarietà multipla o posso invece fare altre cose?

Odori di ereditarietà multipla (abbreviato in MI), il che significa che di solito è stato fatto per cattive ragioni e si ripercuote in faccia al manutentore.

Sommario

  1. Considera la composizione delle caratteristiche, invece dell’ereditarietà
  2. Fai attenzione al Diamante del Terrore
  3. Considera l’ereditarietà di più interfacce anziché oggetti
  4. A volte, l’ereditarietà multipla è la cosa giusta. Se lo è, quindi usarlo.
  5. Preparati a difendere la tua architettura ereditaria multipla nelle revisioni del codice

1. Forse composizione?

Questo è vero per l’ereditarietà, e così è ancora più vero per l’ereditarietà multipla.

Il tuo object ha davvero bisogno di ereditare da un altro? Car non ha bisogno di ereditare da un Engine per funzionare, né da una Wheel . Una Car ha un Engine e quattro Wheel .

Se usi l’ereditarietà multipla per risolvere questi problemi anziché la composizione, hai fatto qualcosa di sbagliato.

2. Il diamante del terrore

Di solito, hai una class A , quindi B e C ereditano entrambi dalla A E (non chiedermi perché) qualcuno decide che D deve ereditare sia da B che da C

Ho incontrato questo tipo di problema due volte in 8 anni, ed è divertente vedere a causa di:

  1. Quanto di un errore è stato dall’inizio (in entrambi i casi, D non avrebbe dovuto ereditare sia da B che da C ), perché questa era una ctriggers architettura (infatti, C non avrebbe dovuto esistere affatto …)
  2. Quanti sostenitori lo stavano pagando, perché in C ++, la class genitrice A era presente due volte nella sua class di nipote D , e quindi, aggiornando un campo genitore, il campo A::field significava che lo aggiornava due volte (tramite B::field e C::field ), o se qualcosa va in silenzio e si blocca in un secondo momento (nuovo puntatore nel B::field ed elimina C::field …)

Usare la parola chiave virtual in C ++ per qualificare l’ereditarietà evita il doppio layout descritto sopra se questo non è quello che vuoi, ma comunque, nella mia esperienza, probabilmente stai facendo qualcosa di sbagliato …

Nella gerarchia Object, dovresti cercare di mantenere la gerarchia come un albero (un nodo ha UNO genitore), non come un grafico.

Maggiori informazioni sul diamante (modifica 2017-05-03)

Il vero problema con il Diamond of Dread in C ++ ( supponendo che il design sia il suono – ha rivisto il tuo codice! ), È che devi fare una scelta :

  • È auspicabile che la class A esista due volte nel tuo layout e che cosa significa? Se sì, allora con tutti i mezzi ereditarlo due volte.
  • se dovrebbe esistere solo una volta, ereditarlo virtualmente.

Questa scelta è inerente al problema, e in C ++, a differenza di altri linguaggi, è ansible farlo senza dogma forzando la progettazione a livello linguistico.

Ma come tutti i poteri, con questo potere viene la responsabilità: fai revisionare il tuo progetto.

3. Interfacce

L’ereditarietà multipla di zero o di una class concreta, e zero o più interfacce è di solito Ok, perché non incontrerai il Diamond of Dread descritto sopra. In realtà, questo è il modo in cui le cose sono fatte in Java.

Di solito, cosa intendi quando C eredita da A e B è che gli utenti possono usare C come se fosse un A e / o come se fosse un B

In C ++, un’interfaccia è una class astratta che ha:

  1. tutto il suo metodo dichiarato puro virtuale (suffisso da = 0) (rimosso il 2017-05-03)
  2. nessuna variabile membro

L’ereditarietà multipla di zero su un object reale e zero o più interfacce non è considerata “puzzolente” (almeno, non tanto).

Ulteriori informazioni sull’interfaccia astratta C ++ (modifica 2017-05-03)

Primo, il pattern NVI può essere usato per produrre un’interfaccia, perché il vero criterio è quello di non avere uno stato (cioè nessuna variabile membro, eccetto this ). Il punto dell’interfaccia astratta è pubblicare un contratto (“puoi chiamarmi in questo modo, e in questo modo”), niente di più, niente di meno. La limitazione di avere solo il metodo virtuale astratto dovrebbe essere una scelta progettuale, non un obbligo.

Secondo, in C ++, ha senso ereditare virtualmente da interfacce astratte (anche con il costo aggiuntivo / indiretto). Se non lo fai, e l’ereditarietà dell’interfaccia appare più volte nella tua gerarchia, allora avrai delle ambiguità.

Terzo, l’orientamento agli oggetti è grande, ma non è l’unica verità fuori dal mondo in C ++. Usa gli strumenti giusti e ricorda sempre di avere altri paradigmi in C ++ che offrono diversi tipi di soluzioni.

4. Hai veramente bisogno dell’eredità multipla?

A Volte si.

Di solito, la tua class C eredita da A e B , e A e B sono due oggetti non correlati (cioè non nella stessa gerarchia, niente in comune, concetti diversi, ecc.).

Ad esempio, potresti avere un sistema di Nodes con coordinate X, Y, Z, in grado di fare molti calcoli geometrici (forse un punto, parte di oggetti geometrici) e ogni Nodo è un Agente Automatico, in grado di comunicare con altri agenti .

Forse hai già accesso a due librerie, ognuna con il proprio spazio dei nomi (un altro motivo per usare gli spazi dei nomi … Ma tu usi gli spazi dei nomi, vero?), Uno è geo e l’altro è ai

Quindi hai il tuo own::Node derivare sia da ai::Agent che da geo::Point .

Questo è il momento in cui dovresti chiederti se non dovresti usare la composizione. Se own::Node è davvero davvero sia un ai::Agent che un geo::Point , allora la composizione non funzionerà.

Quindi avrai bisogno di ereditarietà multipla, avendo il tuo own::Node comunicazione con altri agenti in base alla loro posizione in uno spazio 3D.

(Noterai che ai::Agent e geo::Point sono completamente, totalmente, completamente NON RIDOTTI … Questo riduce drasticamente il pericolo di ereditarietà multipla)

Altri casi (modifica 2017-05-03)

Ci sono altri casi:

  • utilizzo dell’eredità (si spera privatamente) come dettaglio di implementazione
  • alcuni idiomi C ++ come le politiche potrebbero utilizzare l’ereditarietà multipla (quando ogni parte ha bisogno di comunicare con gli altri attraverso this )
  • l’ereditarietà virtuale di std :: exception ( È necessaria l’ereditarietà virtuale per le eccezioni? )
  • eccetera.

A volte puoi usare la composizione e talvolta MI è migliore. Il punto è: hai una scelta. Fallo responsabilmente (e verifica la revisione del tuo codice).

5. Quindi, dovrei fare l’ereditarietà multipla?

Il più delle volte, nella mia esperienza, no. MI non è lo strumento giusto, anche se sembra funzionare, perché può essere usato dai pigri per raggruppare le caratteristiche insieme senza rendersi conto delle conseguenze (come fare una Car sia un Engine che una Wheel ).

Ma a volte sì. E a quel tempo, niente funzionerà meglio di MI.

Ma poiché MI è maleodorante, preparati a difendere la tua architettura nelle revisioni del codice (e difenderla è una buona cosa, perché se non sei in grado di difenderla, allora non dovresti farlo).

Da un’intervista con Bjarne Stroustrup :

Le persone dicono giustamente che non hai bisogno di ereditarietà multipla, perché tutto ciò che puoi fare con l’ereditarietà multipla puoi fare anche con l’ereditarietà singola. Hai appena usato il trucco di delega che ho menzionato. Inoltre, non hai bisogno di alcuna ereditarietà, perché qualsiasi cosa tu faccia con l’ereditarietà singola puoi anche fare a meno dell’eredità inoltrandola attraverso una class. In realtà, non hai bisogno di nessuna class, perché puoi fare tutto con i puntatori e le strutture dati. Ma perché vorresti farlo? Quando è conveniente usare le strutture linguistiche? Quando preferiresti una soluzione alternativa? Ho visto casi in cui l’ereditarietà multipla è utile, e ho persino visto casi in cui è utile un’ereditarietà multipla piuttosto complicata. In genere, preferisco utilizzare le funzionalità offerte dalla lingua per risolvere i problemi

Non c’è motivo di evitarlo e può essere molto utile in situazioni. È necessario essere consapevoli dei potenziali problemi però.

Il più grande è il diamante della morte:

 class GrandParent; class Parent1 : public GrandParent; class Parent2 : public GrandParent; class Child : public Parent1, public Parent2; 

Ora hai due “copie” di GrandParent in Child.

C ++ ha pensato a questo però e ti permette di fare dell’ereditarietà virtuale per aggirare i problemi.

 class GrandParent; class Parent1 : public virtual GrandParent; class Parent2 : public virtual GrandParent; class Child : public Parent1, public Parent2; 

Rivedere sempre la progettazione, assicurarsi di non utilizzare l’ereditarietà per risparmiare sul riutilizzo dei dati. Se puoi rappresentare la stessa cosa con la composizione (e tipicamente puoi farlo) questo è un approccio molto migliore.

Vedi w: Eredità multipla .

L’ereditarietà multipla ha ricevuto critiche e in quanto tale non è implementata in molte lingue. Le critiche includono:

  • Maggiore complessità
  • Ambiguità semantica spesso riassunta come problema dei diamanti .
  • Non essere in grado di ereditare esplicitamente più volte da una singola class
  • Ordine di ereditarietà che cambia la semantica delle classi.

L’ereditarietà multipla in linguaggi con costruttori in stile C ++ / Java esacerba il problema dell’ereditarietà dei costruttori e del concatenamento del costruttore, creando in tal modo problemi di manutenzione ed estensibilità in questi linguaggi. Gli oggetti nelle relazioni di ereditarietà con metodi di costruzione molto diversi sono difficili da implementare sotto il paradigma del concatenamento del costruttore.

Modo moderno di risolvere questo per usare l’interfaccia (pura class astratta) come l’interfaccia COM e Java.

Posso fare altre cose al posto di questo?

Si, puoi. Vado a rubare da GoF .

  • Programma su un’interfaccia, non su un’implementazione
  • Preferisci la composizione rispetto all’ereditarietà

L’ereditarietà pubblica è una relazione IS-A, e a volte una class sarà un tipo di classi diverse e talvolta è importante riflettere questo.

Anche i “Mixin” sono talvolta utili. Sono generalmente classi piccole, di solito non ereditano nulla, forniscono funzionalità utili.

Finché la gerarchia dell’ereditarietà è piuttosto superficiale (come dovrebbe quasi sempre essere) e ben gestita, è improbabile che tu possa ottenere l’eredità del temuto diamante. Il diamante non è un problema con tutte le lingue che usano l’ereditarietà multipla, ma il trattamento da parte del C ++ è spesso scomodo e talvolta sconcertante.

Mentre ho incontrato casi in cui l’ereditarietà multipla è molto utile, in realtà sono abbastanza rari. Questo è probabile perché preferisco utilizzare altri metodi di progettazione quando non ho davvero bisogno di ereditarietà multipla. Preferisco evitare di confondere costrutti linguistici, ed è facile build casi di ereditarietà in cui devi leggere il manuale molto bene per capire cosa sta succedendo.

Non si dovrebbe “evitare” l’ereditarietà multipla ma si dovrebbe essere consapevoli dei problemi che possono sorgere come il “problema dei diamanti” ( http://en.wikipedia.org/wiki/Diamond_problem ) e trattare con cura il potere che ti è stato dato , come dovresti con tutti i poteri.

Dovresti usarlo con attenzione, ci sono alcuni casi, come il Diamond Problem , quando le cose possono complicarsi.

alt text http://sofit.miximages.com/c%2B%2B/PoweredDevice.gif

Ogni linguaggio di programmazione ha un trattamento leggermente diverso della programmazione orientata agli oggetti con pro e contro. La versione di C ++ pone l’accento sulle prestazioni e ha il rovescio della medaglia che è fastidiosamente facile scrivere codice non valido – e questo è vero per l’ereditarietà multipla. Di conseguenza c’è una tendenza a guidare i programmatori lontano da questa funzione.

Altre persone hanno affrontato la questione di ciò per cui l’ereditarietà multipla non è adatta. Ma abbiamo visto parecchi commenti che più o meno implicano che la ragione per evitarlo è perché non è sicuro. Bene, sì e no.

Come è spesso vero in C ++, se si segue una linea guida di base si può usare tranquillamente senza dover “guardare oltre le spalle” costantemente. L’idea chiave è che si distingue un tipo speciale di definizione di class chiamato “mix-in”; class è un mix-in se tutte le sue funzioni membro sono virtuali (o pure virtuali). Quindi ti è permesso ereditare da una singola class principale e da tutti i “mix-in” che desideri, ma dovresti ereditare mixins con la parola chiave “virtuale”. per esempio

 class CounterMixin { int count; public: CounterMixin() : count( 0 ) {} virtual ~CounterMixin() {} virtual void increment() { count += 1; } virtual int getCount() { return count; } }; class Foo : public Bar, virtual public CounterMixin { ..... }; 

Il mio suggerimento è che se intendi usare una class come class mista, adotti anche una convenzione di denominazione per rendere più semplice per chiunque legga il codice per vedere cosa sta succedendo e per verificare che stai giocando secondo le regole della linea guida di base . E troverai che funziona molto meglio se i tuoi mix-in hanno anche costruttori predefiniti, solo per il modo in cui funzionano le classi base virtuali. E ricorda di rendere virtuali anche tutti i distruttori.

Nota che il mio uso della parola “mix-in” qui non è lo stesso della class template parametrizzata (vedi questo link per una buona spiegazione) ma penso che sia un uso corretto della terminologia.

Ora non voglio dare l’impressione che questo sia l’unico modo per usare l’ereditarietà in modo sicuro. È solo un modo che è abbastanza facile da controllare.

A rischio di diventare un po ‘astratto, trovo illuminante pensare all’eredità all’interno della teoria delle categorie.

Se pensiamo a tutte le nostre classi e frecce tra loro che denotano relazioni di eredità, allora qualcosa del genere

 A --> B 

significa che la class B deriva dalla class A Si noti che, dato

 A --> B, B --> C 

diciamo che C deriva da B che deriva da A, quindi anche C deriva da A, quindi

 A --> C 

Inoltre, diciamo che per ogni class A che banalmente A deriva da A , quindi il nostro modello di successione soddisfa la definizione di una categoria. Nel linguaggio più tradizionale, abbiamo una categoria Class con oggetti tutte le classi e morfismi i rapporti di ereditarietà.

È un po ‘di setup, ma diamo un’occhiata al nostro Diamond of Doom:

 C --> D ^ ^ | | A --> B 

È un diagramma dall’aspetto ombroso, ma lo farà. Quindi D eredita da tutti A , B e C Inoltre, e avvicinandosi all’affrontare la domanda dell’OP, D eredita anche da qualsiasi superclass di A Possiamo disegnare un diagramma

 C --> D --> R ^ ^ | | A --> B ^ | Q 

Ora, i problemi associati al diamante della morte qui sono quando C e B condividono alcuni nomi di proprietà / metodo e le cose diventano ambigue; tuttavia, se spostiamo qualsiasi comportamento condiviso in A l’ambiguità scompare.

In termini categoriali, vogliamo che A , B e C siano tali che se B e C ereditano da Q allora A può essere riscritto come sottoclass di Q Questo rende A qualcosa chiamato pushout .

C’è anche una costruzione simmetrica su D chiamata pullback . Questa è essenzialmente la class utile più generale che puoi build e che eredita sia da B che da C Cioè, se hai un’altra class R moltiplica ereditando da B e C , allora D è una class in cui R può essere riscritta come sottoclass di D

Assicurandoti che i tuoi consigli sul diamante siano dei pullback e dei pushouts ci dà un buon modo per gestire genericamente problemi di “name-clashing” o di manutenzione che potrebbero sorgere altrimenti.

Nota La risposta di Paercebal ha ispirato questo come le sue ammonizioni sono implicite dal modello di cui sopra dato che lavoriamo nella class di tutte le classi di tutte le classi possibili.

Volevo generalizzare la sua argomentazione a qualcosa che mostra quanto complicate relazioni di eredità multiple possano essere sia potenti che non problematiche.

TL; DR Pensa alle relazioni di ereditarietà nel tuo programma come a una categoria. Quindi puoi evitare i problemi di Diamond of Doom facendo pushouts di classi ereditate da multipli e simmetricamente, creando una class genitore comune che è un pullback.

Noi usiamo Eiffel. Abbiamo un MI eccellente. Nessun problema. Senza problemi. Gestito facilmente. Ci sono volte per NON usare l’MI. Tuttavia, è utile più di quanto le persone si rendano conto perché sono: A) in un linguaggio pericoloso che non lo gestisce bene -OR- B) soddisfatto di come hanno lavorato intorno a MI per anni e anni -OR- C) per altri motivi ( Troppo numerosi da elencare sono abbastanza sicuro – vedi le risposte sopra).

Per noi, usando Eiffel, MI è naturale come qualsiasi altra cosa e un altro strumento eccellente nella casella degli strumenti. Francamente, non ci preoccupiamo del fatto che nessun altro stia usando Eiffel. Nessun problema. Siamo contenti di ciò che abbiamo e ti invitiamo a dare un’occhiata.

Mentre stai osservando: Prendi nota speciale di Void-safety e dell’eradicazione del dereferenziamento del puntatore Null. Mentre balliamo tutti intorno a MI, i tuoi consigli si perdono! 🙂

Usi e abusi dell’eredità.

L’articolo fa un grande lavoro di spiegazione dell’eredità, ed è pericoli.

Oltre il modello a rombi, l’ereditarietà multipla tende a rendere il modello object più difficile da comprendere, il che a sua volta aumenta i costi di manutenzione.

La composizione è intrinsecamente facile da comprendere, comprendere e spiegare. Può essere noioso scrivere codice, ma un buon IDE (sono passati alcuni anni da quando ho lavorato con Visual Studio, ma certamente gli IDE Java hanno tutti degli ottimi strumenti per l’automazione dei collegamenti di composizione) dovrebbero superare questo ostacolo.

Inoltre, in termini di manutenzione, il “problema dei diamanti” si presenta anche in istanze di ereditarietà non letterale. Ad esempio, se hai A e B e la tua class C li estende entrambi, e A ha un metodo ‘makeJuice’ che produce il succo d’arancia e lo estendi per fare il succo d’arancia con un touch di lime: cosa succede quando il designer per ‘ B ‘aggiunge un metodo’ makeJuice ‘che genera e corrente elettrica? “A” e “B” potrebbero essere “genitori” compatibili al momento , ma ciò non significa che lo saranno sempre!

Nel complesso, la massima tendenziale per evitare l’ereditarietà, e in particolare l’ereditarietà multipla, è solida. Come tutte le massime, ci sono delle eccezioni, ma devi assicurarti che ci sia un’insegna al neon verde lampeggiante che punta a tutte le eccezioni che codifichi (e allena il tuo cervello in modo che ogni volta che vedi questi alberi ereditari disegni il tuo stesso neon verde lampeggiante segno), e che controlli per assicurarsi che tutto abbia senso ogni tanto.

Il problema chiave con MI di oggetti concreti è che raramente si dispone di un object che legittimamente dovrebbe essere “Essere un A AND essere un B”, quindi raramente è la soluzione corretta su basi logiche. Molto più spesso, hai un object C che obbedisce a “C può agire come A o B”, che puoi ottenere tramite l’ereditarietà e la composizione dell’interfaccia. Ma non fare errori: l’ereditarietà di più interfacce è ancora MI, solo un sottoinsieme di esso.

In particolare per C ++, la principale debolezza della funzionalità non è l’effettiva ESISTENZA dell’ereditarietà multipla, ma alcuni costrutti permettono che siano quasi sempre malformati. Ad esempio, ereditando più copie dello stesso object come:

 class B : public A, public A {}; 

è malformato PER DEFINIZIONE. Tradotto in inglese, questo è “B è un A e un A”. Quindi, anche nel linguaggio umano c’è una severa ambiguità. Intendevi dire “B ha 2 come” o semplicemente “B è un A” ?. Consentendo un tale codice patologico, e peggio di averlo reso un esempio di utilizzo, C ++ non ha fatto favori quando si trattava di fare un caso per mantenere la funzionalità nei linguaggi successivi.

Puoi usare la composizione preferibilmente per ereditarietà.

La sensazione generale è che la composizione sia migliore, ed è molto ben discussa.

ci vogliono 4/8 byte per class coinvolti. (Uno questo puntatore per class).

Questo potrebbe non essere mai un problema, ma se un giorno avessi una struttura di microdati che sarà istanziata di miliardi di volte.