Как виртуальное наследование решает «ромбовидную» (множественное наследование) неоднозначность?

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

Я понимаю проблему с алмазом, и в приведенном выше фрагменте кода этой проблемы нет.

Как именно виртуальное наследование решает проблему?

Что я понимаю: когда я говорю A *a = new D();, компилятор хочет знать, может ли объект типа Dбыть назначен указателю типа A, но у него есть два пути, по которым он может следовать, но не может решить сам.

Итак, как виртуальное наследование решает проблему (помогите компилятору принять решение)?

Moeb
источник

Ответы:

112

Вы хотите: (достижимо с виртуальным наследованием)

  A  
 / \  
B   C  
 \ /  
  D 

А не: (Что происходит без виртуального наследования)

A   A  
|   |
B   C  
 \ /  
  D 

Виртуальное наследование означает, что будет только 1 экземпляр базового Aкласса, а не 2.

Ваш тип Dбудет иметь 2 указателя vtable (вы можете увидеть их на первой диаграмме), один для Bи один для тех, Cкто фактически наследует A. Dразмер объекта увеличен, потому что теперь он хранит 2 указателя; однако сейчас есть только один A.

Так B::Aи C::Aтакие же и так неоднозначных звонков быть не может D. Если вы не используете виртуальное наследование, у вас есть вторая диаграмма выше. И тогда любой вызов члена A становится неоднозначным, и вам нужно указать, по какому пути вы хотите пойти.

В Википедии есть еще одно хорошее изложение и пример здесь

Брайан Р. Бонди
источник
2
Указатель Vtable - это деталь реализации. В этом случае не все компиляторы будут вводить указатели vtable.
curiousguy
19
Думаю, было бы лучше, если бы графики были зеркальными по вертикали. В большинстве случаев я находил такие диаграммы наследования, чтобы показать производные классы под базами. (см. «подавленный», «положительный»)
Питер - Восстановить Монику
Как я могу изменить его код, чтобы вместо этого использовать реализацию B'or C'? Благодарность!
Minh Nghĩa
46

Почему еще один ответ?

Что ж, во многих сообщениях на SO и в других статьях говорится, что проблема с бриллиантами решается путем создания одного экземпляра Aвместо двух (по одному для каждого родителя D), тем самым устраняя двусмысленность. Однако это не дало мне полного понимания процесса, у меня возникло еще больше вопросов, например

  1. что, если Bи Cпытается создать разные экземпляры, Aнапример, вызывая параметризованный конструктор с разными параметрами ( D::D(int x, int y): C(x), B(y) {})? Какой экземпляр Aбудет выбран, чтобы стать его частью D?
  2. что, если я использую не виртуальное наследование B, а виртуальное C? Этого достаточно для создания одного экземпляра Ain D?
  3. Должен ли я всегда использовать виртуальное наследование по умолчанию в качестве превентивной меры, поскольку оно решает возможные проблемы с алмазами с незначительными затратами на производительность и без каких-либо других недостатков?

Неспособность предсказать поведение, не попробовав образцы кода, означает непонимание концепции. Ниже описано, что помогло мне разобраться в виртуальном наследовании.

Двойной А

Во-первых, давайте начнем с этого кода без виртуального наследования:

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

Пройдем через вывод. Выполнение B b(2);создает, A(2)как ожидалось, то же самое для 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);потребности как Bи Cкаждый из них создает свой собственный A, так что мы дважды Aв d:

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

Это причина для d.getX()возникновения ошибки компиляции, поскольку компилятор не может выбрать, для какого Aэкземпляра он должен вызывать метод. Тем не менее, можно вызывать методы напрямую для выбранного родительского класса:

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

Виртуальность

Теперь давайте добавим виртуальное наследование. Используя тот же образец кода со следующими изменениями:

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

Перейдем к созданию d:

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

Как видите, Aсоздается конструктор по умолчанию, игнорирующий параметры, передаваемые из конструкторов Bи C. Поскольку двусмысленность исчезла, все вызовы getX()возвращают одно и то же значение:

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

Но что, если мы хотим вызвать параметризованный конструктор для A? Это можно сделать, явно вызвав его из конструктора D:

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

Обычно класс может явно использовать конструкторы только прямых родителей, но есть исключение для случая виртуального наследования. Открытие этого правила "щелкнуло" для меня и очень помогло понять виртуальные интерфейсы:

Код class B: virtual Aозначает, что любой класс, унаследованный от B, теперь отвечает за создание Aсамостоятельно, поскольку Bне собирается делать это автоматически.

Имея в виду это утверждение, легко ответить на все мои вопросы:

  1. Во время Dсоздания ни Bи Cне отвечает за параметры A, это полностью зависит Dтолько от.
  2. Cделегирует создание Aдля D, но Bсоздаст свой собственный экземпляр , Aтаким образом , принося проблемы алмазов назад
  3. Определение параметров базового класса в классе внука, а не в прямом дочернем, не является хорошей практикой, поэтому его следует терпеть, когда существует проблема с алмазом, и эта мера неизбежна.
ннович-ок
источник
Этот ответ чрезвычайно информативен! Особенно ваша интерпретация virtualключевого слова как «определено позже (в подклассах)», то есть не «реально» определено, а «определено виртуально». Эта интерпретация работает не только для базовых классов, но и для методов. Спасибо!
Maggyero
45

Экземпляры производных классов хранят члены своих базовых классов.

Без виртуального наследования схемы памяти выглядят так (обратите внимание на две копии Aчленов в классе 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]

При виртуальном наследовании схемы памяти выглядят следующим образом (обратите внимание на единственную копию Aчленов в классе 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 ----------------|

Для каждого производного класса компилятор создает виртуальную таблицу, содержащую указатели на члены его виртуальных базовых классов, хранящихся в производном классе, и добавляет указатель на эту виртуальную таблицу в производном классе.

el.pescado
источник
@Balu: время компиляции stackoverflow.com/questions/3849498/when-is-vtable-in-c-created
Rasmi Ranjan Nayak
10

Проблема не в пути, по которому должен идти компилятор. Проблема в конечной точке этого пути: в результате приведения. Когда дело доходит до преобразования типов, путь не имеет значения, имеет значение только конечный результат.

Если вы используете обычное наследование, каждый путь имеет свою собственную отличительную конечную точку, что означает, что результат приведения неоднозначен, что является проблемой.

Если вы используете виртуальное наследование, вы получаете ромбовидную иерархию: оба пути ведут к одной и той же конечной точке. В этом случае проблема выбора пути больше не существует (или, точнее, перестает иметь значение), потому что оба пути приводят к одному и тому же результату. Результат больше не однозначный - вот что важно. Точный путь - нет.

Муравей
источник
@Andrey: Как компилятор реализует наследование ... Я имею в виду, что я понял ваш аргумент и хочу поблагодарить вас за столь ясное объяснение ... но это действительно поможет, если вы можете объяснить (или указать ссылку) относительно как компилятор на самом деле реализует наследование и что меняется, когда я выполняю виртуальное наследование
Брюс
8

Собственно пример должен быть таким:

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

... таким образом вывод будет правильным: "EAT => D"

Виртуальное наследование решает только дублирование дедушки! НО вам все равно нужно указать методы, которые будут виртуальными, чтобы методы были правильно переопределены ...

инженер
источник