C++基类与派生类在内存中是如何存储的?他们的内存模型是什么样的?虚函数,虚表,虚表指针,是如何被继承的?

之前一节讨论了C++语言中类在内存中分布模型,提到了C++语言编译器会自动为每一个拥有虚函数的类创建虚表虚表指针,其中虚表指针指向虚表,而虚表则用于存放虚函数指针,如下图所示:

其中pub_vfoo1和pub_vfoo2是类A的两个虚成员函数

基类的内存模型

那么,若是拥有虚函数的类 A 作为基类,派生出其他类,这些派生类如何处理类 A 的虚表呢?换句话说,C++语言中的派生类是如何继承基类的虚函数的呢?为了便于讨论,先将类 A 的C++语言代码补充完整,请看:

class A {
public:
    void foo1()  {
        printf("A::prv_i1 \t addr: %p\n", &prv_i1);
        printf("A::prv_i2 \t addr: %p\n", &prv_i2);
    }
    void foo2() {}
    virtual void vfoo1() {}
    virtual void vfoo2() {}

    int pub_i1;
private:
    int prv_i1;
    int prv_i2;
};

在上述C++语言代码中,类 A 拥有两个常规函数,两个 int 型变量,此外它还有两个虚函数 vfoo1() 和 vfoo2(),因此编译器处理类 A 时,会自动为它分配一个虚表,用于存放 vfoo1() 和 vfoo2() 两个函数的地址。我们先定义一个 A 对象,并将相关成员的地址打印出来,相关的C++语言代码如下,请看:

...
A a;
print_addr(A, a);
...

因为稍后还要分析基类 A 的派生类成员地址,所以为了方便,将打印地址的功能定义为 print_addr() 宏了,它的C++语言代码如下,请看:

#define print_addr(class, obj) \
    printf(#obj" addr: %p\n", &obj); \
    printf("sizeof "#obj" : %d\n", sizeof(obj));\
    printf(#class"::pub_i1 \t addr: %p\n", &obj.pub_i1); \
    obj.foo1(); \
    printf(#class"::foo1() \t addr: %p\n", (void *)&class::foo1); \
    printf(#class"::foo2() \t addr: %p\n", (void *)&class::foo2); \
    printf(#class"::vfoo1() \t addr: %p\n", (void *)&class::vfoo1); \
    printf(#class"::vfoo2() \t addr: %p\n", (void *)&class::vfoo2)

print_addr()是一个宏

编译并执行,得到如下输出:

a addr: 0x7ffdcf3c8d50
sizeof a : 24
A::pub_i1        addr: 0x7ffdcf3c8d58
A::prv_i1        addr: 0x7ffdcf3c8d5c
A::prv_i2        addr: 0x7ffdcf3c8d60
A::foo1()        addr: 0x400b62
A::foo2()        addr: 0x400ba4
A::vfoo1()       addr: 0x400bae
A::vfoo2()       addr: 0x400bb8

C++语言类的内存分布一节我们曾提到类的成员函数是独立存储的,只有成员变量和虚表指针(如果该类有虚函数的话)才会为类占据内存空间,因此对象 a 的 size 为 24,正是它的 3 个 int 型的成员变量与一个虚表指针占用的内存大小。

在我的机器上,int 型变量占用内存为 4 字节,指针占用内存大小为 8 字节。到这里可能有读者迷惑了,3个int型成员变量占用的内存为 12 字节,加上虚表指针的 8 字节,也才 20 字节,而 sizeof(a) 等于 24,多出的 4 字节从哪里来呢?请参考我之前关于内存对齐的文章。

内存对齐后,对象 a 的内存分布如下图所示:

对象 a 的内存分布

派生类的内存模型

类 A 作为基类,肯定可以被其他派生类继承的,下面是一段C++语言代码示例:

class B : public A {
public:
    void foo1() {
        printf("B::prv_i1 \t addr: %p\n", &prv_i1);
        printf("B::prv_i2 \t addr: %p\n", &prv_i2);
    }
    void foo2() {}
    virtual void vfoo1() {}
    //void vfoo2() {}
private:
    int prv_i1;
    int prv_i2;
public:
    int pub_i1;
};

class C : public B {
public:
    void foo1()  {
        printf("C::prv_i1 \t addr: %p\n", &prv_i1);
        printf("C::prv_i2 \t addr: %p\n", &prv_i2);
    }
    void foo2() {}
    //void vfoo1() {}
    virtual void vfoo2() {}
private:
    int prv_i1;
    int prv_i2;
};

上述C++语言代码定义了继承基类 A 的派生类 B,接着又以类 B 为基类定义了派生类 C。其中类 B 重写了由基类 A 继承而来的虚函数 vfoo1(),类 C 重写了由基类 B 继承而来的虚函数 vfoo2()。为了弄清派生类的内存模型,我们使用print_addr()宏将类A、B、C相关的地址打印出来:

A a;
B b;
C c;

cout << endl;
print_addr(A, a);
cout << endl;
print_addr(B, b);
cout << endl;
print_addr(C, c);
cout << endl;

编译并执行这段C++语言代码,得到的输出如下:

a addr: 0x7ffe4eeacd50
sizeof a : 24
A::pub_i1        addr: 0x7ffe4eeacd58
A::prv_i1        addr: 0x7ffe4eeacd5c
A::prv_i2        addr: 0x7ffe4eeacd60
A::foo1()        addr: 0x400b9e
A::foo2()        addr: 0x400be0
A::vfoo1()       addr: 0x400bea
A::vfoo2()       addr: 0x400bf4

b addr: 0x7ffe4eeacd70
sizeof b : 32
B::pub_i1        addr: 0x7ffe4eeacd8c
B::prv_i1        addr: 0x7ffe4eeacd84
B::prv_i2        addr: 0x7ffe4eeacd88
B::foo1()        addr: 0x400bfe
B::foo2()        addr: 0x400c40
B::vfoo1()       addr: 0x400c4a
B::vfoo2()       addr: 0x400bf4

c addr: 0x7ffe4eeacd90
sizeof c : 40
C::pub_i1        addr: 0x7ffe4eeacdac
C::prv_i1        addr: 0x7ffe4eeacdb0
C::prv_i2        addr: 0x7ffe4eeacdb4
C::foo1()        addr: 0x400c54
C::foo2()        addr: 0x400c96
C::vfoo1()       addr: 0x400c4a
C::vfoo2()       addr: 0x400ca0

先关注类 B 的内存分布。注意到对象 b 的 size 为 32,这是类 B 继承基类 A 的结果。而基类 A 的 size 等于 24,类 B 仅多出 8 字节问题来了:且不谈类 B 拥有自己的虚函数,可能拥有虚表指针,仅仅 3 个 int 型变量就要占用 12 字节内存,8 字节怎么放得下呢?

仔细考虑下,这个问题并不难回答。在分析基类 A 的内存分布时,提到了类 A 占据的内存中其实是有 4 个字节被填充的,这 4 个字节内存当然不会永远被白白浪费——在不违背内存对齐规则下,编译器会在“恰当的”时机使用这 4 个字节。在本例中,“恰当的时机”就是类 B 中的成员变量 prv_i1恰好为 4 字节,既然基类 A 可以被填充无意义的数据,自然也可以填充“有用的数据(prv_i1)”。这就解释了派生类 B 多出了 3 个 int 型成员变量,占用的内存却只比基类 A 多出 8 字节的原因。

仔细观察类 B 对象 b 的输出,应该能够发现对象 b 的地址与它的第一个成员变量(prv_i1)的地址偏移了 0x14 也就是 20 字节,在上一节我们已经知道对象的前 8 字节用于存储了虚表指针,接下来的 12 字节恰好存储了由基类 A 继承而来的三个 int 型变量,因此此时对象 b 占用的的内存模型如下图所示:

对象 b 的内存模型

对象 b 占用的内存空间为 32 字节一目了然了。再来分析对象 b 的成员函数,首先常规非虚函数就不必说了,它独立于对象 b 存储,不管类 B 实例化多少个对象,这些对象都共用同一地址处的成员函数,需要仔细考虑的是 b 的虚函数。

类 B 继承基类 A,并且重写了虚成员函数 vfoo1(),从对象 b 打印的地址可以看出,由基类 A 继承而来的 vfoo2() 函数地址与对象 a 中的 vfoo2() 函数地址是一样的,这说明对象 a 和 b 共用同一个虚函数 vfoo2(),同样的,对象 b 和对象 c 公用同一个虚函数 vfoo1()。

可以在 main() 函数中添加下面这段C++语言代码,分别将对象 a 和对象 b 的虚表中记录的函数指针打印出来:

...
    void **p = (void **)&b;
    void **vptr = (void **)*p;
    printf("b vptr[0]: %p\n", vptr[0]);
    printf("b vptr[1]: %p\n", vptr[1]);

    p = (void **)&a;
    vptr = (void **)*p;
    printf("a vptr[0]: %p\n", vptr[0]);
    printf("a vptr[1]: %p\n", vptr[1]);
...

编译并执行修改后的C++语言代码,得到的输出如下,请看:

...
A::vfoo1()       addr: 0x400c7c
A::vfoo2()       addr: 0x400c86
...
B::vfoo1()       addr: 0x400cdc
B::vfoo2()       addr: 0x400c86
...
b vptr[0]: 0x400cdc
b vptr[1]: 0x400c86
a vptr[0]: 0x400c7c
a vptr[1]: 0x400c86

应注意 b vptr1 和 a vptr1 的值都是 0x400c86,该地址对应的函数为 vfoo2(),这进一步验证了前文的推测,事实上,派生类 B 及其基类 A 的实例化对象包括成员函数在内的内存模型如下图所示:

内存模型

该图清晰的描述了C++语言中的派生类是如何继承基类包括虚函数在内的成员的。弄懂了这张图,读者应该能够轻易的分析出派生类 C 的内存模型,这里就不再赘述了。

小结

本文主要在上一节的基础上,进一步分析了C++语言中带虚函数的基类与派生类的内存模型,值得注意的是C++语言中的对象和C语言的结构体有些相似,在追求效率时,也是会执行内存对齐操作的。另外,C++语言在处理虚函数的继承与派生时,的确有一些不同,例如自动分配虚表指针与虚表,共用同一个虚函数等,不过从本质上来看,虚函数又的确没有什么特殊的。

阅读更多:   C++ , C++虚函数
添加新评论

icon_redface.gificon_idea.gificon_cool.gif2016kuk.gificon_mrgreen.gif2016shuai.gif2016tp.gif2016db.gif2016ch.gificon_razz.gif2016zj.gificon_sad.gificon_cry.gif2016zhh.gificon_question.gif2016jk.gif2016bs.gificon_lol.gif2016qiao.gificon_surprised.gif2016fendou.gif2016ll.gif