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

Data 语义学

一个class的data members,通常而言,能够表现这个class在程序执行时的某种状态。Nonstatic data members放置的是“个别的class object”感兴趣的数据,static data members则放置的是“整个class”感兴趣的数据。ios

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声明以后才发生。ide

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实例的直接参考操做。例如:this

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作法都有两个要点:

  1. 一种算法,推导出独一无二的名称
  2. 万一编译系统(或环境工具)必须和使用者交谈,那些独一无二的名称能够轻易被推导回原来的名称

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,细分以下:

  1. val占用4bytes
  2. cl,c2,c3各占用1bytes
  3. 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_1pc1_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;

这样的实现模型有两个主要的缺点:

  1. 每个对象必须针对每个virtual base class背负一个额外的指针。然而理想上咱们但愿class object有固定的负担,不由于其virtual base classes的数目而有所变化。
  2. 因为虚拟继承串链的加长,致使间接存取层次增长。好比,有三层虚拟衍化,就须要三次间接存取(经由三个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;

上述操做将获得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::z;  
&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);
相关文章
相关标签/搜索