一、类对象所占用的空间
(1)一个空类所占用空间是一个字节,如果有成员变量,就是成员变量占用的内存。为什么空类还要占一个字节的内存?这是因为创建了一个对象就要占用一个字节的地址,就像买房子一样,空房子也是占面积的。
(2)类的成员函数不占用类对象的内存空间,而成员变量占用对象的内存空间。
(3)成员变量是包含在每个对象中的,是占用对象字节的,有多少个对象就有多少个成员变量。而对于成员函数,每个类只诞生一个(跟着类走),而不管你用这个类产生了多少个该类的对象。
二、对象结构的发展和演化
(1)非静态的成员变量(普通成员变量)跟着类对象走(存在对象内部),也就是每个类对象都有自己的成员变量。
(2)静态成员变量跟对象没有什么关系,所以肯定不会保存在对象内部,是保存在对象外面(表示所占用的内存空间和类对象无关)的,所以不计算在类对象sizeof()内。
(3)成员函数:不管静态的还是非静态,全部都保存在类对象之外。所以不管几个成员函数,不管是否是静态的成员函数,对象的sizeof的大小都是不增加的。
(4)虚函数:不管有几个虚函数,sizeof()都是多了4个字节。类里只要有一个虚函数(或者说至少有一个虚函数),这个类就会产生一个指向虚函数的指针。有两个虚函数,那么这个类就会产生两个指向虚函数的指针。而类本身指向虚函数的指针(一个或者一堆)要有地方存放,存放在一个表格里,这个表格我们就称为“虚函数表(virtual table【vtbl】)”。这个虚函数表一般是保存在可执行文件中的,在程序执行的时候载入到内存中来。
(5)虚函数表是基于类的,跟着类走的。对于类对象,这四个字节的增加,其实是因为虚函数的存在;因为有了虚函数的存在,导致系统往类对象中添加了一个指针,这个指针正好指向这个虚函数表,很多资料上把这个指针叫vptr;这个vptr的值由系统在适当的时机赋值(比如构造函数中通过增加额外的代码来给值)。
(6)如果有多个数据成员,那么为了提高访问速度,某些编译器可能会将数据成员之间的内存占用比例进行调整(内存字节对齐)。
(7)不管什么类型指针char *p,int *q;,该指针占用的内存大小是固定的。
三、this指针调整:多重继承
(1)派生类对象是包含基类子对象的。
(2)如果派生类只从一个基类继承的话,那么这个派生类对象的地址和基类子对象的地址相同。
(3)如果派生类对象同时继承多个基类,那么就要注意:第一个基类子对象的开始地址和派生类对象的开始地址相同。后续这些基类子对象的开始地址和派生类对象的开始地址相差多少呢?那就得把前边那些基类子对象所占用的内存空间累加。
(4)调用哪个子类的成员函数,这个this指针就会被编译器自动调整到对象内存布局中对应该子类对象的起始地址那去。
四、构造函数语义
传统认识认为:如果我们自己没定义任何构造函数,那么编译器就会为我们隐式自动定义一个默认的构造函数,我们称这种构造函数为:“合成的默认构造函数”。
事实是:“合成的默认构造函数”,只有在必要的时候,编译器才会为我们合成出来,而不是必然或者必须为我们合成出来。那么编译器会在哪些必要的时候帮助我们把默认的构造函数合成出来呢?
(1)该类没有任何构造函数,但包含一个类类型的成员,而该对象所属于的类有一个缺省的构造函数。这个时候,编译器就会为该类生成一个 “合成默认的构造函数”,合成的目的是为了调用类类型成员所属类里的默认构造函数。
(2)父类带缺省构造函数,子类没有任何构造函数,那因为父类这个缺省的构造函数要被调用,所以编译器会为这个子类合成出一个默认构造函数。合成的目的是为了调用这个父类的构造函数。换句话说,编译器合成了默认的构造函数,并在其中安插代码,调用其父类的默认构造函数。
(3)如果一个类含有虚函数,但没有任何构造函数时,因为虚函数的存在,编译器会给我们生成一个基于该类的虚函数表vftable。此外编译器给我们合成了一个构造函数,调用了父类的构造函数,并且在其中安插代码,把类的虚函数表地址赋给类对象的虚函数表指针(赋值语句/代码)。我们可以把虚函数表指针看成是我们表面上看不见的一个类的成员函数。
(4)当我们有自己的默认构造函数时,编译器会根据需要扩充我们自己写的构造函数代码,比如调用父类构造函数,给对象的虚函数表指针赋值。
(5)如果一个类带有虚基类,编译器也会为它合成一个默认构造函数。虚基类:通过两个直接基类(虚基类)继承同一个间接基类。所以一般是三层 ,有爷爷Grand,有两个爹A,A2,有孙子C。有虚基类结构,编译器为子类和父类都产生了“合成的默认构造函数”。
五、拷贝构造函数语义
传统上,大家认为:如果我们没有定义一个自己的拷贝构造函数,编译器会帮助我们合成 一个拷贝构造函数。
事实上,这个合成的拷贝构造函数,也是在必要的时候才会被编译器合成出来。那编译器在什么情况下会帮助我们合成出拷贝构造函数来呢?这个编译器合成出来的拷贝构造函数又要干什么事情呢?
(1)如果一个类A没有拷贝构造函数,但是含有一个类类型CTB的成员变量m_ctb。该类型CTB含有拷贝构造函数,那么当代码中有涉及到类A的拷贝构造时,编译器就会为类A合成一个拷贝构造函数。编译器合成的拷贝构造函数往往都是干一些特殊的事情。如果只是一些类成员变量值的拷贝这些事,编译器是不用专门合成出拷贝构造函数来干的,编译器内部就干了,即成员变量初始化手法,比如int这种简单类型,直接就按值拷贝过去,编译器不需要合成拷贝构造函数的情况下就帮助我们把这个事情办了。再如类A中有类类型ASon成员变量asubobj,也会递归似的去拷贝类ASon的每个成员变量。
(2)如果一个类CTBSon没有拷贝构造函数,但是它有一个父类CTB,父类有拷贝构造函数,当代码中有涉及到类CTBSon的拷贝构造时,编译器会为CTBSon合成一个拷贝构造函数,调用父类的拷贝构造函数。
(3)如果一个类CTBSon没有拷贝构造函数,但是该类声明了或者继承了虚函数,当代码中有涉及到类CTBSon的拷贝构造时,编译器会为CTBSon合成一个拷贝构造函数 ,往这个拷贝构造函数里插入语句。这个语句的含义是设定类对象的虚函数表指针值。
(4)如果一个类没有拷贝构造函数,但是该类含有虚基类,当代码中有涉及到类的拷贝构造时,编译器会为该类合成一个拷贝构造函数。
六、程序转化语义
程序转化语义主要是理解编译器如何将人类写的代码解析成编译器理解的代码。为此,从两个角度来分析。下面的代码帮助更好的理解。
1 | class X |
我们写的代码,编译器会对代码进行拆分,拆分成编译器更容易理解和实现的代码。看一看编译器是如何解析这些代码的。
(1)定义时初始化对象
1 | //程序员视角 |
(2)参数的初始化
1 | //程序员视角/现代编译器 |
(3)返回值初始化
1 | //程序员角度 |
七、拷贝构造续与深浅拷贝
(1)当编译器面临用一个类对象作为另外一个类对象初值的情况,各个编译器表现不同。但是所有编译器都为了提高效率而努力。我们也没有办法确定我们自己使用的编译器是否一定会调用拷贝构造函数。
(2)拷贝构造函数不是必须有的,如果只有一些简单的成员变量类型,int,double,你会发现你根本不需要拷贝构造函数,编译器内部本身就支持成员变量的bitwise(按位)copy,即按位拷贝。
(3)当需要处理很复杂的成员变量类型的时候。因为我们增加了自己的拷贝构造函数,导致编译器本身的bitwise拷贝能力失效,它会调用我们自己的拷贝构造函数,因此如果你增加了自己的拷贝构造函数后,就要对各个成员变量的值的初始化负责了。
(4)我们自己创建内存,把目标对象的内存内容拷贝过来,叫深拷贝,例如下面的程序。相反,上述编译器做的拷贝叫浅拷贝。(注意:涉及到指针的时候,必须用深拷贝,不能浅拷贝,否则指针所指的内存会被析构两次,导致错误)
1 | class X |
八、初始化列表
(1)必须使用初始化列表的情况
(a)这个成员是个引用
(b)是个const类型成员
(c)如果这个类是继承一个基类,并且基类中有构造函数,这个构造函数里边还有参数。
(d)如果成员变量类型是某个类类型,而这个类的构造函数带参数时。
(2)使用初始化列表的优势。除了必须用初始化列表的场合,我们用初始化列表还有什么其他目的? 有,就是提高程序运行效率。对于类类型成员变量放到初始化列表中能够比较明显的看到效率的提升,但是如果是个简单类型的成员变量比如int m_test,其实放在初始化列表或者放在函数体里效率差别不大。(提醒:成员变量初始化尽量放在初始化列表里,显得高端,大气上档次)
(3)初始化列表中的代码可以看作是被编译器安插到构造函数体中的,只是这些代码有些特殊。这些代码是在任何用户自己的构造函数体代码之前被执行的。所以要区分开构造函数中的用户代码和编译器插入的初始化所属的代码。
(4)初始化列表中变量的初始化顺序是变量的定义顺序,而不是在初始化列表中的顺序。不建议在初始化列表中进行两个都在初始化列表中出现的成员之间的初始化(例如:m1和m2都在初始化列表中,不建议用m1来初始化m2,或用m2初始化m1)。