对于多继承类的理解,我一直感到疑惑。为什么要使用多继承呢?为什么不用组合呢?这个疑惑一会儿再来解答。今天先来探索一下多继承类下,虚函数表与虚函数表指针在内存布局的情况。

在运行任何测试代码之前,我先提出我目前的理解。如果先不考虑有菱形继承的情况。我们有一个电动车ElectricCar类,它继承了两个类:一个是Vehicle交通工具类(电动车是交通工具),一个是Electronic电子设备类(不得不说确实是电子设备)。它的内存布局我目前的理解长这样:

1
2
3
4
5
6
Vehicle的虚函数表指针
Vehicle的成员对象(int,string等)
Electronic的虚函数表指针
Electronic的成员对象
ElectricCar的虚函数表指针
ElectricCar的成员对象

按照我目前的理解,为了实现多态(也就是可以用基类的指针指向派生类的对象)的话,好像必须要保留每一个基类对象的虚函数表指针。

口说无凭。来看看代码测试:

首先定义了三个基类,分别是交通工具类,电子设备类和导航类(工程里还是用组合好)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Vehicle {
public:
virtual void vehicle_start() {
cout << "Vehicle::vehicle_start()" << endl;
}

virtual void vehicle_stop() {
cout << "Vehicle::vehicle_stop()" << endl;
}

virtual ~Vehicle() {
cout << "Vehicle destructor" << endl;
}
};

class Electronic {
public:
virtual void electronic_powerOn() {
cout << "Electronic::electronic_powerOn()" << endl;
}

virtual void electronic_powerOff() {
cout << "Electronic::electronic_powerOff()" << endl;
}

virtual ~Electronic() {
cout << "Electronic destructor" << endl;
}
};


class Navigable {
public:
virtual void navigable_setDestination() {
cout << "Navigable::navigable_setDestination()" << endl;
}

virtual ~Navigable() {
cout << "Navigable destructor" << endl;
}
};

又定义了派生类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 派生类1:电动车(继承自Vehicle和Electronic)
class ElectricCar : public Vehicle, public Electronic {
public:
// 重写Vehicle的虚函数
virtual void vehicle_start() override {
cout << "ElectricCar::vehicle_start() [静音启动]" << endl;
}

// 重写Electronic的虚函数
virtual void electronic_powerOn() override {
cout << "ElectricCar::electronic_powerOn() [智能启动]" << endl;
}

// 新增的虚函数
virtual void electricCar_charge() {
cout << "ElectricCar::electricCar_charge() [专用充电]" << endl;
}

virtual ~ElectricCar() {
cout << "ElectricCar destructor" << endl;
}
};

// 派生类2:智能电动车(继承自三个基类)
class SmartElectricCar : public Vehicle, public Electronic, public Navigable {
public:
// 重写Vehicle的虚函数
virtual void vehicle_start() override {
cout << "SmartElectricCar::vehicle_start() [语音控制启动]" << endl;
}

// 重写Electronic的虚函数
virtual void electronic_powerOff() override {
cout << "SmartElectricCar::electronic_powerOff() [延时关机]" << endl;
}

// 重写Navigable的虚函数
virtual void navigable_setDestination() override {
cout << "SmartElectricCar::navigable_setDestination() [AI推荐路线]" << endl;
}

// 新增的虚函数
virtual void smartElectricCar_autoPilot() {
cout << "SmartElectricCar::smartElectricCar_autoPilot() [自动驾驶]" << endl;
}

virtual ~SmartElectricCar() {
cout << "SmartElectricCar destructor" << endl;
}
};

首先来测试不同类的大小

1
2
3
4
5
6
7
8
void testVTablePointers() {
cout << "======= 测试不同类的对象大小 =======\n";
cout << "sizeof(Vehicle): " << sizeof(Vehicle) << " bytes" << endl;
cout << "sizeof(Electronic): " << sizeof(Electronic) << " bytes" << endl;
cout << "sizeof(Navigable): " << sizeof(Navigable) << " bytes" << endl;
cout << "sizeof(ElectricCar): " << sizeof(ElectricCar) << " bytes" << endl;
cout << "sizeof(SmartElectricCar): " << sizeof(SmartElectricCar) << " bytes" << endl;
}

测试结果:

1
2
3
4
5
6
7
======= 测试不同类的对象大小 =======
sizeof(Vehicle): 8 bytes
sizeof(Electronic): 8 bytes
sizeof(Navigable): 8 bytes

sizeof(ElectricCar): 16 bytes
sizeof(SmartElectricCar): 24 bytes

在64位系统中,一个指针通常为8字节。从测试结果可以看出:
ElectricCar有2个vptr,所以有16字节
SmartElectricCar有3个vptr,所以有24字节

接着看一看多继承下的指针转换

我一直认为,当一个基类指针指向派生类时,这个基类指针的地址就是派生类最头的那个虚函数表指针的地址。但这好像只适用于单继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 测试多继承下的指针转换
void testPointerCasting() {
cout << "\n======= 测试多继承下的指针转换 =======\n";

ElectricCar electricCar;

cout << "\n1. ElectricCar对象地址: " << &electricCar << endl;

// 转换为不同基类指针
Vehicle* vehiclePtr = &electricCar;
Electronic* electronicPtr = &electricCar;

cout << "2. Vehicle* 指针值: " << vehiclePtr << endl;
cout << "3. Electronic* 指针值: " << electronicPtr << endl;

// 计算偏移量
cout << "4. 两个指针的差值: "
<< reinterpret_cast<char*>(electronicPtr) - reinterpret_cast<char*>(vehiclePtr)
<< " 字节" << endl;
}

测试结果:

1
2
3
4
5
6
7
8
9
10
======= 测试多继承下的指针转换 =======

1. ElectricCar对象地址: 000000CC36AFF868
2. Vehicle* 指针值: 000000CC36AFF868
3. Electronic* 指针值: 000000CC36AFF870
4. 两个指针的差值: 8 字节

ElectricCar destructor
Electronic destructor
Vehicle destructor

可以看出,和单继承不同。多继承时,不同的基类子对象在派生类中位置不同。Vehicle子对象在ElectricCar的开始位置而Electronic子对象在Vehicle子对象之后。正因如此,二者的指针地址并不一样,他们各自指向了自己的那个子对象位置的开头。相差的8字节便是一个虚函数表指针的大小。

现在可以认为,两个派生类的内存布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ElectricCar对象布局:
[vptr_Vehicle] → Vehicle虚函数表
[Vehicle数据成员]
[vptr_Electronic] → Electronic虚函数表
[Electronic数据成员]
[ElectricCar新增数据成员]

SmartElectricCar对象布局:
[vptr_Vehicle] → Vehicle虚函数表
[Vehicle数据成员]
[vptr_Electronic] → Electronic虚函数表
[Electronic数据成员]
[vptr_Navigable] → Navigable虚函数表
[Navigable数据成员]
[SmartElectricCar新增数据成员]

一个很重要的点:当通过基类指针调用虚函数时,编译器会自动调整this指针。

用一个直观的多继承的内存布局图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
地址              内容                    大小
0x628C0FF328 ┌─────────────────────┐
│ Base1的vptr │ 8字节
0x628C0FF330 ├─────────────────────┤
│ Base1::b1_data=100 │ 4字节
├─────────────────────┤
│ 填充字节 │ 4字节
0x628C0FF338 ├─────────────────────┤
│ Base2的vptr │ 8字节
0x628C0FF340 ├─────────────────────┤
│ Base2::b2_data=200 │ 4字节
├─────────────────────┤
│ 填充字节 │ 4字节
0x628C0FF348 ├─────────────────────┤
│ Derived::d_data=300 │ 4字节
├─────────────────────┤
│ 填充字节 │ 4字节
0x628C0FF350 └─────────────────────┘

Derived d;
Base2* pb2 = &d; // pb2指向0x628C0FF338,而不是内存最头的0x628C0FF328

调用pb2->b2_func()时,主要的步骤如下:

  1. 通过pb2找到Base2的vptr
  2. 通过vptr找到虚函数表
  3. 调用正确的函数
  4. 函数内部的this指针就是pb2(指向Base2子对象)

虚函数表里到底有什么?

既然每个基类都有自己的虚函数表指针,那么这些虚函数表里到底存储了什么内容呢?

对于ElectricCar类,它有两个虚函数表:

  • Vehicle的虚函数表:存储Vehicle的虚函数地址
  • Electronic的虚函数表:存储Electronic的虚函数地址

但是,当ElectricCar重写了某些虚函数时,这些虚函数表里的内容会发生变化。让我写个测试来验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 测试虚函数表的内容
void testVTableContent() {
cout << "\n======= 测试虚函数表内容 =======\n";

ElectricCar electricCar;

// 获取Vehicle虚函数表
typedef void(*FuncPtr)();
void** vtable_vehicle = *(void***)&electricCar;

cout << "Vehicle虚函数表内容:" << endl;
cout << " [0] vehicle_start: " << vtable_vehicle[0] << endl;
cout << " [1] vehicle_stop: " << vtable_vehicle[1] << endl;
cout << " [2] ~Vehicle: " << vtable_vehicle[2] << endl;

// 获取Electronic虚函数表(需要偏移8字节)
void** vtable_electronic = *(void***)((char*)&electricCar + 8);

cout << "\nElectronic虚函数表内容:" << endl;
cout << " [0] electronic_powerOn: " << vtable_electronic[0] << endl;
cout << " [1] electronic_powerOff: " << vtable_electronic[1] << endl;
cout << " [2] ~Electronic: " << vtable_electronic[2] << endl;

// 调用验证
cout << "\n通过函数指针调用验证:" << endl;
((FuncPtr)vtable_vehicle[0])(); // 应该调用ElectricCar::vehicle_start
((FuncPtr)vtable_electronic[0])(); // 应该调用ElectricCar::electronic_powerOn
}

测试结果会显示,Vehicle虚函数表中的vehicle_start指向的是ElectricCar::vehicle_start,而不是Vehicle::vehicle_start。这说明派生类重写的虚函数会替换基类虚函数表中的对应项。

但是,ElectricCar新增的虚函数electricCar_charge()会放在哪里呢?答案是:它会被添加到第一个基类(Vehicle)的虚函数表中。这是因为派生类新增的虚函数需要能够通过派生类指针调用,而第一个基类指针通常指向对象的起始位置。

多继承时,派生类自己的虚函数放在哪里?

(引用自博客园某篇,忘复制地址了)

  • 为什么派生类的虚函数是追加在第一张虚表的后面? 请看下面的一段汇编(没学过汇编,不献丑)结论: 派生类的虚函数是追加在第一张虚表的后面。当需要使用派生类的虚函数是,用第一张表的虚函数表指针指向派生类的虚函数即可。(个人观点)下面的汇编也应该是这样:1,找到虚函数表的起始地址,2.找到派生类的虚函数偏移,3.使用虚函数表指针指向派生类的虚函数。
1
2
3
4
5
6
7
8
9
10
11
12
Copy Highlighter-hljs	deriveA *pda = &da;
00A7A02E lea eax,[ebp-28h]
00A7A031 mov dword ptr [ebp-34h],eax
pda->print();
00A7A034 mov eax,dword ptr [ebp-34h]
00A7A037 mov edx,dword ptr [eax]
00A7A039 mov esi,esp
00A7A03B mov ecx,dword ptr [ebp-34h]
00A7A03E mov eax,dword ptr [edx+4]
00A7A041 call eax
00A7A043 cmp esi,esp
00A7A045 call 00A714C4

构造函数和析构函数的调用顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 添加一些数据成员来观察构造顺序
class Vehicle {
public:
int vehicle_id;
Vehicle() : vehicle_id(1) {
cout << "Vehicle constructor" << endl;
}

};

class Electronic {
public:
int electronic_id;
Electronic() : electronic_id(2) {
cout << "Electronic constructor" << endl;
}

};

void testConstructionOrder() {
cout << "\n======= 测试构造和析构顺序 =======\n";
cout << "创建ElectricCar对象:" << endl;
ElectricCar ec;
cout << "\n销毁ElectricCar对象:" << endl;
}

测试结果:

1
2
3
4
5
6
7
8
9
10
======= 测试构造和析构顺序 =======
创建ElectricCar对象:
Vehicle constructor
Electronic constructor
ElectricCar constructor

销毁ElectricCar对象:
ElectricCar destructor
Electronic destructor
Vehicle destructor

可以看出:

  • 构造顺序:按照继承列表的顺序,从左到右依次构造基类,最后构造派生类
  • 析构顺序:与构造顺序完全相反,先析构派生类,然后从右到左析构基类

函数名冲突:二义性问题

多继承最容易遇到的问题就是函数名冲突。如果两个基类有同名的函数(即使参数不同),派生类在调用时就会产生二义性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Vehicle {
public:
void start() {
cout << "Vehicle::start()" << endl;
}
};

class Electronic {
public:
void start() { // 同名函数!
cout << "Electronic::start()" << endl;
}
};

class ElectricCar : public Vehicle, public Electronic {
public:
void test() {
// start(); // 错误!二义性,不知道调用哪个
Vehicle::start(); // 正确:明确指定基类
Electronic::start(); // 正确:明确指定基类
}
};

解决方法有几种:

  1. 使用作用域解析符Vehicle::start()Electronic::start()
  2. 在派生类中重写:在派生类中定义一个start()函数,内部调用需要的基类版本
  3. 使用using声明using Vehicle::start; 将某个基类的函数引入派生类作用域
1
2
3
4
5
class ElectricCar : public Vehicle, public Electronic {
public:
using Vehicle::start; // 引入Vehicle::start到当前作用域
// 现在可以直接调用start(),会调用Vehicle::start()
};

为什么要用多继承?为什么不用组合?

现在来解答我一开始的疑惑。

多继承的适用场景:

  1. 接口实现:当一个类需要实现多个接口时,多继承是自然的选择。比如Java的接口、C++的纯虚基类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 接口类
class IReadable {
public:
virtual void read() = 0;
};

class IWritable {
public:
virtual void write() = 0;
};

// 文件类实现两个接口
class File : public IReadable, public IWritable {
public:
void read() override { /* ... */ }
void write() override { /* ... */ }
};
  1. 混入(Mixin)模式:提供可复用的功能片段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 混入类:提供序列化功能
class Serializable {
public:
virtual string serialize() = 0;
};

// 混入类:提供日志功能
class Loggable {
public:
virtual void log() = 0;
};

// 业务类通过多继承获得这些功能
class User : public Serializable, public Loggable {
// ...
};
  1. “是一个”关系的多重性:当派生类确实是多个基类的特化时。

比如我们的ElectricCar,它确实”是一个”Vehicle,也”是一个”Electronic。这种情况下,多继承在语义上是合理的。

总结

  1. 多继承类有几个虚函数表? 答案:有几个基类(有虚函数的),就有几个虚函数表指针。
  2. 内存布局:每个基类子对象在派生类中都有独立的内存区域,包括自己的虚函数表指针。
  3. 指针转换:不同基类指针指向派生类对象时,地址可能不同,编译器会自动调整。
  4. this指针调整:通过不同基类指针调用虚函数时,this指针会自动调整到正确的子对象位置。
  5. 构造/析构顺序:按继承列表顺序构造,逆序析构。
  6. 二义性处理:同名函数冲突需要用作用域解析符或using声明解决。

但这里没有提到多继承下虚析构的一些细节,后面再说吧。