Inside C++ Object Model: Inheritance and the Data Member

       在继承体系中,C++的标准并没有规定是基类成员放在前面还是派生类成员放在前面。但在具体的实现中,通常基类成员会放置在派生类成员的前面。但也有例外,这个例外就是当继承体系中具有虚基类时。 

没有多态的继承(Inheritance without Polymorphism)

        对于下面这种实继承,一般情况下不会增加空间与时间的开销。采用这种设计可以清晰的表达两个类的关系,但是这种设计的缺点是什么呢?

class Point2D
{
public:
    float x;
    float y;
    void operator+=(Point2D& p)
    {
        this->x += p.x;
        this->y += p.y;
    }
};

class Point3D
{
public:
    float x;
    void operator+=(Point3D& p)
    {
        Point2D(p);
        this->z += p.z;
    }
};

这种设计的缺点之一是,可能增加函数的调用次数。比如对于Point3D::operator+=() ,其在内部会再次调用Point2D::operator+=(),增加了一次函数调用的开销。因此在继承体系中,合理的内联化函数也是非常重要的优化。第二个缺点在于,将一个类拆分成两个或多个继承层次的类可能会造成内存的膨胀。

        以下面个类为例,其类对象的内存大小为8个字节。成员x占四个字节,成员a、b、c分别占一个字节,最后加上一个字节的内存对其,这时候类Concrete的总大小为8字节。但如果将这个类拆分成一个具有三层继承关系的类呢?

class Concrete
{
private:
    int x;
    char a;
    char b;
    char c;
};

class Concrete1
{
protected:
    int x;
    char a;
};

class Concrete2 : public Concrete1
{
protected:
    char b;
};

class Concrete3 : public Concrete2
{
protected:
    char c;
};

        此时,类Concrete3的大小变成了16字节(在我的编译器上,Concrete3的大小仍然是8字节,看来是编译器进行了优化)。对于类Concrete1来说,成员x占4个字节,成员a占1个字节,考虑字节对齐之后,类Concrete1的内存大小是8字节。而类Concrete2中的成员b,并不会被放置在Concrete1由于内存对齐而浪费的内存中,而是被放置在了类Concrete1中3字节对齐的后面。这样的话,考虑字节对齐,类Concrete2的内存大小就是12字节。基于同样的原因,Concrete3的内存大小是16字节。

        为什么编译器要做这样看起不那么聪明的事情?编译器这样做是基于考虑了继承层次中的非同类型赋值所造成的内存覆盖问题。说起来有点不清晰,具体看下面的代码。

Concrete1 p_c1, p_c2;
Concrete1 c1;
Concrete2 c2;

p_c1 = &c1;
p_c2 = &c2;

*p_c1 = *p_c2;

        如果在类Concrete2中,b放在a的下一个字节,那么通过*p_c1 = *p_c2 进行拷贝时,b的内容将会被拷贝到类Concrete1的内存空间中,这显然不合理。所以为了避免这种情况,编译器没有将继承体系中多个类的数据成员紧密排布。

        但现在的编译器应该对这种情况进行了优化,在我本地的编译器(clang)上,类Concrete1、Concrete2、Concrete3具有相同的8字节大小。如果将类中的protected换成public,类Concrete1、Concrete2、Concrete3的大小分别为8、12、12字节。本来想基于这个现象继续探索下去,但是却发生了如下述代码所示的事情。可见,编译器做了些很难揣测的优化。将char换成int类型后,对象的内存空间就很清晰了。

class Concrete1
{
public:
    int x;
    char a;
    Concrete1() {x = 0; a = 'a';}
};

Concrete1 c1;

cout << "address of c1.x = " << &c1.x << endl;
cout << "address of c1.a = " << &(c1.a) << endl;


// 程序输出
// address of c1.x = 0x7ffee395a980
// address of c1.a = a

具有多态的继承体系(Adding Polymorphism)

        当在继承层次中出现多态时,我们都知道会发生什么。具有多态的类多有一个虚表指针,该虚表指针指向一个虚函数表,虚函数表中存储虚函数的地址,也有可能会存储跟类的类型相关的一些信息。那么当类具有多态时,其空间和时间上的开销主要有以下几个方面:

  • 每个类会有一个虚函数表,虚函数表存储虚函数的地址及类的RTTI(run time type info)

  • 每个类对象都有一个虚表指针,指向该类的虚函数表。

  • 在构造函数中初始化虚表指针。

  • 在析构函数中重置虚表指针。 

        后面还有一些讨论是与虚表指针在对象的内存空间中的位置的。一开始,C++的实现为了兼容C的struct,将虚表指针放在了对象内存空间的结尾。但是,为了支持多继承和虚基类,以及随着面向对象思维的发展,越来越多的C++实现开始将虚标指针放在对象内存空间的开头。

多继承(Multiple Inheritance)

        在单继承的多态中,如果将虚表指针放在对象内存空间的开头时,有时候需要编译器的干预来实现指针(引用)的正确转换。比如下面这个类设计,基类Point并没有虚函数,当然也没有虚表指针,派生类Line具有虚表指针。如果虚表指针存放在派生类对象内存空间的开头,那么将一个派生类对象的地址赋值给一个基类指针时,该基类指针不应该指向派生类对象内存空间开头的部分,而应该偏移一个虚表指针的地址。这时候就需要编译器的干预,以使基类指针能够指向正确的地址。那么当多继承和虚继承出现时,就更加需要编译器的干预了。

class Point
{};

class Line : public Point
{
public:
    virtual void Show(){}
};


Point* p_ptr = nullptr;
Line l;

p_ptr = &l;

        多继承相对于单继承会更加的复杂,其复杂体现在派生类与基类的“不自然”的转换关系。如以下继承体系。如果将Vertex3d转换为Point2d,这种转换关系与人类的认知是相矛盾的,所以显得不自然。

class Point2d {
protected:
    float x, y;
};

class Point3d : public Point2d {
protected:
    float z;
};

class Vertex {
protected:
    Vertex* next;
};

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

        多继承的难点在于其对派生类与基类或者后续基类对象直接转换或者通过虚函数机制间接转换的影响。 这个继承体系的内存空间如下(这里以书中的为准,现实中vptr应该会被放在类内存空间的起始位置)。

        ( 为什么Vertex3d没有虚函数表呢?我目前的推测是Vertex3d中__vptr__Point2指向的虚函数表与Point2d中__vptr__Point2d指向的虚函数表不是同一个。Vertex3d中__vptr__Point2指向的虚函数表既包含了Point2d的虚函数地址(如果没有虚函数的重写的话)也包含了Point3d自身的虚函数地址。同样Vertex3d中__vptr__Point3d中也是如此。)

        这上述的多继承中,如果将Vertex3d转换为Vertex的话,由于其首地址相同,所以不需要特别的操作。但是如果将Vertex3d转换为Point3d呢?此时就需要在Vertex3d地址的基础上加上Vertex对象的大小了。可见在多继承的情况下,编译器要在背后偷偷做很多工作。

虚继承(Virtual Inheritance)

        如果多继承的情况下已经很麻烦了,那如果多继承中还有虚继承呢?其情况又会复杂一点。比如下面这个类。

class ios {};

class istream : public virtual ios {};
class ostream : public virtual ios {};

class iostream : public iostream, public ostream {};

        在虚继承中,比较难的一点是,编译器应该在iostream的内存空间中如何放置ios才能在将iostream转化为istream或者ostream时(多态),仍然能够访问得到ios?通常的实现是将具有虚基类的对象的内存空间分成两个部分,一部分是不变区域,另一部分是共享区域。不变区域内的数据与对象内存空间的起始地址保持一个固定的位移,因此,不变区域内的数据成员可以直接访问。而共享区域则存放着虚基类的子对象(subobject)。共享区域内数据的位置会随着每次派生而改变。所以,位于共享区的数据成员的访问需要间接来进行。而各种不同C++实现的区别也就在于共享区内数据成员的间接访问方式。

        通过下面这个虚继承的类,我们可以看看虚继承的几种实现方式,及其优缺点。

class Point2d
{
protected:
    float x, y;
};

class Vertex : public virtual Point2d
{
protected:
    Vertex* next;
};

class Point3d : public virtual Point2d
{
protected:
    float z;
};

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

        在cfront最初的实现里,会在派生类中插入一个指向基类的指针,访问基类数据成员的时候通过这个指针来间接的访问。比如下面这个函数,对数据成员x和y的访问,会被转化为指针的形式。这样的转换,同样发生在指针转换的时候。

void Point3d::operator+=(const Point3d& rhs)
{
    x += x;
    y += y;
    z += z;
}

//伪代码
__vbcPoint2d->x += rhs.__vbcPoint2d->x;
__vbcPoint2d->y += rhs.__vbcPoint2d->y;
z += rhs.z;

Vertex* v_p = p3_p;

//伪代码
Vertex* v_p = p3_p ? p3_d->__vbcPoint2d : 0;

        但是,上面这种实现有两个明显的缺点。第一,派生类有几个虚基类,就有几个指针,比如Vertex3d中就有两个指向Point2d的指针,这样会浪费内存。第二,如果整个派生层次很多,那么会发生多次间接的寻址,运行效率不够高。 

        针对第一个问题,MicroSoft的解决办法是生成一个虚基类指针表,然后派生类中只有一个指向该虚拟基类表的指针。这样会减少虚基类指针的个数,但增加了一次寻址。还有一种解决办法是,在派生类中的虚函数表中存储每个虚基类成员在派生类内存空间中相对于派生类对象地址的偏移量,这样就不需要存储多个指针。

         针对第二个问题,作者没有进一步讨论,估计是没办法。

总之,使用虚拟基类最好的场景是,虚拟基类是一个纯虚类,不含有任何数据成员。 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值