冰冰学习笔记:多态

欢迎各位大佬光临本文章!!!

 

还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。

本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。

我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog

我的gitee:冰冰棒 (BingbingSuperEffort) - Gitee.comhttps://gitee.com/BingbingSuperEffort


系列文章推荐

冰冰学习笔记:《继承》

冰冰学习笔记:《基础IO》


目录

系列文章推荐

前言

1、多态的概念

2.虚函数

2.1虚函数的重写

2.2析构函数的重写

2.3final与override关键字

2.4不构成多态的几种状况

2.5重写,重载,重定义的对比

3.抽象类

3.1抽象类的概念

3.2接口继承和实现继承

4.多态的原理

4.1虚函数表

4.2打印虚函数表的内容

4.3虚函数表的总结

4.4多态的调用方式

5.多继承中的虚函数表

总结


前言

        面向对象的封装和继承讲完,接下来就是第三大特性---多态。继承和多态是C++中非常重要的部分,无论是学习C++还是为了工作,这两个部分都是学习与面试中的一座高山,翻过去,C++就成功一半了。那什么是多态呢?多态又是怎么构成的呢?多态的原理又是什么呢?接下来我们一一介绍。

1、多态的概念

        什么是多态?顾名思义,多态就是多种状态。当我们使用不同的对象去完成某个相同的行为的时候,会产生不同的状态。例如同样是春运时期的买票环节,普通人买票需要全价购买,而学生就可以半价,军人则可以优先买票不用排队。这就是多态的具体应用。

多态的构成条件:

        多态并不是随随便便就能形成的,多态实现的前提条件就是继承体系。多态是在不同继承关系的类对象中去调用同一函数,产生不同的行为。

构成多态要必须满足下面的条件:

(1)必须通过基类的指针或者引用去调用虚函数。

(2)被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。

2.虚函数

        既然构成多态需要虚函数,那什么是虚函数呢?还记得关键字virtual吗?在修饰菱形继承的时候会形成菱形虚拟继承,解决菱形继承的二义性和数据冗余的问题。这个关键字还有一个用处就是修饰类成员函数,被virtual修饰的类成员函数就称为虚函数

2.1虚函数的重写

        并不是有了虚函数就形成了多态,再用父类对象指针或者引用调用的前提下,我们还需要对父类继承下来的虚函数完成重写才能形成多态。那什么是虚函数的重写呢?

        虚函数的重写也叫做覆盖,派生类中有一个跟基类完全相同的虚函数(满足三同:返回值,函数名,参数列表完全相同),称子类重写了基类的虚函数。

注意:虚函数构成重写有两个特例:

(1) 子类中在重写父类的虚函数时,前面不加virtual关键字,依然构成重写,前提是父类中必须要有virtual修饰。

(2) 重写的协变:派生类重写基类的虚函数时,与基类虚函数的返回值允许不同,但是必须是满足基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。

2.2析构函数的重写

        析构函数需要重写吗?析构函数最好是重写,为什么呢?我们先看下面的例子:

        当我们使用父类指针去接受new出来的子类对象时,调用delete释放空间会发生错误,我们创建的是子类对象,可是在释放的时候却调用父类的析构函数,这将导致子类对象中的内容只有继承父类的成员被释放,子类成员没有被成功析构,造成内存泄漏。

        基于上面的这种情况,我们的析构函数就必须要完成重写来解决这种问题。当父类中虚函数被virtual修饰后,子类的虚函数无论加不加virtual修饰都会被处理成与父类析构函数的重写。这里虽然函数名不同,但是编译器会自动将析构函数的函数名处理成destructor,这样就构成了重写的条件。析构函数完成重写后,上面的情况将会正常被析构。

2.3final与override关键字

        由于C++对虚函数的重写比较严格,当我们不小心写错函数名时,编译阶段是无法发现错误,这将导致我们无法构成多态又难以排查错误,因此C++11增加了两个关键字进行解决这个问题。

(1)final关键字也是一个关键字多用的情况,在修饰类时,表示该类为最终类,不支持继承。当final修饰虚函数的时候,表示虚函数不能被重写。但是这基本没有意义,虚函数本来就是用来重写构成多态调用的。

(2) override关键字用来检查派生类虚函数是否重写了基类的某个函数,如果没有重写那么编译报错。

2.4不构成多态的几种状况

        前面也说了,多态的构成非常严格,两个条件缺一不可。下面的情况就是不构成多态的情况。

(1)当我们使用父类对象调用而不是指针或者引用时,不构成多态。

(2)当虚函数的参数不同也不构成多态

(3)返回值不同,不构成多态

(4)函数名不同,不构成多态

2.5重写,重载,重定义的对比

重载:两个函数在同一作用域,函数名相同,参数不同。

重写:两个函数分别在基类和派生类的作用域,函数名,参数,返回值都必须相同(协变例外),且两个函数必须是虚函数。

重定义:两个函数分别在基类和派生类的作用域,函数名相同,不构成重写就是重定义。

3.抽象类

3.1抽象类的概念

        在虚函数后面写上=0,这个函数称为纯虚函数纯虚函数可以实现也可以不实现。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承之后也不能直接实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。换句话说,纯虚函数规范了派生类必须重写,纯虚函数更加体现了接口继承。

3.2接口继承和实现继承

        普通函数的继承是一种接口继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数,会降低效率。

下面的例题就能体现接口继承:

        B中的func函数与A中的func函数构成重写的条件,因此p去调用test函数的时候将会发生切片,A类中test函数的this指针是父类对象的指针,因此多态的另一个条件也满足。此时将会调用B中的func函数而不是A中的func函数。但是B中func函数是继承的A中func函数的接口,并且对实现进行重写,所以B类中func函数的参数,返回值,返回类型都会和A类中的函数保持一致,所以val的确实参数将会是1而不是0,因此打印出B->1。

4.多态的原理

        多态的构成与运用我们都有所了解,但是多态的原理究竟是什么呢,编译器又是怎么去调用不同的函数的呢?

4.1虚函数表

我们先看下面类中,占了多少字节?

        这简单,根据之前所学的内容,类中的函数并不会在类中,而是存储在公共代码区,因此我们只需要计算类中成员的大小就行,并且满足内存对齐规则,所以Base类占据了8字节。

        但是结果却是12字节,内存窗口中显示多了一个指针,这是怎么回事?

        我们发现多了一个_vfptr指针放在了对象的前面(不同平台放置的位置可能不同),我们将这个指针叫做虚函数表指针,它指向一张虚函数表,表中存放着虚函数函数指针,通过指针可以找到虚函数。这里一定要注意:虚函数表中放置的是指向虚函数的函数指针,并不是虚函数本身,虚函数和其他成员函数一样都存放在代码区。

        因此虚函数表本质是一个函数指针数组。

        现在我们创建一个类A去继承Base类,并且对函数Func1进行重写。

        根据内存窗口和监视窗口我们可以看到子类中也存在一张虚函数表,并且与父类中的虚函数表不是同一张。我们在父类对象中虚函数表中存放的是父类Func1函数的地址,子类中则存在的是子类中Func1函数的地址。如果我们不重写那么两张虚表中函数地址是一样的吗?

我们在父类中添加虚函数Func2,子类中继承下来但是不进行重写。

        这里我们发现完成重写的函数Func1在子类对象的虚表中存放的就是子类重写的Func1,父类对象中存放的是父类的Func1,而没有重写的函数Func2在父类和子类对象的虚表中均指向父类中的函数。此时我们就能理解为什么虚函数的重写又称为覆盖了,原因在于子类中重写后的函数会将子类对象中虚表的函数地址覆盖为子类的函数,而不再指向父类的函数。重写是语法的叫法,覆盖是原理层的叫法。

下面我们增加几个成员函数,看看究竟哪些函数会放到虚表中。

        此时我们发现父类对象中Func3函数并不在虚表中,原因在于Func3并不是虚函数,因此不会放在虚表中,子类的虚表中放入了重写的Func1函数和继承父类但没有重写的Func2函数。但是子类本身所具备的虚函数Func4和非虚函数Func5没有在虚表中。难道子类自己的虚函数不放在虚表中吗?

        并不是,这只是VS编译器的处理结果,编译器的监视窗口没有显示,其实Func4函数依然存在子类的虚函数表中。怎么确定呢?我们可以打印来看看。

4.2打印虚函数表的内容

        如果我们能将虚函数表的内容打印出来不就可以确定Func4函数在不在子类虚表中了吗?因此我们使用地址将虚函数表的内容打印出来。我们知道虚函数表本质是一个函数指针数组,VS下将该数组的最后放入了一个nullptr来表示数组内容结束,并且虚表中虚函数的地址是按照声明顺序依次放入的。所以我们只要获得虚函数表的地址就能打印出来。

        观察上面的监视窗口我们发现,父类对象中虚函数表的地址放在对象的前4个字节中,子类对象中虚函数表的地址则放在父类成员中的前4个字节中。所以我们采用下面的方式获得虚函数表的地址,并写出下列打印函数(注:所有操作均在32位机器下)

        取出对象的地址,然后将其强制转换位int*类型,再对其进行解引用拿到对象前4个字节的内容。里面存储的正是虚函数表的地址,将其强制类型转换为函数指针类型的指针,然后传递给函数进行打印。

typedef void(*MYPTR)();//重命名函数指针类型
void print(MYPTR VFT[])
{
	for (int i = 0; VFT[i]!=nullptr; i++)
	{
		printf("VFT[%d]->0x%x :", i, VFT[i]);
		VFT[i]();
		cout << endl;
	}
}

 根据结果我们可以发现,Fun4函数也存在虚表中。

4.3虚函数表的总结

        虚函数表本质是一个存放函数指针的数组,虚函数表并没有存放在对象中,对象中存放的只是一个指向虚函数表的指针,验证发现,虚函数表存放在常量区。

        虚函数并非存在虚函数表中,虚函数与其他成员函数一样,放置在代码段,虚函数表中只是存放的指向虚函数的函数指针。

        同一个类的不同对象共用同一个虚函数表。虚函数都会放在虚函数表中,不是虚函数不会放在虚函数表中。如果不使用多态,成员函数就不要写成虚函数,写出虚函数会降低效率。

        虚函数表在编译阶段就生成了,构造函数在初始化阶段将会初始化对象中的虚函数表指针。

        派生类的虚函数表的生成是先将基类的虚函数表的内容拷贝到派生类中,如果派生类中重写了某个虚函数,那么会将虚函数表中的函数指针进行覆盖,指向重写后的函数,派生类中自己新增加的虚函数将会按其在派生类中的声明顺序增加到派生类虚表后面。

4.4多态的调用方式

        通过虚函数表我们现在知道了,类中的虚函数都会被放到虚函数表,重写后的函数在子类和父类对象虚函数表中的地址是不一样的,那么多态究竟是怎么完成的呢?

        实际上多态的调用是一种动态编译,虚函数的地址并非在编译时确定的,而是在程序运行期间去对象的虚表中取出函数的地址,然后在进行函数的调用。如果不构成多态,那就是普通的函数调用,函数的地址是在编译链接阶段确定的。

        多态的这种调用方式又称为动态绑定或后期绑定,是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

        与之对应的是静态绑定又称为前期绑定在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。

5.多继承中的虚函数表

        前面我们介绍的虚表都是单继承体系,那如果在多继承体系中,虚表会有几个呢?通过验证我们发现,多继承中,子类对象中含有多个虚表,不同的父类虚函数放在不同的虚函数表中。

        例如下面的继承关系,C类继承了A类和B类,因此C类中包含两张虚表,一张是A类中虚函数的虚表,如果C类中对A类中继承下来的虚函数完成了重写,那么重写后的虚函数地址会覆盖掉原本A类中虚函数的地址。另一张就是B类中的虚表,同A类一样,重写的B类中的虚函数会放进B类中的虚表中。由于子类对象先继承的A类后继承的B类,因此A类的虚表放在上面,B类的虚表会靠下一点。

class A 
{
public:
	virtual void fun1()
	{
		cout << "A::fun1" << endl;
	}
	virtual void funa()
	{
		cout << "A::funa" << endl;
	}
	int _a = 0;
};
class B 
{
public:
	virtual void fun1()
	{
		cout << "B::fun1" << endl;
	}
	virtual void funb()
	{
		cout << "B::funb" << endl;
	}
	int _b = 1;
};
class C : public A, public B
{
public:
	virtual void fun1()
	{
		cout << "C::fun1" << endl;
	}
	virtual void funb()
	{
		cout << "C::funb" << endl;
	}
	virtual void fun3()
	{
		cout << "C::fun3" << endl;
	}
	int _c = 1;
};
void Fun(B& b)
{
	b.fun2();
}
void test1()
{
	A a;
	B b;
	C c;
	Fun(c);
}

        由于我们对B类对象中funb函数进行了重写,因此子类对象的虚表中funb函数的地址就是我们重写后的地址,而A类对象中的函数funa没有重写,因此函数还是同一个。但是我们会发现我们对A类和B类共有的fun1函数进行了重写,但是两张虚表中函数的地址却不一样。

        至于为什么函数地址不同我们一会在讨论,现在还有一个问题,C类对象中虚函数fun3去哪了?虚函数不应该在虚表里面吗?怎么两张虚表都没有。

至于有没有,我们用前面的打印方式,将虚表打印出来看看:

        由于C类先继承的A类,因此A类对象中的虚表就在前4个字节,而B类对象中的虚表就需要我们手动去计算或者使用切片获得C类对象在B类的一部分,然后在对其进行转化。

打印结果如下所示:

        此时我们发现,子类中的虚函数确实存放在了虚表中,而且是放在了第一张虚表中,如果我们改变继承顺序,会发现还是放在第一张虚表中。至于为什么VS下的窗口没有显示,可能是VS对其做的一种封装。所以我们可以做出以下结论:

        多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

        现在我们来讨论为什么fun1函数在两张虚表中的地址不同,但是最终调用了同一个函数,都是我们重写之后的函数。

        我们先将两张虚表中fun1函数的地址打印出来,并且我们还需要单独打印出fun1函数的地址进行对比。

        此时我们发现不仅两张虚表中的地址不同,这两个地址还和函数实际的地址也不同!其实这是VS的一种封装手法,VS下的所有函数都会增加一层jam指令的跳转。

        所以虚函数表中存放的其实是jam指令的地址,并非实际函数的地址,但是最终调用的却是同一个函数。

        菱形虚拟继承中的多态调用会更加复杂,基本上是不会用到的。有兴趣的可以去其他大佬中的博客中去观看。C++ 虚函数表解析 | 酷 壳 - CoolShellC++ 对象的内存布局 | 酷 壳 - CoolShell

        这里我们需要注意,如果B,C继承了A,D继承了B和C,此时形成了菱形虚拟继承,这时子类必须要重写父类的虚函数,不重写会出现二义性,虚拟继承会将虚表处理成一份(前提是子类B,C中没有虚函数),如果不重写,两个父类中的虚函数会不知道放哪一个。

        如果父类B,C中有虚函数,此时子类D中虚表就会有三个,A的虚表,B的虚表,C的虚表。此种情况虚基表中前4个字节存储的不在是0,而是存储和虚表的偏移量。 

总结

多态的常用问题总结:

(1)内联函数能否是虚函数?

        答:内联函数不能是虚函数,内联函数没有地址,因为内联函数需要在调用的地方展开,虚函数需要地址,因为虚函数需要放到虚函数表中。但是编译器实际上是允许内联函数添加virtual修饰的,原因在于inline关键字只是一个建议性关键字,只有编译器才能决定是否成为内联函数,在多态调用中,inline有可能就失效了。

(2)静态成员函数可以是虚函数吗?

        答:不能,因为静态成员函数没有this指针,直接使用类域指定调用,静态成员函数都是在编译时决定的函数地址。虚函数是为了实现多态,多态调用是运行时去虚表中找寻函数地址。

(3)构造函数可以是虚函数吗?

        答:不可以,虚函数是为了实现多态,运行时去虚函数表中进行调用,虚函数表虽然早就形成了,但是对象中存储的虚函数表指针是在对象构造期间初始化列表时进行的初始化,构造函数没有调用之前,虚函数表指针没有初始化,所以构造函数为虚函数没有意义。

(4)析构函数可以是虚函数吗?

        答:可以,并且建议析构函数写成虚函数。具体原因在前文已给出。

(5)拷贝构造和赋值重载可以是虚函数吗?

        答:拷贝构造不可以,拷贝构造也是构造函数的一种。赋值重载为虚函数在语法上是允许的,但是没有价值,唯一的作用可能就是将父类赋值给子类。

(6)对象访问普通函数快还是访问虚函数快?

        答:虚函数如果不构成多态,就一样快,如果构成多态就是普通函数快,因为虚函数需要去虚表中找虚函数的地址。

(7)虚函数表是在什么阶段形成的?存在哪里?

        答:虚函数表在编译阶段就生成了,构造函数初始化阶段初始化列表会初始化虚函数表的指针,对象中存储的也是虚函数表的指针。虚函数表本身存储在常量区。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bingbing~bang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值