深入理解C++对象模型(三)
hahahanba Lv1

Data语义学

一、数据成员的绑定

考虑下面的代码:

1
2
// 某个foo.h头文件,从某处含入
extern float x;
1
2
3
4
5
6
7
8
9
10
11
// Point3d.h文件
class Point3d
{
public:
Point3d(float, float, float);
float X() const { return x; }
void X(float new_x) const { x = new_x; }
// ...
private:
float x, y, z;
}

C++ 早期如果在 Point3d::X() 的两个函数实例中对x做出调用操作,该操作将会指向全局的x对象。这样因此也导出了 C++ 的两种防御性程序设计风格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 把所有的数据成员放在类声明起头处,以确保正确的绑定
class Point3d
{
// 防御性程序设计风格 #1
// 在类声明起头处先放置所有的数据成员
float x, y, z;
public:
float X() const { return x; }
// ... etc. ...
};

// 2. 把所有的内联函数,不管大小都放在类声明之外
class Point3d
{
public:
// 防御性程序设计风格 #2
// 把所有的内联函数都移到类之外
Point3d();
float X() const;
void X(float) const;
// ... etc. ...
};
inline float Point3d::X() const { return x; }
// ... etc. ...

自从C++ 2.0之后,它们的必要性消失了。现在的函数即使在声明之后马上定义,对于函数本体的分析也会延迟到整个类声明完成之后才分析。因此一个内联函数函数体内的一个数据成员的绑定操作,会在整个类声明完成之后才发生。

1
2
3
4
5
6
7
8
9
10
11
12
13
extern int x;

class Point3d
{
public:
// 对于函数本体的分析将延迟,直到类声明的右括号出现才开始
float X() const { return x; }
// ...
private:
float x;
...
}
// 事实上,分析在这里进行

需要注意,上述对于成员函数的参数列表并不为真。参数列表中的名称还是会在它们第一次遇到时被适当地决议完成。因此在externnested type names之间的非直觉绑定操作还是会发生。

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

class Point3d
{
public:
// length被决议为global,_val被决议为Point3d::_val
void mumble( length val ) { _val = val; }
length mumble() { return _val; }
// ...
private:
// length必须在“本类对它的第一个操作”之前被看到
// 这样的声明将使先前的参考操作不合法
typedef float length;
length _val;
// ...
};

上述程序中,length的类型在两个member function signatures中都决议为global typedef,也就是int。当后续再有lengthnested typedef声明出现时,C++标准就把稍早的绑定标示为非法。因此,请总是把nested type声明放在类的起始处。

二、数据成员的布局

  • 非静态的数据成员在类对象中的排列顺序和其被声明的顺序一样;
  • 静态数据成员存储在程序的数据段中和个别的类对象无关;
  • 编译器可能会合成一些内部使用的数据成员,以支持整个对象模型。vptr就是这样的东西,目前编译器都把它安插在每一个“内含虚函数的类”对象内。传统上它被放在所有显式声明的成员的最后,不过也有些编译器将它放在一个类对象最前端。

三、数据成员的存取

我们来看下述一段代码:

1
2
3
Point3d origin, *pt = &origin;
origin.x = 0.0;
pt->x = 0.0;

通过origin存取,和通过pt存取有什么区别?

3.1 静态数据成员

每一个静态数据成员只有一个实例(不管是简单类,继承类),存放在程序的数据段中。每一次程序取值静态成员时,就会被内部转化为对该实例的直接参考操作。例如:

1
2
3
4
5
// origin.chunkSize = 250;
Point3d::chunkSize = 250;

// pt->chunkSize = 250;
Point3d::chunkSize = 250;

从指令执行的观点来看,通过一个指针和通过一个对象来存取成员,结论完全相同。

经由.对一个静态数据成员进行存取操作只是文法上的一种便宜行事而已。静态成员其实不在类对象内部,因此存取静态成员并不需要通过类对象。同时,若取一个静态数据成员的地址,会得到一个指向其数据类型的指针,而不是一个指向其类成员的指针。

如果一个静态数据成员是经由函数调用,或其他某些语法而被存取呢?

1
2
3
4
5
// C++标准明确要求foobar()必须被求值,虽然结果并没用
// foobar().chunkSize = 250;
// 可能的转化方式
(void) foobar();
Point3d.chunkSize = 250;

3.2 非静态数据成员

非静态数据成员直接存放在类对象内部。除非经由显式的或隐式的类对象,否则没有办法直接存取他们。

只要在一个成员函数中直接处理一个非静态数据成员,所谓“implicit class object”就会发生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

欲对一个 非静态变量进行存取操作,编译器需要把类对象的起始地址加上数据成员的偏移地址offset

1
2
3
origin._y = 0.0;
//那么地址&origin._y将等于
&origin + ( &Point3d::_y - 1 );

需要注意其中的-1操作。指向数据成员的指针,其offset值总是被加上1,这样可以使编译系统区分出:

  • 一个指向数据成员的指针,用以指出类的第一个成员;
  • 一个指向数据成员的指针,没有指出任何成员。

每一个非静态成员的偏移位置在编译时期就可获知,甚至如果成员属于一个基类子对象(派生自单一或者多重继承链)也是一样。因此,存取一个非静态数据成员,其效率和存取一个C struct member或非派生类的成员一样。

虚拟继承将为“经由基类子对象存取类成员”导入一层新的间接性,比如:

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

其执行效率在_x是一个结构体成员、一个类成员、单一继承、多重继承的情况下都完全相同。但如果_x是一个虚基类的成员,存取速度会稍慢一点。

四、继承于数据成员

C++ 继承模型中,一个派生类所表现出来的东西,是其自己的成员加上其基类成员的总和。

2D3D坐标点提供两个抽象数据类型为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Point2d {
public:
// constructor(s)
// operations
// access functions
private:
float x, y;
};

class Point3d {
public:
// constructor(s)
// operations
// access functions
private:
float x, y, z;
};

在不含虚函数的情况下,Point2dPoint3d 的对象布局图和C 结构体一样。

image

4.1 只要继承不要多态

  • 一般而言,具体继承并不会增加空间或存取时间上的额外负担。
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
30
31
class Point2d {
public:
Point2d(float x = 0.0, float y = 0.0)
: _x(x), _y(y) {}
float x() { return _x; }
float y() { return _y; }
void x(float newX) { _x = newX; }
void y(float newY) { _y = newY; }
void operator+=(const Point2d& rhs) {
_x += rhs.x();
_y += rhs.y();
}
// ...more members
private:
float _x, _y;
};

class Point3d : public Point2d {
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
: Point2d(x, y), _z(z) {}
float z() { return _z; }
void z(float newZ) { _z = newZ; }
void operator+=(const Point3d& rhs) {
Point2d::operator+=(rhs);
_z += rhs.z();
}
// ...more members
private:
float _z;
};

Point2dPoint3d 继承关系的实物布局,其间并没有声明虚接口。

image

  • 如果把一个类分解为两层或更多层,有可能会为了“表现类体系之抽象化”而膨胀所需的空间。**C++ 语言保证“出现在派生类中的基类子对象有其完整原样性”**。例如下面的实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Concretel {
public:
// ...
private:
int val;
char bit1;
};

class Concrete2 : public Concrete1 {
public:
// ...
private:
char bit2;
};

class Concrete3 : public Concrete2 {
public:
// ...
private:
char bit3;
};

image

image

4.2 加上多态

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
30
31
32
33
34
class Point2d {
public:
Point2d(float x = 0.0, float y = 0.0)
: _x(x), _y(y) {}

// x和y的存取函数与前一版相同。

// 加上z的保留空间(目前什么也没做)
virtual float z() { return 0.0; }
virtual void z(float) {}
virtual void operator+=(const Point2d& rhs) {
_x += rhs.x();
_y += rhs.y();
}
// ...more members
private:
float _x, _y;
};

class Point3d : public Point2d {
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
: Point2d(x, y), _z(z) {}
float z() { return _z; }
void z(float newZ) { _z = newZ; }
void operator+=(const Point2d& rhs) {
// 注意上行是Point2d&而非Point3d&
Point2d::operator+=(rhs);
_z += rhs.z();
}
// ...more members
private:
float _z;
};

加入多态后,势必对我们的 Point2d 类带来空间和存取时间上的额外负担:

  • 导入一个和 Point2d 有关的虚表,用来存放它所声明的每一个虚函数的地址。这个表的元素一般而言是被声明的虚函数的个数,再加上一个或两个 slots (用于支持runtime type identification)。
  • 在每一个类对象中导入一个vptr,提供执行期的链接,使每一个对象能够找到相应的虚表。
  • 加强构造函数,使它能够为vptr设定初值,让它指向类所对应的虚表。这可能意味着在派生类和每一个基类的构造函数中,重新设定vptr的值。
  • 加强析构函数,使它能够抹消“指向类相关虚表”的vptr

image

4.3 多重继承

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
class Point2d {
public:
// ...
protected:
float _x, _y;
};

class Point3d : public Point2d {
public:
// ...
protected:
float _z;
};

class Vertex {
public:
// ...
protected:
Vertex *next;
};

class Vertex3d : public Point3d, public Vertex {
public:
// ...
protected:
float mumble;
}

image

对一个多重派生对象,将其地址指定给“最左端基类的指针”,情况和单一继承时相同,因为两者都指向相同的起始地址。需付出的成本只有地址的指定操作而已。至于第二个或候机的基类地址指定操作,则需要讲地址修改过去:加上/减去介于中间的基类子对象大小。

1
2
3
4
5
6
7
8
9
10
11
12
Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;

// 1.指定操作,需内部转化
pv = &v3d;
// 内部转化
pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));
// 2.指定操作,简单拷贝其地址就好
p2d = &v3d;
p3d = &v3d;

image

4.4 虚拟继承

类如果内含一个或多个虚基类子对象,将被分割成两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何衍化,总拥有固定的 offset (从对象的开头算起),所以这一部分数据可以直接存取。至于共享区域,所表现的就是虚基类子对象。这一部分的数据,其位置会因为每次的派生操作而有变化,所以他们只可以被间接存取。

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
class Point2d {
public:
...
protected:
float _x, _y;
};

class Vertex : public virtual Point2d {
public:
...
protected:
Vertex *next;
};

class Point3d : public virtual Point2d {
public:
...
protected:
float _z;
};

class Vertex3d : public Vertex, public Point3d
{
public:
...
protected:
float mumble;
};

一般的布局策略是先安排好派生类的不变部分,然后再建立其共享部分。然而,这中间存在一个问题:如何能够存取类的共享部分呢?

有两种策略可以解决上述问题:

  • 编译器会在每一个派生类对象中安插一些指针,每一个指针指向一个虚基类。要存取继承得来的虚基类成员,可以通过相关指针间接完成。即,以指针指向基类的实现模型

上述实现模型有两个主要的缺点:

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

对于第二个问题,MetaWare和其他编译器到今天仍采用cfron t的原始模型来解决,即,他们经由拷贝操作取得所有的nested 虚基类指针,放到派生类对象之中。这就解决了“固定存取的问题”,虽然付出了一些空间上的代价。下图便说明了这种以指针指向基类的实现模型

对于第一个问题,一般有两种解决方法。

  • 微软编译器引入所谓的虚基类表。每一个类对象如果有一个或者多个虚基类,就会有编译器安插一个指针,指向虚基类表。而真正的虚基类指针或被放到该表中。
  • 在虚函数表中放置虚基类的offset(而不是地址)。

image

image

 评论