zhouqijie

Data语义概述

如果定义一些空的类,它们的size一般不会是0。
对象的大小收到三个因素的影响:

  1. 语言本身的额外负担。(例如如果有虚基类就需要存储指向虚基类subobject的指针或偏移)
  2. 编译器对于特殊情况提供的优化处理。
  3. 对齐(alignment)的限制。(使得它们能够更有效地在内存中被存取)

对象的size膨胀主要来自:

  1. 为支持语言特性(主要是virtual)而额外加上的data成员。
  2. alignment的需要。





数据成员的绑定(Binding)

在早期的编译器中,inline的函数体,在整个class声明未被完全看见之前,是不会被evaluate的。这有时会导致错误的绑定。所以早期C++通常把数据成员都放在class声明开头处,或者把inline函数不管大小都放在class声明之外。
除此之外,嵌套类型通常也要声明在class的起始处。





数据成员的布局(Layout)

非静态数据成员在类对象中的排列方式和其声明顺序一样(CRE:仅限同一个访问区段),任何静态数据成员都不会被放入对象布局中,而是放在程序的数据段(data segment),和个别的对象无关。

C++Standard要求,在同一个访问区段(也就是public、private区段)中,成员的排列只需要符合”较晚出现的成员在类对象中有较高的地址”这一条件即可。也就是说各个member之间不一定连续排列。alignment可能会填补一些bytes。

编译器还可能合成一些内部使用的数据成员,以支持整个对象模型,vptr就是这样的东西,传统上它被放在所有显式声明的最后,不过如今有些编译器把它放在最前面。

访问区段的出现顺序,视编译器而异。





数据成员的存取

Static数据成员

每一个Static数据成员只有一个实例,存放在程序数据段。每次程序取用Static成员,就会被内部转化为对该唯一extern实例的直接参考操作。

CRE:C++和C#不同的是,C++可以经由对象实例访问静态成员,虽然是一样的。

Non-Static数据成员

Nonstatic数据成员直接存放在每个类对象中。除非经由显式的或者隐式的(CRE:this指针就是隐式的)类对象,否则无法直接存取它们。

Point Point::translate(const Point& p)
{
    x += p.x;
    y += p.y;
    z += p.z;
}
//(成员函数的内部转化)
//事实上的函数是:
Point Point::translate(Point* const this, const Point& p)//隐式的对象this指针
{
    this->x += p.x;
    this->y += p.y;
    this->z += p.z;
}

对数据成员的访问,实际上就是对象地址加上数据的偏移量(offset)。其执行效率在struct成员、class成员、单一继承、多重继承的情况下都完全相同。但是如果是虚继承的成员,存取速度会稍慢。

从对象存取(通过.操作符)和从对象指针存取(通过->操作符)有什么重大差异?答案是当成员是虚基类中的数据成员时,就会有重大的差异。这个时候不知道指针指向的是哪个class类型,所以这个存取操作必须延迟至执行期。但是如果使用对象存取,就不会有这些问题,即使继承自虚基类,成员的偏移量就已经在编译时期固定了。





“继承”和数据成员

在C++继承模型中,一个派生类所表现出来的东西,是其自己的成员加上基类成员的总和。至于派生类和基类的排列顺序,则并未在C++Standard中强制指定,理论上编译器可以自有安排。但是在大部分编译器中,基类成员总是先出现。

只要继承不要多态

CRE:基类对象在子类中以subobject的形式存在。

CRE:C++语言保证“出现在派生类中的基类subobject有其完整原样性”。这样,继承关系就会导致alignment的padding层层叠加,最终导致派生类对象的膨胀。

加上多态

空间和时间上的额外负担:

  1. 导入一个virtual table,用它存放它所声明的每一个虚函数的地址。这个table的元素个数一般而言是被声明的虚函数的个数,再加上一两个slot用于支持runtime type identification
  2. 在每一个类对象中导入一个vptr,提供运行时期的链接,使每一个对象能够找到相应的virtual table。
  3. 加强构造函数,使它能够为vptr设定初值,让它指向class所对应的virutal table。这可能意味着在派生类和每一个基类的构造函数中,重新设定vptr的值。
  4. 加强析构函数,使它能够摸出“指向class之相关virtual table”的vptr。(析构函数的执行顺序和构造函数相反)

cfront把虚函数表放在对象的尾端。这样可以保留基类C struct的对象布局。到了后来C++开始支持虚拟继承和抽象基类,并且由于面向对象范式(OO paradigm)的兴起,某些编译器开始把vptr放到类对象开头处。代价就是丧失了C语言兼容性。

多重继承

单一继承提供了一种“自然多态”形式,是关于类体系中基类型和派生类型之间的转换。

多重继承下既不像单一继承,也不容易塑造出模型。多重继承的复杂度在于派生类和其生一个基类乃至上上个基类之间“非自然”的关系。

多重继承的问题主要发生在派生类对象和其第二或后继的基类之间的转换。

CRE:注意,多重继承一般会有多个vptr。

虚继承

如果出现类似菱形继承,想要数据成员不重复,解决方式就是导入虚拟继承。

一般的实现方法如下所述。class如果内含一个或多个虚基类subobject,将被分割成两个部分:一个不变区域和一个共享区域。不变区域中的数据,无论后继如何衍化,总是拥有固定的offset,所以这一部分数据可以直接被存取。至于共享区域,所表现的就是虚基类subobject。这一部分数据,其位置会因为每次的派生操作而有所变化,所以它们只能被间接存取。(各个编译器实现技术之间的差异就在于间接存取的方法不同)





对象成员的效率

如果把优化开关打开,“封装”就不会带来执行期的效率成本。使用inline存取函数亦然。
虚拟继承的效率很低。





指向数据成员的指针

//取得“指向数据成员的指针”的地址    
float Point::*ax = &Point::x;
//赋值、加法、减法等操作,都是使用“指向数据成员的指针”语法,把数据绑定到对象pa和pb中。    
pa.*bx = pa.*ax - pb.*bz;

以“指向成员的指针”来存取数据,可能效率较低,有些编译器都无法优化,假如有虚继承存在,更是难以优化。





(END)