¿Cómo resuelve la herencia virtual la ambigüedad del “diamante” (herencia múltiple)?

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

Entiendo el problema del diamante, y el código anterior no tiene ese problema.

¿Cómo resuelve exactamente el problema la herencia virtual?

Lo que entiendo: cuando digo A *a = new D();, el compilador quiere saber si un objeto de tipo Dse puede asignar a un puntero de tipo A, pero tiene dos caminos que puede seguir, pero no puede decidir por sí mismo.

Entonces, ¿cómo resuelve la herencia virtual el problema (ayude al compilador a tomar la decisión)?

Moeb
fuente

Respuestas:

109

Quieres: (Alcanzable con herencia virtual)

  A  
 / \  
B   C  
 \ /  
  D 

Y no: (Qué pasa sin herencia virtual)

A   A  
|   |
B   C  
 \ /  
  D 

La herencia virtual significa que solo habrá 1 instancia de la Aclase base , no 2.

Su tipo Dtendría 2 punteros vtable (puede verlos en el primer diagrama), uno para By otro para Cquien hereda virtualmente A. DEl tamaño del objeto de 'se incrementa porque ahora almacena 2 punteros; sin embargo, Aahora solo hay uno .

So B::Ay C::Ason lo mismo y, por lo tanto, no puede haber llamadas ambiguas de D. Si no usa la herencia virtual, tiene el segundo diagrama anterior. Y cualquier llamada a un miembro de A se vuelve ambigua y debe especificar qué ruta desea tomar.

Wikipedia tiene otro buen resumen y ejemplo aquí.

Brian R. Bondy
fuente
2
El puntero de Vtable es un detalle de implementación. No todos los compiladores introducirán punteros vtable en este caso.
Curioso
19
Creo que se vería mejor si los gráficos se reflejaran verticalmente. En la mayoría de los casos, he encontrado tales diagramas de herencia para mostrar las clases derivadas debajo de las bases. (ver "abatido", "upcast")
peterh - Reincorpora a Monica
¿Cómo puedo modificar su código para usar la implementación de Bo Cen su lugar? ¡Gracias!
Minh Nghĩa
44

Las instancias de clases derivadas "contienen" instancias de clases base, por lo que se ven así en la memoria:

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

Por lo tanto, sin herencia virtual, la instancia de la clase D se vería así:

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

Por lo tanto, observe dos "copias" de los datos A. La herencia virtual significa que dentro de la clase derivada hay un puntero vtable configurado en tiempo de ejecución que apunta a los datos de la clase base, por lo que las instancias de las clases B, C y D se ven así:

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
fuente
@Balu: Tiempo de compilación stackoverflow.com/questions/3849498/when-is-vtable-in-c-created
Rasmi Ranjan Nayak
43

¿Por qué otra respuesta?

Bueno, muchas publicaciones en SO y artículos externos dicen que el problema del diamante se resuelve creando una sola instancia de en Alugar de dos (una para cada padre de D), resolviendo así la ambigüedad. Sin embargo, esto no me dio una comprensión completa del proceso, terminé con más preguntas como

  1. ¿Qué pasa si Be Cintenta crear diferentes instancias de, Apor ejemplo, llamar a un constructor parametrizado con diferentes parámetros ( D::D(int x, int y): C(x), B(y) {})? ¿De qué instancia Ase elegirá para formar parte D?
  2. ¿Qué pasa si uso herencia no virtual para B, pero virtual para C? ¿Es suficiente para crear una sola instancia de Ain D?
  3. ¿Debería utilizar siempre la herencia virtual por defecto a partir de ahora como medida preventiva, ya que resuelve un posible problema de diamantes con un coste de rendimiento menor y sin otros inconvenientes?

No poder predecir el comportamiento sin probar ejemplos de código significa no comprender el concepto. A continuación se muestra lo que me ayudó a comprender la herencia virtual.

Doble a

Primero, comencemos con este código sin herencia virtual:

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

Veamos la salida. La ejecución B b(2);crea A(2)como se esperaba, lo mismo para 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);necesita ambos By C, cada uno de ellos creando el suyo A, por lo que tenemos doble Aen d:

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

Esa es la razón para d.getX()causar un error de compilación, ya que el compilador no puede elegir para qué Ainstancia debe llamar al método. Aún así, es posible llamar a métodos directamente para la clase principal elegida:

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

Virtualidad

Ahora agreguemos herencia virtual. Usando el mismo ejemplo de código con los siguientes cambios:

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

Saltemos a la creación de d:

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

Puede ver que Ase crea con el constructor predeterminado ignorando los parámetros pasados ​​de los constructores de By C. Una vez que la ambigüedad desaparece, todas las llamadas getX()devuelven el mismo valor:

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

Pero, ¿y si queremos llamar al constructor parametrizado A? Se puede hacer llamándolo explícitamente desde el constructor de D:

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

Normalmente, la clase puede usar explícitamente solo constructores de padres directos, pero hay una exclusión para el caso de herencia virtual. Descubrir esta regla me hizo "clic" y me ayudó a comprender mucho las interfaces virtuales:

El código class B: virtual Asignifica que cualquier clase heredada Bahora es responsable de crear Apor sí misma, ya Bque no lo hará automáticamente.

Con esta afirmación en mente, es fácil responder todas las preguntas que tenía:

  1. Durante la Dcreación ni Bni Ces responsable de los parámetros de A, depende totalmente de Dsolo.
  2. Cdelegará la creación de Aa D, pero Bcreará su propia instancia de Adevolver así el problema del diamante
  3. Definir los parámetros de la clase base en la clase del nieto en lugar de la clase del hijo directo no es una buena práctica, por lo que debe tolerarse cuando exista un problema de diamantes y esta medida sea inevitable.
nnovich-OK
fuente
10

El problema no es la ruta que debe seguir el compilador. El problema es el punto final de ese camino: el resultado del reparto. Cuando se trata de conversiones de tipos, la ruta no importa, solo importa el resultado final.

Si usa herencia ordinaria, cada ruta tiene su propio punto final distintivo, lo que significa que el resultado de la conversión es ambiguo, que es el problema.

Si usa la herencia virtual, obtiene una jerarquía en forma de diamante: ambas rutas conducen al mismo punto final. En este caso, el problema de elegir el camino ya no existe (o, más precisamente, ya no importa), porque ambos caminos conducen al mismo resultado. El resultado ya no es ambiguo, eso es lo que importa. El camino exacto no lo hace.

Hormiga
fuente
@Andrey: ¿Cómo implementa el compilador la herencia ... quiero decir, entiendo su argumento y quiero agradecerle por explicarlo tan lúcidamente ... pero realmente ayudaría si pudiera explicar (o señalar una referencia) como cómo el compilador realmente implementa la herencia y qué cambia cuando hago herencia virtual
Bruce
8

En realidad, el ejemplo debería ser el siguiente:

#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 esa manera la salida será la correcta: "EAT => D"

¡La herencia virtual solo resuelve la duplicación del abuelo! PERO todavía necesita especificar los métodos para que sean virtuales para que los métodos se anulen correctamente ...

enger
fuente