C++对象模型:g++实现(二)
上一篇博客《C++对象模型:g++实现(一)》用我的理解总结了在无继承体系下g++实现的C++对象的内存布局,这篇就来总结一下在有继承情况下的C++对象的内存布局。
本文中所有代码均可从这里拿到(百度网盘)。
1. 有继承情况下的C++对象的内存布局
C++是支持多继承的,而多继承可能就会出现继承的两个(或多个)类有公共的父类,为了防止在一个对象中出现多个公共父类的实体,C++就提出了虚继承。因此总的来说C++的继承有两种:无虚继承和有虚继承。
1.1 无虚继承
在无虚继承情况下,无论是public
继承还是protected
、private
继承,其内存布局都是一样的,因此这里只提供public
继承的例子。
1.1.1 所有的基类和派生类都没有虚函数
在这种情况下,其实就相当于构造了一个对象,其内部按基类的声明顺序添加了各个基类对象,最后再添加派生类自己的对象,其对齐要求和类作为(非静态)成员变量是一样的。
// 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调试,查看内存布局,如下:
如果不是按基类整体作为一个(非静态)成员变量的话,Base2::m_s
的offset应为6,但实际上其offset为8,因为Base2
的对齐要求为4。
1.1.2 基类无虚函数,派生类有虚函数
这个也很简单,只需要在整个类的头部安插一个虚表指针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
1.1.3 基类也有虚函数
在这种情况下,派生类会继承基类的虚函数,也会复用基类放置虚表指针的内存位置,无论基类有没有定义虚函数,有没有新增虚函数,都不会增加新的虚表指针,而是复用基类的虚表指针的内存位置。
// 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调试编译出来的程序,如下:
我们可以看到只有两个虚函数表,是继承自Base1
和Base2
的。
我们再看一下这两个虚函数表里面的内容:
可以看到Base1
的虚表里面,Base1::vptr[0]
为Base1::getChar()
的地址,Base1::vptr[2]
为Derived::getInt()
的地址;在Base2
的虚表里面,Base2::vptr[0]
为Base2::getShort()
的地址。
那Base1::vptr[1]
是什么地址?我们现在还没有看到Derived::getLong()
这个虚函数,是它的地址吗?查看该地址处的反汇编代码可知,确实如此。
至此,我们可以总结在基类有虚函数的时候C++的内存布局了:派生类新增的虚函数并不会导致新的虚表指针的诞生,而是会复用第一个虚表指针,使其指向一个新的虚表,这个虚表前几项都是基类的虚函数地址(当然,如果派生类覆写[override
]了该虚函数,新的虚函数地址会在该位置覆盖基类的虚函数地址),后面则是派生类的虚函数地址。
1.1.4 指针的调整
为了支持多态,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; }
反汇编正如注释:
1.2 有虚继承
按照《深度探索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(); }
1.2.1 派生类的所有非虚基类里都没有虚表指针
对上面代码生成的程序使用gdb进行调试。
首先查看各个类对象的size。
我们可以看到,Point2D
的size为16,这是可以预见的,虚表指针vptr
占用8个字节,两个int
类型的非static
成员变量占用8个字节,一共16个字节。
而Point3D
的size为32,虚基类Point2D
的size为16,对齐要求为8,自己的一个int
类型的非static
成员变量占用4个字节,如果没有新添加东西,则加上对齐要求则只需要24字节size,说明必然加了新的东西,要么是基类offset,要么是新的虚表指针。
可以看到虚基类Point2D
被放在了offset0x10
的位置,而Point3D::m_z
在offset0x8
,那在offset0x0
的位置那里的是什么?可以确定不是虚基类的偏移,因为虚基类的偏移为0x10
,那就应该是虚表指针了,那offset会在里面吗?
可以看到在vptr[-3]
处有一个0x10
的值,那虚基类的偏移量会是在vptr[-3]
处吗?,我们看一下反汇编是如何对指针进行操作的。
可以看到汇编代码也正是这样做的,相当于:pp2d = (pp3d == nulltr ? nullptr : pp3d + vptr[-3])
。其中vptr
为位于Point3D开头,保存虚基类的虚表指针,而不是虚基类Point2D
的虚表指针.
也就是说虚基类的偏移量不会保存在对象的内存布局中,而是放在虚表中,而派生类不会复用虚基类的虚表指针(也应该这样,虚基类都找不到怎么用它的虚表指针找偏移量)。而且派生类的虚表指针和新成员变量是放在类的开头的,并不是像没有虚继承那样基类对象在最开头(offset0x0
的位置)。
其实在《深度探索C++对象模型》中说的是虚基类的偏移放到了vptr[-1]
的位置,但我们知道在g++的实现里这个位置给了该类对应的typeinfo
对象,g++实际上将虚基类偏移量放在了vptr[-3]
。
1.2.2 派生类的非虚基类里都有虚表指针
那么如果派生类还继承(非虚继承)了其他有虚函数表指针的类,那么派生类会不会复用其虚表指针呢?类Vertex
正是这样,Point2D
是它的虚基类,而VertexInterface
是它的普通基类。类Point2D
的size为16,VertexInterface
的size为8(只包含一个虚表指针)。Vertex
还有自己的成员变量m_next
,size为8,这样就凑够32个字节了,说明Vertex
没有生成自己的虚表指针,会复用非虚基类的虚表指针。
从调试结果来看,g++会在派生类的最前面按照生命顺序放置非虚基类对象,然后放置派生类自己的成员,最后放置虚基类对象。
1.2.3 派生类继承了拥有公共虚基类的两个类
我们来算一下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查看可以看到其内存布局正如上面所言。
所以,对于派生了继承了有公共虚基类的两个类,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++对象模型》里还有对于成员指针的讲解,但这篇博文已经很长了,就放在下一篇总结吧。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通