【问题标题】:How does virtual inheritance solve the "diamond" (multiple inheritance) ambiguity?虚拟继承如何解决“钻石”(多重继承)的歧义?
【发布时间】:2024-01-21 16:29:01
【问题描述】:
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类型的指针,但它有两条路径可以遵循,但不能自行决定。

那么,虚拟继承如何解决这个问题(帮助编译器做出决定)?

【问题讨论】:

    标签: c++ inheritance multiple-inheritance virtual-inheritance diamond-problem


    【解决方案1】:

    您想要:(可通过虚拟继承实现)

      A  
     / \  
    B   C  
     \ /  
      D 
    

    而不是:(没有虚拟继承会发生什么)

    A   A  
    |   |
    B   C  
     \ /  
      D 
    

    虚拟继承意味着基类A的实例只有1个,而不是2个。

    您的类型D 将有2 个vtable 指针(您可以在第一张图中看到它们),一个用于B,一个用于C,它们实际上继承了AD 的对象大小增加了,因为它现在存储了 2 个指针;但是现在只有一个A

    所以B::AC::A 是相同的,所以不会有来自D 的模棱两可的调用。如果你不使用虚拟继承,你有上面的第二张图。任何对 A 成员的调用都会变得模棱两可,您需要指定要采用的路径。

    Wikipedia has another good rundown and example here

    【讨论】:

    • Vtable 指针是一个实现细节。在这种情况下,并非所有编译器都会引入 vtable 指针。
    • 我认为如果图表垂直镜像会更好看。在大多数情况下,我发现这样的继承图可以显示基类下面的派生类。 (见“沮丧”,“向上”)
    • 如何修改他的代码以使用BC 的实现?谢谢!
    【解决方案2】:

    为什么是另一个答案?

    嗯,许多关于 SO 的帖子和外面的文章都说,钻石问题是通过创建 A 的单个实例而不是两个(D 的每个父级一个)来解决的,从而解决了歧义。然而,这并没有让我对流程有全面的了解,我最终得到了更多的问题,比如

    1. 如果BC 尝试创建A 的不同实例会怎样?调用具有不同参数的参数化构造函数 (D::D(int x, int y): C(x), B(y) {})?将选择A 的哪个实例成为D 的一部分?
    2. 如果我对B 使用非虚拟继承,而对C 使用虚拟继承呢?在D 中创建A 的单个实例是否足够?
    3. 从现在开始我是否应该始终默认使用虚拟继承作为预防措施,因为它解决了可能的菱形问题,性能成本低且没有其他缺点?

    如果不尝试代码示例就无法预测行为意味着不理解这个概念。以下是帮助我了解虚拟继承的内容。

    双A

    首先,让我们从没有虚拟继承的代码开始:

    #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); 需要BC,它们每个都创建自己的A,所以我们在d 中有双重A

    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 是使用默认构造函数创建的,忽略了从 BC 的构造函数传递的参数。随着歧义消失,所有对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创建过程中BC都不负责A的参数,完全由D决定。
    2. C 会将A 的创建委托给D,但B 将创建自己的A 实例,从而将钻石问题带回来
    3. 在孙类而不是直接子类中定义基类参数不是一个好的做法,因此当存在菱形问题并且这种措施不可避免时应该容忍。

    【讨论】:

    • 这个答案非常有用!特别是您将virtual 关键字解释为“稍后定义(在子类中)”,也就是说不是“真正”定义而是“虚拟”定义。这种解释不仅适用于基类,也适用于方法。谢谢!
    • 在我看来,virtual-example(不是解释)的一个弱点,尽管在D-ctors init-list 中调用A-ctor 就足够了,它也必须在B,C-ctor 初始化列表中指定。如果你把它留在那里(并删除在这种情况下隐含假设的 0-args ctor A()),它将不会编译。这种必要性对我来说似乎是多余的。在更复杂的情况下,这需要定义无意义的 0-args ctor A() 或在 B,Cs 初始化列表中写入无意义的 A(..)-ctor-calls,而结构的想法应该是将这些信息转移到 D .
    • 请注意,我的评论指的是示例D(int x, int y, int z): A(x), C(y), B(z)。这里A被初始化为A(x),而根据BC的初始化列表,它分别初始化为A(y)A(z),实际上从来没有发生,因此会误导读者。
    【解决方案3】:

    派生类的实例存储其基类的成员

    没有虚拟继承,内存布局看起来像(注意类DA成员的两个副本):

    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]
    

    使用虚拟继承,内存布局看起来像(注意类DA成员的单个副本):

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

    对于每个派生类,编译器创建一个虚拟表,其中包含指向其存储在派生类中的虚拟基类成员的指针,并在派生类中添加指向该虚拟表的指针。

    【讨论】:

    【解决方案4】:

    问题不在于编译器必须遵循的路径。问题在于该路径的端点:演员表的结果。当涉及到类型转换时,路径无关紧要,只有最终结果才重要。

    如果使用普通继承,每条路径都有自己独特的端点,这意味着强制转换的结果是不明确的,这就是问题所在。

    如果你使用虚拟继承,你会得到一个菱形的层次结构:两条路径都指向同一个端点。在这种情况下,选择路径的问题不再存在(或者更准确地说,不再重要),因为两条路径导致相同的结果。结果不再模棱两可——这才是最重要的。确切的路径没有。

    【讨论】:

    • @Andrey:编译器如何实现继承......我的意思是我明白你的论点,我要感谢你如此清晰地解释它......但如果你能解释它真的会有所帮助(或指向参考)关于编译器如何实际实现继承以及当我进行虚拟继承时会发生什么变化
    【解决方案5】:

    其实例子应该是这样的:

    #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”

    虚拟继承只解决了爷爷的重复! 但是您仍然需要将方法指定为虚拟方法,以便正确覆盖这些方法...

    【讨论】:

    • 这里有一个额外的例子来说明(1)A 有一个构造函数变量和(2)D 被扩展的情况:godbolt.org/z/53aKMfMar