Como a herança virtual resolve a ambigüidade do “diamante” (herança múltipla)?

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

Eu entendo o problema do diamante, e o código acima não tem esse problema.

Como exatamente a herança virtual resolve o problema?

O que eu entendo: Quando digo A *a = new D();, o compilador quer saber se um objeto do tipo Dpode ser atribuído a um ponteiro do tipo A, mas tem dois caminhos que pode seguir, mas não pode decidir por si mesmo.

Então, como a herança virtual resolve o problema (ajude o compilador a tomar a decisão)?

Moeb
fonte

Respostas:

109

Você deseja: (Alcançável com herança virtual)

  A  
 / \  
B   C  
 \ /  
  D 

E não: (O que acontece sem herança virtual)

A   A  
|   |
B   C  
 \ /  
  D 

Herança virtual significa que haverá apenas 1 instância da Aclasse base, não 2.

Seu tipo Dteria 2 ponteiros vtable (você pode vê-los no primeiro diagrama), um para Be outro para Cquem herda virtualmente A. Do tamanho do objeto de é aumentado porque agora armazena 2 ponteiros; no entanto, há apenas um Aagora.

Portanto B::Ae C::Asão iguais e não pode haver chamadas ambíguas deD . Se você não usa herança virtual, você tem o segundo diagrama acima. E qualquer chamada para um membro de A torna-se ambígua e você precisa especificar o caminho que deseja seguir.

A Wikipedia tem outro bom resumo e exemplo aqui

Brian R. Bondy
fonte
2
O ponteiro Vtable é um detalhe de implementação. Nem todos os compiladores apresentarão ponteiros vtable neste caso.
curioso
19
Acho que ficaria melhor se os gráficos fossem espelhados verticalmente. Na maioria dos casos, encontrei esses diagramas de herança para mostrar as classes derivadas abaixo das bases. (veja "downcast", "
upcast
Como posso modificar seu código para usar Ba Cimplementação de ou em vez disso? Obrigado!
Minh Nghĩa de
44

Instâncias de classes derivadas "contêm" instâncias de classes básicas, de modo que se parecem com isso na memória:

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

Assim, sem herança virtual, a instância da classe D seria semelhante a:

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

Portanto, observe duas "cópias" dos dados A. Herança virtual significa que dentro da classe derivada há um ponteiro vtable definido em tempo de execução que aponta para os dados da classe base, de modo que as instâncias das classes B, C e D se parecem com:

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
fonte
43

Por que outra resposta?

Bem, muitos posts no SO e artigos externos dizem que o problema do diamante é resolvido criando uma instância única de em Avez de duas (uma para cada pai de D), resolvendo assim a ambigüidade. No entanto, isso não me deu uma compreensão abrangente do processo, acabei com ainda mais perguntas como

  1. Be se Ctentar criar instâncias diferentes de, Apor exemplo, chamar o construtor parametrizado com parâmetros diferentes ( D::D(int x, int y): C(x), B(y) {})? De qual instância de Aserá escolhida para fazer parte D?
  2. e se eu usar herança não virtual para B, mas virtual C? É o suficiente para criar uma única instância de Ain D?
  3. Devo sempre usar a herança virtual por padrão a partir de agora como medida preventiva, uma vez que resolve o possível problema do diamante com baixo custo de desempenho e sem outras desvantagens?

Não ser capaz de prever o comportamento sem tentar amostras de código significa não entender o conceito. Abaixo está o que me ajudou a entender a herança virtual.

Double A

Primeiro, vamos começar com este código sem herança 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;
}

Vamos ver a saída. Executar B b(2);cria A(2)conforme esperado, o mesmo 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);precisa de ambos Be C, cada um deles criando o seu próprio A, então temos o dobro Aemd :

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

Essa é a razão para d.getX()causar um erro de compilação, já que o compilador não pode escolher qualA instância ele deve chamar o método. Ainda assim, é possível chamar métodos diretamente para a classe pai escolhida:

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

Virtualidade

Agora vamos adicionar herança virtual. Usando o mesmo exemplo de código com as seguintes alterações:

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

Vamos pular para a criação de d:

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

Você pode ver, Aé criado com o construtor padrão, ignorando os parâmetros passados ​​dos construtores de Be C. Como a ambiguidade se foi, todas as chamadas para getX()retornar o mesmo valor:

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

Mas e se quisermos chamar o construtor parametrizado para A? Isso pode ser feito chamando-o explicitamente do construtor de D:

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

Normalmente, a classe pode usar explicitamente apenas construtores de pais diretos, mas há uma exclusão para o caso de herança virtual. Descobrir essa regra me "clicou" e ajudou muito a entender as interfaces virtuais:

Código class B: virtual Asignifica que qualquer classe herdada de Bagora é responsável por criar Apor si mesma, já que Bnão vai fazer isso automaticamente.

Com esta declaração em mente, é fácil responder a todas as minhas perguntas:

  1. Durante a Dcriação nem Bnem Cé responsável pelos parâmetros de A, é totalmente dependente de Dapenas.
  2. Cvai delegar a criação de Apara D, masB vai criar sua própria instância do Atrazendo problema diamante de volta
  3. Definir parâmetros de classe base na classe neto em vez de filho direto não é uma boa prática, portanto, deve ser tolerado quando existe o problema do diamante e esta medida é inevitável.
nnovich-OK
fonte
10

O problema não é o caminho que o compilador deve seguir. O problema é o ponto final desse caminho: o resultado do elenco. Quando se trata de conversões de tipo, o caminho não importa, apenas o resultado final.

Se você usar herança comum, cada caminho terá seu próprio ponto final distinto, o que significa que o resultado da conversão é ambíguo, que é o problema.

Se você usar herança virtual, obterá uma hierarquia em forma de diamante: ambos os caminhos levam ao mesmo ponto final. Nesse caso, o problema de escolher o caminho não existe mais (ou, mais precisamente, não importa mais), pois os dois caminhos levam ao mesmo resultado. O resultado não é mais ambíguo - isso é o que importa. O caminho exato não.

Formiga
fonte
@Andrey: Como o compilador implementa a herança ... quero dizer, recebo seu argumento e quero agradecer por explicá-lo de forma tão lúcida ... mas ajudaria muito se você pudesse explicar (ou apontar uma referência) quanto a como o compilador realmente implementa a herança e o que muda quando eu faço herança virtual
Bruce,
8

Na verdade, o exemplo deve ser o seguinte:

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

... assim a saída será a correta: "EAT => D"

A herança virtual resolve apenas a duplicação do avô! MAS você ainda precisa especificar os métodos a serem virtuais para que os métodos sejam substituídos corretamente ...

Enger
fonte