Domanda

Riesco a vedere persone che chiedono continuamente se l'ereditarietà multipla debba essere inclusa nella prossima versione di C # o Java. Le persone in C ++, che sono abbastanza fortunate da avere questa abilità, dicono che è come dare a qualcuno una corda per impiccarsi.

Qual è la questione con eredità multipla? Ci sono campioni concreti?

È stato utile?

Soluzione

Il problema più evidente è con l'override della funzione.

Diciamo che hanno due classi A e B , entrambi che definiscono un metodo doSomething . Ora definisci un C di terza classe, che eredita da A e B , ma non sostituisci doSomething metodo.

Quando il compilatore esegue il seeding di questo codice ...

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

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

Oltre a sovrascrivere, l'altro grosso problema con l'ereditarietà multipla è il layout degli oggetti fisici in memoria.

Lingue come C ++ e Java e C # creano un layout basato su indirizzo fisso per ogni tipo di oggetto. Qualcosa del genere:

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 bytecode), utilizza quegli offset numerici per accedere a ciascun metodo o campo.

L'ereditarietà multipla la rende molto complicata.

Se la classe C eredita sia da A sia da B , il compilatore deve decidere se impaginare i dati in AB o nell'ordine BA .

Ma ora immagina di chiamare metodi su un oggetto B . È davvero solo un B ? Oppure è in realtà un oggetto C che viene chiamato polimorficamente, attraverso la sua interfaccia B ? A seconda dell'identità effettiva dell'oggetto, il layout fisico sarà diverso ed è impossibile conoscere l'offset della funzione da invocare nel sito di chiamata.

Il modo di gestire questo tipo di sistema è quello di abbandonare l'approccio a layout fisso, permettendo a ogni oggetto di essere interrogato per il suo layout prima tentando di invocare le funzioni o accedere ai suoi campi.

Quindi ... per farla breve ... è un dolore al collo per gli autori di compilatori supportare l'ereditarietà multipla. Quindi quando qualcuno come Guido van Rossum progetta Python o quando Anders Hejlsberg progetta c #, sanno che supportare l'ereditarietà multipla renderà le implementazioni del compilatore significativamente più complesse e presumibilmente non pensano che il vantaggio valga il costo.

Altri suggerimenti

I problemi che menzionate non sono così difficili da risolvere. Infatti ad es. Eiffel lo fa perfettamente bene! (e senza introdurre scelte arbitrarie o altro)

es. se erediti da A e B, entrambi con metodo foo (), ovviamente non vuoi una scelta arbitraria nella tua classe C che erediti da A e B. Devi ridefinire foo, quindi è chiaro cosa verrà usato se viene chiamato c.foo () 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 biblioteche di Eiffel vedrai che è usato dappertutto e personalmente ho perso la funzionalità quando sono dovuto tornare alla programmazione in Java.

Il problema del diamante :

  

un'ambiguità che sorge quando due classi B e C ereditano da A e la classe D eredita sia da B che da C. Se esiste un metodo in A che B e C hanno sovrascritto e D non lo sovrascrive, quindi quale versione del metodo eredita D: quella di B o quella di C?

     

... Si chiama il "problema del diamante" a causa della forma del diagramma di ereditarietà della classe in questa situazione. In questo caso, la classe A è in alto, sia B che C separatamente sotto di essa, e D unisce i due insieme in basso per formare una forma di diamante ...

L'ereditarietà multipla è una di quelle cose che non viene utilizzata spesso e può essere utilizzata in modo improprio, ma a volte è necessaria.

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

diciamo che hai oggetti A e B che sono entrambi ereditati da C. A e B implementano entrambi 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 classe base. (eredità di diamante, se disegni l'albero ereditario ottieni una forma di diamante)

Questi problemi non sono realmente 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 eseguo una buona progettazione OO non ho mai bisogno dell'ereditarietà multipla. Nei casi in cui ne ho bisogno, di solito trovo che sto usando l'ereditarietà per riutilizzare la funzionalità mentre l'ereditarietà è appropriata solo per "is-a" rapporti.

Esistono altre tecniche come i mixin che risolvono gli stessi problemi e non hanno i problemi dell'ereditarietà multipla.

Non penso che il problema del diamante sia un problema, considererei quel sofisma, nient'altro.

Il peggior problema, dal mio punto di vista, con ereditarietà multipla è RAD: le vittime e le persone che dichiarano di essere sviluppatori ma che in realtà sono bloccate da una mezza 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<Customer>

Questo è il problema principale che ho senza avere l'ereditarietà multipla. PUOI fare qualcosa di simile con le interfacce, ma c'è quello che chiamo "codice ***", è questo doloroso ripetitivo c *** che devi scrivere in ciascuna delle tue classi per ottenere un contesto di dati, per esempio.

A mio avviso, non dovrebbe assolutamente esserci la necessità, 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 a default sensibile , pur consentendo la libertà di decidere esplicitamente come, per esempio, definire esattamente il comportamento di un super.

Non c'è nulla di sbagliato nella stessa eredità multipla. Il problema è aggiungere l'ereditarietà multipla a una lingua che non è stata progettata pensando all'ereditarietà multipla fin dall'inizio.

Il linguaggio Eiffel supporta l'ereditarietà multipla senza restrizioni in un modo molto efficiente e produttivo, ma il linguaggio è stato progettato da quel momento per supportarlo.

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

Penso che supportare 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 può essere più controversa. L'implementazione di C ++ può essere il motivo per cui l'ereditarietà multipla non è stata implementata in C # e Java ...

Uno degli obiettivi di progettazione di framework come Java e .NET è quello di rendere possibile che il codice compilato funzioni con una versione di una libreria precompilata, per funzionare altrettanto bene con le versioni successive di quella libreria, anche se quelle versioni successive aggiungono nuove funzionalità. Mentre il normale paradigma in linguaggi come C o C ++ è distribuire eseguibili collegati staticamente che contengono tutte le librerie di cui hanno bisogno, il paradigma in .NET e Java è quello di distribuire applicazioni come raccolte di componenti che sono "collegati". in fase di esecuzione.

Il modello COM che ha preceduto .NET ha tentato di utilizzare questo approccio generale, ma in realtà non aveva ereditarietà; invece, ogni definizione di classe ha effettivamente definito sia una classe che un'interfaccia con lo stesso nome che conteneva tutti i suoi membri pubblici . Le istanze erano del tipo di classe, mentre i riferimenti erano del tipo di interfaccia. Dichiarare una classe come derivante da un'altra equivaleva a dichiarare una classe come implementante l'interfaccia dell'altra e richiedeva alla nuova classe di implementare nuovamente tutti i membri pubblici delle classi da cui una derivava. Se Y e Z derivano da X, e quindi 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 utilizzare le loro implementazioni - dovrà definire il suo proprio. W potrebbe incapsulare istanze di Y e / o Z e incatenare le sue implementazioni dei metodi di X attraverso i loro, ma non ci sarebbe alcuna ambiguità su ciò che i metodi di X dovrebbero fare: farebbero qualunque cosa il codice di Z esplicitamente li diresse a fare.

La difficoltà in Java e .NET è che al codice è permesso ereditare i membri e avere accesso a loro implicitamente fare riferimento ai membri principali. Supponiamo che uno avesse le classi W-Z correlate 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 () dovrebbe creare un'istanza di W che chiama l'implementazione del metodo virtuale Foo definito in X . Supponiamo, tuttavia, che Y e Z fossero effettivamente in un modulo compilato separatamente, e sebbene siano stati definiti come sopra quando sono stati compilati X e W, 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 dovesse essere collegato staticamente prima della distribuzione, la fase di collegamento statico potrebbe essere in grado di discernere che mentre il programma non aveva ambiguità prima che Y e Z fossero cambiate, le modifiche a Y e Z hanno reso le cose ambigue e il linker poteva rifiutare di costruire il programma a meno che o fino a quando tale ambiguità non sia stata risolta. D'altra parte, è possibile che la persona che ha sia W che le nuove versioni di Y e Z sia qualcuno che vuole semplicemente eseguire il programma e non ha un codice sorgente per nessuno di essi. Quando viene eseguito W.Test () , non è più chiaro cosa dovrebbe fare W.Test () , ma fino a quando l'utente non tenta di eseguire W con la nuova versione di Y e Z non ci sarebbe modo in cui nessuna parte del sistema potesse riconoscere che c'era un problema (a meno che W non fosse considerato illegittimo anche prima delle modifiche a Y e Z).

Il diamante non è un problema, a patto che non si utilizzi qualcosa come l'eredità virtuale C ++: in eredità normale ogni classe di base assomiglia a un campo membro (in realtà sono disposti in RAM in questo modo), dandoti un po 'di zucchero sintattico e una capacità extra di scavalcare più metodi virtuali. Ciò potrebbe imporre alcune ambiguità in fase di compilazione, ma di solito è facile da risolvere.

D'altra parte, con l'eredità virtuale diventa troppo facilmente fuori controllo (e quindi diventa un disastro). Considera come esempio un & # 8220; heart & # 8221; Schema:

  A       A
 / \     / \
B   C   D   E
 \ /     \ /
  F       G
    \   /
      H

In C ++ è del tutto impossibile: non appena F e G vengono uniti in una singola classe, anche i loro A vengono uniti , punto. Ciò significa che non puoi mai considerare le classi di base opache in C ++ (in questo esempio devi costruire A in H , quindi devi sapere che è presente da qualche parte nella gerarchia). In altre lingue, tuttavia, potrebbe funzionare; ad esempio, F e G potrebbero dichiarare esplicitamente A come & # 8220; internal, & # 8221; vietando così la conseguente fusione e concretizzandosi efficacemente.

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

  A
 / \
B   B
|   |
C   D
 \ /
  E

Qui, solo B utilizza l'eredità virtuale. Quindi E contiene due B che condividono lo stesso A . In questo modo, puoi ottenere un puntatore A * che punta a E , ma non puoi lanciarlo su un puntatore B * sebbene l'oggetto sia in realtà B in quanto tale cast è ambiguo, e questa ambiguità non può essere rilevata al momento della 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<A *>(e); // works, A is unambiguous
//      B *b = dynamic_cast<B *>(e); // doesn't compile
        B *b = dynamic_cast<B *>(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<B *>(dynamic_cast<C *>(e)) << std::endl;
        std::cout << "A::D::B: " << dynamic_cast<B *>(dynamic_cast<D *>(e)) << std::endl;
        std::cout << "A=>C=>B: " << dynamic_cast<B *>(dynamic_cast<C *>(a)) << std::endl;
        std::cout << "A=>D=>B: " << dynamic_cast<B *>(dynamic_cast<D *>(a)) << std::endl;
        return 0;
}

Inoltre, l'implementazione può essere molto complessa (dipende dal linguaggio; vedi la risposta di benjismith).

Autorizzato sotto: CC-BY-SA insieme a attribuzione
Non affiliato a StackOverflow
scroll top