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++ 虚函数的主要内容,如果未能解决你的问题,请参考以下文章

c++ 之 内存模型:虚函数篇

C++虚函数除了vtable怎么实现? [复制]

为什么析构函数必须是虚函数?为什么C++默认的析构函数不是虚函数

C++虚函数

一文读懂C++虚函数的内存模型

在c++中虚函数和多态性是啥意思