深度探索C++对象模型(3)
在学习完类对象的构造后,下面就需要学习类数据成员和函数成员的存取。
编译器对于类对象的处理方式:(1)对于空类,编译器为该类添加一个char类型的成员,用来唯一标识该类在内存的位置(2)使用对齐机制,当一个类的内存字节数不足4的倍数将自动补充,目的是为了寻址的方便
有些编译器对于空类的处理进行了优化处理,仅当该空类被继承的时候,空类对象在子类对象中不占用任何内存,单独空类的大小仍是1个字节,这样可能会避免对齐机制,优化了C++对象模型的内存空间。
但是对于非空类,不处理和优化处理对于类对象的内存空间没有任何改变。
例如:
class A{};
class X:public virtual A{};
class Y:public virtual B{};
Class Z:public X,public Y{};
两种处理机制得到的各个类的大小:
未优化 优化后
1 1
8 4
8 4
12 8
--------------------------------------------------------------------------------------------------------
一、数据成员深度探索
1、数据成员在类中声明的位置尽量靠前,虽然C++标准没有规定声明顺序,但是为了防范全局变量和局部成员变量产生二义性,所以尽量在声明函数前声明全部变量。
inline函数在未定义之前不去去决断其中使用的变量,但若声明之后立即定义,则会判断函数前声明的变量。
2、nostatic数据成员是在对象存储空间中存放的,static数据成员存在在静态存储区中,每个类只用一份实例(除了模板类)。nostatic数据成员在内存中的布局要求是:在同一访问权限段(public private protected)中,晚声明的成员的地址比早声明的成员的地址高即可,不要求相邻。
3、编译器为了实现某种机制为类对象添加的成员,如vptr指针,它的存放位置C++标准并没有限制,可以放在对象首部,也可以放在尾部。放在首部方便了对虚函数调用,放在尾部可以与C语言中的结构体相兼容,各有好处,视编译器而定。
4、数据成员的存取
考虑通过对象存取成员和通过对象指针存取成员有什么区别?
(1)static数据成员的存取
由于static数据成员存储在程序的静态存储区中,当通过类对象、对象指针或者类::方式存取该成员是,编译器将内部转化为对静态变量的存取。因为static数据成员并不存储在类对象空间中,所以对静态成员的存取不需要经过对象,因此通过对象和指针存取static变量没有任何差异。
若取static数据成员的地址,将会得到该成员在内存中的实际地址,而且其指针类型和普通指针类型是相同的,而nostatic数据成员则有所区别。
static int a;为一个A类中的一个static成员,声明一个指向它的指针应该这样声明:int *p=&A::a;指针的使用也和普通指针相同。
(2)nostatic数据成员的存取
nostatic数据成员的存取必须通过对象或指向对象的指针,因为nostatic数据成员的地址依赖于对象的存储地址,编译器会在存取nostatic成员时加上this指针(指向对象的起始地址),通过this指针和nostatic成员在对象中的offset值存取该成员。
换句话说,nostatic数据成员的物理地址可表示为this+offset。
所以,取某个类中的nostatic数据成员地址得到的是该成员在对象中存储offset,即&A::b转化成指针类型就是int A::*p=&A::b;想要使用p还需要通过对象才能完成如a.*p==a.b;
注意:&A::b的到的值在编译器端将会自动加1,也就是说真实的offset=&A::b-1,这样做的目的是为了区别空的指向数据成员指针(0)和非空指向数据成员指针。当调用指针的时候首先将指针值减1,如果是空的成员指针的话将不能调用。这样就可以把空指针(0)和指向首部成员的指针(0)分开。
例如int A::*P1=0;int A::*p2=&A::b;则p2的值是1(假设b放在对象首部,vptr在尾部),当调用时想将p2-1+this获取成员地址,空指针则是-1。
1)单一继承(非虚拟继承):子类总是把基类对象放在子类对象的首部,然后才放子类自己的成员。因此,子类通过对象或者通过对象指针访问基类成员不会存在间接性,基类成员在编译期就可以确定其offset值(基类成员在基类中的offset值和在子类中的offset值是一样的)。因为基类对象在子类对象的首部,这样当基类指针被子类赋值时,基类指针仍然指向基类对象起始地址。
当存在多态时,编译器会自动根据vptr的位置修改数据成员的offset值。
2)多重继承(非虚拟继承):此种情况较上述情况麻烦,需要编译器进行地址转换。基类按照继承生命的顺序在继承类中排列,因此第一个基类的地址不需要转化,直接复制即可。而处于中间的基类的地址就需要通过this+中间类的大小才能够确定,这个工作由编译器完成。地址转化的时候首先判断子类地址是否为零。
这种情况下,存取基类对象中的成员在编译器是就确定了offset值,因此通过对象和指针访问基类对象不会存在差异。
3)虚拟继承:虚拟继承使得继承类无论继承多少个虚拟基类,都会只包含一个虚拟基类对象。那么,虚拟基类对象在继承类对象内存分布中就只存在一个基类对象实例。现在通用的是将虚拟基类对象放在继承类对象的尾部,继承类其他成员在虚拟基类对象上面。
那么,虚拟基类对象在每个继承类中的offset值是不同的,因此如果通过对象指针存取基类对象成员,不能在编译期确定基类成员的offset值。但是,通过对象存取基类成员时是在编译期确定offset值。
比如:
Vertex3D v3d;
Point3D *p=&v3d;
那么p->_x的步骤是:v3d的this指针指向Point3D子对象的地址传递给p,然后通过p查询虚表找到虚拟基类对象的地址,再根据offset进行相应的修改即可存取_x。
如果还按照之前的offset值,_X在Point3D中的offset是13,但是_x在Vertex3D中offset不是13,这样就可能提取错误的值,因此,对虚拟基类对象成员的提取将通过虚表间接获得基类对象的地址进行转换。
在虚拟继承时,使用对象和对象指针存取基类对象成员会产生差异,使用对象指针存取基类对象成员时,必须等到运行期进行判断指针指向的真正类型才能确定offset值。