揭秘C++对象模型:虚表与内存布局

📅 2026/7/10 15:39:16
揭秘C++对象模型:虚表与内存布局
目录1.什么是c对象模型2.查看对象结构模型内存布局3.多态与虚函数与虚函数表与虚基类表4.对象内存布局4.1.非继承下的对象内存布局4.2.单继承下的对象内存布局4.3.多继承下的对象内存布局4.4.多层继承下的对象内存布局4.5.菱形继承下的对象内存布局4.6.菱形虚拟继承下的对象内存布局4.7.虚拟继承中有关构造函数的问题4.8.后记5.虚构造函数和虚析构函数1.什么是c对象模型在这里引用《深度探索C对象模型》这本书中的话有两个概念可以解释C对象模型语言中直接支持面向对象程序设计的部分。对于各种支持的底层实现机制。关于“语言中直接支持面向对象程序设计的部分”大家都耳熟能详比如说c当中的类包括类中的构造函数、析构函数、继承、多态等等这些相关语法。他们使程序员们可以面向对象编程。而对于“各种支持的底层实现机制”我们听的则并不是很多比如虚函数表虚基类表以及对象类实例的内存布局他们在底层支持了面向对象的各种特性继承多态等。在这篇文章中我主要介绍的是这部分的一些内容虚函数表虚基类表和对象内存布局。2.查看对象结构模型内存布局在正式开始本文的主题之前我想先给大家介绍如何在VS下直接查看对象的结构模型也就是内存布局为了查看对象的结构模型我们需要在编译器配置时做一些初始化。在VS2010中在项目——属性——配置属性——C/C——命令行——其他选项中添加选项“/d1reportAllClassLayout”。再次编译时候编译器会输出所有定义类的对象模型在输出栏查看。由于输出的信息过多我们可以使用“CtrlF”查找命令找到对象模型的输出。通过这种方式查询到的对象结构模型的形式如下从上面的模型我们可以得出这些信息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呈现出的对象结构模型时会遇到一些无法理解我却没有解释的东西比如thunkadjuster比如上面说的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的地址分别赋给BaseBase2类型得指针pp2。从结果可以看出p和p2的值是不一样的且正好相差16字节。由此可以得出编译器对于父类指针接收子类地址做了特殊处理结果就是p指向x对象中Base子对象的起始位置p2指向x对象中Base2子对象的起始位置。接下来用p调用f用p2调用f2。而虚函数调用时先要查询当前对象的虚函数表于是系统分别在Base子对象Base2子对象的vfptr指向的虚函数表查询各自调用的函数。查到了Left::f和Left::f2,接着直接调用了这两个函数这从输出框也看得出来。不知不觉中我们通过pp2两个父类指针调用了子类重写的虚函数而不是父类的。现在我们知道了虚函数表如何发挥作用使我们能用父类指针调用子类重写的虚函数。再看一个例子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子对象中包含的_var14.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被从父类LeftRight中提取出来并放到整个对象的最后面的位置而原来虚基类所在的位置被指向虚基类表的指针vbptr替换。这样一来冗余性和二义性就被解决了。其次菱形虚拟继承下的对象有三个虚函数表。Bottom对象中Left和Right父类子对象中的vfptr指向的虚函数表中的源自虚基类Base的虚函数被提取出来放到了Base爷爷类子对象中的vfptr指向的虚函数表中了。那么虚基类表如何发挥它的作用呢简单来讲就是当想要访问虚基类成员时先在当前指针指向的子对象LeftRightBottom中的虚基类表中找到虚基类子对象相对于当前子对象的偏移量然后就得知了虚基类子对象的位置就可以去访问虚基类子对象的成员。原来我们是不需要通过偏移量找到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虚基类的默认构造函数而这个默认构造函数什么都没做尽管LeftRight都调用了Bottom的带参构造也没用了因为构造函数只能调用一次。所以_a实际上并没有被初始化。因此应该在Bottom的构造函数中就显示调用Base虚基类的带参构造4.8.后记通过上述的解析我想读者已经明白了在各种情况下一个类对象的内存布局是什么样的并且了解了虚函数表虚基类表是如何支持多态的。但是读者也可能会问为什么我没有列举单虚拟继承甚至在多继承的下一半虚拟继承一半正常继承的情况这是可以理解的但是读者需要明白的是虚拟继承本身就是为了解决类似于菱形继承的问题因此讨论其他情况却是并无太大意义。还有一点需要告诉读者对象内存布局是由编译器决定的因此如果vs版本甚至编译器和我使用的不一样最终的结果可能与我所列举的结果大相径庭但是我们练习的是分析问题的方法至于问题本身也就是万变不离其宗了。5.虚构造函数和虚析构函数没有虚构造函数vptr需要通过构造函数初始化而虚构造函数需要通过vptr调用这就是死循环了。析构函数最好是虚函数否则编译器根据指针类型基类静态绑定只调用基类析构函数。派生类特有的资源如堆内存将无法释放。读者可能会感觉到奇怪父类和子类析构函数的函数名明显不同为什么他两能实现函数重写那是因为在底层编译器会把父子类的析构函数的函数名处理成一样的。文章参考图说C对象模型对象内存布局详解 - melonstreet - 博客园虚函数与虚继承寻踪 - Florian - 博客园