C++继承中成员变量的内存排布

这篇文章主要讨论C++继承中成员变量的内存排布情况,主要包括单继承,多继承,带虚函数的继承,带虚基类的继承


单继承无多态

C++语言保证在派生类中的基类子对象有其完整原样性。
比如下面这个 Concrete 类,其内存排布可以分析为(32位下) 4B+3x1B=7B + 1B(对齐)= 8B

class Concrete {
private:
    int val;
    char c1;
    char c2;
    char c3;
};


如果把上面的 Concrete 分为三层结构

class Concrete1 {
private:
    int val;
    char bit1;
};

class Concrete2 : virtual public Concrete1 {
private:
    char bit2;
};

class Concrete3 : public Concrete2 {
private:
    char bit3;
};

此时为了满足C++语言保证在派生类中的基类子对象的原样性,Concrete3 对象占的内存大小应该为 16 字节

我们可以想想为什么C++要保证派生类中基类子对象的原样性,假如我们有下面一组指针:

Concrete2 *pc2;
Concrete1 *pc1_1, *pc1_2;

其中 pc1_1pc1_2 两者都可以指向前述三种类对象,下面这个指定操作:

*pc1_2 = *pc1_1;

应该执行一个默认的逐对象拷贝操作,复制的对象是 Concrete1 那一部分,如果 pc1_1 实际指向一个 Concrete2Concrete3 对象,则上述操作要复制的对象应该是其中 Concrete1 子对象。

此时,如果不保证派生类中基类子对象的原样性,也就是 Concrete2::bit2Concrete3::bit3Concrete1 子对象捆绑在一起,去除填补空间,其内存布局就和一开始 Concrete 类的内存布局一致。

那么下面的复制操作就会产生问题:

pc1_1 = pc2; // pc1_1 指向 Concrete2对象
*pc1_2 = *pc1_1;

因为会把 pc1_1bit2 属性也拷贝过去,因为此时它处于 Concrete1 的padding中,如果 pc1_2 也指向一个 Concrete2 对象,那么其 bit2 的值就会发生我们不期望的改变(我们只是想拷贝Concrete1子对象)


加上多态

一般多态是通过一个对象的虚函数表指针实现,该虚函数表指针指向该对象的虚函数表(其位于只读数据段.rodata),虚函数表中存放的是每个虚函数指针,下面这张图很直观

一般来说,编译器会把虚函数表指针放在对象的内存起始位置,比如我们在上面的 Concrete2 加入一个虚函数

class Concrete2 : public Concrete1 {
public:
    virtual void f() { cout << "调用f()" << endl; }
private:
    char bit2;
};

其对象内存布局如下,可以看到确实是在起始位置加入了虚函数表指针

我们知道 Concrete1 里是没有虚函数的,所以其内存布局中不应该有指向虚函数表的指针,那么如果有一个 Concrete1 类型的指针指向一个Concrete2 类型的对象,其指向的地址应该是 Concrete2 对象虚函数表指针之后的位置,由编译器在编译时处理。

Concrete1* c1 = new Concrete2();

可以看到,调用 Concrete2 的默认构造函数后,其起始地址为 0x009b3ca8

而最后赋给 c1 的值为 0x009b3cac 正好为 0x009b3ca8 + 4


多重继承

多重继承需要处理的情况就是,一个派生类,其有三个父类,且有两个父类都有虚函数时,派生类的内存布局是怎么样的。
实际会根据继承的顺序会有不同的内存布局。

有三个父类

class X {
public:
    virtual void f() {cout << "X::f()" << endl;}
    int m_x;
    char m_cx;
};

class Y {
public:
    virtual void f() {cout << "Y::f()" << endl;}
    int m_y;
    char m_cy;
};

class Z {
public:
    int m_z;
    char m_cz;
};

如果继承顺序如下

class Q : public Z, public Y, public X {
public:
    virtual void f() { cout << "Z::f()" << endl; }
    int m_q;
    char m_cq;
};

因为起始继承的 Z 不包括任何的虚函数,所以会加上一个 Q 的虚函数表指针

其实编译的时候会有优化,会将继承的含有虚函数的基类的子对象放在前面,后面再放不含虚函数的基类的子对象,这样就可以省去一个虚函数表指针的大小,其内存布局像下面这样

要是继承的顺序像下面一样

class Q : pulic Z, public X, public Y{
public:
    virtual void f1() { cout << "Z::f()" << endl; }
    int m_q{ 2 };
    char m_cq{ 1 };
};

那么内存布局如下

上面 Qf() 同时重写了 XYf(),比如我们有如下代码:

Y* y = new Q();
X* x = new Q();
    
y->f();
x->f();

从汇编码中可以看出,yx分别调用各自 YX 虚函数表指针对应的虚函数表下的虚函数 f()

y 的调用跳转

继续跳转


最后跳转到对应虚函数

x 的调用跳转为,然后直接跳到了虚函数 f(),这是因为它们实际上是同一个虚函数,所以 y 这里调用做了个间接跳转操作


虚拟继承

对于虚拟继承,其考虑的场景是在菱形继承下,基类子对象只会在内存中存在一份
考虑下面这个继承关系,Concrete1 会在 Concrete4 存在两份基类子对象

class Concrete1 {
public:
    int val;
    char bit1;
};

class Concrete2 : public Concrete1 {
private:
    char bit2;
};

class Concrete3 : public Concrete1 {
private:
    char bit3;
};

class Concrete4 : public Concrete2, public Concrete3{
public:
    char bit4;
};

其内存布局如下

且如果要访问 Concrete1 中的成员,会出现访问成员不明确的问题

为了解决这个问题,C++中可以使用虚基类,使得基类子对象在内存中只存在一份,上面的例子可以改为

class Concrete1 {
public:
    int val;
    char bit1;
};

class Concrete2 : virtual public Concrete1 {
private:
    char bit2;
};

class Concrete3 : virtual public Concrete1 {
private:
    char bit3;
};

class Concrete4 : public Concrete2, public Concrete3{
public:
    char bit4;
};

可以看到虚基类被放在了末尾

那么在编译时无法确定对象的具体类是,如何在运行时找到虚基类成员位置

我们运行如下代码

Concrete2* c2 = new Concrete4();
Concrete3* c3 = new Concrete4();
c2->val = 10;
c3->val = 20;

Concrete4 的默认构造函数中,通过将一个指向虚基表的指针初始化,可以在需要找到虚基类成员时,利用指向虚基表的指针,就可以得到虚基类子对象关于当前虚基表指针的偏移,间接得到虚基类子对象的位置。

如果此时还有虚函数,比如 Concrete1 中有一个虚函数 f() 那么,Concrete4 中内存布局如下,其实虚基表指针应该存放于变量之前

如果 c3->f() 调用虚函数,编译器如何找到虚函数表指针的位置,其实也和取变量一致,先找到虚基类子对象的起始位置(通过虚基表中的偏移),然后这个位置其实就是虚函数表指针的位置。

[c3] 是 c3 的起始位置,就是对应 Concrete3 中虚基表指针的位置,然后取出虚基表,找到虚基类子对象对应的偏差,然后加到当前 c3 地址上,得到虚函数表指针的地址,然后根据虚函数表,找到对应虚函数地址,然后调用。


参考资料

《深度探索C++对象模型》—— Stanley B.Lippman著,侯捷译

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-05-04 08:48:03       98 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-05-04 08:48:03       106 阅读
  3. 在Django里面运行非项目文件

    2024-05-04 08:48:03       87 阅读
  4. Python语言-面向对象

    2024-05-04 08:48:03       96 阅读

热门阅读

  1. 前端初学者的 CSS 入门

    2024-05-04 08:48:03       29 阅读
  2. 蓝桥杯国赛备赛复习——数据结构

    2024-05-04 08:48:03       34 阅读
  3. 网络安全运维类面试非技术问题

    2024-05-04 08:48:03       31 阅读
  4. Python闭包:深入解析与使用场景

    2024-05-04 08:48:03       33 阅读
  5. helm安装 AWS Load Balancer Controller

    2024-05-04 08:48:03       33 阅读
  6. Apache Kafka知识点表格总结

    2024-05-04 08:48:03       29 阅读
  7. 什么是g++-arm-linux-gnueabihf

    2024-05-04 08:48:03       35 阅读
  8. Vue在/public目录下访问process.env.NODE_ENV(其它通用)

    2024-05-04 08:48:03       29 阅读
  9. Spark RDD

    2024-05-04 08:48:03       33 阅读
  10. git ,查看某个版本的某个文件内容

    2024-05-04 08:48:03       32 阅读