深入分析C++虚函数表

C++中的虚函数(Virtual Function)是用来实现动态多态性的,指的是当基类指针指向其派生类实例时,可以用基类指针调用派生类中的成员函数。如果基类指针指向不同的派生类,则它调用同一个函数就可以实现不同的逻辑,这种机制可以让基类指针有“多种形态”,它的实现依赖于虚函数表。虚函数表(Virtual Table)是指在每个包含虚函数的类中都存在着一个函数地址的数组。本文将详细介绍虚函数表的实现及其内存布局。

1. 虚函数表概述

首先我们要知道虚函数表的地址总是存在于对象实例中最前面的位置,其后依次是对象实例的成员。下图中vtptr就是虚函数表的地址,可看出虚函数表中的每个成员都对应类中的一个虚函数的地址。据图所述,我们可以使用对象实例的地址来得到虚函数表的地址,进而获得具体的虚函数的地址,然后进行调用。

假如有如下定义 Base b; 那么虚函数表的地址vtptr的值就是:(int*)*(int*)&b,第一个虚函数vfunc1的地址就是:*(int*)*(int*)&b,vfunc2的地址是:*( (int*)*(int*)&b + 1 ),详见本节后文所附代码。

下文为验证代码,其中Base类包含3个虚函数 vfunc1~vfunc3和两个数据成员m_iMem1, m_iMem2,该类与上图中的保持一致。在main中,详细描述了怎么获取虚表的地址,怎么获取成员变量,怎么通过虚表地址获取虚函数的地址

class Base
{
public:
    Base(int mem1 = 1, int mem2 = 2) : m_iMem1(mem1), m_iMem2(mem2){ ; }

    virtual void vfunc1() { std::cout << "In vfunc1()" << std::endl; }
    virtual void vfunc2() { std::cout << "In vfunc2()" << std::endl; }
    virtual void vfunc3() { std::cout << "In vfunc3()" << std::endl; }

private:
    int m_iMem1;
    int m_iMem2;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Base b;

    // 对象b的地址
    int *bAddress = (int *)&b;    

    // 对象b的vtptr的值
    int *vtptr = (int *)*(bAddress + 0);
    printf("vtptr: 0x%08x\n", vtptr);

    // 对象b的第一个虚函数的地址
    int *pFunc1 = (int *)*(vtptr + 0);
    int *pFunc2 = (int *)*(vtptr + 1);
    int *pFunc3 = (int *)*(vtptr + 2);
    printf("\t vfunc1addr: 0x%08x \n" 
           "\t vfunc2addr: 0x%08x \n" 
           "\t vfunc3addr: 0x%08x \n",
           pFunc1, 
           pFunc2, 
           pFunc3);

    // 对象b的两个成员变量的值(用这种方式可轻松突破private不能访问的限制)
    int mem1 = (int)*(bAddress + 1);
    int mem2 = (int)*(bAddress + 2);
    printf("m_iMem1: %d \nm_iMem2: %d \n\n",mem1, mem2);

    // 调用虚函数
    (FUNC(pFunc1))();
    (FUNC(pFunc2))();
    (FUNC(pFunc3))();
    return 0;
}

程序运行结果如下面两幅图所示,其中左边部分是程序运行结果,右边部分为调试窗口中显示的类中各成员的值,可以发现两者结果一致。同时在运行结果窗口中可见直接使用地址调用虚函数的方法也是正确的,这就验证了我们本节开始部分的阐述。

2. 单继承下的虚函数表

2.1 派生类未覆盖基类虚函数

下面我们来看下派生类没有覆盖基类虚函数的情况,其中Base类延用上一节的定义。从图中可看出虚函数表中依照声明顺序先放基类的虚函数地址,再放派生类的虚函数地址。

其对应的代码如下所示:

class Derived : public Base
{
public:
    Devired(int mem = 3) : m_iDMem1(mem){ ; }
    
    virtual void vdfunc1() { std::cout << "In Devired vfunc3()" << std::endl; }

    void dfunc1() { std::cout << "In Devired dfunc1" << std::endl; }

private:     
    int m_iDMem1;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Derived d;
    int *dAddress = (int*)&d;

    /* 1. 获取对象的内存布局信息 */
    // 虚表地址
    int *vtptr = (int*)*(dAddress + 0);

    // 数据成员的地址
    int  mem1  = (int)*(dAddress + 1);
    int  mem2  = (int)*(dAddress + 2);
    int dmem1  = (int)*(dAddress + 3);

    /* 2. 输出对象的内存布局信息 */
    int *pFunc1 = (int *)*(vtptr + 0);
    int *pFunc2 = (int *)*(vtptr + 1);
    int *pFunc3 = (int *)*(vtptr + 2);
    int *pdFunc1 = (int *)*(vtptr + 3);
    
    (FUNC(pFunc1))();
    (FUNC(pFunc2))();
    (FUNC(pFunc3))();
    (FUNC(pdFunc1))();

    printf("\t vfunc1addr: 0x%08x \n"
            "\t vfunc2addr: 0x%08x \n" 
            "\t vfunc3addr: 0x%08x \n"
            "\t vdfunc1addr: 0x%08x \n\n",
            pFunc1, 
            pFunc2, 
            pFunc3,
            pdFunc1
            );


    printf("m_iMem1: %d, m_iMem2: %d, m_iDMem3: %d \n", mem1, mem2, dmem1);
    return 0;
}

其输出结果如下图所示,可见与本节开始介绍的结论是一致的。

2.2 派生类覆盖基类虚函数

我们再来看一下派生类覆盖了基类的虚函数的情形,可见:1. 虚表中派生类覆盖的虚函数的地址被放在了基类相应的函数原来的位置  2. 派生类没有覆盖的虚函数延用基类的

代码如下所示,注意这里只给出了类的定义,main函数的测试代码与上节一样:

class Devired : public Base
{
public:
    // 覆盖基类的虚函数
    virtual void vfunc2() { std::cout << "In Devired vfunc2()" << std::endl; }

public:
    Devired(int mem = 3) : m_iDMem1(mem){ ; }

    virtual void vdfunc1() { std::cout << "In Devired vdfunc1()" << std::endl; }
    void dfunc1() { std::cout << "In Devired dfunc1" << std::endl; }

private:     
    int m_iDMem1;
};

运行结果如下所示:

3. 多继承下的虚函数表

3.1 无虚函数覆盖

如果是多重继承的话,问题就变得稍微复杂一丢丢,主要有几点:1. 有几个基类就有几个虚函数表   2. 派生类的虚函数地址存依照声明顺序放在第一个基类的虚表最后,见下图所示:

Base类延用本文之前的定义,其余部分代码如下所示:

class Base2
{
public:
    Base2(int mem = 3) : m_iBase2Mem(mem){ ; }
    virtual void vBase2func1() { std::cout << "In Base2 vfunc1()" << std::endl; }
    virtual void vBase2func2() { std::cout << "In Base2 vfunc2()" << std::endl; }

private:
    int m_iBase2Mem;
};

class Base3
{
public:
    Base3(int mem = 4) : m_iBase3Mem(mem) { ; }
    virtual void vBase3func1() { std::cout << "In Base3 vfunc1()" << std::endl; }
    virtual void vBase3func2() { std::cout << "In Base3 vfunc2()" << std::endl; }

private:
    int m_iBase3Mem;
};

class Devired: public Base, public Base2, public Base3
{
public:
    Devired(int mem = 7) : m_iMem1(mem) { ; }
    virtual void vdfunc1() { std::cout << "In Devired vdfunc1()" << std::endl; }

private:
    int m_iMem1;
};

int _tmain(int argc, _TCHAR* argv[])
{
    // Test_3
    Devired d;
    int *dAddress = (int*)&d;

    /* 1. 获取对象的内存布局信息 */
    // 虚表地址一
    int *vtptr1  = (int*)*(dAddress + 0);
    int basemem1 = (int)*(dAddress + 1);
    int basemem2 = (int)*(dAddress + 2);

    int *vtpttr2 = (int*)*(dAddress + 3);
    int base2mem = (int)*(dAddress + 4);    

    int *vtptr3  = (int*)*(dAddress + 5);
    int base3mem = (int)*(dAddress + 6);

    /* 2. 输出对象的内存布局信息 */
    int *pBaseFunc1 = (int *)*(vtptr1 + 0);
    int *pBaseFunc2 = (int *)*(vtptr1 + 1);
    int *pBaseFunc3 = (int *)*(vtptr1 + 2);
    int *pBaseFunc4 = (int *)*(vtptr1 + 3);

    (FUNC(pBaseFunc1))();
    (FUNC(pBaseFunc2))();
    (FUNC(pBaseFunc3))();
    (FUNC(pBaseFunc4))();
    // .... 后面省略若干输出内容,可自行补充
    return 0;
}

调试输出如下图,这里的展示结果与本节开始所展示的内存布局图是一致的

3.2 有虚函数覆盖

本节不再给出任何分析,读者如果想彻底搞明白可以根据本文上述内容自行画图写代码验证。

 




3 Comments

  • 博主,这边文章写得非常棒,对含有虚函数的 C++ 类对象在内存中的布局结构,解释的非常清楚。
    但是我发现文中的代码有一个小问题,就是你在 **1. 虚函数表概述** 小节中的测试代码中,直接将对象 b 的地址 直接强制转换成 int* 类型的 bAddress 是会存在问题的,因为如果 bAddress 类型是 int* ,下面的代码中:
    // 对象b的vtptr的值
    int *vtptr = (int *)*(bAddress + 0);
    printf(“vtptr: 0x%08x\n”, vtptr);

    // 对象b的第一个虚函数的地址
    int *pFunc1 = (int *)*(vtptr + 0);
    int *pFunc2 = (int *)*(vtptr + 1);
    int *pFunc3 = (int *)*(vtptr + 2);

    vptr 的类型也是 int*,那么 (vptr+1) 的值其实是将 vptr 向高地址移动了 4 个字节(也就是 int 类型的的长度)的位置,但是实际上虚函数表中保存的不是 int 类型,而实际上是指向虚函数的地址。如果在 32 位系统中,博主的代码是没有问题的,但是如果在 64 位的系统中,博主的代码就可能导致 Segment Falut 的错误。

  • 对,int型大小与地址指针大小存在不一致是个问题,感谢指出!由于是测试代码,只用于阐述原理,平时也不会这么野蛮的使用,因此有所疏忽,见谅。 最近工作繁忙,评论回复的不及时,抱歉。

  • 可以考虑使用long类型。博主使用的FUNC类型,也建议写出来。在将一个地址转换成指定类类型的函数时,我是直接强制转换的。如:Base有一个成员函数为 int add(int a, int b),则实际上这个函数转换的类型为 int (*)(void* this, int a, int b)。另外博主还差一个虚继承呢,哈哈。
    另:64位操作系统中,虚指针可以这样写:
    auto vptr = (long*)(*((long*)&base));
    auto func1 = (void*)(*vptr);
    auto func2 = (void*)(*(vptr + 1));
    // 64位操作系统
    // &base: base对象的地址
    // (long*)&base: 将base对象的地址解析成一个long指针
    // *((long*)&base): 取base对象前8个字节组成的long整数值(也即vptr中保存的值)
    // (long*)(*((long*)&base)): 将base对象前8个字节组成的整数值解析成long指针,也就是最终的虚指针。
    // func1:虚函数1
    // func2:虚函数2

  • Comments are closed.