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} |