C++之虚函数表和vptr指针

一、序章

如果说指针是C语言的精髓,那C++的精髓就是多态,而多态实现的基础是动态联编(晚绑定),动态联编实现的基础是虚函数.
在C++中是这样是这样规定虚函数:

	1.函数前面加上关键字virtua 就形成了虚函数
	2,当指针或者引用在调用虚函数的时候,编辑器会根据其指向的实际对象,调用相应的虚函数,也就是说 派生类重写基类的虚函数的时候,当基类指针指向派生类对象并在调用虚函数的时候,这时基类指针会调用派生类重写的虚函数,

其实C++仅仅是规定了虚函数的规则,但是其具体的实现却是由编辑器来实现的,而编辑器实现虚函数的原理就是虚函数表和vptr指针.
虚函数表的原理是:

1,当类中出现了虚函数的时,每当类生成一个对象时,编辑器会自动在该对象首部添加一个vptr指针,
指向 一个独立的指针数组,该数组中存储着虚函数地址,当指针(引用)调用虚函数的时候,
会通过这个vptr指针找到虚函数表中对应的虚函数地址,并调用.
2,当派生类继承基类,会继承他的vptr指针和虚拟表,当派生类重写基类的虚函数的时候,
编辑器会更改虚函数表中相应的虚函数地址,将其改为派生类重写后的虚函数地址.

先在我们大概知道虚函数表的原理,那么我们需要详细的剖析和重现期原理,我们首先证明vptr的存在并且虚函数表中存储的是虚函数地址

二、 证明vptr指针的存在&虚函数表中存储着虚函数地址

先看下没有虚函数,没有vptr指针的一个代码示例,这样有助于我们后面对虚函数的比较

	#include"iostream";
	using namespace std;
	class Parent {
	public:
		void func1() {
			cout<<"Parent----func1--"<

上面的代码 我们用命令行查看 类Parent的对象在内存中分部情况

1>class Parent	size(8): //说明在内存中总共占据8个字节
1>	+---
1> 0	| a //a的地址偏移量是0,说明a是首地址和对象的地址相同
1> 4	| b //b的地址偏移量是4 
1>	+---

上面我们看出在内存中Parent的对象一共占据8个字节 这是正确的 我们知道 类对象的大小是所有成员变量大小的总和,因为成员函数是属于类的所有对象共享成员函数,所以成员函数是不在类对象所占内存中的,
对于类Parent的对象 其大小是成员变量大小的总和就是8个字节

上面的对象内存分布 我们可以可以看到变量a的地址偏移量是0,说明:对象p的地址 和对象p中成员变量a的地址是相同的 下图是是在vs监控中对象p的地址和其成员的地址,其中p的地址正好和a的地址相同,此外我们还可以看出对于类对象的内存块中,成员变量的地址顺序按照声明的顺序排列的
C++之虚函数表和vptr指针_第1张图片

接下来我们将函数func1 变成虚函数 观察对象p的内存变化 即在成员函数 func1前面加上virtual,其他不变

	virtual void func1() {
			cout<<"Parent----func1--"<

通过命令行获取其对象内存分布:

	1>class Parent	size(12):
	1>	+---
	1> 0	| {vfptr}//多出了变量vptr,其地址偏移量是0 说明其地址和对象的地址相同
	1> 4	| a      //偏移量是4 说明a不是首地址了 而且vptr的大小是4个字节
	1> 8	| b
	1>	+---

此处说明,当类中出现了虚函数时 编辑器会自动为每个类对象生成一个vptr的变量 其大小是4个字节, 我们通过断点查看其类型是什么,如下图所示
C++之虚函数表和vptr指针_第2张图片

注意:上面我们可以看到vptr的类型是 void ** 说明vptr是 指针的指针(二级指针) 类型 而我们注意vptr下面是个数组,共有一个元素,元素类型是 void *.
注意上面监控的地址,这是根据上图整理来的对象p的内存

变量 内存地址 偏移量
&p 0x005cf7ec 0
&p.vptr 0x005cf7ec 0
&p.a 0x005cf7f0 4
&p.b 0x005cf7f4 8

从上图和上标我们可以得出结论:

  1. 当类出现虚函数表的时候,编辑器会自动为对象生成一个vptr指针,其指向一个指针数组
  2. vptr指针地址和对象的首地址相同,而且其大小是4个字节

根据上面的结论我们可以得到,虚函数表存储的每一个指针,接下来我们来验证虚函数表存储的的确就是虚函数地址,.如果我们用虚函数表存储的指针调用了虚函数,则证明虚函数表中的确存储着虚函数地址,
还是用上面的例子不过我们需要修改主函数里的内容 整体代码如下:

#include"iostream";
using namespace std;
class Parent {
public:
	virtual void func1() {
		cout<<"Parent----func1--"<

打印结果是:
Parent----func1–

说明正确的调用了 func1函数,证明了虚函数表存储的的确是虚函数地址.

我们从上面图中得到结果是:vptr 存储的值是 void ** 类型 而其指向的指针数组存储的值是 void* 类型 所以下面解析下 从对象地址得到虚函数指针的过程

  1. 第一步,因为对象p地址和vptr指针的值都是对象p的首地址,所以这里得到vptr的首地址
  2. 第二步,因为vptr是 void ** 类型所以将其转换为 vptr 指针类型 再解引用,取到vptr存储的值
  3. 我们知道vptr存储的是虚函数表,指针数组的地址,而数组地址和数组首元素的地址是相同,所以这里我们将vptr存储的值转换为 void * 类型再解引用就可以得到虚函数表中的第一个虚函数的地址,但是因为VS中不允许将指针转换为void *类型,所以这里我们将vptr的值转换为int * 类型(注:所以指针大小在环境下都是相同的,我的环境是4个字节),所以有 *(int *)pp1,这时已经得到虚函数表存储的第一个元素的值,即是:虚函数的首地址,这里我们将其转换为虚函数相应的指针类型,这里我们便得到虚函数指针.3.我们知道vptr存储的是虚函数表,指针数组的地址,而数组地址和数组首元素的地址是相同,所以这里我们将vptr存储的值转换为 void * 类型再解引用就可以得到虚函数表中的第一个虚函数的地址,但是因为VS中不允许将指针转换为void *类型,所以这里我们将vptr的值转换为int * 类型(注:所以指针大小在环境下都是相同的,我的环境是4个字节),所以有 *(int *)pp1,这时已经得到虚函数表存储的第一个元素的值,即是:虚函数的首地址,这里我们将其转换为虚函数相应的指针类型,这里我们便得到虚函数指针.

三、 同一类的不用对象的虚函数表&基类和派生类的虚函数表

  • 该小节我们问题是,同一类的不同对象的虚函数表和vptr是否相同
  • 基类和派生类的虚函数表和vptr是否相同
  • vptr初始化的时间,或者说是生成的时间

(1)同一类的不同对象的虚函数表是否相同

我们生成多个对象观察其vptr的值是否相同 相同说明指向同一个虚函数表

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	virtual void func1() {
		cout<<"Parent----func1--"<

我们打断点观察4个对象的vptr指针,结果如下:
C++之虚函数表和vptr指针_第3张图片

上图是生成4的不同对象的vptr其指向的对象的值是相同的说明其指向同一个虚函数表,这也说明了
同一类的所有对象共享同一个虚函数表,

(2)vptr指针初始化的时间(没有基类)

注意我们这里分析的是没有继承的时候vptr初始化的时间,当有继承的时候,派生类vptr初始话要复杂的多,这里先不做分析
我们都知道当类生成对象的时候,首先是从构造函数开始的,所以这里我们先对构造函数进行反编译看看是否有vptr的声明如下
这是parent的无参构造函数

	Parent() {//构造函数开始
01031F40  push        ebp  
01031F41  mov         ebp,esp  
01031F43  sub         esp,0CCh  
01031F49  push        ebx  
01031F4A  push        esi  
01031F4B  push        edi  
01031F4C  push        ecx  
01031F4D  lea         edi,[ebp-0CCh]  
01031F53  mov         ecx,33h  
01031F58  mov         eax,0CCCCCCCCh  
01031F5D  rep stos    dword ptr es:[edi]  
01031F5F  pop         ecx  
01031F60  mov         dword ptr [this],ecx  
01031F63  mov         ecx,offset _5926205C_虚函数\虚函数\test.cpp (0103F027h)  
01031F68  call        @__CheckForDebuggerJustMyCode@4 (01031299h)  
01031F6D  mov         eax,dword ptr [this]  //传入this指针
01031F70  mov         dword ptr [eax],offset Parent::`vftable' (01039B34h)  //这里定义vptr(对象首地址)指向vftable
		a = 0;//定义a
01031F76  mov         eax,dword ptr [this]  
01031F79  mov         dword ptr [eax+4],0  
		b = 0;//定义b
01031F80  mov         eax,dword ptr [this]  
01031F83  mov         dword ptr [eax+8],0  
	}

01031F6D mov eax,dword ptr [this] //传入this指针
01031F70 mov dword ptr [eax],offset Parent::`vftable’ (01039B34h) //这里定义vptr(对象首地址)指向vftable

注意上面两句汇编语言,首先传入this指针,然后让首地址指向虚函数表Parent::`vftable’
然后才定义变量a和变量b

在有参构造函数Parent(int a,int b) {},反编译的结果类似

结论:在类构造函数中编辑器会首先自动添加vptr指针并指向虚函数表的地址,然后再编辑用户的代码,换句话说在类的构造函数中会首先执行vptr的初始化然后再执行其他的

(3)基类和派生类的虚函数表是否相同

为了查看基类和派生类的虚函数表是否相同,我们需要新定义一个派生类用来继承基类,代码如下

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	virtual void func1() {
		cout<<"Parent----func1--"<

这里我们打断点观察 p1和c1的vptr指针的值是否相同 相同说明派生类和基类指向同一个虚函数表,否则基类和派生类的虚函数表不相同.断点结果如下:
C++之虚函数表和vptr指针_第4张图片
上图中
c1 vptr指向的值是:0x010f9b58
p1 vptr指向的值是:0x010f9b34
c1 和p1的vptr指向不同 说明两个类的虚函数表示不同的

总结,不同类的虚函数表都是独立的,不同,(这里说的是整个虚函数表对象而不是其内容)

前面我们分析了,vptr的产生以及初始化,验证了虚函数表的虚函数指针,以及比较同一类的不同虚函数表,以及不同类的虚函数表, 接下来我们来分析

  1. 虚函数表的核心–虚函数地址的替换,
  2. vptr的分部初始化
  3. 各种不同情况下,派生类虚函数表的生成以及和基类虚函数表的关系

四、 虚函数表的核心–虚函数地址的替换

原理:虚函数表实现基类指针指向派生类对象的时,调用派生类重载函数时,会调用派生类的函数而不是基类的函数的原因是就是:派生类的虚函数首先会拷贝基类的虚函数内容,然后让派生类重写了基类的虚函数时,派生类的虚函数表就会将重写的函数地址改为派生类重载的函数地址,而不再是父类的虚函数地址了,

接下来我们验证下结果是否如此,我们首先定义一个基类,并且定义2个虚函数,然后在定义一个派生类来继承该类 并且重写其中的一个虚函数,再观察虚函数表

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	virtual void func1() {
		cout<<"Parent----func1--"<

打断点对比基类和派生类的虚函数表
C++之虚函数表和vptr指针_第5张图片
上图显示:

  1. c1和p1的vptr指向的值不相同,说明不用类的虚函数表不同
  2. c1和p1的虚函数表的内存都是含有2个虚函数指针(因为派生类没有新增虚函数)
  3. c1和p1的虚函数表的第二个元素相同,指向的都是 Parent::func2(void);
  4. c1虚函数表的第一个元素指向的是:Child::func1(void);而p1虚函数的第一个元素指向的是Parent::func1(void);

所以得出结论:
派生类声明后编辑器在编译期间会新创建一个独立的虚函数表,然后复制基类虚函数表的内容,
然后查看派生类是否重写了基类的虚函数,如果重写了,则将虚函数表中的该虚函数地址改为派生类重写的函数地址,如果没重写则不操作.

五、vptr的分步初始化

我们在上面反汇编知道了,在类的构造函数中编辑器会添加初始化vptr的操作,
在继承中派生类会继承基类的一切,构造方法也会继承下来,派生类在调用构造函数的时候,必须先调用基类的构造函数,执行结束后才会执行派生类的构造函数,而我们知道基类的构造函数中肯定是有vptr初始化的,而且指向的是基类的虚函数表,而我们在上面的结论中又知道基类和派生类的vptr指向是不同的所以派生类的构造函数中肯定有什么执行,
所以我们接下来反汇编基类和派生类的构造函数,

代码和"虚函数表的核心"中代码一样 我们接下来反汇编 Parent和Child的无参构造函数

Parent的无参构造函数的反汇编

	Parent() {
010D1F40  push        ebp  
010D1F41  mov         ebp,esp  
010D1F43  sub         esp,0CCh  
010D1F49  push        ebx  
010D1F4A  push        esi  
010D1F4B  push        edi  
010D1F4C  push        ecx  
010D1F4D  lea         edi,[ebp-0CCh]  
010D1F53  mov         ecx,33h  
010D1F58  mov         eax,0CCCCCCCCh  
010D1F5D  rep stos    dword ptr es:[edi]  
010D1F5F  pop         ecx  
010D1F60  mov         dword ptr [this],ecx  
010D1F63  mov         ecx,offset _5926205C_虚函数\虚函数\test.cpp (010DF027h)  
010D1F68  call        @__CheckForDebuggerJustMyCode@4 (010D1299h)  
010D1F6D  mov         eax,dword ptr [this]  
010D1F70  mov         dword ptr [eax],offset Parent::`vftable' (010D9B34h)  //(1) 初始化首地址指向类Parent的虚函数表
		a = 0;
010D1F76  mov         eax,dword ptr [this]  
010D1F79  mov         dword ptr [eax+4],0   //(2)初始化 a
		b = 0;
010D1F80  mov         eax,dword ptr [this]  
010D1F83  mov         dword ptr [eax+8],0  //(3) 初始化b
	}

Child的无参构造函数的反汇编

	Child() {
010D1D10  push        ebp  
010D1D11  mov         ebp,esp  
010D1D13  sub         esp,0CCh  
010D1D19  push        ebx  
010D1D1A  push        esi  
010D1D1B  push        edi  
010D1D1C  push        ecx  
010D1D1D  lea         edi,[ebp-0CCh]  
010D1D23  mov         ecx,33h  
010D1D28  mov         eax,0CCCCCCCCh  
010D1D2D  rep stos    dword ptr es:[edi]  
010D1D2F  pop         ecx  
010D1D30  mov         dword ptr [this],ecx  
010D1D33  mov         ecx,offset _5926205C_虚函数\虚函数\test.cpp (010DF027h)  
010D1D38  call        @__CheckForDebuggerJustMyCode@4 (010D1299h)  
010D1D3D  mov         ecx,dword ptr [this]  
010D1D40  call        Parent::Parent (010D10AAh)  
010D1D45  mov         eax,dword ptr [this]  
010D1D48  mov         dword ptr [eax],offset Child::`vftable' (010D9B54h)  //(4) 再次初始化vptr将对象首地址指向 新建的派生类的虚函数表Child::`vftable'
		c = 0;
010D1D4E  mov         eax,dword ptr [this]  
010D1D51  mov         dword ptr [eax+0Ch],0  //(5) 初始化变量C
	}

创建派生类对象,将会依次执行parent和child的构造函数,
我们注意首先执行(1) 初始化vptr(对象首地址),将其指向基类Parent的虚函数表Parent::`vftable’
再执行(2)初始化变量a
再执行(3)初始化变量b
--------------基类构造函数---------
再执行(4)造次初始化vptr(对象首地址),将其指向派生类的虚函数表Child::‘vftable’
再执行(5)初始化变量c 注意初始化变量C的时候 ptr [eax+0Ch] 也就是说c的地址是对象首地址加上 0Ch而这个0Ch就是基类对象的大小

利用命名行打印
基类Parent的内存分布
1>class Parent size(12):
1> ±–
1> 0 | {vfptr}
1> 4 | a
1> 8 | b
1> ±–
1>
派生类 Child的内存分布
1>class Child size(16):
1> ±–
1> 0 | ±-- (base class Parent)
1> 0 | | {vfptr}
1> 4 | | a
1> 8 | | b
1> | ±–
1>12 | c //正好印证 dword ptr [eax+0Ch],0 而och是12 eax是对象首地址0
1> ±–

结论:对于单继承而言,vptr的初始化是分步进行的,首先执行基类构造函数,在执行基类构造函数中最先执行vptr初始化指向基类的虚函数表.当基类的构造函数的执行完毕后 再执行派生类的构造函数 在派生类的构造函数中 最先执行vptr的二次初始化,指向派生类虚函数表.
引申:
1. 每执行一次构造函数就会初始化一次vptr执行 而其指向的是构造函数所属对象的虚函数表
2. 所以在基类的构造函数中调用虚函数,执行的也是基类的虚函数,而不是派生类重载后的虚函数(针对所有具有继承关系的类都适用

六、各种不同情况下,派生类虚函数表的生成以及和基类虚函数表的关系

注:此处内容较多可跳过,直接看结论
我们主要观察基类和派生类的

(1)单继承 --基类有虚函数 派生类没有新增的虚函数并且不重写基类虚函数

代码:

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	virtual void func1() {
		cout<<"Parent----func1--"<

打断点观察基类和派生类vptr和虚函数表
C++之虚函数表和vptr指针_第6张图片
这里我们看到

  • c1和p1的vptr分别指向两个不同的地址,说明基类和派生类的虚函数表不同(前面已经验证过了)
  • c1和p1的虚函数表完全相同,都是包含两个地址,第一个指向Parent::func1(void) 第二个指向 Parent::func2(void),说明当派生类没有新增虚函数并且没有重写基类的虚函数的时候,派生类创建一个新的虚函数表,并且拷贝基类虚函数表的内容不做增改

通过命令行打印派生类 Child的内存布局(和我们打断点得到的图印证)

1>class Child	size(16)://
1>	+---
1> 0	| +--- (base class Parent)
1> 0	| | {vfptr}
1> 4	| | a
1> 8	| | b
1>	| +---
1>12	| c
1>	+---
1>

(2)单继承 --基类有虚函数 派生类没有新增的虚函数 ,但是重写基类虚函数

代码:

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	virtual void func1() {
		cout<<"Parent----func1--"<

打断点观察基类和派生类vptr和虚函数表
C++之虚函数表和vptr指针_第7张图片

这里我们注意相对于(1)中的内容:此时c1的虚函数表原本指向基类func1虚函数地址 ,此时改为指向派生类的重写后的Child::func1(void)虚函数的地址;

结论:当派生类重写基类的虚函数的时候,编辑器会将派生类的虚函数表中原本指向基类的虚函数地址改为指向派生类重写后的虚函数地址

(3)单继承 --基类有虚函数 派生类有新增虚函数

代码:

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	virtual void parent_func1() {
		cout<<"Parent----parent_func1--"<

打断点观察基类和派生类的虚函数表:
C++之虚函数表和vptr指针_第8张图片

c1的虚函数表中这时居然只有2个值,并没有值指向类Child新增的虚函数,居然和(2)中的图一样,why 难道新增的虚函数没有加上,
我们知道虚函数表是指针数组.现在已知这个数组中有两个值,那我们看看这个第三个值是否是否,不为空,那是否是新增的虚函数的指针,
修改main函数代码

typedef void(*Fn)(void);
int main() {
	Parent p1;
	Child  c1;
	Fn fn = (Fn)*((int *)*(void **)&c1+2);//上面内容已经说过了如何取得虚函数表中的虚函数地址 
	cout << "fn====" << fn << endl;
	fn();

	system("pause");
	return 0;
}

打印结果是:
fn====002D1451
Child----child_func1–
说明虚函数表中的第三个元素不是空,而且就是Child类新增的虚函数地址,而且我们也可以通过反汇编进一步验证
代码

Child  c1;
	Child & c2 = c1;
	c2.child_func1();

反汇编c2.child_func1();

	c2.child_func1();
01365ED8  mov         eax,dword ptr [c2]  //取指针c2的值(c1的指针)赋值给eax
01365EDB  mov         edx,dword ptr [eax]  //将c1指针指向的值(对象c1的首地址)赋值给edx
01365EDD  mov         esi,esp  
01365EDF  mov         ecx,dword ptr [c2]  //取指针c2的值(c1的指针)赋值给ecx
01365EE2  mov         eax,dword ptr [edx+8]  //将将指针(首地址+8)指向的值赋值给eax
01365EE5  call        eax      //执行eax
01365EE7  cmp         esi,esp  
01365EE9  call        __RTC_CheckEsp (013612A3h)

我们知道对象的首地址也是vptr的值,所以讲vptr+8也就是执行虚函数表的第三个元素,就是说,派生类新增的虚函数地址是加载虚函数表的后面,
关于为什么虚函数表没有显示,个人理解是因为派生类的虚函数表的建立首先要看基类是否有虚函数表,如果有就进行拷贝,而这时因为拷贝是基类的虚函数表,基类并不知道派生类是否有新增的虚函数,所以有索引的只有基类的虚函数,然后再看派生类是否有新增的虚函数,如果有的话则将其地址依次加载虚函数表的后面

如果基类没有虚函数,我们就会发现派生类的虚函数表所有的虚函数都有索引,嗯 是不是有点感觉了 下面给出代码验证下

class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	 void parent_func1() {
		cout<<"Parent----parent_func1--"<

C++之虚函数表和vptr指针_第9张图片

vptr显示的的确如我们所说

(4)多继承

我现在看下多继承,并且基类都有虚函数,派生类有新增的虚函数
代码

#include"iostream";
using namespace std;
class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	Parent(int a,int b) {
		this->a = a;
		this->b = b;
	}
	 virtual void parent_func1() {
		cout<<"Parent----parent_func1--"<

我们通过VS看下对象c1的变量:
C++之虚函数表和vptr指针_第10张图片
追高亮的地方 ,对象c1包含两个继承类对象,每个继承类对象都有一个vptr指针,而且其指向的值还不相同,说明指向2个不同的虚函数表, 嗯? 我们再通过命令行观看下类Child的内存分布

1>class Child	size(24):
1>	+---
1> 0	| +--- (base class Parent) //偏移量0 包含的基类对象 Parent的开始
1> 0	| | {vfptr}  //偏移量0 包含的基类Parent对象的vptr 指向由基类Parent拷贝而来的派生类的虚函数表
1> 4	| | a
1> 8	| | b
1>	| +---
1>12	| +--- (base class Parent1) //偏移量12(正好是类Parent的大小)  包含的Parent1对象的开始
1>12	| | {vfptr} //偏移量12 包含的基类Parent1对象的vptr 指向由基类Parent1拷贝而来的派生类的虚函数表
1>16	| | p1_a
1>	| +---
1>20	| c
1>	+---
1>
1>Child::$vftable@Parent@://派生类拷贝Parent的虚函数表
1>	| &Child_meta//共有5个成员
1>	|  0  
1> 0	| &Parent::parent_func1 //第一个元素 指向\Parent类的parent_func1 
1> 1	| &Parent::parent_func2 /第二个元素 指向\Parent类的parent_func2 
1> 2	| &Child::child_func1  /第三个元素 指向派生类新增的child_func1  
1> 3	| &Child::child_func2  /第四个元素 指向派生类新增的child_func2
1>                                        //空成员 标志着虚函数表的结束
1>Child::$vftable@Parent1@: //派生类拷贝Parent1的虚函数表
1>	| -12    
1> 0	| &Parent1::parent1_func1 //第一个元素 指向\Parent1类的parent1_func1 
1> 1	| &Parent1::parent1_func2 //第二个元素 指向\Parent1类的parent1_func2 
1>                                            //空成员 标志着虚函数表的结束
1>Child::child_func1 this adjustor: 0
1>Child::child_func2 this adjustor: 0

通过上面我们 得到了什么?

  1. 多继承,继承来每个基类对象都包含一个vptr指针,分别指向一个由基类虚函数表拷贝而来的独立虚函数表,
  2. 派生类新增的虚函数地址会添加在第一个vptr指针指向的虚函数表(这个可能还有疑问我们等下验证)

我们验证下派生类新增的虚函数地址是添加在哪一个虚函数表的后面 ,嗯 我们将Child的继承方式修改下

class Child:public Parent1,public Parent 

通过命令行打印Child类的内存分布

1>class Child	size(24):
1>	+---
1> 0	| +--- (base class Parent1)//首地址 变成了 嵌套的Parent1对象的首地址 
1> 0	| | {vfptr}   //嵌套的Parent1对象的vptr指针 我们看下面Parent1的虚函数表是否包含 派生类新增的虚函数地址
1> 4	| | p1_a
1>	| +---
1> 8	| +--- (base class Parent)
1> 8	| | {vfptr}
1>12	| | a
1>16	| | b
1>	| +---
1>20	| c
1>	+---
1>
1>Child::$vftable@Parent1@://嵌套的Parent1对象指向的虚函数表
1>	| &Child_meta
1>	|  0
1> 0	| &Parent1::parent1_func1
1> 1	| &Parent1::parent1_func2
1> 2	| &Child::child_func1   //果然这里包含派生类新增的虚函数地址
1> 3	| &Child::child_func2
1>
1>Child::$vftable@Parent@:
1>	| -8
1> 0	| &Parent::parent_func1
1> 1	| &Parent::parent_func2
1>

这里我们得到: 真的有虚函数的基类,派生类内存中嵌套的基类对象顺序和声明的继承顺序相同,而且派生类新增的虚函数地址会添加在第一个虚函数地址的后面
上面我为什么说是针对有虚函数的基类呢,下面我们在继承的第一顺序新添加一个无虚函数的基类
代码:

class Parent {
public:
	Parent() {
		a = 0;
		b = 0;
	}
	 virtual void parent_func1() {
		cout<<"Parent----parent_func1--"<

好了 我们现在通过命令行看下 派生类的内存分布

1>class Child	size(36):
1>	+---
1> 0	| +--- (base class Parent1)
1> 0	| | {vfptr}
1> 4	| | p1_a
1>	| +---
1> 8	| +--- (base class Parent)
1> 8	| | {vfptr}
1>12	| | a
1>16	| | b
1>	| +---
1>20	| +--- (base class Parent2)
1>20	| | {vfptr}
1>24	| | p1_a
1>	| +---
1>28	| +--- (base class Parent3)
1>28	| | p1_a
1>	| +---
1>32	| c
1>	+---
1>
1>Child::$vftable@Parent1@://派生类新增的虚函数地址,在该虚函数地址的后面
1>	| &Child_meta
1>	|  0
1> 0	| &Parent1::parent1_func1
1> 1	| &Parent1::parent1_func2
1> 2	| &Child::child_func1
1> 3	| &Child::child_func2
1>
1>Child::$vftable@Parent@:
1>	| -8
1> 0	| &Parent::parent_func1
1> 1	| &Parent::parent_func2
1>
1>Child::$vftable@Parent2@:
1>	| -20
1> 0	| &Parent2::parent2_func1
1> 1	| &Parent2::parent2_func2
1>
1>Child::child_func1 this adjustor: 0
1>Child::child_func2 this adjustor: 0

注意我们的代码:

class Child:public Parent3, public Parent1,public Parent, public Parent2

而Parent3是无虚函数,其他的都是有虚函数的
注意上面通过命令行打印出来的类Child的内存分布,
child的内存一共嵌套4个基类对象,其顺序是 Parent1 Parent Parent2 Parent3 +派生类自己的成员变量,而且每个有虚函数的嵌套对象都有一个vptr指针指向一个由基类拷贝出来的独立虚函数表,而派生类新增的虚函数地址添加在第一个虚函数表的后面,

结论:在多继承的派生类内存分布中有如下规则

1. 无虚函数的嵌套基类对象一定排在有虚函数的嵌套基类对象的后面
2. 有虚函数的嵌套基类对象的排列顺序和派生类声明时的继承顺序相同
3. 无虚函数的嵌套基类对象的排列顺序和派生类声明时的继承顺序相同(这点我就不在写出验证了)
4. 派生类新增的虚函数地址添加在派生类对象首地址指向的虚函数表的后面

七、总结

一、概论
实现多态的核心是动态联编,而动态联编的原理就是虚函数表和vptr指针
二、本质
虚函数表的本质是指针数组,其元素是指向虚函数的指针,而vptr是指向指针数组的指针,是void ** 类型,

三、生成
(1)对于无继承的类(基类)
没有继承的类(基类),生成虚函数表和vptr指针,必须要有虚函数(用virtual修饰的函数),在编译期间,编辑器会查看类是否有虚函数,如果有便会生成一个指针数组,用来存储指向虚函数地址的指针,该指针数组的大小是 虚函数数量+1 最后一个元素是0 用来表示数组结束,
在类的所有构造函数的最前面,编辑器都会默认加上一些代码用来生成vptr指针指向在编译期间生成的指针数组,然后再指向用户的代码
因为vptr指针是最先声明对应的变量,然后再声明定义用户的成员变量,所以vptr的地址和类对象的首地址相同
注意:每个对象的同一个vptr指向同一个虚函数表
(2)单继承的派生类
–如果基类有虚函数,
派生类继承基类的一切,这时无论基类是否有虚函数,在编译期间都会生成一个虚函数表,这个虚函数表是完全拷贝基类的虚函数表, 派生类的vptr的初始化是分步初始化的,因为派生类对象的生成首先要调用派生类的构造函数,而在继承中派生类构造函数调用前必须先调用基类的构造函数,所以在基类中会先初始化一次vptr指针指向基类的虚函数表,等到基类构造函数执行完毕,再执行派生类的构造函数时,会再一次初始化vptr指针将其指向派生类的虚函数表.
–如果基类没有虚函数
如果派生类没有虚函数,则不会创建虚函数表和vptr指针
如果派生类有虚函数,则编译期间,会创建一个虚函数表,并在构造函数中初始化一个vptr指针指向虚函数表,不过我们需要注意的是其内存分布:如下

1>class Child	size(12):
1>	+---
1> 0	| {vfptr}  //vptr指针
1> 4	| +--- (base class Parent3)//基类没有虚函数  注意其偏移量
1> 4	| | p1_a    //基类的成员变量
1>	| +---
1> 8	| c    //派生类新增的成员变量
1>	+---
1>
 (3)多继承的派生类

多继承的虚函数表的生成和和单继承虚函数表的生成类似,不过就是在基类的构造函数执行前多执行几次了不同基类的构造函数, 其有以下特点(详情见六、(4) )

在多继承的派生类内存分布中有如下规则
1. 无虚函数的嵌套基类对象一定排在有虚函数的嵌套基类对象的后面
2. 有虚函数的嵌套基类对象的排列顺序和派生类声明时的继承顺序相同
3. 无虚函数的嵌套基类对象的排列顺序和派生类声明时的继承顺序相同(这点我就不在写出验证了)
4. 派生类新增的虚函数地址添加在派生类对象首地址指向的虚函数表的后面
5. 有几个有虚函数的基类就有几个vptr指针

四、修正虚函数表
前面说完了虚函数表和vptr指针的生成,这里说下虚函数地址的修改,
当派生类的虚函数 表创建完成后,编辑器会查看派生类是否有重写基类虚函数的方法,如果重写了,含有被重写的虚函数的虚函数表的相应地址修改为派生类重写后的虚函数地址,(多继承可能有多个vptr指针,和虚函数表)
因为虚函数表的修正才真正实现了多态,但是我们需要注意的是,因为vptr指针是分步进行的,所以当基类在构造函数中调用被派生类重载的函数时,无论基类指针指向的是什么对象,最终调用的都是基类的虚函数,因为基类的构造函数中初始化的vptr指针指向的永远都是基类的虚函数表,(所以不会出现虚函数地址被修改的现象了)
五、使用
前面我们知道了 多继承可能会有多个vptr指针和虚函数表,如

//派生类声明
class Child:public Parent3, public Parent1,public Parent,public Parent2 {}
//派生类Child的内存分布
1>class Child	size(36):
1>	+---
1> 0	| +--- (base class Parent1)
1> 0	| | {vfptr}
1> 4	| | p1_a
1>	| +---
1> 8	| +--- (base class Parent)
1> 8	| | {vfptr}
1>12	| | a
1>16	| | b
1>	| +---
1>20	| +--- (base class Parent2)
1>20	| | {vfptr}
1>24	| | p1_a
1>	| +---
1>28	| +--- (base class Parent3)
1>28	| | p1_a
1>	| +---
1>32	| c
1>	+---
1>
1>Child::$vftable@Parent1@:
1>	| &Child_meta
1>	|  0
1> 0	| &Parent1::parent1_func1
1> 1	| &Parent1::parent1_func2
1> 2	| &Child::child_func1
1> 3	| &Child::child_func2
1>
1>Child::$vftable@Parent@:
1>	| -8
1> 0	| &Parent::parent_func1
1> 1	| &Parent::parent_func2
1>
1>Child::$vftable@Parent2@:
1>	| -20
1> 0	| &Parent2::parent2_func1
1> 1	| &Parent2::parent2_func2
1>
1>Child::child_func1 this adjustor: 0
1>Child::child_func2 this adjustor: 0
1>
//主函数
	Child  c1;
	Child * c = &c1;
	Parent *p = &c1;
	Parent1 *p1 = &c1;
	Parent2  * p2 = &c1;
	Parent3  * p2 = &c1;

Vs断点
C++之虚函数表和vptr指针_第11张图片
注:Parent3是无虚函数基类,其他的都是有虚函数基类
我们注意到都是&c 但是其值是不同的 为什么呢 因为在进行赋值的时候会发生地址偏移,其偏移量和上面我们通过命令行打印出列Child的内存分布相同
比如:
p = &c 得到 p = 0x0137fc9c
p的值减去c的值为8 正好是为内存分布中嵌套基类Parent对象的首地址,即是:

1> 8	| +--- (base class Parent)//偏移量是8
1> 8	| | {vfptr}

所以 此时对p的操作和对指向真正的Parent对象的指针的操作是完全相同的,(此时p指向的就是派生类中嵌套的基类对象,而且虚函数表也是完整拷贝过来后进行修改的)
这时候我们是不是更能理解多态了呢

备注:
调试VS 输出对象内存 指令:
/d1 reportSingleClassLayout[classname] 注意:没有空格也没有[] [classname]代表的就是一个类名称 输出单个对象的内存空间
/d1 reportAllClassLayout 输出所有对象的内存空间

你可能感兴趣的:(C++)