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

来源:互联网 发布:sopcast网络电视如何用 编辑:程序博客网 时间:2024/06/05 14:45

3. The Semantics of Data

这一章主要讨论类的数据成员的内存模型,涉及的干货比较多。

从一个类继承的例子,讨论类的大小:

class X {};class Y : public virtual X {};class Z : public virtual X {};class A : public Y, public Z {};

类层次
结果如下:

sizeof(X) => 1sizeof(Y) => 8sizeof(Z) => 8sizeof(A) => 12

Viusal C++的结果:

sizeof(X) => 1sizeof(Y) => 4sizeof(Z) => 4sizeof(A) => 8
  • 对于类X:一个空类的大小并不是空,而是1byte,编译器安插一个char,使得同一类的两个对象在内存中可以配置独一无二的地址。
  • 对于类Y和Z:这两个类同时虚继承一个类X,大小受三个因素影响:
    1. 语言本身造成的overhead(虚指针):当语言支持虚基类时,导致一些额外负担。派生类存在一个指针,它指向virtual base class subobject(虚基类子对象)或者一个存放前者的相关表格,表格里存放着虚基类子对象的地址或者其偏移位置offset。在例子中Y和Z这部分指针大小是4bytes(32位机器上,类的虚指针vptr一般是4bytes)。
    2. 编译器对于特殊情况提供的优化处理:因为Y和Z也是空类,和X一样,也有1byte的char,放在派生类的固定部分的尾端。(这是编译器对empty virtual base class的处理,不同编译器有不同实现。)
    3. Alignment的限制:一般聚合的结构体大小会受到alignment对齐的限制,是的能够有效率在内存中存取。32位计算机alignment是4bytes使得bus运输量达到最高效率。因此类空间会被补全到alignment的整数倍,padding大小为3bytes。因此这两个类的大小:4+1+3=8bytes。
  • 对于类A:(不管virtual base class subobject在class继承体系出现多少次,只会在derived class中存在一份实例
    1. Y和Z共享的唯一一个X实例,大小为1byte
    2. 基类Y的大小,减去“因virtual base class X而配置”的大小(应该是指针的大小),剩下4bytes。Z同理。
    3. A自身大小0byte,对齐补全3bytes,因此得到1+4+4+3=12bytes。

注:书中多处提及的class subobject,应该是区别于class object,用来表示被其他class包含的object,如类包含中一个类的object作为其他类的成员,如类继承中基类的成员会被包含在派生类中。virtual base class subobject就是后者,指派生类中继承virtual base的那部分。(个人理解)

对象布局1

Empty virtual base class,只提供一个virtual interface,没有定义任何数据。新的编译器如Visual C++对此有特殊处理:空的虚基类被认为是派生类的最开头部分,不花费任何额外空间,派生类也就有了member,不需要原本为了empty class而安插的1byte大小的char。上述例子中的Y和Z就只剩下4byte虚指针的overhead,也就不需要3byte的padding来对齐,因此在Visual C++下的模型布局,Y和Z都是4个bytes。对于A,X的1byte被拿掉,只剩下Y和Z的4bytes,对齐补全的3bytes也不需要了,共计4+4=8bytes。

对于这两种编译器实现,如果虚基类X中至少有一个成员,那么两种编译器能得到完全相同的对象布局。即都没有因为空类X产生1byte的char以及对齐带来的padding。
对象布局2
讨论class的data members的存放模型:

  • nonstatic数据成员: 数据面向个别class object。C++对象模型以空间优化和存取速度优化的考虑来表现nonstatic数据成员,且保持和C语言struct的兼容性。把数据直接存放在每一个class object中,对于继承而来的非静态成员(不管是基类是否virtual)也一样。多个变量一般按声明的先后顺序存放。
  • static数据成员:数据面向整个class。静态数据存放在全局的data segment,不会影响具体class object的大小,不管class(直接产生或间接派生)实例化多少个object,静态数据只存在一份实例。即使该类没有任何object,它的静态数据也是存在的。(但一个模板类的静态数据成员行为有不同)

总结:一个对象的内存布局由三部分组成

  • nonstatic data members的总和大小;
  • 编译器自动加上额外的数据成员的overhead,支持语言特性,主要是各种virtual特性(虚指针);
  • 边界对齐的需要填补padding的空间。

3.1 数据成员的绑定

对成员函数本身的分析(evaluate)会直到整个类的声明都出现了才开始。所以类的成员函数可以引用声明在后面的成员,C 语言做不到。

typedef int length;class Point3d{public:void f1(length l){ cout << l << endl; }typedef string length;void f2(length l){ cout << l << endl; }};//f1绑定的length类型是int;而f2绑定的length类型才是string。

但类中的typedef并不具备这个性质,类中的typedef会受到函数与typedef的先后顺序的影响。因此,对于typedf需要防御性的程序风格:始终把“nested type声明”即typedef放在类的起始处。


3.2 数据成员的布局

正如开头例子总结的对象数据成员layout:

  1. Nonstatic data members在class object中的排序顺序和其被声明的顺序一样,任何中间接入的static数据不会放进对象布局中。
  2. 同一个access section(即private、public、protected等区段)中,members的排列只需要符合“较晚出现的members在object中有较高的地址”即可。各个members不一定连续排列,members之间可能由于alignment需要填补bytes。
  3. 编译器会合成内部使用的data members,支持整个对象模型。如vptr,编译器会把它安插在每一个含有虚函数的类的对象内,一般在开头或者结尾。

3.3 数据成员的存取

一个例子,通过object存取和指针存取数据成员,有什么重大区别呢?

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

当类型Point3d是一个继承体系中有虚基类的派生类,并且 x 成员又是虚基类中的成员时,这两种写法在编译器看来就会有重大的区别了(这种情况下的 offset 计算方式不同,其它情况的offset 也可以直接算出)。

如果用origin对象来存取,就确定是Point3d class,即使它继承自虚基类,成员x的offset在编译期就确定了,可以静态通过origin存取x。但是对于ptr来说,由于无法确定ptr指向的真正类型(多态?动态绑定,运行时确定),所以只能借由一个运行时的间接来得到成员的具体地址。

静态数据成员

每一个静态数据成员只有一个实例,存放在程序的data segment之中,视为全局变量,但只在class的生命范围之内可见。

每次程序读写静态成员(无论是通过对象还是通过指针),都会被内部转化为对改唯一extern实例的直接引用来操作。此时从指令执行的角度看,通过对象和通过指针读写member,没有区别。因为静态成员不在class object之中,读写静态成员并不需要通过class object。

对静态成员取地址,会得到一个指向其数据类型的指针,而不是指向其class member的指针(对非静态成员取地址就是了???)。对于class的不同object,其静态成员的地址是相同。

非静态数据成员

非静态数据成员在每一个class object内,只能通过显式或隐式的class object来获取。在成员函数中直接处理非静态成员,就回通过隐式对象(this指针表达)来完成读写。

对非静态数据的读写,编译器通过把class object的起始地址,加上data member的偏移地址offset来获取。每一个非静态数据成员的offset在编译时期即可获知,即使member属于base class subobject也一样。因此,存取一个非静态成员,其效率和存取一个C struct member或nonderived class的member是一样的。

对于虚继承,如果一个非静态成员是虚基类的成员,读写该变量的速度回比它是struct member、class member、单继承或者多继承的成员要慢。一般而言,具体继承(相对于虚继承)并不会增加时间和空间上的负担,书中也强调多次:C++中的额外成本大多来自于 virtual 机制。


3.4 继承&数据成员

这里讨论C++几种继承模型下的数据成员存储分布。

A. 没有多态的单继承

单继承1

以及

单继承2

B. 加上多态的单继承

在继承关系中提供虚函数接口,支持多态会带来的 4 个额外负担:

  1. 导入vtbl用来存放每一个virtual functions的地址。这个表的元素数目一般是被声明的virtual functions数目,再加上一个或两个 slots(用以支持 RTTI)。
  2. 在每一个class object中安插一个vptr指向相应的vtbl,实现运行时的动态绑定,使得每一个object能找到相应的vtbl。
  3. 在constructor中安插代码以正确设置vptr初值,指向class对应的vtbl。
  4. 在destructor 中安插代码以正确设置vptr,使它能抹消指向class相关的vtbl。

多态单继承

C. 多继承

单一继承提供了“自然多态”形式,是关于类体系中base type和derived type之间的转换。base class和derived class的object都是从相同的地址开始,只是derived object还要容纳自己的非静态数据。当用基类指针或引用指向派生类对象时,这个操作不需要编译器去介入,可以很自然操作,提供最佳执行效率。

当单一继承带有虚函数,即基类没有虚函数,派生类有虚函数,单继承的“自然多态”会被打破。当基类指针指向派生类时,需要编译器介入调整地址(因为插入了vptr)。

class Point2d { ... };class Point3d : public Point2d { ... };class Vertex { ... };class Vertex3d : public Point3d, public Vertex { ... };Vertex3d v3d;Point2d *p2d;Point3d *p3d;Vertex *pv;

多继承1

对于上述多继承的例子,Point3d是Vertex3d的第一基类,Vertex是第二基类。
- 当用第一基类的指针p2d或p3d指向派生类的对象v3d,和单继承一样,只需要简单拷贝地址即可。
- 当用第二基类的指针pv指向派生类的对象v3d,则需要修改地址offset,加上(或减去)介于中间的base class subobject(s)的大小。

pv = &v3d;// =>pv = (((Vertex*)(((char*)&v3d)  + sizeof(Point3d));

多继承2

多重继承中,可能会有多个 vptr 指针,视其继承体系而定:**派生类中vptr的数目最等于所有基
类的vptr数目的总和。**

D. 虚继承

iostream的继承层次有用到虚继承,实现共享继承。

class ios {...};class istream : public virtual ios { ... };class ostream : public virtual ios { ... };class iostream : public istream, public ostream { ... };

虚继承1

虚继承2

通过虚继承实现共享基类,这个共享必须通过编译器安插的一些指针指向virtual base classobject来间接的存取,这样才能够实现共享。

对于这个安插指针来实现共享的技术,有两种主流的做法:Pointer Strategy和Virtual Table Offset Strategy,产生的数据布局如下两图所示。

Pointer Strategy:对每个虚继承的派生类对象,安插一个指针指向基类,实现共享。如Point3d和Vertex中的指针pPoint2d,Vertex3d中的两个subobject中的两个指针pPoint2d。

虚继承3

Virtual Table Offset Strategy:在每个虚继承的派生类相关的vtbl中,放置virtual base class的offset(而不是地址,记录虚基类的位置 )和virtual function的地址。

虚继承4

0 0
原创粉丝点击