C++对象之内存(有继承)

上一篇介绍了C++对象在无继承的各种情况下的内存空间占用情况和内存布局,这一篇来讨论有继承时的情况。有继承的情况会复杂一些,本文会分为四个层次讨论:单一继承无多态、单一继承有多态、多重继承和虚拟继承。本文中的一些图片和一些观点来自《深入探索C++对象模型》,它C++对象的底层原理讲解得非常详细,强烈安利!
如无特别说明,以下代码均在64位机器上的VS2019运行

在C++继承模型中,子类是自己的成员与父类的成员的总和。但标准并没有规定子类和父类的排列顺序,大部分编译器将父类放在子类前面,但virtual base class除外,后面会讨论到。


单一继承无多态

对于单一继承无多态,我们会讨论子类为空类和非空类这两种情况。

一、空类

class Concrete1 { };
class Concrete2 : public Concrete1 { };

输出sizeof(Concrete2)结果是1。原因和无继承时的空类大小为1的原因一样,空类的实例也需要有独一无二的内存地址,因此空类被赋予了1字节。
可见,只要类为空,无论它是否继承了其他类,大小都为1。

二、非空类

内存布局

在没有虚函数时,类的内存布局和C语言的struct完全一样。
struct
父类和子类

代码测试

代码测试一下:

class Point2d {
protected:
    float _x, _y;
};

class Point3d : public Point2d {
protected:
    float _z;
};
//main函数中:
cout << sizeof(Point3d);

输出:
12
12 = 3个float变量 * 每个float所占的4字节。

考虑subobject的padding

上一篇提到了,有时编译器会调整类的边界,也就是给类加一些字节来调整它的大小,被添加的字节称为padding。比如下面代码中的Concrete1,所有成员变量加起来占5字节,编译器又给了它3字节的padding使其大小为8字节。而当Concrete2继承Concrete1后,Concrete2的对象会拥有Concrete1的一个subobject,那么这个subobject还会保留这个padding吗?从节约内存空间的角度来想,似乎应该除去这个padding,但如果代码测试一下会发现事实截然相反,padding会被保留:

class Concrete1 {
public:
    int i1;
    char c1;
};

class Concrete2 : public Concrete1 {
public:
    char c2;
};
//main函数中:
//若输出为12则保留了subobject的padding
//若输出为8则未保留
cout << sizeof(Concrete2);

输出为:
12
这12字节是这样来的:8字节Concrete1的subobject + 1字节char + 3字节padding,其中Concrete1的subobject包括4字节int + 1字节char + 3字节padding。内存布局如下图所示:

为什么不去除子类中父类的subobject所包含的padding呢

看起来父类的subobject和子类成员之间的padding有些多余,那么如果除去这块padding会发生什么呢?
在这块padding存在的情况下,如果我们进行如下操作:

Concrete1 *pc1_1, *pc1_2;
*pc1_2 = *pc1_1;//pc1_2指向一个Concrete1对象

当pc1_1指向一个Concrete1对象时,执行复制操作,会逐个复制pc1_1所指对象的内存内容,然后放到pc1_2所指的对象的相应内存位置。而当pc1_1指向一个Concrete2对象时,执行复制操作,则会将该Concrete2对象中Concrete1的subobject部分所在内存的内容,逐个复制到pc1_2所指的对象的相应内存位置。因此,当父类的padding还在、而子类中这块padding被除去后,再执行上述操作,子类独有的成员bit2就会被放到父类原本应该是padding的位置,这就改变了父类,不是我们想要的结果。用图片解释:
没有subobject与子类成员间的padding时的内存布局
复制后
由于逐位复制这一特点,子类的对象中必须包含完整的父类对象。(但有例外,后面会讨论到)


单一继承有多态

通过虚函数,父类可以调用子类的函数,由此实现了多态。虚函数被存储在虚函数表里,虚函数表实质是一个指针数组,每个元素都是一个虚函数的函数指针。每个对象都有一个指向相应虚函数表的指针,编译器要知道某个对象到底调用了哪个虚函数,就是通过这个指针查表实现的。下面先简单看一下这个指针在对象中的位置和大小,然后讨论它和虚函数表在继承和多态中的一些细节。
为便于表述,下面将“指向虚函数表的指针”简称为“虚表指针”。

一、虚表指针在对象中的位置和大小

位置

虚表指针在对象中有两个可能的位置:头部或尾部。这个选择由编译器做出。
虚表指针在对象的尾部
上图是将其放在尾部的布局模型。早期C++编译器倾向于像这样将虚表指针放在尾部,这样可以和C语言的struct兼容。后来,C++开始支持虚拟继承和抽象类,面向对象范式兴起,越来越多的编译器开始倾向于将虚表指针像下面这样放在头部:
虚表指针在对象的头部
这样的布局就不需要编译器在执行期花额外的功夫备妥虚表指针的偏移量(offset)了,代价是失去了和C struct的兼容(但似乎没有多少程序会从C struct派生出一个有多态性质的类)。

代码测试

下面看一下VS将虚表指针放在哪里了:

class Base {
public:
    virtual void f() { }
    int ib;
};
//main函数中:
//输出成员变量ib在对象中从第一个内存位置开始算起的偏移量
//若输出00000000,则表示没有偏移,那就说明虚表指针在尾部,否则在头部
printf_s("%p", &Base::ib);

输出为:
00000004
这说明在ib前面有4字节被续表指针占用了。这同时也说明了虚表指针的大小。但是,很多文章都说64位机器上指针大小为8字节、32位机器上为4字节,可是这段代码是在64位机器上运行的,结果却是4字节。暂时不知道原因是什么。

二、父类的虚函数表与子类的虚函数表

多态时对象的内存布局

下面来看一下虚表指针和虚函数表在多态过程中的一些细节。
现在有这样两个类:

class Base {
public:
    virtual void f() {
        cout << "Base f()\n";
    }
    virtual void g() {
        cout << "Base g()\n";
    }
    //other members
};
class Derived : public Base {
public:
    void f() override {
        cout << "Derived f()\n";
    }
    //other members
};

那么它们的内存布局大概是这个样子的:

代码测试

下面输出一下父类和子类的虚函数表看一下是不是如上所述:

//使用<iostream>和namespace std
int main() {
    using Func = void(*)(void);

    cout << "父类的虚函数表:\n";
    Base* p_base = new Base();
    Func* vptr_base = (Func*)*(Func*)p_base;
    vptr_base[0]();
    vptr_base[1]();
    cout << *((long*)*(Func*)p_base+2) << endl;

    cout << "子类的虚函数表:\n";
    Base* p_derived = new Derived();
    Func* vptr_derived = (Func*) * (Func*)p_derived;
    vptr_derived[0]();
    vptr_derived[1]();
    cout << *((long*) * (Func*)p_derived + 2) << endl;

    delete p_base, p_derived;
    return 0;
}

先解释一下其中的几行代码。
一开始,p_base指向一个Base对象,更具体的说,是这个对象的第一个内存地址。Func* vptr_base = (Func*)*(Func*)p_base;中,(Func*)p_base将p_base强制转换成了一个函数指针,此处也可以写(int*)p_base(long*)p_base。接下来,*(Func*)p_base取出指针的值,也就是第一个虚函数的内存地址(如果你的编译器把虚表指针放在对象尾部的话就不能这么写了,要先算出虚表指针的偏移量)。(Func*)*(Func*)p_base代表了指向第一个虚函数的指针,同时它也就是指向虚函数表的指针。最后把它赋给vptr_base。我们说过,虚函数表实质上是一个数组,因此vptr_base实际上就是个数组指针。
*((long*) * (Func*)p_derived + 2)代表的是虚函数表的结束符,它是一个整型,因此这里最终转换为一个long*指针然后取值。
输出为:
父类的虚函数表:
Base f()
Base g()
0
子类的虚函数表:
Derived f()
Base g()
0
输出证实了这个布局模型的正确性,且在Windows(64位)VS2019上,虚函数表的结束符是0。

※关于《深入探索C++对象模型》介绍的模型中,子类的虚表指针的疑问

在“单一继承无多态”的最后提到,“子类的对象必须包含完整的父类对象”这一原则存在例外,下面介绍例外的情况。
父类和子类维护着不同的虚函数表,这样编译器才知道到底应该调用哪个函数。而获取虚函数表的元素是通过虚表指针,因此父类和子类的虚表指针是不同的,它们分别指向各自的虚函数表,且父类没有子类的虚表指针,子类也没有父类的虚表指针——此时子类并不包含“完整”的父类对象,因为它没有父类的虚表指针。然而,如果你阅读过《深入探索C++对象模型》,或至少阅读了P111的话,会发现书上不是这么说的!根据图3.3(下图),子类的对象中包含“完整”的父类对象,包括父类的虚表指针,但是它没有子类自己的虚表指针!
图3.3
(该图形容的是把虚表指针放在尾部的情况,但即使把虚表指针挪到头部依然不成立——问题在于Point3d不应该包含__vptr__Point2d,而应该包含__vptr__Point3d。)
这里给我造成了很大的困惑,我查阅了网上的一些相关博客并自己代码测试了一下,发现书上的模型确实是不成立的(至少在这种情况下,或许有什么没有考虑到的情况?如果你知道为什么,请一定告诉我!)。代码测试如下:

#include<iostream>
using namespace std;

class Base {
public:
    virtual void f() {
        cout << "Base\n";
    }
    int ib = 1;
};

class Derived : public Base {
public:
    virtual void f() {
	cout << "Derived\n";
    }
    int id = 2;
};

int main() {
    using Func = void(*)(void);
    //p指向Derived对象的第一个内存地址
    Base *p = new Derived();
    //VS将虚函数表放在对象的头部,因此vptr指向Derived的虚函数表
    Func* vptr = (Func*)*(Func*)p;
    //vf为Derived的虚函数表的第一个元素,即Derived的成员函数f的函数指针
    Func vf = vptr[0];
    //若调用vf输出的是Base则说明此处的vptr为父类的虚表指针,若输出Derived则为子类的虚表指针
    vf();
    //输出紧邻p所指内存之后的内存存储的值,如果为1(ib的值)就说明Derived对象不包含父类维护的虚表指针
    cout << *((int*)p + 1) << endl;//注意这里不要把p改成vptr,否则输出的是vptr[1]的值
    cout << *((int*)p + 2) << endl;
    delete p;
    return 0;
}

完整的输出为:
Derived
1
2
通过输出可以看出,此处的虚表指针是Derived类的,虚表指针后紧接着存放的是Base类的成员变量ib,然后是Derived类自己的成员变量id,里面并没有Base类的虚表指针。也就是说,单一继承有多态的模型似乎应该是这个样子的(虚表指针在头部的情况):


多重继承

继承多个父类时,每个父类会维护自己的虚函数表,而子类继承了几个父类就要维护几个虚函数表,相应地就拥有几个虚表指针。但是在子类的对象布局中,这些虚表指针是连续地放在对象的头部还是分别放在父类subobjects的头部呢?

内存布局

父类对象的内存布局和单一继承时一样,主要看一下子类的。对于上面那个问题,答案是后者。如果Derived类头部是这样的:class Derived : public Base1, public Base2,那么它的内存布局如下图所示:

父类的subobjects按照声明顺序排列,其中虚表指针换成子类自己的,最后是子类自己的成员变量。

代码测试

#include <iostream>
using namespace std;

class Base1 {
public:
    virtual void f() {
	cout << "Base1 f()\n";
    }
    int ib1 = 1;
};

class Base2 {
public:
    virtual void g() {
    	cout << "Base2 g()\n";
    }
    int ib2 = 2;
};

class Derived : public Base1, public Base2 {
public:
    void g() override {
    	cout << "Derived g()\n";
    }
    void f() override {
	cout << "Derived f()\n";
    }
    int id = 3;
};

int main() {
    using Func = void(*)(void);
    Derived* pd = new Derived();
    int* pd_int = (int*)pd;
    
    for (int i = 0; i < 2; ++i) {
        Func* vptr = (Func*)*pd_int;
	vptr[0]();
	cout << *(pd_int+1) << endl;
	pd_int += 2;
    }
    cout << *pd_int;

    delete pd;
    return 0;
}

输出为:
Derived f()
1
Derived g()
2
3


虚拟继承

虚拟继承鸽了……

posted @ 2019-12-11 15:00  Irene_f  阅读(453)  评论(0编辑  收藏  举报