1.什么是虚函数?
虚函数是一种由virtual关键字修饰的一种类内函数,可分为虚函数和纯虚函数。我们还是直接先上代码看看吧(代码1.1):
输出为:
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:
这个时候,输出为:
A() called.
B() called.
~B() called.
~A() called.
好,那么我们来观察一下这里的virtual有什么用呢?你可以尝试把virtual去掉,观察一下输出有没有不同。
结论是,没有不同,无论基类的析构函数virtual与否,输出都是这样的。
啊咧,那给析构函数加虚有什么用啊!那你现在也暂且先认为它没用吧……
那我们来讲一讲纯虚函数。
什么叫纯虚函数呢?还是代码1.1那个例子,把:
修改为:
这样类A的func就是一个纯虚函数。
这个时候我们再编译一下(用VS2017),会报错:
error C2259: “A”: 不能实例化抽象类
note: 由于下列成员:
note: “void A::func(void)”: 是抽象的
note: 参见“A::func”的声明
对!纯虚函数是不能被调用的,因为它根本就没有实现,只有声明。
所以a.func();这样的代码是会报错的。
那我们把代码变成这样(代码1.3):
输出为:
好,那我们就知道纯虚函数,是一种不需要写实现,只需要写声明的一种函数,它留待派生类(也就是继承于此类的类)来实现它的具体细节,我们在这里称A为基类,B为派生类,下文同。
特殊地,在代码1.3中,因为类A拥有纯虚函数,我们也称它为抽象类,称A::func()为抽象函数。
请记住,抽象类是不可以被实例化的,也就是说在代码1.3中,A a;语句是非法的。
那问题又来了,派生类可以是抽象类吗?
我们不妨试试(代码1.4)
输出为:
套娃狂喜,也就是说,在单继承的前提下,你只要实例化的派生类不是抽象类就可以了,一个抽象类是可以继承自抽象类的,并且它可以被另一个类所继承。
2.什么是虚继承?
啊咧啊咧,虚继承是这样的,我们先来看代码2.1:
编译一哈:
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给出了一种解决方案:
输出为:
好的我们终于发现了一种经过指定类访问到爷类(基类的基类)的成员方法了!
那我又提出了一个新问题:经过B访问的a和经过C访问的a,它们,是一个a吗?
我们做个实验就知道了,因此,我们有代码2.3:
输出为(这两个地址是随机的,每次执行都会不同):
0000007CF67DF948
0000007CF67DF950
地址都不同,那就肯定不是一个a了,但是它们的地址位置相差2,这难道是一个巧合吗?
我们后面会说到,可以证明它们的偏移量在这个例子中,是不会随着代码执行的次数的多少而有所改变的。
那其实也就是说,如果是这样继承,D中将会有两份A的副本。
这不对劲,我们应该只想要一份A而已。
这个时候,我们就需要引入虚继承,在需要继承的基类前加virtual关键字修饰该基类,使其成为虚基类,见代码2.4:
我们可以发现,无论指不指定经过的类,a都只会在d中有一份副本了。
但请记住,把代码2.2的
写成:
是不可以实现多继承的。
3.多态
我们来解决第一节中所提出的问题,在基类中给成员函数/析构函数分别加virtual到底有什么作用?
我们先来看C++是如何实现多态的,见代码3.1,代码3.1给出了一种基类调用派生类方法的例子:
代码3.1的输出为:
可以发现,给Base类的指针赋予派生类的属性,居然是可以正确调用派生类的方法的!
那我们把Base类的virtual删掉呢?
那输出就会变为:
我们可以发现,这个时候派生类的方法就不会去覆盖基类,从而无法调用派生类的方法。
那么同样地,我们可以猜想,如果Base类的析构函数不虚,将会发生怎样的结果?(代码3.2):
输出为:
Base() called.
Dervied() called.
~Base() called.
我们可以发现,Dervied类的空间并没有被释放,这个时候就内存泄漏了。
把Base类的析构函数设为虚的,那就有:
Base() called.
Dervied() called.
Dervied() called.
~Base() called.
这样子就可以成功释放所有申请的内存了。
好,析构函数的虚特性我们就搞清楚了,但是我们还是没有搞清楚一个问题:
把函数声明为虚的,为什么Base的指针就可以在绑定派生类的属性之后,寻找到正确的方法呢?
接下来我们就来讲虚函数表及虚函数表指针。这两者是实现多态的必备工具。同时,我们将会讨论内存分布,并且我们通过虚函数表明白,为什么不能把基类方法赋值给派生类指针。
4.虚函数表及虚函数表指针
我们先来一段不表现多态的代码,来探究一下虚函数表及其指针是什么,见代码4.1:
这个时候我们打开监视,看一下类对象有什么东西:
如果看不清图的我给大家说说:
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:
好,我们其实测试代码4.1的时候是用了4.2的代码的,这样可以保证地址的一致性,那我们现在来看看代码4.2的b1的状态:
我们可以发现,这个时候的b1是一个Base类的指针,但是__vfptr指向的却是Dervied类的虚函数表!连地址都是和上次d1的__vfptr是一样的。
诶嘛,好起来了,那我要调函数的时候(比如调func),我要去虚函数表找函数地址的嘛,那我不就一找就能找到我想调用的Dervied类的方法了?
那我们也可以知道,整个程序的生存周期中,每个类的虚函数表都有唯一的一个地址。
那回到之前的问题,为什么不能把基类的属性赋值给派生类指针呢?
我们来举一个例子就知道了,见代码4.3:
我们来看看b1和d1的虚函数表:
可以发现,b1的虚函数表的长度为4,而d1的虚函数表的长度为5。
那也就是说,Dervied类对象指针本该接收一个长度为5的虚函数表,可是你给它传了一个Base类属性,这个Base类只有长度为4的虚函数表,没办法填满这个长度为5的虚函数表。
那也就是说,缺失了一种方法的实现。缺失了哪个方法呢?
func3()
假设我有语句:
并且假设这个语句合法,那么我们随即调用Dervied的func3()方法:
请记住,就算你把Base类的属性赋值给了d,可d本身依然还是一个Derived指针,编译器是不会管你把什么属性赋值给了d的,所以d->func3();这个语句本来就应该是合法的。
那这个时候程序跳转到d的虚函数表(这个虚函数表是Base类的虚函数表),发现找不到func3()方法,所以就崩了。
因为派生类继承了基类所有的公有虚函数,所以派生类是基类的超集,所以把派生类属性赋值给基类是合法的,但基类赋值给派生类就一定不合法,因为基类缺失了一些派生类所新定义的属性。
至于给private属性的函数打virtual会怎样?
你可以试试,在派生类中根本就没有办法重写这些虚方法,也没法访问,一样是没有意义的。
最后我们讲讲单继承下的类大小,菱形继承下的类大小和虚继承下的类大小。
5.各种继承下的类大小
先来代码5.1:
输出为:
好,我们来看看8是怎么来的,很简单:sizeof(int) + sizeof(void **)
就是base变量和虚函数指针的长度之和。
那为什么Dervied是12呢?
因为Dervied本身还有一个dervied变量呀。
好,我们再来看看菱形继承的(见代码5.2):
(注:在C++11中,final是一个关键字,可以用来指定一个类不可再被继承)
输出结果为:
为什么呢?
我们来分析一下:
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:
输出为:
为啥?
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
至于它们的地址偏移……看看我有没有兴趣吧,有兴趣我就写,没兴趣就算了。
这个应该跟编译器强相关吧。
我猜。