深入探索C++对象模型-Data语义学

一个类的大小受哪些因素影响?

1、语言本身所造成的overhead。例如C++为了支持virtual base class和virtual function,就会有虚基类表和虚表的实现,这些额外机制的实现需要额外的开销。

2、编译器对于特殊情况所提供的优化处理。例如,C++里面针对空类,空类的大小并非为0,还是有1个字节的开销。

3、Alignment内存对齐的限制。C++中一个结构体或者对象的内存大小需要遵循内存对齐的要求,这也会使一个类的内存开销增大。

以上三点,我们可以通过一个例子来体现。

class X
{};
​
class Y : public virtual X
{};
​
class Z : public virtual X
{};
​
class A : public Y, public Z
{};
​
// sizeof X : 1
// sizeof Y : 8
// sizeof Z : 8
// sizeof A : 12
我们可以看到上面X、Y、Z、A四个类中没有任何数据,但是通过sizeof方法查看大小却发现有巨大的内存开销。

解释:

类X:事实上并非是空的,它有一个隐晦的1byte。这个字节是被编译器安插进去的。这是为了让这个类的对象拥有一个属于他的内存地址。

类Y和类Z:由于虚继承的性质,会让类中拥有一个虚基表指针的额外开销,而指针的开销为4byte,这个指针放在内存布局的开头位置,后续是1byte的X,这末尾的1byte会因为内存对齐的特性,额外占用3字节,将整体的内存开销变为8byte。

类A:同理可得,由于虚继承的原因,A会有Y和Z的两个虚基表指针,然后会有1byte的X类的内存开销,最后会因为内存对齐的机制,将总的内存开销拓展到了12byte。

但是上面我们可以看到X空类给类Y、Z带来的额外内存开销远不止1个字节,部分编译器会针对这个问题做出优化处理。所以编译器不同,上面得到的结果也不同。

如果编译器对此进行了优化,那么X空类的1byte去掉后,那么X、Y的代傲为4byte,A的大小也就只有8byte。

Data Member的绑定

extern float x;

class Point3d
{
public:
  Point3d (float, float, float);
  float X() const { return x; }
private:
	float x, y, z;  
};

我们可以看到Point3d类中的X方法预期是返回了成员变量x的数值,但是在此处,我们可以看到在类的外部还声明了一个外部变量x,在早期的C++编译器中,这个方法会返回外部变量x而非成员变量x,因为编译器在处理这个成员函数时,尚未处理到下面的成员变量,不知道这个类还有成员变量x。

这也导致了早期C++程序的防御性程序设计风格:

1、class开始先声明所有的data member;

2、把所有的class inline function都放在类外进行实现,类中仅声明各个方法;

member scope resolution rules

这个规则是,类中的一个inline函数实体,在整个class声明未被完全看见之前,是不会对这个方法进行处理的。

举例来说

extern int x;
class Point3d
{
public:
 // 当编译器处理这段代码时,读取到这里时,并不会开始处理这个函数
 // 这个函数的解析时间会延迟到class声明的右大括号出现时才开始
 float X() const { return x; }  
private:
 float x;
};
// 当编译器读取代码到这里后,获取了这个类的全部信息,才会开始解析这个类的内联成员函数X()

但这只是对member function内部的data member的绑定操作的规则,但是这个规则并不适用于member function的argument list。member function的参数还是会在读取代码的时候,第一时间进行决议绑定操作。可能难以理解,参数的决议,下面举例说明:

typedef int length;
​
class Point3d
{
public:
...
void mumble(length val)
{
  ... 
}
private:
 typedef float length;
 length _val;
};

我们可以看到针对length的类型定义存在两个地方,一个是代码开头处,一个的类的private中的,在读取类的成员函数时,在处理参数列表中的Length类型时,并不会有延迟处理机制,因此mumber成员方法中,val类型在编译器看来是int类型,这也是反直觉的。

至今编译器也还没有针对这个bug做出修改,因此如果类中存在typedef的语句,应该将这个语句放在类的开头。

Data Member的布局

一个对象中仅会保存这个类的nonstatic data member,那些static data member会被类共享的存放在数据段。

class Point3d
{
public:
...
private:
 float x;
public:
 float y;
private:
 float z;
};

当前各家的编译器针对这个类中的各个nonstatic data member的内存排布都是直接按照类中的声明顺序来,并不考虑access sections。

Data Member的存取

Point3d origin, *pt = &origin;
​
origin.x = 0.0;
pt->x = 0.0;

我们可以看到上面的两种存取方式,一种是直接存取,另一种是通过指针来进行间接存取。

这两个方式的存取开销其实并不相同。后续将基于这个存取开销进行深入探索。

Static Data Menber

一个类的static data member其实在编译器看来就是一个class之外的变量,会被视作为一个global变量。这个static data member的存取许可并不会对这个变量造成任何空间或者是执行时间上的额外开销。

// chunkSize为一个静态成员变量
origin.chunkSize = 250;
pt->chunkSize = 250;

以上这两个指令的开销都是一样的。

不论chunkSize是否是从一个复杂的继承关系继承而来。因为,这个chunkSize是唯一的,在数据段拥有唯一的一个内存地址。对于这个变量的存取就是直接的。

还有一种情况,如果static data member是通过函数调用存取的。

foobar().chunkSize = 25;
// 其实chunkSize的内存地址和foobar函数返回的指针没有任何关系,当确定下返回值是哪个类,那么这个chunkSize的内存地址就已经确定了。
​
// 这条语句可能会被编译器进行优化,优化成以下形式
(void) foobar();
Point3d::chunSize = 25;

还有一点静态成员变量特殊的点,我们取静态成员变量的地址的时候,得到的并非是成员变量指针,而是一个具体数据类型指针。

&Point3d::chunkSize;//这个语句的值的类型为const int*

Nonstatic Data Members

Nonstatic data members都存放在每个class object中,能通过显式或隐式存取。

Point3d Point3d::translate( const Point3d &pt)
{
   x += pt.x;
   y += pt.y;
   z += pt.z;
}
​
// 经过编译器处理后,这段函数的真实执行代码如下
// 成员方法对一个nonstatic data member进行存取操作,需要获取这个data member的offset,而每个成员变量的offset在编译阶段就是已知的
// 这里就是通过指针进行隐式存取
​
Point3d Point3d::translate( Point3d* const this, const Point3d &pt)
{
   this->x += pt.x;
   this->y += pt.y;
   this->z += pt.z;
}
​
Point3d tmp;
// 这里就是通过对象来进行显式存取
tmp.x = 3;

对一个nonstatic成员变量进行存取操作,编译器需要知道成员变量在这个对象中的偏移量,如果指定对象的类型是确定的,那么偏移量也就是确定的。

显式存取:编译器在处理时,这个对象的类型是确定的,因此偏移量也是确定的,可是直接存取。

隐式存取:由于指针指向的对象并不确定真实的数据类型是什么,从而编译器无法确定该成员变量的偏移量是多少,此时的存取会在运行时才会确定。

继承和Data Member

class Point2d
{
public:
  ...
private:
   float x, y;
};
​
class Point3d
{
public:
  ...
private:
   float x, y, z;
};

上面如果改让Point3d继承Point2d的方式来实现,那么Point3d中的成员变量的布局会有什么不同呢?

下面就是分别针对发生继承无多态、继承有多态、多重继承、虚拟继承这4种情况来讲解,通过继承来实现一个类时,类中成员变量的布局。

继承无多态

可以看到这种继承相比不继承实现,可以将管理x和y坐标的程序代码局部化。通过继承关系,我们清晰地看到两个类之间的特殊关系,并且这两个类都可以独立使用。

但是这种继承会有什么坏处吗?

1、可能会重复设计实现一些相同操作的函数,base class中已经实现了的操作,但是在derived class中又进行重复实现。

2、将一个class分解成多层,为了表现class体系中的多态特性而导致类对象发生膨胀,下面举例说明:

class Concrete
{
   int val;
   char a;
   char b;
   char c;
};
​
class Concrete1
{
   int val;
   char a;
};
​
class Concrete2 : public Concrete1
{
   char b;
};
​
class Concrete3 : public Concrete2
{
   char c;
};
​
// sizeof(Concrete) -> 8
// sizeof(Concrete3) -> 16

我们会惊奇地发现类Concrete3的大小居然膨胀为了Concrete类的2倍。

我们会发现Concrete2类并不会往继承而来的Concrete1中的padding空间中存放数据。

这是因为多态的特性要求,Concrete1的大小为8byte,如果将Concrete2对象的值赋值为Concrete1,会按照bitwise的方式来进行赋值,根据Concrete的大小为8byte,将前8byte的数据按位拷贝给Concrete1对象,因此不能将Concrete2中的成员变量存放在前8个字节中,即便前8个字节中存在padding的浪费。

继承有多态

多态就是在base class和derived class中存在虚函数,多态使得方法能够以多态方式处理多种类型

class Point2d
{
public:
   float x() { return x; }
   float y() { return y; }
   virtual z() { return 0.0; }
   
   virtual void operator += (const Point2d& rhs)
  {
       x += rhs.x();
       y += rhs.y();
  }
   
//...
};
​
class Point3d : public Point2d
{
public:
   virtual z() { return z; }
   
   virtual void operator += (const Point2d& rhs)
  {
       Point2d::operator +=(rhs);
       z += rhs.z();
  }
   
//...
};
​
void foo(Point2d& p1, Point2d& p2)
{
   p1 += p2;
}

我们可以看到foo函数可以接受两种类型的对象,这种弹性特性就是多态带来的。但是多态的实现要求加入virtual table虚表,以及编译阶段无法确定执行的方法。这就带来了额外的空间和存期开销。

1、每个类都会有一个对应的虚表,虚表中存放了每个虚函数的地址,其中还会有1、2个slot用以支持runtime type identification。

2、每个对象中也会新增一个vptr来指向虚表。

3、constructor也会新增vptr的初始化。

4、destructor执行过程中也需要进行vptr的更新调整,因为一个多层继承结构的类对象,析构函数会逐级调用,调用父类析构函数的时候,此时vptr也已经发生了改变,如果父类的析构函数中调用了虚函数,那么这个虚函数调用不应该是子类的,而是父类的自身的实现,因此vptr也必须要及时调整。

多态的特点在于可以让一个方法接受处理Point2d和Point3d两种类型的对象,但是如果这个程序设计出来就只会让同类型之间的变量进行交互,无需多态的特性,那么务必消除多态的特性,避免这个开销。

vptr放在class object哪里最好?

vptr被放在尾端

把vptr放在class object尾端,可以保留C语言中结构体的成员布局,因而允许在C程序代码中也能被使用。这种做法在C++最初问世时,很多人采用的。

vptr被放在起头处

当C++支持虚拟继承以及抽象类的时候,vptr开始被放在开头了。

特别在多层继承的情况下,如果vptr被放在尾端,那么每次通过指针来进行调用虚函数时,都需要根据实际类型来计算vptr的在对象中的offset,这个offset需要在执行期备妥。

但是将vptr放在起头处后,自然就丧失了C语言兼容性,但是这个兼容性对于大多数程序而言并没有什么意义。

多重继承

在单一继承中,不论继承深度有多深,通过指针来进行操作,并不需要考虑任何offset相关的事情。因为,vptr始终放在开头,而指定的成员变量的offset也是确定的。

但是在多重继承中,这个情况就发生了变化。

我们可以看到多重继承中,vertex3d和第二个基类vertex之间并非是自然的继承关系。

Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;
​
pv = &v3d;
// Vertex和Vertex3d之间无法自然的转化,将Vertex3d类型转化为Vertex类型实际会进行以下操作,需要给v3d的地址加上Point3d的大小作为offset计算出pv的地址
pv = (Vertex*)((char*)&v3d + sizeof(Point3d));
​
// Point3d和Point2d和Vertex3d之间是自然继承关系,因此仅需简单的地址拷贝即可
p2d = &v3d;
p3d = &v3d;
​
Vertex3d *pv3d;
Vertex  *pv;
​
// 以下Vertex3d和Vertex类型的指针之间相互赋值也是同理,也会进行offset的地址转化,但和上面又有点不同
pv = pv3d;
// 由于指针可能是空指针,pv3d = 0。但是我们无脑将offset赋值给pv,那么pv就不是一个空指针了,但是将空指针赋值给另个指针,pv也应该是空指针,因此实际的转化操作应该如下所示
pv = pv3d ? (Vertex*)((char*)pv3d + sizeof(Point3d)) : 0;
// 指针的情况,自然还能想到引用,引用之间赋值无需考虑指向为空的情况,因为引用不可能引用到空对象

可以看到多重继承中这种不自然的继承关系会带来额外的开销。

同理对于虚函数的调用是需要通过vptr来进行调用,部分编译器会针对此进行优化,如果两个基类,其中一个有虚函数,另一个没有,那么进行顺序调换,使得有虚函数的类放在第一个进行自然继承,方便通过vptr调用虚函数。

虚继承

为了避免派生类中包含重复的基类信息,因此使用了上图右边的虚继承结构。

如今针对虚继承实现的方法为将共享部分数据和独立部门区分存储,共享部分的数据只能通过间接存取。

类的布局策略中,针对这两部分数据,都是先安排好派生类中独立的那部分数据,然后再建立共享部分的数据。

这些共享部分的数据,我们也称其为virtual base class members,现在一般都是通过相关指针来间接指向这些virtual base class members。

我们可以看下cfront编译器的解决策略

首先上图是这几个类之间的继承结构图

void Point3d::operator += (const Point3d &rhs)
{
 _x += rhs._x;
 _y += rhs._y;
 _z += rhs._z;
};
​
// 这个运算符经过编译器的内部转换,就变成以下形式,Point3d的每个对象会新增一个成员变量指针vbptrPoint2d指向虚继承的Point2d类
​
void Point3d::operator += (const Point3d &rhs)
{
 vbptrPoint2d->_x += rhs.vbptrPoint2d->_x;
 vbptrPoint2d->_y += rhs.vbptrPoint2d->_y;
 _z += rhs._z;
};
​
// 同理这种情况下,那么派生类和基类之间的指针类型转换就会如下所示
​
Point2d *p2d = pv3d ? pv3d->vbptrPoint2d : 0;

针对cfront这种策略,我们可以看到两个缺点:

1、类每虚继承一个类,那么这个派生类的对象就需要多背负一个额外的指针。如果避免背负的指针开销线性增加,后续会讲解。

2、如果存在多级虚继承,那么底层的基类的对象的存取,需要通过多级指针才能存取到,理想上,我们希望能有固定的存取时间,不会随着虚继承的深度增加而增大。

问题的解决办法

方法1:微软的编译器引入了virtual base class table,虚基类表,也就是我们现在所常见的解决方法。

方法2:将virtual base class放到存放虚函数的虚表中,虚表的正数索引中存放的指向虚函数的指针,虚表的负数索引中存放的是虚基类的在该对象中的偏移。举例说明:

// 我们以上面的operator+=来进行举例,经过编译器转化后
void Point3d::operator += (const Point3d &rhs)
{
(this + vptr[-1])->_x += (&rhs + rhs.vptr[-1])->_x;
(this + vptr[-1])->_y += (&rhs + rhs.vptr[-1])->_y;
 _z += rhs._z;
};
​
// 同理派生类和基类之间的指针的转化也会如下所示
Point2d *p2d = pv3d ? pv3d + pv3d->vptr[-1] : 0;

我们可以看到上面两种方法,其实就是新加了一层间接性,这两种解决方法都是将虚继承而来的类存放了一个表中,多一层间接性自然就会导致存取开销增大。

我们需要注意的一点就是,上面讨论的依旧是多态引入导致的问题,如果不使用指针或者是引用来存取成员对象就不会有以上的那些存取开销问题。

Point3d origin;
origin._x;
// 这里针对origin中的_x的存取,经过编译器就可决定类型和成员变量的Offset,编译器可优化为直接存取

上面讲解的虚继承的麻烦点都在于虚基类中的成员变量的存取,如果虚基类中没有任何data member,那么上述问题都会没有,因此virtual base class最有效的一种运用形式就是没有任何data members。

成员变量的存取效率

封装对于数据存取效率的影响

将编译器的优化开关打开,那么”封装“基本就不会带来额外的执行期的效率成本,inline函数也亦然。

这里还有一个非常反常的现象:在局部数据的测试集中,CC编译器居然比NCC编译器慢了这么多。这是因为在CC编译器中,每个局部变量的地址都被计算并放到一个缓存器中,这种策略有利于局部变量被存取多次,对于单一的存取操作,这个过程显然增加了开销。

继承对于数据存取效率的影响

单一继承是自然继承,继承的数据都很自然地连续存储在derived class object中。

比较奇怪的是,虚拟继承的测试中,直接存取居然也有额外开销,明明在编译期间可以直接存取虚基类中的成员变量,无需通过虚基类表来进行间接访存。但是这两个编译器都没能识别出来。

注意:我们可以看到上面两个表,如果编译器不打开优化开关,很明显可以观察到inline存取会降低存取效率,也这和我们的理论推论不一致,因此任何程序的效率都应该要实际测试。

指向Data Members的指针

& Point3d::z;
// 这里是取类Point3d中z成员变量的地址
这个操作实际得到并非是一个内存地址,而是一个offset,这个offset是指成员变量z在class object中的offset。

一般来说成员变量指针的值都是offset + 1。

为什么要+1?

因为要区分空成员变量指针,如果一个指针指向的是第一个成员变量,那么实际offset就是0,那和空指针的数值重合了。

实验过程中,还发现一个,想要打印出成员变量指针,只能通过printf来进行输出,通过cout来进行输出会有问题。以及输出的数值并非是经过+1的数值。

成员变量指针的效率
float Point3d::*ax = &Point3d::x;
pb.*ax = 10;

我们可以看到成员变量指针的存取方式显然操作更加繁琐,需要给对象地址加偏移值计算得到地址,后续才能取得成员变量。经过实际测试,如果不打开优化开关,成员变量指针的效率比寻常指针慢了接近1倍。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值