In che modo l'ereditarietà virtuale risolve l'ambiguità del "diamante" (eredità multipla)?


95
class A                     { public: void eat(){ cout<<"A";} }; 
class B: virtual public A   { public: void eat(){ cout<<"B";} }; 
class C: virtual public A   { public: void eat(){ cout<<"C";} }; 
class D: public         B,C { public: void eat(){ cout<<"D";} }; 

int main(){ 
    A *a = new D(); 
    a->eat(); 
} 

Capisco il problema del diamante, e il pezzo di codice sopra non ha quel problema.

In che modo esattamente l'ereditarietà virtuale risolve il problema?

Cosa capisco: quando dico A *a = new D();, il compilatore vuole sapere se un oggetto di tipo Dpuò essere assegnato a un puntatore di tipo A, ma ha due percorsi che può seguire, ma non può decidere da solo.

Quindi, come risolve il problema l'ereditarietà virtuale (aiuta il compilatore a prendere la decisione)?

Risposte:


109

Vuoi: (realizzabile con eredità virtuale)

  A  
 / \  
B   C  
 \ /  
  D 

E non: (cosa succede senza l'eredità virtuale)

A   A  
|   |
B   C  
 \ /  
  D 

L'ereditarietà virtuale significa che ci sarà solo 1 istanza della Aclasse base e non 2.

Il tuo tipo Davrebbe 2 puntatori vtable (puoi vederli nel primo diagramma), uno per Be uno per Cchi eredita virtualmente A. DLa dimensione dell'oggetto di è aumentata perché ora memorizza 2 puntatori; tuttavia ce n'è solo uno Aora.

Quindi B::Ae C::Asono uguali e quindi non possono esserci chiamate ambigue da D. Se non usi l'ereditarietà virtuale, hai il secondo diagramma sopra. E qualsiasi chiamata a un membro di A diventa quindi ambigua e devi specificare quale percorso vuoi prendere.

Wikipedia ha un altro buon riassunto ed esempio qui


2
Il puntatore di Vtable è un dettaglio di implementazione. Non tutti i compilatori introdurranno i puntatori vtable in questo caso.
Curiousguy

19
Penso che sarebbe migliore se i grafici fossero specchiati verticalmente. Nella maggior parte dei casi ho trovato tali diagrammi di ereditarietà per mostrare le classi derivate sotto le basi. (vedi "downcast", "upcast")
peterh - Reinstate Monica

Come posso modificare il suo codice per utilizzare invece l'implementazione di Bo C? Grazie!
Minh Nghĩa

44

Le istanze di classi derivate "contengono" istanze di classi di base, quindi nella memoria appaiono così:

class A: [A fields]
class B: [A fields | B fields]
class C: [A fields | C fields]

Pertanto, senza l'ereditarietà virtuale, l'istanza della classe D sarebbe simile a:

class D: [A fields | B fields | A fields | C fields | D fields]
          '- derived from B -' '- derived from C -'

Quindi, nota due "copie" di dati A. L'ereditarietà virtuale significa che all'interno della classe derivata è presente un puntatore vtable impostato in fase di esecuzione che punta ai dati della classe base, in modo che le istanze delle classi B, C e D abbiano il seguente aspetto:

class B: [A fields | B fields]
          ^---------- pointer to A

class C: [A fields | C fields]
          ^---------- pointer to A

class D: [A fields | B fields | C fields | D fields]
          ^---------- pointer to B::A
          ^--------------------- pointer to C::A


43

Perché un'altra risposta?

Bene, molti post su SO e articoli esterni dicono che il problema del diamante viene risolto creando una singola istanza Ainvece di due (una per ogni genitore di D), risolvendo così l'ambiguità. Tuttavia, questo non mi ha dato una comprensione completa del processo, mi sono ritrovato con ancora più domande come

  1. cosa succede se Be Ccerca di creare diverse istanze, Aad esempio chiamando il costruttore parametrizzato con parametri diversi ( D::D(int x, int y): C(x), B(y) {})? Di quale istanza Averrà scelto di entrare a far parte D?
  2. cosa succede se uso l'ereditarietà non virtuale per B, ma virtuale per C? È sufficiente per creare una singola istanza di Ain D?
  3. dovrei sempre utilizzare l'ereditarietà virtuale di default d'ora in poi come misura preventiva poiché risolve un possibile problema del diamante con un costo di prestazione minore e senza altri inconvenienti?

Non essere in grado di prevedere il comportamento senza provare esempi di codice significa non comprendere il concetto. Di seguito è riportato ciò che mi ha aiutato a capire l'eredità virtuale.

Doppia A

Innanzitutto, iniziamo con questo codice senza ereditarietà virtuale:

#include<iostream>
using namespace std;
class A {
public:
    A()                { cout << "A::A() "; }
    A(int x) : m_x(x)  { cout << "A::A(" << x << ") "; }
    int getX() const   { return m_x; }
private:
    int m_x = 42;
};

class B : public A {
public:
    B(int x):A(x)   { cout << "B::B(" << x << ") "; }
};

class C : public A {
public:
    C(int x):A(x) { cout << "C::C(" << x << ") "; }
};

class D : public C, public B  {
public:
    D(int x, int y): C(x), B(y)   {
        cout << "D::D(" << x << ", " << y << ") "; }
};

int main()  {
    cout << "Create b(2): " << endl;
    B b(2); cout << endl << endl;

    cout << "Create c(3): " << endl;
    C c(3); cout << endl << endl;

    cout << "Create d(2,3): " << endl;
    D d(2, 3); cout << endl << endl;

    // error: request for member 'getX' is ambiguous
    //cout << "d.getX() = " << d.getX() << endl;

    // error: 'A' is an ambiguous base of 'D'
    //cout << "d.A::getX() = " << d.A::getX() << endl;

    cout << "d.B::getX() = " << d.B::getX() << endl;
    cout << "d.C::getX() = " << d.C::getX() << endl;
}

Esaminiamo l'output. L'esecuzione B b(2);crea A(2)come previsto, lo stesso per C c(3);:

Create b(2): 
A::A(2) B::B(2) 

Create c(3): 
A::A(3) C::C(3) 

D d(2, 3);ha bisogno di entrambi Be C, ognuno di loro crea il proprio A, quindi abbiamo il doppio Ain d:

Create d(2,3): 
A::A(2) C::C(2) A::A(3) B::B(3) D::D(2, 3) 

Questo è il motivo per d.getX()causare un errore di compilazione in quanto il compilatore non può scegliere per quale Aistanza deve chiamare il metodo. È comunque possibile chiamare i metodi direttamente per la classe genitore scelta:

d.B::getX() = 3
d.C::getX() = 2

Virtualità

Ora aggiungiamo l'eredità virtuale. Utilizzando lo stesso esempio di codice con le seguenti modifiche:

class B : virtual public A
...
class C : virtual public A
...
cout << "d.getX() = " << d.getX() << endl; //uncommented
cout << "d.A::getX() = " << d.A::getX() << endl; //uncommented
...

Passiamo alla creazione di d:

Create d(2,3): 
A::A() C::C(2) B::B(3) D::D(2, 3) 

Come puoi vedere, Aviene creato con il costruttore predefinito che ignora i parametri passati dai costruttori di Be C. Poiché l'ambiguità è scomparsa, tutte le chiamate per getX()restituire lo stesso valore:

d.getX() = 42
d.A::getX() = 42
d.B::getX() = 42
d.C::getX() = 42

Ma cosa succede se vogliamo chiamare un costruttore parametrizzato A? Può essere fatto chiamandolo esplicitamente dal costruttore di D:

D(int x, int y, int z): A(x), C(y), B(z)

Normalmente, la classe può utilizzare esplicitamente solo i costruttori di genitori diretti, ma esiste un'esclusione per il caso di ereditarietà virtuale. La scoperta di questa regola ha "cliccato" per me e mi ha aiutato a capire molto le interfacce virtuali:

Codice class B: virtual Asignifica che qualsiasi classe ereditata da Bè ora responsabile della creazione Ada sola, poiché Bnon lo farà automaticamente.

Con questa affermazione in mente è facile rispondere a tutte le domande che avevo:

  1. Durante la Dcreazione né BCè responsabile per i parametri di A, dipende totalmente da Dsolo.
  2. Cdelegherà la creazione di Aa D, ma Bcreerà una propria istanza Aper riportare in tal modo il problema dei diamanti
  3. Definire i parametri della classe base nella classe nipote piuttosto che nel figlio diretto non è una buona pratica, quindi dovrebbe essere tollerata quando esiste il problema del diamante e questa misura è inevitabile.

10

Il problema non è il percorso che il compilatore deve seguire. Il problema è il punto finale di quel percorso: il risultato del cast. Quando si tratta di conversioni di tipo, il percorso non ha importanza, lo fa solo il risultato finale.

Se usi l'ereditarietà ordinaria, ogni percorso ha il proprio punto finale distintivo, il che significa che il risultato del cast è ambiguo, che è il problema.

Se utilizzi l'ereditarietà virtuale, ottieni una gerarchia a forma di rombo: entrambi i percorsi portano allo stesso endpoint. In questo caso il problema della scelta del percorso non esiste più (o, più precisamente, non conta più), perché entrambi i percorsi portano allo stesso risultato. Il risultato non è più ambiguo: questo è ciò che conta. Il percorso esatto no.


@Andrey: In che modo il compilatore implementa l'ereditarietà ... Voglio dire, ho capito il tuo argomento e voglio ringraziarti per averlo spiegato così lucidamente ... ma sarebbe davvero d'aiuto se tu potessi spiegare (o indicare un riferimento) come come il compilatore implementa effettivamente l'ereditarietà e cosa cambia quando eseguo l'ereditarietà virtuale
Bruce

8

In realtà l'esempio dovrebbe essere il seguente:

#include <iostream>

//THE DIAMOND PROBLEM SOLVED!!!
class A                     { public: virtual ~A(){ } virtual void eat(){ std::cout<<"EAT=>A";} }; 
class B: virtual public A   { public: virtual ~B(){ } virtual void eat(){ std::cout<<"EAT=>B";} }; 
class C: virtual public A   { public: virtual ~C(){ } virtual void eat(){ std::cout<<"EAT=>C";} }; 
class D: public         B,C { public: virtual ~D(){ } virtual void eat(){ std::cout<<"EAT=>D";} }; 

int main(int argc, char ** argv){
    A *a = new D(); 
    a->eat(); 
    delete a;
}

... in questo modo l'output sarà quello corretto: "EAT => D"

L'eredità virtuale risolve solo la duplicazione del nonno! MA devi ancora specificare i metodi che devono essere virtuali per far sì che i metodi vengano ignorati correttamente ...

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.