18、继承(下):多重继承、菱形继承问题、虚继承、virtual基类

好,咱们接着聊继承。上一章我们把单继承讲透了,这一章要聊的,是C++里最让人又爱又恨的部分——多重继承。

说实话,我刚开始学C++那会儿,看到多重继承的第一反应是:「哇,这个好强!一个类可以同时继承好几个爹!」后来真正写项目了才发现,这玩意儿用不好,分分钟让你怀疑人生。

多重继承:一个儿子多个爹

先看个简单的例子。假设我们有两个基类:Printer(打印机)和 Scanner(扫描仪)。

class Printer {
public:
    void print() {
        cout << "打印文档" << endl;
    }
};

class Scanner {
public:
    void scan() {
        cout << "扫描文档" << endl;
    }
};

// 多功能一体机,既继承打印机,又继承扫描仪
class AllInOne : public Printer, public Scanner {
public:
    void fax() {
        cout << "发送传真" << endl;
    }
};

你看,AllInOne 同时继承了 PrinterScanner。用起来也很自然:

AllInOne device;
device.print();  // 打印
device.scan();   // 扫描
device.fax();    // 传真

多重继承的语法就是 class 派生类 : 访问限定符 基类1, 访问限定符 基类2, ...。每个基类前面都要写访问限定符,默认是 private,但我个人习惯都显式写出来,避免歧义。

小提示:多重继承不是「越多越好」。我见过有人为了复用代码,一个类继承了七八个基类,结果代码维护起来跟拆炸弹一样。一般来说,2~3个基类是比较合理的上限。

菱形继承问题:C++里最经典的坑

好,接下来这个坑,几乎每个C++程序员都踩过——菱形继承。

什么叫菱形继承?说白了就是:两个派生类继承同一个基类,然后又有另一个类同时继承这两个派生类。形状像个菱形,所以叫菱形继承。

我画个图你就明白了:

class A class B : public A class C : public A class D : public B, public C ⚠ 菱形继承:D 中有两份 A 的成员

问题来了:D 继承自 BC,而 BC 又都继承自 A。那么 D 对象里到底有几份 A 的成员?

答案是:两份

看代码:

class A {
public:
    int value;
    A() : value(0) {}
};

class B : public A {};
class C : public A {};
class D : public B, public C {};

int main() {
    D d;
    // d.value = 10;  // ❌ 编译错误!不明确
    d.B::value = 10;  // ✅ 指定访问 B 中的 value
    d.C::value = 20;  // ✅ 指定访问 C 中的 value
    return 0;
}

看到了吗?d.value 直接写会报错,因为编译器不知道你指的是 B 里的那份,还是 C 里的那份。这就是菱形继承的典型问题——二义性

注意:菱形继承不仅导致二义性,还会造成内存浪费。每个 D 对象里都存了两份 A 的成员变量,这在大型项目中可能是灾难性的。我曾经在一个图形引擎项目里遇到过类似问题,一个对象多出了几十字节的冗余数据,乘以几万个对象,内存直接爆了。

虚继承:解决菱形继承的终极方案

那怎么解决?C++ 提供了 虚继承(virtual inheritance)。

虚继承的核心思想是:让派生类共享同一个基类实例。说白了就是,不管有多少条继承路径,基类只保留一份。

语法很简单,在继承时加上 virtual 关键字:

class A {
public:
    int value;
    A() : value(0) {}
};

class B : virtual public A {};  // 虚继承
class C : virtual public A {};  // 虚继承
class D : public B, public C {};

int main() {
    D d;
    d.value = 10;  // ✅ 现在没问题了!只有一份 A
    cout << d.value << endl;  // 输出 10
    return 0;
}

加了 virtual 之后,BC 不再是各自持有一份 A 的副本,而是通过某种机制共享同一个 A 实例。这样 D 里就只有一份 value,二义性自然消失了。

虚基类:底层是怎么实现的?

你可能会问:「虚继承到底是怎么做到的?」

嗯,这里我稍微讲点底层的东西。编译器在处理虚继承时,会在派生类对象中插入一个 虚基类指针(vbptr),指向一个 虚基类表(vbtable)。通过这个表,可以找到虚基类成员在对象中的偏移量。

我画个内存布局图:

普通继承(D 对象内存布局) B 部分(包含 A 的 value) C 部分(包含 A 的 value) D 自己的成员 ⚠ value 出现两次,浪费内存 虚继承(D 对象内存布局) B 部分(含 vbptr → vbtable) C 部分(含 vbptr → vbtable) D 自己的成员 A 的 value(只有一份!)

你看,普通继承时 value 出现了两次。而虚继承把 A 的成员放到了对象的最末尾,BC 通过虚基类表找到它。这样虽然多了一个指针的开销(通常4或8字节),但避免了数据冗余和二义性。

经验之谈:虚继承不是免费的午餐。它带来了额外的间接访问开销(通过指针查表),而且构造函数的调用顺序也更复杂。我的建议是:只在确实需要解决菱形继承问题时才用虚继承,不要为了「以防万一」到处加 virtual。

虚继承的构造函数调用顺序

这一点很多人搞混,我单独拎出来说。

在虚继承中,虚基类的构造函数由最终派生类直接调用,而不是由中间类一层层传递。什么意思呢?看代码:

class A {
public:
    A(int x) { cout << "A(" << x << ")" << endl; }
};

class B : virtual public A {
public:
    B() : A(1) { cout << "B()" << endl; }  // 这个 A(1) 会被忽略!
};

class C : virtual public A {
public:
    C() : A(2) { cout << "C()" << endl; }  // 这个 A(2) 也会被忽略!
};

class D : public B, public C {
public:
    D() : A(100), B(), C() { cout << "D()" << endl; }  // 最终派生类负责初始化 A
};

int main() {
    D d;
    // 输出顺序:
    // A(100)  ← 由 D 直接调用
    // B()
    // C()
    // D()
    return 0;
}

看到了吗?BC 构造函数里写的 A(1)A(2) 全被忽略了。因为编译器规定:虚基类的构造函数只能由最底层的派生类调用

我个人觉得这个设计很合理。你想啊,如果让 BC 各自初始化 A,那到底听谁的?所以干脆让最终派生类 D 说了算。

注意:如果你在 D 的构造函数里没有显式调用 A 的构造函数,那么 A 会调用默认构造函数。如果 A 没有默认构造函数,编译就会报错。所以,使用虚继承时,一定要确保最终派生类能正确初始化虚基类

什么时候该用多重继承?

说了这么多,你可能想问:「那多重继承到底该不该用?」

我的看法是:用,但要克制

适合用多重继承的场景:

  • 接口分离:一个类需要实现多个功能接口。比如一个类既要是 Drawable(可绘制),又要是 Clickable(可点击)。
  • Mixin 风格:通过继承多个小类来组合功能。比如 class Logger 提供日志功能,class Serializable 提供序列化功能,你的业务类可以同时继承它们。
  • 适配器模式:一个类需要适配多个不同的接口。

不适合的场景:

  • 只是为了复用代码:能用组合(composition)解决的问题,就别用继承。组合比继承更灵活,耦合度更低。
  • 继承层次过深:超过3层的继承体系,维护起来就很痛苦了。
  • 出现菱形继承:如果不得不出现菱形继承,一定要用虚继承,并且确保所有中间层都正确使用 virtual
核心原则:继承表达的是「is-a」关系,组合表达的是「has-a」关系。如果你不确定该用哪个,先试试组合。我在项目中见过太多「为了继承而继承」的代码,最后改得面目全非。

避坑指南

最后,分享几个我踩过的坑:

  • 我曾经在一个项目中忘了给中间层加 virtual,结果菱形继承的底层类里出现了两份基类数据,调试了整整一天才发现问题。从那以后,我只要看到菱形继承,第一件事就是检查所有中间层是不是都加了 virtual
  • 我曾经在虚继承的构造函数里传错了参数,因为最终派生类调用了虚基类的构造函数,而中间类的构造函数参数被忽略了,导致数据初始化不对。记住:虚基类的构造函数只由最终派生类调用。
  • 我曾经试图用 dynamic_cast 在多重继承的类层次中做类型转换,结果因为虚继承的内存布局复杂,转换失败。后来改用 static_cast 加上手动判断,才解决问题。

嗯,关于多重继承和虚继承,今天就聊这么多。这些东西看起来复杂,但只要你理解了「菱形继承为什么有问题」和「虚继承怎么解决问题」,剩下的就是多写多练了。


公众号:蓝海资料掘金营,微信deep3321