发布时间:2022-10-27 文章分类:编程知识 投稿人:赵颖 字号: 默认 | | 超大 打印

上一篇博客《C++对象模型:g++实现(一)》用我的理解总结了在无继承体系下g++实现的C++对象的内存布局,这篇就来总结一下在有继承情况下的C++对象的内存布局。
本文中所有代码均可从这里拿到(百度网盘)。

有继承情况下的C++对象的内存布局

C++是支持多继承的,而多继承可能就会出现继承的两个(或多个)类有公共的父类,为了防止在一个对象中出现多个公共父类的实体,C++就提出了虚继承。因此总的来说C++的继承有两种:无虚继承和有虚继承。

无虚继承

在无虚继承情况下,无论是public继承还是protectedprivate继承,其内存布局都是一样的,因此这里只提供public继承的例子。

所有的基类和派生类都没有虚函数

在这种情况下,其实就相当于构造了一个对象,其内部按基类的声明顺序添加了各个基类对象,最后再添加派生类自己的对象,其对齐要求和类作为(非静态)成员变量是一样的。

// test09.cpp
#include <cstdio>
class Base1 {
public:
    Base1(int i, char c)
    :m_i(i), m_c(c)
    {}
private:        // 在Deriverd中:
    int m_i;    //   Offset: 0
    char m_c;   //   Offset: 4
};              //   size: 8
class Base2{
public:
    Base2(short s, int i)
    : m_s(s), m_i(i) {}
private:        // 在Deriverd中:
    short m_s;  //   Offset: 8 
    int m_i;    //   Offset: 10
};              //   size: 8
class Derived: public Base1, public Base2 {
public:
    Derived(int i1, char c, short s, int i2, long l)
    : Base1(i1, c), Base2(s, i2), m_l(l)
    {}
    long getLong() const { return m_l; }
private:
    long m_l;   //   Offset: 16
};              //   size: 8 + 8 + 8 = 24
int main() {
    std::printf("sizeof Derived: %d\n", static_cast<int>(sizeof(Derived)));
    Derived d(1, static_cast<char>(2), static_cast<short>(3), 4, static_cast<long>(5));
    long l = d.getLong();
}
// Output:
//   sizeof Derived: 24

使用gdb调试,查看内存布局,如下:
C++对象模型:g++实现(二)
如果不是按基类整体作为一个(非静态)成员变量的话,Base2::m_s的offset应为6,但实际上其offset为8,因为Base2的对齐要求为4。

基类无虚函数,派生类有虚函数

这个也很简单,只需要在整个类的头部安插一个虚表指针vptr即可。

// test10.cpp
// 只是令Derived::getLong()为虚函数
#include <cstdio>
class Base1 {
public:
    Base1(int i, char c)
    :m_i(i), m_c(c)
    {}
private:        // 在Deriverd中:
    int m_i;    //   Offset: 0
    char m_c;   //   Offset: 4
};              //   size: 8
class Base2{
public:
    Base2(short s, int i)
    : m_s(s), m_i(i) {}
private:        // 在Deriverd中:
    short m_s;  //   Offset: 8 
    int m_i;    //   Offset: 10
};              //   size: 8
class Derived: public Base1, public Base2 {
public:
    Derived(int i1, char c, short s, int i2, long l)
    : Base1(i1, c), Base2(s, i2), m_l(l)
    {}
    virtual                                     // 仅在此有改动
    long getLong() const { return m_l; }
private:
    long m_l;   //   Offset: 16
};              //   size: 8 + 8 + 8 = 24
int main() {
    std::printf("sizeof Derived: %d\n", static_cast<int>(sizeof(Derived)));
    Derived d(1, static_cast<char>(2), static_cast<short>(3), 4, static_cast<long>(5));
    long l = d.getLong();
}
// Output:
//   sizeof Derived: 32
基类也有虚函数

在这种情况下,派生类会继承基类的虚函数,也会复用基类放置虚表指针的内存位置,无论基类有没有定义虚函数,有没有新增虚函数,都不会增加新的虚表指针,而是复用基类的虚表指针的内存位置。

// test11.cpp
// 添加了:
//   virtual char Base1::getChar();
//           int  Base1::getIntOfBase1();
//   virtual char Base2::getShort();
//           int  Base2::getIntOfBase2();
//   virtual int  Derived::getInt();
#include <cstdio>
class Base1 {
public:
    Base1(int i, char c)
    :m_i(i), m_c(c)
    {}
    virtual
    char getChar() const { return m_c; }        // ! 添加
    int getIntOfBase1() const { return m_i; }   // ! 添加
private:
    int m_i;
    char m_c;
};
class Base2{
public:
    Base2(short s, int i)
    : m_s(s), m_i(i) {}
    virtual
    short getShort() const { return m_s; }      // ! 添加
    int getIntOfBase2() const { return m_i; }   // ! 添加
private:
    short m_s;
    int m_i;
};
class Derived: public Base1, public Base2 {
public:
    Derived(int i1, char c, short s, int i2, long l)
    : Base1(i1, c), Base2(s, i2), m_l(l)
    {}
    virtual
    long getLong() const { return m_l; }
    virtual
    int getInt() { return getIntOfBase2(); }    // ! 添加
private:
    long m_l;
};
int main() {
    std::printf("sizeof Derived: %d\n", static_cast<int>(sizeof(Derived)));
    Derived d(1, static_cast<char>(2), static_cast<short>(3), 4, static_cast<long>(5));
    auto c = d.getChar();
    auto s = d.getShort();
    auto i = d.getInt();
}
// Output:
//   sizeof Derived: 40

使用gdb调试编译出来的程序,如下:
C++对象模型:g++实现(二)
我们可以看到只有两个虚函数表,是继承自Base1Base2的。
我们再看一下这两个虚函数表里面的内容:
C++对象模型:g++实现(二)
可以看到Base1的虚表里面,Base1::vptr[0]Base1::getChar()的地址,Base1::vptr[2]Derived::getInt()的地址;在Base2的虚表里面,Base2::vptr[0]Base2::getShort()的地址。
Base1::vptr[1]是什么地址?我们现在还没有看到Derived::getLong()这个虚函数,是它的地址吗?查看该地址处的反汇编代码可知,确实如此。
C++对象模型:g++实现(二)
至此,我们可以总结在基类有虚函数的时候C++的内存布局了:派生类新增的虚函数并不会导致新的虚表指针的诞生,而是会复用第一个虚表指针,使其指向一个新的虚表,这个虚表前几项都是基类的虚函数地址(当然,如果派生类覆写[override]了该虚函数,新的虚函数地址会在该位置覆盖基类的虚函数地址),后面则是派生类的虚函数地址。

指针的调整

为了支持多态,C++支持将派生类的指针赋值给基类的指针,那C++是如何保证这个指针能正确索引到成员变量和成员函数的呢?
其实很简单,在派生类中,有着完整的基类对象,包括基类的虚函数指针,而且虚函数表中的虚函数地址也已经被正确设置,因此只需要把指针调整到该基类对应的位置即可。如:

// file test12.cpp
#include <cstdio>
class Base1 {
public:
    Base1(int i, char c)
    :m_i(i), m_c(c)
    {}
    virtual
    char getChar() const { return m_c; }
    int getIntOfBase1() const { return m_i; }
private:
    int m_i;
    char m_c;
};
class Base2{
public:
    Base2(short s, int i)
    : m_s(s), m_i(i) {}
    virtual
    short getShort() const { return m_s; }
    int getIntOfBase2() const { return m_i; }
private:
    short m_s;
    int m_i;
};
class Derived: public Base1, public Base2 {
public:
    Derived(int i1, char c, short s, int i2, long l)
    : Base1(i1, c), Base2(s, i2), m_l(l)
    {}
    virtual
    long getLong() const { return m_l; }
    virtual
    int getInt() { return getIntOfBase2(); }
private:
    long m_l;
};
int main() {
    Derived d(1, static_cast<char>(2), static_cast<short>(3), 4, static_cast<long>(5));
    Derived* pd = &d;
    // Base1 在Derived类开头,不需要调整指针
    Base1* pb1 = pd;
    // Base2 在Derived类0x10偏移处,
    // pb2 = (pd == nullptr ? nullptr : pd + 0x10)
    Base2* pb2 = pd;
}

反汇编正如注释
C++对象模型:g++实现(二)

有虚继承

按照《深度探索C++对象模型》的描述,对于有虚继承的类,会有一个偏移量值表征当前指针与虚基类基址距离的偏移量。那么这个偏移量在哪里?在对象实例的内存布局中?还是在虚表里面?如果在虚表里面会不会添加新的虚表指针?还是继续复用基类的虚表指针?

// test13.cpp
class Point2D {
public:
    Point2D(int x, int y)
        : m_x(x), m_y(y)
    {}
    virtual
    int getX() const { return m_x; }
private:
    int m_x;
    int m_y;
};
class Point3D: virtual public Point2D {
public:
    Point3D(int x, int y, int z)
    : Point2D(x, y), m_z(z)
    {}
    virtual
    int getZ() {  return m_z; }
private:
    int m_z;
};
class VertexInterface {
public:
    virtual
    VertexInterface* getNext() = 0;
};
class Vertex: virtual public Point2D, public VertexInterface {
public:
    Vertex(int x, int y, Vertex* next)
    : Point2D(x, y), m_next(next)
    {}
    virtual
    Vertex* getNext() override { return m_next; }
private:
    Vertex* m_next;
};
class Vertex3D: public Point3D, public Vertex {
public:
    Vertex3D(int x, int y ,int z, int mumble)
    : Point3D(x, y, z),
      Vertex(x, y, nullptr),
      Point2D(x, y),
      m_mumble(mumble)
    {}
    virtual
    long getMumble()
    { return m_mumble; }
private:
    int m_mumble;
};
int main() {
    Point3D p3d(1,2,3);
    Point3D* pp3d = &p3d;
    Point2D* pp2d = pp3d;
    Vertex vex(1, 2, nullptr);
    Vertex* next = vex.getNext();
    Vertex3D v3d(1, 2, 3, 4);
    Vertex3D* pv3d = &v3d;
    Point2D* pp2d_1 = pv3d;
    int mumble = v3d.getMumble();
}
派生类的所有非虚基类里都没有虚表指针

对上面代码生成的程序使用gdb进行调试。
C++对象模型:g++实现(二)
首先查看各个类对象的size。
我们可以看到,Point2D的size为16,这是可以预见的,虚表指针vptr占用8个字节,两个int类型的非static成员变量占用8个字节,一共16个字节。
Point3D的size为32,虚基类Point2D的size为16,对齐要求为8,自己的一个int类型的非static成员变量占用4个字节,如果没有新添加东西,则加上对齐要求则只需要24字节size,说明必然加了新的东西,要么是基类offset,要么是新的虚表指针。
C++对象模型:g++实现(二)
可以看到虚基类Point2D被放在了offset0x10的位置,而Point3D::m_z在offset0x8,那在offset0x0的位置那里的是什么?可以确定不是虚基类的偏移,因为虚基类的偏移为0x10,那就应该是虚表指针了,那offset会在里面吗?
C++对象模型:g++实现(二)
可以看到在vptr[-3]处有一个0x10的值,那虚基类的偏移量会是在vptr[-3]处吗?,我们看一下反汇编是如何对指针进行操作的。
C++对象模型:g++实现(二)
可以看到汇编代码也正是这样做的,相当于:pp2d = (pp3d == nulltr ? nullptr : pp3d + vptr[-3])。其中vptr为位于Point3D开头,保存虚基类的虚表指针,而不是虚基类Point2D的虚表指针.
也就是说虚基类的偏移量不会保存在对象的内存布局中,而是放在虚表中,而派生类不会复用虚基类的虚表指针(也应该这样,虚基类都找不到怎么用它的虚表指针找偏移量)。而且派生类的虚表指针和新成员变量是放在类的开头的,并不是像没有虚继承那样基类对象在最开头(offset0x0的位置)。
其实在《深度探索C++对象模型》中说的是虚基类的偏移放到了vptr[-1]的位置,但我们知道在g++的实现里这个位置给了该类对应的typeinfo对象,g++实际上将虚基类偏移量放在了vptr[-2]

派生类的非虚基类里都有虚表指针

那么如果派生类还继承(非虚继承)了其他有虚函数表指针的类,那么派生类会不会复用其虚表指针呢?类Vertex正是这样,Point2D是它的虚基类,而VertexInterface是它的普通基类。类Point2D的size为16,VertexInterface的size为8(只包含一个虚表指针)。Vertex还有自己的成员变量m_next,size为8,这样就凑够32个字节了,说明Vertex没有生成自己的虚表指针,会复用非虚基类的虚表指针。
C++对象模型:g++实现(二)
从调试结果来看,g++会在派生类的最前面按照生命顺序放置非虚基类对象,然后放置派生类自己的成员,最后放置虚基类对象。

派生类继承了拥有公共虚基类的两个类

我们来算一下Vertex3D类里面的各个成员及其size,首先Point3D除了虚基类Point2D外还有16字节(虚表指针8,int成员4,对齐4),Vertex除了虚基类Point2D外还有16字节(VertexInterface的虚表指针8,Vertex*成员8),Vertex3D自己的int成员4字节,最后虚基类Point2D的size为16,对齐为8,所以前面要填充4字节一共16+16+4+4+16=56字节。正好是Vertex3D类的大小,所以继承有公共虚基类的两个类不会增加虚表指针,而是直接复用基类的虚表指针,用gdb查看可以看到其内存布局正如上面所言。
C++对象模型:g++实现(二)
所以,对于派生了继承了有公共虚基类的两个类,g++会将非虚基类的所有成员按声明顺序在内存中排列,再将派生类自己的成员放在内存里,最后就是虚基类。
最后,在使用分析类对象的时候,可以使用 p /x {objName}来查看对象内容和各个成员的值,我在写这篇博客的时候就忘记对Vertex3D::m_mumble成员初始化,结果其是一个未初始化的值,而且我还我忘记了Vertex3D内还有这个成员,一直没发现这个是什么值,不知道为什么出现了这未知的8字节,使用p /x v3d命令后才发现是Vertex3D::m_mumble,浪费了很多时间。自己还有很多要提升的地方,gdb的这个用法来自《C++ 虚函数之一:对象内存布局》。最后给大家展示一下p /x v3d后的结果:

(gdb) p /x v3d
$2 = {<Point3D> = {<Point2D> = {_vptr.Point2D = 0x8201b70 <vtable for Vertex3D+96>, m_x = 0x1, m_y = 0x2}, _vptr.Point3D = 0x8201b28 <vtable for Vertex3D+24>,
    m_z = 0x3}, <Vertex> = {<VertexInterface> = {_vptr.VertexInterface = 0x8201b50 <vtable for Vertex3D+64>}, m_next = 0x0}, m_mumble = 0x4}

《深度探索C++对象模型》里还有对于成员指针的讲解,但这篇博文已经很长了,就放在下一篇总结吧。