【深度探索C++对象模型】第三章 Data语意学
C++对象模型尽量以空间优化和存取速度优化的考虑来表现nonstatic data members,并且保持和C语言struct数据配置的兼容性,它把数据直接存放在每一个class object中。对于继承而来的nonstatic data members也是如此。不过并没有强制定义其间的排列顺序。至于static data members,则被放置在程序的一个global data segment中,不会影响个别class object的大小,static data members永远只存在一份实例。
每一个class object必须有足够的大小以容纳它所有的nonstatic data members。但有时候可能比预想的还大,原因是:
有编译器自动加上额外的data members,用以支持某些语言特性(主要是各种virtual特性);
因为alignment(边界调整)的需要。
Data member的绑定
Data member的布局
已知下面一组data member:
class Point3d{ public: //…… private: float x; static List<Point3d*> freeList; float y; static const int chuckSize=250; float z; };
Nonstatic data member在class object中的排列顺序和其被声明的顺序一样,任何中间介入的static data member如freeList、chuckSize都不会被放进对象布局中。在上例中Point3d对象是由3个float组成的,顺序是x,y,z。static data members存放在程序的data segment中,和个别的class object无关。
C++标准要求,在同一个access section(也就是private、public、protected等区段)中,members的排列顺序只需符合“较晚出现的members在class object中较高的地址”这一条即可。也就是说,各个members并不一定得连续排列。members的边界调整(alignment)可能需要补充一些bytes。
编译器还会合成一些内部使用的data members,以支持整个对象模型。vptr就是这样的东西。vptr传统上它被安放在所有显示声明memebers的最后,不过如今也有一些编译器把vptr放在一个class object的最前端。
C++允许编译器将多个access sections中的data members自由排列,不必在乎它们出现在class声明中的顺序,不过目前没有编译器这么做。
Data member的存取
已知下面这段代码:
Point3d origin; origin.x=0.0;
x的存取成本是什么?答案视x和Point3d如何声明而定。x可能是个static member,也可能是nonstatic member。Point可能是个独立(非派生)的class,也可能是从另一个单一的base class派生而来。
抛出问题。如果有两个定义,origin和pt:
Point3d origin,*pt=&origin;
用它们存取data members,例如:
origin.x=0.0; pt->x=0.0;
两种存取方式是否有重大差异?
Static Data Members
类的每个static成员都只有一个实例,存放在程序的data segment之中,和对象无关;因此对于这种情况,对x的存取并不会招致任何空间和时间上的额外负担。从指令执行的观点来看,这是C++语言中”通过一个指针和通过一个对象来存取member,结论完全相同“的唯一一种情况。
Nonstatic Data Members
欲对一个nonstatic data members进行存取操作,编译器需要把class object的起始地址加上data member的偏移地址。例如:
//源代码 origin._y=0.0; //编译后的代码 *(&origin+(&Point3d::_y-1))=0.0;
"继承"与Data member
只要继承不要多态
在有继承的情况下,可能会导致空间上的浪费。我们来看这样一个例子:
class Concrete{ public: //…… private: int val; char c1; char c2; char c3; };
这个类中存有一个int和三个char,如果我们把这些变量都放到一个类中声明,那么算上alignment,它的对象大小为8字节。
现假设Concrete分裂为三层结构:
class Concrete1{ public: //…… private: int val; char bit1; }; class Concrete2:public Concrete1{ public: //…… private: char bit2; }; class Concrete3:public Concrete2{ public: //…… private: char bit3; };
那么Concrete3的对象的大小将达到16字节,比原先的设计多了100%!
这是因为alignment(边界调整)导致的,因为C++的对象模型中,在一个继承而来的类的内存分布里,各个基类需要分别遵循alignment,从而导致了空间的浪费。具体地对象布局可见下图:
加上多态
在这种情况下,无论是时间还是空间上,访问类的成员都会带来一定额外的负担,主要体现在以下几个方面:
virtual table,用来存放它所声明的每一个virtual functions的地址。
每一个对象中会有一个vptr,提供执行期的链接。
编译器会重写constructor和destructor,使其能够创建和删除vptr
多重继承
多重继承既不像单一继承,也不容易模塑出其模型。多重继承的复杂度在于derived class和其上一个base class乃至上上一个base class……之间的“非自然”关系。例如,考虑下面这个多重继承所获得的class vertex3d:
class Point2d{ public: //…… protected: float _x,_y; }; class Point3d:public Point2d{ public: //…… protected: float _z; }; class Vertex{ public: //…… protected: Vertex *next; }; class Vertex3d:public Point3d,public Vertex{ public: //…… protected: float mumble; };
继承关系如下:
对一个多重派生对象,将其地址指定给”最左端(也就是第一个)base class的指针“,情况将和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的指定操作而已。至于第二个或后继的base class的地址指定操作,则需要将地址修改过:加上介于中间的base class subobject大小,例如:
Vertex3d v3d; Vertex *pv; Point2d *p2d; Point3d *p3d;
对于操作:
pv=&v3d;
需要这样的内部转化:
//虚拟C++代码 pv=(Vertex*)(((char*)&v3d)+sizeof(Point3d));
而对于下面操作:
p2d=&v3d; p3d-&v3d;
只需要简单地拷贝其地址就好。
虚拟继承
在虚拟继承中,C++对象模型将Class分为两个区域,一个是不变区域,直接存储在对象中;一个是共享区域,存储的是virtual base class subobjects,它在内存中单独存储在某处,derived class object持有指向它的指针。在cfront编译器中,每一个derived class object中安插一些指针,每个指针指向一个virtual base class,为此需要付出相应的时间和空间成本。如下所示:
class Point2d{ public: //…… protected: float _x,_y; }; class Point3d:public virtual Point2d{ public: //…… protected: float _z; }; class Vertex:public virtual Point2d{ public: //…… protected: Vertex *next; }; class Vertex3d:public Point3d,public Vertex{ public: //…… protected: float mumble; }; //具体的类同上一节多重继承,不同的是Vertex和Point3d虚拟继承了Point2d void Point3d::operator+=(const Point3d&rhs) { x+=rhs.x; y+=rhs.y; z+=rhs.z; } //编译器翻译后的版本 _vbcPoint2d->x+=rhs._vbcPoint2d->x; _vbcPoint2d->y+=rhs._vbcPoint2d->y; z+=rhs.z;