C++ 虚函数
Posted 嘻嘻兮
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ 虚函数相关的知识,希望对你有一定的参考价值。
对于虚函数而言,这篇博客主要侧重于理解虚函数的实现机制,还有就是如何实现的多态?
在C++中,使用virtual关键字声明的函数为虚函数。当类中有虚函数时,编译器会将该类中所有虚函数的首地址保存在一张表中,这张表也称为虚函数地址表(虚表),在我们代码层面可以理解为函数指针数组。同时编译器还会在类中添加一个隐藏数据成员,该成员称为虚表指针,该指针也就是用于保存前面所说的虚表首地址。
好了,下面就先来看一个简单的虚函数类
class CVirtual
public:
virtual int GetNumber() //虚函数一
return m_nNum;
virtual void SetNumber(int num) //虚函数二
m_nNum = num;
private:
int m_nNum;
;
上面我们说了虚表指针的概念,对于虚表指针而言,其肯定会在对象的首地址处,由于是指针,所以其大小占用四个字节,那么可以说明上面的这个对象大小应该总共应该是8字节(虚表指针+成员字段m_nNum)
int main(int argc)
CVirtual obj;
int objSize = sizeof obj;
return 0;
下面,我们可以通过调试来看一下其对象内存分布情况
对于上面其内存布局不太清楚的,可以再看一下下面这内存分配图
OK,上面主要说的是编译器对于虚函数而做的额外工作,当然这些工作是不可少的,下面我们来看看虚函数的调用,就可知道这些虚表和虚表指针的用途了。
int main(int argc)
CVirtual obj;
CVirtual *pObj = &obj;
pObj->SetNumber(10); //虚函数调用
return 0;
对于虚函数的实现调用,这里我们需要来简单的分析一下其汇编代码
pObj->SetNumber(10);
002817EE 6A 0A push 0Ah //压入参数
002817F0 8B 45 E8 mov eax,dword ptr [pObj] //定位到虚表指针 vfptr
002817F3 8B 10 mov edx,dword ptr [eax] //获取虚表
002817F5 8B 4D E8 mov ecx,dword ptr [pObj] //thiscall 需要传递对象首地址,vfptr肯定在对象首地址的前四个字节,所以其也是虚表指针的位置
002817F8 8B 42 04 mov eax,dword ptr [edx+4] //虚表的第二项
002817FB FF D0 call eax //调用成员函数
结合上面的图片观察,可以发现SetNumber函数正是在函数指针数组的第二项。那么对于虚函数的调用,其本质就是查虚表然后获取其函数地址并调用。
对象
首四字节-vfptr
fun1
fun2
1.获取对象的首四个字节(虚表指针)
2.通过该指针定位到虚表
3.获取虚表内的函数地址
4.调用-(比较明显的特性这里是间接调用)
OK,通过上面的分析,应该对虚函数有了一个宏观上的理解了,下面我们来考虑一些问题,用于加深我们的印象
首先,虚函数必须作为成员函数使用吗?
这里答案显然是肯定的,因为对于非成员函数而言,其没有this指针,也就是无法获取虚表指针,而无法定位到虚表,自然也就无法获取虚函数的地址了。
下个问题,对于上面的例子,可以发现其使用的是指针调用?为什么不使用对象调用
这种查虚表调用的情况只有在指针或者引用的情况下才会出现,因为使用对象调用,其目的很明显,无需进行查表调用,直接调用自身的成员函数即可。
虚表是何时产生的?
虚表信息会在编译后链接到对应的可执行文件中(也就是在你按下编译的那一瞬间),好比编译器使用了一个全局变量用于保存这些虚函数首地址(编译器肯定知道地址是多少),所以说虚表地址也是一个相对固定的地址。虚表中虚函数的地址的排列顺序依据虚函数在类中的声明顺序而定(注意当有虚函数重载时可能会与声明顺序不同)。
虚表指针又是何时被初始化的?
这个问题也就说vfptr什么时候被填写为指向虚表的,首先对于对象的创建而言,编译器只能做到知道对象大小开辟对象空间,对于对象的数据是无法掌控的,所以此时只能运行时完成,那么什么时候填写虚表最合适呢?此时在对象的创建阶段填写自然是最合适的,所以可以想到在构造中填写,这个也是实际的情况,就是在构造时完成对虚表指针的赋值。
简单说,对于虚表指针的初始化,是编译器在构造函数内插入代码来完成的。
OK,了解完虚表后就可以探究其多态了,实现多态的本质其实就是对虚表的替换,下面先来看一个简单的多态例子
class CBase
public:
CBase()
m_nNum = 1;
printf("CBase()\\r\\n");
virtual ~CBase()
printf("~CBase()\\r\\n");
virtual void fun1()
printf("CBase::fun1\\r\\n");
private:
int m_nNum;
;
class CDerived : public CBase
public:
CDerived()
m_nNum = 2;
printf("CDerived()\\r\\n");
virtual ~CDerived()
printf("~CDerived()\\r\\n");
virtual void fun1()
printf("CDerived::fun1\\r\\n");
virtual void fun2()
printf("CDerived::fun2\\r\\n");
private:
int m_nNum;
;
int main(int argc)
CBase* pBase = new CDerived();
pBase->fun1();
delete pBase;
return 0;
其打印结果如下
CBase()
CDerived()
CDerived::fun1
~CDerived()
~CBase()
不过下面先通过画图来看一下上例中的这两个类的内存模型
好了,对于多态而言,也就是用父类指针指向子类对象,会根据其子类对象的不同,会调用不同的子类函数。
首先需要明白一个观点,就是虽然使用的是父类指针指向子类对象,但是其内存模型是不会发生变化的,也就是说上面例子中pBase指向的内存大小为12字节(CDerived的对象大小),或者简单来说,虽然使用了父类指针,但是其本质上还是一个子类的对象。
你可能会说使用了父类指针之后,就调用不了fun2函数了,那其不是变成了父类的对象么?
其实并不是,这里只是编译器做的限制而已(或者说其指针的范围只能是父类那么大),所以才调用不了fun2,但是本质来说他就是一个CDerived对象,可以合理的调用fun2。
CBase* pBase = new CDerived();
//不管是啥指针,其指向的内容本质还是CDerived对象
int* pInt = (int*)pBase;
char* pChar = (char*)pInt;
CBase* pBase2 = (CBase*)pChar;
pBase2->fun1(); //正常调用
下面我们在内存窗口部分再来看一下其内存数据
虽然我们使用的pChar,但是可以发现其本质数据还是个CDerived对象,而对于pChar而言,他只能取到第一个字节罢了。
OK,对于这部分还是不太理解的,还可以看看上一篇C++博客继承,里面也有对该部分的解释。
好了,再结合前面的最开始的内存模型,应该就能明白为何可以多态了吧,因为其本质是一个子类对象,而子类对象中存在的虚表是和父类不相同的,所以在调用函数的时候,查找其子类的虚表,自然就调用到子类函数了。
那么结合之前的继承和现在的虚表,因为在构造时会先调用父类构造,而虚表的填写时机也就是构造内,所以其实子类的虚表填写第一次会在父类构造的时候(填写的父类虚表),然后在自己构造时覆盖虚表,所以这里的虚表并不是一次直接填写为子类的虚表的。
但是我们需要明白的是,不管如何,因为子类的构造的最后执行的,所以其最终的虚表是子类对象的虚表。
下面我们可以来简单分析一下汇编了解一下其虚表的填写覆盖过程
00951D5D 8B 8D F0 FE FF FF mov ecx,dword ptr [ebp-110h] //this指针
00951D63 E8 DB F5 FF FF call CDerived::CDerived (0951343h) //调用构造
//子类构造
00951343 E9 A8 05 00 00 jmp CDerived::CDerived (09518F0h)
009518F0 55 push ebp
//..省略部分初始化汇编代码
00951913 8B 4D F8 mov ecx,dword ptr [this]
00951916 E8 AF FA FF FF call CBase::CBase (09513CAh) //先调用父类构造
//这里是父类构造的代码,简单起见粘贴到括号内部
008413CA E9 B1 04 00 00 jmp CBase::CBase (0841880h)
00841880 55 push ebp
//...省略部分代码
008418A3 8B 45 F8 mov eax,dword ptr [this]
//第一次填写的是父类的虚表
008418A6 C7 00 34 8B 84 00 mov dword ptr [eax],offset CBase::`vftable' (0848B34h)
008418AC 8B 45 F8 mov eax,dword ptr [this]
008418AF C7 40 04 01 00 00 00 mov dword ptr [eax+4],1 //this+4的位置成员字段赋值1
//...打印输出 "CBase()\\r\\n"
0095191B 8B 45 F8 mov eax,dword ptr [this]
//第二次在相同的位置覆盖为子类的虚表
0095191E C7 00 70 8B 95 00 mov dword ptr [eax],offset CDerived::`vftable' (0958B70h)
00841924 8B 45 F8 mov eax,dword ptr [this]
00841927 C7 40 08 02 00 00 00 mov dword ptr [eax+8],2 //this+8的位置成员字段赋值2
//...打印输出"CDerived()\\r\\n"
OK,其多态的本质这里其实差不多就讲完了。
下面可以纠正上面的一个错误了,其实对于析构而言,上面虚表并不是对应着真正的析构函数,而是一个代理析构函数,为什么需要一个代理析构函数呢,首先我们需要明白这个代理析构做了什么事
1.调用真正析构函数
2.释放内存
代理析构主要就做了上面两件事,调用析构和释放内存。下面我们通过汇编来验证一下
delete pBase; //delete会查虚表调用析构代理函数
01331DCC 6A 01 push 1 //析构代理函数的参数一
01331DCE 8B 95 08 FF FF FF mov edx,dword ptr [ebp-0F8h]
01331DD4 8B 02 mov eax,dword ptr [edx] //虚表
01331DD6 8B 8D 08 FF FF FF mov ecx,dword ptr [ebp-0F8h] //this指针
01331DDC 8B 10 mov edx,dword ptr [eax] //获取虚表第一项
01331DDE FF D2 call edx
//析构代理函数
0133138E E9 1D 07 00 00 jmp CDerived::`scalar deleting destructor' (01331AB0h)
01331AB0 55 push ebp
//...
01331AD0 89 4D F8 mov dword ptr [this],ecx
01331AD3 8B 4D F8 mov ecx,dword ptr [this]
01331AD6 E8 D2 F7 FF FF call CDerived::~CDerived (013312ADh) //调用析构函数
01331ADB 8B 45 08 mov eax,dword ptr [ebp+8] //获取参数一
01331ADE 83 E0 01 and eax,1 //判断是否是1
01331AE1 74 0E je CDerived::`scalar deleting destructor'+41h (01331AF1h) //如果结果是1,那么zf=0不会跳转
01331AE3 6A 0C push 0Ch
01331AE5 8B 45 F8 mov eax,dword ptr [this]
01331AE8 50 push eax
01331AE9 E8 62 F5 FF FF call operator delete (01331050h) //释放空间
//...
好了,大致看完其实可以明白,这个析构代理函数会传递一个参数,这个参数主要决定是否用于释放空间,那么什么时候会传递0呢,貌似都需要释放空间吧,其实当指针指向的是栈内存数据时,就不用释放空间,我们可以根据下面的代码验证一下
CBase baseObj;
CBase* pBase = &baseObj;
pBase->~CBase(); //显示调用析构
delete pBase;
//下面比较最后两行代码的汇编代码进行验证
pBase->~CBase();
0090584E 6A 00 push 0 //传递参数值为0
00905850 8B 45 E8 mov eax,dword ptr [pBase]
00905853 8B 10 mov edx,dword ptr [eax]
00905855 8B 4D E8 mov ecx,dword ptr [pBase]
00905858 8B 02 mov eax,dword ptr [edx]
0090585A FF D0 call eax
delete pBase;
00905883 6A 01 push 1 //传递参数值为1
00905885 8B 95 1C FF FF FF mov edx,dword ptr [ebp-0E4h]
0090588B 8B 02 mov eax,dword ptr [edx]
0090588D 8B 8D 1C FF FF FF mov ecx,dword ptr [ebp-0E4h]
00905893 8B 10 mov edx,dword ptr [eax]
00905895 FF D2 call edx
简单来说,因为我们的指针实际指向的是栈空间内存,而当进行虚构虚函数调用时,其自然是不用释放内存空间的。
OK,下面我们就可以来谈谈析构了,因为C++语法规定,析构函数在调用虚函数时无多态性,所以在析构最开始我们需要做的就是还原虚表。
这里其实很好理解,在构造时,其最终的虚表是子类对象的,那么在析构时,其会析构自己然后在调用父类的析构函数,那么此时在父类的析构函数中调用虚函数,是不是就会跑到子类对象中呢?而此时子类已析构,为了避免这个问题,我们可以在每个析构函数之前和构造一样覆盖一下虚表(还原)。那么当到父类析构时,先执行虚表的还原,此时如果在调用虚函数,也只是会调用父类的虚函数了。
0101142E E9 9D 05 00 00 jmp CDerived::~CDerived (010119D0h)
010119D0 55 push ebp
//...
010119F0 89 4D F8 mov dword ptr [this],ecx
010119F3 8B 45 F8 mov eax,dword ptr [this]
010119F6 C7 00 70 8B 01 01 mov dword ptr [eax],offset CDerived::`vftable' (01018B70h) //开始先还原虚表
//打印"~CDerived()\\r\\n"
01011A09 8B 4D F8 mov ecx,dword ptr [this]
01011A0C E8 23 F9 FF FF call CBase::~CBase (01011334h) //函数最后调用父类的析构
01011334 E9 37 06 00 00 jmp CBase::~CBase (01011970h)
01011970 55 push ebp
//...
01011990 89 4D F8 mov dword ptr [this],ecx
01011993 8B 45 F8 mov eax,dword ptr [this]
01011996 C7 00 34 8B 01 01 mov dword ptr [eax],offset CBase::`vftable' (01018B34h) //还原虚表
//打印"~CBase()\\r\\n"
//...
//...
好了,下面再来谈谈抽象类的虚表,首先在C++中,含有纯虚函数的类称为抽象类,它不能实例化对象。与普通类相比,抽象类的虚函数最大特点是没有实现代码,其抽象类通常就是给子类规范一个接口而已。
那么如果抽象类的虚函数没有其实现代码,那么其虚表填写的是什么值呢,会是null么,下面看一下下面这段代码
class IBase
public:
IBase()
m_nNum = 1;
printf("IBase()\\r\\n");
~IBase()
printf("~IBase()\\r\\n");
virtual void fun1() = 0; //纯虚函数
virtual void fun2() = 0;
private:
int m_nNum;
;
class CDerived : public IBase
public:
CDerived()
m_nNum = 2;
printf("CDerived()\\r\\n");
virtual ~CDerived()
printf("~CDerived()\\r\\n");
virtual void fun1()
printf("CDerived::fun1\\r\\n");
virtual void fun2()
printf("CDerived::fun2\\r\\n");
private:
int m_nNum;
;
int main(int argc)
IBase* pBase = new CDerived();
pBase->fun1();
delete pBase;
return 0;
下面,我们将应用程序拖入IDA中分析一下去虚表内容
//在构造填写虚表
.text:00411906 mov dword ptr [eax], offset ??_7IBase@@6B@ ; const IBase::`vftable'
//虚表内容
.rdata:00418B34 ??_7IBase@@6B@ dd offset j__purecall ; DATA XREF: sub_4118E0+26↑o
.rdata:00418B38 dd offset j__purecall
可以发现,由于纯虚函数没有实现代码,编译器默认填写了_purecall函数的地址。该函数的功能就是显示一个错误信息并退出程序,毕竟正常程序是不可能会调用到纯虚函数的。
下面,我们可以通过上面所学的析构知识点,在析构的时候会还原虚表的特性来进行测试,毕竟如果虚表被还原了,那么在调用函数那就是基类的函数了。
CDerived* pDerived = new CDerived();
pDerived->~CDerived(); //还原虚表,此时虚表是父类的vfptr
pDerived->fun1(); //调用的是纯虚函数
//012713AC E9 9F 41 00 00 jmp __purecall (01275550h) 调用到这里
编译完直接运行程序,就可以发现其终止的对话框了,这个就是_purecall函数做的事。
以上是关于C++ 虚函数的主要内容,如果未能解决你的问题,请参考以下文章