八叶一刀·无仞剑

万物流转,无中生有,有归于无

导航

《深度探索C++对象模型》读书笔记(二)

Posted on 2017-03-20 23:34  闪之剑圣  阅读(919)  评论(0编辑  收藏  举报

第三章:Data语意学

这一章主要讲了类中的数据在内存中是如何分配的,包括(多重)继承和多态。

让我们首先从一段代码开始:

class X{};
class Y :virtual public X{};
class Z :virtual public X{};
class A :public Y, public Z{};
std::cout << sizeof(X) << std::endl;
std::cout << sizeof(Y) << std::endl;
std::cout << sizeof(Z) << std::endl;
std::cout << sizeof(A) << std::endl;

    在VS2013上输出的结果为1,4,4,8。为什么会这样呢?这就涉及到编译器针对C++语法而采取的对象模型。X虽然是一个空类,但为了使它的对象具有地址,编译器实际上是会为它的对象分配1个字节大小的空间,因此它的大小为1。Y和Z是对X的虚拟继承,编译器会为它们各分配一个指针,指向X的对象,因此它们的大小为4.A同理,它是Y和Z的多重继承,因此编译器会为它分配两个指针,所以它的大小为8.内存分布如图所示:

   

   书中还提到了一种模型,其输出的结果为1,8,8,12。这种模型的思路是给每一个空类分配1 byte的空间,再考虑到4 byte的alignment,因此可以推导出书中的结果,在这里不再赘述。

   关于类中Member的布局,C++ Standard是这样规定的:static data members不会被放到对象的布局之中;在同一个access section(public、private、protected等区段),member的排列是按照声明顺序分布的,但不一定是连续排列(因为存在alignment);同时,对于不同的access section,它们的data members自由排列,不必在乎声明顺序(也就是说access section之内是按照声明顺序排列,而access section之间自由排列)

   之后,书中讨论了单一继承、多态、多重继承、虚拟继承下类中内存分布的表现,这也是本章的重中之重。

   这是一个很简单的程序:

Point3d origin;
origin.x=0.0;

 执行这段程序所需要的时间和空间代价,随x的性质而不同。让我们分情况讨论:

1.x是static data members

   类的每个static成员都只有一个实例,存放在程序的data segment之中,和对象无关;因此对于这种情况,对x的存取并不会招致任何空间和时间上的额外负担。

2.x是 nonstatic data members

   访问类的nonstatic data members时,实际上编译器做了如下工作:

//源代码
origin.x=0.0;
//编译后的代码
*(&origin+(&Point3d::x))=0.0;

  也就是说,为了得到x的地址,编译器需要将origin的起始地址加上x的地址偏移量。(实际上,起码在VS里用&Point3d::x来表示偏移量是有问题的,但思想可以先这样理解)

 3.x是基类的变量,没有多态与多重继承

    在有继承的情况下,可能会导致空间上的浪费。我们来看这样一个例子:

   

    这个类中存有一个int和三个char,如果我们把这些变量都放到一个类中声明,那么算上alignment,它的对象大小为8字节。

    假设我们要继承:

    

    那么Concrete3的对象的大小将达到16字节,比原先的设计多了100%!

    这是因为alignment导致的,因为C++的对象模型中,在一个继承而来的类的内存分布里,各个基类需要分别遵循alignment,从而导致了空间的浪费。具体地对象布局可见下图:

    

4.加上多态

   在这种情况下,无论是时间还是空间上,访问类的成员都会带来一定额外的负担,主要体现在以下几个方面:

   1.virtual table,用来存放它所声明的每一个virtual functions的地址。

   2.每一个对象中会有一个vptr,提供执行期的链接。

   3.编译器会重写constructor和destructor,使其能够创建和删除vptr。

5.多重继承

   在多重继承的条件下,对于指针之间的赋值需要运行时计算。举个例子,以下的继承结构:

   

  我们声明几个对象和指针并赋值:

Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;
pv=&v3d;
p2d=&v3d;'
p3d-&v3d;

  对于p3d和p2d的赋值,只需要直接将v3d的地址赋过去就好。但对于pv的赋值,编译器需要计算一个Vertex在Vertex3d中的偏移量,从这个偏移量起始来得到pv的地址。因为类之间的内存分布如下所示:

   

   Vertex3d中Vertex部分的起始地址并不是Vertex3d对象的起始地址,因此对pv赋值需要一个运行时计算的开销。

6.虚拟继承

    在虚拟继承中,C++对象模型将Class分为两个区域,一个是不变区域,直接存储在对象中;一个是共享区域,存储的是virtual base class subobjects,它在内存中单独存储在某处,derived class object持有指向它的指针。在cfront编译器中,每一个derived class object中安插一些指针,每个指针指向一个virtual base class,为此需要付出相应的时间和空间成本。如下所示:

//具体的类同上一节多重继承,不同的是Vertex和Point3d虚拟继承了Point2d
void Point3d::operator+=(const Point3d&rhs)
{
    x+=rhs.x;
    y+=rhs.y;
    z+=rhs.z;
}

//编译器翻译后的版本
_vbcPoint2d->x+=rhs._vbcPoint2d->x;
_vbcPoint2d->y+=rhs._vbcPoint2d->y;
z+=rhs.z;

  这只是最基本的解决方案,书中还提出了一些编译器优化时间和空间的方法,感兴趣可以深入阅读一下。

最后,书中探讨了如何获得类中某个成员的地址偏移。我在这里就总结两种方法:

1.((int)&((structure*)0)->member)

2.先通过 int Test::* pOffset = &Test::x;获取偏移变量,再利用reinterpret_cast<int>(*(void**)(&pOffset))将其转化为整形量。