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 同时继承了 Printer 和 Scanner。用起来也很自然:
AllInOne device;
device.print(); // 打印
device.scan(); // 扫描
device.fax(); // 传真
多重继承的语法就是 class 派生类 : 访问限定符 基类1, 访问限定符 基类2, ...。每个基类前面都要写访问限定符,默认是 private,但我个人习惯都显式写出来,避免歧义。
菱形继承问题:C++里最经典的坑
好,接下来这个坑,几乎每个C++程序员都踩过——菱形继承。
什么叫菱形继承?说白了就是:两个派生类继承同一个基类,然后又有另一个类同时继承这两个派生类。形状像个菱形,所以叫菱形继承。
我画个图你就明白了:
问题来了:D 继承自 B 和 C,而 B 和 C 又都继承自 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 里的那份。这就是菱形继承的典型问题——二义性。
虚继承:解决菱形继承的终极方案
那怎么解决?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 之后,B 和 C 不再是各自持有一份 A 的副本,而是通过某种机制共享同一个 A 实例。这样 D 里就只有一份 value,二义性自然消失了。
虚基类:底层是怎么实现的?
你可能会问:「虚继承到底是怎么做到的?」
嗯,这里我稍微讲点底层的东西。编译器在处理虚继承时,会在派生类对象中插入一个 虚基类指针(vbptr),指向一个 虚基类表(vbtable)。通过这个表,可以找到虚基类成员在对象中的偏移量。
我画个内存布局图:
你看,普通继承时 value 出现了两次。而虚继承把 A 的成员放到了对象的最末尾,B 和 C 通过虚基类表找到它。这样虽然多了一个指针的开销(通常4或8字节),但避免了数据冗余和二义性。
虚继承的构造函数调用顺序
这一点很多人搞混,我单独拎出来说。
在虚继承中,虚基类的构造函数由最终派生类直接调用,而不是由中间类一层层传递。什么意思呢?看代码:
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;
}
看到了吗?B 和 C 构造函数里写的 A(1) 和 A(2) 全被忽略了。因为编译器规定:虚基类的构造函数只能由最底层的派生类调用。
我个人觉得这个设计很合理。你想啊,如果让 B 和 C 各自初始化 A,那到底听谁的?所以干脆让最终派生类 D 说了算。
D 的构造函数里没有显式调用 A 的构造函数,那么 A 会调用默认构造函数。如果 A 没有默认构造函数,编译就会报错。所以,使用虚继承时,一定要确保最终派生类能正确初始化虚基类。
什么时候该用多重继承?
说了这么多,你可能想问:「那多重继承到底该不该用?」
我的看法是:用,但要克制。
适合用多重继承的场景:
- 接口分离:一个类需要实现多个功能接口。比如一个类既要是
Drawable(可绘制),又要是Clickable(可点击)。 - Mixin 风格:通过继承多个小类来组合功能。比如
class Logger提供日志功能,class Serializable提供序列化功能,你的业务类可以同时继承它们。 - 适配器模式:一个类需要适配多个不同的接口。
不适合的场景:
- 只是为了复用代码:能用组合(composition)解决的问题,就别用继承。组合比继承更灵活,耦合度更低。
- 继承层次过深:超过3层的继承体系,维护起来就很痛苦了。
- 出现菱形继承:如果不得不出现菱形继承,一定要用虚继承,并且确保所有中间层都正确使用
virtual。
避坑指南
最后,分享几个我踩过的坑:
- 我曾经在一个项目中忘了给中间层加
virtual,结果菱形继承的底层类里出现了两份基类数据,调试了整整一天才发现问题。从那以后,我只要看到菱形继承,第一件事就是检查所有中间层是不是都加了virtual。 - 我曾经在虚继承的构造函数里传错了参数,因为最终派生类调用了虚基类的构造函数,而中间类的构造函数参数被忽略了,导致数据初始化不对。记住:虚基类的构造函数只由最终派生类调用。
- 我曾经试图用
dynamic_cast在多重继承的类层次中做类型转换,结果因为虚继承的内存布局复杂,转换失败。后来改用static_cast加上手动判断,才解决问题。
嗯,关于多重继承和虚继承,今天就聊这么多。这些东西看起来复杂,但只要你理解了「菱形继承为什么有问题」和「虚继承怎么解决问题」,剩下的就是多写多练了。
公众号:蓝海资料掘金营,微信deep3321