刚看完了《深度探索C++对象模型》第三章,这里做一下总结,也写一下我自己在g++ 7.5.0上的验证。

本文中所有的源文件都可以在这里拿到(百度网盘链接)。
注意,这里所说的“对象”是指在C++中使用classstruct关键字创建的类的实例。

每篇一首歌

1. 无继承情况下的C++对象内存布局

首先当然是从最基础的情况来讲,在没有继承的情况下的C++对象内存布局是什么样的?这又分为两种:无虚函数和有虚函数。

1.1 无虚函数

C++类内成员变量分为两类:static成员变量和非static成员变量。static成员变量不在类的实例的内部,在整个内存中只有一份,只需要使用类名即可访问;而非static成员变量在类的实例内部,需要为其分配空间。
在这种情况下C++的对象和C的结构体是一样的,毕竟要实现和C的兼容,主要就是结构体/类内成员变量的对齐。
其一般规则总结如下:

  1. 所有成员按照在类内的声明顺序在内存中排列;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // test00.cpp
    #include <iostream>

    int main();

    class Test00 {
    friend int main();
    public:
    int i1;
    private:
    int i2;
    public:
    int i3;
    };

    #define showOffset(ClassName, memberName) (reinterpret_cast<unsigned long>( &(static_cast<ClassName*>(nullptr)->memberName)))

    int main() {
    std::cout << showOffset(Test00, i1) << std::endl;
    std::cout << showOffset(Test00, i2) << std::endl;
    std::cout << showOffset(Test00, i3) << std::endl;
    }

    // Output:
    // 0
    // 4
    // 8
  2. 任一非static成员变量的偏移(offset)要是其大小的倍数;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // test01.cpp
    #include <iostream>
    struct Test01 {
    char c;
    int i; // 如果紧凑排列,则i的偏移为1,但i的size为4,偏移要是4的倍数,因此i的偏移为4
    };

    #define showOffset(ClassName, memberName) (reinterpret_cast<unsigned long>( &(static_cast<ClassName*>(nullptr)->memberName)))

    int main() {
    std::cout << showOffset(struct Test01, i) << std::endl;
    }

    // Output:
    // 4
  3. 结构体整体的size需要为最大非static成员变量size的倍数;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // test02.cpp
    #include <iostream>

    // 如果紧凑排,Test02_1的size应为9,
    // 但要与int(size为4)对其,所以其size为12
    struct Test02_1 {
    char c1; // Offset: 0
    int i; // Offset: 4
    char c2; // Offset: 8
    };

    // Test02_2成员和Test02_1相同,但顺序不同,
    // 受规则2和3影响,其size为8
    struct Test02_2 {
    char c1; // Offset: 0
    char c2; // Offset: 1
    int i; // Offset: 4
    };

    int main() {
    std::cout << "sizeof Test02_1: " << sizeof(Test02_1) << std::endl;
    std::cout << "sizeof Test02_2: " << sizeof(Test02_2) << std::endl;
    }

    // Output:
    // sizeof Test02_1: 12
    // sizeof Test02_2: 8
  4. 空对象的size为1,为了保证每个对象都有唯一的内存位置(memory location)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // test03.cpp
    #include <iostream>

    struct Test03 {}; // Empty class

    int main() {
    Test03 a, b;
    std::cout << "sizeof Test03: " << sizeof(Test03) << std::endl;
    if (&a == &b)
    std::cerr << " Error! &a == &b, at " << static_cast<void*>(&a) << std::endl;
    else
    std::cout << "a and b has different address, &a = " << static_cast<void*>(&a) << " and &b = " << static_cast<void*>(&b) << std::endl;
    }

    // Output:
    // sizeof Test03: 1
    // a and b has different address, &a = 0x7fffe62e8486 and &b = 0x7fffe62e8487
  5. 当类(class)/结构体(struct) A 作为一个类B的内部成员变量时,其对齐要求为类A内部最大的对齐要求;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // test04.cpp
    #include <iostream>

    // 规则2中的类,size为8,对齐要求为4
    struct Test01{
    char c;
    int i;
    };

    struct Test04 {
    char c; // Offset: 1, size 1
    Test01 t; // Offset: 4, size 8
    };

    #define showOffset(ClassName, memberName) (reinterpret_cast<unsigned long>( &(static_cast<ClassName*>(nullptr)->memberName)))

    int main() {
    std::cout << "Offset of t in struct Test04: " << showOffset(Test04, t) << std::endl;
    std::cout << "sizeof Test04: " << sizeof(Test04) << std::endl;
    }

    // Output:
    // Offset of t in struct Test04: 4
    // sizeof Test04: 12
  6. 空的类(empty class)A作为作为一个类B的成员变量时,类A占用一个字节的空间,对其要求也为1;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // test05.cpp
    #include <iostream>

    // 规则4中的类,空类,size = 1
    struct Test03 {};

    struct Test05 {
    char c; // Offset: 0, size: 1
    Test03 t; // Offset: 1, size: 1
    };

    #define showOffset(ClassName, memberName) (reinterpret_cast<unsigned long>( &(static_cast<ClassName*>(nullptr)->memberName)))

    int main() {
    std::cout << "Offset of t in struct Test04: " << showOffset(Test05, t) << std::endl;
    std::cout << "sizeof Test05: " << sizeof(Test05) << std::endl;
    }

    // Output:
    // Offset of t in struct Test04: 1
    // sizeof Test05: 2

1.2 有虚函数

C++使用虚函数来实现多态,非虚函数不展现多态性,当调用非虚函数时,只要调用一个写死的地址即可,无论是使用对象调用还是使用指针/引用调用;而当使用指针/引用调用虚函数需要视其绑定到的实际对象来调用对应的虚函数,以展现多态性(用对象调用虚函数不展现多态性)。
而C++实现虚函数用到的便是虚表。所谓虚表,就是保存该类所有虚函数地址的一张表,一个类的某个确定的虚函数在虚表的确定位置,而类实例中有一个虚表指针指向该虚表,当出现类继承并覆写(override)了该虚函数时,只需要将虚表指针指向另一张虚表,该虚表中对应位置的函数指针换为新的函数即可。另外,一个类的所有对象共享同一张虚表,因此不会带来大的内存消耗。该虚表由编译器生成。
这里只是对于虚函数和虚表进行了简单的描述,详细可查询网络资源,这里不再赘述。
就像上面所说,相比于没有虚函数的类,由虚函数的类的实例只是多了一个指向虚表的指针,其放在类的开头或者结尾(g++将其放在类的开头),大小和对其要求视平台而定,在x86-64平台上,虚表指针大小和对其要求为8字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// test06.cpp
class Point {
public:
Point(int x)
:m_x(x)
{}

virtual
int getX()
{ return m_x; }

private:
int m_x;
};


int main() {
Point p(1);
int x = p.getX();
}

gdb调试图片1
使用gdb观察,可以看到Point类实例p的size为16,包括size为8的虚表指针和size为4的int类型的成员变量m_x,同时,由于虚表指针的对其要求为8,所以Point的size必须是8的倍数,所以其size为16。
同时查看p的内存布局,可以看到虚表指针被放置于类实例的头部,占用8个字节,后面紧跟4个字节的int类型的成员变量m_i,最后填充了4个字节以使类Point的size为8的倍数。
gdb调试图片2
我们在查看一下虚表指针指向的内存,我这里使用的是64位系统和程序,所以函数指针是8位大小,虚表指针指向的虚表的第一个表项是地址0x080007b2,同时查看反汇编,因为我们使用对象来调用虚函数,不展现多态性,这里直接call了Point::getX()的地址,可以看到其地址为0x080007b2,正好是前面虚表的第一个表项。
还有就是Point类型对应的typeinfo对象的地址,在《深度探索C++对象模型》中提到其位于虚表的第一个表项,但前面我们看到虚表第一个表项存放的是虚函数,那typeinfo的地址放在哪里呢?我们来找一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// file test07.cpp
#include <typeinfo>
2 class Point {
3 public:
4 Point(int x)
5 :m_x(x)
6 {}
7
8 virtual
9 int getX()
10 { return m_x; }
11
12 private:
13 int m_x;
14 };
15
16 int main() {
17 Point p(1);
18 auto& ti = typeid(p);
19 int x = p.getX();
20 }

gbd调试图片3
可以看到反汇编中保存了0x8200da8这一地址到栈上,再结合我们的源码,很可能gdb所提示的<_ZTI5Point>这一对象就是Point类的typeinfo对象,我们使用工具c++filt来看_ZTI5Point这个被修饰过的符号是什么含义,不出所料,正是Point类对应的typeinfo对象。

1
2
liuyun@DESKTOP-Q5AT31V:/tmp/test/cppObjectModel/chap03/blog$ c++filt _ZTI5Point
typeinfo for Point

既然Point对应的typeinfo对象的地址为0x8200da8,我们查看虚表附近的地址,发现虚表指针指向的地址的前面的一个QWORD的内容正好是typeinfo的地址,那是不是虚表指针指向的并不是虚表的开头,而是第一个虚函数所在的地址,而在虚表中,第一个虚函数这一表项前面便是该类对应的typeinfo的地址?
g++使用-fdump-class-hierarchy选项生成类信息
在查阅资料的时候,《C++虚函数之二:虚函数表与虚函数调用》这篇博客提到g++支持-fdump-class-hierarchy这一编译选项,可以生成一个名为{source_file_name}.002t.class的文件,文件中详细记录了各个类的信息,包括其虚表信息。
正如我们所想,如果我们使用vptr指代虚表指针,那么vptr[0]就是第一个虚函数的地址,vptr[-1]则是该类对应的typeinfo的地址,而在最前面,g++还填充了一个空的表项。

最后还有一个问题,再没有虚函数的时候,编译器为了让每一个对象都有自己独一无二的地址,会在对象中插入一个字节占位,而在有虚函数的时候类中会有一个原生的虚表指针vptr,从而至少占8字节大小(x86-64上),那么是否就不需要再插入一个字节了呢?事实正如我们所想,Test08类的size为8而不是16。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// test08.cpp
#include <iostream>

class Test08 {
public:
virtual
int getNumber() { return s_i++; }

private:
static int s_i;
};

int Test08::s_i = 0;

int main() {
std::cout << "sizeof Test08: " << sizeof(Test08) << std::endl;
Test08 t;
int i = t.getNumber();
}
// Output:
// sizeof Test08: 8

这一篇博客就先写到这里,下一篇再谈谈在继承体系下g++是如何实现C++对象的内存布局的。