C++中的菱形继承问题【使用虚继承方法来解决】

目录

一、答案速递:菱形继承是什么?菱形继承所带来的问题是什么?如何解决?

1.1 菱形继承是什么? 

1.2 菱形继承问题? 

1.3 如何解决菱形继承问题? 

二、使用虚拟继承解决菱形继承带来的问题的底层原理

2.1 菱形继承带来的问题 

2.2 观察未使用虚继承时的最派生类的对象模型 

2.3 使用虚继承解决上述问题

2.4 观察使用虚拟继承解决菱形问题后最派生类的对象模型

三、菱形继承的构造函数与析构函数调用顺序

3.1 普通菱形继承情况下

3.2 含有虚拟继承的菱形继承情况下

3.3 总结


一、答案速递:菱形继承是什么?菱形继承所带来的问题是什么?如何解决?

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_Agest.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 继承 SheepTuo 的情况下,SheepTuo 各自继承了 Animal,但未声明 Animal 为虚基类,因此它们分别独立地 继承了一份 Animal 的成员。

具体来说:

  • Sheep 继承 Animal,因此 Sheep 内部包含一个 Animal 实例。
  • Tuo 继承 Animal,因此 Tuo 内部也包含一个 Animal 实例。
  • SheepTuo 同时继承 SheepTuo 时,它实际上拥有两个独立的 Animal 实例,SheepTuo 中存在两份 Animal::m_Age,如果直接访问 m_Age,编译器会报二义性错误,因为它不确定 m_Age来自 Sheep 还是Tuo,导致数据冗余访问二义性

这种 因多重继承导致基类被重复包含的问题 就是 菱形继承问题(Diamond Problem)
解决方案是 使用虚继承(virtual public Animal,让 SheepTuo 共享同一个 Animal 实例,从而避免冗余和二义性。

菱形继承的问题

菱形继承的主要问题是基类的成员会被多次继承,导致数据冗余和二义性,包括:

  1. 数据冗余:SheepTuo 类实际上包含了两个 Animal类的实例,浪费内存。
  2. 二义性问题:直接访问 m_Age 时,编译器无法判断该访问 Sheep::Animal::m_Age  还是 Tuo::Animal::m_Age 。
  3. 构造函数的调用问题: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"
按下回车,得到: 

        SheepTuo 都继承了 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;
}

虚拟继承的原理

  1. SheepTuo通过 virtual 继承 Animal 后,在 SheepTuo 继承 Sheep和Tuo时,SheepTuo 只会有一个 Animal 的副本
  2. SheepTuo  访问 m_Age时,不再有二义性,因为 Sheep和 Tuo共享同一个 Animal 。
  3. 由于 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 继承了 SheepSheep 里有一个虚基类指针 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_AgeSheepTuo 内的存储位置)。

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. 关键结论

  1. SheepTuo 具有各自的 vbptr,并指向各自的 vbtable,这确保了它们能够正确找到 SheepTuo 中唯一的 Animal 实例。
  2. Animal 只存在一份,位于 SheepTuo 内存的 偏移量 16,避免了菱形继承导致的重复数据存储。
  3. vbtable 记录了 SheepTuo 如何找到 Animal,例如:
    • Sheep 通过 vbtable 计算 Animal 的地址偏移为 16
    • Tuo 通过 vbtable 计算 Animal 的地址偏移为 8

这就是虚继承解决菱形继承问题的方式——Animal 只有一个实例,并通过 vbtable 确保 SheepTuo 都能正确找到它

5.总结

SheepTuo 的继承体系中:

  • Sheep 需要知道它的 Animal 实例在哪
  • Tuo 也需要知道它的 Animal 实例在哪

SheepTuo 可能在内存布局中被安排在不同的地方,因此:

  • 编译器会分别给 SheepTuo 创建 vbtable,用于存储“如何找到 Animal”的信息
  • 最终,当 SheepTuo 组合 SheepTuo 时,SheepTuovbtable 都会指向 SheepTuo 中唯一的 Animal

📌 重点:每个有虚基类的类都会有一个自己的 vbtablevbtable 不是指向 Animal,而是指引 SheepTuoSheepTuo 里如何找到 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 的作用

  1. 存储虚基类的偏移信息,让派生类可以正确访问虚基类成员。
  2. 支持动态绑定,避免菱形继承导致的 二义性问题

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 中:

  • BC 采用 虚拟继承,所以 D 里不会有两个 A 的副本,而是只存一份 A
  • D 需要通过 vbptr 找到 A,避免二义性问题。

💡 sizeof(D) 会比 sizeof(A) + sizeof(B) + sizeof(C) 大,因为 D 内部包含 vbptr


5️⃣ 内存布局示意图

假设 BC 采用虚拟继承,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 的作用

  1. 记录虚基类的相对偏移量,让派生类能够正确访问虚基类。
  2. 配合 vbptr(虚基类指针)使用vbptr 指向 VBTable,查找基类的实际位置。

3️⃣ VBTable 的工作方式

类通过 virtual public 继承基类时:

  • 编译器会在派生类对象添加一个 vbptr(虚基类指针)
  • vbptr 指向一张 VBTable(虚基类表)。
  • VBTable 记录:当前对象访问 虚基类时,应该在 当前对象内存哪个偏移量找到它。

这样,每当需要访问虚基类成员时,编译器会:

  1. 先通过 vbptr 定位到 VBTable
  2. VBTable 里查找虚基类的偏移量
  3. 通过偏移量计算基类的真实地址,正确访问成员。

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 内存布局示意图

假设 BC 采用虚拟继承,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 继承,编译器会 偷偷加上 vbptrVBTable 来管理虚基类!

 

三、菱形继承的构造函数与析构函数调用顺序

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 被构造 两次(分别由 Derived1Derived2 各自调用)。
  • 可能导致 数据冗余,两个 Base 子对象各自维护独立的数据,MostDerived 可能出现二义性问题。

 

3.2 含有虚拟继承的菱形继承情况下

在 C++ 中,可以使用 虚继承(virtual) 解决 Base 被构造两次的问题,使 BaseMostDerived 中只存在 一个共享实例

#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 → MostDerivedMostDerived → Derived2 → Base → Derived1 → Base
虚继承下的菱形继承Base → Derived1 → Derived2 → MostDerivedMostDerived → Derived2 → Derived1 → Base

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值