深度探索C++对象模型笔记 [2] 数据(Data)语意学

本文参考《Inside the C++ Object Model》,介绍C++对象模型中数据成员的相关知识。包括数据成员的绑定、布局、存取,继承与数据成员的关系,以及指向数据成员的指针。阐述了静态和非静态数据成员的特点,不同继承方式对数据成员的影响等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文主要参考Stanley B.Lippman所著《Inside the C++ Object Model》,侯捷译。

前言

一个表面看上去是空的类,其实并不是空的!因为编译器为它安插了一个隐藏的char,这样使得这个类被实例化后,每个对象都有独一无二的地址(即这个char的地址)。

一个class的data members,一般而言,可以表现出class在程序执行时的某种状态。Nonstatic data members放置的是“个别的class object”感兴趣的数据。Static data members 则放置的是“整个class”感兴趣的数据。在程序中,不管class被产生出多少份实例,static data members永远只存在一份实例,位于global data segment中(甚至该class没有任何实例,其static data members也已经存在)。

每一个class object必须拥有足够的大小以容纳它的所有nonstatic data members。有时候可能比想象的还大,因为:1、由编译器自动加上的额外data members,用以支持某些语言特性。2、因为alignment(边界调整)的需要

一、数据成员的绑定

早期的程序设计遵循“防御性设计风格“。实际上这些风格到今天还在,虽然必要性自C++2.0后就消失了。这类语言规则被称作“member rewriting rule”,意思是,一个inline函数实体,在整个class声明未被完全看见之前,是不会被评估求值的。也就是说,如果一个inline函数在class声明之后被立刻定义的话,对其本体的分析,会直到整个class声明都出现了才开始,因此在inline函数躯体内的一个data member绑定操作,会在整个class 都声明后才开始执行

但是,这对成员函数的参数列并不为真。参数列中的名称还是会在它们第一次遭遇时被适当地决议完成。因此。这种状况仍然需要某种防御性的程序风格:总是把”nested type声明”(比如typename xx xx)放在class的起始处!

二、数据成员的布局

C++ 标准要求,在同一个access section(也就是private、public、protected等区段)中,members的排列只需符合“较晚出现的members在class object中有较高的地址”这一条件即可。也就是说,各个members不一定要连续排列,members之间可能为了alignment填补一些bytes。

编译器还可能会合成一些内部使用的data members,以支持整个对象模型。Vptr(虚函数指针)就是这样的东西。目前所有的编译器都会将其安插在一个“内涵virtual function之class”的object 内。传统上把vptr放在所有显式声明的最后,但如今也有编译器将之放在一个class object的最前端。

目前各家编译器都是把一个以上的access section连锁在一起,依照声明顺序,成为一个连续区块。Access section的多寡并不会招致额外负担。比如在一个access section内声明8个members和在8个access section内总共声明8个members,得到的object大小相同。

 

三、数据成员的存取

先介绍类的数据成员包括哪些类型:

Static Data Members(静态数据成员):按其字面含义,被编译器提取于class之外,并被视作一个global变量(但只在class的生命范围内可见)。每个static data members只有一个实例,存放在程序的data segment(数据段)中,每次程序参阅(取用)static member时,就会被内部转化为对该唯一extern实例的直接参考操作。从指令执行的观点来看,这是C++中“通过一个指针和通过一个对象来存取member,结论完全相同”的唯一情况。因为members其实并不在class object内,即存取static members并不需要通过class object(经由member selection operators,也就是.运算符对一个static data member进行操作不过是文法上的一种方便而已)。

如果有两个class,每一个都声明了一个static member FreeList,那么当它们都被放入程序的data segment时,就会导致名称冲突。编译器的解决方法是暗中对每一个static data member编码,以获得一个独一无二的程序识别码

Nonstatic Data Members(非静态数据成员):Nonstatic data members直接存放在每一个class object中。除非经过显示的(explicit)或者隐式的(implicit)class object,否则没有方法直接存取它们(static data members则可以直接访问,因为如上文所言,实际并不存放于对象实例中)。只要程序员在一个member function中直接处理一个nonstatic data member,所谓“implicit class object”(隐式类对象)就会发生。比如

Point3d

Point3d::translate(const Point3d &pt){

x += pt.x;

y += pt.y;

z += pt.z;

}

表面上看到对于x,y,z的直接存取,实际上是经由一个”implicit class object”(由this指针表达)完成的,即实际上这个函数的参数是

Point3d

Point3d::translate(Point3d *const this , const Point3d &pt){

This->x += pt.x;

This->y += pt.y;

This->z += pt.z;

}

欲对一个nonstatic data member进行存取操作,编译器需要把class object的起始地址加上data members的偏移位置(offset).比如 origin._y = 0.0,那么起始地址&origin._y等于&origin + (&Point3d::y-1).之所以有个-1操作,是因为指向data member的指针,其offset总是被加上1,这样可以使得编译器区分出“一个指向data member的指针,用以指出class的第一个member”和”一个指向data member的指针,没有指出任何member”的两种情况

每一个nonstatic data member的偏移位置在编译器即可获知。存取一个nonstatic data members的效率和存取一个C struct member或者一个nonderived class(非派生类)的member是相同的。

但是,如果引入虚拟继承,虚拟继承将为“经由base class subobject存取class members引入一层间接性”,比如

Point3d *pt3d;

Pt3d->_x = 0.0;

其执行效率在_x是一个struct member、一个class member、单一继承、多重继承的情况下完全相同。但是如果_x是一个virtual base class 的member,存取效率会下降一点。因为这时候不能说pt必定指向哪一种class type(不知道编译期这个member真正的offset位置),所以这个存取操作必须延期执行。

 

四、继承与数据成员

在C++继承模型中,一个derived class object(派生类对象)所表现出来的东西,是自己的members加上其base classes members的总和。至于derived class members和base classes members的排列顺序,则并未在C++ Standard中强制指定。理论上编译器可以自由安排之。

下面分情况讨论:

 

 

只要继承不要多态:

一般而言,具体继承相对于虚拟继承,并不会增加空间或存取时间上的额外负担。

把两个原本独立不相干的classes凑成一对”type/subtype”,并带有继承关系,容易犯重复设计相同函数的关系。同时,把一个class分解为两层或更多层,可能会为了“表现class体系之抽象化”而膨胀所需的空间。C++语言保证,出现在derived class中的base class subobject有其完整原样性(在基类中alignment的部分也会继承下来)。之所以保留alignment的部分,是因为如果C++语言把derived class members和base subobject捆绑在一起,去除填补空间,在执行赋值操作时,就会将“被捆绑在一起、继承而得的”members内容覆盖掉。如下所示

上图即使对应情况:"如果base class subobject在派生类中的原样性遭到破坏",也就是说,编译器吧基类对象原本的填补空间让出来给派生类成员使用,那么如果发生类似赋值操作(将Concrete1对象的数据拷贝给Concrete2中对应的成分),很明显,原本在Conrete1 object中padding的部分会覆盖掉Concrete2中属于bit2的部分,这不是我们所希望发生的!

 

 

 

加上多态:

为支持多态,需要带来空间和存取时间上的额外负担。

  1. 导入一个虚函数表,用来存放其所声明的每一个虚函数地址。此表元素个数一般试被声明的虚函数数目,再加上一两个slots用以支持runtime type identification
  2. 再每一个class object中导入一个vptr,提供执行期的链接,使得每一个对象能够找到相应的虚函数表。
  3. 加强constructor,使它能够为vptr设定初值,让其指向虚函数表。
  4. 加强destructor,使它能够消抹“指向class相关的虚函数表”的vptr。

那么,把vptr放在哪里最好呢?在cfront里,是放在class object的末尾,其可以保持base class C struct的对象布局,因而允许在C程序代码中使用。这种做法在C++最初问世时广为使用。而到了C++2.0后,开始支持虚拟继承及抽象类,并且由于OOC的崛起,编译器开始把vptr放在class object的起头处。这种做法的好处时,对于多重继承下,通过指向class member的指针调用virtual function会带来帮助。代价是失去了与C的兼容性。

TIPS:值得注意的是,即使派生类中拥有基类所不具有的虚函数,这个虚函数也会存在于同一个虚函数表中。即使用基类指针指向派生类,其中的虚函数表也会存在这个“基类所没有”的虚函数。当然,这个基类指针是无法调用它的,因为是在编译期间的语法检查,然后在运行时会通过虚函数表动态绑定实际上才调用的派生类的对应重写方法。

 

多重继承:

单一继承提供了一种“自然多态”,base class和derived class的object都是从相同的地址开始,其间差异只在于derived class比较大,用以多容纳它的nonstatic data members。即使把一个derived class object指定给base class的指针或者引用,并不需要通过编译器去调停或修改地址,而且提供了最佳执行效率。

对于一个多重派生对象,将其地址制定给“最左端(也就是第一个,或者说最先继承的那个)”base class的指针,情况将和单一继承相同,因为二者都指向相同的起始地址。至于第二个或者后继的base class的地址指定操作,则需要将地址进行修改:加上(或减去,如果是downcat的话)介于中间的base class subobject大小。

 如果要存取第二个(或后继)base class中的一个data member,将会是怎样的情况? 要付出额外成本吗?不,members的位置在编译时就固定了,因此存取只是一个简单的offset运算,就如单一继承一样简单。不管是经由一个指针,一个引用还是一个object来存取。

 

 

虚拟继承:

多重继承的一个语意上的副作用是,它必须支持某种形式的“shared subobject继承”。在菱形继承(也就是所谓的钻石继承,多个类共同继承了同一基类,但这些类中的多个又被某些派生类所继承)中,为保证基类的subobject只有一份,引入了虚拟继承。一般的方法如下:class内如果含有一个或者多个虚基类suboject,将分割为两部分,一个不变区和一个共享区。不变区中的数据,不论后继如何变化,总是拥有固定的offset,所以这一部分数据可以直接存取。至于共享区,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次派生的操作而有变化。所以它们只可以被间接存取。以下说明实现虚拟继承的策略。

先安排好derived class的不变部分,再建立其共享部分。为存取class的共享部分,编译器会在每一个derived class object中安插一些指针,每个指针可以指向一个virtual base class。要存取继承的来的virtual base subobject members,可以通过相关指针间接完成如下图

这个模型有两个缺点:

1、首先是每个对象必须对其每一个virtual base class背负一个额外指针。

2、其次,由于虚拟继承串链的加长,导致间接存取层次增加。          

 

至于第一个问题,一般而言有两种解决方法,Microsoft编译器引入所谓的Virtual Base class table.每一个class object如果有一个或者多个virtual base classes,就会由编译器安排一个指针,指向virtual base classe table。至于真正的virtual base class

指针,当然会被放在表格中。第二个解决方法,是在virtual function table中放置virtual base class 的 offset(而不是地址)。、

一般而言,virtual base class 最有效的一种运用形式是,一个抽象的virutal base class,没有任何data members

 

五、指向数据成员的指针

指向data member的指针,是一个神秘但是颇有用处的语言特性,特别是如果需要调查class members的底层布局的话。

但事实上,如果去取data members的地址,传回值却总是多1。理由是,为了区分一个“没有指向任何data member的指针”和一个指向“第一个data member的指针”。

比如下例

Float Point3d::*p1 = 0;(没有指向任何data member,且地址为0)

Float Point3d::*p2 = &Point3d::x;(指向了data member,原本的偏移量也是0)

(Point3d::*的意思是,指向Point3d data member的指针类型)

要如何区分它们呢?---答案就如上述所言。

因此,在真正使用该值指出一个member之前,请先减去1。

认识指向data member的指针后,要解释&Point3d::z;和&origin.z(orign是Point3d的实例)间的差异就很明确了。鉴于“取得一个nonstatic data member“的地址,将会得到它在类中的偏移量。即前者;而”取一个绑定在真正的class object上的data member地址,将会得到该member在内存中的真正地址”,即后者。且前者返回的是float Point3d::*,而后者返回的是float *。

而把&origin.z的结果减去z的偏移量(即&Point3d::z),然后加上1,就会得到origin的起始地址啦!

             

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值