C++对象内存布局初探(1)

Author Avatar
Nightn 8月 05, 2018
  • 在其它设备中阅读本文章

C++ 对象内存布局是一个深入而有趣的话题。sizeof 可以返回对象占用的内存大小,那具体存每个字节的是什么呢?对象的哪些成员会直接放到对象中,哪些不会?每个成员的排布顺序是怎么样的?当发生继承或复合时,子类对象的内存布局又是怎样的?虚函数的实现机制是什么?动态绑定在内存中是怎么体现?如何避免重复继承?「C++对象内存布局初探」系列便尝试着从内存布局的角度去回答这些问题,这是本系列的第一篇。

在本篇文章中,我将对 5 类不同情况下的对象布局进行较为详细的介绍,通过 代码 + UML 图 + 实际内存布局 + 结论分析 的流程对每一种情况进行讨论。这 5 种情况包括:

  • 没有继承的情况
  • 单一继承(不包含 virtual function
  • 单一继承(包含 virtual function
  • 多重继承
  • 虚继承

示例代码的运行环境:

  • 操作系统: win10 专业版
  • 编译器:GCC4.9

一、 最简单情况:没有继承

1. 代码及 UML 图

class Point2d {
public:
    // constructor(s)
    // operators
    // access functions
private:
    long long x = 0x11;
    long long y = 0x22;
};

class Point3d {
public:
    // constructor(s)
    // operators
    // access functions
private:
    long long x = 0x1111;
    long long y = 0x2222;
    long long z = 0x3333;
};

1533467905287

2. 内存布局

1533467946831

3. 结论

Point2dPoint3d 是两个独立的类,它们实例化出来的对象仅包含 non-static data member 。对于各个数据成员在内存中的排列顺序,C++标准并没有强制规定。但大部分编译器实现中,都按照数据成员声明顺序来进行对象的内存布局 (如上图所示)。

二、单一继承不含 virtual function

Point3d 公开继承 Point2d ,并且两个类中都没有 virtual function

1. 代码及 UML 图

class Point2d {
public:
    long long get_x() const { return x; }
    long long get_y() const { return y; }
private:
    long long x = 0x11;
    long long y = 0x22;
};

class Point3d: public Point2d {
public:
    long long get_z() const { return z; }
private:
    long long z = 0x3333;
};

1533468789392

2. 内存布局

1533468881218

3. 结论

在继承体系中,子类对象包含了父类实体部分(subobject),而且 subobject 位于前面,而后是子类中的成员。

三、单一继承并含 virtual function

假如我们现在要对点进行绘制,显然无论是 Point2d 还是 Point3d 都需要可以绘制,但它们绘制的实现不同。因此,我们在 Point2d 中定义名为 drawvirtual function 。另外,我们单独在 Point3d 单独定义一个名为 rotate3dvirtual function

1. 代码及 UML 图

class Point2d {
public:
    long long get_x() const { return x; }
    long long get_y() const { return y; }
    virtual void draw() { cout << "Point2d::draw()" << endl; }
private:
    long long x = 0x11;
    long long y = 0x22;
};

class Point3d: public Point2d {
public:
    long long get_z() const { return z; }
    void draw() override { cout << "Point3d::draw()" << endl; }
    virtual void rotate3d() { cout << "Point3d::draw()" << endl; }
private:
    long long z = 0x3333;
};

1533471900774

2. 内存布局

3. 结论

上面的内存布局图咋看之下有点复杂,不用怕,容我慢慢道来。上图分为上下两部分,上半部分是与 p2 对象相关的内存布局,下半部分是与 p3 对象相关的内存布局。每个部分又包括了对象本身和 vtbl 。什么是 vtbl ,什么又是 vptr ,为什么多了虚函数,对象的内存布局就变得这么复杂了呢。我先简单的解释一下。

考虑一个简单的继承体系:Base 为父类,Derived1Derived2 都公开继承 Base 。且 Base 类中定义了虚函数,子类们可以重写(override)父类定义的虚函数。虚函数是 C++ 用来支持多态的一种函数,所谓多态,是通过动态绑定实现的。当我们通过一个父类指针(如 Base* pb)或引用去调用虚函数时,便会发生动态绑定,即静态类型为 Base ,动态类型由运行时 pb 所绑定的对象决定,绑定不同类型的对象,所调用的虚函数版本也不同,看起来父类指针具有多种状态,具体是什么状态需要等到运行时进行动态绑定,这就是多态。多态就是通过虚函数表 vtbl 实现的。

每一个带有虚函数的类,它所实例出来的对象,除了包含自身的 data member 外,还有一个指针 vptr ,全称是 virtual function table pointer ,即虚函数表指针,顾名思义,它里面存放了这个类的虚函数表的地址。如上图所示,p2 对象的内存布局中,除了包含 p2.xp2.y ,还包含了一个 vptr ,其值为 00 00 00 00 00 49 15 b0 ,它是 Point2d 虚函数表的地址,我们顺着这个地址找到了对应的虚函数表,即图中的 vptr of Point2d ,这个虚表存放的便是 Point2d 所有的虚函数。同理,p3 对象的内存布局也是类似的,不同的是,Point3d 的虚函数表包含了两个虚函数:draw()rotate3d() 。非常重要的一点,Point3d 虚函数表中的第一个虚函数是 Point3d::draw() ,而不是父类版本的 draw() ,这是由于子类重写了父类的虚函数。关于虚函数表,还有几个细节值得注意:

  • 虚函数表的结尾标志。从上图可以看到,虚函数表的结尾标志是一个值为 0,大小为 8 bytes 的一个块。GCC 是这样的,但是,并不是每一个编译器都是如此。
  • 虚表的 -1 位置。细心的读者可能会发现,虚表第一个虚函数的上面还有一个地址,它其实指向的是这个类的 type_info 对象,type_info 对象描述了类的简要信息(如 name, hash_code 等),用以支持 RTTI(runtime type identification) 。我们使用的 typeid 运算符,应该就是通过访问 type_info 对象实现的。
  • 虚表是类层面上的,而不是对象层面上的。即同一个类的不同对象,共享同一个虚表。

以下通过一个简单的例子,结合上面的内存布局图,解释对象是如何与虚表进行交互,实现多态的。

Point2d p2;
Point3d p3;
Point2d* ptr = &p2;
ptr->draw();   // 调用 Point2d::draw()
ptr = &p3;
ptr->draw();   // 调用 Point3d::draw()

同样是 ptr->draw() ,为什么每次调用的版本不一样呢?首先,定义了 p2p3 对象,如上图所示,这两个对象的地址分别为 0x6ffe300x6ffe10ptr 是一个指向父类 Point2d 的指针。一开始,将 p2 的地址初始化 ptr ,即当前 ptr 指向 p2 对象,当执行到 ptr->draw() 时,由于 draw() 是虚函数,因此会去 p2 对象中的 vptr 所指的虚表中查找,虚表地址为 0x4915b0 ,所以调用的是父类的虚函数。然后,将 ptr 重新赋值为 p3 的地址,此时再执行 ptr->draw() 时,会去 p3 对象中的 vptr 所指虚表中查找,虚表地址为 0x4915d0,所以调用的是子类的虚函数。

虚函数表的引入实现了高弹性的多态,但也引入了额外的负担。

  • 对象要增加一个 slot 作为 vtpr ,这样才能确保对象能够找到 vtbl
  • 带有虚函数的类都需要有一个关联的 vtbl ,存放了所有虚函数的指针,以及再提供必要的 slot 作为分隔符,以及支持 RTTI
  • 构造函数需要扩展。对象新增了一个 vptr 隐式成员,显然,它也需要初始化,编译器在合成默认构造函数或扩展现有构造函数的时候,必定会添加初始化 vptr 的代码。
  • 析构函数需要扩展。也是为了处理额外的 vptr 隐式成员。

四、多重继承

1. 代码及 UML 图

class Base1 {
public:
    long long x = 0x1111;
    virtual void f1() { cout << "Base1::f1()" << endl; }
    virtual void g1() { cout << "Base1::g1()" << endl; }
};

class Base2 {
    long long x = 0x2222;
    void f2() { cout << "Base2::f2()" << endl; }
};

class Base3 {
    long long x = 0x3333;
    virtual void f3() { cout << "Base3::f3()" << endl; }
};

class Derived: public Base1, public Base2, public Base3 {
    long long x = 0x8888;
    void f1() override { cout << "Derived::f1()" << endl; }
    virtual hello() { cout << "Derived::hello()" << endl; }
    virtual bye() { cout << "Derived::hello()" << endl; }
};

1533477298831

2. 内存布局

3. 结论

由内存布局图,可以得出以下结论:

  • 多重继承体系中的子类,每多继承一个带有虚函数的父类,就会在子类对象中多一个 vptr 。上图中,Base1Base3 都带有虚函数,因此在子类的 Base1 subobjectBase3 subobject 各有一个 vptr ,为了后面叙述方便,我将它们分别记作 vptr1vptr2 ,它们所指向的虚函数表分别记作 vtbl1vtbl2 。由于 Base2 不带有虚函数,所以 Base2 subobject 仅包含数据成员 Base2::x
  • 多重继承体系中的子类,其对象中各个 subobject 中的顺序与继承声明列表的顺序一致。上图中子类对象的内存布局从上到下分别为:Base1 subobject, Base2 subobject, Base3 subobject 以及 Derived self part
  • 如果子类继承了多个带有虚函数的父类,且子类中也有专属于自己的虚函数(如 Derived::hello()Derived::bye()),那么,专属子类的虚函数会附加在继承列表中第一个带有虚函数父类的虚函数表中。如图中所示,Derived::hello()Derived::bye() 都位于 vtbl1 中。
  • 如果子类重写了父类的虚函数,则子类版本的虚函数会将虚表中父类版本的虚函数替换掉。如图所示,vtbl1 中的虚函数顺序,一开始我们可以想象成这样:Base1::f1(), Base1::g1(), Derived::hello(), Derived::bye() 。子类定义了重写了 f1() ,所以 vtbl1 的顺序变成了现在这样:Derived::f1(), Base1::g1(), Derived::hello(), Derived::bye() 。这便是子类重写父类虚函数的内在原理。
  • 每一个虚表的 -1 位置都带有 type_info 对象的指针,GCC 中,多个父类的虚表是连续存储的,最后一个虚表末尾是 0 ,倒数第二个虚表末尾是 -8 ,然后是 -16 , -24 … 以此类推。

TODO: 虚继承