「书摘」《深度探索 C++ 对象模型》(三)

Data 语意学 (The Semantics of Data)

1
2
3
4
class X {};
class Y : public virtual X{};
class Z : public virtual X{};
class A : public Y, public Z{};

YZ 的大小与机器(字长)有关,也和编译器有关,具体受以下三个因素的影响:

  • 语言本身所造成的额外负担(overhea)
  • 编译器对于特殊情况所提供的优化处理
  • Alignment 的限制

class A 的大小由下列几点决定:

  • 被大家共享的唯一一个 class X 实例,大小为 1 byte
  • Base class YBase class Z 的大小,减去“因 virtual base class X 而配置” 的大小,结果式 4 bytes
  • class A 自己的大小:0 byte
  • class A 的 alignment 数量(如果有的化)。
    结果为 12 bytes

”特别对 empty virtual base class 做了处理“ 的编译器,class X 实例的那 1 byte 将被拿掉,class A 的大小将是 8 bytes

继承得到的数据的存放顺序,C++标准没有强制定义其间的排列顺序。

Data Member 的绑定 (The Binding of a Data Member)

1
2
3
4
5
6
7
8
9
10
11
12
extern int x;
class Point3d
{
public:
...
// 对于函数本体的分析将延迟,直至 class 声明的右大括号出现才开始。
float X() const { return x; }
private:
float x;
...
};
// 分析在这里进行

member function 的 argument list 会在第一次遭遇时被适当地决议完成。extern 和 nested type names 之间的非直觉绑定操作还是会发生。

1
2
3
4
5
6
7
8
9
10
11
typedef int length;

class Point3d
{
public:
void mumble( length val ) { _val = val; } // length 为 int
length mumble() { return _val; }
private:
typedef float length;
length _val;
};

采用防御性程序风格改进后

1
2
3
4
5
6
7
8
9
10
11
12
typedef int length;

class Point3d
{
private:
typedef float length;
public:
void mumble( length val ) { _val = val; } // length 为 float
length mumble() { return _val; }
private:
length _val;
};

请总是把 “nested type 声明” 放在 class 的起始处。

Data Member 的布局 (Data Member Layout)

C++ Standard 要求,在同一个 access section(也就是 private。public,protected 等区段)中,members 的排列只需符合“较晚出现的 members 在 class object 中有较高的地址”这一条件即可。

判断哪个 section 先出现的 template function:

1
2
3
4
5
6
7
8
9
10
template < class class_type, class data_type1, class data_type2 >
const char* access_order(
data_type1 class_type::*mem1,
data_type2 class_type::*mem2 )
{
assert (mem1 != mem2);
return mem1 < mem2
? "member 1 occurs first"
: "member 1 occurs first";
}

调用:access_order( &Point3d::z, &Point3d::y );

Data Member 的存取

1
2
Point origin;
Point *pt = &origin;
1
2
origin.x = 0.0;
pt->x = 0.0;

通过 origin 存取,和通过 pt 存取的差异将在本节得出答案。

Static Data Member

每个 static member 的存取许可,以及与 class 的关联,并不会招致任何空间上或执行时间上的额外负担,不论是在个别的 class objects 还是在 static data member 本身。

Nonstatic Data Member

Nonstatic Data Member 直接存放在每一个 class object 之中。除非经由显式的(explicit)或隐式的(implicit)class object,否则没有办法直接存取它们。

1
2
3
4
5
6
Point 3d
Point 3d::translate( const Point3d &pt){
x += pt.x;
y += pt.y;
z += pt.z;
}

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

1
2
3
4
5
6
Point 3d
Point 3d::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 member 的偏移位置(offset)。
origin._y = 0.0; 会转化为 &origin + (&Point3d::_y - 1);
请注意其中的 -1操作。指向 data member 的指针,其 offset 值总是被加上 1,这样可以使编译系统区分出“一个指向 data member 的指针,用以指出 class 的第一个 member” 和 “一个指向 data members 的指针,没有指出任何 member” 两种情况。

每一个 nonstatic data member 的偏移位置(offset)在编译时期即可获知,甚至如果 member 属于一个 base class subobject(派生自单一或多重继承串链)也是一样的。存取一个 nonstatic data member ,其效率和存取一个 C struct member 或 一个 nonderived class 的 member 是一样的。

Nonstatic Data Member 在虚拟继承中

虚拟继承将为“经由 base class subobject 存取 class member“ 导入一层新的间接性:

1
2
Point3d *pt3d;
pt3d->_x = 0.0;

其执行效率在 _x 是一个 struct member、一个 class member、单一继承、多重继承的情况下都完全相同。但如果 _x 是一个 virtual base class 的 member ,存取速度会稍慢一点。

origin._y = 0.0; 会转化为 &origin + (&Point3d::_y - 1);
Point3d 是一个 derived class,而其继承结构中有一个 virtual base class,并且被存取的 member(如本例的 x )是一个从该 virtual base class 继承而来的 member 时,就会有重大的差异。
这时候我们不能说 pt 必然指向哪一种 class type(因此,我们也就不知道编译时期这个 member 真正的 offset 位置),所以这个存取操作必须延迟至执行期,经由一个额外的间接导引,才能够解决。但如果使用 origin ,就不会有这些问题,members 的 offset 位置在编译时期就固定了。

“继承” 与 Data Member

derived class members 和 base class(es) members 的排列顺序,则并未在 C++ Standard 中强制指定;理论上编译器可以自由安排。在大多数编译器上头,base class members 总是先出现,但属于 virtual base class 的除外(一般而言,任何一条通则一旦碰上 virtual base class 就没辙了,这里亦不例外)。

只要继承不要多态 (Inheritance without Polymorphism)

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

C++语言保证“出现在 derived class 中的 base class subobject 有其完整原样性”。
因为要保证 bitwise 拷贝的正确性。

1
2
3
4
5
6
class Concrete{
int val;
char c1;
char c2;
char c3;
};
  • val 占用 4 bytes
  • c1c2c3 各占用 1 bytes
  • alignment(调整到 word 边界)需要 1 byte
1
2
3
4
5
6
7
8
9
10
class Concrete1 {
int val;
char c1;
};
class Concrete2 : public Concrete1 {
char c2;
};
class Concrete3 : public Concrete2 {
char c3;
};
  • Concrete1 占用 8 bytes,包括填补用的 3 bytes
  • Concrete2 占用 12 bytes,填补 3 bytes
  • Concrete3 占用 16 bytes,填补 3 bytes

加上多态 (Adding Polymorphism)

Polymorphism 带来空间和存取时间上的额外负担

  • 导入一个 virtual table,用来存取它所声明的每一个 virtual functions 的地址。这个 table 的元素个数一般而言是被声明的 virtual functions 的个数,再加上一个或两个 slots(用以支持 runtime tyoe identifition)。
  • 在每一个 class object 中导入一个 vptr,提供执行器的链接,使每一个 object 能够找到相应的 virtual table。
  • 加强 constructor,使它能够为 vptr 设定初值,让它指向 class 所对应的 virtual table。这可能意味着在 derived class 和每一个 base class 的 constructor 中,重新设定 vptr 的值。其情况视编译器优化的积极性而定。
  • 加强 destructor,使它能够抹消“指向 class 之相关 virtual table” 的 vptrvptr 很可能已经在 derived class destructor 中被设定为 derived class 的 virtual table 的地址。destructor 的调用的顺序上反向的:从 derived class 到 base class,一个积极的优化编译器可以压抑那些大量的制定操作。

vptr 放在前端,代价是丧失了 C 语言兼容性。

多重继承 (Multiple Inheritance)

……

虚拟继承 (Virtual Inheritance)

虚拟继承的两个问题

  • 每一个对象必须针对其每一个 virtual base class 背负一个额外的指针,然而理想上我们希望 class object 有固定负担,不因为其 virtual base classes 的个数而有所变化。
  • 由于虚拟继承串链的加长,导致间接存取层次的增加。我们希望有固定的存取时间,不因为虚拟派生的深度而改变。

第一个问题两种解决办法:

  • Microsoft 编译器引入的 virtual base class table。
  • 在 virtual function table 中放置 virtual base class 的 offset(不是地址),在 Sun 编译器中,virtual function table 可经由正值或负值来索引,如果是正值索引到 virtual function ,如果是负值索引到 virtual base class offset。

第二个问题,它们经由拷贝操作取得所有的 nested virtual base class 指针,放到的 derived class object 之中。

对象成员的效率 (Object Member Efficiency)

聚合(aggregtion)
封装(encapsulation)
继承(inheritance)

不断加强抽象化程度后,数据的存取效率

优化 未优化
个别的局部变量 0.80 1.42
局部数组
CC 0.80 2.55
NCC 0.80 1.42
struct 之中有 public 成员 0.80 1.42
class 之中有 inline Get 函数
CC 0.80 2.56
NCC 0.80 3.10
class 之中有 inline Get & Set 函数
CC 0.80 1.74
NCC 0.80 2.87

如果把优化开关打开,“封装”就不会带来执行器的效率成本,使用 inline 存取函数亦然。

为什么在 CC 之下存取数组,几乎比 NCC 慢两倍?

1
2
3
4
5
6
7
8
// CC assembler output
# 13 pB[ x ] = pA[ x ] - pB[ x ];
add $25, $sp, 20
1.s $f4, 0($25)
addu $24, $sp, 8
1.s $f6, 8($24)
sub.s $f8, $f4, $f6
s.s $f8, 0($24)

1
2
3
4
5
6
// NCC assembler output
# 13 pB[ x ] = pA[ x ] - pB[ x ];
1.s $f4, 20($25)
1.s $f6, 16($24)
sub.s $f8, $f4, $f6
s.s $f8, 0($24)
  • 1.s 加载一个单精度浮点数
  • s.s 存储一个单精度浮点数
  • sub.s 将两个单精度浮点数相减

在继承模型之下的数据存取

优化 未优化
单一继承
直接存取 0.80 1.42
使用 inline 函数
CC 0.80 2.55
NCC 0.80 3.10
虚拟继承(单层)
直接存取 1.60 1.94
使用 inline 函数
CC 1.60 2.75
NCC 1.60 3.30
虚拟继承(双层)
直接存取
CC 2.25 2.74
NCC 3.04 3.68
使用 inline 函数
CC 2.25 3.22
NCC 2.50 3.81

指向 Data Member 的指针 (Pointer to Data Members)

1
2
3
4
5
6
7
class Point3d{
public:
virtual ~Point3d();
static Point3d origin;
float x, y, z;
};
&Point3d::z // 得到 z 在 class object 中的 offset

输出 offset

1
2
3
printf("%p\n",&Point3d::x);
printf("%p\n",&Point3d::y);
printf("%p\n",&Point3d::z);

早期一些编译器 &Point3d::z 这个操作的值为 z 的 offset + 1。

1
2
3
float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d:x;
// Point3d::* 的意思是 "指向 Point3d data member" 的指针类型

1
2
3
4
if(p1 == p2) {
cout << " p1 & p2 contain the same value -- ";
cout << " they must address the same member!" << endl;
}

对 offset 加 1,方便区分 NULL 和第一个 data member。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Base1{ int val1; };
struct Base2{ int val2; };
struct Derived : Base1, Base2{};

void func1( int Derived::*dmp, Derived *pd)
{
pd->*dmp;
}

void func2( Derived *pd)
{
// bmp 为 1
int Base2::*bmp = &Base2::val2;
// bmp == 1
// 但在 Derived 中, val2 == 5
func1(bmp,pd);
}
1
2
// 经由编译器内部转换
func1( bmp + sizeof( Base1 ), pd );

一般而言,我们不能够保证 bmp 不是 0,因此必须注意这一点:

1
func1( bmp ? bmp + sizeof( Base1 ) : 0, pd );

我的测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct Base1{ int val1; };
struct Base2{ int val2; };
struct Derived : Base1, Base2{};

int func1( int Derived::*dmp, Derived *pd)
{
printf("%p\n",dmp);
return pd->*dmp;
}

int func2( Derived *pd)
{
int Base2::*bmp = &Base2::val2;
printf("%p\n",bmp);
return func1(bmp,pd);
}


int main()
{
Derived xx;

xx.val1 = 11;
xx.val2 = 22;

cout << func2(&xx) << endl;

return 0;
}

“指向 Data Member 的指针” 的效率问题

存取 Nonstatic Data Member

优化 未优化
直接存取 0.80 1.42
指针指向已绑定的 Member 0.80 3.04
指针指向 Data Member
CC 0.80 5.34
NCC 4.04 5.34

“指向 Data Member 的指针”存取方式

优化 未优化
没有继承 0.80 5.34
单一继承(三层) 0.80 5.34
虚拟继承(单层) 1.60 5.44
虚拟继承(双层) 2.14 5.51

在两个编译器中,每次存取 Point::x,像这样: pB.*bx 会被转换为:

1
&pB->__vbcPoint + ( bx - 1 )

而不是转换为最直接的:

1
&pB + ( bx - 1 )

额外的间接性会降低“把所有的处理都搬移到寄存器中执行”的优化能力。

Donate comment here