目录
一、答案速递:菱形继承是什么?菱形继承所带来的问题是什么?如何解决?
一、答案速递:菱形继承是什么?菱形继承所带来的问题是什么?如何解决?
1.1 菱形继承是什么?
菱形继承是一种特殊的多重继承情形,当两个派生类继承自同一个基类,而另一个类又同时继承这两个派生类,就形成了一个类似菱形(或钻石形)的继承结构,如下图所示:
A
/ \
B C
\ /
D
或者
Base
/ \
Derived1 Derived2
\ /
MostDerived
实例:
1.2 菱形继承问题?
菱形继承问题(Diamond Problem) 是指在 C++ 多重继承 中,同一个基类被两个派生类继承,而最终派生类又同时继承这两个派生类,导致 基类的成员在最终派生类中存在多个独立副本,从而引发 数据冗余 和 访问二义性 的问题。
这里的 "副本" 指的是 基类的多个独立实例。
在 C++ 继承 中,子类对象会包含其基类的一个完整实例。当 多重继承 发生时,如果两个直接派生类都继承了同一个基类,而最终派生类又继承了这两个派生类,就会导致 最终派生类中存在两个独立的基类实例。
这种 "重复的基类实例" 就是 "副本" 的含义。
🔹 举个例子
class Animal { public: int m_Age; }; class Sheep : public Animal {}; // Sheep 继承自 Animal class Tuo : public Animal {}; // Tuo 继承自 Animal class SheepTuo : public Sheep, public Tuo {}; // SheepTuo 继承 Sheep 和 Tuo
当我们创建
SheepTuo
的对象时:SheepTuo st; st.Sheep::m_Age = 18; st.Tuo::m_Age = 28;
st
的 内存布局 如下:[ SheepTuo 对象 ] ┌──────────────┐ │ Sheep 部分 │ │ Animal 部分 │ ← Sheep 继承的 Animal(副本 1) │ m_Age = 18 │ ├──────────────┤ │ Tuo 部分 │ │ Animal 部分 │ ← Tuo 继承的 Animal(副本 2) │ m_Age = 28 │ └──────────────┘
可以看到:
SheepTuo
里有 两个Animal
实例,导致数据冗余,一个属于Sheep
,一个属于Tuo
。m_Age
在SheepTuo
中存在两份,导致访问m_Age
时出现二义性:只能用st.m_Age = 18; // ❌ 错误:m_Age 不明确,编译器不知道是哪个 Animal 的 m_Age
st.Sheep::m_Age
或st.Tuo::m_Age
解决。这种 "一个基类在最终派生类中出现多个实例" 的情况,就是 "副本" 的真正含义。
1.3 如何解决菱形继承问题?
✅ 方案:使用虚拟继承(virtual
)
虚拟继承 确保派生类共享同一个基类实例,从而消除二义性并避免存储冗余。
🔹 原理
- 虚拟继承 让基类成员在最终派生类中只存在一份实例,防止基类被重复继承。
- 编译器会为基类创建虚拟基类表(vtable),用于管理虚基类,可能带来额外的运行时开销。
🚀 推荐:如果遇到 菱形继承问题,尽量使用 虚拟继承 来保证基类的唯一性!
二、使用虚拟继承解决菱形继承带来的问题的底层原理
2.1 菱形继承带来的问题
我们以一个案例来说明菱形继承会带来什么问题?如下所示:
#include<iostream>
// 动物类
class Animal {
public:
int m_Age;
};
// 羊类
class Sheep :public Animal {
};
// 驼类
class Tuo :public Animal {
};
// 羊驼类
class SheepTuo :public Sheep, public Tuo {
};
void test01() {
SheepTuo st;
//st.m_Age = 18; // ❌编译报错:SheepTuo::m_Age 不明确,多继承的二义性问题
st.Sheep::m_Age = 18; // 加入作用域区分,解决多继承带来的二义性问题
st.Tuo::m_Age = 28; // 加入作用域区分
// 多继承中,若两个父类拥有相同数据,需要加以作用域区分
std::cout << "st.Sheep::m_Age=" << st.Sheep::m_Age << std::endl;
std::cout << "st.Tuo::m_Age=" << st.Tuo::m_Age << std::endl;
// 现在这个羊驼到底多大?是18?还是28?
}
int main() {
test01();
return 0;
}
在
SheepTuo
继承Sheep
和Tuo
的情况下,Sheep
和Tuo
各自继承了Animal
,但未声明Animal
为虚基类,因此它们分别独立地 继承了一份Animal
的成员。具体来说:
Sheep
继承Animal
,因此Sheep
内部包含一个Animal
实例。Tuo
继承Animal
,因此Tuo
内部也包含一个Animal
实例。- 当
SheepTuo
同时继承Sheep
和Tuo
时,它实际上拥有两个独立的Animal
实例,SheepTuo 中存在两份Animal::m_Age,
如果直接访问m_Age
,编译器会报二义性错误,因为它不确定m_Age
来自 Sheep 还是Tuo,导致数据冗余和访问二义性。这种 因多重继承导致基类被重复包含的问题 就是 菱形继承问题(Diamond Problem)。
解决方案是 使用虚继承(virtual public Animal
),让Sheep
和Tuo
共享同一个Animal
实例,从而避免冗余和二义性。
菱形继承的问题
菱形继承的主要问题是基类的成员会被多次继承,导致数据冗余和二义性,包括:
- 数据冗余:SheepTuo 类实际上包含了两个
Animal
类的实例,浪费内存。 - 二义性问题:直接访问
m_Age
时,编译器无法判断该访问 Sheep::
Animal::
m_Age
还是 Tuo::
Animal::
m_Age
。 - 构造函数的调用问题:SheepTuo 类的构造函数会调用 Sheep 和 Tuo 的构造函数,而 Sheep 和 Tuo 各自会调用 Animal 的构造函数两次,可能导致重复初始化。
2.2 观察未使用虚继承时的最派生类的对象模型
我们借助 Visual Studio Developer Command Prompt 来具体看一下菱形继承中的最派生类(也就是上述的 SheepTuo 类)的对象模型在内存中是如何分布的:
作者使用的Visual Studio是2019版的,直接在搜索栏中搜索下图中所示的内容:
打开之后是下面这个样子:
接下来我们需要修改当前工作路径到我们写的菱形继承的源文件路径下,具体操作方法如下:
复制当前路径,如下红框所示:
在命令行中输入指令: cd 复制的路径,使用 CTRL+V 或者 右击 均可快捷粘贴复制的路径,然后按下回车按键,当前工作路径就会进入到我们的源文件路径下:
然后在使用 dir 指令看一下当前目录下的内容,如下所示:
然后再输入指令:cl /d1 reportSingleClassLayout类名 "源文件.cpp",这个 源文件.cpp 中包含我们要查看的类。
对于我们的代码中,可以写如下指令:
cl /d1 reportSingleClassLayoutSheepTuo "C++learning.cpp"
按下回车,得到:
Sheep 和 Tuo 都继承了 Animal,因此 SheepTuo 继承了 Sheep 和 Tuo 后,它有两个 Animal 的副本,导致 SheepTuo 中存在两份 Animal::m_Age
。如下图所示:
数据冗余:SheepTuo 类实际上包含了两个 Animal
类的实例,浪费内存。
2.3 使用虚继承解决上述问题
为了解决 菱形继承 的冗余和二义性问题,可以使用虚拟继承(virtual inheritance):
#include<iostream>
// 动物类
class Animal { // Animal 称为虚基类
public:
int m_Age;
};
// 羊类
class Sheep :virtual public Animal { // 虚继承
};
// 驼类
class Tuo :virtual public Animal { // 虚继承
};
// 羊驼类
class SheepTuo :public Sheep, public Tuo {
};
void test01() {
SheepTuo st;
st.m_Age = 18; // ✔编译正确:SheepTuo::m_Age 明确,通过虚继承解决菱形继承(多重继承)的二义性问题
st.Sheep::m_Age = 18;
st.Tuo::m_Age = 28;
std::cout << "st.Sheep::m_Age=" << st.Sheep::m_Age << std::endl; //st.Sheep::m_Age = 28
std::cout << "st.Tuo::m_Age=" << st.Tuo::m_Age << std::endl;//st.Tuo::m_Age = 28
std::cout << "st.m_Age=" << st.m_Age << std::endl;//st.m_Age = 28
}
int main() {
test01();
return 0;
}
虚拟继承的原理
- Sheep和 Tuo通过
virtual
继承 Animal 后,在 SheepTuo 继承 Sheep和Tuo时,SheepTuo 只会有一个 Animal 的副本。 - SheepTuo 访问 m_Age时,不再有二义性,因为 Sheep和 Tuo共享同一个 Animal 。
- 由于 Sheep和 Tuo都是 虚拟继承 Animal ,Animal 的构造函数必须由最派生类 SheepTuo 负责调用,所以 SheepTuo 需要显式调用 Animal 的构造函数:
class SheepTuo : public Sheep, public Tuo { public: SheepTuo() : Animal() {} // 由 SheepTuo 显式调用 Animal 的构造函数 };
2.4 观察使用虚拟继承解决菱形问题后最派生类的对象模型
我们再来看一下使用虚拟继承解决菱形问题后最派生类的对象模型是怎么样的?如下图所示:
我们发现,SheepTuo下面只有一份m_Age,如下所示:
Sheep 类的虚基类指针和指向的虚基类表如下图所示:
Tuo 类的虚基类指针和指向的虚基类表如下图所示:
接下来我们分析上面的内存布局图:
上面的图是
SheepTuo
类的对象模型:
1. 类的内存布局
class SheepTuo size(20):
SheepTuo
这个类的总大小是20
字节。- 这是因为:
Sheep
作为SheepTuo
的基类,占据了一部分空间。Tuo
作为SheepTuo
的基类,也占据了一部分空间。Animal
作为虚基类,只会有一个共享的m_Age
变量。- 由于虚继承,编译器插入了一些
vbptr
(虚基类指针),用于指向vbtable
。
2. 继承关系及
vbptr
0 +--- (base class Sheep) 0 | | {vbptr} 0 | | <alignment member> (size=4)
SheepTuo
继承了Sheep
,Sheep
里有一个虚基类指针vbptr
,用于指向vbtable
。- 由于
vbptr
通常是 4 字节,而 64 位系统上指针通常是 8 字节,所以这里有 alignment member(对齐成员) 让结构体对齐。8 +--- (base class Tuo) 8 | | {vbptr} 8 | | <alignment member> (size=4)
Tuo
也有一个vbptr
,用于指向自己的vbtable
,同样带有对齐成员。16 +--- (virtual base Animal) 16 | m_Age
Animal
作为虚基类,它的m_Age
变量最终只有一份,被放置在SheepTuo
类的偏移量16
处。
3.
vbtable
(虚基类表)分析SheepTuo 的
vbtable
(针对 Sheep)SheepTuo::$vbtable@Sheep@: 0 0 1 16 (SheepTuo d(Sheep+0) Animal)
vbtable
的第0
项是0
,表示vbptr
本身的位置。
vbtable
的第1
项是16
,表示Animal
实例的偏移量是16
(即m_Age
在SheepTuo
内的存储位置)。SheepTuo 的
vbtable
(针对 Tuo)SheepTuo::$vbtable@Tuo@: 0 0 1 8 (SheepTuo d(Tuo+0) Animal)
vbtable
的第0
项是0
,表示vbptr
本身的位置。vbtable
的第1
项是8
,表示Animal
实例的偏移量是8
,但这里显示的是Tuo
视角下的Animal
偏移量。
4. 关键结论
Sheep
和Tuo
具有各自的vbptr
,并指向各自的vbtable
,这确保了它们能够正确找到SheepTuo
中唯一的Animal
实例。Animal
只存在一份,位于SheepTuo
内存的 偏移量16
处,避免了菱形继承导致的重复数据存储。vbtable
记录了Sheep
和Tuo
如何找到Animal
,例如:
Sheep
通过vbtable
计算Animal
的地址偏移为16
。Tuo
通过vbtable
计算Animal
的地址偏移为8
。这就是虚继承解决菱形继承问题的方式——让
Animal
只有一个实例,并通过vbtable
确保Sheep
和Tuo
都能正确找到它。5.总结
在
SheepTuo
的继承体系中:
Sheep
需要知道它的Animal
实例在哪Tuo
也需要知道它的Animal
实例在哪但
Sheep
和Tuo
可能在内存布局中被安排在不同的地方,因此:
- 编译器会分别给
Sheep
和Tuo
创建vbtable
,用于存储“如何找到Animal
”的信息。- 最终,当
SheepTuo
组合Sheep
和Tuo
时,Sheep
和Tuo
的vbtable
都会指向SheepTuo
中唯一的Animal
。📌 重点:每个有虚基类的类都会有一个自己的
vbtable
!vbtable
不是指向Animal
,而是指引Sheep
和Tuo
在SheepTuo
里如何找到Animal
!
名词解释:vbptr、vbtable
📌 vbptr
(虚基类指针)属于对象,而不是类!
📌 每个对象有自己独立的 vbptr
,用于访问 VBTable
,从而正确解析虚基类的位置!
💡 👉 记住:
- 如果一个类没有创建对象,它的
vbptr
是不存在的。 - 不同对象的
vbptr
可能指向不同的VBTable
,因为基类的偏移量可能不同。 vbptr
是编译器自动生成的,程序员无法直接操作,但可以通过sizeof
观察到它的存在。
📌 vbptr(虚基类指针,Virtual Base Pointer)是什么?
🔹 vbptr(Virtual Base Pointer,虚基类指针) 是 C++ 虚拟继承 机制中,编译器为 派生类 生成的一个 隐藏指针,用于 定位虚基类的实际地址。
1️⃣ 为什么需要 vbptr?
在 菱形继承 结构中,虚拟继承确保最终派生类只包含一个基类实例,但由于不同路径的继承关系会导致基类的位置不固定,编译器无法直接通过普通方式访问基类成员。
✅ 解决方案:引入
vbptr
,通过它动态查找虚基类的地址!
2️⃣ vbptr 的作用
- 存储虚基类的偏移信息,让派生类可以正确访问虚基类成员。
- 支持动态绑定,避免菱形继承导致的 二义性问题。
3️⃣ vbptr 的工作方式
当 类通过
virtual public
继承基类 时:
- 编译器会在派生类对象中隐式添加一个
vbptr
,指向一个 虚基类表(VBTable,Virtual Base Table)。VBTable
记录虚基类相对于当前对象的偏移量,这样访问虚基类成员时,编译器可以通过vbptr + 偏移量
计算出虚基类的真实地址。
4️⃣ 代码示例
#include <iostream> class A { public: int a; }; class B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {}; int main() { D obj; std::cout << "Size of D: " << sizeof(D) << std::endl; return 0; }
🔹 在
D
中:
B
和C
采用 虚拟继承,所以D
里不会有两个A
的副本,而是只存一份A
。D
需要通过vbptr
找到A
,避免二义性问题。💡
sizeof(D)
会比sizeof(A) + sizeof(B) + sizeof(C)
大,因为D
内部包含vbptr
。
5️⃣ 内存布局示意图
假设
B
和C
采用虚拟继承,D
的内存布局可能如下:[D] ┌──────────────┐ │ B 的部分 │ │ vbptr ---> │───┐ ├──────────────┤ │ │ C 的部分 │ │ │ vbptr ---> │───┘ ├──────────────┤ │ A 的部分 │ ← 共享的基类实例 └──────────────┘
vbptr
通过VBTable
查找A
的真实地址,保证D
访问A
时不会出错。
6️⃣ vbptr 的特点
✅ 自动生成:由 编译器自动管理,用户无法直接操作。
✅ 节省内存:通过共享基类,避免重复存储数据。
✅ 增加运行时开销:由于每次访问虚基类都需要查询vbptr
,所以可能稍慢。
7️⃣ 总结
概念 作用 vbptr
(虚基类指针)指向 虚基类表(VBTable),用于查找虚基类的真实地址 VBTable
(虚基类表)存储 虚基类相对派生类的偏移信息,供 vbptr
查询用途 解决 虚拟继承 的基类二义性问题,确保基类成员只有一份 影响 节省内存,但 增加访问基类的运行时开销
💡 🚀 记住:如果一个类采用
virtual public
继承,编译器就会 偷偷加上vbptr
来帮助管理虚基类!
📌 VBTable(虚基类表,Virtual Base Table)是什么?
在 C++ 虚拟继承 机制中,VBTable(虚基类表) 是一个 存储虚基类偏移信息的表,用于帮助派生类正确访问虚基类的成员。
1️⃣ 为什么需要 VBTable?
在 菱形继承 结构中,虚拟继承 确保基类成员只存在一份实例,但由于继承路径不同,基类在内存中的位置是动态的,编译器无法在编译期确定基类的偏移量。
✅ 解决方案:使用
VBTable
,动态存储基类偏移量,派生类可通过vbptr
查找正确的虚基类地址!
2️⃣ VBTable 的作用
- 记录虚基类的相对偏移量,让派生类能够正确访问虚基类。
- 配合
vbptr
(虚基类指针)使用,vbptr
指向VBTable
,查找基类的实际位置。
3️⃣ VBTable 的工作方式
当类通过
virtual public
继承基类时:
- 编译器会在派生类对象中添加一个
vbptr
(虚基类指针)。vbptr
指向一张 VBTable(虚基类表)。- VBTable 记录:当前对象访问 虚基类时,应该在 当前对象内存 的哪个偏移量找到它。
这样,每当需要访问虚基类成员时,编译器会:
- 先通过
vbptr
定位到VBTable
。- 在
VBTable
里查找虚基类的偏移量。- 通过偏移量计算基类的真实地址,正确访问成员。
4️⃣ 代码示例
#include <iostream> class A { public: int a; }; class B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {}; int main() { D obj; std::cout << "Size of D: " << sizeof(D) << std::endl; return 0; }
5️⃣ VBTable 内存布局示意图
假设
B
和C
采用虚拟继承,D
的内存布局可能如下:[D] ┌────────────────┐ │ B 的成员 │ │ vbptr ---> VBTable ───┐ ├────────────────┤ │ │ C 的成员 │ │ │ vbptr ---> VBTable ───┘ ├────────────────┤ │ A 的成员(共享) │ ← 通过 VBTable 计算出的地址 └────────────────┘
🔹
vbptr
指向VBTable
,而VBTable
记录了A
的偏移量。
🔹 这样D
访问A
时,不论从B
还是C
进入,都能正确找到A
的位置!
6️⃣ 关键点
概念 作用 VBTable
(虚基类表)记录 虚基类的偏移量,用于查找真实地址 vbptr
(虚基类指针)指向 VBTable
,派生类通过它访问虚基类用途 解决 虚拟继承 的 基类二义性问题,确保基类成员只存一份 影响 节省内存,但 增加访问基类的运行时开销
7️⃣ 总结
📌
VBTable
是编译器生成的表,存储虚基类的偏移信息,配合vbptr
使用,确保虚基类在继承关系复杂的情况下仍能被正确访问。💡 🚀 记住:如果一个类采用
virtual public
继承,编译器会 偷偷加上vbptr
和VBTable
来管理虚基类!
三、菱形继承的构造函数与析构函数调用顺序
3.1 普通菱形继承情况下
菱形继承通常会导致 基类被构造多次,但 虚继承 可以确保基类只被构造 一次。
普通继承(非虚继承)构造顺序: 如果 Base
没有被声明为虚基类,则 MostDerived
会继承两个 Base
子对象,构造函数调用顺序如下:
#include <iostream>
class A {
public:
A(){
std::cout<<"A 的构造函数被调用"<<std::endl;
}
~A(){
std::cout<<"A 的析构函数被调用"<<std::endl;
}
};
class B : public A {// B 继承 A
public:
B(){
std::cout<<"B 的构造函数被调用"<<std::endl;
}
~B(){
std::cout<<"B 的析构函数被调用"<<std::endl;
}
};
class C : public A {// C 继承 A
public:
C(){
std::cout<<"C 的构造函数被调用"<<std::endl;
}
~C(){
std::cout<<"C 的析构函数被调用"<<std::endl;
}
};
class D : public B, public C {// D 同时继承 B 和 C
public:
D(){
std::cout<<"D 的构造函数被调用"<<std::endl;
}
~D(){
std::cout<<"D 的析构函数被调用"<<std::endl;
}
};
int main() {
D obj; // D类实例化
return 0;
}
// 输出:
// A 的构造函数被调用
// B 的构造函数被调用
// A 的构造函数被调用
// C 的构造函数被调用
// D 的构造函数被调用
// D 的析构函数被调用
// C 的析构函数被调用
// A 的析构函数被调用
// B 的析构函数被调用
// A 的析构函数被调用
⚠ 问题:
Base
被构造 两次(分别由Derived1
和Derived2
各自调用)。- 可能导致 数据冗余,两个
Base
子对象各自维护独立的数据,MostDerived
可能出现二义性问题。
3.2 含有虚拟继承的菱形继承情况下
在 C++ 中,可以使用 虚继承(virtual) 解决 Base
被构造两次的问题,使 Base
在 MostDerived
中只存在 一个共享实例。
#include <iostream>
class A { // A此时叫虚基类
public:
A(){
std::cout<<"A 的构造函数被调用"<<std::endl;
}
~A(){
std::cout<<"A 的析构函数被调用"<<std::endl;
}
};
class B : virtual public A {// B 虚继承 A
public:
B(){
std::cout<<"B 的构造函数被调用"<<std::endl;
}
~B(){
std::cout<<"B 的析构函数被调用"<<std::endl;
}
};
class C : virtual public A {// C 虚继承 A
public:
C(){
std::cout<<"C 的构造函数被调用"<<std::endl;
}
~C(){
std::cout<<"C 的析构函数被调用"<<std::endl;
}
};
class D : public B, public C {// D 同时继承 B 和 C
public:
D(){
std::cout<<"D 的构造函数被调用"<<std::endl;
}
~D(){
std::cout<<"D 的析构函数被调用"<<std::endl;
}
};
int main() {
D obj; // D类实例化
return 0;
}
// 输出:
// A 的构造函数被调用
// B 的构造函数被调用
// C 的构造函数被调用
// D 的构造函数被调用
// D 的析构函数被调用
// C 的析构函数被调用
// B 的析构函数被调用
// A 的析构函数被调用
3.3 总结
继承类型 | 构造函数调用顺序 | 析构函数调用顺序 |
---|---|---|
普通菱形继承(非虚继承) | Base → Derived1 → Base → Derived2 → MostDerived | MostDerived → Derived2 → Base → Derived1 → Base |
虚继承下的菱形继承 | Base → Derived1 → Derived2 → MostDerived | MostDerived → Derived2 → Derived1 → Base |