深度探索C++对象模型<七>

The Semantics of Data

前言

一个类的大小主要受三个因素影响:

  • 语言本身带来的额外负担。比如如果是virtual base class,往往需要一个指针去指向某个virtual base class subobject;

  • 编译器的特殊处理。比如有些编译器会为一个空类(继承自另外一个空类)添加1个byte,但有些编译器会做出优化;

  • alignment。对齐;

另外需要注意的是,nonstatic data members也是放在类对象中,即使是继承而来的nonstatic data members也是一样,但static data members则放在程序的一个global data segment中

Data Member Layout

非静态成员变量在类的对象中的排列顺序与其声明顺序是一致的。

如果多个数据成员分布在不同的access session(public,private,protected)中,那么members的排列应该满足:较晚出现的成员应该在高地址,并且允许将多个access session连锁在一起,成为一个连续的区块,也就是你在一个session内声明8个变量,和在8个相同的session内声明8个变量对于内存布局是一样。

Data Member的存取

先抛出一个问题:

1
2
3
Point3d origin, *pt = &origin;
origin.x = 0.0;
pt->x = 0.0;

分别通过指针和对象去读取,会有什么不同呢?

Static Data Members

如果是以这两种方式去读取静态成员,那么是一样的。因为都会转化成对唯一的实体进行直接操作,例如:

1
2
3
4
5
//origin.chunkSize == 250;
Point3d::chunkSize == 250;

//pt->chunkSize == 250;
Point3d::chunkSize == 250;

由于静态数据成员是放在全局数据区,如果有两个类声明同名的静态数据成员,编译器会为每一个static data member进行唯一编码。

Nonstatic Data Members

这里需要注意的是,对一个非静态成员进行存取,编译器会通过把一个class object的起始地址加上data member的偏移量进行存取。

这里,如果是通过对象去存取成员变量,成员变量的偏移量会在编译时就已经得知,效率较高;

而如果一个类是派生类,其通过指针去存取它的成员变量,我们无法得知指针指向哪个class type,必须要等到运行时才确定下来,同时还要经历额外的间接导引才能找到那个数据成员。

继承与Data Members

考虑两个类Point2d, Point3d,如果一种组织方式是形成两个独立的类,另外一种的组织方式是形成继承链。那么会有声明不同。

Inheritance without Polymorphism

假如我们考虑Point3d继承自Point2d,但其中没有virtual functions,也就没有了额外的空间负担(vptr)。

但这种做法会带来的一个错误是,把一个class分解为多层,可能会带来的一个问题是抽象的结构会变得膨胀。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Concrete {
public:
//...
private:
int val;
char c1;
char c2;
char c3;
};

//分解成
class Concrete1 {
public:
//...
private:
char c1;
};

class Concrete2: public Concrete1 {
public:
//...
private:
char c2;
};

class Concrete1: public Concrete3 {
public:
//...
private:
char c3;
};

32位的机器下,对于第一种不分解的情况,类的空间大小为8bytes,而第二种情况,Concrete3变成了16bytes,因为自定义的数据类型是在继承得来的subject的padding后面进行补全的。所以就浪费了更多的padding空间。

另外一个容易出现的问题是,考虑这种情况:

1
2
3
4
5
6
Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;
pc1_1 = pc2;//另pc1_1指向Concrete2对象

//结果就是pc1_2指向的对象的成员变量可能会产生异常值
*pc1_2 = *pc1_1;

由于内存布局的不同,直接的复制操作执行的可能是复制一个一个的member,因此有可能会把原来的padding给覆盖掉。具体得看编译器如何实现,是把数据成员与concrete1的subject捆绑在一起,还是其它的实现。

Adding Polymorphism

如果继承链条(考虑一个point3d继承自point2d)中有虚函数,那么会带来如下的额外负担:

  • point2d会有一个相关联的virtual table,存放这每一个virtual function的地址,则table的元素数目与virtual function的数目相同,另外可能还有一些slots,用以支持run time identification

  • 为每一个class object提供一个vptr,在运行时能够找到virtual table的位置

  • 扩充constructor,使得其能够初始化vptr

  • 扩充destructor,能够摸消指向class相关的virtual table的vptr

关于vptr放在class object的位置有两种做法,一种是放在末端,为的是保留base class C struct的布局;另一种做法则是放在前端,带来的好处是不用在运行期测量出vptr的偏移量

Multiple Inheritance

对于多重继承,如果想要用父类的指针指向一个对象,那么在内存布局和指针的设置上会有更复杂的操作。

假如是这样的继承状态

img

其对象模型会有比较大的不同,与单一继承相比

img

这是因为,如果只是将地址赋予第一个base class会比较简单,和单一继承一样。因为两者指向相同的地址;如果是赋予第二个或者后续的base class的话,就需要修改地址,加上偏移位置。

Virtual Inheritance

虚拟继承的目的就是为了解决如何在派生类里维护一份基类的实体。

对于这样含有多个base class subject的类,会被分割为2个部分:不变局部和共享局部。由于共享局部拥有稳定的offset,可以直接存取;而对于共享局部,由于其位置会随着派生而变化,所以如何进行存取这部分的内容就成了一个难点。

一般来说,会先安排好不变局部,再去建立共享局部。而且编译器会在每一个派生类对象中安插指针,指向virtual base class。

这样就是通过派生类里面的指针去间接的索引共享部分的数据,但有所不同的是,有些编译器会优化使得存取时间和空间负担变得固定。