C++ 对象模型:底层实现与内存布局深度解析

246 阅读38分钟

C++对象模型

引言

在现实开发中,我们开发人员大多很少会去关注编译器层面对对象模型在底层的构建原理,更多的是上层的应用,本着知其然更应知其然的原则,简单记录下其底层的原理。

C++ 对象模型本质上是编译器将面向对象高级概念映射到底层机器实现的核心机制,它清晰定义了 C++ 对象在内存中的布局方式与操作逻辑。

在 C 语言中,"数据"和"处理数据的操作"相互分离,语言本身并未支持“数据和函数”的关联性,这种编程方式被称为程序性编程,由一组功能导向的函数驱动算法,处理共同的外部数据。而 C++ 则支持通过独立的抽象数据类型实现数据与操作的封装,这也在从根本上改变了程序的设计与思考方式。以下是 C 的代码实现:

typedef struct point
{
    float x; 
    float y; 
    float z;
} Point;

void Point_print(const Point *pd)
{
    printf("(%g,%g,%g)",pd->x,pd->y,pd->z);
}

面向对象下的 C++ 对象特性

很明显,C++ 与 C 在程序风格和设计思路上存在截然不同的差异,尤其在使用 template 的场景下,C++ 的实现复杂度会进一步提升。

从软件工程角度来看,抽象数据类型或类层次结构的数据封装,远比 C 程序中对全局数据的程序性使用更具优势。但这一优势往往被追求开发效率、要求程序快速上线且高效运行的开发者忽略,毕竟 C 语言相较于 C++,有着精简易用的核心吸引力。

class Point
{
    public:
        Point(float x = 0.0,float y = 0.0,floatz = 0.0):_x(x),_y(y),_z(z){}
        float x(){return_x;}
        floaty(){return_y;}
        float z(){return_z;}
        void x(float xval){_x = xval;}
    private:
        float_x;
        float y;
        float_z;
};

开发者将 C 的结构体转换为 C++ 的类后,最常提出的问题是:引入封装特性后,对象的内存布局成本会增加多少?

答案是Point 类并未产生任何额外成本:三个数据成员直接内嵌在每个类对象中,与 C 的结构体布局一致;成员函数虽声明于类内,却不会占据对象的内存空间。

其中,非内联的成员函数仅会生成一个函数实体;而拥有零个或一个定义的内联函数,会在其每一个调用模块中各生成一个函数实体。由此可见,Point 类的封装特性并未带来任何空间或运行时的性能损耗。

事实上,C++ 在内存布局和数据存取时间上的主要额外负担,均由virtual关键字引发,具体包括两方面:

  • virtual function(虚函数)机制:为高效实现运行期绑定提供支撑;
  • virtual base class(虚基类):实现 “多次出现在继承体系中的基类,拥有唯一且共享的实体”。

此外,多重继承中也存在少量额外负担,主要体现在派生类与第二及后续基类的类型转换过程中。但总体而言,C++ 程序并非天生比对应的 C 程序更庞大、更迟缓。

C++ 对象模型的核心构成

C++ 对象模型的内涵可通过两个核心概念阐释,其中底层实现机制是理解对象模型的关键,却鲜少被详细讲解:

  • 语言层面的面向对象支持:是 C++ 最广为人知的特性,包括构造函数、析构函数、多态、虚函数等等,也是各类 C++ 书籍的核心讲解内容;
  • 对于各种支持的底层实现机制:无统一的行业标准,不同编译器可自主设计实现细节,核心研究对象是对象在存储上的空间与时间上的优化,并为面向对象技术提供底层支撑,例如通过虚指针、虚表机制实现多态特性。
class Point {
    public:
        Point(float xval);
        virtual ~Point();
        float x() const;
        static int PointCount();
    protected:
        virtual ostream& print(ostream &os) const;
        float _x;
        static int _point_count;
};

// 静态成员初始化
int Point::_point_count = 0;

在 C++ 中,类的成员分为数据成员和函数成员两类,其中数据成员包含静态数据成员非静态数据成员,函数成员则分为静态成员函数非静态成员函数虚函数三类。不同类型的成员,在内存中的存储方式与调用逻辑存在本质差异,这也是对象模型的核心研究内容。

则图示这个Point类在机器中将会被怎么样表现呢?也就是说,我们如何模塑出各种数据成员和函数成员呢?

经典对象模型类别解析

简单对象模型

简单对象模型的设计初衷是尽可能降低 C++ 编译器的设计复杂度,代价则是牺牲了空间利用率和运行时效率,因此并未投入实际产品使用。

在该模型中,一个对象由一系列槽(slot) 组成,每个槽均为一个指针,指向类的一个成员(无论数据成员还是函数成员)。成员会按照声明顺序被分配对应的槽,即所有成员在类中占据的内存大小相同,均为一个指针的大小。图示可以说明这种模型。

数据成员和函数成员本身并不存储在对象中,仅将指向它们的指针放入对象的槽中,以此避免因成员类型不同导致的存储空间不一致问题。对象中的成员可通过槽的索引值进行寻址。

Point 类为例,基于该模型的对象会比 C 的结构体多占用大量内存来存储函数指针,且每次读取数据成员都需要通过指针二次寻址,带来额外的时间消耗。不过,该模型中索引与槽的设计理念,被应用到了 C++ 的指向成员的指针概念中。

20250605-230111.jpg

表格驱动对象模型

表格驱动对象模型设计的核心目标,是为所有类的对象提供统一的表达形式。它将与类成员相关的所有信息抽离出来,分别存入数据成员表函数成员表,类对象本身仅包含指向这两个表格的指针。

其中,函数成员表由一系列槽组成,每个槽指向一个成员函数;数据成员表则直接存储数据本身,也因此,在该模型中对象的内存大小固定为两个指针的大小。

该模型在简单对象模型的基础上增加了一层间接性,同样未被实际的 C++ 编译器采用,但成员函数表的设计理念,成为了支撑虚函数实现的有效方案。

相较于其他模型,表格驱动对象模型具备更高的灵活性:若应用程序代码未发生改变,仅类的非静态数据成员出现增删、修改,应用程序代码无需重新编译。但这一灵活性的背后,是间接性带来的空间占用增加和存取时间延长,付出了空间与执行效率的双重代价。图示了C++对象模型的表格驱动对象模型如何应用于前面所说的Point类。

image.png

非继承下的C++对象模型

实际上被 C++ 编译器采用的对象模型,由简单对象模型派生而来,并针对内存空间和存取时间做了极致优化,也是我们日常开发中接触的核心模型。

在该模型中,各类成员的内存存储规则清晰明确:

  • 非静态数据成员:内嵌在每一个类对象中,是对象内存的核心组成部分;
  • 静态数据成员:存储在所有类对象之外,属于类本身,由该类的所有对象共享;
  • 静态 / 非静态成员函数:均存储在所有类对象之外,不会占用对象的内存空间;
  • 虚函数:通过虚函数表(vtable) + 虚函数表指针(vptr) 实现支撑,是该模型实现多态的核心机制。

核心优势是空间利用率高,数据与函数的存取效率优异,契合 C++ 高效的设计理念;核心劣势是灵活性不足,若类的非静态数据成员发生增删、修改,即使应用程序代码未变,也必须重新编译。图示在此模型下的 Point 类的对象模型。

image.png

继承下的C++对象模型

继承是 C++ 面向对象的核心特性之一,它所决定的派生类对基类实体的内存布局方式,会随继承类型(普通继承 / 虚继承)不同存在显著差异,也是 C++ 对象模型的重点与难点。那么,派生类又是如何在内存层面排布其基类成员的呢?

普通继承

普通继承(非虚继承)中,虚函数表的布局遵循两大规则:

  • 若子类重写了父类的虚函数,子类的虚函数地址会覆盖虚函数表中对应父类虚函数的位置,且父子类拥有各自独立的虚函数表;
  • 若子类未重写父类虚函数,仅声明了新的虚函数,新虚函数的地址会追加到子类虚函数表的末尾。
虚继承

虚继承的设计初衷是解决多重继承中 “菱形继承” 问题,普通菱形继承会导致最终派生类对象包含多份公共基类的子对象,引发内存冗余和成员调用歧义。编译器会为最终派生类对象(或单独实例化的虚继承类对象) 在固定内存位置新增一个虚基类表指针(vbptr) ,该指针仅占用固定大小的内存(与系统位数相关,通常 4/8 字节),其存在与基类的大小、数量无关,会在对象初始化时指向编译器为类生成的虚基类表(vbtable),且与虚函数表一致,同类对象共用一份虚基类表(而非每个对象独有)。

虚基类表中的每个槽,存储的是对应虚基类子对象相对于当前对象起始地址的内存偏移量(而非直接存储地址)。通过 “虚基类表指针 + 偏移量寻址” 的机制,实现虚继承核心目标(公共基类在整个继承链中,无论被派生多少次,仅会在最终派生类对象中存在一份子对象 );部分编译器(如 GCC)会进一步优化 vbptr 的存储:仅让最终派生类对象持有唯一的 vbptr;虚继承的中间类(如菱形继承中的 B/C 类)仅在单独实例化时携带 vbptr,作为最终派生类对象的子对象时,会借助最终派生类的 vbptr 完成虚基类寻址,不重复存储 vbptr。

核心优势是彻底消除菱形继承中公共基类子对象重复的问题,解决内存冗余和成员调用的二义性;同时无需修改类本身的定义,仅通过调整虚基类表的偏移量,即可灵活适配继承关系的变化;核心劣势是相较于普通继承,虚继承因新增 vbptr 带来少量额外空间开销,且访问虚基类成员时需通过指针间接寻址(多一层内存访问),会造成轻微的访问效率损耗,这是虚继承的性能代价。

class istream : virtual public ios {...};
class ostream : virtual public ios {...};
class iostream:public istream, public ostream
{/...};

image.png

以上述代码为例,iostream 类通过虚继承实现了对 ios 基类的唯一继承,内存中仅存在一个 ios 基类实体,避免了菱形继承中的数据冗余与二义性问题。

image.png

虚函数表的底层实现原理

虚函数是 C++ 实现多态的核心,其底层通过虚函数表(vftable)虚函数表指针(vfptr) 两大核心组件实现,整体分为两个关键步骤:

  • 生成虚函数表:编译器会为每个声明了虚函数的类生成专属的虚函数表(vftable),表内存储该类所有虚函数的函数指针(即虚函数对应的内存地址);
  • 植入虚表指针:编译器会为每个类对象新增一个虚表指针(vfptr),该指针指向所属类的虚函数表。

虚表指针的设定与重置,由类的构造函数、析构函数和拷贝赋值运算符自动完成,无需开发者手动干预,确保对象在生命周期内始终指向正确的虚函数表。

此外,用于支持运行时类型识别(RTTI)type_info对象,也会通过虚函数表进行关联,通常被放在虚函数表的第一个槽位。type_info对象存储了类的继承关系、类型描述等多态相关信息,仅当类声明了虚函数时,编译器才会为该类生成对应的type_info对象,进而支持typeiddynamic_cast等 RTTI 相关操作。

不同继承场景下的对象与虚表内存布局

C++ 标准未对对象与虚表的内存布局做统一规定,不同编译器的实现细节存在差异。本文基于 VS2017 x64 编译器,依次解析无继承有虚函数单继承多重继承菱形继承虚继承五种核心场景下的对象与虚表内存布局,清晰呈现底层实现细节。

无继承有虚函数

无继承但包含虚函数的类,其对象内存由虚表指针非静态数据成员组成。以 Qiro_A 类为例,在 x64 环境下,虚表指针占 8 字节,整型成员 a 占 4 字节,编译器为满足内存对齐规则补充 4 字节填充,最终对象总大小为 16 字节(与图片中 class Qiro_A size(16) 完全匹配)。

虚函数表结构解析:无继承场景
  • RTTI 元信息指针(首个槽位) :对应图片中 &Qiro_A_meta,这是编译器为该类生成的 RTTI 元信息对象入口,内部封装了 type_info 指针(指向该类专属的 type_info 对象),核心作用是支撑 typeid(获取对象运行时类型)、dynamic_cast(安全向下转型)等 RTTI 操作,是 C++ 运行时类型识别的核心数据;
  • 偏移量标识(第二个槽位)offset_to_top,对应图片中的数值 0,表示当前虚表指针所在内存位置到最派生对象起始地址的偏移量。在无继承场景下,当前对象即为最派生对象,因此该值固定为 0;仅在多重继承、虚继承等复杂继承场景下,因不同子对象的虚表指针与最派生对象起始地址存在地址差,该值才会呈现非零状态;
  • 虚函数指针区域(后续槽位) :严格按照类中虚函数的声明顺序,依次存储各虚函数的地址(图片中依次为 &Qiro_A::{dtor}(基础析构函数)、&Qiro_A::f&Qiro_A::g)。编译器会为析构函数生成 __delDtor(delete 析构器)、__vecDelDtor(数组 delete 析构器)等变体,但这些变体并非独立虚函数,而是编译器对基础析构函数 {dtor} 的封装逻辑(内部先调用 {dtor},再执行对应内存释放操作),因此不直接出现在虚表中;图片里显示这些变体仅为编译器的调试信息输出,而非虚表实际存储内容;
  • this adjustor(this 指针调整值) :图片中每个虚函数标注 this adjustor: 0,该值用于调用虚函数时调整 this 指针的偏移量。无继承场景下,this 指针直接指向对象起始地址,因此调整值固定为 0;仅在多重继承场景中,子对象的 this 指针需偏移才能指向最派生对象,该值才会非零。

虚表指针(vptr)指向虚函数表的起始地址(即 &Qiro_A_meta 所在位置),而非直接指向第一个虚函数指针;开发者可通过对虚表指针进行内存偏移计算(如 vptr[2] 访问析构函数、vptr[3] 访问 f()),精准访问表中不同位置的虚函数指针,进而调用对应虚函数。

image.png

class Qiro_A{
public:
    Qiro_A(){ cout <<"Qiro_A::Qiro_A()"<<endl;}
    virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<<< endl;}
    virtual void f(){ cout <<"Qiro_A::f()"<<< endl;}
    virtual void g(){cout <<"Qiro_A::g()"<<<endl;}
    int a = 1;
};

单继承

单继承场景下,子类对象会完整继承父类的内存布局,是父类成员子类自身成员的内存拼接,虚表布局则随虚函数的重写与新增发生相应变化。以 Qiro_B 类(继承自 Qiro_A)为例,在 x64 环境下:基类 Qiro_A 子对象占 16 字节,派生类新增成员 b 占 4 字节,加 4 字节对齐填充后,对象总大小为 24 字节(与图片中 class Qiro_B size(24) 匹配)。

核心布局规则
  • 对象布局:先存储父类的所有成员(虚表指针 + 父类非静态数据成员),后存储子类的非静态数据成员;
  • 虚表布局:子类与父类共享虚表结构,若子类重写父类虚函数,会用子类虚函数地址覆盖虚表中对应位置的父类虚函数地址;若子类声明新的虚函数,新虚函数地址会追加到虚表末尾。
虚函数表结构解析:单继承场景
  • 核心特征:对应图中,Qiro_BQiro_A 子对象无独立虚表,其虚表指针与整个 Qiro_B 对象共用,且指向 Qiro_B 专属虚表(首地址为 &Qiro_B_meta);
  • 虚函数指针区域:按 “基类虚函数声明顺序 + 派生类新增虚函数声明顺序” 存储,写的基类虚函数(如 f())会被替换为派生类实现(&Qiro_B::f),未重写的基类虚函数(如 g())复用基类实现(&Qiro_A::g),派生类新增虚函数(如 h())追加至虚表末尾;
访问限制机制:单继承场景

图示当中,Qiro_A 子对象无法访问 Qiro_B 新增的 h(),编译器会通过三层核心机制从编译到链接全流程拦截:

  • 编译期静态类型检查 :C++ 是静态类型语言,编译器在编译阶段会校验调用者类型与函数的匹配性。当通过 Qiro_A*/Qiro_A& 调用 h() 时,编译器会检索 Qiro_A 的类声明,发现其未定义 h() 成员函数,直接抛出编译错误(如 'h': is not a member of 'Qiro_A'),不会生成任何访问 h() 的代码;
  • 虚函数索引专属绑定:编译器为每个类的虚函数分配固定编译期索引,索引仅在该类声明范围内有效。Qiro_A 仅为自身声明的 dtor/f()/g() 分配索引(2/3/4),而 h()Qiro_B 新增函数,对应索引 5 仅归 Qiro_B 所有。Qiro_A 的类型信息中无索引 5 的映射关系,编译器处理 Qiro_A 类型时,只会生成访问索引 2/3/4 的代码,绝不会指向 h() 对应的槽位;
  • 名字修饰(Name Mangling)隔离 :编译器会将类信息嵌入函数名进行修饰(如 MSVC 下,Qiro_B::h() 被修饰为 ?h@Qiro_B@@UEAAXXZ),h() 的修饰名强绑定 Qiro_B。即便通过强制类型转换绕过编译检查,链接期也会因找不到 Qiro_A 相关的 h() 修饰名报错,彻底杜绝跨类非法调用。

虚表指针在派生类对象构造时被初始化为 Qiro_B 虚表地址,保证多态调用能正确路由到派生类实现。

image.png

class Qiro A {
    public:
        Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
        virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<< endl;}
        virtual void f(){ cout << "Qiro_A::f()"<<< endl;}
        virtual void g(){cout <<"Qiro_A::g()"<<<endl;}
        intptr_t a = 1;
};
class Qiro_B : public Qiro_A {
    public:
        Qiro_B(){cout<<"Qiro_B::Qiro_B()"<<endl;}
        virtual mQiro_B(){cout <<"Qiro_B::~Qiro_B()"<<< endl;}
        virtual void f() override { cout << "Qiro_B::f()"<< end1;}
        virtual void h(){cout <<"Qiro_B::h()"<<< endl;}
        intptr_t b = 2;
};

多重继承

多重继承场景下,子类对象的内存与虚表布局是单继承逻辑的扩展,核心体现为多基类成员的顺序排布与多虚表的独立管理。以 Qiro_C 类(继承自 Qiro_AQiro_B)为例,x64 环境下其布局特征如下:

核心布局规则
  • 对象布局:按继承声明顺序依次存储各基类子对象,最后存储子类非静态数据成员(即先是父类 Qiro_A 的成员,然后是 Qiro_B 的成员,最后是子类 Qiro_C 的成员);
  • 虚表布局:子类虚表数量等于含虚函数的直接父类数量(继承 2 个含虚函数的父类,因此 Qiro_C 有 2 张虚表),其中 Qiro_CQiro_A 共享第一张虚表,对象内存中对应存在 2 个虚表指针,分别管理不同基类分支的虚函数调用。
虚函数表结构解析

Qiro_C 的虚表内容随函数重写 / 新增发生针对性变化,虚表中所有条目均为函数指针类型:

  • Qiro_A 共享的第一张虚表:子类 Qiro_Cf() 和析构函数覆盖了 Qiro_A 原有实现,&Qiro_A::g 直接继承下来,Qiro_C 新增的 &Qiro_C::k 追加到该虚表末尾,这张虚表直接绑定对象起始地址,无额外偏移修正;
  • Qiro_B 关联的第二张虚表&Qiro_B::g 直接继承,无新增虚函数;子类重写的 f() 和析构函数会存储编译器自动生成的 thunk goto 跳板函数指针(非直接存储真实业务函数地址),thunk goto 格式为 &thunk: this=-16 goto 目标函数,核心作用是先修正 this 指针偏移,再跳转到 Qiro_C 的真实函数实现,仅适配 Qiro_B 指针的多态调用;同时 Qiro_B 原有函数 h 携带 this adjust=16 修正值,与 thunk 的修正逻辑方向相反。
this 指针调整机制:多重继承核心

由于多基类子对象在内存中顺序排布,Qiro_C 对象赋值给不同基类指针时,this 指针需动态调整才能保证访问和调用的正确性,典型场景如下:

  • 向上转型时的偏移:执行 Qiro_B* qr2 = new Qiro_C; 时,Qiro_C 对象赋值给 Qiro_B 指针,this 指针需要向后调整 sizeof(Qiro_A)(16 字节),精准指向 Qiro_B 子对象起始位置,thunk goto 执行前的 this 就指向该偏移后的地址;
  • thunk goto 细致修正逻辑:通过 Qiro_B* 调用重写的 f() / 析构函数时,会先通过虚表中的跳板函数指针跳转到 thunk 代码,thunk 先执行 this=-16,将偏移后的 Qiro_B 子对象 this 向前修正 16 字节,拉回 Qiro_C 对象起始地址,再跳转到真实函数执行;
  • 析构回退调整:执行 delete qr2; 时,this 指针需向前调整 sizeof(Qiro_A)(16 字节),回到 Qiro_C 对象起始地址,才能调用子类完整析构函数,释放全部对象内存;
  • offset_to_top 与 this adjust 符号对应规则:第二张虚表的 offset_to_top=-16,代表 Qiro_B 子对象地址向前 16 字节为对象起始,与 thunkthis=-16 完全匹配;而 Qiro_B::hthis adjust=16,是对象起始地址向后 16 字节定位 Qiro_B 子对象,二者调整方向完全相反,因此符号一正一负;
  • k 无需偏移的核心原因:子类新增虚函数仅扩展首个基类虚表,k 是子类新增虚函数,仅追加到与 Qiro_A 共享的第一张虚表,第二张 Qiro_B 分支虚表中无 k 对应的函数指针;且能调用 kQiro_A*Qiro_C* 指针均指向对象起始地址,不存在 this 偏移场景,因此无需任何修正。

image.png

class Qiro A {
    public:
        Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
        virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<< endl;}
        virtual void f(){ cout << "Qiro_A::f()"<<< endl;}
        virtual void g(){cout <<"Qiro_A::g()"<<<endl;}
        intptr_t a = 1;
};
class Qiro B {
    public:
        Qiro_A(){cout<<"Qiro_B::Qiro_B()"<<endl;}
        virtual ~Qiro_B(){cout <<"Qiro_B::~Qiro_B()"<< endl;}
        virtual void f(){ cout << "Qiro_B::f()"<<< endl;}
        virtual void g(){cout <<"Qiro_B::g()"<<<endl;}
        virtual void h(){cout <<"Qiro_B::h()"<<<endl;}
        intptr_t b = 2;
};
class Qiro_C : public Qiro_A, public Qiro_B {
    public:
        Qiro_C(){cout<<"Qiro_C::Qiro_C()"<<endl;}
        virtual Qiro_C(){cout <<"Qiro_C::~Qiro_C()"<<< endl;}
        void f() override { cout << "Qiro_B::f()"<< end1;}
        void h() override {cout <<"Qiro_C::h()"<<< endl;}
        virtual void k(){cout <<"Qiro_C::k()"<<< endl;}
        intptr_t c = 3;
};

菱形继承

菱形继承是包含公共顶层基类的特殊多重继承形态,子类通过两条独立继承路径继承同一个公共基类,除复用多重继承的虚表调度、this指针调整基础逻辑外,存在独有的内存布局缺陷与访问问题。以Qiro_D类(继承自Qiro_BQiro_CQiro_BQiro_C均继承自Qiro_A)为例,x64 环境下其独有特征如下:

核心布局规则
  • 对象布局:按继承声明顺序排布基类子对象,先存储完整的Qiro_B子对象,再存储完整的Qiro_C子对象,最后追加子类Qiro_D的非静态数据成员;核心独有特征:因Qiro_BQiro_C均独立继承Qiro_AQiro_D对象内部会生成两份完全独立、无关联的Qiro_A子对象实体,分别嵌套在Qiro_BQiro_C子对象的内存区域中;
  • 虚表布局:虚表数量、指针管理规则遵循多重继承标准,仅偏移参数适配当前布局,无新增专属结构。
虚函数表结构解析

虚表的函数指针类型、thunk跳板基础逻辑与多重继承一致,仅菱形独有参数:

  • Qiro_C关联的第二张虚表中,offset_to_top固定为-24,该数值对应Qiro_C子对象起始地址相对Qiro_D对象起始地址的偏移量(由Qiro_B子对象总大小决定);
  • 重写的f()和析构函数对应的thunk goto修正值为this=-24,与offset_to_top数值完全匹配,保证指针修正的准确性。
菱形继承独有核心问题
  • 数据冗余与内存浪费:公共基类Qiro_A的所有成员变量(如a)在Qiro_D对象中存在两份独立内存拷贝,相同数据重复占用内存,对象体积额外增大,违背内存高效利用原则;
  • 生命周期执行异常Qiro_A的构造函数会在Qiro_BQiro_C初始化时各触发一次,析构函数也会在对象销毁时执行两次,导致公共基类的生命周期逻辑重复执行,极易引发资源管理错误;
  • 成员访问二义性:直接通过Qiro_D对象访问Qiro_A的成员(如d.ad.f())时,编译器无法识别该访问指向Qiro_B分支还是Qiro_C分支的Qiro_A子对象,直接报二义性编译错误,必须通过d.Qiro_B::ad.Qiro_C::a的类名限定方式强制区分,使用复杂度大幅提升;
  • 偏移适配特性:向上转型为Qiro_C*时,this指针需向后偏移 24 字节指向Qiro_C子对象;析构释放时,需向前修正 24 字节回到Qiro_D对象起始地址,偏移量由Qiro_B子对象大小唯一确定。 从右侧的对象布局可以发现:Qiro_A的数据成员a有两份。因为二义性我们也不能直接通过d.a访问a。

image.png

class Qiro_A{
        public:
            Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
            virtual ~Qiro_A() { cout << "Qiro_A::~Qiro_A()" << endl;}
            virtual void f(){ cout <<"Qiro_A::f()"<<< endl;}
            intptr_t a = 1;
    };
    classQiro B : public Qiro_A {
        public:
            Qiro_B(){ cout<<"Qiro_B::Qiro_B()"<<endl;}
            virtual ~Qiro_B(){cout <<"Qiro_B::~Qiro_B()"<<endl{;}
            virtual void g(){cout <<"Qiro_B::g()"<<< endl;}
            intptr_tb=2;
    };
    classQiro_C : public Qiro_A {
        public:
            Qiro_C(){cout<<"Qiro_C::Qiro_C()"<<endl;}
            virtual ~Qiro_C(){cout <<"Qiro_C::~Qiro_C()"<< endl;}
            virtual void h(){cout <<"Qiro_C::h()"<<<endl;}
            intptr_t C = 3;
    };
    classQiro_D:publicQiro_B,publicQiro_C{
        public:
            Qiro_D(){cout<<"Qiro_D::Qiro_D()"<<endl;}
            virtual ~Qiro_D(){cout << "Qiro_D::~Qiro_D()"<<< endl;}
            void f() override{cout << "Qiro_D::f()"<<<endl;}
            virtual void k(){cout <<"Qiro_D::k()"<<<endl;}
            intptr_t d = 4;
    };
    int main(){
        Qiro_D d;
        d.a=2;//Qiro_D::a不明确
        return
    }

菱形继承+虚继承

虚继承是专门解决菱形继承缺陷的标准方案,在保留多重继承虚函数表调度、this指针调整基础逻辑的前提下,通过专属内存结构实现公共基类唯一化,彻底解决数据冗余与二义性问题。以虚继承方式实现的Qiro_D类为例,x64 环境下对象总大小为 80 字节,核心特征与底层机制如下:

核心布局规则
  • 对象布局:按继承声明顺序存储Qiro_BQiro_C子对象,Qiro_B子对象从 0 偏移起始,Qiro_C子对象从 24 偏移起始;每个中间基类(Qiro_BQiro_C)子对象均采用固定结构:8 字节虚表指针 + 8 字节虚基类指针vbptr,因此子对象起始地址向后偏移 8 字节,就是虚基类指针vbptr的位置;取消原有嵌套在Qiro_BQiro_C内部的Qiro_A子对象,在Qiro_D对象中,从 64 偏移位置开始,存放唯一的公共Qiro_A虚基类子对象(该子对象占用 64~79 字节,内部包含 8 字节虚表指针和成员a);布局中额外包含vtordisp补偿字段(4 字节,配合 8 字节对齐实际占用 8 字节,位于子类成员与虚基类Qiro_A之间);严格遵循 x64 8 字节内存对齐规则,b/c/d/a等 4 字节成员变量,均会被编译器自动填充 4 字节空字节,最终各占用 8 字节空间;
  • 表结构布局:保留原有虚函数表的虚函数调度功能,同时配备虚基类表vbtable,由vbptr专属指向,独立于虚函数表,仅用于虚基类定位;对象共包含 2 张虚函数表、2 张虚基类表,分别对应Qiro_BQiro_C分支,虚函数表偏移量适配虚继承布局(Qiro_C分支虚函数表offset_to_top=-24Qiro_A虚基类虚函数表offset_to_top=-64);Qiro_Dd(Qiro_B+8)Qiro_AQiro_Dd(Qiro_C+8)Qiro_A是编译器的人类可读注释,其中Qiro_Dd代表派生类Qiro_D(Qiro_B+8)代表以Qiro_B子对象为基准往后偏移 8 字节找到vbptr(Qiro_C+8)代表以Qiro_C子对象为基准往后偏移 8 字节找到vbptrQiro_A代表目标虚基类Qiro_A
虚函数表 & 虚基类表结构解析
  • 虚函数表部分:重写函数的thunk跳板、新增函数的虚函数表追加规则与多重继承完全一致,仅偏移量适配虚继承布局;Qiro_A虚基类对应的虚函数表中,存储带vtordisp修正的析构函数与f()函数指针,对应&(vtordisp) Qiro_D::{dtor}&(vtordisp) Qiro_D::f
  • vbptr(虚基类指针) :虚继承专属核心指针,不参与普通虚函数跳转,每个中间基类子对象都会独立携带,唯一作用是指向对应的虚基类表;Qiro_B分支vbptr通过0+8定位,Qiro_C分支vbptr通过24+8定位;Qiro_Dd(Qiro_B+8)Qiro_A指从Qiro_D对象出发,先找到Qiro_B子对象起始地址,往后偏移 8 字节拿到vbptr,再通过虚基类表定位到虚基类Qiro_AQiro_Dd(Qiro_C+8)Qiro_A指从Qiro_D对象出发,先找到Qiro_C子对象起始地址,往后偏移 8 字节拿到vbptr,再通过虚基类表定位到虚基类Qiro_A
  • vbtable(虚基类表) :编译器自动为虚继承生成的专属偏移数据表,存储两类关键偏移信息:①vbptr到当前子对象类首地址的偏移(固定为-8,对应vbptr在子对象 + 8 位置);②vbptr到 64 偏移起始的Qiro_A虚基类子对象的偏移(Qiro_B分支为 56,Qiro_C分支为 32),与注释的寻址逻辑一致,最终均指向 64 偏移处的Qiro_A
  • vtordisp字段(全称vtordisp for vbase Qiro_A :虚继承独有核心补偿字段,仅在同时满足两个条件时自动生成:①采用虚继承存在公共虚基类Qiro_A;②派生类Qiro_D重写了虚基类Qiro_A的虚函数(析构函数、f());核心作用是解决对象构造、析构过渡阶段的this指针偏移问题,配合this adjustor:64的修正偏移量,完成this指针校准,保证生命周期内虚函数调用的稳定性与准确性。
虚继承核心机制
  • 数据冗余彻底消除:公共基类Qiro_A仅在对象 64 偏移起始处存储一份实体,成员变量无重复内存拷贝,内存占用优化,且Qiro_A的构造、析构函数仅执行一次,对象生命周期回归正常逻辑;
  • 访问二义性完全解决:编译器通过Qiro_B+8Qiro_C+8定位vbptr,查询虚基类表后自动计算并定位 64 偏移起始的Qiro_A子对象,可直接通过d.a访问公共基类成员,无需任何类名限定,使用方式与普通单继承完全一致;
  • 虚基类自动定位:访问公共基类成员时,无需手动调整指针,Qiro_B分支通过偏移 56、Qiro_C分支通过偏移 32,所有地址计算、指针修正均由编译器通过虚基类表自动完成,开发者无额外使用成本;
  • vtordisp稳定保障:在对象构造初始化、析构销毁的过渡阶段,this指针会因虚基类的后置布局发生动态偏移,vtordisp通过缓存this adjustor:64的修正偏移量,精准校准this指针指向 64 偏移起始的Qiro_A,避免生命周期内虚函数调度异常,从底层保证程序运行稳定性;

image.png

class Qiro_A{
    public:
        Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
        virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<<<endl;}
        virtual void f(){ cout << "Qiro_A::f()"<<< endl;}
        intptr_t a = 1;
};
class Qiro_B : virtual public Qiro_A{
    public:
        Qiro_B(){cout<<"Qiro_B::Qiro_B()"<<endl;}
        virtual Qiro_B(){cout <<"Qiro_B::~Qiro_B()"<<<endl;}
        virtual void g(){cout <<"Qiro_B::g()"<<< endl;}
        intptr_tb=2;
};
class Qiro_C : virtual public Qiro_A{
    public:
        virtual public Qiro_A{
        Qiro_C(){ cout <<"Qiro_C::Qiro_C()"<<endl;}
        virtual Qiro_C(){cout <<"Qiro_C::~Qiro_C()"<<<endl;}
        virtual void h(){cout <<"Qiro_C::h()"<<< endl;}
        intptr_tc=3;
};
classQiro_D:public Qiro_B,public Qiro_C{
public:
    Qiro_D(){ cout <<"Qiro_D::Qiro_D()"<<<endl;}
    virtual ~Qiro_D(){cout <<"Qiro_D::~Qiro_D()"<<<endl;}
    void f() override{cout <<"Qiro_D::f()"<<<endl;}
    virtual void k(){cout << "Qiro_D::k()"<<< endl;}
    intptr_t d = 4;
};

构造函数的建构过程与编译器合成规则

构造函数是 C++ 对象初始化的核心,开发者常对默认构造函数的编译器合成规则存在误解。《C++ Annotated Reference Manual》(ARM,C++ 参考手册)明确指出:默认构造函数仅在编译器需要时,才会被合成出来。这里的 “需要” 是编译器的底层需求,而非开发者的程序需求,二者存在本质区别。

例如,若一个类包含两个未初始化的成员变量,开发者希望默认构造函数将其初始化为 0,这属于程序需求,编译器不会为此合成默认构造函数,需开发者手动实现;而当编译器为了完成底层机制(如虚表指针初始化、虚基类定位)需要默认构造函数时,才会自动合成,且合成的构造函数仅执行编译器所需的操作,不会为成员变量赋予默认值。

核心概念:平凡与非平凡默认构造函数

对于未声明任何构造函数的类class X,编译器会为其暗中声明一个平凡默认构造函数(trivial default constructor) ,该构造函数 “浅薄而无能”,不执行任何实际的初始化操作,本质上不会被真正合成出来,仅作为语法上的补充。

当满足特定条件时,平凡默认构造函数会被转化为非平凡默认构造函数(nontrivial default constructor) ,此时编译器会真正合成该构造函数,并在其中安插底层初始化所需的代码。

编译器合成非平凡默认构造函数的四种场景

只有满足以下四种场景之一,编译器才会为未声明构造函数的类,合成真正可用的非平凡默认构造函数,这也是 C++ 对象模型中构造函数的核心合成规则:

  • 带有默认构造函数的类对象成员
  • 带有默认构造函数的基类
  • 带有虚函数的类
  • 带有虚基类的类
带有默认构造函数的类对象成员

若一个类未声明任何构造函数,但包含一个或多个类对象成员,且这些成员所属的类拥有默认构造函数,那么该类的隐式默认构造函数会被视为非平凡构造函数,编译器会为其合成默认构造函数,且仅在该构造函数被实际调用时,合成操作才会发生

编译器的防重复合成策略:将合成的默认构造函数、拷贝构造函数、析构函数、赋值运算符重载均以内联(inline) 方式实现,内联函数拥有静态链接,仅在当前编译模块可见,避免多个模块中合成多个相同的构造函数实体;若函数逻辑过于复杂,不适合内联,则会合成一个显式的非内联静态实体。

构造函数的扩张规则:合成的默认构造函数,仅会调用类对象成员的默认构造函数,完成成员的初始化,不会为当前类的其他成员(如指针、基本类型)执行初始化操作。若开发者手动声明了默认构造函数,编译器不会合成新的构造函数,而是扩张已存在的构造函数,在用户代码执行前,按类对象成员的声明顺序,依次调用其默认构造函数。

class Foo {
    public:
        Foo(){};
        Foo(int){};
};
class Bar {
    public:
        Foo foo;
        char* str; 
};
// 编译器会为Bar合成非平凡默认构造函数,调用foo的默认构造函数
void foo_bar(){
    Bar bar;
    if(bar.str){
        std::cout << bar.str <<std::endl;
    }
};
// 若开发者手动声明构造函数,编译器会扩张该函数
Bar::Bar(){str=0;}
// 扩张后实际执行:foo.Foo::Foo(); str=0;
带有默认构造函数的基类

若一个类未声明任何构造函数,且继承自带有默认构造函数的基类,那么该类的默认构造函数会被视为非平凡构造函数,编译器会为其合成默认构造函数,核心规则如下:

  • 合成的构造函数,会按基类的声明顺序,依次调用各基类的默认构造函数;
  • 对于后续派生的子类而言,该合成的构造函数与开发者手动声明的默认构造函数无任何差异;
  • 若开发者为类声明了多个构造函数,但未声明默认构造函数,编译器不会合成新的默认构造函数,而是对已存在的每个构造函数进行扩张,在用户代码前插入基类默认构造函数的调用代码;
  • 若类同时包含带有默认构造函数的类对象成员,则基类的默认构造函数会先被调用,随后再按声明顺序调用类对象成员的默认构造函数。
带有虚函数的类

当类声明虚函数,或继承自带有虚函数的基类时,编译器需要为其合成非平凡默认构造函数,核心目的是完成虚表指针(vptr)的初始化,确保对象创建时,vptr 能正确指向所属类的虚函数表,这是虚函数实现运行期多态的底层基础。

编译器会执行两大核心扩张操作:

  • 为该类生成专属的虚函数表(vtable) ,表中按声明顺序存储所有虚函数的地址;
  • 为该类的每个对象合成虚表指针(vptr) ,并在构造函数中安插代码,将 vptr 初始化为虚函数表的首地址。
class Widget {
    public:
        virtual void flip() = 0; // 纯虚函数
};
class Bell : public Widget {
    public:
        virtual void flip() {} // 重写纯虚函数
};
class Whistle : public Widget { 
    public:
        virtual void flip() {} // 重写纯虚函数
};
void flip(Widget& widget) { widget.flip(); }
void foo() {
    Bell b;
    Whistle w;
    flip(b);
    flip(w);
}

虚函数的调用会被编译器底层改写为基于 vptr 的运行期绑定,例如 widget.flip() 会被改写为(*widget.vptr[1])(&widget)。 其中,1 为flip()在虚表中的固定索引,&widget为传递给虚函数的this指针。

若开发者手动声明了构造函数,编译器会自动扩张该构造函数,插入 vptr 的初始化代码;若类未声明任何构造函数,则编译器会合成非平凡默认构造函数,完成 vptr 的初始化工作。

带有虚基类的类

虚基类的底层实现因编译器不同存在较大差异,但核心实现逻辑一致,必须在运行期确定虚基类在派生类对象中的内存位置。编译器无法在编译期固定虚基类成员的偏移地址,因此需要合成非平凡默认构造函数,完成 ** 虚基类指针(vbptr)** 的初始化,支撑运行期的地址定位。

编译器的核心实现策略是:在派生类对象中安插虚基类指针(vbptr) ,该指针指向专属的虚基类表(vbtable) ,表中存储虚基类相对于当前对象的偏移地址;所有通过指针 / 引用对虚基类成员的访问,均通过vbptr查询虚基类表获取偏移后完成定位。

image.png

class X { public: int i; };
class A : public virtual X { public: int j; };
class B : public virtual X { public: double d; };
class C : public A, public B { public: int k; };

// 编译期无法确定pa->i的内存偏移,运行期通过虚基类指针+偏移定位
void foo(const A* pa) { pa->i = 1024; }
// 编译器底层改写:通过vbptr查虚基类表,再访问成员i
// 等价于:*( (A*)pa + pa->vbptr[偏移索引] ) = 1024;

若类未声明任何构造函数,编译器会合成非平凡默认构造函数,在对象创建时初始化虚基类指针vbptr;若开发者手动声明了构造函数,编译器会自动扩张该函数,插入vbptr的初始化代码,确保运行期能精准定位虚基类的内存位置。

关于默认构造函数的常见误解

C++ 初学者对默认构造函数的合成规则,常存在两个核心误解,需重点理清:

  • 误解 1:任何未声明默认构造函数的类,都会被编译器合成一个默认构造函数。正解是仅满足上述四种场景时,编译器才会合成非平凡默认构造函数;否则仅暗中声明一个平凡默认构造函数,不会实际合成,也不执行任何初始化操作。

  • 误解 2:编译器合成的默认构造函数,会为类的所有数据成员设置默认值。正解是合成的非平凡默认构造函数,仅执行编译器底层所需的操作(如调用成员 / 基类默认构造函数、初始化 vptr / 虚基类指针),不会为基本类型、指针等成员赋予默认值,这些成员的初始化是开发者的责任。

总结

C++ 对象模型是连接 C++ 面向对象高级特性与底层机器实现的桥梁,其核心设计思路是在面向对象封装、继承、多态的特性与内存空间、运行效率之间寻求平衡

从内存布局来看,C++ 对象模型对不同类型的类成员做了精细化的存储设计,非静态数据成员内嵌于对象,静态成员与成员函数独立于对象存在,仅通过虚函数和虚基类引入少量的时空损耗,确保了 C++ 的高效性;从继承实现来看,普通继承保证了效率,虚继承则通过虚基类指针 / 表机制解决了菱形继承的问题,代价是少量的性能损耗;从构造函数合成来看,编译器仅在底层机制需要时合成非平凡默认构造函数,且仅执行必要的初始化操作,避免了不必要的性能开销。