多继承理解

1 基础概念:

多继承,顾名思义就是有多个基类,多继承的含义是针对单继承而言的。单继承只有一个基类;而多继承则有多个基类,在内存布局中会将每个基类按照声明的顺序进行分布,排列。如果某个基类包含虚函数,则该类的内存布局中优先进行分布,排列。

1.1 多继承的种类

  • 普通多继承,即各个基类中仅包含属性和方法,但是均不包含虚函数

  • 多继承的一个或者多个基类中包含虚函数

  • 菱形继承(钻石继承)

  • 虚继承

1.2 多继承内存分配原则

默认基类的声明顺序,先声明,先存储,先父类,再子类的顺序。但是有一种情况例外情况就是,如果多继承中先声明的基类没有虚函数,而后声明的基类包含虚函数,那么在内存布局中包含虚函数的类其数据成员在内存布局上优先于不包含虚函数的基类,即使不包含虚函数的类声明优先于包含虚函数的基类。

class A{
void fun(){...}
int a;
}
class B{
virtual void fun1(){....}
public:
int b;
}
class C:public A,public B
{
...
public:
int c;
}

若按照默认原则,则类C的内存布局如下所示:

其实不然的,实际的内存布局是:

看到了吧,再多继承情况下,如果先声明的基类中不包含虚函数,而后声明的基类中包含虚函数,那么在内存布局的时候,优先存储包含虚函数的基类,至于为什么会这样,找了好久的原因,并没有找到,不过我个人分析还是因为虚表指针在对象的首地址,如果按照默认的原则进行存储的话,由于类A没有虚函数,如果优先存储A对象的话,续表指针无法在对象的0地址存储,而且也会造成B对象的数据成员与其对应的虚表指针分离,上述种种原因造成优先存储b。不过可以根据上面得出结论:如果存在这样的情况,包含虚函数的基类优先存储。

1.3 内存布局

下面分别对上述几种多继承的内存布局分别进行介绍

  1. 普通多继承

对于普通多继承来说,由于各个基类中不包含虚函数,那么就按照默认原则进行部署,即先声明,先存储,先父类,再子类。参照如下代码:

class A{
void fun(){...}
int a;
}
class B{
void fun1(){....}
public:
int b;
}
class C:public A,public B
{
...
public:
int c;
}

内存布局如下:

  1. 多继承中一个或多个基类中包含虚函数

在多继承中,如果基类中包含虚函数,无论是一个还是多个基类,那么对象首地址对应的一定是虚表指针,并且有几个基类包含虚函数,在内存布局中就包含几个虚表指针。下面将一一进行介绍。

首先介绍所有基类都包含虚函数的情况,示例代码如下:

class A{
virtual void fun1(){...}
int a;
}
class B{
virtual void fun2(){....}
public:
int b;
}
class C:public A,public B
{
public;
virtual void fun1(){...}
virtual void fun3(){...}
...
public:
int c;
}

如上示例的内存布局如下所示:

结论:通过上面的内存布局可以看到子类的虚函数覆盖所有基类的同名虚函数;同时子类独有的虚函数指针存储到第一个虚函数表的后面。

  1. 菱形继承(钻石继承)

菱形继承,又名钻石继承,典型的组织结构如下所示:

根据多继承默认的存储原则,结合上述的继承形式,就会得出结论,在DDerived类的对象中会存在两个Base类的对象实例,这与开发者的预期不一致,从开发者角度希望在DDerived对象实例中希望只有一份Base实例,否则通过DDerived对象访问Base Class就会必须指明要访问具体哪个Base Class。

综上所述,在开过过程中我们要避免出现这样的继承结构,如果无法避免出现这样的继承形式,那么请使用虚继承来解决这个问题。

菱形继承的内存布局,请参见:

https://blog.csdn.net/weixin_43626741/article/details/124232097

后续有时间在进行补充。

  1. 虚继承

虚继承就是用来解决上文的菱形继承问题,虚继承可以保证上文的Base类对象实例在DDrived对象实例中仅有一份,但是需要知道的是解决的方法是是通过编译器来完成这个工作的,编译器通过新的内存组织形式和对于虚继承下基类数据成员的访问形式来保证Base对象实例仅有一份,这个过程增加了复杂度,会增加软件开销,不能轻易使用。

虚继承只有在多继承下才有意义,脱离多继承没有任何意义,所以在单继承下不要考虑虚继承的问题。

虚继承的思维框架:说到虚继承,就要想到虚基类一定是在派生类内存布局的尾部;而普通继承的内存布局则是按照先声明,先存储,先父类,再子类的原则进行的。虚继承引入了虚基表,虚基表记录了派生类到虚基类的偏移。

虚继承是通过关键字virtual来实现的,并未引入新的关键字,这也是在c++标准委员会在解决这个问题时广大开发者的一致呼声,因为如果引入新的关键字可能会对开发者存量代码中的函数或者符号哦重名,引入不必要的问题。所以虽然都是virtual这个关键字,但是与虚函数还是要区分开,他们具有完全不同的意义。

虚继承下有几个概念,虚基类,虚基表指针(vbptr,这个要与虚函数表指针区分开-vfptr),虚基表。

虚基类:就是在继承时以virtual关键字修饰的基类就叫做虚基类。

虚基表:用于记录虚基表指针相对派生类对象实例的偏移和虚基表指针相对各个虚基类内存偏移。

虚基表指针:该指针指向虚基表,在c++对象模型中,虚基表指针总是在虚函数表指针之后。

引用下面一段话,觉的说的特别好:

在C++对象模型中,虚继承而来的派生类会生成一个隐藏的虚基类指针(vbptr),在Microsoft Visual C++中,虚基类表指针总是在虚函数表指针之后,因而,对某个类实例来说,如果它有虚基类指针,那么虚基类指针可能在实例的0字节偏移处(该类没有vptr时,vbptr就处于类实例内存布局的最前面,否则vptr处于类实例内存布局的最前面),也可能在类实例的4字节偏移处。
一个类的虚基类指针指向的虚基类表,与虚函数表一样,虚基类表也由多个条目组成,条目中存放的是偏移值。第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值,由上面的分析我们知道,这个偏移值为0(类没有vptr)或者-4(类有虚函数,此时有vptr)。虚基类表的第二、第三…个条目依次为该类的最左虚继承父类、次左虚继承父类…的内存地址相对于虚基类表指针的偏移值

虚继承的形式:

class base{
...
}
class derived1:virtual public base{...}  //这里virtual和public先后顺序没有要求
class derived2:virtual public base{...}  //这里virtual和public先后顺序没有要求
class derived:public derived,public derived
{...}

虚继承要解决的问题是派生类中出现间接父类的多个实例问题。

注意事项:

不同的编译器对于虚继承的实现方式有所不同,以gcc和vs为例,在vs中每个子类例如derived1和derived2如果存在虚表指针,这两个类会独立设置一个虚表指针(按照非虚继承的情况,子类是不会分配内存用于存储虚表指针的,但是虚继承情况下载vs编译器下则会分配);在gcc编译器下则不会给派生类分配空间用于存储子类的虚表指针。这一点需要明白。请看如下代码,下面的代码结果以mingw编译器,链接生成

#include <iostream>
#include <QDebug>
using namespace std;
class B

{

public:

    int ib;

public:

    B(int i = 1) :ib(i) {}

    virtual void f() { cout << "B::f()" ; }

    virtual void Bf() { cout << "B::Bf()" ; }

};

class B1 : virtual public B

{

public:

    int ib1;

public:

    B1(int i = 100) :ib1(i) {}

    virtual void f() { cout << "B1::f()" ; }

    virtual void f1() { cout << "B1::f1()" ; }

    virtual void Bf1() { cout << "B1::Bf1()" ; }



};


typedef void(*Fun)(void);
int main()
{
    B1 a;
    cout << "B1对象内存大小为:" << sizeof(a) << endl;

    //取得B1的虚函数表
    cout << "[0]B1::vptr";
    cout << "\t地址:" << (int*)(&a) <<"   "<< (int*)(*(int*)(&a))<<endl;

    cout <<"*******************"<<endl;
    //输出虚表B1::vptr中的函数
    for (int i = 0; i < 3; ++i)
    {
        cout << "  [" << i << "]";
        Fun fun1 = (Fun) * ((int*)*(int*)(&a) + i);
        fun1();
        cout << "\t地址:\t" << *((int*)*(int*)(&a) + i) << endl;
    }
    cout <<"  [" << "*" << "]"<< "\t虚基类表偏移量:\t" << *((int*)*(int*)(&a) + -3) << endl;


    //[1]
    cout << "[1]B1::ib1=" << *(int*)((int*)(&a) + 1);
    cout << "\t地址:" << (int*)(&a) + 1;
    cout << endl;

    //[2]
    cout << "[2]B::vptr";
    cout << "\t地址:" << (int*)(&a) + 2 << endl;

    //输出B::vptr中的虚函数
    for (int i = 0; i < 2; ++i)
    {
        if(i == 0)
        {
            cout <<"invalid pointer. complier operation"<<endl;
            continue;  //如果不进行注释,则会发生错误,因为在虚继承下被派生类重写的虚函数在虚基类的对应的虚函数表中对应的位置是无效的。
        }
        cout << "  [" << i << "]";
        Fun fun1 = (Fun) * ((int*)*((int*)(&a) + 2) + i);
        fun1();
        cout << "\t地址:\t" << *((int*)*((int*)(&a) + 2) + i) << endl;
    }

    //[3]
    cout << "[3]B::ib=" << *(int*)((int*)(&a) + 3);
    cout << "\t地址: " << (int*)(&a) + 3;
    cout << endl;
    return 0;
}

上述代码通过mingw编译运行结果为16;但是在vs studio2013下运行结果为24;其中vs下内存截图如下:

监视图如下所示:

运行结果如下所示:

结合上面的内存图和监视图可以看出:通过vs studio编译实现的虚继承派生类也会分配内存空间派生类的虚表指针,虚基类也会分配存储空间存储虚表指针(这一点与非虚继承是不一样的)

另外需要指明的是在vc编译器下虚基类和派生类的存储区域会以0x00000000进行分隔,在加上派生类的虚表指针所占大小,所以才会在vs studio下运行占用24个字节,而在mingw下却是16个字节。

虚继承的内存结构如下(不同的编译器内存结构不相同,这里列出mingw的内存结构):

mingw编译程序运行结果:

结论:这里引用别人的总结,总结的非常好也非常详细。

派生类中虚继承下来的基类虚函数表(vptr)和基类虚基类表(vbptr)共用一张表,下文统一叫基类虚函数表(下面例子中的A::vptr),基类所有的数据放在派生类的最后。
虚继承自基类的派生类,无论派生类自己有没有虚函数,编译器都要为其生成一个虚函数指针(vptr)以及一张虚函表(和继承下来的是不同的表)。
如果派生类生成的表内有重写(覆盖)了基类的虚函数,那么该函数会存在派生类的虚函数表中,而基类的虚基类表中的函数则会删除,且回收内存,回收后其他函数内存分布不变,意思就是如果是删除的中间的函数(比如基类中有三个虚函数f1,f2,f3),那么这个空位(f2)则会被回收,后面的位置(f3)则还是存在于基类的虚函数表中。(非虚继承时,派生类新的虚函数直接扩展在基类虚函数表的下面。)

虚继承容易踩的坑:

虚继承由于内部布局在整个派生类的尾部,通过计算偏移量来获取基类数据成员的值或者修改基类数据成员的值,最容易出问题的还是向下转换,非虚继承情况下,基类转向子类的转换在编译阶段已经固定,通过偏移一定的字节就可以实现类型的转换;但是在虚继承情况下,由于虚基类的内存布局在整个派生类内存的尾部,通过虚基类如何转换为其子类,需要运行时信息即需要通过dynamic_cast来完成转换,但是使用dynamic_cast运算符又依赖虚函数。故在虚继承情况下要完成向下转换要具备两个条件:

  • 虚基类一定要有虚函数

  • 使用dynamic_cast完成转换,不能使用static_cast运算符

参考链接:

https://blog.csdn.net/weixin_43626741/article/details/124232097 【vc编译器下内存布局】

https://blog.csdn.net/effort_study/article/details/119488496 【mingw/gcc编译器下内存布局】

https://www.cnblogs.com/pandamohist/p/13882020.html

https://blog.csdn.net/smstong/article/details/6604388

https://www.oschina.net/translate/cpp-virtual-inheritance 【详细介绍了虚继承不能static_cast转换的原因】

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值