什么是继承?
什么是多重继承?
多重继承存在变量和函数名冲突怎么办?
子类对象和父类对象的内存模型是什么样的?
虚继承如何解决多重继承冲突问题?
本文将深入C++的底层实现,从内存结构、汇编语言的层面分析这些问题的答案。
一、单继承
继承是面向对象编程的核心,它可以使两个类具有所谓的“父子”关系,子类继承父类所有的可见成员。我们先考虑单继承的情况,即子类只有一个“父亲”。
// File: VirtualInherit.cpp
class Animal {
public:
int age;
virtual void speak() {}
};
class Cat : public Animal {
public:
virtual void speak() {}
};
int main()
{
Animal* animal = new Cat();
animal->speak();
}
这里,父类是Animal
,子类Cat
继承自Animal
,这在逻辑上是合理的。父类有一个虚函数speak
,但实现为空。子类重写该函数的实现,使得对speak
的调用呈现出多态的特性(多态是指Animal* animal = new Cat(); animal->speak();
这段代码实际调用的是Cat
类的speak
方法,父类指针调用虚函数的时候会自动找到对象的实际类型对应的实现)。
看到这里,我猜大家已经觉得无聊了,下面我们深入单继承的内存模型,看看所谓的虚函数表和虚函数表指针都在哪里。
我们使用Microsoft Visual C++中的cl编译器,它提供了一个-d1reportAllClassLayout
参数,可以在编译时打印出所有类的内存模型。在命令行中执行如下命令
cl -d1reportAllClassLayout VirtualInherit.cpp
打印出的结果就是Animal
类和Cat
类的内存结构和虚函数表的结构(其它无关的类结构就不贴出来了)。
class Animal size(8):
+---
0 | {vfptr}
4 | age
+---
Animal::$vftable@:
| &Animal_meta
| 0
0 | &Animal::speak
Animal::speak this adjustor: 0
class Cat size(8):
+---
| +--- (base class Animal)
0 | | {vfptr}
4 | | age
| +---
+---
Cat::$vftable@:
| &Cat_meta
| 0
0 | &Cat::speak
Cat::speak this adjustor: 0
可以看到,Animal
类占用8个字节,前4个字节是虚函数表指针(这里用的是32位编译器,所以指针占4个字节),后4个字节是数据成员。Animal
类的虚函数表中只有一个函数指针&Animal::speak
。
同样地,Cat
类也占用了8个字节,不过这8个字节都继承自父类Animal
。Cat
类的虚函数表也继承自Animal
,但由于重写了speak
函数,所以虚函数表中的函数指针从&Animal::speak
变成了&Cat::speak
。
细心的朋友会发现在虚函数表后面还打印出了一句话Animal::speak this adjustor: 0
,这个数表示的是Animal::speak
函数所在的虚函数表对应的虚函数表指针相对于this
的偏移量。可能不太容易理解,因为这个例子中每个类只有一个虚函数表,对应的虚函数表指针也都处于对象内存空间的起始位置,因此是0。后面我们介绍到多重继承的时候,类可能拥有多个虚函数表,adjustor就不一定是0了。
另外,请读者不要误认为adjustor是对象内存模型的一部分。事实上,对象中不包含adjustor,虚函数表中也不包含adjustor。它只是编译器编译过程的中间产物,体现在汇编代码就就是一个立即数而已。
在进一步介绍多重继承之前,我认为有必要从汇编的层面上彻底分析一下虚函数的调用过程。
二、从汇编码分析虚函数调用
如果你电脑上装有Visual Studio,建议你也按照下面的步骤把C++代码编译成汇编码试试。
执行命令
cl VirtualInherit.cpp /FAs /Od
其中,/FAs
参数用于生成.asm汇编码文件,/Od
用于禁止编译器优化。
由于编译出来的汇编码比较长,为了方便大家理解,我把它拆成几部分,并舍去不重要的内容。
1.函数声明和虚函数表
在汇编代码的开头,声明了许多将在后面定义的函数,类似于C++中“先声明后定义”。之后分别定义了Animal
类和Cat
类的虚函数表。
TITLE D:\c++Projects\VirtualInheritDemo\VirtualInheritDemo\VirtualInherit.cpp
.686P ; 686P指令集
.XMM ; XMM指令集
include listing.inc ; 包含文件
.model flat ; Flat存储模型
INCLUDELIB LIBCMT ; 包含库文件
INCLUDELIB OLDNAMES ; 包含库文件
PUBLIC ?speak@Animal@@UAEXXZ ; Animal::speak
PUBLIC ??0Animal@@QAE@XZ ; Animal::Animal
PUBLIC ?speak@Cat@@UAEXXZ ; Cat::speak
PUBLIC ??0Cat@@QAE@XZ ; Cat::Cat
PUBLIC _main
PUBLIC ??_7Animal@@6B@ ; Animal::`vftable'
PUBLIC ??_7Cat@@6B@ ; Cat::`vftable'
EXTRN ??2@YAPAXI@Z:PROC ; operator new
EXTRN ??_7type_info@@6B@:QWORD ; type_info::`vftable'
; COMDAT ??_7Cat@@6B@
CONST SEGMENT
DD FLAT:??_R4Cat@@6B@
??_7Cat@@6B@ ; Cat::`vftable'
DD FLAT:?speak@Cat@@UAEXXZ
CONST ENDS
; COMDAT ??_7Animal@@6B@
CONST SEGMENT
DD FLAT:??_R4Animal@@6B@
??_7Animal@@6B@ ; Animal::`vftable'
DD FLAT:?speak@Animal@@UAEXXZ
CONST ENDS
这里没有太多值得关注的。唯一让我们不太习惯的是,编译器把函数名后面加了很多奇怪的字符,这是编译器为了方便区分各种函数的类型和参数,对函数名做了调整,习惯就好。
此外,两个虚函数表都由CONST SEGMENT
包围,表示它们属于常量数据段。
2.Cat::Cat构造函数
在查看Cat
类的构造函数之前,我们要了解一些寄存器使用惯例,才能更轻松的理解程序。
- ebp为帧指针,指向栈底(栈底是高地址端,因为栈是向低地址方向扩展的)。
- esp为栈指针,指向栈顶(低地址端)。
- 在栈动态变化的过程中,通常ebp不变,esp变化,push操作使esp自动减4,pop操作使esp自动加4。
- ecx用来传递this指针。
- 函数返回结果保存在eax寄存器中。
如果你无法理解上面的叙述,推荐看我的另一篇文章《函数调用栈》。
下面我们来看Cat::Cat
构造函数的代码,这里涉及到虚函数表指针的设置,因此值得关注。
; Function compile flags: /Odtp
; COMDAT ??0Cat@@QAE@XZ
_TEXT SEGMENT
_this$ = -4 ; size = 4
??0Cat@@QAE@XZ PROC ; Cat::Cat, COMDAT
; _this$ = ecx
push ebp
mov ebp, esp
push ecx ; 保护现场
mov DWORD PTR _this$[ebp], ecx ; 把ecx寄存器的值复制到地址ebp+_this$处,即保存this指针的副本
mov ecx, DWORD PTR _this$[ebp] ; 把this指针赋值给ecx寄存器
call ??0Animal@@QAE@XZ ; 调用基类Animal的构造函数
mov eax, DWORD PTR _this$[ebp] ; 把this指针赋值给eax寄存器
mov DWORD PTR [eax], OFFSET ??_7Cat@@6B@ ; 把虚函数表的地址复制到地址eax处,即Cat对象的首地址处
mov eax, DWORD PTR _this$[ebp] ; 把this指针赋值给eax寄存器
mov esp, ebp
pop ebp
ret 0
??0Cat@@QAE@XZ ENDP ; Cat::Cat
_TEXT ENDS
这里的关键操作是把虚函数表的地址,即虚函数表指针放到了Cat
对象的首地址处,也就是this
指针指向的位置。后面我们将看到编译器这样做的良苦用心。
3.主函数
主函数部分的汇编代码如下。
; Function compile flags: /Odtp
; File d:\c++projects\virtualinheritdemo\virtualinheritdemo\virtualinherit.cpp
_TEXT SEGMENT
_animal$ = -12 ; size = 4,在栈中申请的空间大小,下同
tv75 = -8 ; size = 4
$T1 = -4 ; size = 4
_main PROC
; 23 : {
push ebp
mov ebp, esp
sub esp, 12 ; 0000000cH
; 24 : Animal* animal = new Cat;
push 8 ; 为operator new函数准备参数8
call ??2@YAPAXI@Z ; 调用operator new函数,参数为申请的空间大小
add esp, 4 ; 栈缩小4字节
mov DWORD PTR $T1[ebp], eax ; 将operator new函数的返回值复制到地址ebp+$T1处
; operator new的返回值是申请的内存空间的首地址
cmp DWORD PTR $T1[ebp], 0 ; 判断值是否为0
je SHORT $LN3@main ; 为0则跳转到$LN3处,说明申请空间失败
mov ecx, DWORD PTR $T1[ebp] ; 否则把该值赋值给ecx寄存器,按照惯例,ecx用于传递this指针
call ??0Cat@@QAE@XZ ; 调用Cat::Cat构造函数,参数为刚才申请的空间的首地址
; 也就是this指针
mov DWORD PTR tv75[ebp], eax ; 将构造函数返回值复制到地址ebp+tv75处
; 注意,C++语义中构造函数没有返回值,但在汇编层面上有返回值,返回值是this指针
jmp SHORT $LN4@main ; 跳转到$LN4处
$LN3@main:
mov DWORD PTR tv75[ebp], 0 ; 地址ebp+tv75处赋值0
$LN4@main:
mov eax, DWORD PTR tv75[ebp] ; 把地址ebp+tv75处的值赋值给eax寄存器
mov DWORD PTR _animal$[ebp], eax ; 把eax寄存器的值复制到地址ebp+_animal$处(仍然是this指针)
; 25 : animal->speak();
mov ecx, DWORD PTR _animal$[ebp] ; 把this指针赋值给ecx寄存器
mov edx, DWORD PTR [ecx] ; 把this指针指向的值赋值给edx寄存器,即虚函数表指针
mov ecx, DWORD PTR _animal$[ebp] ; 把this指针赋值给ecx寄存器
mov eax, DWORD PTR [edx] ; 把虚函数表指针指向的值赋值给eax寄存器,即虚函数的地址
; 这里由于只有一个虚函数,因此是[edx],若调用第二个虚函数,则为[edx+4]
call eax ; 调用虚函数
; 26 : }
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
这段代码比较长,但其实我们只需要关注最后调用虚函数Cat::speak
的部分。可以发现,并没有直接通过Cat::speak
的常量地址?speak@Cat@@UAEXXZ
来调用,而是从this
指针找到虚函数表,再找到虚函数表的第一项,把该项的值作为函数指针来调用。这就是虚函数调用的秘密!
三、多重继承中的变量冲突
现在,我们增加两个类Dog
和CatDog
,来构成一个钻石型的继承结构。Cat
和Dog
都继承自Animal
,CatDog
多重继承Cat
和Dog
(你可以把它当做猫和狗的杂交...)。
class Animal {
public:
int age;
virtual void speak() {}
};
class Cat : public Animal {
public:
virtual void speak() {}
virtual void scratch() {}
};
class Dog : public Animal {
public:
virtual void speak() {}
virtual void bite() {}
};
class CatDog : public Cat, Dog {
};
在这种继承结构中,下面的调用会出错。
CatDog catDog;
int age = catDog.age; // 错误:"CatDog::age"不明确
catDog.speak(); // 错误:"CatDog::speak"不明确
因为CatDog
类会把所有父类的成员各保存一份,两个父类又包含了相同的Animal
类的成员,此时Animal
类的成员变量age
和成员函数speak
在CatDog
中都保存了两份。我们把此时的内存模型打印出来看一下。
class Animal size(8):
+---
0 | {vfptr}
4 | age
+---
Animal::$vftable@:
| &Animal_meta
| 0
0 | &Animal::speak
Animal::speak this adjustor: 0
class Cat size(8):
+---
| +--- (base class Animal)
0 | | {vfptr}
4 | | age
| +---
+---
Cat::$vftable@:
| &Cat_meta
| 0
0 | &Cat::speak
1 | &Cat::scratch
Cat::speak this adjustor: 0
Cat::scratch this adjustor: 0
class Dog size(8):
+---
| +--- (base class Animal)
0 | | {vfptr}
4 | | age
| +---
+---
Dog::$vftable@:
| &Dog_meta
| 0
0 | &Dog::speak
1 | &Dog::bite
Dog::speak this adjustor: 0
Dog::bite this adjustor: 0
class CatDog size(16):
+---
| +--- (base class Cat)
| | +--- (base class Animal)
0 | | | {vfptr}
4 | | | age
| | +---
| +---
| +--- (base class Dog)
| | +--- (base class Animal)
8 | | | {vfptr}
12 | | | age
| | +---
| +---
+---
CatDog::$vftable@Cat@:
| &CatDog_meta
| 0
0 | &Cat::speak
1 | &Cat::scratch
CatDog::$vftable@Dog@:
| -8
0 | &Dog::speak
1 | &Dog::bite
重点查看CatDog
类的内存结构,可以发现,首先存放的是base class Cat
的内容,里面嵌套了Animal
的虚函数表和成员变量,之后是base class Dog
的内容,里面也嵌套了同样的Animal
的虚函数表和成员变量。所以我们直接调用age
或者speak
的时候,编译器无法确定我们到底想调用哪个成员变量或是哪个虚函数表中的speak
。
解决方案当然是传说中的虚继承。
四、虚继承
接着上面的例子,只需要将Cat
和Dog
继承自Animal
的方式改为虚继承即可,其它部分不变。写法如下。
class Cat : virtual public Animal {
public:
virtual void speak() {}
virtual void scratch() {}
};
class Dog : virtual public Animal {
public:
virtual void speak() {}
virtual void bite() {}
};
虽然只加了两个virtual
关键字,但内存结构却发生了翻天覆地的变化,让我们再查看一下内存模型。
class Animal size(8):
+---
0 | {vfptr}
4 | age
+---
Animal::$vftable@:
| &Animal_meta
| 0
0 | &Animal::speak
Animal::speak this adjustor: 0
class Cat size(16):
+---
0 | {vfptr}
4 | {vbptr}
+---
+--- (virtual base Animal)
8 | {vfptr}
12 | age
+---
Cat::$vftable@Cat@:
| &Cat_meta
| 0
0 | &Cat::scratch
Cat::$vbtable@:
0 | -4
1 | 4 (Catd(Cat+4)Animal)
Cat::$vftable@Animal@:
| -8
0 | &Cat::speak
Cat::speak this adjustor: 8
Cat::scratch this adjustor: 0
vbi: class offset o.vbptr o.vbte fVtorDisp
Animal 8 4 4 0
class Dog size(16):
+---
0 | {vfptr}
4 | {vbptr}
+---
+--- (virtual base Animal)
8 | {vfptr}
12 | age
+---
Dog::$vftable@Dog@:
| &Dog_meta
| 0
0 | &Dog::bite
Dog::$vbtable@:
0 | -4
1 | 4 (Dogd(Dog+4)Animal)
Dog::$vftable@Animal@:
| -8
0 | &Dog::speak
Dog::speak this adjustor: 8
Dog::bite this adjustor: 0
vbi: class offset o.vbptr o.vbte fVtorDisp
Animal 8 4 4 0
class CatDog size(24):
+---
| +--- (base class Cat)
0 | | {vfptr}
4 | | {vbptr}
| +---
| +--- (base class Dog)
8 | | {vfptr}
12 | | {vbptr}
| +---
+---
+--- (virtual base Animal)
16 | {vfptr}
20 | age
+---
CatDog::$vftable@Cat@:
| &CatDog_meta
| 0
0 | &Cat::scratch
CatDog::$vftable@Dog@:
| -8
0 | &Dog::bite
CatDog::$vbtable@Cat@:
0 | -4
1 | 12 (CatDogd(Cat+4)Animal)
CatDog::$vbtable@Dog@:
0 | -4
1 | 4 (CatDogd(Dog+4)Animal)
CatDog::$vftable@Animal@:
| -16
0 | &CatDog::speak
CatDog::speak this adjustor: 16
vbi: class offset o.vbptr o.vbte fVtorDisp
Animal 16 4 4 0
有没有发现,虚继承的类及其子类在虚函数表指针后面又多了一个vbptr
,叫做虚基类表指针。该指针指向的虚基类表中包含了一个偏移量,正是虚基类表指针到虚基类成员的偏移量。
我们以CatDog
为例分析。它的内存模型为:
class CatDog size(24):
+---
| +--- (base class Cat)
0 | | {vfptr}
4 | | {vbptr}
| +---
| +--- (base class Dog)
8 | | {vfptr}
12 | | {vbptr}
| +---
+---
+--- (virtual base Animal)
16 | {vfptr}
20 | age
+---
base class Cat
中的虚基类表指针指向的虚基类表为
CatDog::$vbtable@Cat@:
0 | -4
1 | 12 (CatDogd(Cat+4)Animal)
里面有两项,第一项是该虚基类表指针到虚函数表指针的偏移量,通常为-4,因为vfptr
和vbptr
一般是紧挨着放置的。第二项是该虚基类表指针到虚基类成员的偏移量,我们对照上面的完整内存模型,可以找到虚基类Animal
的成员首地址是16,而该虚基类表指针的地址是4,相差正好12。对base class Dog
可以做同样的分析,这里就不赘述了。
现在,使用了虚继承之后,Animal
类在CatDog
中只有一份,对age
和speak
的调用不会再产生歧义。
不过,事情可能会比想象中复杂一些,如果你按照我的写法亲自尝试一下,可能会发现编译器提示CatDog
类无法编译,提示错误:虚拟函数 函数"Animal::speak"的重写不明确
。这是因为Cat
类和Dog
类都重写了speak
函数,然而CatDog
类中只能有一份speak
函数,编译器不知道该保留哪个,所以要求CatDog
类也重写speak
函数。如果Cat
和Dog
中没有重写speak
或只有一个重写了speak
,就不会提示错误。其实,只要我们站在编译器设计者的角度思考,就能理清这些纷繁复杂的规则。
另外,虚继承使用的时候还需要考虑虚基类的初始化。本文就不展开讲了,可以参考百度百科中的解释。
本文对虚继承的讨论只算是浅尝辄止,我们并没有深入到汇编层面查看指令到底是如何运行的。如果你感兴趣,完全可以像本文第二部分一样,把代码编译成汇编码,一行一行查看在虚继承体系中虚函数的调用过程,相信一定会很有收获。
本文在分析汇编码时遇到了一些困难,在此感谢StackOverFlow中热心网友的解答。如果你自行尝试的过程中遇到了问题,请查看文末参考资料中的链接,或在评论区提问,我会尽快答复。
参考资料
虚函数与虚继承寻踪 范志东
用汇编分析C++程序 牧秦丶
虚继承 百度百科
Reversing Microsoft Visual C++ Part II: Classes, Methods and RTTI igorsk
函数调用栈 金戈大王
汇编伪指令 strikeshine
汇编写函数:关于PUBLIC和EXTRN的区别 襄坤在线
Is vftable[0] stores the first virtual function or RTTI Complete Object Locator? StackOverFlow