¿Cómo resuelve la herencia virtual la ambigüedad del "diamante" (herencia múltiple)?
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 la herencia virtual el problema?
Lo que entiendo:
cuando digo A *a = new D();
, el compilador quiere saber si un objeto de tipo D
se 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 (ayuda al compilador a tomar la decisión)?
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 A
clase base, no 2.
Su tipo D
tendría 2 punteros vtable (puede verlos en el primer diagrama), uno para B
y otro para C
quién hereda virtualmente A
. D
El tamaño del objeto aumenta porque ahora almacena 2 punteros; sin embargo, ahora sólo hay uno A
.
Entonces B::A
y C::A
son iguales y por eso no puede haber llamadas ambiguas de D
. Si no utilizas la herencia virtual tienes el segundo diagrama de arriba. Y cualquier llamada a un miembro de A se vuelve ambigua y es necesario especificar qué camino desea tomar.
Wikipedia tiene otro buen resumen y ejemplo aquí.
¿Por qué otra respuesta?
Bueno, muchas publicaciones sobre SO y artículos externos dicen que el problema del diamante se resuelve creando una sola instancia de A
en lugar de dos (una para cada padre de D
), resolviendo así la ambigüedad. Sin embargo, esto no me dio una comprensión integral del proceso, terminé con aún más preguntas como
- ¿Qué pasa si
B
intentaC
crear diferentes instancias de,A
por ejemplo, llamar a un constructor parametrizado con diferentes parámetros (D::D(int x, int y): C(x), B(y) {}
)? ¿ De qué instanciaA
se elegirá para formar parteD
? - ¿Qué pasa si uso herencia no virtual
B
, pero virtualC
? ¿Es suficiente para crear una sola instancia deA
inD
? - ¿Debería utilizar siempre la herencia virtual de forma predeterminada a partir de ahora como medida preventiva, ya que resuelve el posible problema de los diamantes con un costo 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 entender 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;
}
Repasemos 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 B
y C
, cada uno de ellos creando el suyo A
, así que tenemos doble A
en 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 por la que d.getX()
se produce un error de compilación, ya que el compilador no puede elegir A
para qué instancia 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 la 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)
Como puede ver, A
se crea con el constructor predeterminado ignorando los parámetros pasados por los constructores de B
y C
. Como desaparece la ambigüedad, todas las llamadas devuelven getX()
el mismo valor:
d.getX() = 42
d.A::getX() = 42
d.B::getX() = 42
d.C::getX() = 42
Pero, ¿qué pasa 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 utilizar explícitamente constructores de padres directos únicamente, pero existe una exclusión para el caso de herencia virtual. Descubrir esta regla me hizo "clic" y me ayudó mucho a comprender las interfaces virtuales:
Código class B: virtual A
significa que cualquier clase heredada B
ahora es responsable de crearse A
por sí misma, ya que B
no lo hará automáticamente.
Con esta afirmación en mente, es fácil responder todas las preguntas que tenía:
- Durante
D
la creación, niB
niC
es responsable de los parámetros deA
, depende totalmente deD
solo. C
delegará la creación deA
aD
, peroB
creará su propia instancia paraA
traer de vuelta el problema de los diamantes.- Definir los parámetros de la clase base en la clase nieto en lugar de en la clase hija directa no es una buena práctica, por lo que debe tolerarse cuando existe un problema de diamantes y esta medida es inevitable.
Las instancias de clases derivadas almacenan los miembros de sus clases base.
Sin herencia virtual, los diseños de la memoria se ven así (tenga en cuenta las dos copias de los A
miembros de la clase D
):
class A: [A members]
class B: public A [A members|B members]
class C: public A [A members|C members]
class D: public B, public C [A members|B members|A members|C members|D members]
Con la herencia virtual, los diseños de la memoria se ven así (tenga en cuenta la copia única de los A
miembros de la clase D
):
class A: [A members]
class B: virtual public A [B members|A members]
| ^
v |
virtual table B
class C: virtual public A [C members|A members]
| ^
v |
virtual table C
class D: public B, public C [B members|C members|D members|A members]
| | ^
v v |
virtual table D ----------------|
Para cada clase derivada, el compilador crea una tabla virtual que contiene punteros a los miembros de sus clases base virtuales almacenadas en la clase derivada y agrega un puntero a esa tabla virtual en la clase derivada.