深入探索C++对象模型(三)

转自https://www.cnblogs.com/lengender-12/p/6953351.html
Data 语义学
一个class的data members,一般而言,可以表现这个class在程序执行时的某种状态。Nonstatic data members放置的是“个别的class object”感兴趣的数据,static data members则放置的是“整个class”感兴趣的数据。

C++对象模型尽量以空间优化和存取速度优化的考虑来表现nonstatic data members,并且保持和C语言struct数据配置的兼容性。它们把数据直接存放在每一个class object之中。对于继承而来的nonstatic data members(不管是virtual还是nonvirtual base class)也是如此。不过没有强制定义其间的排列顺序。
至于static data members,则被放置在程序的一个global data segment中,不会影响个别class object的大小。在程序之中,不管该class被产生出多少个objects(经由直接产生或间接派生),static data members永远只存在一份实例(甚至即使该class没有任何object实例,其static data members也已存在)。但是一个template class的static data members的行为稍有不同。

Data Member的绑定(The Binding of a Data Member)
C++ Standard以“member scope resolution rules”来精炼这个“rewriting rule”,其效果是,如果一个inline函数在class声明之后立即被定义的话,那么就还是对齐评估求值(evaluae)。也就是说,当一个人写下这样的代码:

extern int x;

class Point3d{
public:
//对于函数本身的分析将延迟直至class声明的右大括号出现才开始
float X() const { return x; }
//…
private:
float x;
};
//事实上,分析在这里运行
时,对于member functions本身的分析,会直到整个class的声明都出现了才开始。
因此,在一个inline member function躯体之内的一个data member绑定操作,会在整个class声明之后才发生。

Data Member的布局(Data Member Layout)
已知下面一组data members:

class Point3d{
public:
//…
private:
float x;
static List<Point3d*> *freeList;
float y;
static const int chunkSize = 250;
float z;
};
nonstatic data members在class object中的排列顺序和其被声明的顺序一样,任何中间介入的static data members,如freeList和chunkSize都不会被放进对象布局之中。在上述例子中,每一个Point3d对象是由三个float组成,次序是x,y,z。static data members存放在程序的data segment中,和个别的class objects无关。

C++ Standard要求,在同一个access section(也就是private、public、protected等区段)中,members的排列只需符合“较晚出现的members在class object中有较高的地址”这一条件即可。也就是说,各个members并不一定连续排列。什么东西可能会介于被声明的members之间呢?members的边界调整(alignment)可能就需要填补一些bytes。对于C和C++ 而言这的确是真的,对目前的C++编译器实现情况而言,这也是真的。

编译器还可能会合成一些内部使用的data members,以支持整个对象模型,vptr就是这样的东西,当前所有的编译器都把它安插在每一个“内含virtual function之class”的object内。

Data Member的存取
Static Data Members
static data members,按其字面意义,被编译器提出于class之外,并被视为一个global变量(但只在class生命范围内可见)。每一个member的存取许可(译注:private、protected或public),以及与class的关联,并不会招致任何空间上或执行时间上的额外负担——不论是在个别的class objects还是在static data member本身

每一个static data member只有一个实例,存放在程序的data segment之中。每次程序参阅(取用)static member时,就会被内部转化为对该唯一extern实例的直接参考操作。例如:

Point3d origin, *pt;

//origin.chunkSize = 250
Point3d::chunkSize = 250;

//pt->chunkSize = 250
Point3d::chunkSize = 250;
从指令执行的观点来看,这是C++语言中“通过一个指针和通过一个对象来存取member,结论完成相同”的唯一一种情况,这是因为“经由member selection operators(也就是’.'运算符)对一个static data member进行存取操作”只是语法上的一种便宜行事而已,member其实并不在class object之中,因此存取static membeers并不需要通过class object。

若取一个static data member的地址,会得到一个指向其数据类型的指针,而不是一个指向其class member的指针,因为static member并不内含在一个class object之中。例如:

&Point3d::chunkSize;
会获得类型如下的内存地址:

const int*
如果有两个classes,每一个都声明了一个static member freeList,那么当它们都被放在程序的data segment时,就会导致名称冲突。编译器的解决方法是暗中对每一个static data member编码(对于这种手法有个很美的名称:name-mangling),以获得一个独一无的程序识别代码。有多少个编译器,就有多少中name-manglint做法。任何name-mangling做法都有两个要点:

一种算法,推导出独一无二的名称
万一编译系统(或环境工具)必须和使用者交谈,那些独一无二的名称可以轻易被推导回原来的名称
Nonstatic Data Members
Nonstatic data members直接存放在每一个class object之中。除非经由显式的(explicit)或隐式的(implicit)class object,否则没有办法直接存取它们。只要程序员在一个member function中直接处理一个nonstatic data member,所谓“implicit class object”就会发生。例如:

Point3d
Point3d::translate(const Point3d &pt){
x += pt.x;
y += pt.y;
z += pt.z;
}
表面上所看到的对于x,y,z的直接存取,事实上是经由一个"implicit class object"(由this指针表达)完成,实际上这个函数的参数是:

//member function的内部转化
Point3d
Point3d::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._y将等于:

&origin + (&Point3d::_y - 1);
请注意其中的 -1 操作。指向data member的指针,其offset值总是被加上 1,这样可以使编译系统区分出“一个指向data member的指针,用以指出class的第一个member”和“一个指向data member的指针,没有指出任何member”两种情况。

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

“继承”与Data Member
在C++ 继承模型中,一个derived class object所表现出来的东西,是其自己的members加上其base class members的总和。至于derived class members和base class members的排列顺序,则未在C++ standard中强制指定:理论上编译器可以自由安排之。在大部分编译器上头,base class members总是先出现,但属于virtual base class的除外。(一般而言,任何一条通则一旦碰上virtual base class就没有辙了,这里亦不例外)

只要继承不要多态(Inheritance without Polymorphism)
一般而言,具体继承(concrete inheritance,译注:相对于虚拟继承virtual inheritance)并不会增加空间或存取时间上的额外负担。

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

protected:
float _x, _y;
};

//inheritance from concrete class
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();
}

protected:
float _z;
};
这样设计的好处就是可以把管理x和y坐标的程序代码局部化。此外这个设计明显表现出两个抽象类之间的紧密关系。当这两个classes独立的时候,Point2d object和Point3d object的声明和使用都不会有所改变,所以这两个抽象类的使用者不需要知道objects是否为独立的classes类型,或是彼此之间有继承的关系。

把两个原本不想干的classes凑出一对“type/subtype”,并带有继承关系,会有什么易犯的错误呢?经验不足的人可能会重复设计一些相同操作的函数。第二个易犯的错误是,把一个class分解为两层或更多层,有可能会为了“表现class体系之抽象化”而膨胀所需空间。C++ 语言保证“出现在derived class中的base class subobject有其完整原样性”。举例如下:

class Concrete{
public:
//…
private:
int val;
char c1;
char c2;
char c3;
};
在一部32位机器中,每一个Concrete class object的大小都是8bytes,细分如下:

val占用4bytes
cl,c2,c3各占用1bytes
alignment(调整到word边界)需要1bytes
现在假设,经过某些分析之后,我们决定了一个更逻辑的表达方式,把Concrete分裂成三层结构:

class Concrete1{
public:
//…
private:
int val;
char bit1;
};

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

class Concrete3 : public Concrete2{
public:
//…
private:
char bit3;
};
从设计的观点来看,这个结构可能更合理。但是从效率的观点来看,我们可能会受困于一个事实:现在Concrete3 object的大小是16bytes,比原先的设计多了一倍。

怎么回事?还记得“base class subobject在derived class中的原样性”吗?

Concrete1内含两个members: val和bit1, 加起来是5bytes。而一个Concrete1 object实际用掉8bytes,包括填补用的3bytes,以使object能够符合一个机器的word边界。Concrete2加了唯一一个nonstatic data member bit2,数据类型为char,轻率的程序员会认为它会和Concrete1捆绑在一起,占用原本用来填补的1bytes。然而Concrete2的bit2实际上却是被放在填补的3bytes之后,于是大小变成12bytes,而不是8bytes。其中有6bytes浪费在填补空间上。相同的道理是Concrete3 object的大小是16bytes,其中9bytes用于填补空间。

声明如下:

Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;
其中pc1_1和pc1_2两者都可以指向前述三种class objects。下面这个指定操作:

*pc1_2 = *pc1_1;
应该执行一个默认“memberwise”复制操作(复制一个个的members),对象是被指的object的Concrete1那一部分。如果pc1_1实际指向一个Concrete2 object或Concrete3 object,则上述操作应该将复制内容指定给其Concrete1 subobject。

然而,如果C++ 语言把derived class members(也就是Concrete2::bit2 或Concrete3::bit3)和Concrete subobject捆绑在一起,去除填补空间,上面那些语意就无法保留了,如下:

pc1_1 = pc2; //令pc1_1指向Concrete2对象

//derived class subobject被覆盖掉,于是bit2 member现有一个并非预期的数值
*pc1_2 = *pc1_1;

加上多态(Adding Polymorphism)
class Point2d{
public:
Point2d(float x = 0.0, float y = 0.0)
: _x(x), _y(y) { };

//x和y的存取函数与前一版相同
//由于对不同维度的点,这些函数的操作固定不变,所以不必设计为virtual

//加上z的保留空间(当前什么也不做)
virtual float z() { return 0.0; }
virtual void z(float){ };

//设定以下的运算符为virtual
virtual void operator+=(const Point2d& rhs){
    _x += rhs.x();
    _y += rhs.y();
}

protected:
float _x, _y;
};
这样的设计,给Point2d class带来空间和存储时间的额外负担:

导入一个和Point2d有关的virtual table,用来存放它所声明的每一个virtual functions的地址。这个table的元素数目一般而言是被声明的virtual functions的数目,再加上一个或两个slots(用以支持runtime type identification)
每一个class object中导入一个vptr,提供执行期的链接,使每一个object能够找到相应的virtual table.
加强constructor,使它能够为vptr设定初值,让它指向class所对应的virtual table.
加强destructor,使它能够抹消“指向class之相关virtual table”的vptr
多重继承(Multiple Inheritance)
单一继承提供了一种“自然多态(natural polymorphism)”形式,是关于classed体系中的base type和derived type之间的转换。

多重继承既不像单一继承,也不容易模塑出其模型。多重继承的复杂度在于derived class和其上一个base class乃至于上上一个base class…之间的“非自然”关系。

考虑下面这个多重继承所获得的class Vertex3d

class Point2d{
public:
//…拥有virtual接口,所以,Point2d对象中会有vptr
protected:
float _x, _y;
};

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

class Vertex{
public:
//… 拥有virtual接口,所以Vertex对象之中会有vptr
protected:
Vertex *next;
};

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

多重继承的问题主要发生于derived class objects和其第二或后继的base class object之间的转换。不论是直接转换如下:

extern void mumble(const Vertex&);
Vertex3d v;

//将一个Vertex3d转换为一个Vertex,这是“不自然的”
mumble(v);
或是经由其所支持的virtual function机制做转换。

对一个多重派生对象,将其地址指定给“最左端(也就是第一个)base class的指针”,情况将和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的指定操作而已。至于第二个或后继的base class的地址指定操作,则需要将地址修改过:加上(或减去,如果downcast的话)介于中间的base class subobjects大小。例如:

Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;
经过下面这个指定操作:

pv = &v3d;
需要这样的内部转化:

pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));
而下面的指定操作:

p2d = &v3d;
p3d = &v3d;
都只需要简单地拷贝其地址就行了。如果有两个指针如下:

Vertex3d *pv3d;
Vertex *pv;
那么下面的指定操作:

pv = pv3d;
不能够只是简单地被转换为:

pv = (Vertex*)((char*)pv3d) + sizeof(Point3d);
因为如果pv3d为0,pv将获得sizeof(Point3d)的值,这是错误的。所以,对于指针,内部转换操作需要一个条件测试:

pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof(Point3d) : 0 ;
至于reference,则不需要针对可能的0值做防卫,因为reference不可能参考到“无物”

如果要存取第二个(或后继)base class中的一个data member会是怎样的情况?需要付出额外的成本吗? 不,members的位置在编译期就固定了,因此,存取members只是一个简单的offset运算,就像单一继承一样简单——不管是经由一个指针,一个reference或是一个object来存取。

虚拟继承(Virtual Inheritance)
多重继承的一个语意上的副作用就是,它必须支持某种形式的“shared subobject继承”。一个典型的例子就是最早的iostream library:

不论是istream或ostream都内含一个ios subobject,然而在iostream的对象布局中,我们只需要一份ios subobject就好。语言层面的解决办法是导入所谓的虚拟继承。

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

以下说明三种主流策略,下面是Vertex3d虚拟继承的层次结构:

一般的布局策略是先安排好derived class的不变部分,然后再建立其共享部分。

如何能够存取class的共享部分呢?

cfont编译器会在每一个derived class object中安插一些指针,每个指针指向一个virtual base class。要存取继承得来的virtual base class members,可以使用相关指针间接完成。举例如下:

void Point3d::operator+=(const Point3d &rhs){
_x += rhs._x;
_y += rhs._y;
_z += rhs._z;
}
在cfront策略下,这个运算会被内部转换为:

_vbcPoint2d->_x += rhs._vbcPoint2d->_x; // vbc意指virtual base class
_vbcPoint2d->_y += rhs._vbcPoint2d->_y;
_z += rhs._z;
而一个derived class和一个base class的实例之间的转换,如

Point2d *p2d = pv3d;
在cfront实现模型下,会变成:

Point2d *p2d = pv3d ? pv3d->_vbcPoint2d : 0;
这样的实现模型有两个主要的缺点:

每一个对象必须针对每一个virtual base class背负一个额外的指针。然而理想上我们希望class object有固定的负担,不因为其virtual base classes的数目而有所变化。
由于虚拟继承串链的加长,导致间接存取层次增加。比如,有三层虚拟衍化,就需要三次间接存取(经由三个virtual base class指针),然而理想上我们却希望有固定的存取时间,不因为虚拟衍化的深度而改变。
MetaWare和其他编译器使用cfront的原始模型来解决第二个问题,它们经由拷贝操作去的所有的nested virtual base class指针,放到derived class object中,这就解决了"固定存储时间"的问题。虽然付出了一些空间上的代价。下图说明了这种“以指针指向base class”的实现模型。

对于第一个问题,一般有两个解决办法。Microsoft编译器引入所谓的virtual base class table。每一个class object如果有一个或多个virtual base classes,就会由编译器安插一个指针,指向virtual base class table。至于真正的virtual base class指针,当然是被放在表格中。

第二个解决办法是在virtual function table中放置virtual base class的offset。下图显示了base class offset实现模型。

在新近的Sun编译器中,virtual functon table可经由正值或负值来索引,如果是正值,很显然就是索引到virtual function;若为负值,则是索引到virtual base class offsets。在这样的策略下,Point3d的operator+=运算符必须被转换为以下形式:

(this + _vptr_Point3d[-1])->_x += (&rhs + rhs._vptr_Point3d[-1])->_x;
(this + _vptr_Point3d[-1])->_y += (&rhs + rhs._vptr_Point3d[-1])->_y;
_z += rhs._z;
上述的每一种方法都是一种实现模型,而不是一种标准,每一种模型都是用来解决“存取shared subobject内的数据(其位置会因为每次派生操作而有变化)”所引发的问题。由于对virtual base class的支持带来额外的负担以及高度的复杂性,每一种模型多少有点不同,而且还会随着时间而进化。

一般而言,virtual base class最有效的一种运用形式就是:一个抽象的virtual base class,没有任何data members

指向Data members的指针(Pointer to Data Members)
考虑下面的Point3d声明,其中有一个virtual function, 一个static data member,以及三个坐标值:

class Point3d{
public:
virtual ~Point3d();
//…
protected:
static Point3d origin;
float x, y, z;
};
取某个坐标成员的地址,代表什么意思? 如下:

&Point3d:?;
上述操作将得到z坐标的class object中的偏移量(offset),最低限度其值将是x和y的大小总和,以为C++ 语言要求同一个access level中的members的排列次序应该和其声明次序相同。

然而vptr的位置就没有限制,实际上vptr不是放在对象的头部,就是放在对象的尾部。每一个float是4bytes,所以我们应该期望刚才获得的值要不是8,就是12(在32位机器上一个vptr是4bytes)

然而,这样的期望还少1bytes。

如果vptr放在对象的尾端,则三个坐标值在对象布局中的offset分别是0,4,8。如果vptr放在对象的起头,则三个坐标值在对象布局中的offset分别是4,8,12。然而你若去取data members的地址,传回的值总是多1, 也就是1,5,9或9,5,13等等。

如何区分一个“没有指向任何data member”的指针,和一个指向“第一个data member”的指针?考虑这样的例子:

float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
//Point3d::*的意思是:“指向Point3d data member”的指针类型

//如何区分
if(p1 == p2){
cout << “p1 & p2 contain the same value --” ;
cout << " they must address the same member!" << endl;
}
为了区分p1和p2, 每一个真正的member offset值都被加上1。因此,不论编译器或使用者都必须记住,在真正使用该值以指出一个member之前,请先减掉1

认识“指向data members的指针”之后,我们发现,要解释:

&Point3d:?;
&origin.z;
之间的差异,就非常明确了。鉴于“取一个nonstatic data member的地址,将会得到它在class中的offset”,取一个“绑定于真正class object身上的data member”的地址,将会得到该member在内存中的真正地址。把

&origin.z
所得结果减z的偏移量(相对于origin的起始地址),并加1,就会得到origin的起始地址。上一行的返回值类型应该是float*,而不是float Point3d:?。

由于上述操作所参考的是一个特定实例,所以取一个static data member的地址,意义也相同。

在多重继承中,若要将第二个(或后继)base class的指针,和一个“与derived class object绑定”的member结合起来,那么将会因为“需要加入offset值”而变得相当复杂。例如:

struct Base1{ int val1; }
struct Base2{ int val2; }
struct Derived : Base1, Base2{ … }

void fun1(int Derived::*dmp, Derived *pd){
//期望第一个参数得到一个“指向derived class之member”的指针
//如果传进来的却是一个"指向base class之member"的指针,会怎样
pd->*dmp;
}

void fun2(Derived *pd){
//bmp将成为1
int Base2::*bmp = &Base2::val2;

//bmp = 1
//但是在Derived中,val2 = 5
fun1(bmp, pd);

}
当bmp被作为fun1()的第一个参数时,它的值就必须因介入的Base1 class的大小而调整,否则fun1()中这样的操作:

pd->*dmp;
将存取Base1::val1,而非程序员所以为的Base2::val2。要解决这个问题,必须

//经由编译器内部转换
fun1(bmp + sizeof(Base1), pd);
然而,一般而言,我们不能保证bmp不是0,因此必须特别留意之:

//内部转换
//防范bmp == 0
fun1(bmp ? bmp + sizeof(Base1) : 0, pd);

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值