Comment l'héritage virtuel résout-il l'ambiguïté du «diamant» (héritage multiple)?

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(); 
} 

Je comprends le problème du diamant, et le morceau de code ci-dessus n'a pas ce problème.

Comment l'héritage virtuel résout-il exactement le problème?

Ce que je comprends: Quand je dis A *a = new D();, le compilateur veut savoir si un objet de type Dpeut être assigné à un pointeur de type A, mais il a deux chemins qu'il peut suivre, mais ne peut pas décider par lui-même.

Alors, comment l'héritage virtuel résout-il le problème (aide le compilateur à prendre la décision)?

Moeb
la source

Réponses:

109

Vous voulez: (réalisable avec l'héritage virtuel)

  A  
 / \  
B   C  
 \ /  
  D 

Et non: (Que se passe-t-il sans héritage virtuel)

A   A  
|   |
B   C  
 \ /  
  D 

L'héritage virtuel signifie qu'il n'y aura qu'une seule instance de la Aclasse de base et non 2.

Votre type Daurait 2 pointeurs vtable (vous pouvez les voir dans le premier diagramme), un pour Bet un pour Cqui héritent virtuellement A. DLa taille de l'objet est augmentée car elle stocke maintenant 2 pointeurs; cependant il n'y en a qu'un Amaintenant.

Donc B::Aet C::Asont les mêmes et donc il ne peut y avoir d'appels ambigus de D. Si vous n'utilisez pas l'héritage virtuel, vous avez le deuxième diagramme ci-dessus. Et tout appel à un membre de A devient alors ambigu et vous devez spécifier le chemin que vous souhaitez emprunter.

Wikipedia a un autre bon aperçu et un exemple ici

Brian R. Bondy
la source
2
Le pointeur Vtable est un détail d'implémentation. Tous les compilateurs n'introduiront pas de pointeurs vtable dans ce cas.
curiousguy
19
Je pense qu'il serait préférable que les graphiques soient reflétés verticalement. Dans la plupart des cas, j'ai trouvé de tels diagrammes d'héritage pour montrer les classes dérivées sous les bases. (voir "downcast", "upcast")
peterh - Réintégrer Monica le
Comment puis - je modifier son code pour utiliser Bl C'implémentation de ou à la place? Merci!
Minh Nghĩa
44

Les instances de classes dérivées "contiennent" des instances de classes de base, donc elles ressemblent à ceci en mémoire:

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

Ainsi, sans héritage virtuel, l'instance de la classe D ressemblerait à:

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

Alors, notez deux "copies" des données A. L'héritage virtuel signifie qu'à l'intérieur de la classe dérivée, il existe un pointeur vtable défini au moment de l'exécution qui pointe vers les données de la classe de base, de sorte que les instances des classes B, C et D ressemblent à:

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
el.pescado
la source
43

Pourquoi une autre réponse?

Eh bien, de nombreux articles sur SO et articles extérieurs disent que le problème du diamant est résolu en créant une seule instance de Aau lieu de deux (une pour chaque parent de D), résolvant ainsi l'ambiguïté. Cependant, cela ne m'a pas donné une compréhension complète du processus, je me suis retrouvé avec encore plus de questions comme

  1. Bet si et Cessayait de créer différentes instances, Apar exemple, d'appeler un constructeur paramétré avec des paramètres différents ( D::D(int x, int y): C(x), B(y) {})? Quelle instance de Asera choisie pour faire partie de D?
  2. et si j'utilise l'héritage non virtuel pour B, mais virtuel pour C? Est-ce suffisant pour créer une seule instance de Ain D?
  3. Dois-je toujours utiliser l'héritage virtuel par défaut à partir de maintenant comme mesure préventive car il résout d'éventuels problèmes de diamant avec un coût de performance mineur et aucun autre inconvénient?

Ne pas pouvoir prédire le comportement sans essayer des échantillons de code signifie ne pas comprendre le concept. Voici ce qui m'a aidé à comprendre l'héritage virtuel.

Double A

Tout d'abord, commençons avec ce code sans héritage virtuel:

#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;
}

Permet de passer par la sortie. L'exécution B b(2);crée A(2)comme prévu, idem pour 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);a besoin des deux Bet C, chacun d'eux créant le sien A, nous avons donc le double Ade d:

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

C'est la raison pour laquelle d.getX()une erreur de compilation est provoquée car le compilateur ne peut pas choisir l' Ainstance pour laquelle il doit appeler la méthode. Il est toujours possible d'appeler des méthodes directement pour la classe parent choisie:

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

Virtualité

Ajoutons maintenant l'héritage virtuel. Utilisation du même exemple de code avec les modifications suivantes:

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
...

Passons à la création de d:

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

Vous pouvez voir, Aest créé avec le constructeur par défaut en ignorant les paramètres transmis par les constructeurs de Bet C. Comme l'ambiguïté a disparu, tous les appels pour getX()renvoyer la même valeur:

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

Mais que faire si nous voulons appeler le constructeur paramétré pour A? Cela peut être fait en l'appelant explicitement depuis le constructeur de D:

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

Normalement, la classe peut utiliser explicitement des constructeurs de parents directs uniquement, mais il existe une exclusion pour le cas d'héritage virtuel. La découverte de cette règle a «cliqué» pour moi et m'a beaucoup aidé à comprendre les interfaces virtuelles:

Le code class B: virtual Asignifie que toute classe héritée de Best désormais responsable de la création Apar elle-même, car elle Bne le fera pas automatiquement.

Avec cette déclaration à l'esprit, il est facile de répondre à toutes mes questions:

  1. Pendant la Dcréation, Bni Cn'est responsable des paramètres de A, c'est totalement à Dseulement.
  2. Cdéléguera la création de Aà D, mais Bcréera sa propre instance Apour ramener ainsi le problème du diamant
  3. La définition des paramètres de classe de base dans la classe petit-enfant plutôt que dans la classe enfant directe n'est pas une bonne pratique, elle doit donc être tolérée lorsqu'un problème de diamant existe et que cette mesure est inévitable.
nnovich-OK
la source
10

Le problème n'est pas le chemin que le compilateur doit suivre. Le problème est le point final de ce chemin: le résultat de la distribution. En ce qui concerne les conversions de type, le chemin n'a pas d'importance, seul le résultat final fait.

Si vous utilisez l'héritage ordinaire, chaque chemin a son propre point de terminaison distinctif, ce qui signifie que le résultat de la conversion est ambigu, ce qui est le problème.

Si vous utilisez l'héritage virtuel, vous obtenez une hiérarchie en forme de losange: les deux chemins mènent au même point de terminaison. Dans ce cas, le problème du choix du chemin n'existe plus (ou, plus précisément, n'a plus d'importance), car les deux chemins aboutissent au même résultat. Le résultat n'est plus ambigu, c'est ce qui compte. Le chemin exact ne l'est pas.

Fourmi
la source
@Andrey: Comment le compilateur implémente-t-il l'héritage ... Je veux dire, je comprends votre argument et je tiens à vous remercier de l'avoir expliqué si lucidement ... mais cela aiderait vraiment si vous pouviez expliquer (ou indiquer une référence) quant à comment le compilateur implémente réellement l'héritage et ce qui change lorsque je fais l'héritage virtuel
Bruce
8

En fait, l'exemple devrait être le suivant:

#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;
}

... de cette façon, la sortie sera la bonne: "EAT => D"

L'héritage virtuel ne résout que la duplication du grand-père! MAIS vous devez toujours spécifier les méthodes à être virtuelles afin d'obtenir les méthodes correctement remplacées ...

enger
la source