The Semantics Of Data

Class Data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

class A {};

class B : public virtual A {};

class C : public virtual B{};

int main() {
std::cout << "Size Test" << std::endl;
std::cout << "A:" << sizeof(A) << std::endl;
std::cout << "B:" << sizeof(B) << std::endl;
std::cout << "C:" << sizeof(B) << std::endl;
std::cout << "A Pointer:" << sizeof(A*) << std::endl;
return 0;
}

为什么?既然是空的类,那么更应该是0才对

然而输出的时候,A=1,B=8,C=8

不难理解,B和C存在vtpr,因此会占用4byte,而A并不是空,它存在隐藏的1byte

C++中一个类的实例需要得到区分,那么就需要类里面可以装点东西,例如一个空类,然后声明3个实例,有点编译器就在类中插入一个char以区分不同的实例

由于这个原因,在B和C上也会存在一个char,这样一来,B=C=5byte

为了更有效的存储,B和C会受到 Memory Alignment (内存对齐)的限制,从而被拓展为8byte

但是如果是那种对Empty Virtual Base Class进行特别优化的编译器,那么额外的那个char将会被拿去,只用vptr标示,这样B和C都是8byte

The Binding Of Data Member

在早期的C++编译器中,会出现class中的member function引用global object而不是data member的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extern float x;

class Test {
public:
float getX() {return x;} //将返回全局的x
private:
float x; //被无视掉
}

//所以会出现防御型风格的class
class Test {
private:
float x;
public:
float getX() {return x;};
}

因为当时对data member的绑定是遇到就开始的

虽然现在已经改为整个class声明结束才开始,但是对于typedef仍需要防御型风格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef int length;
class Test {
Test(length i) {} //i被定义为int类型
private:
typedef float length;
}

//防御型风格

typedef int length;
class Test {
typedef float length;
Test (length i) {}
}

Data Member Layout

c++要求,同一个access section中(private, public, protected区段)中,member的排列要符合较晚出现的members要在class object中有较高的位置

也就是说,各个member不一定连续,原因是Memory Alignment时产生的byte

此外,编译器还会插入一些内部生成的data members,例如vptr,不过由于c++对内部的data members的位置持放任态度,因此甚至可能将其放置在程序员声明对data members之间

Data Member的存取

static Data Member

这是一个被编译器提出到class之外的member

正因如此,每一个static data member只有一个实例,存放在程序的data segment

其存取并不需要通过object

1
2
3
4
5
6
7
8
9
10
11
class Test {
public:
static int i;
}

int main() {
Test k;
//k.i=34;等价于
Test::i=34;
return 0;
}

即便static data member是通过层层继承过来的,仍可以使用以上方法进行存取

Nonstatic Data Member

nonstatic data member存储在class object之中,只有借助显式或者隐式的class object才能存取它们

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Test {
public:
int i;
Test();
}
//所谓隐式
Test::Test() {
i = 0;
}
//等价于
Test::Test(Test * const this) {
this->i = 0;
}

//所谓显式
int main() {
Test t;
t.i = 0;
return 0;
}

那为什么non-static data member的存取需要借助class object

因为欲对一个non-static data member进行存取,编译器需要吧class object的起始地址加上data member的偏移地址

1
2
object.data = 0;
&object + (&Test::data - 1); //取data的offset,且offset值总是会被+1

此时,由于offset的值在编译期便可得知,所以这时non-static data member的存取效率和struct是一样的

但是,如果加上虚拟继承呢?

如果我们要存取的non-static data member是从virtual base class继承过来的,那么存取速度就会变慢

如下

1
2
3
4
5
Point3d origin;
Point3dBaseClass *pt;

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

x是从一个virtual base class中继承过来的,那么两种方法则会有很大差异

origin,我们知道它的起始地址就是Point3d的开头,那么,对xoffset也会在编译期确定下来

pt则不同,我们不知道它指向的类型是Point3d还是Point3d 的 Base Class,这样一来,offset也无法确定,所以存取操作必须延迟到执行期

继承与Data Member

derived class memberbase class member的位置理论上可以由编译器自由安排,但是在大部分的编译器中,base class member会首先出现,前提是不存在virtual base class

本节将讨论

  • 单一继承
  • 单一继承+virtual function
  • 多重继承
  • 虚拟继承

单一继承

单一继承并不会增加空间和存取时间上的额外负担

但是,如果是糟糕的设计,那么将会因为内存对齐膨胀所需空间,例如,把一个class分解为多层

由于C++保证,出现在derived class中的base class具有完整的原样性,也就是说,在base class中为内存对齐安插的空间不变

假设base class的member所占空间为1,derived class的member所占空间为3,对齐为4

那么布局如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
对齐4

base class: 1 + (3)
----
1
----
padding 3
----


derived class: 1 + (3) + 3 + 1
----
1
----
paddiing 3
----
3
----
padding 1

可以看出,derived class 所占空间是 1+(3)+3+(1),而不是1+3

为什么会这样?因为如果不这样的话,就会在进行某些操作时破坏整个类的空间

1
2
3
4
5
6
7
8
9
10
class1 *p1, *p3;
class2 *p2;
p1 = new class1();
p2 = new class2();
p3 = p2;//发生截断,截断大小为class1的大小,但是由于derived class的member入侵到了base class的空间
*p3 = *p1;
//在进行memberwise的操作时,由于按照固定的内存大小拷贝
//此时p1的空间没有被入侵,所以把p1空间复制到p3
//会将p3所指空间的derived class的member覆盖掉
//破坏了derived class的member

加上多态

支持多态性,势必会带来空间和存取时间上的负担

  • 导入virtual table,存放每一个virtual function所带来的负担
  • 导入vptr,提供vtbl地址所带来的负担
  • 在constructor中初始化vptr所带来的负担
  • 在deconstructor中,重设vptr

为什么要重设vptr?

1
2
3
4
5
6
7
8
9
10
11
12
class A {
A() {get();}
~A() {get();}
virtual void get();
};

class B : public A {
B() {get();}
void get();
};

A *p = new B();

p所指空间存在两种成分,A和B,p中只有一个vptr,p通过vptr调用virtual函数

为了使得在A和B的构造函数能调用正确的get(),我们可以控制vptr的指向达到这个目的

vptr的初始化在于base class构造之后,在member初始化之前

1
2
3
4
5
6
7
8
9
10
11
12
B() {
this->vptr = A::vtbl;
A();
this->vptr = B::vtbl;
//member initilization
}

~B() {
//deconstructe
this->vptr = A::vtbl;
~A();
}

对于vptr在布局中所处的位置,c++标准并没有规定,具体位置看不同厂商的编译器的实现

该讨论没有意义

多重继承

在多重继承下,一个 derived class 内含 n-1 个额外的 virtual tables , n 表示其上一层 base classes 的个数;针对每一个 virtual tables, derived 对象中有对应的 vptr

1
2
3
4
5
6
7
8
9
class A1;
class A2 : public A1;
class B;
class C : public A2, public B;

A1 *a1;
A2 *a2;
B *b;
C *c;

将c转为b

1
2
3
b = c ? //判断是否为空
(B*) ((char *)c + sizeof(A2)):
0;

不过对于A2和B的排列顺序并没有具体规定,上面的代码只是为了说明多重继承中的转化需要经过运算

虚拟继承

如果

1
class B : public A1;

B也继承与A1,那么在布局中就会含有两份A1,语言层面的解决方法就是使用虚拟继承

为了实现虚拟继承,会将A1分成两部分

  • 不变区域
  • 共享区域

共享区域会因为操作有所变化,而不变区域则不会

一般的布局是先安排好不变区域,然后再建立共享区域,那么如何存取共享部分?

cfront会在每一个derived class object中安插指针,指向virtual base class

1
2
3
4
class A1 {int x;};
class A2 : virtual public A1;
class B : virtual public A1;
class C : public A2, public B;

如果在B中有如下操作

1
2
3
x++;
----------转化为
_VBCA1->x++;

如果要实现转化

1
2
3
4
5
A1 *a1;
A2 *a2;
a1 = a2;
----------
a1 = a2? a2->_VBCA1 : 0;

这个模型主要有两个问题

  • 对于每一个virtual base classderived class必须有一个指向virtual base class的指针,增加了空间成本
  • 如果继承链加长,通过指针间接存取导致时间成本增加

对于第一个问题,有两种解决方法(注意⚠️,这不是标准)

  • 可以设置virtual base class table
  • 可以在virtual table中存放virtual base classoffset