您的位置:首页 > 编程语言 > C语言/C++

C++对象模型——"继承"与Data Member(第三章)

2015-08-05 21:35 141 查看

3.4 "继承"与Data Member

在C++继承模型中,一个derived class object所表现出来的东西,是其自己的members加上其base class members的总和.至于derived class members和base class members的排列次序并未在C++ Standard中强制指定:理论上编译器可以自由安排.在大部分编译器上,base class members总是先出现,但属于 virtual base class的除外.

了解这种继承模型之后,那么如果为2D或者3D坐标点提供两个抽象模型如下:

// support abstract data types
class Point2d {
public:
 // constructor
 // operations
 // access functions
private:
 float x, y;
};
class Point3d {
public:
 // constructor
 // operations
 // access functions
private:
 float x, y, z;
};
这和"提供两层或三层继承结构,每一层(代表一个维度)是一个class,派生自较低维层次"有什么不同?

下面各小节的讨论将涵盖"单一继承且不含virtual functions","单一继承并含virtual functions","多重继承","虚拟继承"等四种情况.

只要继承不要多态

想象一下,程序员或许希望,不论是2D或3D坐标点,都能够共享同一个实体,但又能够继续使用"与类型性质相关(所谓type-specific)"的实体.有一个设计策略,就是从Point2d派生出一个Point3d,于是Point3d将继承x和y坐标的一切(包括数据实体和操作方法),带来的影响则是可以共享"数据本身"以及"数据的处理方法",并将其局部化.一般而言,具体继承(concrete 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();
 }
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坐标的程序代码局部化,此外这个设计明显表现出两个抽象类之间的紧密关系.当这两个 class 独立的时候,Point2d object和Point3d object的声明和使用都不会有所改变.所以这两个抽象类的使用者不需要知道objects是否为独立的 class 类型,或是彼此之间有继承的关系.

把原本两个独立不相干的 class 凑成一对"type/subtype",并带有继承关系,会有什么易犯的错误呢?经验不足的人可能会重复设计一些相同操作的函数.以例子中的constructor和operator+=为例,它们并没有被做成 inline 函数.Point3d object的初始化操作或加法操作,将需要部分的Point2d object和部分的Point3d object作为成本.第二个易犯的错误是,把一个 class 分解为两层或更多层,有可能为了"表现class体系的抽象化"而膨胀所需空间.C++语言保证"出现在derived
class中的base class subobject有其完整原样性".正是重点所在,如下:

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

1. val占用4 bytes

2. clc,c2和c3各占用1 bytes

3. alignment(调整到 void 边界)需要1 bytes


现在假设,经过某些分析后,决定了一个更逻辑的表达方式,把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的大小是16 bytes,比原先的设计多一倍.

怎么回事,之前提到过"base class subobject在derived class中的原样性":

Concrete1内含两个members:val和bit1,加起来是5 bytes.而一个Concrete1 object实际用掉8 bytes,包括填充用的3 bytes,以使object符合一部机器的word边界.不论是C或C++都是这样.一般而言,边界调整(alignment)是由处理器(processor)来决定.

Concrete2加了唯一一个nonstatic data member bit2,数据类型为char.轻率的程序员以为它会和Concrete1捆绑在一起,占用原本用来填充空间的1 bytes;于是Concreate2 object的大小为8 bytes,其中2 bytes用于填补空间.

然而Concrete2的bit2实际上却是被放在填补空间所用的3 bytes之后,于是其大小变成12 bytes,不是8 bytes.其中的6 bytes浪费在填补空间上.同样的道理使得Concrete3 object的大小是16 bytes,其中9 bytes用于填补空间.

声明以下指针:

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

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

然而,如果C++语言把derived class member(也就是Concrete2::bit2或Concrete3:;bit3)和Concrete1 subobject捆绑在一起,去除填补空间.上述那些语意就无法保留,那么下面的指定操作:

pc1_1 = pc;   // 令pc1_1指向Concrete2对象
*pc1_2 = *pc1_1; // derived class subobject被覆盖掉
就会将"被捆绑在一起,继承而得的"members内容覆盖掉,程序员必须花费极大的心力才能找出这个bug.

加上多态 (Adding Polymorphism)

如果要处理一个坐标点,不打算在乎它是一个Point2d或Point3d实例,那么需要在继承关系中提供一个 virtual function接口.看看如果这样做,情况会有什么改变:

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;
};
只有企图以多态的方式(polynmorphically)处理2d或3d坐标点时,在设计中导入一个 virtual 接口才显得合理,也就是说,写下这样的码:

void foo(Point2d &p1, Point2d &p2) {
 // ...
 p1 += p2;
}
其中p1和p2可能是2d也可能是3d坐标点,这并不先前任何设计所能支持的,这样的弹性,正是面向对象程序设计的中心.支持这样的弹性,势必给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.
这可能意味着在derived class 和每一个base class 的constructor中,重新设定vptr的值,其情况视编译器的优化的积极性而定.

加强destructor,使它能够抹消"指向class相关virtual table"的vptr.要知道,vptr很可能已经在derived class destructor中被设定为derived class 的 virtual table地址.记住,destructor的调用次序是反向的:从derived class 到base class.一个积极的优化编译器可以压抑那些大量的指定操作.

这些额外的负担带来的冲击程序视"被处理的Point2d objects的数目和生命期"而定,也视"对这些objects做多态程序设计所得到的利益"而定.如果一个而应用程序知道它所能使用的point object只限于二维坐标点或三维坐标点,那么这种设计所带来的额外负担可能变得无法接受.

以下是新的Point3d声明:

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::operator(rhs);
		_z += rhs.z();
	}
protected:
	float _z;
};
上述新的Point2d和Point3d声明,最大一个好处是,可以把operator+=运用在一个Point3d对象和一个Point2d对象上:

Point2d p2d(2.1, 2.2);
Point3d p3d(3.1, 3.2, 3.3);
p3d += p2d;
得到的p3d新值将是(5.2, 5.4, 3.3);

虽然 class 的声明语法没有改变,但每一件事情都不一样了:两个z() member functions以及 operator+=()运算符都成了虚拟函数:每一个Point3d class object内含一个额外的vptr member:多了一个Point3d virtual table;此外,每一个 virtual member function的调用也比以前复杂.

目前在C++编译器那个领域里有一个主要的讨论题目:把vptr放置在 class object的哪里会最好?在cfront编译器中,它被放在 class object的尾端,用以支持下面的继承类型,如下所示:

struct no_virts {
	int d1, d2;
};
class has_virts : public no_virts {
public:
	virtual void foo();
private:
	int d3;
};
no_virts *p = new has_virts;
把vptr放在 class object的尾端,可以保留base class C struct 的对象布局,因而允许在C程序代码中也能使用,这种做法最初经常采用.

到来C++2.0,开始支持虚拟继承以及抽象基类,并且由于面向对象典范(OO paradigm)的兴起,某些编译器开始把vptr放到 class object的起始位置.

把vptr放在 class object的前端,对于"在多重继承下,通过指向class members的指针调用virtual function",会带来一些帮助.否则,不仅"从class object起始位置开始量起"的offset必须在执行期备妥,甚至与 class vptr之间的offset也必须备妥.当然,vptr放在前端,代价就是丧失了C语言兼容性,这种丧失没有意义,因为不会有人这样使用.

多重继承 (Multiple Inheritance)

单一继承提供了一种"自然多态(natural polymorphism)"形式,是关于 class 体系中的base type和derived type之间的转换.base class 和derived class 的objects都是从相同的地址开始,其间差异只在于derived object比较大,用以容纳它自己的nonstatic data members,下面这样的指向操作:

Point3d p3d;
Point2d *p = &p3d;
把一个derived class object指定给base class(不管继承深度有多深)的指针或reference.该操作并不需要编译器去调停或修改稿地址,它很自然地可以发生,而且提供了最佳执行效率.

把vptr放在 class object的起始位置,如果base class 没有 virtual function而derived class 有,那么单一继承的自然多态(natural polymorphism)就会被打破.在这种情况下,把一个derived objects转换为其base类型,就需要编译器的介入,用以调整地址(因vptr插入的原因),在既是多重继承又是虚拟继承的情况下,编译器的介入更有必要.

多重继承既不像单一继承,也不容易塑出其模型.多重继承的复杂度在于derived class 和其上一个base class 乃至上上一个base class...之间的"非自然关系".例如,考虑下面这个多重继承所获得的 class Vertex3d:

class Point2d {
public:
// 拥有virtual 接口,所以Point2d对象中会有vptr
protected:
float _x, _y;
};
class Point3d : public 2d {
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 objects之间的转换;不论是直接转换如下:

extern void mumble(const Vertex &);
Vertex3d v;
// 将一个Vertex3d转换为一个Vertex.这是"不自然的"
mumble(v);
或是经由其所支持的 virtual function机制做转换.

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

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不可能参考到"无物"(no object).

C++ Standare并未要求Vertex3d中的base class Point3d和Vertex有特定的排列顺序.原始的cfront编译器是根据声明次序排列它们.因此cfront编译器***出来的Vertex3d对象,将可被视为是一个Point3d subobject(其中又有一个Point2d subobject)加上一个Vertex subobject,最后再加上Vertex3d自己的部分.目前各编译器仍然是以次方式完成多重继承base
class 的布局.

某些编译器(例如MetaWare)设计有一种优化技术,只有第二个(或后继)base class 声明了一个 virtual function,而第一个base class 没有,就把多个base class 的次序进行调换.这样可以在derived class object中少产生一个vptr.

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

虚拟继承 (Virtual Inheritance)

多重继承的一个语意上的副作用就是,它必须支持某种形式的"shared subobject继承".一个典型的例子是最早的iostream library:

// pre-standard iostream implementation
class ios { ... }
class istream : public ios { ... };
class ostream : public ios { ... };
class iostream : public istream, public ostream { ... };
不论是istream或ostream都内含一个ios subobject,然而在iostream的对象布局中,只需要单一一份ios subobject就好.语言层面的解决办法是导入所谓的虚拟继承:

class ios { ... };
class istream : public virtual ios { ... };
class ostream : public virtual ios { ... };
class iostream : public istream, public ostream { ... };
一如其语意所呈现的复杂度,要在编译器中支持虚拟继承,实在是非常困难.在上述iostream例子中,实现技术的挑战在于,要找到一个足够有效的方法,将istream和ostream各自维护的一个ios subobject,折叠成为一个由iostream维护的单一ios subobject,并且还可以保存base class 和derived class 的指针(以及reference)之间的多态指定操作(polumorphism
assignments).

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

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;
};
一般的布局策略是先安排好derived class 的不变部分,然后再建立其共享部分.

然而,这中间存在着一个问题:如果能够存取 class 的共享部分呢?

cfron编译器会在每一个derived class object中插入一些指针,每个指针指向一个 virtual base class.要存取继承而来的 virtual base class members,可以使用相关指针间接完成.举个例子,如果有以下的Point3d运算符:

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

// 虚拟C++代码
_vbcPoint2d->_x += rhs._vbcPoint2d->_x;
_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 class的数目而所有变化.

2.
由于虚拟继承串链的加长,导致间接存取层次的增加,这里的意思是,如果有三层虚拟衍化,就需要三次间接存取(经由三个 virtual base class 指针).然而却希望有固定的存取时间,不因为虚拟衍化的深度而改变.

MetaWare和其他编译器到今天仍然使用cfront的原始实现模型来解决第二个问题,它们经由拷贝操作取得所有的nested virtual base class 指针,放到derived class object中,这就解决了"固定存取时间"的问题.虽然付出了一些空间上的代价.

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

第二个解决办法,同时也是Bjarne比较喜欢的方法,是在 virtual function table中放置 virtual base class 的offset(而不是地址).在新近的Sun编译器中,virtual
function table可经由正值或负值来索引.如果是正值,很显然就是索引到 virtual functions;如果是负值,则是索引到 virtual base class offsets.
在这样的策略下,Point3d的operator+=运算符必须被转换为以下形式:

// 虚拟C++代码
(this + __vptr__Point3d[-1])->_x += (&rhs + rhs.__vptr__Point3d[-1])->_x;
(this + __vptr__Point3d[-1])->_y += (&rhs + rhs.__vptr__Point3d[-1])->_y;
虽然在此策略下,对于继承而来的members做存取操作,成本会比较昂贵,不过该成本已经被分散至"对member的使用"上,属于局部性成本.Derived实体和base class 实体之间的转换操作,例如:

Point2d *p2d = pv3d;
在上述实现模型下将变成:

// 虚拟C++代码
Point2d *p2d = pv3d ? pv3d + pv3d->__vptr__Point3d[-1] : 0;
上述每一种方法都是一种实现模型,而不是一种标准.每一种模型都是用来解决"存取shared subobject内的数据(其位置因每次派生操作而有变化)"所引发的问题.由于对 virtual base class 的支持带来额外的负担以及高度的复杂性.每一种实现模型多少有点不同,而且随着时间而进化.

经由一个非多态的 class object来存取一个继承而来的 virtual base class 的member,像这样:

Point3d origin;
origin._x;
可以被优化为一个直接存取操作,就好像一个经由对象调用的 virtual function调用操作,可以在编译时期被决议(resolved)完成一样.在这次存取以及下一次存取之间,对象的类型不可以改变,所以"virtual base class subobjects的位置会变化"的问题在这种情况下就不再存在了.

一般而言,virtual base class 最有效的一种运用形式就是:一个抽象的 virtual base class,没有任何data members.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: