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

2024-02-29 21:30

本文主要是介绍c++头脑风暴-多态、虚继承、多重继承内存布局,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本篇文章深入分析多态、虚继承、多重继承的内存布局以及实现原理。

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

在这里插入图片描述

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

一、没有虚函数时内存布局是怎样的
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. 多重继承时最好使用虚继承,否则不只是会产生令人头疼的二义性问题,还会多一份虚基类的拷贝,使用虚继承以后,大家共享虚基类,既节约了空间,又避免了二义性问题。

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

这篇关于c++头脑风暴-多态、虚继承、多重继承内存布局的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/760145

相关文章

HarmonyOS学习(七)——UI(五)常用布局总结

自适应布局 1.1、线性布局(LinearLayout) 通过线性容器Row和Column实现线性布局。Column容器内的子组件按照垂直方向排列,Row组件中的子组件按照水平方向排列。 属性说明space通过space参数设置主轴上子组件的间距,达到各子组件在排列上的等间距效果alignItems设置子组件在交叉轴上的对齐方式,且在各类尺寸屏幕上表现一致,其中交叉轴为垂直时,取值为Vert

NameNode内存生产配置

Hadoop2.x 系列,配置 NameNode 内存 NameNode 内存默认 2000m ,如果服务器内存 4G , NameNode 内存可以配置 3g 。在 hadoop-env.sh 文件中配置如下。 HADOOP_NAMENODE_OPTS=-Xmx3072m Hadoop3.x 系列,配置 Nam

【C++ Primer Plus习题】13.4

大家好,这里是国中之林! ❥前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。有兴趣的可以点点进去看看← 问题: 解答: main.cpp #include <iostream>#include "port.h"int main() {Port p1;Port p2("Abc", "Bcc", 30);std::cout <<

C++包装器

包装器 在 C++ 中,“包装器”通常指的是一种设计模式或编程技巧,用于封装其他代码或对象,使其更易于使用、管理或扩展。包装器的概念在编程中非常普遍,可以用于函数、类、库等多个方面。下面是几个常见的 “包装器” 类型: 1. 函数包装器 函数包装器用于封装一个或多个函数,使其接口更统一或更便于调用。例如,std::function 是一个通用的函数包装器,它可以存储任意可调用对象(函数、函数

hdu1171(母函数或多重背包)

题意:把物品分成两份,使得价值最接近 可以用背包,或者是母函数来解,母函数(1 + x^v+x^2v+.....+x^num*v)(1 + x^v+x^2v+.....+x^num*v)(1 + x^v+x^2v+.....+x^num*v) 其中指数为价值,每一项的数目为(该物品数+1)个 代码如下: #include<iostream>#include<algorithm>

C++11第三弹:lambda表达式 | 新的类功能 | 模板的可变参数

🌈个人主页: 南桥几晴秋 🌈C++专栏: 南桥谈C++ 🌈C语言专栏: C语言学习系列 🌈Linux学习专栏: 南桥谈Linux 🌈数据结构学习专栏: 数据结构杂谈 🌈数据库学习专栏: 南桥谈MySQL 🌈Qt学习专栏: 南桥谈Qt 🌈菜鸡代码练习: 练习随想记录 🌈git学习: 南桥谈Git 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

06 C++Lambda表达式

lambda表达式的定义 没有显式模版形参的lambda表达式 [捕获] 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 有显式模版形参的lambda表达式 [捕获] <模版形参> 模版约束 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 含义 捕获:包含零个或者多个捕获符的逗号分隔列表 模板形参:用于泛型lambda提供个模板形参的名

6.1.数据结构-c/c++堆详解下篇(堆排序,TopK问题)

上篇:6.1.数据结构-c/c++模拟实现堆上篇(向下,上调整算法,建堆,增删数据)-CSDN博客 本章重点 1.使用堆来完成堆排序 2.使用堆解决TopK问题 目录 一.堆排序 1.1 思路 1.2 代码 1.3 简单测试 二.TopK问题 2.1 思路(求最小): 2.2 C语言代码(手写堆) 2.3 C++代码(使用优先级队列 priority_queue)

【C++高阶】C++类型转换全攻略:深入理解并高效应用

📝个人主页🌹:Eternity._ ⏩收录专栏⏪:C++ “ 登神长阶 ” 🤡往期回顾🤡:C++ 智能指针 🌹🌹期待您的关注 🌹🌹 ❀C++的类型转换 📒1. C语言中的类型转换📚2. C++强制类型转换⛰️static_cast🌞reinterpret_cast⭐const_cast🍁dynamic_cast 📜3. C++强制类型转换的原因📝