在复杂的多重继承层次结构中,“virtual”关键字在哪些地方是必要的?

14

我了解C++虚拟继承的基础知识。然而,对于一个复杂的类层次结构,我不清楚在哪里需要使用virtual关键字。例如,假设我有以下类:

            A
           / \
          B   C
         / \ / \
        D   E   F
         \ / \ /
          G   H
           \ /
            I

如果我想确保在任何子类中,没有一个类会出现多次,那么哪些基类需要标记为virtual?所有的基类吗?还是只有那些可能拥有多个实例的直接派生自其他类的基类需要标记为virtual(例如B、C、D、E和F;以及G和H(但仅针对基类E,而不是基类D和F))?


这是一个理论问题,还是你真的遇到了这个问题并且它对你有实际价值? :) - falstro
我们实际上有类层次结构看起来像这样(事实上,它们甚至更糟)。一些重构计划中... - jchl
2
这是一个相当复杂的继承情景 :) !!!! - DumbCoder
2
我认为他想理解虚基类。它们有一些棘手的细节。 - Nordic Mainframe
3
虚继承调用顺序。http://gotw.ca/gotw/080.htm - DumbCoder
7个回答

26

我编写了一个程序,可以帮助您学习虚拟基类的复杂性。它会将在I下的类层次结构以适合graphiviz(http://www.graphviz.org/)的有向图形式打印出来。每个实例都有一个计数器,可帮助您了解构造顺序。以下是该程序:

#include <stdio.h>
int counter=0; 



#define CONN2(N,X,Y)\
    int id; N() { id=counter++; }\
    void conn() \
    {\
        printf("%s_%d->%s_%d\n",#N,this->id,#X,((X*)this)->id); \
        printf("%s_%d->%s_%d\n",#N,this->id,#Y,((Y*)this)->id); \
        X::conn(); \
        Y::conn();\
    }
#define CONN1(N,X)\
    int id; N() { id=counter++; }\
    void conn() \
    {\
        printf("%s_%d->%s_%d\n",#N,this->id,#X,((X*)this)->id); \
        X::conn(); \
    }

struct A { int id; A() { id=counter++; } void conn() {} };
struct B : A { CONN1(B,A) };
struct C : A { CONN1(C,A)  };
struct D : B { CONN1(D,B) };
struct E : B,C { CONN2(E,B,C) };
struct F : C { CONN1(F,C) };
struct G : D,E { CONN2(G,D,E) };
struct H : E,F { CONN2(H,E,F) };
struct I : G,H { CONN2(I,G,H) };
int main()
{
    printf("digraph inh {\n");
    I i; 
    i.conn(); 
    printf("}\n");
}
如果我运行这个命令 (g++ base.cc ; ./a.out >h.dot ; dot -Tpng -o o.png h.dot ; display o.png),我将得到典型的非虚基类树形结构: alt text

增加足够的虚拟性...

struct B : virtual A { CONN1(B,A) };
struct C : virtual A { CONN1(C,A)  };
struct D : virtual B { CONN1(D,B) };
struct E : virtual B, virtual C { CONN2(E,B,C) };
struct F : virtual C { CONN1(F,C) };
struct G : D, virtual E { CONN2(G,D,E) };
struct H : virtual E,F { CONN2(H,E,F) };
struct I : G,H { CONN2(I,G,H) };

..得到了菱形形状(查看数字以了解构造顺序!!)

alt text

但是如果您将所有基类都设置为虚拟的:

struct A { int id; A() { id=counter++; } void conn() {} };
struct B : virtual A { CONN1(B,A) };
struct C : virtual A { CONN1(C,A)  };
struct D : virtual B { CONN1(D,B) };
struct E : virtual B, virtual C { CONN2(E,B,C) };
struct F : virtual C { CONN1(F,C) };
struct G : virtual D, virtual E { CONN2(G,D,E) };
struct H : virtual E, virtual F { CONN2(H,E,F) };
struct I : virtual G,virtual H { CONN2(I,G,H) };

你可以通过不同的初始化顺序来获得一个钻石:

alt text

玩得开心!


1
哇,多么棒的答案啊!我得把这个问题收藏起来,以便将来参考。 - bshields
虽然不是我想要的解释,但仍然非常酷。 - jchl
把以下与编程有关的内容从英语翻译成中文。如果可以的话,就加10分... ;) - 所以不幸的是,我只能给+1。 - IanH

8

当从A、B、C和E类(位于菱形结构的顶部)继承时,您必须指定virtual继承。

class A;
class B: virtual A;
class C: virtual A;
class D: virtual B;
class E: virtual B, virtual C;
class F: virtual C;
class G:         D, virtual E;
class H: virtual E,         F;
class I:         G,         H;

1
在所有地方都使用虚继承会使程序变慢一些(尽管我从未进行过测量)。在我看来,虚继承的主要缺点是,一旦将对象转换为指向其虚基类之一的指针或引用,就无法将其转换回继承类。 - Didier Trosset
在所有地方使用虚继承将会有什么影响(如果有的话)?然后,类 I 的构造函数将调用所有基类的构造函数。 - curiousguy
@curiousguy: dynamic_cast无法工作,因为当它有一个A*时,它无法知道这个AB的虚拟部分,还是EF或任何其他从A虚拟继承的类型。 - Didier Trosset
@curiousguy:这是因为虚拟继承必须被实现的方式。这些信息就会被简单地丢失。 - Didier Trosset
1
@curiousguy,Didier 可能在考虑使用 static_cast,而不是 dynamic_cast - mlvljr
显示剩余4条评论

2
我的个人建议是从B和C开始:虚拟A,然后不断添加直到编译器停止抱怨。
实际上,我会说B和C:虚拟A,G和H:虚拟E,以及E:虚拟B和C。所有其他继承链接都可以使用普通继承。不过这种怪物需要花费大约六十年来进行虚拟调用。

你还需要声明F:virtual CD:virtual B,否则H通过E将继承C的虚拟性,但从F继承非虚拟性。 - Didier Trosset
你能解释一下为什么只有那些需要是虚拟的吗?为什么不是 D:virtual B 和 F:virtual C?以及为什么在这样的类上进行虚拟函数调用会很慢? - jchl
你说得对,那些可能也需要是虚函数。你真的需要使用编译器检查一下。至于慢的问题,因为编译器需要为每个虚函数调用检查六十多种不同的类型。 - Puppy

1

如果您想确保继承层次结构中顶级类(在您的情况下为I)包含每个父类的一个子对象,您必须找到继承层次结构中所有具有多个超类的类,并使这些类成为其超类的虚拟基类。就是这样。

在您的情况下,每当您从类ABCE继承时,这些类都必须成为虚拟基类。

DFGH不必成为虚拟基类。


0
需要记住的一件事是C++会保留继承表。你添加的虚拟类越多,编译时间(链接)就会越长,运行时也会更加沉重。
通常情况下,如果可以避免使用虚拟类,可以通过一些模板替代或尝试以某种方式解耦。

“继承表”是什么意思? - curiousguy
C++保留了类层次结构的存储库以进行动态绑定。 树形存储库越复杂,编译和执行所需的时间就越长。 - user5497885

0
如果您希望每个类型的每个实例只有一个“物理”实例(仅一个A,仅一个B等),则每次使用继承时都必须使用虚拟继承。
如果您想要某个类型的单独实例,请使用普通继承。

问题是是否需要将所有继承都设置为虚拟以提供所需的层次结构。答案是否定的。 - Mike Seymour

0

编辑:我以为A是最派生的类;)

@Luther的答案真的很棒,但回到原始问题:

当从继承层次结构中至少有一个其他类继承的类继承时(在Luther的图表中,这意味着至少有两个箭头指向该类),您需要使用virtual继承。

DFGH之前,这是不必要的,因为只有一个类从它们派生(目前没有类从I派生)。

然而,如果您事先不知道是否会有另一个类从基类继承,您可以作为预防措施添加virtual。例如,建议Exception类从std::exception虚拟继承,这是Stroustrup本人推荐的。

正如Luther所指出的,它修改了实例化顺序(并且对性能有轻微影响),但我认为任何依赖于构造顺序的设计本来就是错误的。只是作为一个精度:您仍然保证在派生类的任何属性以及执行派生类构造函数体之前初始化基类,因此在任何情况下都会先初始化基类。


我的原始问题不太清楚:类I(不是A)是最派生的类。所以我认为你的意思是(正如其他人所说),在D、F、G和H之前(以及I,尽管没有人从I派生)virtual是不必要的。我不关心实例化顺序;我确实关心对象大小。我希望避免在每个实例中有不必要的额外虚函数表指针。 - jchl
@jchl:你必须要记住,虚拟继承通常是通过为每个虚拟派生-基础链接在对象中嵌入派生到基础的指针来实现的。换句话说,如果切换到虚拟继承,你不会真正节省太多隐藏的家庭数据。相反,使用虚拟继承,内部工作通常变得更加复杂。 - AnT stands with Russia
1
@AnT:内部指针只是一种可能的实现方式和一种小优化。它们并不是必需的,因为虚函数表包含了所有信息。 - curiousguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接