Data语义学
一、数据成员的绑定
考虑下面的代码:
1 | // 某个foo.h头文件,从某处含入 |
1 | // Point3d.h文件 |
在 C++
早期如果在 Point3d::X()
的两个函数实例中对x
做出调用操作,该操作将会指向全局的x
对象。这样因此也导出了 C++
的两种防御性程序设计风格:
1 | // 1. 把所有的数据成员放在类声明起头处,以确保正确的绑定 |
自从C++ 2.0
之后,它们的必要性消失了。现在的函数即使在声明之后马上定义,对于函数本体的分析也会延迟到整个类声明完成之后才分析。因此一个内联函数函数体内的一个数据成员的绑定操作,会在整个类声明完成之后才发生。
1 | extern int x; |
需要注意,上述对于成员函数的参数列表并不为真。参数列表中的名称还是会在它们第一次遇到时被适当地决议完成。因此在extern
和nested type names
之间的非直觉绑定操作还是会发生。
1 | typedef int length; |
上述程序中,length
的类型在两个member function signatures
中都决议为global typedef
,也就是int
。当后续再有length
的nested typedef
声明出现时,C++
标准就把稍早的绑定标示为非法。因此,请总是把nested type
声明放在类的起始处。
二、数据成员的布局
- 非静态的数据成员在类对象中的排列顺序和其被声明的顺序一样;
- 静态数据成员存储在程序的数据段中和个别的类对象无关;
- 编译器可能会合成一些内部使用的数据成员,以支持整个对象模型。
vptr
就是这样的东西,目前编译器都把它安插在每一个“内含虚函数的类”对象内。传统上它被放在所有显式声明的成员的最后,不过也有些编译器将它放在一个类对象最前端。
三、数据成员的存取
我们来看下述一段代码:
1 | Point3d origin, *pt = &origin; |
通过origin
存取,和通过pt
存取有什么区别?
3.1 静态数据成员
每一个静态数据成员只有一个实例(不管是简单类,继承类),存放在程序的数据段中。每一次程序取值静态成员时,就会被内部转化为对该实例的直接参考操作。例如:
1 | // origin.chunkSize = 250; |
从指令执行的观点来看,通过一个指针和通过一个对象来存取成员,结论完全相同。
经由
.
对一个静态数据成员进行存取操作只是文法上的一种便宜行事而已。静态成员其实不在类对象内部,因此存取静态成员并不需要通过类对象。同时,若取一个静态数据成员的地址,会得到一个指向其数据类型的指针,而不是一个指向其类成员的指针。
如果一个静态数据成员是经由函数调用,或其他某些语法而被存取呢?
1 | // C++标准明确要求foobar()必须被求值,虽然结果并没用 |
3.2 非静态数据成员
非静态数据成员直接存放在类对象内部。除非经由显式的或隐式的类对象,否则没有办法直接存取他们。
只要在一个成员函数中直接处理一个非静态数据成员,所谓“implicit class object”就会发生。
1 | Point3d Point3d::translate( const Point3d &pt) |
欲对一个 非静态变量进行存取操作,编译器需要把类对象的起始地址加上数据成员的偏移地址offset
:
1 | origin._y = 0.0; |
需要注意其中的-1操作。指向数据成员的指针,其offset值总是被加上1,这样可以使编译系统区分出:
- 一个指向数据成员的指针,用以指出类的第一个成员;
- 一个指向数据成员的指针,没有指出任何成员。
每一个非静态成员的偏移位置在编译时期就可获知,甚至如果成员属于一个基类子对象(派生自单一或者多重继承链)也是一样。因此,存取一个非静态数据成员,其效率和存取一个C struct member
或非派生类的成员一样。
虚拟继承将为“经由基类子对象存取类成员”导入一层新的间接性,比如:
1 | Point3d *pt3d; |
其执行效率在_x
是一个结构体成员、一个类成员、单一继承、多重继承的情况下都完全相同。但如果_x
是一个虚基类的成员,存取速度会稍慢一点。
四、继承于数据成员
在 C++
继承模型中,一个派生类所表现出来的东西,是其自己的成员加上其基类成员的总和。
以 2D
或 3D
坐标点提供两个抽象数据类型为例:
1 | class Point2d { |
在不含虚函数的情况下,Point2d
和 Point3d
的对象布局图和C 结构体一样。
4.1 只要继承不要多态
- 一般而言,具体继承并不会增加空间或存取时间上的额外负担。
1 | class Point2d { |
Point2d
和 Point3d
继承关系的实物布局,其间并没有声明虚接口。
- 如果把一个类分解为两层或更多层,有可能会为了“表现类体系之抽象化”而膨胀所需的空间。**
C++
语言保证“出现在派生类中的基类子对象有其完整原样性”**。例如下面的实例:
1 | class Concretel { |
4.2 加上多态
1 | class Point2d { |
加入多态后,势必对我们的 Point2d
类带来空间和存取时间上的额外负担:
- 导入一个和
Point2d
有关的虚表,用来存放它所声明的每一个虚函数的地址。这个表的元素一般而言是被声明的虚函数的个数,再加上一个或两个slots
(用于支持runtime type identification
)。 - 在每一个类对象中导入一个
vptr
,提供执行期的链接,使每一个对象能够找到相应的虚表。 - 加强构造函数,使它能够为
vptr
设定初值,让它指向类所对应的虚表。这可能意味着在派生类和每一个基类的构造函数中,重新设定vptr
的值。 - 加强析构函数,使它能够抹消“指向类相关虚表”的
vptr
。
4.3 多重继承
1 | class Point2d { |
对一个多重派生对象,将其地址指定给“最左端基类的指针”,情况和单一继承时相同,因为两者都指向相同的起始地址。需付出的成本只有地址的指定操作而已。至于第二个或候机的基类地址指定操作,则需要讲地址修改过去:加上/减去介于中间的基类子对象大小。
1 | Vertex3d v3d; |
4.4 虚拟继承
类如果内含一个或多个虚基类子对象,将被分割成两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何衍化,总拥有固定的 offset
(从对象的开头算起),所以这一部分数据可以直接存取。至于共享区域,所表现的就是虚基类子对象。这一部分的数据,其位置会因为每次的派生操作而有变化,所以他们只可以被间接存取。
1 | class Point2d { |
一般的布局策略是先安排好派生类的不变部分,然后再建立其共享部分。然而,这中间存在一个问题:如何能够存取类的共享部分呢?
有两种策略可以解决上述问题:
- 编译器会在每一个派生类对象中安插一些指针,每一个指针指向一个虚基类。要存取继承得来的虚基类成员,可以通过相关指针间接完成。即,以指针指向基类的实现模型。
上述实现模型有两个主要的缺点:
- 每一个对象必须针对其每一个虚基类背负一个额外的指针。然而理想上我们却希望类对象有固定的负担,不因为其虚基类的个数而有所变化。
- 由于虚拟继承串链的加长,导致间接存取层次的增加。如果我有三层虚拟派生,我就需要三次间接存取。然而理想上我们却希望有固定的存取时间,不因为虚拟派生的深度而改变。
对于第二个问题,MetaWare和其他编译器到今天仍采用cfron t的原始模型来解决,即,他们经由拷贝操作取得所有的nested 虚基类指针,放到派生类对象之中。这就解决了“固定存取的问题”,虽然付出了一些空间上的代价。下图便说明了这种以指针指向基类的实现模型。
对于第一个问题,一般有两种解决方法。
- 微软编译器引入所谓的虚基类表。每一个类对象如果有一个或者多个虚基类,就会有编译器安插一个指针,指向虚基类表。而真正的虚基类指针或被放到该表中。
- 在虚函数表中放置虚基类的offset(而不是地址)。
- 本文标题:深入理解C++对象模型(三)
- 创建时间:2024-04-23 13:46:02
- 本文链接:2024/04/23/深入理解C-对象模型-三/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!