c++ map 析构函数_C++——来讲讲虚函数、虚继承、多态和虚函数表

2023-05-16

1.什么是虚函数?

虚函数是一种由virtual关键字修饰的一种类内函数,可分为虚函数和纯虚函数。我们还是直接先上代码看看吧(代码1.1):

#include 

输出为:

A func() called.
B func() called.

好,我们来看看virtual关键字在这里的作用,类B继承于类A,而B中有和A同名的func()函数,这个时候声明一个类B的对象,它就能正确地调用B的func。

你这个时候可能会问,啊?virtual关键字我没看到在这起了什么作用啊?

那我们把类A的virtual去掉,再看看输出:

输出为:

A func() called.
B func() called.

没错!你说的是对的,virtual看起来在这里确实没有起到任何作用。

我们等下再说为什么要把需要重写的方法用virtual修饰,现在你就先认为它没用吧!

首先我们补一个知识点:析构函数可以写成虚的,但是构造函数不行。

为什么呢?其中的原因比较复杂,简单地来说就是虚函数是通过一种特殊的功能来实现的,它储存在类所在的内存空间中,构造函数一般用于申请内存,那连内存都没有,怎么能找到这种特殊的功能呢?所以构造函数不能是虚的。

当然,还有其它的原因,具体的可以看看这篇文章:

为什么构造函数不能为虚函数_C/C++_布衣不舍的专栏-CSDN博客​blog.csdn.net

好,那么我们来试试把析构函数写成虚的,来看看会发生什么事?

首先我们把基类的析构函数写成虚的,有代码1.2:

#include 

这个时候,输出为:

A() called.
B() called.
~B() called.
~A() called.

好,那么我们来观察一下这里的virtual有什么用呢?你可以尝试把virtual去掉,观察一下输出有没有不同。

结论是,没有不同,无论基类的析构函数virtual与否,输出都是这样的。

啊咧,那给析构函数加虚有什么用啊!那你现在也暂且先认为它没用吧……

那我们来讲一讲纯虚函数。

什么叫纯虚函数呢?还是代码1.1那个例子,把:

virtual 

修改为:

virtual 

这样类A的func就是一个纯虚函数。

这个时候我们再编译一下(用VS2017),会报错:

error C2259: “A”: 不能实例化抽象类
note: 由于下列成员:
note: “void A::func(void)”: 是抽象的
note: 参见“A::func”的声明

对!纯虚函数是不能被调用的,因为它根本就没有实现,只有声明。

所以a.func();这样的代码是会报错的。

那我们把代码变成这样(代码1.3):

#include 

输出为:

B func() called.

好,那我们就知道纯虚函数,是一种不需要写实现,只需要写声明的一种函数,它留待派生类(也就是继承于此类的类)来实现它的具体细节,我们在这里称A为基类,B为派生类,下文同。

特殊地,在代码1.3中,因为类A拥有纯虚函数,我们也称它为抽象类,称A::func()为抽象函数。

请记住,抽象类是不可以被实例化的,也就是说在代码1.3中,A a;语句是非法的。

那问题又来了,派生类可以是抽象类吗?

我们不妨试试(代码1.4)

#include 

输出为:

C func() called.

套娃狂喜,也就是说,在单继承的前提下,你只要实例化的派生类不是抽象类就可以了,一个抽象类是可以继承自抽象类的,并且它可以被另一个类所继承。

2.什么是虚继承?

啊咧啊咧,虚继承是这样的,我们先来看代码2.1:

#include 

编译一哈:

main.cpp(26): error C2385: 对“a”的访问不明确
main.cpp(26): note: 可能是“a”(位于基“A”中)
main.cpp(26): note: 也可能是“a”(位于基“A”中)
main.cpp(27): error C2385: 对“a”的访问不明确
main.cpp(27): note: 可能是“a”(位于基“A”中)
main.cpp(27): note: 也可能是“a”(位于基“A”中)

后三条和前三条报的都是一样的错误。

指的就是D这个类被实例化了之后,对象d想访问基类A的成员a的时候,竟然不知道应该是通过B来找还是通过C来找。

这种就叫菱形继承,如图:

那这个时候我们该如何访问到经过B的A的成员a呢?代码2.2给出了一种解决方案:

#include 

输出为:

5

好的我们终于发现了一种经过指定类访问到爷类(基类的基类)的成员方法了!

那我又提出了一个新问题:经过B访问的a和经过C访问的a,它们,是一个a吗?

我们做个实验就知道了,因此,我们有代码2.3:

#include 

输出为(这两个地址是随机的,每次执行都会不同):

0000007CF67DF948
0000007CF67DF950

地址都不同,那就肯定不是一个a了,但是它们的地址位置相差2,这难道是一个巧合吗?

我们后面会说到,可以证明它们的偏移量在这个例子中,是不会随着代码执行的次数的多少而有所改变的。

那其实也就是说,如果是这样继承,D中将会有两份A的副本。

这不对劲,我们应该只想要一份A而已。

这个时候,我们就需要引入虚继承,在需要继承的基类前加virtual关键字修饰该基类,使其成为虚基类,见代码2.4:

#include 

我们可以发现,无论指不指定经过的类,a都只会在d中有一份副本了。

但请记住,把代码2.2的

class 

写成:

class 

是不可以实现多继承的。

3.多态

我们来解决第一节中所提出的问题,在基类中给成员函数/析构函数分别加virtual到底有什么作用?

我们先来看C++是如何实现多态的,见代码3.1,代码3.1给出了一种基类调用派生类方法的例子:

#include 

代码3.1的输出为:

Dervied func() called.

可以发现,给Base类的指针赋予派生类的属性,居然是可以正确调用派生类的方法的!

那我们把Base类的virtual删掉呢?

那输出就会变为:

Base func() called.

我们可以发现,这个时候派生类的方法就不会去覆盖基类,从而无法调用派生类的方法。

那么同样地,我们可以猜想,如果Base类的析构函数不虚,将会发生怎样的结果?(代码3.2):

#include 

输出为:

Base() called.
Dervied() called.
~Base() called.

我们可以发现,Dervied类的空间并没有被释放,这个时候就内存泄漏了。

把Base类的析构函数设为虚的,那就有:

Base() called.
Dervied() called.
Dervied() called.
~Base() called.

这样子就可以成功释放所有申请的内存了。

好,析构函数的虚特性我们就搞清楚了,但是我们还是没有搞清楚一个问题:

把函数声明为虚的,为什么Base的指针就可以在绑定派生类的属性之后,寻找到正确的方法呢?

接下来我们就来讲虚函数表及虚函数表指针。这两者是实现多态的必备工具。同时,我们将会讨论内存分布,并且我们通过虚函数表明白,为什么不能把基类方法赋值给派生类指针。

4.虚函数表及虚函数表指针

我们先来一段不表现多态的代码,来探究一下虚函数表及其指针是什么,见代码4.1:

#include 

这个时候我们打开监视,看一下类对象有什么东西:

如果看不清图的我给大家说说:

b1的地址为0x0115d908,其中有一个隐藏变量__vfptr,类型为void **,地址为0x00c49ca0。

d1的地址为0x01157660,其中有一个Base类,Base类下有一个隐藏变量__vfptr,类型为void **,地址为0x00c49b5c。

好,这个__vfptr指向什么呢?它应该指向一个void *的数组,这个void *的数组,就是我们的虚函数表。

我们仅看b1,打开b1的__vfptr往下展开:

我们看到里面有两个数据,一个是Base的析构函数的地址,地址为0x00c414d3,另一个是Base的func,地址为0x00c414f1。

这个void *其实就存放着所有被virtual关键字修饰的函数的实际存放地址。

我们可以看看__vfptr指向的变量叫什么名字,叫:

Projec1.exe!void(* Base::`vftable`[3])()

就是一个叫`vftable`的函数指针数组,长度为3(虽然我挺奇怪为啥长度为3,大家可以在评论区教我一下)。

好,这个虚函数表就真相大白了。

接下来我们把代码4.1改写一下,使其展现出多态,代码4.2:

#include 

好,我们其实测试代码4.1的时候是用了4.2的代码的,这样可以保证地址的一致性,那我们现在来看看代码4.2的b1的状态:

我们可以发现,这个时候的b1是一个Base类的指针,但是__vfptr指向的却是Dervied类的虚函数表!连地址都是和上次d1的__vfptr是一样的。

诶嘛,好起来了,那我要调函数的时候(比如调func),我要去虚函数表找函数地址的嘛,那我不就一找就能找到我想调用的Dervied类的方法了?

那我们也可以知道,整个程序的生存周期中,每个类的虚函数表都有唯一的一个地址。

那回到之前的问题,为什么不能把基类的属性赋值给派生类指针呢?

我们来举一个例子就知道了,见代码4.3:

#include 

我们来看看b1和d1的虚函数表:

可以发现,b1的虚函数表的长度为4,而d1的虚函数表的长度为5。

那也就是说,Dervied类对象指针本该接收一个长度为5的虚函数表,可是你给它传了一个Base类属性,这个Base类只有长度为4的虚函数表,没办法填满这个长度为5的虚函数表。

那也就是说,缺失了一种方法的实现。缺失了哪个方法呢?

func3()

假设我有语句:

Dervied *d = new Base();

并且假设这个语句合法,那么我们随即调用Dervied的func3()方法:

d->func3();

请记住,就算你把Base类的属性赋值给了d,可d本身依然还是一个Derived指针,编译器是不会管你把什么属性赋值给了d的,所以d->func3();这个语句本来就应该是合法的。


那这个时候程序跳转到d的虚函数表(这个虚函数表是Base类的虚函数表),发现找不到func3()方法,所以就崩了。

因为派生类继承了基类所有的公有虚函数,所以派生类是基类的超集,所以把派生类属性赋值给基类是合法的,但基类赋值给派生类就一定不合法,因为基类缺失了一些派生类所新定义的属性。

至于给private属性的函数打virtual会怎样?

你可以试试,在派生类中根本就没有办法重写这些虚方法,也没法访问,一样是没有意义的。

最后我们讲讲单继承下的类大小,菱形继承下的类大小和虚继承下的类大小。

5.各种继承下的类大小

先来代码5.1:

#include 

输出为:

8
12

好,我们来看看8是怎么来的,很简单:sizeof(int) + sizeof(void **)

就是base变量和虚函数指针的长度之和。

那为什么Dervied是12呢?

因为Dervied本身还有一个dervied变量呀。

好,我们再来看看菱形继承的(见代码5.2):

#include 

(注:在C++11中,final是一个关键字,可以用来指定一个类不可再被继承)

输出结果为:

8
12
12
28

为什么呢?

我们来分析一下:

Base的大小为sizeof(Base::base) + sizeof(Base::__vfptr) = 8

Dervied1的大小为sizeof(Dervied1::dervied1) + sizeof(Base) = 12

Dervied2的大小为sizeof(Dervied2::dervied2) +sizeof(Base) = 12

Final的大小为sizeof(Dervied1)+sizeof(Dervied2) + sizeof(Final::final) = 28

OK,这个好判别。

那虚继承呢?

我们把代码5.2稍微改一下,就可以得出代码5.3:

#include 

输出为:

8
16
16
28

为啥?

Base就不用讲了,和上面是一样的。

那Derived1呢?16 = sizeof(Base) + sizeof(Dervied1::dervied1) +sizeof (Dervied1::__dervied1)

Dervied2同理

那为什么Final是28呢?

请记住,因为是虚继承,Final只会有一份Base的副本,所以大小就是:

sizeof(Base::base)+sizeof(Base::__vfptr)+sizeof(Dervied1::dervied1)+sizeof(Dervied1::__vfptr)+sizeof(Derived2::dervied2)+sizeof(Dervied2::__vfptr)+sizeof(Final::final) = 28

至于它们的地址偏移……看看我有没有兴趣吧,有兴趣我就写,没兴趣就算了。

这个应该跟编译器强相关吧。

我猜。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

c++ map 析构函数_C++——来讲讲虚函数、虚继承、多态和虚函数表 的相关文章

随机推荐