多态的学习是建立在继承之上的,如果你没有事先了解学习过继承,请去看看笔者写的关于继承的文章,对继承有概念之后,再来学习多态。多态的坑是相当的多,如果未来就业,公司对多态的考察也是让人直呼:这谁能想得到。但是不要慌,笔者带你一起刨析多态,从头到尾给你讲明白什么是多态
什么是多态
在一些C++书籍上是没有多态概念的,而是采用虚函数来称呼,多态一词多用于Java语言,国内叫习惯了也就这么叫了。事实上,有很多花里胡哨的名词实际上指的都是同一个东西,多是翻译的原因。但多态这个翻译,其实还挺应景,缺点是可能让同笔者一样的新手感到抽象晦涩
在具体讲述多态之前,回忆一下继承中的相关概念,还记得在继承中提到的隐藏/重定义概念吗?子类和父类之间的成员如果同名的话,那么就会构成隐藏关系,而且我们也提到,子类是可以切片赋值给父类的,这个过程不涉及到类型转换
现在我们有这么一个场景,分别有三个类,分别是普通人类,学生类,军人类,然后我们写一个买票函数buy(),通过把不同的类的对象传给buy(),buy()在内部会调用类中的buy_ticket(),根据不同类的身份进行买票,代码如下
buy函数的参数就是person &tmp,因为学生类和军人类都是继承自普通人类,都可以切片式转换成person
class person { public: void buy_ticket() { cout << "普通人身份:全价买票" << endl; } }; class student: public person{ public: void buy_ticket() { cout << "学生身份:半价买票" << endl; } }; class soldier: public person{ public: void buy_ticket() { cout << "军人身份:免费买票" << endl; } }; void buy(person& tmp) { tmp.buy_ticket(); }
我们希望buy函数能够在买票的时候去调不同类各自的buy_ticket()
然而执行buy函数之后,我们发现,买的票都是普通人身份,这是因为我们把student和soldier都转换成person之后,此时的tmp对象是person类型
student和soldier的buy_ticket()和person中的buy_ticket()同名,构成了隐藏关系,此时调用buy_ticket(),调用的都是person类中实现的buy_ticket()
这不符合我们的预期,我们想的是,student和soldier转成person对象后调用的buy_ticket()是自己实现的buy_ticket(),不要构成隐藏关系了,而是把person的buy_ticket()实现覆盖掉
简单来说,person实现了buy_ticket(),student和soldier也各自实现了buy_ticket()
那么student在切片赋值成person后,再调用buy_ticket()就不要用person实现的buy_ticket()了,而是换成我student实现的buy_ticket()
同理,soldier也是如此,把person的buy_ticket()换成soldier自己实现的buy_ticket()
像这种在继承关系下,父类和子类的同名同参的函数,由不同的对象来调用能够实现各自类对应的功能,我们就称之为多态
多态调用对应上面的代码就是:
person类的对象test1传给buy后,调用person的buy_ticket()
student类的对象test2传给buy后,调用student的buy_ticket()
soldier类的对象test3传给buy后,调用soldier的buy_ticket()
实现多态调用的条件
实现多态不是我们嘴上说说那么简单呀,我们该怎样操作才能实现多态调用呢?
满足多态调用的条件如下:
1.实现虚函数重写
2.必须由父类的指针或者引用去调用
何为虚函数重写?前面说到,student和person的buy_ticket()不是构成了隐藏关系嘛,现在我们要转成重写/覆盖关系。因为在用student的对象调用时,要把person的buy_ticket()函数重写覆盖成自己的,具体的做法就是要给buy_ticket()函数加上virtual关键字进行修饰,表示这是一个虚函数,可以被重写覆盖。同时student类和person类中的buy_ticket()函数要满足三同
即函数名相同,参数相同,返回值相同
满足这三同,再加上virtual修饰为虚函数,就构成了虚函数重写
加下来,我们按照这个规则,把上面的代码进行改写,实现多态调用,代码如下
//三同的条件我们已经满足了,三个类中的buy_ticket()函数名相同,参数相同,返回值也相同 //只需要给每个函数加上virtual修饰就可以构成虚函数重写了 class person { public: virtual void buy_ticket() { cout << "普通人身份:全价买票" << endl; } }; class student : public person { public: virtual void buy_ticket() { cout << "学生身份:半价买票" << endl; } }; class soldier : public person { public: virtual void buy_ticket() { cout << "军人身份:免费买票" << endl; } };
多态调用的虚函数重写条件我们已经满足了,现在还差使用父类的指针或引用调用这个条件,我们是在buy函数中调用buy_ticket()函数,接下来看看buy函数
void buy(person& tmp) //buy函数使用的是父类的引用,满足调用条件 { tmp.buy_ticket(); }
那赶紧试试能不能实现多态调用吧,测试结果如下
成功了,我们实现了多态调用,解决了我们的应用场景。大家现在对多态调用这么多规矩不理解很正常,会使用就行了,后面笔者会讲多态调用实现的原理,看完实现原理,大家再回头看看,就很容易理解为什么有这些规定
虚函数重写的两个例外
在上述虚函数重写的三同条件中,返回值是可以不同的,但也局限了父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用,我们称其为协变
还有一个就是建议给析构函数无脑加上virtual修饰,这样做可以避免内存泄漏的情况,这是什么情况?请分析一下下述代码,person类中申请了10个int类型空间,并写了析构函数
student类中申请了20个int类型空间,也写了析构函数
在调用的时候,把student对象赋值给一个person指针变量
class person { public: int* _per = new int[10]; ~person() { delete[] _per; cout << "person已经完成析构" << endl; } }; class student : public person { public: int* _stu = new int[20]; ~student() { delete[] _stu; cout << "student已经完成析构" << endl; } }; int main() { person* test1 = new person; person* test2 = new student; delete test1; delete test2; return 0; }
运行结果如上,可以发现,student类中的析构函数并没有被调用,person的析构被调用了两次,这意味着test1完成了它自己的析构调用,释放了_per指向的内存,test2中只释放了它继承自person类中_per指向的内存,但是还有_stu指向的内存没有被释放,造成内存泄漏
delete在释放test1以及test2指向的空间时,会使用指针去调用test1及test2对应类的析构函数,此时test1和test2都是person*类型呀,肯定是去调用person的析构函数
我们希望test1去调用person对应的析构,test2去调用student对应的析构函数
这不就是多态的应用场景嘛,所以我们要给析构函数加上virtual修饰,让其满足多态调用的条件,从而让test2去调用student的析构,避免内存泄漏
可能你会说,给析构函数加上virtual怎么会满足多态调用呢?三同中的函数名相同并没有被满足呀,person的析构函数名为~person(),student的析构函数名为~student()
有此担心就放心好了,因为编译器会统一将析构函数做同名化处理
给析构加上virtual修饰,看看运行结果
重载 & 隐藏(重定义) & 覆盖(重写)
这三者的概念看着很像,该如何区分这三者呢?
重载和重写,重定义之间还是好区分的,重载要求两个函数在同一作用域下,重写和重定义要求两个函数分别在基类和派生类的作用域下,也就是父类和子类
需要区分的是重写和重定义,父类和子类的函数只要函数名相同,那么就构成了重定义关系,重写比重定义的要求要高很多,不仅要三同,还要是虚函数,如此才能构成重写
final & override
final源自于一个要求: 写一个不能被继承的类
按照C++98的做法就是把构造函数都放到private区域,子类调不了父类的构造函数,连初始化都做不到,更别谈继承了。但是这样自己创建的对象也没法调用构造,连对象都创建不了
C++11提供了一个关键字final,被该关键字修饰的类,无法被继承
如果用final去修饰一个虚函数,那么该虚函数不能被重写,也就无法多态调用
override则是一个用于检查的关键字,在想实现多态调用,给函数加上virtual修饰时,有时并不知道该虚函数是否能重写,构成多态调用。此时可以给该函数再加上一个override关键字,该关键字可以帮你检查该虚函数是否能重写,如果不能重写就会编译报错
抽象类
抽象一般是指对某些事物共有特征的一种概述,我们觉得抽象难以理解,因为它看不到摸不着,要靠我们脑子去想,去理解,是没有实体的概念
抽象类一样,抽象类意味着它不能够创建(实例化)具体的对象,子类继承抽象类之后,也不能实例化出具体的对象,可能你会觉得一个类连实例化对象都做不到,要这样的类有什么用呢?我们不能理解一些概念知识,除了某些前置知识的缺乏,还有就是没有遇到合适的使用场景,没有需求自然觉得无用
我们先看看哪些类算是抽象类吧,抽象类是作用在虚函数(在类中可以称为虚方法)之上的,也就是被virtual修饰的函数,在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类,如下图的person即为抽象类,不能实例化出对象
子类继承后也不能实例化出对象,只有重写纯虚函数,子类才能实例化出对象。纯虚函数规范了子类必须重写,某种程度上,抽象类是想自己的函数被继承下去,是作为一个接口被使用,它不需要实例化出对象
它会逼着子类重写纯虚函数,也就是子类继承了它的虚函数接口后,根据接口重写自己的功能,实现多态,否则子类也无法实例化出对象
接口继承&实现继承
这里我们又引出了一个概念,何为接口继承?除了接口继承,还有实现继承,实现继承又是什么意思呢?可以看出,到了继承和多态这部分,概念明显变得多了起来,因为继承和多态作用于面向对象编程,是由类的简单使用到更为抽象的使用
普通函数的继承就是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的整体的实现
我们前面所谈论的虚函数的继承,是属于接口继承,子类继承了父类的虚函数,但是注意,只继承了虚函数的接口(函数头),不继承虚函数具体的实现部分(函数体),子类要自己重写函数体,不明白函数头和函数体概念请一定搜索查看
接下来,笔者用程序分别验证实现继承和接口继承
//这是实现继承的代码验证 class person { public: void test(int val = 10) { cout << "我是父类的一个普通函数,我的值是:" << val << endl; } }; class student : public person { public: void test(int val = 20) { cout << "我是子类的一个普通函数,我的值是:" << val << endl; } }; int main() { student stu; stu.person::test(); stu.test(); return 0; }
实现继承,子类会继承父类函数的整体实现,因此,用子类对象调用父类的test()函数时,函数的调用结果将是:我是父类的一个普通函数,我的值是:10
//这是接口继承的代码验证 class person { public: virtual void test(int val = 10) { cout << "我是父类的一个普通函数,我的值是:" << val << endl; } }; class student : public person { public: virtual void test(int val = 20) { cout << "我是子类的一个普通函数,我的值是:" << val << endl; } }; int main() { //因为虚函数多态调用必须用父类引用或指针 //这里把子类对象转换成父类指针 person* ptr = new student; ptr->test(); return 0; }
按照我们前面所说的,虚函数继承就是接口继承,子类只继承接口部分(函数头),不继承实现部分(函数体),那意味着ptr调用test()函数时,函数头将使用从父类继承下来的,函数体将会被子类重写,最终结果就是:我是子类的一个普通函数,我的值是:10
很多同学曾经搞不明白,为什么子类的多态调用,打印的值是却是10,而不是20,不是说了子类会将父类的虚函数重写吗?现在你是否能明白?虚函数继承是接口继承,重写的只是函数体,而不是把整个函数都给重写了,接口(函数头)还是要用父类的,故而打印父类的缺省值:10
同时也能解释另一个问题,就是多态要求的:虚函数+三同的条件
虚函数表明该函数是接口继承,也就是子类继承了父类虚函数后,可以重写该函数的函数体
再看看三同是啥?函数名,返回值,参数不就是函数头的全部内容嘛,我子类要想把父类虚函数的函数体给替换掉,首先我得保证自己的函数头(接口)和父类的函数头(接口)是一致的,只有接口一致才能对接替换嘛,否则不就乱了套了
多态的原理
知道多态是什么以及如何使用了,下一步就是探究多态的实现原理,我们将通过通过监视和内存窗口来看看多态调用到底是怎么回事
首先监视看一下,没有构成多态关系时,类中都有啥
结果符合预期,因为两个类都没有成员变量,成员函数也不放在类中,所以监视里查看两个类时并没有什么东西
接下来,让两个类的成员函数之间构成多态关系,再次查看监视窗口
这次再打开监视窗口就发现不一样了,person里面多了一个_vfptr
这个东西是什么?有什么用呢?别急,至少我们目前知道它跟多态有关,仔细观察,_vfptr里面包含了一个元素,把鼠标放到第一个元素[0]处看看是什么
我们发现它是person::test(void),这就是我们在person里实现的test函数,我们大概能推断出来,_vfptr是一个函数指针数组,在test函数不是虚函数时没有出现这张表,可以推断这张表是存放虚函数的,至于对不对,我们在person里加一个非虚函数来验证一下
在person里加了非虚函数no_virtual_test() ,和另一个虚函数 other_virtual_fun() 。查看_vfptr里并没有出现no_virtual_test() ,而出现了other_virtual_fun() ,可以判断出_vfptr是一张虚函数表,这张表里存放的都是虚函数
如果能明白上述的内容,那么我们再看看st,因为st继承了person类,所以它也继承了person类的虚函数表,通过监视窗口是可以看到的,我们再仔细看看监视图
我们发现pt对象中的虚函数test()的地址和st中继承下来的虚函数test()的地址不一样(图中红色方框)
同时可以发现,pt对象中的虚函数other_virtual_fun()的地址和st中继承下来的虚函数other_virtual_fun()的地址是一样的(图中绿色方框)
我们看看代码就明白了,student类中也定义了test()函数,这意味着student类中的test()函数实现将会重写person类中test()函数中的实现,所以你会看到红色方框中的两个函数的地址不一样,这是因为student类对test()函数重写了,把person类中test()函数中的地址替换成自己的,从而调用了自己实现的test()函数,而这就是多态的原理
现在我们也能明白为什么调用多态时,必须要用父类的指针或者引用,就拿上图student类来说,我们把student类转换成person指针或者引用时,地址就会自动偏移到person类在stuent类中的偏移地址处,就能找到_vfptr这张虚函数表,从而调用被重写的虚函数
查看虚表
接下来看看虚表存放在内存的哪个区域,是放在栈区?代码区?堆区?还是静态区?
虚表的地址一般是位于父类的起始位置处,在32位机下,我们取父类的前4个字节的内容,打印出来不就是虚表的地址吗?只要能找到地址,我们就能大概推断出虚表在哪个区
class person { public: virtual void test() { cout << "我是父类的一个虚函数" << endl; } }; int main() { person test; int a; cout << "栈区的大概地址:" << (void*)&a << endl; int* b = new int; cout << "堆区的大概地址:" << (void*)b << endl; static int c = 10; cout << "数据段/静态区的大概地址:" << (void*)&c << endl; const char* str = "test"; cout << "代码段/常量区的大概地址:" << (void*)str << endl; cout << "虚表的地址:" << (void*)*((int*)&test) << endl; //这串代码同时适配32位机和64位机 cout << "虚表的地址:" << (void*)*((void**)&test) << endl; return 0; }
从运行结果可以得知,虚表的地址最接近代码段,所以虚表位于代码段区域
通过去内存窗口查看虚表的地址,我们可以发现虚表最后会以全0标志结束(vs2022)
那我们就可以写一段代码,把虚表中的函数地址全部都打印出来,代码如下
class person { public: virtual void test() { cout << "我是父类的一个虚函数" << endl; } virtual void test_fun() { cout << "我是父类的另一个虚函数" << endl; } }; //虚函数表是个函数指针数组 void print_virtual_fun(void(*arr[])()) { for (int i = 0; arr[i] != 0; i++) { printf("虚函数表地址[%d]:%p\n", i + 1, (void*)arr[i]); arr[i](); //这里是调用虚表里的函数,检测是否是虚函数 } } int main() { person test; print_virtual_fun((void(**)()) *(void**)&test); return 0; } //*(void**)&test 这部分是取test类的前一个指针大小,也就是虚函数表的地址 //(void(**)()) 将虚函数表地址强转成函数指针数组
根据运行结果可知,确实是这样,最后我们简单提一提在多继承情况下,哪个父函数最先被初始化的问题,这取决于继承的顺序
class A { public: int _a; }; class B { public: int _a; }; class C: public A, public B //先初始化A后初始B,且A被继承后位于C对象的起始位置 { public: int _a; };
为什么会提到这个呢?是因为有一个题目考察过这点,这并非无意义的事,因为只有你明白了这一点,才能说你理解了继承和多态的概念,题目如下
正确答案是C,如果不理解可以画一张继承图来看看,至此,本篇文章就结束了,本篇文章主要是对多态概念及其实现的浅浅探讨,了解这么多足够使用多态了