c++头脑风暴-多态、虚继承、多重继承内存布局

首先还是看一下思维导图:

Image

下面根据这个大纲一步一步的进行深入解析。

一、没有虚函数时内存布局是怎样的

1. 没有虚函数时类的内存布局

一个类没有虚函数的时候,其实就是结构体,它的内存布局就是按照成员变量的顺序来的。

看如下代码:

#include <iostream>
using namespace std;

class CPeople
{
    double height;
    int age;
    char sex;
public:
    CPeople(){}
    ~CPeople(){}
};

int main()
{
    CPeople people;
    return 0;
}

gdb怎么用这里就不展开了,默认你会使用gdb,使用gdb设置打印格式,然后看对象people的内存布局及大小,如下:

(gdb) set p pretty on
(gdb) p people
$6 = {
  height = 2.0731864055035386e-317, 
  age = 0, 
  sex = 0 '\000'
}
(gdb) p sizeof(people)
$7 = 16
(gdb)

此时没有虚函数,类CPeople就是一个结构体,计算大小按照8个字节对齐。

2. 没有虚函数时派生类的内存布局

把上面代码修改一下,增加一个派生类CSon,如下:

#include <iostream>
using namespace std;

class CPeople
{
    double height;
    int age;
    char sex;

public:
    CPeople(){}
    ~CPeople(){}
};

class CSon: CPeople
{
    int sisters;
public:
    CSon(){}
    ~CSon(){}
};

int main()
{
    CSon son;
    return 0;
}

此时再查看对象son的内存布局及大小,如下:

(gdb) p son
$1 = {
  <CPeople> = {
    height = 2.317785465194599e-310, 
    age = -228471872, 
    sex = 54 '6'
  }, 
  members of CSon: 
  sisters = 4196224
}
(gdb) p sizeof(son)
$2 = 24

说白了,就类似于下面这样的一个结构体:

struct a
{
    struct b
    {
        double h;
        int a;
        char s;
    }bbb;
    int s;
};

没有虚函数时不会涉及到虚函数表和虚表指针等问题,所以相对而言还比较简单。

二、有虚函数时内存布局是怎样的

1. 有虚函数的类的内存布局

还是先看一个包含虚函数的单类,代码如下:

#include <iostream>
using namespace std;

class CPeople
{
public:
    double height;//这里设置为公共成员变量方便查看地址
    int age;
    char sex;

public:
    CPeople(){}
    ~CPeople(){}
    virtual void set(){}
};

int main()
{
    CPeople people;
    return 0;
}

还是使用gdb进行查看内存布局,如下:

(gdb) p people
$1 = {
  _vptr.CPeople = 0x4008e0 <vtable for CPeople+16>, 
  height = 1.1659688840009374e-312, 
  age = 4196320, 
  sex = 0 '\000'
}
(gdb) p sizeof(people)
$2 = 24
(gdb) p &people
$3 = (CPeople *) 0x7fffffffe810
(gdb) p &people.height
$4 = (double *) 0x7fffffffe818
(gdb) p &people.age
$5 = (int *) 0x7fffffffe820
(gdb) p &people.sex
$6 = 0x7fffffffe824 ""
(gdb)

可以看到,有了虚函数以后,在之前基础上增加了_vptr.CPeople = 0x4008e0 <vtable for CPeople+16>这一行,其中vptr其实就是虚表指针,vtable就表示虚表,所以有了虚函数,对象就会相应的增加一个虚指针。

凡是存在虚函数的类,生成的对象都会生成一个虚表指针,并且这个虚表指针存储于对象所占用内存的最开始,也就是首先生成了虚表指针,然后再给成员变量分配的空间,虚表指针占用大小与操作系统有关,我这里是64位系统,所以这个虚表指针在这里是占用了8个字节。

接下来使用CPeople生成一个派生类CSon,但不实现同样的虚函数,看看是什么样的:

#include <iostream>
using namespace std;

class CPeople
{
public:
    double height;
    int age;
    char sex;

public:
    CPeople(){}
    ~CPeople(){}
    virtual void set(){}
};

class CSon:public CPeople
{
public:
    int sisters;
//    void set(){}
};

int main()
{
    CSon son;
    return 0;
}

gdb查看内存布局,如下:

(gdb) p son
$1 = {
  <CPeople> = {
    _vptr.CPeople = 0x400990 <vtable for CSon+16>, 
    height = 1.1659688840009374e-312, 
    age = 4196496, 
    sex = 0 '\000'
  }, 
  members of CSon: 
  sisters = 0
}
(gdb)

此时对于派生类对象而言,跟之前没有虚函数的时候没啥区别哈,一样的只是在基类基础上增加了派生类的成员变量而已,接下来我们在派生类中实现基类同样的虚函数看看会发生什么。

2. 多态的原理

派生类中实现基类同样的虚函数,其实就是多态的基本操作啦,先看一下直接使用派生类对象是怎么样的,如下:

#include <iostream>
using namespace std;

class CPeople
{
public:
    double height;
    int age;
    char sex;

public:
    CPeople(){}
    ~CPeople(){}
    virtual void set(){}
};

class CSon:public CPeople
{
public:
    int sisters;
    virtual void set(){}
};

int main()
{
    CSon son;
    return 0;
}

还是使用gdb查看,如下:

(gdb) p son
$2 = (CSon) {
  <CPeople> = {
    _vptr.CPeople = 0x4009a0 <vtable for CSon+16>, 
    height = 1.1659688840009374e-312, 
    age = 4196512, 
    sex = 0 '\000'
  }, 
  members of CSon: 
  sisters = 0
}
(gdb) p /a *(void**)0x4009a0
$5 = 0x40082a <CSon::set()>

看起来内存布局其实跟之前没有区别哈,派生类并没有重新生成虚表指针,直接继承了基类的虚表指针,但从gdb的第二个打印我们可以看出,根据虚函数表指针找到虚函数表,此时我们看到虚函数表里面存放的是派生类的虚函数。

其实在普通继承(非虚继承)的时候派生类并不会重新生成虚表指针,只是会使用它自身的虚函数地址去覆盖基类的相同虚函数,如果是派生类独有的虚函数,则直接追加到虚函数表的最后面。

下面真正的实现一把多态,使用父类指针生成一个派生类对象,看看是怎样的,代码如下:

#include <iostream>
using namespace std;

class CPeople
{
public:
    double height;
    int age;
    char sex;

public:
    CPeople(){}
    ~CPeople(){}
    virtual void set(){}
};

class CSon:public CPeople
{
public:
    int sisters;
    virtual void set(){}
    virtual void get(){}
};

int main()
{
    CPeople *son = new CSon();
    if ( son != nullptr )
    {
        delete son;
        son = nullptr;
    }
    return 0;
}

使用gdb查看*son的内存布局,如下:

(gdb) p *son
$2 = (CSon) {
  <CPeople> = {
    _vptr.CPeople = 0x400a90 <vtable for CSon+16>, 
    height = 0, 
    age = 0, 
    sex = 0 '\000'
  }, 
  members of CSon: 
  sisters = 0
}
(gdb) p /a *(void**)0x400a90
$3 = 0x400938 <CSon::set()>
(gdb) p /a *(void**)0x400a90@2
$4 = {0x400938 <CSon::set()>, 0x400944 <CSon::get()>}

这里可以看到哈,其实跟直接使用派生类对象时内存布局没有不同哈,是一样的,只不过直接使用派生类对象是在编译时就已经确定了是调用基类还是派生类的虚函数,而使用基类指针则是在运行时才能确定的。

总结一下:c++继承时的多态一般指的运行时多态,使用基类指针或者引用指向一个派生类对象,在非虚继承的情况下,派生类直接继承基类的虚表指针,然后使用派生类的虚函数去覆盖基类的虚函数,这样派生类对象通过虚表指针访问到的虚函数就是派生类的虚函数了。

接着我们看下对象中各成员变量内存分布是怎么样的,还是用gdb,如下:

(gdb) p son
$2 = (CSon *) 0x613c20
(gdb) p &son->height
$3 = (double *) 0x613c28
(gdb) p &son->sisters
$4 = (int *) 0x613c38

看的出来对象指针所指的一块内存,首地址是0x613c20,然后虚表指针占用8个字节,接着依次按照基类和派生类声明成员变量的顺序来存放,也就是说,非虚继承时内存是按照类继承顺序以及成员变量声明顺序来存储的,基类在前,派生类在后面。

三、虚继承

如果仔细看的话,可以发现我先前多次强调了非虚继承,这是因为在没有虚函数的时候是不是虚继承影响不大,但存在虚函数的时候虚继承和非虚继承是不一样的,如下:

#include <iostream>
using namespace std;

class CPeople
{
public:
    double height;
    int age;
    char sex;

public:
    CPeople(){}
    ~CPeople(){}
    virtual void set(){}
};

class CSon:virtual public CPeople
{
public:
    int sisters;
    virtual void set(){}
    virtual void get(){}
};

int main()
{
    CPeople *son = new CSon();
    if ( son != nullptr )
    {
        delete son;
        son = nullptr;
    }
    return 0;
}

同样使用gdb调试,打印出内存布局,如下:

(gdb) p *son
$1 = (CSon) {
  <CPeople> = {
    _vptr.CPeople = 0x400b00 <vtable for CSon+64>, 
    height = 0, 
    age = 0, 
    sex = 0 '\000'
  }, 
  members of CSon: 
  _vptr.CSon = 0x400ad8 <vtable for CSon+24>, 
  sisters = 0
}
(gdb) p /a *(void**)0x400ad8@2
$4 = {0x40095a <CSon::set()>, 0x40096e <CSon::get()>}

看一下跟之前有啥区别呢,很明显,多了一个派生类自己的虚表指针,并且派生类的虚表指针和基类的虚表指针地址还不一样,这说明什么呢。

这说明虚继承不只是实现了派生类自己的虚表指针,还重新生成了属于它自己的虚函数表,但这样一来,等于虚继承就比非虚继承多了很多开销,所以大多数情况还是不要使用虚继承吧。

再说回内存布局,在非虚继承的时候,前面也说了是按照顺序存储,那么虚继承也是这样吗?看下面打印的数据:

(gdb) p son
$2 = (CSon *) 0x613c20
(gdb) p &son->height
$3 = (double *) 0x613c38
(gdb) p &son->sisters
$4 = (int *) 0x613c28

什么意思呢,很明显这里变了,派生类的虚表指针和成员变量在前面,基类的虚表指针和成员变量在后面,那么为什么基类的放在后面去了呢?

所以虚拟继承不只是资源开销多一些,内存布局也会发生变化,那为什么还要有虚继承这个东西呢,接着往下看。

四、多重继承和二义性问题

看下面这段使用了多重继承的代码:

#include <iostream>
using namespace std;

class A
{
public:
    A()
    {
        cout << "A()" << endl;
    }
    virtual ~A()
    {
        cout << "~A()" << endl;
    }
};

class B: public A
{
public:
    B()
    {
        cout << "B()" << endl;
    }
    ~B()
    {
        cout << "~B()" << endl;
    }
};

class C: public A
{
public:
    C()
    {
        cout << "C()" << endl;
    }
    ~C()
    {
        cout << "~C()" << endl;
    }
};

class D:public B, public C
{
};

int main()
{
    D d;
    return 0;
}

执行后输出结果如下:

A()
B()
A()
C()
~C()
~A()
~B()
~A()

看到没有类A的构造函数和析构函数都执行了两次,这很显然是不正确的,因为执行类B构造函数时要执行一次类A的构造函数,执行类C的时候也要执行一次类A的构造函数,析构函数同理,到这里问题还不大,毕竟可以编译和运行。

把代码改一下,如下:

#include <iostream>
using namespace std;

class A
{
public:
    A()
    {
        cout << "A()" << endl;
    }
    void print()
    {
        cout << "print()" << endl;
    }
    virtual ~A()
    {
        cout << "~A()" << endl;
    }
};

class B: public A
{
public:
    B()
    {
        cout << "B()" << endl;
    }
    ~B()
    {
        cout << "~B()" << endl;
    }
};

class C: public A
{
public:
    C()
    {
        cout << "C()" << endl;
    }
    ~C()
    {
        cout << "~C()" << endl;
    }
};

class D:public B, public C
{
};

int main()
{
    D d;
    d.print();
    return 0;
}

编译直接就报错了:

test.cpp:54:4: 错误:对成员‘print’的请求有歧义

为什么会有歧义呢,我们注释掉d.print()这一行,然后看下对象d的内存布局,如下:

(gdb) p d
$1 = (D) {
  <B> = {
    <A> = {
      _vptr.A = 0x400fb8 <vtable for D+16>
    }, <No data fields>}, 
  <C> = {
    <A> = {
      _vptr.A = 0x400fd8 <vtable for D+48>
    }, <No data fields>}, <No data fields>}
(gdb)

对象d里面有两个A,类B继承一个,类C继承一个,相当于有两条路,编译器此时不知道该走哪条路了,这就发生了歧义。

而所谓有歧义,其实就是我们通常所说的二义性问题,而二义性问题要怎么解决呢?这就回答了我们上一章的问题,需要使用虚继承。

把代码修改一下:

#include <iostream>
using namespace std;

class A
{
public:
    A()
    {
        cout << "A()" << endl;
    }
    void print()
    {
        cout << "print()" << endl;
    }
    virtual ~A()
    {
        cout << "~A()" << endl;
    }
};

class B: virtual public A
{
public:
    B()
    {
        cout << "B()" << endl;
    }
    ~B()
    {
        cout << "~B()" << endl;
    }
};

class C: virtual public A
{
public:
    C()
    {
        cout << "C()" << endl;
    }
    ~C()
    {
        cout << "~C()" << endl;
    }
};

class D:public B, public C
{
};

int main()
{
    D d;
    d.print();
    return 0;
}

使用virtual public XX这样的形式就叫做虚继承,类A就是虚基类,此时再看对象d的内存布局,如下:

(gdb) p d
$1 = (D) {
  <B> = {
    <A> = {
      _vptr.A = 0x400fe0 <vtable for D+32>
    }, <No data fields>}, 
  <C> = {<No data fields>}, <No data fields>}
(gdb)

此时就可以看到类D首先继承的类B有一份类A的继承,然后类C就不再有类A的继承,这样就把两条路变成了一条路了。

有人会说,上面不是说虚继承会重新生成虚表指针吗,但这里是类B虚继承类A,但是类D继承的时候是非虚继承,所以类D并不会重新生成虚表指针,但此处类B和类C应该重新生产虚表指针,gdb查看却没有,我一开始也很疑惑,但到后面我又明白了。

这时我们给类A加上成员变量,看一下他们是怎么样的,如下:

#include <iostream>
using namespace std;

class A
{
public:
    int a;
    A(){}
    virtual ~A(){}
};

class B: virtual public A
{
public:
    B(){}
    ~B(){}
};

class C: virtual public A
{
public:
    C(){}
    ~C(){}
};

class D:public B, public C
{
};

int main()
{
    D d;
    return 0;
}

这次再查看内存布局,如下所示:

(gdb) p d
$1 = (D) {
  <B> = {
    <A> = {
      _vptr.A = 0x400c58 <vtable for D+104>, 
      a = 0
    }, 
    members of B: 
    _vptr.B = 0x400c08 <vtable for D+24>
  }, 
  <C> = {
    members of C: 
    _vptr.C = 0x400c30 <vtable for D+64>
  }, <No data fields>}

在类A有成员变量的情况下,类B和类C都重新生成了虚表指针和自己的虚表,而如果再改一下代码,类A没有成员变量,此时无论类B和类C有成员变量还是虚函数,都不会再生成它们自己的虚表指针和虚函数表。

所以这里我们又知道了一个事,之前说虚继承后,派生类都会生成它自己的虚函数表和虚表指针,并不完全准确,准确来讲,当虚基类有成员变量时,派生类会生成它自己的虚函数表和虚表指针,当派生类没有成员变量时,并不会重新生成派生类自己的虚函数表和虚表指针。

然后我们给四个类都加上成员变量,看下多重继承时的内存布局,代码如下:

#include <iostream>
using namespace std;

class A
{
public:
    int a;
    A(){}
    virtual ~A(){}
};

class B: virtual public A
{
public:
    int b;
    B(){}
    ~B(){}
};

class C: virtual public A
{
public:
    int c;
    C(){}
    ~C(){}
};

class D:public B, public C
{
    public:
    int d;
};

int main()
{
    D d;
    return 0;
}

注意多重继承并没有按照声明和继承顺序那样去布局,它的地址分布如下所示:

(gdb) p d
$1 = (D) {
  <B> = {
    <A> = {
      _vptr.A = 0x400c58 <vtable for D+104>, 
      a = 0
    }, 
    members of B: 
    _vptr.B = 0x400c08 <vtable for D+24>, 
    b = 4197175
  }, 
  <C> = {
    members of C: 
    _vptr.C = 0x400c30 <vtable for D+64>, 
    c = -228471872
  }, 
  members of D: 
  d = 54
}
(gdb) p &d
$2 = (D *) 0x7fffffffe800
(gdb) p &d.a
$3 = (int *) 0x7fffffffe828
(gdb) p &d.b
$4 = (int *) 0x7fffffffe808
(gdb) p &d.c
$5 = (int *) 0x7fffffffe818
(gdb) p &d.d
$6 = (int *) 0x7fffffffe81c

通过地址可以看得出来,对于类B、类C、类D这三个,它是按照顺序来存储的,对于类A与我们上一章虚继承得出的结果一样,虚基类的虚表指针和成员变量是放在一块内存的最后面的。

个人理解:虚基类之所以放在对象所属内存的后面,跟虚继承的机制有关,我们说用了虚继承以后,能保证虚基类在对象内存中永远只有一份拷贝,如果还是按照顺序存储,虚基类只有一份,但是派生类却有多个,那编译器到底该把虚基类放在哪个派生类前面呢,手心手背都是肉,不好处理,那就干脆放在最后面,让大家共享,这样就不存在打架的行为啦,同时这也解释了为什么虚继承能解决二义性问题。

五、总结

根据以上分析,总结出如下几点:

  1. 一个没有虚函数的类,它的大小其实就是所有成员变量的大小,此时它就是一个由诸多成员变量组成的结构体,计算大小时同样要按照字节对齐去计算,下面所有计算大小都需按照字节对齐去计算,后面不再说明;
  2. 一个没有虚函数的类派生出一个没有虚函数的派生类,那么这个派生类的内存布局就是先基类成员变量,然后派生类成员变量组成的结构体,各成员变量在内存中存储顺序按照声明时的顺序来存放;
  3. 一个有虚函数的类,类本身会生成一份虚函数表,这个虚函数表是所有类对象共享的,每个类对象都会在构造时首先生成一个虚表指针,指向这个虚函数表,然后才是各个成员变量,所以有虚函数的类对象会比没有虚函数的类多一个虚表指针,虚表指针跟其他指针没有区别,在64位系统中就是占用8个字节;
  4. 一个派生类非虚继承于一个有虚函数的类,不论派生类是否有同样的虚函数,它的内存布局都只是在有虚函数的基类基础上增加派生类的成员变量,虚表指针是直接继承基类的,指向基类虚表指针,如果派生类有同样的虚函数,那就覆盖基类虚表中同名函数,如果是派生类独有的虚函数,那就追加在基类虚函数表后面;
  5. 一个派生类虚继承于一个有虚函数且没有成员变量的基类,则派生类也不会生成它自己的虚表指针和虚函数表,此时内存布局是首先是虚表指针,然后是派生类的成员变量,与第4点区别不大;
  6. 一个派生类虚继承于一个有虚函数且有成员变量的基类,此时派生类会重新生成它自己的虚表指针和虚函数表,内存布局则是派生类的虚表指针和成员变量在前,基类的虚表指针和成员变量在后;
  7. 多重继承时最好使用虚继承,否则不只是会产生令人头疼的二义性问题,还会多一份虚基类的拷贝,使用虚继承以后,大家共享虚基类,既节约了空间,又避免了二义性问题。

好了,本篇文章就为大家介绍到这里,觉得内容对你有用的话,记得顺手点个在看哦~

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注