Data语意学


本文主要介绍C++对象中数据成员的有关内容,详细示例请参考C++对象数据成员存储示例

一、数据成员的布局

已知下面一组数据成员:
在这里插入图片描述
非静态数据成员在类对象中的排列顺序将和其被声明的顺序一样,任何中间介入的静态数据成员如freelist和chunkSize都不会被放进对象布局之中。在上述例子中,每一个Point3d对象是由三个float组成的,顺序是x、y、z。静态数据成员存放在程序的data segment中,和类对象无关。

C++标准要求,在同一个access section(也就是private、public、protected等区段)中,成员的排列只需符合"较晚出现的成员在类对象中有较高的地址"这一条即可。也就是说,各个成员并不一定得连续排列。那么什么东西可能会介于声明的成员之间呢?成员的边界对齐调整可能就需要填补一些bytes。

编译器还可能会合成一些内部使用的数据成员,以支持整个对象模型。vptr(虚函数表指针)就是这样的东西,目前所有的编译器都把它安插在每一个内含虚函数的类的对象内。但是C++标准并没有规定vptr的存放位置。

C++编译器也允许编译器将多个access section之中的数据成员自由排列,不必在乎它们出现在类声明中的顺序。对于下面这样的声明:
在这里插入图片描述
其类对象的大小和组成都和先前声明的那个相同,但是成员的排列顺序则视编译器而定。目前各家编译器都是把一个以上的access section连锁在一起,依照声明的顺序,成为一个连续区块。

二、数据成员的存取

2.1、静态数据成员

2.1.1、简单情况

每一个静态数据成员只有一个实例,存放在程序的data segment中。每次存取静态成员时,就会被内部转化为对该唯一extern实例的直接存取操作。例如:

在这里插入图片描述
从指令执行的观点来看,这是C++语言中"通过一个指针和通过一个对象来存取成员,结论完全相同"的唯一一种情况。这是因为经由成员选择运算符(.)对一个静态数据成员进行存取操作,这只是文法上的一种便宜行事而已。静态数据成员其实并不在类对象中,因此存取静态数据成员并不需要通过类对象。

2.1.2、包含继承机制的复杂情况

如果chunkSize是从一个复杂继承关系中继承而来的成员,情况又如何呢?答案是,那无关紧要,程序之中对于静态数据成员还是只有唯一一个实例,而存取路径仍然是那么直接。

2.1.3、经由函数调用

如果静态数据成员是经由函数调用,或其他某些语法而被存取呢?例如:
在这里插入图片描述
调用foobar()会发生什么事情?在C++的准标准(pre-Standard)规格中,没有人知道会发生什么事,因为ARM并未指定foobar()是否必须被求值。下面是一种可能的转化:
在这里插入图片描述

2.1.4、静态数据成员的地址

若存取一个静态数据成员的地址,会得到一个指向其数据类型的指针,而不是一个指向其类成员的指针,因为静态成员并不内含在一个类对象之中。例如:
在这里插入图片描述
会获得类型如下的内存地址:
在这里插入图片描述
如果有两个类,每一个都声明了一个静态数据成员freelist,那么当它们都被放在程序的data segment时,就会导致名称冲突。编译器的解决方法是暗中对每一个静态数据成员编码(name-mangling),以获得独一无二的程序识别代码。

2.2、非静态数据成员

非静态数据成员直接存放在一个类对象中。除非经由显式的(explicit)或隐式的(implicit)类对象,否则没有办法直接存取它们。只要在一个成员函数中直接处理一个非静态数据成员,所谓的"implicit class object"就会发生。例如:
在这里插入图片描述
将被转化为:
在这里插入图片描述
有关成员函数的转换,请参考Function语意学

欲对一个非静态数据成员进行存取操作,编译器需要把类对象的起始地址加上数据成员的偏移位置(offset)。例如:
在这里插入图片描述
那么地址&origin._y将等于:
在这里插入图片描述
请注意其中的-1操作。指向数据成员的指针,其offset值总是被加上1,这样可以使编译系统区分出"一个指向数据成员的指针,用以指向类的第一个成员"和"一个指向数据成员的指针,没有指向任何成员"两种情况。

对于下面的代码:
在这里插入图片描述
其执行效率在_x是一个struct member、一个class member、单一继承、多重继承的情况下都完全相同。但是如果_x是一个虚基类的成员,存取速度会稍慢一点。详情请参考三、继承与数据成员

三、继承与数据成员

三、继承与数据成员

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

考虑下面这两个类型定义:
在这里插入图片描述
Point2d和Point3d的内存布局图如下所示:
在这里插入图片描述

上面的定义和"提供两层或三层继承结构,每一层(代表一个维度)是一个类,派生自较低维度层次"有什么不同?下面各小节的讨论将涵盖"单一继承且不含虚函数"、“单一继承并含虚函数”、“多重继承”、"虚拟继承"等4种情况。

3.1、单一继承不含虚函数

3.1、单一继承不含虚函数

我们或许希望,不论是2D或3D坐标点,都能够共享同一个实例,但又能够继续使用"与类型性质相关(所谓type-specific)“的实例。我们有一个设计策略,就是从Point2d派生出一个Point3d,于是Point3d将继承x和y坐标的一切(包括数据实例和操作方法)。带来的影响则是可以共享"数据本身"以及"数据的处理方法”,并将之局部化。一般而言,具体继承(相对于虚继承)并不会增加空间或存取时间上的额外负担。
在这里插入图片描述
这样设计的好处就是可以把管理x和y坐标的程序代码局部化。此外,这个设计可以明显表现出两个抽象类之间的紧密关系。当这两个类独立的时候,Point2d对象和Point3d对象的声明和使用都不会有所改变。所以这两个抽象类的使用者不需要知道对象是否为独立的类类型,或是彼此之间有继承的关系。下图显示了Point2d和Point3d继承关系的布局:
在这里插入图片描述
把两个原本独立不相干的类凑成一对"type/subtype",并带有继承关系,会有什么易犯的错误呢?

  • 第一个易犯的错误是,经验不足的人可能会重复设计一些相同操作的函数。
  • 第二个易犯的错误是,把一个类分解为两层或更多层,有可能会为了"表现类体系的抽象化"而膨胀所需的空间。C++语言保证"出现在派生类中的基类subobject有其完整原样性",正是重点所在。

3.1.1、基类subobject的完整原样性

以下面例子所示:
在这里插入图片描述
在一台32位机器中,每一个Concrete类对象的大小都是8bytes,细分如下:

  • val占用4bytes
  • c1、c2、c3各占用1byte
  • 边界对齐需要1byte

假设我们将Concrete分裂为三层结构:
在这里插入图片描述
那么Concrete3对象的大小是16bytes。具体分析如下:Concrete1内含两个成员:val和bit1,加起来是5bytes。而一个Concrete1对象实际用掉8bytes,包括填补用的3bytes。

这样的布局会导致我们犯下这样的错误:Concrete2加了唯一一个非静态数据成员bit2,数据类型为char。我们可能会以为它会和Concrete1捆绑在一起,占用原本用来填补空间的1byte;于是Concrete2 对象的大小为8bytes,其中2bytes用于填补空间。

然而Concrete2的bit2实际上却是被放在填补空间所用的3bytes之后。于是其大小变成12bytes,其中有6bytes浪费在填补空间上。相同的道理使得Concrete3对象的大小是16bytes,其中9bytes用于填补空间。Concrete1、Concrete2、Concrete3的内存布局如下图所示:
在这里插入图片描述

3.1.2、为什么

那么C++为什么要这样做呢?考虑下面的一组指针:
在这里插入图片描述
其中pc1_1和pc1_2两者都可以指向前述三种类对象。下面这个指定操作:
在这里插入图片描述
应该执行一个默认的"memberwise"复制操作(复制一个个的成员),对象是被指之对象的Concrete1那一部分。如果pc1_1实际指向一个Concrete2对象或Concrete3对象,则上述操作应该将复制内容指定给其Concrete1 subobject。

然而,如果C++语言把派生类成员(也就是Concrete2::bit2或Concrete3::bit3)和Concrete1 subobject捆绑在一起,去除填补空间,上述那些语意就无法保留了,那么下面的指定操作:
在这里插入图片描述
就会将"被捆绑在一起、继承而得的"成员内容覆盖掉。这肯定会导致bug!
在这里插入图片描述

3.2、单一继承含虚函数

3.2、单一继承含虚函数

如果我们要处理一个坐标点,不在乎它是一个Point2d或Point3d实例,那么我们需要在继承关系中提供一个虚函数接口。如下所示:
在这里插入图片描述
只有当我们企图以多态的方式处理2d或3d坐标点时,在设计中导入一个virtual接口才显得合理。也就是说,下面这样的代码:
在这里插入图片描述
其中,p1和p2可能是2d,也可能是3d坐标点。这并不是先前任何设计所能支持的。这样的弹性,当然正是面向对象程序设计的中心。支持这样的弹性,势必对我们的Point2d类带来空间和存取时间上的额外负担:

  • 导入一个和Point2d有关的virtual table,用来存放它所声明的每一个虚函数的地址。这个virtual table的元素个数一般而言是被声明的虚函数的个数,再加上一个或两个slot(用以支持runtime type identification)
  • 在每一个类对象中导入一个vptr,提供执行期的链接,使每一个对象能够找到对应的virtual table
  • 加强constructor,使它能够为vptr设定初值,让它指向类所对应的virtual table。这可能意味着在派生类和每一个基类的constructor中,重新设定vptr的值。其情况视编译器优化的积极性而定
  • 加强destructor,使它能够抹消"指向类的相关virtual table"的vptr。要知道,vptr很可能已经在派生类的destructor中被设定为派生类的virtual table地址。

这些额外负担带来的冲击程度视"被处理的Point2d 对象的个数和生命期"而定,也视"对这些对象做多态程序设计所得的利益"而定。

新的Point3d如下所示:
在这里插入图片描述
与先前的Point3d声明相比,虽然类的声明语法没有改变,但每一件事情都不一样了:两个z()以及operator +=运算符都成了虚函数;每一个Point3d类对象内含一个额外的vptr(继承自Point2d);多了一个Point3d virtual table;此外每一个虚成员函数的调用也比以前复杂了。

Point2d和Point3d加上了虚函数之后的布局如下所示:
在这里插入图片描述

3.3、多重继承

单一继承提供了一种"自然多态(natural polymorphism)"形式,是关于类体系中的基类类型与派生类类型之间的转换。在3.1单一继承不含虚函数3.2、单一继承含虚函数两种情况下,基类和派生类的对象都是从相同的地址开始,其间差异只在于派生类对象比较大,用以容纳它自己的非静态数据成员。下面这样的操作:
在这里插入图片描述
把一个派生类对象指定给基类(不管继承深度有多深)的指针或引用。这个操作并不需要编译器去调停或修改地址。它很自然地可以发生,而且提供了最佳执行效率。

多重继承既不像单一继承,也不容易模塑其模型。多重继承的复杂度在于派生类和其上一个基类乃至于上上一个基类……之间的"非自然"关系。例如,考虑下面这个多重继承体系:
在这里插入图片描述
对一个多重派生对象,将其地址指定给"最左端(也就是第一个)基类的指针",情况将和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的指定操作而已。置于第二个或后继的基类的地址指定操作,则需要将地址修改:加上(或减去,如果downcast的话)介于中间的基类subobject(s)大小。例如:
在这里插入图片描述
那么下面这个指定操作:
在这里插入图片描述
需要这样的内部转化:
在这里插入图片描述
而下面的指定操作:
在这里插入图片描述
都只需要简单地拷贝其地址就好。如果有两个指针如下:
在这里插入图片描述
那么下面的指定操作:
在这里插入图片描述
不能够只是简单地被转换为:
在这里插入图片描述
因为如果pv3d为0,pv将获得sizeof(Point3d)的值。这是错误的!所以,对于指针,内部转换操作需要有一个条件测试:
在这里插入图片描述
至于引用,则不需要针对可能的0值做防卫,因为引用不可能引用到no
object。

Point2d、Point3d、Vertex、Vertex3d的内存布局如下所示:
在这里插入图片描述

3.4、虚继承

考虑如下类继承体系:
在这里插入图片描述
在上述iostream中,实现技术的挑战在于,要找到一个足够有效的方法,将istream和ostream各自维护的一个ios subobject,折叠成为一个由iostream维护的单一ios subobject,并且还可以保存基类和派生类的指针(以及引用)之间的多态指定操作。

一般的实现方法为:如果一个类内含一个或多个virtual base class subobject,向istream那样,将被分隔为两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何变化,总是拥有固定的offset(从object的开头算起),所以这一部分数据可以被直接存取。至于共享区域,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。各种编译器实现技术之间的差异就在于间接存取的方法不同。

3.4.1、安插指向虚基类的指针

考虑如下继承层次结构:
在这里插入图片描述
一般的布局策略是先安排好派生类的不变部分,然后再建立起共享部分。问题是:如何能够存取类的共享部分呢?cfront编译器会在每一个派生类对象中安插一些指针,每个指针指向一个虚基类。要存取继承得来的虚基类成员,可以通过相关指针间接完成。例如:
在这里插入图片描述
在cfront策略之下,这个运算符会被内部转换为:
在这里插入图片描述
而一个派生类和一个基类的实例之间的转换,像这样:
在这里插入图片描述
在cfront实现模型之下,会变成:
在这里插入图片描述
这样的实现模型有两个主要的缺点:

  • 1、每一个对象必须针对其每一个虚基类背负一个额外的指针。然而理想上我们却希望类对象有固定的负担,不因为其虚基类的个数而有所变化
  • 2、由于虚继承链的加长,导致间接存取层次的增加。如果有三层虚派生,那么就需要三次间接存取(经由三个虚基类指针)。然而理想上我们却希望有固定的存取时间,不因为虚派生的深度而改变

MetaWare和其他编译器到今天仍然使用cfront的原始实现模型来解决第二个问题。它们经由拷贝操作取得所有的nested virtual base class指针,放到派生类对象中。这样就解决了固定存取时间的问题,虽然付出了一些空间上的代价。如下图所示:
在这里插入图片描述

3.4.2、虚基类表

至于第一个问题,一般而言有两个解决办法。Microsoft编译器引入所谓的virtual base class table。每一个类对象如果有一个或多个虚基类,就会由编译器安插一个指针,指向virtual base class table。

3.4.3、设置offset

第二个解决方法,同时也是Bjarne比较喜欢的方法,是在virtual function table中放置virtual base class的offset(而不是地址)。在该方法中,virtual base class offset和virtual function entries混杂在一起。如下图所示:
在这里插入图片描述
在这样的策略下,Point3d的operator+=运算符将被转换为如下形式:
在这里插入图片描述
派生类实例和基类实例之间的转换操作,如:
在这里插入图片描述
将被转换为:
在这里插入图片描述

四、指向数据成员的指针

考虑下面的Point3d声明。其中有一个虚函数,一个静态数据成员以及三个坐标值:
在这里插入图片描述
每一个Point3d对象含有三个坐标值,依序为x、y、z,以及一个vptr。唯一可能因编译器不同而不同的是vptr的位置。C++ Standard允许vptr被放在对象中的任何位置,然而实际上,所有编译器不是把vptr放在对象的头,就是放在对象的尾。

取某个坐标成员的地址,代表什么意思呢?例如:
在这里插入图片描述
上述操作将得到z坐标在类对象中的偏移位置。由于vptr的位置没有限制。在一台32位机器上, 每一个float是4bytes,所以我们应该期望刚才获得的值要不是8,要不就是12。

然而,这样的期望却还少1byte。为什么呢?问题在于,如何区分一个"没有指向任何数据成员"的指针,和一个指向"第一个数据成员"的指针。考虑下面的例子:

在这里插入图片描述
为了区分p1和p2,每一个真正的成员offset值都被加上1.因此,不论编译器或使用者都必须记住,在真正使用该值以指出一个成员之前,请先减掉1。

认识"指向数据成员的指针"之后,考虑下面的代码:
在这里插入图片描述
鉴于"取一个非静态数据成员的地址,将会得到它在类中的offset",取一个"绑定于真正类对象身上的数据成员的地址,将会得到该成员在内存中的真正地址"。把&origin.z所得结果减去z的偏移值(相对于origin起始地址),并加1,就会得到origin起始地址。

五、总结

在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值