揭秘C++对象模型:虚表与内存布局
目录
1.什么是c++对象模型
在这里引用《深度探索C++对象模型》这本书中的话:
有两个概念可以解释C++对象模型:
语言中直接支持面向对象程序设计的部分。
对于各种支持的底层实现机制。
关于“语言中直接支持面向对象程序设计的部分”,大家都耳熟能详:比如说c++当中的类,包括类中的构造函数、析构函数、继承、多态等等这些相关语法。他们使程序员们可以面向对象编程。
而对于“各种支持的底层实现机制 ”我们听的则并不是很多,比如虚函数表,虚基类表,以及对象(类实例)的内存布局,他们在底层支持了面向对象的各种特性(继承,多态等)。在这篇文章中,我主要介绍的是这部分的一些内容(虚函数表,虚基类表和对象内存布局)。
2.查看对象结构模型(内存布局)
在正式开始本文的主题之前,我想先给大家介绍如何在VS下直接查看对象的结构模型,也就是内存布局:
为了查看对象的结构模型,我们需要在编译器配置时做一些初始化。在VS2010中,在项目——属性——配置属性——C/C++——命令行——其他选项中添加选项:“/d1reportAllClassLayout”。再次编译时候,编译器会输出所有定义类的对象模型(在输出栏查看)。由于输出的信息过多,我们可以使用“Ctrl+F”查找命令,找到对象模型的输出。
通过这种方式查询到的对象结构模型的形式如下:

从上面的模型我们可以得出这些信息:
- Base对象的大小是16字节。
- 在Base对象的内存空间中,第一个成员是vfptr(指向虚函数表的指针),偏移量是0;第二个成员是_var1变量,偏移量是8。
- 为了内存对齐,在对象中插入了4字节的空数据(<alignment member>)。
- &Base::Base_meta表示RTTI(运行时类型信息,这不是本文的重点)以及当前类的信息的地址,它不在虚函数表中,但在虚函数表所处空间的上方。
- &Base::Base_meta 下面的0 也是一种偏移量,这里不做讨论。
- 在虚函数表中,只有存储了一个虚函数的地址,也就是Base::f,它在虚函数表中的偏移量为0。
- Base::f this adjustor: 0 表示调用该虚函数时this指针的调整量,用于支持多态。下文中我会有所提及。
这里我提前和读者说一下,观察vs呈现出的对象结构模型时,会遇到一些无法理解,我却没有解释的东西,比如thunk,adjuster,比如上面说的&Base::Base_meta 下面的0偏移。这些都是支撑上层的底层的机制,乃至于优化。它们对于我或者大多数人来说还过于复杂,我也并没有搞明白,因此读者在阅读过程中还是要抓大放小,感谢阅读。
除了上面的方式,实际上,还可以通过地址访问来验证对象内存的布局:
//x64环境使用对象指针访问虚函数表
int main()
{
typedef void(*Func) (Base*);//定义一个要调用的成员函数类型的函数指针,记得要加Base*参数,不加的话,
//如果成员函数访问类的其他成员,程序就会崩溃,this指针是野指针嘛
Base base;
Func func = (Func) * ((long long*)*(long long*)(&base) + 0);//取虚函数表中第一个虚函数的地址
func(&base);//调用第一个虚函数
}
解释一下取虚函数表第一个虚函数地址的过程:
- (long long*)(&base)//将base的地址强转成long long*
- *(long long*)(&base)//从base对象的开头处取出sizeof(long long)个字节,即取出虚函数表的地址
- (long long*)*(long long*)(&base)//将虚函数表的地址强转为long long*
- (long long*)*(long long*)(&base) + 0//对虚函数表的地址偏移,每次偏移sizeof(long long)个字节,偏移量帮我们定位到我们想取的虚函数地址的所在位置。
- * ((long long*)*(long long*)(&base) + 0)//在定位到的位置取出sizeof(long long)个字节,也就是我们想取的函数的地址。
- (Func) * ((long long*)*(long long*)(&base) + 0)//把地址类型从long long强转为函数类型
这种方式相对来说比较麻烦,本文就尽量不使用这种方式讲解了。
3.多态与虚函数与虚函数表与虚基类表
在查看对象内存布局之前,总得先了解一下一下虚函数表和虚基类表,但是我在这里只介绍一个大概,具体它两的作用我则融合在对象内存布局中,更易于理解。
虚函数——用virtual关键字修饰的函数:
- 在父类(包括父类的父类......)中的虚函数,在子类中依然是虚函数,并且子类可以重写父类的虚函数。
- 所有虚函数的地址都会被存放在虚函数表中
虚函数表:
- 每个有虚函数的类或者其父类(包括父类的父类......)有虚函数的类,都有自己的虚函数表,类的所有实例共用这一张表。
- 虚函数表中存放了该类及其父类中的所有虚函数的地址。
- 拥有虚函数的类的实例化对象都存放着指向这个虚函数表的指针(vfptr)。
- 一个类的虚函数表可能不止一个。
- 虚表在编译阶段就生成了,储存在代码段中,而指向虚表的指针初始化是在创建对象时构造函数的初始化列表中完成。虚表存储在代码段。
虚基类:
- 如果一个父类被子类以虚继承方式(virtual关键字修饰)继承,该父类就叫做这个子类的虚基类。
虚基类表:
- 如果某个子类的多个父类同时继承了同一个爷爷类(最典型的就是菱形继承),并且这个爷爷类是父类们的虚基类,那么在这个子类的对象中,只会保存一份这个虚基类子对象,而不是多份。而原来父类子对象中本应有虚基类子对象的位置都替换成一张指向虚基类表的指针(vbptr)。这解决了虚基类子对象的冗余性和使用时的二义性。
- 虚基类表存储两种数据,第一种就是虚基类表指针相对于其所在父类子对象起始位置的偏移量,第二种就是从当前父类子对象到虚基类子对象的偏移量。
可能读者会听的云里雾里,比如什么是子对象呀,那是因为读者还不了解对象内存布局,结合下面的讨论就会豁然开朗。
4.对象内存布局
在c++的类中,成员变量有两种:static成员变量,nostatic成员变量(普通成员变量)。成员函数有三种:static成员函数,vitual成员函数,nostatic成员函数(普通成员函数)。如下图:

无论什么样的内存布局,我们知道,static成员变量以及任何成员函数都是不存放在对象中的。实际上,一个对象中一般只可能有3种成员,nostatic成员变量,vfptr(指向虚函数表的指针),vbptr(指向虚基类表的指针)。因此其他类型的成员我不在类中体现,我只是在类中定义了nostatic成员变量,和与vfptr和vbptr息息相关的虚函数。
在下面我们定义一个基类作为研究对象:
class Base
{
public:
virtual void f() { cout << "调用 Base::f()..." << endl; }//virtual function
private:
int _var1;//nostatic member
};
4.1.非继承下的对象内存布局
关于非继承下的对象内存布局,我们只需要查看一下Base类的对象内存布局即可。下面是Base类对象的内存布局:

这样可能不够直观,转换成图形如下:

对象空间的最前面存放的是指向Base类的虚函数表的指针,下面存放的是Base对象的nostatic成员变量(当然,如果我没有在Base类中定义虚函数,那么这个Base对象的内存中也就只剩下成员变量这一项了)。由此我们可以得出,在非继承下,对象的前面部分用来存放vfptr,剩下部分用来存放nostatic成员变量
虚函数表中只有一个虚函数的地址,也就是Base::f。Type_info是指向运行时类型信息,它不属于虚函数表中的内容,而是在虚函数表空间的上方。虚函数表中最后的0表示虚函数表的结束,就像字符串用‘\0’表示结尾,然而这个结束符这不是固定的,有的编译器也会用1或者其他什么表示结尾。
4.2.单继承下的对象内存布局
为了模拟单继承的情况,首先我们得创建出一个继承自Base类的子类:
class Left :public Base
{
public:
virtual void f(){ cout << "调用 Left::f()..." << endl; }
virtual void g() { cout << "调用 Left::g()..." << endl; }
private:
int _var2;
};
Left类不仅重写了Base类的虚函数,还创建了自己的虚函数,下面是Left类的对象内存布局:

还是画图来理顺一下这个布局:

首先可以看到,在Left对象中,有一块区域被称为Base子对象,所以我来啰嗦一下:实际上,在继承体系中,不管继承了几个类,继承的层次有多深,在子类对象中,属于同一个父类的成员总是集中在一块的,所以我们可以把子类对象中属于父类的一块叫做该子类对象的子对象。
然后我们分析这个内存布局可以得出,父类子对象(也就是Base子对象)被放在子类对象(Left对象)内存的前面部分,子类对象中非继承而来的成员变量(_var2)被紧跟着放在子类对象的后面部分。而子类的虚函数表的指针则是放在父类子对象内部的最前面部分。
在这样设计的情况下,在子类对象(Left对象)中仍然可以找到完整的父类对象(Base),这有助于多态的实现。
对于子类的虚函数表,其实就是把父类的虚函数表拷贝一份,然后再在后面追加上自己创建的虚函数的地址。并且如果子类重写了父类的某虚函数,那么会将原本虚函数表中的该虚函数的地址覆盖成子类重写的虚函数的地址,不再是父类的该虚函数的地址,当然如果没有重写,那就还是父类的。比如上面的例子中,子类重写父类的f函数后,子类的Left::f函数覆盖了虚函数表中的Base::f函数。
这里总结一下单继承下类对象内存布局:

4.3.多继承下的对象内存布局
为了模拟多继承的情况,我定义了几个类:
class Base
{
public:
virtual void f() { cout << "调用 Base::f()..." << endl; }//virtual function
private:
int _var1;//nostatic member
};
class Base2
{
public:
virtual void f2() { cout << "调用 Base2::f2()..." << endl; }
private:
int _var2;
};
class Left :public Base, public Base2
{
public:
virtual void f(){ cout << "调用 Left::f()..." << endl; }
virtual void f2() { cout << "调用 Left::f2()..." << endl; }
virtual void g() { cout << "调用 Left::g()..." << endl; }
private:
int _var3;
};
现在可以来看看Left的对象内存布局:
还是来画个图表示一下Left对象内存布局:

首先可以发现,在子类对象中,多个父类子对象依次排列存储在子类对象内部,而子类特有的成员则被放在在子类对象的最后。
其次,子类中不只有一张虚函数表了,每个父类子对象中都有一个vfptr指向一个虚函数表,每个父类子对象中的vfptr指向的虚函数表都是把对应父类的虚函数表拷贝过来一份,如果子类重写了某父类的某虚函数,就进行覆盖。而子类新定义的虚函数的地址追加了到了第一个父类子对象中的vfptr指向的虚函数表的末尾。
这里提一句,如果多个父类有同样签名(签名就是标识一个函数的信息组合,函数的“身份证”)的虚函数,子类又重写了该虚函数,那么这些父类子对象中的vfptr指向的虚函数表中相应虚函数地址都会被覆盖,不是只覆盖一处。
借着这个例子,我乘机给读者介绍一下虚函数表的使用。
Left x;
Base* p = &x;
Base2* p2 = &x;
cout << "p的值:" << p << endl;
cout << "p2的值:" << p2 << endl;
p->f();
p2->f2();

我来给读者解释一下上面的例子:
将Left对象也就是x的地址分别赋给Base,Base2类型得指针:p,p2。从结果可以看出,p和p2的值是不一样的,且正好相差16字节。由此可以得出,编译器对于父类指针接收子类地址做了特殊处理,结果就是p指向x对象中Base子对象的起始位置,p2指向x对象中Base2子对象的起始位置。
接下来用p调用f,用p2调用f2。而虚函数调用时先要查询当前对象的虚函数表,于是系统分别在Base子对象,Base2子对象的vfptr指向的虚函数表查询各自调用的函数。查到了Left::f和Left::f2,接着直接调用了这两个函数,这从输出框也看得出来。
不知不觉中,我们通过p,p2两个父类指针调用了子类重写的虚函数,而不是父类的。现在我们知道了,虚函数表如何发挥作用,使我们能用父类指针调用子类重写的虚函数。
再看一个例子:
class Base
{
public:
virtual void f() { cout << "调用 Base::f()..." << endl; }
private:
int _var1;
};
class Left :public Base
{
public:
virtual void f() { cout << "调用 Left::f()..." << endl; }
};
class Right :public Base
{
public:
virtual void f() { cout << "调用 Right::f()..." << endl; }
};
void func(Base*p)
{
p->f();
}
int main()
{
Left l;
Right r;
func(&l);
func(&r);
}

Left和Right两个子类都继承了Base父类,各自重写了父类虚函数f,因此在Left对象和Right对象中,Base父类子对象中的vfptr指向的虚函数表中的函数地址指向的函数实现不同。
第一次调用,p指向Left对象中的Base子对象,第二次调用,p指向Right对象中的Base子对象,访问虚函数表所得的f函数的实现不同,也就导致了不同的结果。
这是多态的一个经典例子。
4.4.多层继承下的对象内存布局
首先写出模拟多层继承的代码:
class Base
{
public:
virtual void f() { }
private:
int _var1;
};
class Base2 :public Base
{
public:
virtual void f() { }
virtual void f2() { }
private:
int _var2;
};
class Base3 :public Base2
{
public:
virtual void f1() { }
virtual void f2() { }
virtual void f3() { }
private:
int _var3;
};
得到的Base3的对象内存布局如下图:

可以把多层继承拆分成多次的单继承来看待,Base3的对象内存布局用图形表示为:

布局和单继承一样,只是单继承中又嵌套了一层单继承,父类子对象中又套了一层父类子对象。
4.5.菱形继承下的对象内存布局
模拟菱形继承的代码如下:
class Base
{
public:
virtual void f() { }
int _var1;
};
class Left :public Base
{
public:
virtual void f() { }
virtual void f2() { }
int _var2;
};
class Right :public Base
{
public:
virtual void f() { }
virtual void f3() { }
int _var3;
};
class Bottom :public Left, public Right
{
public:
virtual void f() { }
virtual void f4() { }
int _var4;
};
查看Bottom对象内存布局如下:

还是用图形表示Bottom对象内存布局:

菱形继承也是多种继承叠加起来的,比如上面的Left和Right类单继承Base类,而后Bottom类多继承Left和Right类。这里我就不赘述了
不得不提的是Right父类子对象中的Base子对象中的vfptr指针指向的虚函数表中的0号位置不是存放虚函数的地址了,而是一个名叫thunk函数的地址。但是我画图的时候直接把0号位置画成了Bottom::f,可以先就这样理解。
菱形继承的最大问题就是访问时的二义性和数据的冗余性。数据冗余很好理解,存了两份Base类的数据嘛,而访问的二义性我想举个例子:
Bottom x;
x._var1 = 10;//报错,Bottom::_var1指向不明确
x.Left::_var1 = 10;//正确,修改Left子对象中包含的_var1
x.Right::_var1 = 10;//正确,修改Right子对象中包含的_var1
4.6.菱形虚拟继承下的对象内存布局
将上面菱形继承的代码改成菱形虚拟继承:
class Base
{
public:
virtual void f() { }
//private:
int _var1;
};
class Left :public virtual Base
{
public:
virtual void f() { }
virtual void f2() { }
//private:
int _var2;
};
class Right : public virtual Base
{
public:
virtual void f() { }
virtual void f3() { }
//private:
int _var3;
};
class Bottom :public Left, public Right
{
public:
virtual void f() { }
virtual void f4() { }
//private:
int _var4;
};
查看Bottom对象内存布局如下:

仍然的,我们用图形来表示出Bottom对象的内存布局:

首先要说的是整体的布局,菱形虚拟继承后,虚基类(Base)被从父类(Left,Right)中提取出来,并放到整个对象的最后面的位置,而原来虚基类所在的位置被指向虚基类表的指针(vbptr)替换。这样一来,冗余性和二义性就被解决了。
其次,菱形虚拟继承下的对象有三个虚函数表。Bottom对象中Left和Right父类子对象中的vfptr指向的虚函数表中的源自虚基类(Base)的虚函数被提取出来放到了Base爷爷类子对象中的vfptr指向的虚函数表中了。
那么,虚基类表如何发挥它的作用呢?简单来讲,就是当想要访问虚基类成员时,先在当前指针指向的子对象(Left,Right,Bottom)中的虚基类表中找到虚基类子对象相对于当前子对象的偏移量,然后就得知了虚基类子对象的位置,就可以去访问虚基类子对象的成员。原来我们是不需要通过偏移量找到Base子对象的,因为普通继承中Base子对象是在Left和Right子对象内部的。
4.7.虚拟继承中有关构造函数的问题
来看一段代码:
class Base
{
public:
Base() = default;
Base(int a)
:_a(a)
{}
int _a;
};
class Left :public virtual Base
{
public:
Left(int a,int b)
:Base(a), _b(b)
{}
int _b;
};
class Right : public virtual Base
{
public:
Right(int a,int c)
:Base(a), _c(c)
{}
int _c;
};
class Bottom :public Left, public Right
{
public:
Bottom(int a,int b,int c,int d)
:Left(a,b),Right(a,c),_d(d)
{
}
int _d;
};
int main()
{
Bottom x(1,2,3,4);
cout << x._a << ":"<<x._b<<":"<<x._c<<":"<< x._d<< endl;
}
结果却是这样的:

编译器必须要保证所有的虚函数指针要被正确的初始化。特别是要保证类中所有虚基类的构造函数都要被调用,而且还只能调用一次。如果你写代码时不在子类显示调用虚基类的构造函数,编译器会在最开始自动插入一段虚基类的默认构造函数调用代码。
上述代码的问题在于,Bottom隐式调用了Base虚基类的默认构造函数,而这个默认构造函数什么都没做,尽管Left,Right都调用了Bottom的带参构造也没用了,因为构造函数只能调用一次。所以_a实际上并没有被初始化。
因此,应该在Bottom的构造函数中就显示调用Base虚基类的带参构造:

4.8.后记
通过上述的解析,我想读者已经明白了在各种情况下一个类对象的内存布局是什么样的,并且了解了虚函数表,虚基类表是如何支持多态的。但是读者也可能会问,为什么我没有列举单虚拟继承,甚至在多继承的下,一半虚拟继承,一半正常继承的情况,这是可以理解的,但是读者需要明白的是,虚拟继承本身就是为了解决类似于菱形继承的问题,因此讨论其他情况却是并无太大意义。
还有一点需要告诉读者,对象内存布局是由编译器决定的,因此如果vs版本,甚至编译器和我使用的不一样,最终的结果可能与我所列举的结果大相径庭,但是,我们练习的是分析问题的方法,至于问题本身也就是万变不离其宗了。
5.虚构造函数和虚析构函数
没有虚构造函数!!!vptr需要通过构造函数初始化,而虚构造函数需要通过vptr调用,这就是死循环了。
析构函数最好是虚函数!!!否则,编译器根据指针类型(基类)静态绑定,只调用基类析构函数。派生类特有的资源(如堆内存)将无法释放。读者可能会感觉到奇怪,父类和子类析构函数的函数名明显不同,为什么他两能实现函数重写,那是因为在底层编译器会把父子类的析构函数的函数名处理成一样的。
文章参考:
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)