Class_memory

接上一篇末尾虚拟继承的简单介绍之后,这篇来详细讲一下这个内存大小是怎么分配的。

使用cl

cl 是 Microsoft Visual Studio 中的 C/C++ 编译器命令。通过在命令行中键入 cl 命令,可以调用 Visual Studio 的编译器进行编译操作。cl 命令提供了各种选项和参数,用于指定源文件、编译选项、输出目标等信息,从而进行编译过程。

cl /d1 reportSingleClassLayoutBigBase useclub.cpp这是我们要查看内存使用的命令,具体的语法是

cl /d1 reportSingleClassLayoutSSSS MMMM.cpp

SSSS代表的是你想要查看的类,MMMM就是所要针对的文件名。

这条命令 cl /d1 reportSingleClassLayoutSSSS MMMM.cpp 是使用 Microsoft Visual C++ 编译器的特殊选项来生成有关指定类的单个类布局报告的命令。

具体来说:

  • /d1 是一个编译选项,用于启用或禁用某些特定的编译器功能。
  • reportSingleClassLayoutSSSS 是一个编译器选项,用于指示编译器生成关于类的单个类布局报告。在这里,SSSS 是指要生成报告的类的名称。
  • MMMM.cpp 是包含类定义的源文件。

通过运行这个命令,编译器将会生成关于指定类的单个类布局报告,其中包括类的成员变量在内存中的偏移量、大小等信息。这对于调试和优化代码时了解类的内部布局非常有用。

image-20240301185724567

但是最初我们还没有把cl命令放进环境变量里,所以无法运行。这个是VS自带的一个工具,所以无法运行。这个是VS自带的一个工具,根据型号我们选择的都是X86,找到图里三个文件的位置放进PATH环境里image-20240301180546409

image-20240301190109394

完成这一步之后运行cl命令就没问题了。image-20240301190346504

但是又会出现找不到头文件位置fatal error C1034: iostream: 不包括路径集这种问题是由于头文件也还没有导入到系统变量里面。image-20240301190717870

所以在 Windows 系统中,INCLUDE 环境变量是用于指定 C/C++ 编译器在编译过程中搜索头文件的路径的一个环境变量。当您编译 C/C++ 代码时,编译器会根据 INCLUDE 环境变量中指定的路径去查找要包含的头文件。

通常情况下,INCLUDE 环境变量会包含一系列目录路径,这些路径是编译器用来搜索头文件的位置。如果您使用的是 Visual Studio 或者其他集成开发环境,通常会自动配置好这个环境变量,使得编译器可以顺利地找到所需的头文件。编译器会自动查找 C:\Program Files (x86)\Windows Kits\10\Include 目录中的头文件,因此开发人员无需手动指定这个路径,所以我们把这里的文件都放进去,并且变量名命名为INCLUDE。

image-20240301191340335

此时的问题就变为了下图的新问题fatal error LNK1104: 无法打开文件'libcpmt.lib'

image-20240301191424104

在 Windows 系统中,LIB 环境变量是用于指定编译器在链接过程中搜索库文件的路径的一个环境变量。当您使用编译器链接代码时,编译器会根据 LIB 环境变量中指定的路径去查找要链接的库文件。

类似于 INCLUDE 环境变量用于指定头文件路径,LIB 环境变量用于指定库文件路径。这些库文件包括静态库(.lib)和动态链接库(.dll)等,它们包含了已经编译好的函数和数据结构,可以供程序在运行时调用和使用。

通常情况下,LIB 环境变量会包含一系列目录路径,这些路径是编译器用来搜索库文件的位置。如果您使用的是 Visual Studio 或其他集成开发环境,通常会自动配置好这个环境变量,使得编译器可以顺利地找到所需的库文件。

所以现在再把lib相关的路径也添加进去

image-20240301191630683

image-20240301191717161

这下每个类的内存图就都有了。

使用内存图

类名 普通继承 虚继承
BigBase image-20240301193220732 image-20240301191926745
Base1 image-20240301193156218 image-20240301191951830
Base2 image-20240301193040510 image-20240301192007263
Derived image-20240301193015307 image-20240301192029630

因为使用了虚拟继承,所以会涉及到虚表指针(vptr)和虚表(vtable)的处理,以及对齐等问题。在大多数情况下,编译器为了内存对齐的目的会在数据成员后面填充一些字节,以保证存取效率。因此,尽管只有一个成员变量,但实际占用的空间会比较大。

首先注意到一点是上一篇中讲到这里使用sizeof(Derived)得到了24,但是此时的大小是12image-20240301192714292

这两个大小指的是不同的概念。

sizeof(Derived) 表示派生类 Derived 对象所占用的内存大小。在这个例子中,Derived 类包含了一个虚基类 BigBase 的子对象和一些额外的信息,比如虚函数表等。因此,sizeof(Derived) 的结果为 24。

sizeof(class Derived) 表示仅考虑 Derived 类本身所需的内存大小,不包括其继承的部分。在这个例子中,Derived 类本身没有定义任何成员变量,因此 sizeof(class Derived) 的结果为 12,表示只包含了一些额外的信息,比如虚函数表等。

总结起来,sizeof(Derived) 考虑了整个 Derived 对象所需的内存大小,包括继承的部分和额外的信息,而 sizeof(class Derived) 只考虑了 Derived 类本身的大小。

但是我们可以看到在普通继承关系当中,Derived 类同时继承了 Base1Base2 两个类,而这两个类又都直接继承自 BigBase 类。这种多重继承的情况下,如果没有使用虚拟继承(virtual),每个基类会在派生类中各自存在一份实例,导致内存占用增加。

因为 Base1Base2 都直接继承自 BigBase,所以在 Derived 类中将会包含两份 BigBase 的子对象,每份包含一个 int 类型的成员变量 mParam。这就导致了 Derived 对象的大小等于 Base1Base2BigBase 中成员变量的总和,所以大小和类本身一样。

换句话说,每个基类都会在派生类中引入自己的成员变量和函数,而不会共享相同的基类实例。这种情况下,派生类的大小等于各个基类的大小之和,因此大小和类本身一样。

两种继承方式对比着看,

  1. BigBase 菱形最顶层的类,内存布局图没有发生改变。

  2. Base1和Base2通过虚继承的方式派生自BigBase,这两个对象的布局图中可以看出编译器为我们的对象中增加了一个vbptr (virtual base pointer),vbptr指向了一张表,这张表保存了当前的虚指针相对于虚基类的首地址的偏移量。

  3. Derived派生于Base1和Base2,继承了两个基类的vbptr指针,并调整了vbptr与虚基类的首地址的偏移量。

由此可知编译器帮我们做了一些幕后工作,使得这种菱形问题在继承时候能只继承一份数据,并且也解决了二义性的问题。现在模型就变成了Base1和 Base2 Derived三个类对象共享了一份BigBase数据。

当使用虚继承时,虚基类是被共享的,也就是在继承体系中无论被继承多少次,对象内存模型中均只会出现一个虚基类的子对象(这和多继承是完全不同的)。即使共享虚基类,但是必须要有一个类来完成基类的初始化(因为所有的对象都必须被初始化,哪怕是默认的),同时还不能够重复进行初始化,那到底谁应该负责完成初始化呢?C++标准中选择在每一次继承子类中都必须书写初始化语句(因为每一次继承子类可能都会用来定义对象),但是虚基类的初始化是由最后的子类完成,其他的初始化语句都不会调用。

class A {
public:	
	A() {
		cout << "A(): " << endl;
	}
};
class B : virtual public A {
public:
	B() :A() {
		cout << "B():A(): " << endl;
	}
};
class C : virtual public A {
public:
	C() :A() {
		cout << "C():A(): " << endl;
	}
};
class D : public C, public B {
public:
	D() {
		cout << "D() " << endl;
	}
};
void test() {
	D d;
}

image-20240301223934521

这个输出代表了对象的构造顺序。根据输出可以看出:

  1. 首先,类 A 的构造函数被调用,输出 "A(): "。
  2. 接着,类 C 的构造函数被调用,由于 C 类继承了虚基类 A,所以会先调用 A 的构造函数,输出 "C():A(): "。
  3. 然后,类 B 的构造函数被调用,同样会先调用 A 的构造函数,输出 "B():A(): "。
  4. 最后,类 D 的构造函数被调用,由于 D 类同时继承了类 C 和类 B,而这两个类都已经初始化过虚基类 A 的部分,所以在 D 的构造函数中不需要再次调用 A 的构造函数。

由于类D是多重继承体系中的最底层类,它同时继承了类C和类B,而这两个类都间接继承了虚基类A。在这种情况下,编译器会负责确保虚基类A只被初始化一次