首页 > 系统相关 >C++类内存布局与虚继承

C++类内存布局与虚继承

时间:2023-10-15 18:23:00浏览次数:46  
标签:函数 继承 virtual class int 内存 基类 public C++

类的内存布局

本文参考浅析C++类的内存布局,做了一些修改和补充

1. 无继承的情况

为了得到类的内存布局,先设置一下

1697183896007

1697183918859

输入 /d1 reportAllClassLayout,结果会在输出窗口打印出。最后会打印很多类,基本上最后就是自己的类的布局,也可以指定类。如果写上 /d1 reportSingleClassLayoutXXX(XXX为类名),则只会打出指定类XXX的内存布局。

1.1 无虚函数

class Base
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_1() {}

};

输出的类布局如图

1697184308194

其中 <alignment member>表示为了内存对齐填充了字节

可以得到以下结论:

  • 普通的变量 :是要占用内存的,但是要注意对齐原则
  • static修饰的静态变量 :不占用内容,原因是编译器将其放在全局变量区
  • 成员函数不占用具体类对象内存空间,成员函数存在代码区
  • 数据成员的访问级别并不影响其在内存的排布和大小,均是按照声明的顺序在内存中有序排布,并适当对齐

1.2 有虚函数

class Base
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_1() {}
	virtual void vfun_1() {}

};

1697184386294

现在Base类的布局改变了,最起始储存的是vfptr虚函数指针。这个指针占用了8个字节(64位操作系统),如果是32位操作系统应该是4个字节,我看网上大部分都是4个字节的,我测试多次是8个字节。

下面有一个vftable虚表,里面只有虚函数 vfun_1。如果我们再加一个虚函数 vfun_2会怎样呢?

1697184711334

可以发现,类的布局没有改变,依旧只有一个指向虚表的虚函数指针。也就是说无论有多少个虚函数,只会有一个虚函数指针存入内存,而这个虚函数指针指向的虚表里面多了一个虚函数 vfun_2,它指向了这个虚函数的地址。

2. 单一继承的情况

2.1 无虚函数

class Base
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_1() {}
};

//Derived1类
class Derived1 : public Base
{
	char c_b;

public:
	void fun_d1() {}
};

//Derived2类
class Derived2 : public Derived1
{
	double d_b;

public:
	void fun_d2() {}
};

1697185794406

可以发现:

  • 每个派生类中起始位置都是Base class subobjectj基类子对象
  • 内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局

2.2 有虚函数

  • Base类布局

    1697186155898

  • Derived1布局
    首先不变的是内存空间起始是虚函数指针,之后会按照类的继承顺序(父类到子类)和字段的声明顺序布局。在这里Derived1重写了虚函数 vfun_1。因此虚表中函数地址是 &Derived1::vfun_1,而没有重写的虚函数依旧是基类的虚函数。
    1697186192455

  • Derived2布局
    Derived2的布局是差不多的

    1697186239408

2.3 虚析构函数

当一个派生类对象经由一个base class指针被删除,而该base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没被销毁。所以上述的类设计其实有错误,带多态性质的基类应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。

3. 多重继承

class A
{
private:
	char c_a;
	static char c_b;
	int i_a;
	static int i_b;
	float f_a;
	static float f_b;
	double d_a;
public:
	void fun_A() {}
	virtual void vfun_A() {}
	virtual ~A() {}
};

class B
{
	char c_b;

public:
	void fun_B() {}
	virtual void vfun_B() {}
	virtual ~B() {}
};

class C : public A, public B
{
	double d_b;

public:
	void fun_C() {}
	virtual void vfun_C() {}
	virtual ~C() {}
};

这里A和B是独立的类,没有继承关系,而C继承A和B。A和B的内存布局不必多赘述了,这里直接看C

1697187220093

每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面。

4. 菱形继承

1697189015017

class A
{
private:
	char c_a;
	int i_a;
	float f_a;
	double d_a;
public:
	virtual ~A() {}
};

class B : public A
{
	int i_b;

public:
	virtual ~B() {}
};

class C : public A
{
	int i_c;

public:
	virtual ~C() {}
};

class D : public B, public C
{
	int i_d;

public:
	virtual ~D() {}
};

直接看D的布局

1697189946772

D中依次存放基类B subobject和基类C subobject。其中B和C中均存放一份class A subobject

4.1 虚拟继承

从菱形继承的D的内存布局可以看出,subobject A有两份,所以A的数据成员也存了两份,但 实际上对于D而言,只需要有一份subobject A即够了。菱形继承不仅浪费存储空间,而且造成了数据访问的二义性 。虚拟继承可以很好地解决这个问题。

我们给B和C对A的继承都加上了关键字virtual。

class A
{
private:
	char c_a;
	int i_a;
	float f_a;
	double d_a;
public:
	virtual ~A() {}
};

class B : virtual public A
{
	int i_b;

public:
	virtual ~B() {}
};

class C : virtual public A
{
	int i_c;

public:
	virtual ~C() {}
};

class D : public B, public C
{
	int i_d;

public:
	virtual ~D() {}
};

B和C类内存布局类似,如下

1697190022019

可以看到,class B中有两个虚指针: 第一个指向B自己的虚表(注意这里是vbptr而不是vfptr,是虚基类指针),第二个指向虚基类A的虚表 。而且, 从布局上看,class B的部分要放在前面,虚基类A的部分放在后面 。在class B中虚基类A的成分相对内存起始处的偏移offset等于class B的大小(16字节)。C的内存布局和B类似。

Class如果内含一个或多个virtual base subobjects,将被分割成两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何衍化,总有固定的offset(从object的开头算起),所以这一部分可以直接存取。而共享区域所表现的就是virtual base class subobject。这部分数据的位置会因为每次的派生操作而发生变化,所以它们只可以被间接存取。

D的内存布局如下所示。

1697189990701

菱形/钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚基类指针)、子类、公共基类(最上方的父类,包含虚函数指针),并且各个父类不再拷贝公共基类中的数据成员。

虚继承的实现原理是,编译器在派生类的对象中添加一个指针vbptr。vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚基表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

仔细观察可以发现,D::$vbtable@B@中的偏移量是40,观察D的内存布局可以看到B类起始偏移量是0,而A类的偏移量是40,A相对D的偏移量是40;

同理观察 D::$vbtable@C@中的偏移量是24,可以看到D内存布局中C类偏移量是16,A相对C的便宜就是26。

综上验证了虚基表中记录了虚基类与本类的偏移地址

在加一个例子

class A
{
private:
	char c_a;
	int i_a;
	float f_a;
	double d_a;

public:
	virtual ~A() {}
};

class A2
{
private:
	char c_a2;

public:
	virtual ~A2() {}
};

class B : virtual public A, virtual public A2
{
	int i_b;

public:
	virtual ~B() {}
};

class C : virtual public A, virtual public A2
{
	int i_c;

public:
	virtual ~C() {}
};

class D : public B, public C
{
	int i_d;

public:
	virtual ~D() {}
};

1697364515545

5. 总结

  • 如果是有虚函数的话,虚函数表的指针始终存放在内存空间的头部;
  • 除了虚函数之外,内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局;
  • 如果有多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面;
  • 如果有菱形/钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚表)、子类、公共基类(最上方的父类,包含虚表),并且各个父类不再拷贝公共基类中的数据成员。
  • 虚继承的实现原理是,编译器在派生类的对象中添加一个指针vbptr。vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚基表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。
  • 空的类是会占用内存空间的,而且大小是1,原因是C++要求每个实例在内存中都有独一无二的地址。
  • 类内部的成员变量:
    普通的变量 :是要占用内存的,但是要注意 对齐原则 (这点和struct类型很相似)。
    static修饰的静态变量 :不占用内容,原因是编译器将其放在全局变量区。
  • 类内部的成员函数:
    普通函数:不占用内存。
    虚函数:要占用4个字节(32位系统)或8个字节(64位系统),用来指定虚函数的虚拟函数表的入口地址。所以一个类的虚函数所占用的地址是不变的,和虚函数的个数是没有关系的。
  • C++编译系统中,数据和函数是分开存放的(函数放在代码区;数据主要放在栈区或堆区,静态/全局区以及文字常量区也有),实例化不同对象时,只给数据分配空间,各个对象调用函数时都都跳转到(内联函数例外)找到函数在代码区的入口执行,可以节省拷贝多份代码的空间
    数据主要放在栈区或堆区,有可能是堆,也有可能是栈。这取决于实例化对象的方式:
    A a1 = new A(); //堆
    A a2; //栈
  • 类的静态成员变量编译时被分配到静态/全局区,因此静态成员变量是属于类的,所有对象共用一份,不计入类的内存空间。
  • 内联函数(声明和定义都要加inline)也是存放在代码区,在编译阶段,编译器会用内联函数的代码替换掉函数,避免了函数跳转和保护现场的开销。不要将成员函数的这种存储方式和inline(内联)函数的概念混淆。不要误以为用inline声明(或默认为inline)的成员函数,其代码段占用对象的存储空间,而不用inline声明的成员函数,其代码段不占用对象的存储空间。不论是否用inline声明(或默认为inline),成员函数的代码段都不占用对象的存储空间。用inline声明的作用是在编译时期,将函数的代码段复制插人到函数调用点,而若不用inline声明,在调用该函数时,流程转去函数代码段的入口地址,在执行完该函数代码段后,流程返回函数调用点。inline与成员函数是否占用对象的存储空间无关

Reference

标签:函数,继承,virtual,class,int,内存,基类,public,C++
From: https://www.cnblogs.com/dogwealth/p/17765936.html

相关文章

  • C++多态与虚函数
    多态与虚函数1.什么是多态所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。1.1编译时多态重载(Overloading)是指同一......
  • Java面试题支招-为什么Java语言不支持多继承
    这是非常经典的问题,与“为什么String类在Java中是不可变的”很类似;这两个问题之间的相似之处在于它们主要是由Java创作者的设计决策使然。Java不支持类的多继承。因为多继承会增加编程的复杂性。下图选自孙卫琴的经典Java书籍《漫画Java编程》当一个子类有多个父类可能出......
  • valgrind分析内存
    安装valgrindyuminstall-yvalgrind分析内存泄漏valgrind--tool=memcheck--show-leak-kinds=all--undef-value-errors=no--log-file=check--leak-check=full二进制命令分析堆内存valgrind--tool=massif二进制命令ms_print本地文件路径 ......
  • c++ 线段树模板
    洛谷模板:P3372【线段树1】 #include<bits/stdc++.h>#defineintlonglongusingnamespacestd;constintN=1e5+10;inta[N],d[N<<2],b[N<<2];intn,q;inlinevoidbuild(intl,intr,intp){if(l==r){d[p]=a[l];......
  • C++ const 在函数中的使用
    C++中的const在函数中的用法有三种:修饰形参此时写法如下:voidfun(constClassA&a);目的为防止传入的原始参数被修改;修饰返回值此时写法为constint&getAge();目的为防止函数返回值作为左值被修改;修饰函数此时的写法为typeNamefun()const();当const修饰函数时,所有......
  • stl(c++)
    1.vector定义: a.size()a.empty()a.clear()vector<int>::iteratorit=a.begin()迭代器(可类比于指针)前开后闭a.begin()a.end()是开始迭代器和最后一个元素的下一个迭代器a[0]=*a.begin()a.back()最后一个元素a.push_back()O(1)加入元素到末尾a.pop_back()删除最后一......
  • 一些 C/C++ 的知识
    引用https://zhuanlan.zhihu.com/p/100050970https://www.sohu.com/a/300755552_120111838gcc与g++的区别GCC:GNUCompilerCollection(GUN编译器集合),它可以编译C、C++、JAVA、Fortran、Pascal、Object-C等语言。gcc是GCC中的GUNCCompiler(C编译器);g++是GCC中的GUNC++Co......
  • OnTheSSH使用技巧(四)直接观看进程内存
    堆(heap)和栈(stack)是进程中的两片内存区域,这是学习编程过程中,特别是C语言这种直接操作内存的程序员必须要掌握的知识。如果能直观的看到进程运行时堆内存和栈内存的变化,相信对内存知识的掌握和程序的调试都能带来帮助。OnTheSSH是一款SSH工具,提供了图形化的进程内存的监控功能,今天我......
  • 动态内存管理函数及应用--通讯录管理系统(1)
    引言:我们在创建一个局部变量时,通过下列定义语句向内存申请空间,内存在栈区为变量开辟相应的空间。intval=10;//在内存中栈区中开辟大小为4Byte大小的空间chararray[10]={0};//在内存中栈区中开辟大小为10Byte大小的连续的空间...上述方式开辟空间的特点:空间开辟大小是固定的,开辟好......
  • (待完善)C/C++ Language Standard
    C89/C90(ANSICorISOC)wasthefirststandardizedversionofthelanguage,releasedin1989and1990,respectivelyC99(ISO/IEC9899:1999)C11(ISO/IEC9899:2011)C18(ISO/IEC9899:2018)ThefirstversionofCwascalled"ASystemProgrammingLang......