对于上一篇文章遗留下来问题的补充:
一、菱形继承(钻石问题)
在多继承场景中,最经典的问题就是菱形继承(Diamond Inheritance),也叫钻石问题。
1.什么是菱形继承?
cpp
class GrandBase { // 祖父类
public:
int value = 100;
virtual void show() { std::cout << "GrandBase\n"; }
};
class Base1 : public GrandBase { }; // 父亲1
class Base2 : public GrandBase { }; // 父亲2
class Derived : public Base1, public Base2 { }; // 孙子类
此时 Derived 对象中会出现两份 GrandBase 子对象(两份 value,两个 vptr)。
2.不使用虚继承会产生什么问题?
- 数据冗余:同一份基类数据被复制两份,浪费内存。
- 访问二义性:编译器不知道你想访问哪一份 GrandBase 的成员。
cpp
Derived d;
d.value = 200; // 编译错误:二义性!哪个 value?
d.Base1::value = 200; // 必须显式限定,麻烦且容易出错
d.show(); // 同样二义性
即使你用限定符解决二义性,数据仍然是两份,这在大多数场景下都不是我们想要的。
3.虚继承(virtual inheritance)如何解决?
只需在中间层继承时加上 virtual 关键字:
cpp
class Base1 : virtual public GrandBase { };
class Base2 : virtual public GrandBase { };
class Derived : public Base1, public Base2 { };
效果:
- Derived 中只有一份GrandBase 子对象。
- 数据冗余消失。
- 访问不再二义性:d.value、d.show() 都可以直接使用。
4.虚继承的底层实现原理(vbptr + vbtable)
内存布局对比(简化示意,32位为例):
不使用虚继承时(有两份 GrandBase):
bash
Derived 对象:
├── Base1 子对象
│ ├── vptr (指向 Base1 的 vtable)
│ └── GrandBase 子对象(value + vptr)
├── Base2 子对象
│ ├── vptr (指向 Base2 的 vtable)
│ └── GrandBase 子对象(value + vptr) ← 重复!
└── Derived 自己的成员
使用虚继承后(只有一份 GrandBase):
bash
Derived 对象:
├── Base1 子对象(不含 GrandBase)
│ ├── vptr (普通虚函数表指针)
│ └── vbptr(虚基类指针) → 指向 vbtable
├── Base2 子对象(不含 GrandBase)
│ ├── vptr
│ └── vbptr → 指向 vbtable
├── Derived 自己的成员
└── GrandBase 子对象(共享的,放在对象末尾)
├── value
└── vptr(可能共享或调整)
vbptr 和 vbtable 的工作机制:
1.每个虚继承的类(Base1、Base2)都会在对象中多出一个隐藏的 vbptr(virtual base pointer)。
2.vbptr 指向一个 vbtable(虚基类表),表里存放的是偏移量(offset)。
3.通过这个偏移量,运行时可以动态计算出共享的 GrandBase 子对象在当前对象中的真实位置。
4.GrandBase 子对象被"推迟"到整个派生类对象的末尾,由最派生类(Derived)负责构造和存放。
关键特点:
1.vbptr 通常位于每个虚继承路径的子对象开头附近。
2.虚基类表(vbtable)是编译期生成的静态表,不占用每个对象的空间。
3.访问虚基类成员时,编译器会生成代码:对象地址 + vbptr 指向的偏移量 来找到正确位置。
代价:
1.每个对象体积增大(多出若干个 vbptr,通常 4/8 字节一个)。
2.访问虚基类成员的效率略低(需要一次间接寻址)。
3.构造函数更复杂:最派生类负责初始化虚基类,中间类只负责设置 vbptr。
注意:
1.virtual 关键字只需写在直接继承虚基类的中间类中(Base1 和 Base2)。
2.虚继承与虚函数(virtual function)是完全独立的两个机制,不要混淆。
二、常见陷阱与深入理解
在使用多态和继承时,以下四个陷阱非常容易踩到,务必深刻理解其原因和正确写法。
1.通过基类指针 delete 非虚析构 → 未定义行为
cpp
Base* p = new Derived();
delete p; // 如果 Base 的析构函数不是 virtual -> 危险
原因:
- delete 时,编译器根据指针的静态类型(Base*)决定调用哪个析构函数。
- 如果 ~Base() 不是 virtual,只会调用 Base::Base(),Derived::Derived() 根本不会执行。
- 结果:Derived 中申请的资源(内存、文件句柄、锁等)得不到释放,导致内存泄漏或资源泄漏。
- 这属于未定义行为,可能崩溃、数据损坏,或在某些环境下"侥幸"正常,换个编译器/优化级别就出问题。
正确做法:
cpp
class Base {
public:
virtual ~Base() = default; // 必须加 virtual
};
凡是打算多态删除的基类,析构函数必须是 virtual.
2.在构造函数/析构函数中调用虚函数 -> 永远调用当前类的版本
反直觉现象:
在 Base 的构造函数里调用虚函数,永远执行的是 Base 版本,而不是派生类重写的版本。
根本原因:
- 对象构造顺序是先基类后派生类。
- 当 Base 构造函数执行时,Derived 部分还没有构造完成,对象此时还"不是一个完整的 Derived"。
- 析构时顺序相反,先派生类后基类,Base 析构时 Derived 部分已经销毁。
- C++ 标准明确规定:此时对虚函数的调用不进行动态绑定,而是静态绑定到当前正在构造/析构的类的版本。
建议:
- 尽量避免在构造函数和析构函数中调用虚函数。
- 如果确实需要,可以在对象完全构造后再调用(例如通过成员函数或工厂模式)。
3.值传递对象(切片)-> 多态彻底失效
示例:
cpp
void process(Base b) { // 值传递
b.show(); // 永远调用 Base::show(),即使传入 Derived 对象
}
std::vector<Base> vec;
vec.push_back(Derived{}); // 切片发生
原因:
值传递/值拷贝时,编译器只会拷贝 Base 部分的数据和 vptr。
Derived 特有的成员和虚函数表信息被"切掉"。
对象不再是 Derived,变成了一个纯粹的 Base 对象,多态机制失效。
正确做法:
始终使用指针(Base*、std::unique_ptr)或引用(Base&、const Base&)。
永远不要用值传递可能发生多态的对象。
可以这样说:多态只认指针和引用,值传递直接切片。
4.不要在 vtable 里放非虚函数
含义:
vtable(虚表)只存放声明为 virtual 的函数的地址。
非虚函数在编译期就已经静态绑定,直接跳转到函数地址,不需要通过 vptr 查表。
因此,非虚函数不会出现在 vtable 中。
为什么强调这一点?
如果把不需要多态的函数也声明为 virtual,会白白增加每个对象的 vptr 开销和 vtable 空间。
正确做法:只有真正需要"运行时根据实际类型调用"的函数才加 virtual。