深度探索C++对象模型(三)Data语意学

(一)Data Member的布局

Nonstatic data members在class object 中的排列顺序将和其被声明的顺序一样,任何中间介入的static data members都不会被放进对象布局之中。static data members存放在程序的data segment中,和个别的class objects无关。

C++ Standard要求,在同一个access section(也就是private、public、protected等区段)中,members的排列只需符合“较晚出现的members在class object中有较高的地址”这一条件即可。也就是说,各个members并不一定得连续排列。什么东西可能会介于被声明的members之间呢?members的边界调整(alignment)可能就需要填补一些bytes。

编译器还可能会合成一些内部使用的data members,以支持整个对象模型。vptr就是这样的东西,目前所有的编译器都把它安插在每一个内涵“virtual function之class”的object内。传统上vptr被放在所有显式声明的members的最后。不过如今也有一些编译器把vptr放在一个class object的最前端。C++ Standard秉承先前所说的“对于布局所持的放任态度”,允许编译器把那些内部产生出来的members自由地放在任何位置上,甚至放在那些被程序员声明出来的members之间。

C++ Standard也允许编译器将多个access sections之中的data members自由排列,不必在乎它们出现在class声明中的顺序。

(二)DATA Member的存取

1、Static Data Members

Static Data Members,按其字面意义,被编译器提出于class之外,并被视为一个global变量(但只在class生命范围之内可见)。每一个member的存取许可(private、protected或public),以及与class的关联,并不会招致任何空间或执行时间上的额外负担——不论是在个别的class objects还是在static data member本身。
每一个static data member只有一个实例,存放在程序的data segment之中。每次程序参阅(取用)static member时,就会被内部转化为对该唯一extern实例的直接参考操作。
“经由member selection operators(也就是“.”运算符)对一个static data member进行存取操作”只是文法上的一种便宜行事而已。member其实并不在class object之中,因此存取static members并不需要通过class object。
若取一个static data member的地址,会得到一个指向其数据类型的指针,而不是一个指向其class member的指针,因为static member并不内含在一个class object之中。

如果有两个classes,每一个都声明了一个static member freeList,那么当它们都被放在程序的data segment时,就会导致名称冲突。编译器的解决方法是暗中对每一个static data member编码(这种手法有个很美的名称:name-mangling),以获得独一无二的程序识别代码。
任何name-mangling做法都有两个重点:
1、一个算法,推导出独一无二的名称。
2、万一编码系统(或环境工具)必须和使用者交谈,那些独一无二的名称可以轻易被推导回到原来的名称。

2、Nonstatic Data Members

Nonstatic data members直接存放在每一个class object之中。除非经由显式的(explicit)或隐式(implicit)class object,否则没有办法直接读取它们。只要程序员在一个member function中直接处理一个nonstatic data member,所谓“implicit class object ”就会发生。
欲对一个Nonstatic data member进行存取操作,编译器需要把class object的起始地址加上data member的偏移位置(offset)。
每一个nonstatic data member的偏移地址(offset)在编译时期即可获知,甚至如果member属于一个base class subobject(派生自单一或多重继承串链)也是一样的。因此,存放一个nonstatic data member,其效率和存取一个C struct member或一个nonderived class的member是一样的。

虚拟继承将为“经由base class subobject存取class members”导入一层新的间接性,比如:

Point3d *pt3d;
pt3d->_x=0.0

其执行效率在_x是一个struct member、一个class member、单一继承、多重继承的情况下都完全相同。但如果_x是一个virtual base class的member,存取速度会稍慢一点。

以两种方法存取x坐标,像这样:

origin.x=0.0;
pt->x=0.0;

“从origin存取”和“从pt存取”有什么重大的差异?答案是“当Point3d是一个derived class,而其继承机构中有一个virtual base class,并且被存取的member(如本例的x)是一个从该virtual base class继承而来的member”时,就会有重大的差异。这时候我们不能够说pt必然指向哪一种class type(因此,我们也就不知道编译时期这个member真正的offset位置),所以这个存取操作必须延迟至执行期,经由一个额外的间接导引,才能够解决。但如果使用origin,就不会有这些问题,其类型无疑是Point3d class,而即使它继承自virtual base class,members的offset位置也在编译时期就固定了。一个积极进取的编译器甚至可以静态地经由origin就解决掉对x的存取。

(三)“继承”与Data Member
在C++继承模型中,一个derived class object所表现出来的东西,是其自己的members加上其base class(es) members的总和。至于derived class members和base class(es) members的排列顺序,则并未在C++ Standard中强制指出;理论上编译器可以自由安排之。在大部分编译器上头,base class members总是先出现,但属于virtual base class的除外(一般而言,任何一条通则一旦碰上virtual base class就没辙了,这里亦不例外)。
1、只要继承不要多态
一般而言,具体继承(相对于虚拟继承)并不会增加空间或存取时间上的额外负担。
把两个原本独立不相干的classes凑成一对“type/subtype”,并带有继承关系,经验不足的人可能会设计一些相同操作的函数。第二个容易犯的错误是,把一个class分解为两层或更多层,有可能会为了“表现class体系之抽象化”而膨胀所需的空间。C++语言保证“出现在derived class中的base class subobject有其完整原样性”。
2、加上多态
加上多态后将对类带来空间和存取时间上的额外负担:
1)导入一个和类有关的virtual table,用来存放它所声明的每一个virtual functions的地址。这个table的元素个数一般而言是被声明的virtual functions的个数,在加上一个或两个slots(用以支持runtime type identification)。
2)在每一个class object中导入一个vptr,提供执行期的链接,使每一个object能够找到相应的virtual table。
3)加强constructor,使其能够为vptr设定初值,让它指向class所对应的virtual table。这可能意味着在derived class和一个base class的constructor中,重新设定vptr的值。其情况视编译器优化的积极性而定。
4)加强destructor,使它能够抹消“指向class之相关virtual table”的vptr。要知道,vptr很可能已经在derived class destructor中被设定为derived class的virtual table地址。记住,destructor的调用顺序是反向的:从derived class到base class。一个积极的优化编译器可以压抑那些大量的指定操作。

把vptr放在class object的尾端,可以保留base class C struct的对象布局,因而允许在C程序代码中也能使用。这种做法在C++最初问世时,被许多人采用。
到了C++ 2.0,开始支持虚拟继承以及抽象基类,并且由于面向对象范式(OO paradigm)的兴起,某些编译器开始把vptr放到class object的起头处。
把vptr放在class object的前端,对于“在多重继承之下,通过指向class members的指针调用virtual function”,会带来一些帮助。否则,不仅“从class object起始点开始量起”的offset必须在执行期备妥,甚至于class vptr之间的offset也必须备妥。当然,vptr放在前端,代价就是丧失了C语言兼容性。
3、多重继承
多重继承的问题主要发生于derived class objects和其第二或后继的base class objects之间的转换。
对于一个多重派生对象,将其地址指定 给“最左端(也就是第一个)base class的指针”,情况将和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的指定操作而已。至于第二个或后继的base class的地址指定操作,则需要将地址修改过:加上(或减去,如果downcast的话)介于中间的base class subobjects(s)大小。
4、虚拟继承
class如果内含一个或多个virtual base class subobjects,像istream那样,将被分割为两部分:一个不变区域和一个共享区域。不变区域 中的数据,不管后继如何衍化,总是拥有固定的offset(从object的开头算起),所以这一部分数据可以被直接存取,至于共享区域,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。各家编译器实现技术之间的差异就在于间接存取的方法不同。

以下说明三种主流策略。
1)、一般的布局策略是先安排好derived class的不变部分,然后再建立其共享部分。
然而,这中间存在着一个问题:如何能够存取class的共享部分呢?cfront编译器会在每一个derived class object中安插一些指针,每个指针指向一个virtual base class。要存取继承得来的virtual base class members,可以通过相关指针间接完成。

这样的实现模型有两个主要的缺点:
a、每一个对象必须针对其每一个virtual base class背负一个额外的指针。然而;理想上我们却希望class object有固定的负担,不因为其virtual base classes的个数而有所变化。
b、由于虚拟继承串链的加长,导致间接存取层次的增加。这里的意思是,如果我有三层虚拟派生,我就需要三次间接存取(经由三个virtual base class指针)。然而理想上我们却希望有固定的存取时间,不因为虚拟派生的深度而改变。

至于第一个问题,一般而言有两个解决方法。Microsoft编译器引入所谓的virtual base class table。每一个class object如果有一个或多个virtual base classes,就会由编译器安插一个指针,指向virtual base class table。至于真正的virtual base class指针,当然是被放在该表格中。虽然此法已行之有年,但是不知道还有没有其他任何编译器使用此法。
第二个解决方法,是在virtual function table中放置virtual base class的offset(而不是地址)。virtual function table可经由正值或负值来索引。如果是正值,很显然就是索引到virtual functions;如果是负值,则是索引到virtual base class offsets。 在这种策略之下,对于继承而来的members做存取操作,成本会比较昂贵,不过此成本已经被分散至“对member的使用”上,属于局部性成本。

(四)对象成员的效率
单一继承应该不会影响效率,因为members被连续存储于derived class object中,并且其offset在编译时期就已知了。
虚拟继承的效率令人失望!两种编译器都没能够辨识出对“继承而来的data member ptld::_x”的存取是通过一个非多态对象(因而不需要执行期的间接存取)进行的。两个编译器都会对ptld::x(及双层虚拟继承中的pt2d::_y)产生间接存取操作,虽然其在Point3d对象中的位置早在编译时期就固定了。“间接性”压抑了“把所有运算都移往寄存器执行”的优化能力。但是间接性并不会严重影响非优化程序的执行效率。

(五)指向Data Members的指针
指向data members的指针,是一个有点神秘但颇有用处的语言特性,特别是如果你需要详细调查class members的底层布局的话。这样的调查可以决定vptr是放在class的起始处或是尾端。另一个用处,用来决定class中的access sections的顺序。
考虑下面的Point3d声明。其中有一个virtual function,一个static data member,以及三个坐标值:

class Point3d
{
public:
    virtual ~Point3d();
    //...
    protected:
    static Point3d origin;
    float x,y,z;
};

C++ Standard允许vptr被放在对象中的任何位置:在起始处,在尾端,或是在各个members之间。然而实际上,所有编译器不是把vptr放在对象的头,就是放在对象的尾。

& Point3d::z;

上述操作将得到z坐标在class object中的偏移位置(offset)。最低限度其值将是x和y的大小总和,因为C++语言要求同一个access level中的members的排列顺序应该和其声明顺序相同。
如果vptr放在对象的尾端,三个坐标值在对象布局中的offset分别是0,4,8。如果vptr放在对象的起头,三个坐标值在对象布局中的offset分别是4,8,12。然而你若去取data members的地址,传回的值总是多1,也就是1,5,9或5,9,13等等。你知道为什么吗?

问题在于,如何区分一个“没有指向任何data member”的指针,和一个指向“第一个data member”的指针?考虑这样的例子:

float Point3d::*p1=0;
float Point3d::*p2=&Point3d::x;
//Point3d::*的意思是:“指向Point3d data member”的指针类型。
//如何区分?
if(p1==p2)
{
    cout<<"p1 & p2 contain the same value --";
    cout<<"they must address the same member!"<

为了区分p1和p2,每一个真正的member offset值都被加上1。因此,不论编译器或使用者都必须记住,在真正使用该值以指出一个member之前,请先减掉1。

认识“指向data member的指针”之后,我们发现,要解释:
& Point3d::z;

& origin.z;
之间的差异,就非常明确了。鉴于“取一个nonstatic data member的地址,将会得到它在class中的offset”,取一个“绑定于真正class object身上的data member”的地址,将会得到该member在内存中的真正地址。把& origin.z所得结果减z的偏移值(相对于origin起始地址),并加1,就会得到origin起始地址。

“指向Members的指针”的效率问题
由于被继承的data members是直接存放在class object之中的,所以继承的引入一点也不会影响这些代码的效率。虚拟继承所带来的主要冲击是,它妨碍了优化的有效性。每一层虚拟继承都导入一个额外层次的间接性。额外的间接性会降低“把所有的处理都搬移到寄存器中执行”的优化能力。

你可能感兴趣的:(C和C++)