Qual è il problema esatto con l’ereditarietà multipla?

Riesco a vedere persone che chiedono sempre se l’ereditarietà multipla dovrebbe essere inclusa nella prossima versione di C # o Java. Le persone C ++, che hanno la fortuna di avere questa capacità, dicono che è come dare a qualcuno una corda per impiccarsi.

Qual è il problema con l’ereditarietà multipla? Ci sono campioni di cemento?

Il problema più ovvio è con la funzione di sovrascrittura.

Diciamo che abbiamo due classi A e B, che definiscono entrambi un metodo “doSomething”. Ora si definisce una terza class C, che eredita sia da A che da B, ma non si sostituisce il metodo “doSomething”.

Quando il compilatore semina questo codice …

C c = new C(); c.doSomething(); 

… quale implementazione del metodo dovrebbe usare? Senza ulteriori chiarimenti, è imansible per il compilatore risolvere l’ambiguità.

Oltre a scavalcare, l’altro grande problema con l’ereditarietà multipla è il layout degli oggetti fisici nella memoria.

Linguaggi come C ++ e Java e C # creano un layout fisso basato sull’indirizzo per ogni tipo di object. Qualcosa come questo:

 class A: at offset 0 ... "abc" ... 4 byte int field at offset 4 ... "xyz" ... 8 byte double field at offset 12 ... "speak" ... 4 byte function pointer class B: at offset 0 ... "foo" ... 2 byte short field at offset 2 ... 2 bytes of alignment padding at offset 4 ... "bar" ... 4 byte array pointer at offset 8 ... "baz" ... 4 byte function pointer 

Quando il compilatore genera codice macchina (o codice byte), utilizza gli offset numerici per accedere a ciascun metodo o campo.

L’ereditarietà multipla lo rende molto complicato.

Se la class C eredita sia da A che da B, il compilatore deve decidere se impaginare i dati nell’ordine AB o nell’ordine BA.

Ma ora immagina di chiamare metodi su un object B. È davvero solo una B? O è in realtà un object C chiamato polimorficamente, attraverso la sua interfaccia B? A seconda dell’id quadro reale dell’object, il layout fisico sarà diverso e sarà imansible conoscere l’offset della funzione da richiamare sul sito di chiamata.

Il modo di gestire questo tipo di sistema è di abbandonare l’approccio a layout fisso, consentendo a ogni object di essere interrogato per il suo layout prima di tentare di richiamare le funzioni o accedere ai suoi campi.

Quindi … per farla breve … è un dolore al collo per gli autori di compilatori per supportare l’ereditarietà multipla. Quindi, quando qualcuno come Guido van Rossum disegna python, o quando Anders Hejlsberg disegna c #, sanno che il supporto dell’ereditarietà multipla renderà le implementazioni del compilatore molto più complesse e presumibilmente non ritengono che il vantaggio valga il costo.

I problemi che voi menzionate non sono poi così difficili da risolvere. Infatti, ad esempio, Eiffel lo fa perfettamente! (e senza introdurre scelte arbitrarie o qualsiasi altra cosa)

Ad esempio, se erediti da A e B, entrambi con metodo foo (), allora ovviamente non vuoi una scelta arbitraria nella tua class C ereditando da entrambi A e B. Devi ridefinire foo quindi è chiaro quale sarà usato se c.foo () è chiamato o altrimenti devi rinominare uno dei metodi in C. (potrebbe diventare bar ())

Inoltre penso che l’ereditarietà multipla sia spesso abbastanza utile. Se guardi le librerie di Eiffel vedrai che è usato dappertutto e personalmente ho perso la funzione quando ho dovuto tornare alla programmazione in Java.

Il problema dei diamanti :

un’ambiguità che sorge quando due classi B e C ereditano da A, e la class D eredita sia da B che da C. Se c’è un metodo in A che B e C hanno sovrascritto , e D non lo sovrascrive, allora quale versione del il metodo D eredita: quello di B, o quello di C?

… È chiamato il “problema dei diamanti” a causa della forma del diagramma di ereditarietà della class in questa situazione. In questo caso, la class A è in cima, sia B che C separatamente sotto di essa, e D unisce i due insieme in basso per formare una forma a diamante …

L’ereditarietà multipla è una di quelle cose che non vengono utilizzate spesso e che possono essere utilizzate in modo improprio, ma a volte sono necessarie.

Non ho mai capito di non aggiungere una funzione, solo perché potrebbe essere usata in modo improprio, quando non ci sono alternative valide. Le interfacce non sono un’alternativa all’ereditarietà multipla. Per uno, non ti permettono di far rispettare le precondizioni o le postcondizioni. Proprio come qualsiasi altro strumento, è necessario sapere quando è opportuno utilizzarlo e come usarlo.

Diciamo che avete gli oggetti A e B che sono entrambi ereditati da C. A e B entrambi implementano foo () e C no. Chiamo C.foo (). Quale implementazione viene scelta? Ci sono altri problemi, ma questo tipo di cose è grande.

Il problema principale con l’ereditarietà multipla è ben riassunto con l’esempio di tloach. Quando si eredita da più classi base che implementano la stessa funzione o campo, il compilatore deve prendere una decisione su quale implementazione ereditare.

Questo peggiora quando si eredita da più classi che ereditano dalla stessa class base. (eredità del diamante, se si disegna l’albero dell’eredità si ottiene una forma a rombo)

Questi problemi non sono davvero problematici da superare per un compilatore. Ma la scelta che il compilatore deve fare qui è piuttosto arbitraria, questo rende il codice molto meno intuitivo.

Trovo che quando si fa un buon design OO non ho mai bisogno di ereditarietà multipla. Nei casi in cui ne ho bisogno, di solito trovo che utilizzo l’ereditarietà per riutilizzare le funzionalità, mentre l’ereditarietà è appropriata solo per le relazioni “è-a”.

Ci sono altre tecniche come mixin che risolvono gli stessi problemi e non hanno i problemi che ha l’ereditarietà multipla.

Non penso che il problema dei diamanti sia un problema, considererei questo sofisma, nient’altro.

Il problema peggiore, dal mio punto di vista, con ereditarietà multipla è RAD – vittime e persone che affermano di essere sviluppatori ma in realtà sono bloccati da una semi-conoscenza (nella migliore delle ipotesi).

Personalmente, sarei molto felice se potessi finalmente fare qualcosa in Windows Form come questo (non è un codice corretto, ma dovrebbe darti l’idea):

 public sealed class CustomerEditView : Form, MVCView 

Questo è il problema principale che ho con l’assenza di ereditarietà multipla. È ansible fare qualcosa di simile con le interfacce, ma c’è quello che chiamo “s *** code”, è questo doloroso cet ripetitivo che devi scrivere in ciascuna delle tue classi per ottenere un contesto di dati, per esempio.

A mio parere, non dovrebbe esserci assolutamente alcun bisogno, non il minimo, di QUALSIASI ripetizione del codice in un linguaggio moderno.

Il Common Lisp Object System (CLOS) è un altro esempio di qualcosa che supporta l’MI evitando i problemi di stile C ++: l’ereditarietà viene data come impostazione predefinita , pur consentendo la libertà di decidere in modo esplicito come, ad esempio, chiamare il comportamento di un super .

Non c’è nulla di sbagliato nell’ereditarietà stessa. Il problema è quello di aggiungere ereditarietà multiple a un linguaggio che non è stato progettato pensando in primo luogo all’ereditarietà multipla.

La lingua Eiffel sta supportando l’ereditarietà multipla senza restrizioni in un modo molto efficiente e produttivo, ma il linguaggio è stato progettato sin dall’inizio per supportarlo.

Questa funzionalità è complessa da implementare per gli sviluppatori di compilatori, ma sembra che tale svantaggio potrebbe essere compensato dal fatto che un buon supporto di ereditarietà multipla potrebbe evitare il supporto di altre funzionalità (ovvero non è necessario il metodo di interfaccia o di estensione).

Penso che sostenere l’eredità multipla o meno sia più una questione di scelta, una questione di priorità. Una funzionalità più complessa richiede più tempo per essere correttamente implementata e operativa e potrebbe essere più controversa. L’implementazione di C ++ potrebbe essere la ragione per cui l’ereditarietà multipla non è stata implementata in C # e Java …

Il diamante non è un problema, purché non si utilizzi nulla come l’ereditarietà virtuale del C ++: nell’ereditarietà normale ogni class base assomiglia a un campo membro (in realtà sono disposti in RAM in questo modo), dando un po ‘di zucchero sintattico e un capacità extra di scavalcare più metodi virtuali. Ciò potrebbe imporre qualche ambiguità in fase di compilazione, ma di solito è facile da risolvere.

D’altra parte, con l’ereditarietà virtuale troppo facilmente va fuori controllo (e poi diventa un disastro). Considera come esempio un diagramma “cuore”:

  AA / \ / \ BCDE \ / \ / FG \ / H 

In C ++ è del tutto imansible: non appena F e G vengono uniti in una singola class, anche i loro A vengono unificati, punto. Ciò significa che potresti non considerare mai le classi di base opache in C ++ (in questo esempio devi build A in H quindi devi sapere che è presente da qualche parte nella gerarchia). In altre lingue potrebbe funzionare, tuttavia; per esempio, F e G potrebbero dichiarare esplicitamente A come “interno”, impedendo quindi la fusione e rendendosi effettivamente solidi.

Un altro esempio interessante ( non specifico per C ++):

  A / \ BB | | CD \ / E 

Qui, solo B utilizza l’ereditarietà virtuale. Quindi E contiene due B che condividono la stessa A In questo modo, puoi ottenere un puntatore A* che punta a E , ma non puoi lanciarlo su un puntatore B* sebbene l’object sia effettivamente B poiché tale cast è ambiguo e questa ambiguità non può essere rilevata in fase di compilazione (a meno che il compilatore non veda l’intero programma). Ecco il codice di prova:

 struct A { virtual ~A() {} /* so that the class is polymorphic */ }; struct B: virtual A {}; struct C: B {}; struct D: B {}; struct E: C, D {}; int main() { E data; E *e = &data; A *a = dynamic_cast(e); // works, A is unambiguous // B *b = dynamic_cast(e); // doesn't compile B *b = dynamic_cast(a); // NULL: B is ambiguous std::cout << "E: " << e << std::endl; std::cout << "A: " << a << std::endl; std::cout << "B: " << b << std::endl; // the next casts work std::cout << "A::C::B: " << dynamic_cast(dynamic_cast(e)) << std::endl; std::cout << "A::D::B: " << dynamic_cast(dynamic_cast(e)) << std::endl; std::cout << "A=>C=>B: " << dynamic_cast(dynamic_cast(a)) << std::endl; std::cout << "A=>D=>B: " << dynamic_cast(dynamic_cast(a)) << std::endl; return 0; } 

Inoltre, l'implementazione può essere molto complessa (dipende dalla lingua, vedi la risposta di Benjismith).

Uno degli obiettivi di progettazione di framework come Java e .NET è quello di rendere ansible il codice compilato per funzionare con una versione di una libreria precompilata, per funzionare altrettanto bene con le versioni successive di tale libreria, anche se quelle versioni successive aggiungi nuove funzionalità. Mentre il normale paradigma in linguaggi come C o C ++ è quello di distribuire gli eseguibili collegati in modo statico che contengono tutte le librerie di cui hanno bisogno, il paradigma in .NET e Java è distribuire le applicazioni come raccolte di componenti “collegati” in fase di esecuzione .

Il modello COM che ha preceduto .NET ha tentato di utilizzare questo approccio generale, ma in realtà non ha ereditarietà. Invece, ogni definizione di class ha effettivamente definito sia una class che un’interfaccia con lo stesso nome che conteneva tutti i suoi membri pubblici. Le istanze erano del tipo di class, mentre i riferimenti erano del tipo di interfaccia. Dichiarare una class come derivante da un’altra equivaleva a dichiarare una class come l’implementazione dell’interfaccia dell’altro, e richiedeva alla nuova class di implementare nuovamente tutti i membri pubblici delle classi da cui si derivava. Se Y e Z derivano da X, e poi W deriva da Y e Z, non importa se Y e Z implementano i membri di X in modo diverso, perché Z non sarà in grado di usare le loro implementazioni – dovrà definire il suo proprio. W potrebbe incapsulare le istanze di Y e / o Z, e concatenare le sue implementazioni dei metodi di X attraverso il loro, ma non ci sarebbe alcuna ambiguità su cosa dovrebbero fare i metodi di X – farebbero tutto ciò che il codice di Z ha esplicitamente indirizzato a fare.

La difficoltà in Java e .NET è che il codice è autorizzato ad ereditare membri e avere accesso a essi in modo implicito ai membri principali. Supponiamo che uno avesse classi correlate a WZ come sopra:

 class X { public virtual void Foo() { Console.WriteLine("XFoo"); } class Y : X {}; class Z : X {}; class W : Y, Z // Not actually permitted in C# { public static void Test() { var it = new W(); it.Foo(); } } 

Sembrerebbe che W.Test() debba creare un’istanza di W per chiamare l’implementazione del metodo virtuale Foo definito in X Supponiamo, tuttavia, che Y e Z fossero in realtà in un modulo compilato separatamente, e sebbene siano stati definiti come sopra quando X e W sono stati compilati, sono stati successivamente modificati e ricompilati:

 class Y : X { public override void Foo() { Console.WriteLine("YFoo"); } class Z : X { public override void Foo() { Console.WriteLine("ZFoo"); } 

Ora quale dovrebbe essere l’effetto di chiamare W.Test() ? Se il programma doveva essere collegato staticamente prima della distribuzione, lo stage link statico potrebbe essere in grado di discernere che mentre il programma non aveva ambiguità prima che Y e Z venissero cambiati, le modifiche a Y e Z hanno reso le cose ambigue e il linker poteva rifiutarsi di build il programma a meno che o fino a quando tale ambiguità non sia risolta. D’altra parte, è ansible che la persona che ha sia W che le nuove versioni di Y e Z sia qualcuno che semplicemente vuole eseguire il programma e non ha codice sorgente per nessuno di essi. Quando si W.Test() , non sarebbe più chiaro cosa dovrebbe fare W.Test() , ma fino a quando l’utente non W.Test() di eseguire W con la nuova versione di Y e Z non ci sarebbe alcun modo in cui parte del sistema potrebbe riconoscere che c’era un problema (a meno che W fosse considerato illegittimo anche prima delle modifiche a Y e Z).