CPP对象内存布局 (下)

发布于 2021-09-14  49 次阅读


书接上文,虚函数机制是cpp实现动态多态的一种方式。简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。

出现继承的情况 --存在虚函数

当一个类存在虚函数的时候,那么编译器就会给它生成一个虚函数表。并在该类中添加一个成员变量--虚函数表指针。编译器会保证这个指针处于类的最开头,以保证性能。

举个例子,存在这么一个类:

class Base {

     public:

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

            virtual void g() { cout << "Base::g" << endl; }

            virtual void h() { cout << "Base::h" << endl; }

};

那么在内存里,这个类的实例会长成这样:

虚函数表的末尾有一个标志节点,在出现多继承的时候可能会出现多个虚函数表,这个节点是用来表示这个表之后是否还存在其他表的。根据编译器实现的不同各不相同。

有了对虚函数基类的基本认识,现在就可以讨论继承的情况了。以下情况基于GCC,不同编译器对于父子类虚函数表的处理也不同。

一般继承 --无虚函数覆盖情况

直接看图

可以看到,B拥有自己的新虚函数表,由于B的虚函数与A没有重名的部分,即不发生覆盖(重写override)所以B的虚函数表会把父类的虚函数按顺序存放,之后跟上自己的虚函数。而成员变量依然遵照前面的规则,按顺序继承。不同的是,成员变量的第一位加了一个虚表指针。

一般继承 --虚函数覆盖原则

一样来看图:

稍微解释一下,Child类重写了Parent类的f(),并新增了两个虚函数,GrandChild类同样重写了f(),并重写了 Child类 中的g_child(),另外添加了一个自己的虚函数。

它在内存里长这样:

由于都是单一继承,所以只有一个虚函数表。在Child继承Parent的时候,虚表长这样:

重写的f()替换了父类对应的虚函数的位置,自己的虚函数跟在最后。

而GrandChild继承 Child 的时候,做出了同样的替换和追加,所以最后的虚函数表结构就成了这样。

成员变量依然按照之前的规则,按照继承顺序依次排列。

多重继承

上图:

出现多重继承的情况时,每一个基类都有自己的虚函数表,细想一下,虚函数表在类的内部是以成员变量-->虚表指针来表示的,也就是跟之前的成员变量继承规则一样,有三个虚表指针!

内存里长这样:

派生类同时重写了三个基类的共有函数:f(),那么在三个虚函数表中,对应位置的函数指针都被替换成了子类,此举可以保证无论是哪一个父类的指针,在指向派生类的时候都能正确调用函数。子类自己的虚函数,会跟在第一个虚函数表末尾。

对于成员变量来说,也是同样的道理,把虚表指针看作父类的一个成员变量,自然是Base1的虚表指针后跟上Base1的成员变量,之后才轮到Base2。

重复继承

存在如下继承情况:

B1和B2继承于同一个基类B,既然是多继承,那么D也会存在两个虚表指针,在内存里长这样:

可以看到,继承来的两个虚表都存在一个基类B的函数指针Bf(),出现了重复,但问题不大,毕竟函数也正确重写了。真正的问题出现在成员变量上。

B1继承了一个B.ib,B2也继承了一个B.ib。这时候会发现,D中出现了两个ib成员。直接调用D.id的时候,就会出现二义性。必须使用D.B1::ib和 D.B2::ib来分别指定。

为了解决重复继承带来的数据冗余和二义性,C++引入了虚拟继承的概念。

虚拟单一继承

先来看看在单一继承中,虚拟继承方式都干了啥:

假设存在如下结构:

class A {
private:
    int ida;
public:
    virtual fun();
    virtual funA();
}

Class B : virtual public A {
private:
    int idb;
}

那么对于classB来说,其内存空间长这样:

注意,虽然A中有虚函数,但这里B中并没有虚表指针,取而代之的是一个 V_Base 即虚基指针。从A继承来的东西被放在了B的最后头。其中包括A本身的虚函数表和成员变量。

虚基指针的作用就是用来代替直接继承A的所有成员,转而使用一个指针做跳转。虚基表V_BASE_TABLE里存有两个值,第一个是当前位置到当前类虚表指针的偏移量。由于这里B没有虚表指针,所以是0。第二个值代表当前类起始位置到共享的基类对象的偏移量,这个例子中,系统为32位,虚表指针4字节,int成员4字节,之后就是共享基类对象的部分了。所以这个值是8。


如果B中出现了虚函数和虚函数覆盖的情况呢?类的结构如下:

class A {
private:
    int ida;
public:
    virtual fun();
    virtual funA();
}

Class B : virtual public A {
private:
    int idb;
public:
    virtual fun() override;
    virtual funB();
}

此时的内存结构:

由于B中出现了新的虚函数,所以多出了自己的虚表指针。但其中仅有新的虚函数,重写的虚函数在基类对象A的虚函数表中被修改。

再来看虚基类表,此时由于多出了一个虚表指针,所以虚基指针到虚表指针的偏移量变成了-4,而类到共享基类的偏移量变成了12。

复杂的虚拟多继承

见识一下什么叫做丧心病狂

按顺序来处理,首先是B1,B2的内存结构,这两个内存结构相似,虚继承了B,并且重写了funB(),所以内存长这样:

最后来个大杂烩

重点关注这个V_BASE_A,由于C虚继承A,所以C的成员中会添加一个新的虚基指针,对应虚基表的偏移量的计算就要注意。由于当前类的虚表指针和继承而来的B1共用(c的虚函数也被追加到表后),所以虚表指针就是最开头那个,计算偏移量就从开头开始。而到公用类的偏移量也是一样,相对C的开头,也是最开头。

而 V_BASE_B2计算偏移量的时候就是相对于继承而来的V_TABLE_B2来计算的了。

公用基类对象也是按顺序,存放于C的末尾,当然,该覆盖的函数指针一样覆盖。这里其实是为了好理解才说进行覆盖。实际编译器可能将所有的虚函数放到一个表里,被覆盖的地方使用一句跳转指令。这就是题外话了。

到此,Cpp的内存模型基本讲解完毕。。不得不说,这些东西纠结的要死要死的,不愧是你C++


当其他人都认为你要鸽的时候,你鸽了,亦是一种不鸽