深入探索C++内存模型(3)
第三章的标题是Data语意学,这一章主要的研究对象是类的成员变量相关的操作原理,静态成员变量与非静态成员变量,单一继承、多重继承、虚继承对成员变量内存分布和存取性能的影响。
第三章 Data语意学(the semantics of data)
一个空的(不含有任何成员变量)class,事实上并不是空的,它有一个隐晦的1byte,那是被编译器安插进去的一个char,这使得这个class的两个objects得以在内存中配置独一无二的地址。
含有虚继承父类的大小受到三个因素的影响:
- 语言本身所造成的额外负担(overhead),当语言支持 virtual base class时,就会导致一些额外负担;
- 编译器对于特殊情况所提供的优化处理;
- Alignment的限制,在大部分机器上,群聚的结构体大小会受到alignment的限制,使它们能够更有效率地在内存中被存取。
3.1 Data Member的绑定(the binding of a data member)
文中介绍的一个古老的语言规则“member rewriting rule”,大意是:一个inline函数实体,在整个 class 声明未被完全看见之前,是不会被评估求值(evaluated)的。
extern int x;
class Point3d {
public:
float X() const {return x;}
private:
float x;
};
上面的代码,对于函数本身的分析将延迟直至 class 声明的右大括号出现才开始。
但是对于 member function 的 argument list 却并不是这样。Argument list 中的名称还是会在它们第一次遭遇时被适当地决议(resolved)完成,示例代码如下。
typedef int length;
class Point3d {
public:
// length 被 resovled 为 global
void mumble(length val) { _val = val;}
length mumble() { return _val;}
private:
typedef float length;
length _val;
};
针对上述这种语言状况,需要某种防御性程序风格:请始终把“nested type 声明”放在class 的起始处。在上述例子中,如果把 length
的 nested typedef 定义于“在 class 中被参考”之前,就可以确保非直觉绑定的正确性。
3.2 Data Member 的布局(data member layout)
Nonstatic data members 在 class object 中的排列顺序将和其被声明的顺序一样,任何中间介入的 static data members 如 freeList 和 chunkSize 都不会被放进对象布局之中。
C++ standard 要求,在同一个 access section(也就是 private、public、protected等区段)中,members 的排列只需符合“较晚出现的members 在 class object 中有较高的地址” 这一条即可(请看 C++ Standard9.2节)。也就是说各个 members 并不一定得连续排列,members的字节对齐(alignment)可能就需要填补一些 bytes。编译器还可能会合成一些内部是即用的data members,以支持整个对象模型,如vptr虚指针。
当前各家编译器都是把一个以上的 access sections 连锁在一起,依照声明次序,成为一个连续区块。Access sections 的多寡并不会招来额外负担。
3.3 Data member 的存取
思考一个问题,对于如下代码,通过 origin 存取和通过 pt 存取,有什么重大差异吗?
Point3d origin, *pt = &origin;
// 用它们来存取 data members
origin.x = 0.0;
pt->x = 0.0;
Static Data Members
Static data members,按其字面意义,被编译器提出于 class 之外,并被视为一个global变量(但只在 class 声明范围之内可见)。每一个 static data member 只有一个实体,存放在程序的 data segment 之中,每次程序取用 static member,就会被内部转化为对该唯一的 extern 实体的直接参考操作。
如果该 static data member 是一个从复杂继承关系中继承而来的,也都是一样,程序之中对于 static data members 还是只有唯一一个实体,而其存取路径仍然是那么直接。
若取一个 static data member 的地址,会得到一个指向其数据类型的指针,而不是一个指向其 class member 的指针,因为 static data member 并不内含在一个 class object 之中。
编译器会对 static data member 进行换名(name-mangling),有两个要点:
- 一种推导出独一无二名称的算法;
- 如果编译系统(或环境工具)必须和使用者交谈,那些独一无二的名称可以轻易被推导回原来的名称。
Nonstatic Data Members
Nonstatic data members 直接存放在每一个 class object 之中,需要通过明确的(explicit)或暗喻的(implicit) class object 存取它们。常见的Implicit存取方式是使用省略掉的 this
指针对成员变量进行存取。
欲对一个 nonstatic data member 进行存取操作,编译器需要把 class object 的起始地址加上 data member 的偏移量(offset)。
origin._y = 0.0;
// 那么地址 &origin._y 将等于:
&origin + (&Point3d::_y - 1);
请注意其中的 -1 操作,指向 data member 的指针,其 offset 值总是被加上1,这样可以使编译系统区分出以下两种情况:
- 一个指向 data member 的指针,用以指出 class 的第一个 member (
Point3d::*_y
)
- 一个指向 data member 的指针,没有指出任何 member (空指针)
注意: 经过我在gcc5.3中测试,指向第一个成员变量的指针是0,并没有如书中所说的被加上1。可见编译器的特性也是不断发展的。
每一个 nonstatic data member 的偏移量(offset)在编译时期即可获知,甚至如果 member 属于一个 base class subobject(派生自单一或多重继承串链)也是一样。
对于虚继承,它将为“经由 base class subobject 存取 class members”导入一层新的间接性。
分析本章开始的问题,当 Point3d
是一个derived class,而在其继承结构中有一个 virtual base class,并且被存取的 member(如本例的 x
)是一个从该 virtual base class 继承而来的 member 时,就会有重大差异。这时我们不能确定 pt
必然指向哪一种 class type,因此我们也就不知道编译时期这个 member 真正的 offset 位置,这个存取操作必须延迟至执行期。但如果使用 origin
就不会有这些问题,其类型无疑是 Point3d
class,即使它继承自 virtual base class,members 的 offset 位置也在编译时期就固定了。
3.4 “继承” 与 Data Member
把两个原本独立不相干的 classes 凑成一对“type/subtype”,并带有继承关系,会有一些易犯错误:
- 经验不足的人可能会重复设计一些相同操作的函数
- 把一个 class 分解为两层或更多层,有可能会为了“表现 class 体系的抽象化”而膨胀所需空间。C++语言保证出现在 derived class 中的 base class subobject 有其完整原样性。书中有完整的例子可供参考。
对于 vptr 在 class object 的位置有两种方案,首端和尾端。放在尾端可以保留 base class C struct 的对象布局,因而允许在 C 程序代码中也能使用,这种做法在 C++ 最初问世时,被许多人采用。
到了 C++2.0,开始支持虚拟继承以及抽象基类,并且由于面向对象典范(OO paradigm)的兴起,某些编译器开始把 vptr 放到 class object 的起头处。
多重继承
对一个多重派生对象,将其地址指定给“最左端(也就是第一个)base class 的指针”,情况和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的赋值操作而已。对于第二个或后继的 base class 的地址赋值操作,则需要将地址修改过:加上(或减去,如果 downcast 的话)介于中间的 base class subobject(s) 大小。
虚继承
class 如果内含一个或多个 virtual base class subobjects,将被分割为两部分:一个不变局部和一个共享局部。不变局部中的数据,不管后继如何衍化,总是拥有固定的 offset (从 object 的开头算起),所以这一部分数据可以被直接存取。至于共享局部,所表现的就是 virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。
方法1:Microsoft 编译器引入所谓的 virtual base class table, 每一个 class object 如果有一个或多个 virtual base class,就会由编译器安插一个指针,指向 virtual base class table,真正的 virtual base class 指针被放在该表格中。
方法2:在 virtual function table 中放置 virtual base class 的 offset(而不是地址)。将 virtual base class offset 和 virtual function entries 混杂在一起。在新的 Sun 编译器中,virtual function table 可经由正值或负值来索引。如果是正值,很显然就是索引到 virtual function;如果是负值,则是索引到 virtual base class offsets。
一般而言,virtual base class 最有效的一种运用形式就是:一个抽象的virtual base class,没有任何 data members。
3.5 对象成员的效率(object member efficiency)
本节包含数个测试,测试聚合(aggregation)、封装(encapsulation)、继承(inheritance)所引发的额外负荷的程度。
以下是汇总后的测试结论:
- 如果把优化开关打开,“封装”就不会带来执行期的效率成本,使用inline存取函数亦然。
- 单一继承不会影响效率,因为 members 被连续存储于 derived class object 中,并且其 offset 在编译时期就已知了。
3.6 指向 data members 的指针(pointer to data members)
指向 data members 的指针,是一个有点神秘但颇有用处的语言特性。特别是你需要详细调查 class members 的底层布局的话,可以得知 vptr 是放在 class 的起始处或是尾端。另一个用途展现于3.2节,可以用来检测 class 中的 access section 的次序。
& Point3d::z;
如上述代码,取某个坐标成员的地址,将得到 member 在 class object 中的偏移量(offset)。
需要注意的是,在打印这些指针时,需要使用推荐使用 printf
,而不推荐使用 std::cout
,示例代码如下。
printf("&Point3d::x = %p\n", &Point3d::x);
// 而以下写法会报错
cout << "&Point3d::x = " << &Point3d::x << endl;
为了区分一个“没有指向任何 data member”的指针和一个指向“第一个 data member”的指针(下面代码中的 p1
和 p2
),每一个真正的 member offset 值都被加上1。因此,不论编译器或使用者都必须记住,在真正使用该值以指出一个 member 之前,要先减掉 1。
float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
使用“指向members的指针”的效率问题,在经过优化之后,测试结果并没有明显性能损失;而未优化的代码呈现出较大的性能损失。
指向 data member 的指针,在单一继承情况下没有明显的性能损失,在虚拟继承情况下会有性能损失。并且虚拟继承的层数增多也相应的性能损耗会增多。
(全文完)