爱技术 & 爱分享
爱蛋蛋 & 爱生活

Data Member 与继承

在 C++ 继承模型中,一个 drived class object 所表现出来的东西,是其自己的member 加上bases members的总和。

至于 drived class member 和base class members 的排列顺序,C++ Standard 并未强制指定,理论上大多数编译器可以自由安排,不过大部分编译器总是把 base class member 放在首部,但是属于 virtual base class 的除外(一般而言,只要扯上 virtual base class 就没辙了,这里也不例外。)

单一继承(无虚(函数/继承))

一般而言,非虚继承的继承并不会增加操作members时间的额外负担,但是却可能会膨胀其所需空间。

C++语言保证,“出现在 drived class 中的 base class subobject 有其完整原样性”,这一点可能跟我们想象的有点不一样,接下来我会解释一下。

class Concrete
{
    int val;
    char bit1;
    char bit2;
    char bit3;
};

class Concrete1
{
    int val;
    char bit1;
};

class  Concrete2:public Concrete1
{
    char bit2;
};

class  Concrete3 :public Concrete2
{
    char bit3;
};

首先,根据“出现在 drived class 中的 base class subobject 有其完整原样性”这一点,我大致画了一下,以上三个类的内存布局。

这跟我们猜想中的好像有些不一样,不是应该是下面这种布局嘛?

可是,试想,如果是第二种布局,那么基类对象赋值给派生类对象的时候,基类对象就会覆盖掉派生类对象中一些临近的值…

比如说将 Concrete1的对象赋值给 Concrete2的对象并且是第二种布局,那么bit2将会被覆盖掉。

好啦,理论聊完了,现在我们来验证一下。

int main()
{
    cout << sizeof(Concrete) << endl;
    cout << sizeof(Concrete1) << endl;
    cout << sizeof(Concrete2) << endl;
    cout << sizeof(Concrete3) << endl;
    return 0;
}

在Visual Studio 2017输出8,8,12,16 ,在GCC7.2上输出8,8,8,8。咦,这就奇了怪哉了,怎么会不一样呢。难道之前都在胡说八道?

胖虎眉头一皱,发现事情并不简单…

然后我将代码改成这样

int main()
{
        Concrete3 c3test1;
        Concrete3 c3test2;
        cout<<&c3test1<<endl;
        cout<<&c3test2<<endl;
        cout << sizeof(Concrete) << endl;
        cout << sizeof(Concrete1) << endl;
        cout << sizeof(Concrete2) << endl;
        cout << sizeof(Concrete3) << endl;
        return 0;
}

输出两个变量的地址,然后看看中间的差距,结果还真发现了些不寻常的事情。

连续运行两次,结果如下:

0x7ffee63579b0
0x7ffee63579a0
8
8
8
8
----------------
0x7ffec27287c0
0x7ffec27287b0
8
8
8
8

可以发现 c3test1 与 c3test2 之间相差16 bytes(因为栈是向下生长的,所以c3test1 地址比 c3test2大),恰好跟之前 Visual Studio 2017 得出的 16 bytes 大小一样。 看来是GCC的sizeof 在糊弄我们啊。

多态(虚函数)

我们现在来看这么一种情况,有类 Point2D 和 Point3D ,现在我们要处理一个坐标点,而不打算在乎它是一个Point2D 或是 Point3D 实例,那么我们需要在继承体系中提供一个 virtual function。

比如说我们希望有这样一个函数 foo(Point2D &pt1,Point2D &pt2),其中pt1,pt2可能是2D点也可能是3D点。

为了支持这样的函数,势必需要在Point2D继承体系中增加虚函数。那么就会对Point2D带来空间和存取时间上的额外负担:

  • 导入一个和 Point2D 有关的 virtual table,用来存放它所声明的每一个 virtual function 地址。这个 table 的元素个数一般而言是被声明的 virtual function 个数,再加上一个或者两个slots(用以支持,runtime type identitification)

  • 在每一个class object 中插入一个 vptr,提供执行期的链接,使每一个object 都能找到相应的 virtual table。

  • 扩展constructor 使其能够进行 vptr 初值的设定,让他指向 class 所对应的 virtual table。这可能意味着在 drived class 和每一个 base class 的constructor 中,重新设定 vptr 的值。其情况视编译器优化的积极性而定。

  • 扩展 destructor ,使它能够抹除 “指向 class 相关 virtual table”的 vptr,要知道,vptr 可能已经在 derived class destructor 中被设定为 derived class 的 virtual table 的地址。 注意的是,destructor 的调用顺序是相反的,从derived class 到 base class 。一个聪明的编译器是可以通过优化,压抑掉大量的指定操作的。

这里有一个比较重要的点大家需要了解:把vptr 放置在哪里会比较好?

cfront 编译器将其放置在 object的尾端,用以支持从 struct 派生出 带virtual function 的 class这样的操作。这样子的话 派生出的class中前半部分是跟 struct一样的结构,然后才是 drived class 的member 最后是vptr。

从C++2.0 开始支持虚继承以及抽象类,一些编译器把 vptr转移到object 的首部(Mircrosoft C++正式此类编译器)。将 vptr 放在 class object 的首部,对于“在多继承下,通过 class member 的指针调用 virtual function”,会带来一些帮助。否则的话,不仅”从class object 的起点开始量起”的offset 必须在执行期准备妥当。甚至 class vptr 之间的offset 也必须准备妥当。当然 vptr 放在首部就丧失了 C语言兼容性。 不过我想也比较少有人会从一个 struct 派生出一个带有 多态性质的 class。(不过现代编译器也没有这个问题了,GCC7.2和Visual Studio 2017,都解决了这个兼容的问题,此处跟大家聊这个只是当历史来讲)

所以一般的编译器会在保持base class 在 derived class 中的完整性的同时,在首部或者尾部安插vptr。

多重继承

单一继承中,base class 和 derived class 的 object 都是从相同的地址开始,其差异只在于 derived class 比较大,用以容纳它自己的 nonstatic data member。(称为自然多态)

Point3D p3d;
Point2D *p=&p3d;

上述代码,把一个 drived clas object 的指针或reference赋值给 base class (不管继承深度有多深),这个操作并不需要编译器切调停或是修改地址。它可以很自然的发生,并且提供了最佳的执行效率。

不过如果把 vptr放在object 的首部(这也是现代编译器的做法),如果 base class 没有virtual function 而 derived class 有,那么单一继承的自然多态就会被打破,这种情况下,把一个 derive object 转换为其 base 类型,就需要编译器介入,用以调整地址(因为derived class 首部为vptr)。在既是多继承也是虚继承的情况下,编译器的介入更加的有必要。

多重继承既不像单一继承,也不容易塑造出其模型。多重继承的复杂度在于 derived class 和其上一个 base class 乃至于上上一个 base class …之间的“非自然”关系。

多重继承的问题主要发生于 derived class object 和其第一或后继的 base class objects 之间的转换。不论是直接转换或是经由所支持的virtual function 机制转换。

对一个多重派生对象,将其地址赋值给”最左端的 base class 的指针”,情况将和 单一继承时相同,因为两者都指向相同的起始地址,需要付出的代价只有地址赋值而已(但是如果是base class 无vptr,但是 derived class 拥有的时候,需要编译器介入,以修改地址)。

至于第二个或后继的 base class 的地址赋值操作,则需要将地址修改,加上或者减去(如果是 downcast的话)监狱中间的 base class subobject(s)的大小。

所以如果有如下的继承体系:

class Point2D
{
    //...
};
class Point3D:public Point2D
{
    //...
};
class Vertex
{
    //...
};

class  Vertex3D:public Point3D,public Vertex
{
    //...
};
Vertex3D v3d;
Vertex *pv;
Point2D *p2d;
Point3D *p3d;

当执行 pv=&v3d 时在其内部可能需要进行如下转换:

pv=(Vertex*)((char*)&v3d+sizeof(Point3D)); //无虚函数的情况下

而下面的赋值操作:

p2d=&v3d;,p3d=&ved;只需要进行简单的拷贝就好(无虚函数情况下)

在有虚函数的情况下,因为每一个拥有虚函数的object 在编译器优化得当的情况下都会有一个(且仅有一个)位于首部或者位于尾部的 vptr。所以这里的地址转换需要编译器做更多的工作来适配。

C++ Standard并未要求各 base class 的排列顺序,不过现在大多数编译器都会以声明顺序来排列他们(但是加上虚继承,everything maybe changed)。

那么如果要存取第二个或后继的 base class 中的 data member,将会是什么情况呢?需要付出额外的成本?

并不会,member 的位置在编译时就固定了,因此存取 member 只是进行一个简单的 offset 运算,就像单继承一样简单(无论是经由一个指针,一个reference 或是一个 object来存取)。

虚继承

多重继承的一个副作用就是,它必须支持某种形式的”share subobject 继承”,最为典型的例子就是早期的 iostream library。

不论是 istream 还是 ostream 都内含一个 ios subobject。然而 iostream 的对象布局中,我们只需要一份 ios subobject 就好。所以在语言层面上解决问题的办法就是引入虚继承。

要在编译器中支持虚继承,实在是一个高难度的操作。例如以上的 iostream,实现技术的挑战在于,要找到一个足够有效的方法,将 istream和 ostream中各自维护的一个 ios subobject,折叠成为一个由 iostream 维护的 单一 ios subobject,并且还可以保存 base class 和derived class 的指针(以及reference)之间的多态赋值操作。

一般的实现方法如下:

class 如果含有一个或多个 virtual base class subobject,像 istream 那样,将被分割为两个部分:一个不变的区域和一个共享的区域,不变区域中的数据不管后继如何衍化,总拥有固定的 offset(从 object的首部开始算起),所以这部分数据是可以被直接存取的。至于共享区域,表现的就是 virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。各家编译器实现技术的差异就在于间接存取的方法不同。

下面是 Vertex3D虚拟继承的层次结构:

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

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

class Point3D:public virtual Point2D
{
    //...
protected:
    float _z;
};

class Vertext3D:public Vertex,public Point3D
{
    //...
protected:
    float mumble;
}

一般的策略是先安排好derived class的不变部分,再建立共享部分。

然而,这中间存在着一个问题,如何能够存取 class 的共享部分呢? cfront 编译器会在每一个 derived class object 中安插一些指针,每个指针指向一个 virtual base class。要存取继承而来的 virtual base class member,可以通过相关指针间接完成。

比如我们有以下代码:

void Point3D operator +=(const Point3D &rhs)
{
    _x+=rhs._x;
    _y+=rhs._y;
    _z+=rhs._z;
}

可能被编译器转换为:

void Point3D operator +=(const Point3D &rhs)
{
    this._vbcPoint2D->_x+=rhs._vbcPoint2D->_x;
    this._vbcPoint2D->_y+=rhs._vbcPoint2D->_y;
    this._vbcPoint2D->_z+=rhs._vbcPoint2D->_z;
}

而一个 derived class 和一个 base class 的实例之间的转换,像这样:

Point2D*p2d=pv3d;

在这种实现方案下,会变成:

Point2D *p2d=pv3d?pv3d->_vbcPoint2D:0;

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

  • 每一个对象必须只对其每一个virtual base class 而附带一个额外的指针。然而理想上我们希望 class object 有固定的开销,不因为其 virtual base class 的个数而有所变化。(你能想到解决方案?)
  • 由于虚拟继承串链的加长,导致间接存取层次的增加。这里的意思是。如果我有三层虚拟派生,那么我就需要三次间接存取(经由一个 virtual base class 指针)。然而理想上我们却希望有固定的存取时间,不因为虚拟派生的深度而改变。

解决第一个问题,一般而言有两种方法。

Microsoft 编译器引入 virtual base class table。每一个 class object 如果有一个或多个 virtual base classes,就会由编译器安插一个指针,指向virtual base class table。至于真正的 virtual base class 指针当然是被放在该表格中。直至现在Microsoft采用的仍然是此方法,可以使用 cl编译选项 /d1 reportAllClassLayout或者/d1 reportSingleClassLayoutXXX(XXX为类名)查看类的布局。

还有一种解决方法是在virtual function table中放置virtual base class 的 offset(不是地址)。将virtual base class offset和 virtual function entires 混杂在一起,virtual function table 可以由正值或者负值来索引。 如果是正值则索引到 virtual function,如果是负值 则索引到 virtual base class offset。

虽然在此策略下,对于继承而来的 member 做读写操作,成本会比较昂贵,不过此成本已经被分散至”对 member 的使用”上,属于局部性成本。Derived class 实例和 base class 实例之间的转换操作,如:

Point2D *p2d=pv3d;

在上述模型下,将可能变成:

Point2d *p2d=pv3d?pv3d+pv3d->_vptr_Point3D[-1]:0;

上述每一种方法都是一种实现模型,而不是一种标准,且C++ Standard 也从未强制规定过这一点。每一种模型都是用来解决”存取 shared subobject 内的数据(其位置会因每次派生操作而有变化)”所引发的问题。由于对于 virtual base class 的支持带来额外的负担以及高度的复杂性,每一种实现模型都有点不同,而且随时在更新进化,由于各编译器实现厂商并未开源其实现方案,所以这里讨论的东西都是比较年代久远的事情,仅供参考。

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

Point3D origin;
//...
origin._x=0.0;

可以被直接优化为一个存取操作,就好像经由对象调用virtual function 调用操作,可以在编译时期就定下来,

一般而言,virtual base class 最有效的一种运用形式就是:一个抽象的 virtual base class,没有任何 data member。

赞(1) 传送门
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。编程日志 » Data Member 与继承
分享到: 更多 (0)

游戏 && 后端

传送门传送门