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

断了今生、忘了曾经 提交于 2020-04-01 07:50:34

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

  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);
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!