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 坐标点提供两个抽象数据类型如下:

//supporting abstract data types
class Point2d
{
public:
    //constructors
    //operations
    //access functinos
private:
    float _x, _y;
};

class Point3d
{
public:
    //constructors
    //opsrations
    //access functions
private:
    float _x, _y, _z;
};

和C struct 布局完全一样:
image

以下探讨4种情况:

1. 单一继承且不含 virtual functions
2. 单一继承含 virtual functions
3. 多重继承
4. 虚拟继承

单一继承且不含 virtual functions

从 Point2d 派生出一个 Point3d, 于是 Point3d 将继承 _x 和 _y 坐标的一切。影响则是可以共享数据 和 对数据的处理方法。
具体继承(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 x){ _x = x; }
    void Y(float y){ _y = y; }

    void operator+=(const Point2d &rhs)
    {
        _x += rhs.X();
        _y += rhs.Y();
    }
    //more operations
protected:
    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;}
    float Z(float z){_z = z;}

    void operator+=(const Point3d &rhs)
    {
        Point2d::operator+=(rhs);
        _z += rhs.z();
    }
    //more operations
protected:
    float _z;
};

image

把一个 class 分解为多层, 有可能为了表现 class 体系的抽象化,却导致空间膨胀(内存对齐)。

class Concrete
{
public:
private:
	int val;
	char c1;
	char c2;
	char c3;
}
// concrete object
	-----------------
	| 4	int val |
	| 1	char c1 |
	| 1	char c2 |
	| 1	char c3 |
	| 1	padding |
	-----------------
// 共 8 个字节大小
class Concrete1
{
public:
private:
	int val;
	char c1;
};
class Concrete2:public Concrete1
{
public:
private:
	char c2;
};
class Concrete3:public Concrete1
{
public:
private:
	char c3;
};

image

而更可怕的结果是地址的覆盖, 比如我声明一组指针:

Concrete2 *p2;
Concrete1 *p1_1, *p1_2;

其中 p1_1 和 p1_2 都可以指向上述三种 classes objects, 而以下的的这个操作:
*p1_2 = *p1_1;
应该执行一个 memberwise 的复制操作, 对象则是被指的 object 的 Concrete1 那部分, 那如果有以下操作:
p1_1 = p2;
//error! derived subobject 被覆盖掉
*p1_2 = *p1_1; // p1_2 对象现在有了一个不确定的数值(同类型对象拷贝)

image



2. 单一继承含 virtual functions


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.X();
    }
    //...more operations
protected:
    float _x, _y;
};

这样的好处就是可以让不同维度之间的点进行运算的弹性,代价是空间和存取时间的额外负担:

  1. 导入一个和 Point2d 有关的 virtual table,用来存放它所声明的每一个 virtual functions 的地址。table 元素数目一般而言是被声明的 virtual functions 的数目, 再加上一个或两个 slots(以支持 runtime type identification)
  2. 在每一个 class object 中导入一个 vptr,提供执行期的链接,使每一个 object 能够找到相应的 virtual table。
  3. 加强 constructor,使它能够为 vptr 设定初值,让它指向 class 所对应的 virtual table。 这可能意味着在 derived class 和每一个 base class 的 constructor 中,重新设定 vptr 的值,其情况视编译器而定。
    4.加强 destructor,能够抹掉指向 class 的相关 virtual table 的 vptr。因为 vptr 很可能已经在 derived class destructor 中被设定为 derived class 的 virtual table 地址。值得注意的是,destructor 的调用顺序是反向的,与栈的行为类似。一个积极的优化编译器可以压抑那些大量的制定操作。
    这些额外负担的收益视被处理的 Point2d objects 的数目和生命期而定。
//新版本的 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 z){ _z = z; }
    void operator+=(const Point3d &rhs)
    {
        Point2d+=(rhs);
        _z += rhs.Z();
    }
    //...more members
protected:
    float _z;
};

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;

image
把 vptr 放在编译器尾端,可以保留 base class C struct 的对象布局,因而允许在 C 程序代码中也能使用。
随着 OOP 的兴起, 某些编译器开始把 vptr 放到 class obejct 的开头处,这对于在多重继承下,通过指向 class members 的指针调用 virtual function会带来一些帮助。否则, 不仅从 class object 起始点开始量起的 offset 必须在执行期备妥,甚至与 class vptr 之间的 offset 也必须备妥。代价就是放弃了与 C 的兼容性。
放在开头的数据布局:
image


3、多重继承


单一继承提供了一种自然多态的形式,是关于 class 体系中的 base type 和derived type 之间的转换,base class 和 derived class 的 objects 都是从相同的地址开始, 其间差异只在于 derived object 比较大, 用以多容纳它自己的 nonstatic data members。

Point3d *p3d;
Point2d *p = &p3d;

把一个 derived class 指定给一个 base class 的指针或 reference, 该操作并不需要编译器去调停或修改地址,很自然地发生,并提供了最佳执行效率。

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

多重继承不像单一继承,也不容易画出其模型,多重继承的复杂度在于 derived class 和其上一个 base class 乃至于上上个 base class 之间的非自然联系, 例如:

class Point2d
{
public:
    //...
protected:
    float _x, _y;
};

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

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

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

image
多重继承的问题主要发生于 derived class objects 和其第二或后继的 base class objects 之间的转换:

extern void mumble(const Vertex&);
Vertex3d v;
...
// 将一个 Vertext3d 转换为一个 Vertex。
mumble(v);

对一个多重派生对象,将其地址指定给第一个 base class 的指针,情况和单一继承时相同,因为二者都指向相同的起始地址,付出的成本只有地址的指定操作。至于第二个 base class 的地址制定操作,则需要修改地址:加上或减去介于中间的 base class subobject(s)大小:

Vertex3d v3d;
Vertex   *pv;
Point2d  *p2d;
Point3d  *p3d;

pv = &v3d;	//这个操作,内部的转化:
pv = (Vertex*)( (char*)&v3d + sizeof(Point3d) );

// 下面的指定操作,就只需要简单的拷贝其地址:

p3d = &v3d;
p2d = &v3d;

但如果有两个指针如下:

Vertex3d *pv3d;
Vertex   *pv;
下面的指定操作,不能够只是简单的转换:
pv = pv3d;
pv = (Vertex*)((char*)pv3d) + sizeof(Point3d);
因为如果 pv3d == 0,之前的代码中 pv = (Vertex*)(sizeof(Point3d)),不成立。
因为此pv3d == nullptr;访问权限冲突。
所以这就需要一个条件测试:
pv = pv3d ? (Vertex*)((char*)pv3d + sizeof(Point3d)) : 0;

image
原始编译器根据声明顺序排列,完成多重base classes 的布局。


4. 虚拟继承


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

// pre-standard iostream implement
class ios{ ... };
class istream: public ios{ ... };
class ostream: public ios{ ... };
class iostream:
    public istream, public ostream{ ... };

image
istream 和 ostream,都内含一个 ios subobject,然而在 iostream 的对象布局中,只需要一份 ios subobject 就好,所以在语言层面的解决方法就是虚拟继承。

虚拟继承的实现难点在于要找到一个有效的方法,将 istream 和 ostream 各自维护的 ios subobject 折叠成为一个单一的 ios subobject, 并且还可以保存 base class 和 derived class 的指针(以及 reference) 之间的多态指定操作。
一般实现的方法如下所述,class 如果含有一个或多个 virtual base class subobject, 像 istream 那样,将被分割成两部分:不变局部共享局部
不变局部:不变局部中的数据,不管后继如何衍化,总是有固定的 offset(从 object 的开头算起),所以这一部分数据可以被直接存取。
共享局部:所表现的就是 virtual base class subobject,这一部分的数据,其位置会因为每次的派生操作而变化,所以它们只能被间接存取。
各编译器实现技术之间的差异就在于间接存取的方法不同,以下是三种主流策略:

//虚拟继承的层次结构
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;
};

image
一般的布局策略是先安排好 derived class 的不变部分,然后再建立起共享部分。
然而,这中间存在一个问题:如何能够存取 class 的共享部分呢?cfront 编译器会在每一个 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 有固定的负担(只有一个虚基类指针vbptr),不因为其 virtual base class 的数目而有变化。

2. 由于虚拟继承的串联的加长,导致间接存取层次的增加。意思是,如果有三层虚拟衍化,就需要三次间接存取。但是我们希望有固定的存取时间,不会因为虚拟衍化的深度而改变。

其实就是这个策略开销不稳定
许多的编译器都使用原始实现模型来解决第二个问题,它们经由拷贝操作得到的所有的 nested virtual class 指针, 放到 derived class object 之中, 这就解决了固定存储时间的问题。 代价是空间。
第二个问题,以指针指向base class的实现模型:
image

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

经由一个非多态的 clas object 来存取一个继承而来的 virtual bass class 的 member, 可以被优化为一个直接存取操作,就好像一个经由对象调用的 virtual function 调用操作可以在编译时期被决议完成一样。在这次存取和下次存取之间, 对象的类型不可以改变, 所以 virtual base class subobjects 的位置会变化的问题在这之下就不存在了。

Point3d origin;
origin._x;
posted @ 2021-04-19 15:58  点|滴  阅读(10)  评论(0)    收藏  举报