在上一篇的C++《继承》当中我们了解了C++当中面向对象的一大特性继承,在类和对象章节我们了解了面向对象的特性封装,那么接下来我们在本篇就来了解面向对象的最后一个特性——多态,在此我们会了解到多态的概念以及要实现多态的必要条件是什么,最后我们会了解到多态的原理。相信通过本篇的学习会让你深入了解多态,接下来就开始本篇的学习吧!!!
1.多态的概念
多态(polymorphism)的概念:通俗来说,就是多种形态。
多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态,编译时多态(静态多态)和运行时多态(动态多态)。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调⽤不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的
注:在此我们把编译时⼀般归为静态,运行时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。
比如买票这个行为,当普通⼈买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军⼈买票时是优先买票。再⽐如,同样是动物叫的⼀个行为(函数),传猫对象过去,就是”(>^ω^<)喵“,传狗对象过去,就是"汪汪"。
2. 多态的定义及实现
在以上我们了解了多态的概念,那么接下来我们就来了解多态的定义以及实现条件是什么
2.1 多态的构成条件
多态是⼀个继承关系的下的类对象,去调用同⼀函数,产⽣了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象优惠买票。
2.1.1 实现多态两个必要条件
在此要实现多态除了要在继承体系下还要有以下的必要条件:
1• 必须指针或者引用调用虚函数
2• 被调用的函数必须是虚函数。
注:要实现多态效果,第⼀必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生
类对象;第二派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的数,多态的不同形态效果才能达到。
在此你会疑惑虚函数是什么?对基类的虚函数重写/覆盖是什么?这些概念怎么之前都没有了解过
你不必惊慌,这些就是我们接下来在本篇要重点了解的概念,通过下面的学习再回过来看时你就会明白以上的描述
2.1.2 虚函数
在了解继承体系下的多态之前我们先要来了解类内的虚函数是什么
类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。
注:非成员函数不能加virtual修饰。并且在此不要将虚函数和之前在继承章节了解的虚继承混淆,在此只是使用了相同的关键字其他并没有关联
例如以下示例:
class Person{public:virtual void BuyTicket() { cout << "买票-全价" << endl;}};
2.1.3 虚函数的重写/覆盖
在了解了虚函数是什么之后接下来我们来了解虚函数的重写/覆盖是什么
虚函数的重写/覆盖:派生类中有⼀个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
注:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派里类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
例如以下示例:
示例1:
#include<iostream>using namespace std;class Person {public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};class Student : public Person {public:virtual void BuyTicket() { cout << "买票-打折" << endl; }};void Func(Person* ptr){// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。ptr->BuyTicket();}int main(){Person ps;Student st;Func(&ps);Func(&st);return 0;}
注:在以上当中在Func函数当中形参是基类的指针,看起来像是使用基类的指针调用成员函数,但其实调用和指针没关系,而是指针指向的对象决定调用基类的函数还是派生类的函数,在此接下来在讲解多态的原理时会详细讲解里面的原因
示例2:
#include<iostream>using namespace std;class Animal{public:virtual void talk() const{}};class Dog : public Animal{public:virtual void talk() const{std::cout << "汪汪" << std::endl;}};class Cat : public Animal{public:virtual void talk() const{std::cout << "(>^ω^<)喵" << std::endl;}};void letsHear(const Animal& animal){animal.talk();}int main(){Cat cat;Dog dog;letsHear(cat);letsHear(dog);return 0;}
2.1.4 虚函数重写(补充)
• 协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
注:协变的实际意义并不大,所以我们了解⼀下即可。
例如以下示例:
#include<iostream>using namespace std;class A {};class B : public A {};class Person {public:virtual A* BuyTicket(){cout << "买票-全价" << endl;return nullptr;}};class Student : public Person {public:virtual B* BuyTicket(){cout << "买票-打折" << endl;return nullptr;}};void Func(Person* ptr){ptr->BuyTicket();}int main(){Person ps;Student st;Func(&ps);Func(&st);return 0;}
• 析构函数的重写
基类的析构函数为虚函数,此时派⽣类析构函数只要定义,⽆论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。
接下来来看以下的代码:
#include<iostream>using namespace std;class A{public:virtual~A(){cout << "~A()" << endl;}};class B : public A {public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}protected:int* _p = new int[10];};// 只有派⽣类Student的析构函数重写了Person的析构函数,下⾯的delete对象调⽤析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调⽤析构函数。int main(){A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;}
以上代码输出结果如下所示:
注:以上会调用两次A的析构是由于在析构B类型对象p2最后会自动基类A的析构函数
那么在以上代码如果基类的析构函数前不加virtual输出结果会变为以下形式:
那么为什么没加virtual会变为只调用两次A的析构函数呢?
要解答以上问题就要先知道在delete p1;和delete p2;这两句代码实际上在编译之后会变成以下的形式:
p1->destructor,operator delete(p1) ;
p2->destructor,operator delete(p2) ;
在此会先调用对应的析构函数,之后再调用operator delete进行空间的释放。当我们在A类的析构函数之前加上virtual之后,那么A类的析构函数就会与B类的析构函数构成多态,在以上使用基类A的指针调用析构函数时就会根据对象的类型调用对应类的析构函数,在以上先是类A的对象在此就会调用A的析构;之后是B的对象就会调用B的析构之后编译器会自动的调用基类A的析构。
但如果未在基类A的析构函数之前加上virtual,那么该析构函数就不是虚函数,那么这时使用派生类内的析构函数就无法和A内的析构函数构成重写,这时使用基类A的指针去调用析构函数就只会根据指针的类型来调用对应的析构函数,这时由于p1和p2都是A类型的指针在此就会调用两次A的析构函数
2.1.5 练习题
在以上我们了解了多态的相关概念以及构成多态的必要条件之后接下来我们来看以下的练习题
#include<iostream>using namespace std;class A{public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }};class B : public A{public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }};int main(int argc, char* argv[]){B* p = new B;p->test();return 0;}
以上程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
以上程序的输出结果实际上是B->1,所以以上正确的选项是B,看到这个结果是不是出乎你的预料,接下来就来讲解输出该结果的原因
首先在以上程序中创建了一个B类型的对象,并且将指向该对象的指针赋值给变量p,之后通过p调用函数test;那么在此由于只有在基类A当中才有函数test,那么在此调用的就是A类的test函数。
之后进入test函数之后再调用func函数,那么在此要进行分析的就是在此调用的是哪个类内的func函数;是A类的还是B类的?
在此由于是在A类内调用func函数,那么此时隐含的this指针就是A类型的指针,并且又因为基类和派生类的fun函数构成多态,再结合前面的基类的指针this,这时就会根据对象的实际类型调用对应的func函数。在此因为new的对象是B类型的,接下来就会调用B类域内的函数func。
通过以上的分析你这时就会好奇最后调用B内的func函数不应该是输出B->0吗?为什么一开始说明的输出结果是B->1?
其实在最后调用func函数时你忽略了一个点,这也是这道题坑的地方。在此在进行虚函数的重写时要求的是函数的参数列表要相同,那么若基类对应的虚函数和派生类对应拆卸后的虚函数参数缺省值不同时就会将派生类的函数形参缺省值变为和基类内相同的值。
因此通过以上分析最后在调用派生类内的func函数时1缺少参数应该为1
所以最后输出结果就为B->1
2.2 override 和 final关键字
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。
例如以下示例:
#include<iostream>using namespace std;// error C3668: “Benz::Drive”: 包含重写说明符“override”的⽅法没有重写任何基类⽅法class Car {public:virtual void Dirve(){}};class Benz :public Car {public:virtual void Drive()override{cout << "Benz-舒适" << endl; }};int main(){return 0;}
以上代码编译时就会出现以下的报错
当如果我们不想让派⽣类重写基类内的虚函数,那么可以在此C++还提供了final这个关键字,在此使用final去修饰之后的基类内的虚函数就无法在派生类内重写。
例如以下示例:
#include<iostream>using namespace std;// error C3248: “Car::Drive”: 声明为“final”的函数⽆法被“Benz::Drive”重写class Car{public:virtual void Drive() final {}};class Benz :public Car{public:virtual void Drive() { cout << "Benz-舒适" << endl; }};int main(){return 0;}
由于Car类内的虚函数Drive使用final修饰之后就无法在其的派生类内对该虚函数进行重写,所以以上代码就会有以下的报错
2.3 重载/重写/隐藏的对比
在之前类和对象章节我们了解过函数重载是什么,在上一篇继承章节我们了解了继承体系下的基类和派生类的成员在什么条件下构成隐藏,在本篇又学习了继承体系下的虚函数的重写。那么这三种概念有什么异同点呢?接下来来看以下的总结图
注:在此隐藏在有的地方也描述为重定义
3. 纯虚函数和抽象类
纯虚函数与抽象类的概念:
在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派⽣类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。
注:纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
例如以下示例:
#include<iostream>using namespace std;class Car{public:virtual void Drive() = 0;};class Benz :public Car{public:virtual void Drive(){cout << "Benz-舒适" << endl;}}; class BMW :public Car{public:virtual void Drive(){cout << "BMW-操控" << endl;}};int main(){// 编译报错:error C2259: “Car”: ⽆法实例化抽象类Car car;Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;}
以上代码由于Car类是抽象类因此无法实例化出对象,以上代码会在Car car这句代码编译报错
4.多态的原理
在以上我们大致了解了多态实现的要求那么接下来就来了解多态是如何实现的;其原理是怎么样的 。但在此之前我们先要了解一下什么虚函数表指针是什么
4.1 虚函数表指针
先来看以下的代码在x86环境下输出结果是什么?
A. 编译报错 B. 运行报错 C. 8 D. 12
#include<iostream>using namespace std;class Base{public:virtual void Func1(){cout << "Func1()" << endl;}protected:int _b = 1;char _ch = 'x';};int main(){Base b;cout << sizeof(b) << endl;return 0;}
在以上代码中Base类型的对象当中其实除了存储了成员变量_b和_ch以外还存储了指针变量_vfptr,在此该指针就是虚函数表指针。在此由于是在32位的环境下,指针的大小都为4字节,再根据计算类对象是的内存对齐规则就可以的出以上的对象所占的内存空间大小为12字节,因此以上代码正确的结果是D
那么在以上提到的虚函数表是什么呢?
其实⼀个含有虚函数的类中都⾄少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
以上代码的对象b内的成员内容就如下所示:
4.2 多态的原理
在了解了含有虚函数的类当中都有虚函数表指针,那么虚函数表又和我们要了解的多态原理有什么关系呢?接下来就来了解多态的原理
4.2.1 多态是如何实现的
#include<iostream>using namespace std;class Person {public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};class Student : public Person {public:virtual void BuyTicket() { cout << "买票-打折" << endl; }};void Func(Person* ptr){// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。ptr->BuyTicket();}int main(){Person ps;Student st;Func(&ps);Func(&st);return 0;}
在以上的代码中从底层的⻆度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调⽤Person::BuyTicket,ptr指向Student对象调⽤Student::BuyTicket的呢?
通过下图我们可以看到,满⾜多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引⽤指向基类就调⽤基类的虚函数,指向派⽣类就调⽤派⽣类对应的虚函数。第⼀张图,ptr指向的Person对象,调用的是Person的虚函数;第⼆张图,ptr指向的Student对象,调⽤的是Student的虚函数。
4.2.2 动态绑定与静态绑定
• 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。例如函数重载就是静态的绑定。
• 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调行函数的地址,也就做动态绑定。
4.2.3虚函数表
接下来在了解了多态的原理之后接下来就来看看虚函数表内是按照什么样的逻辑存储相应的指针的
• 基类对象的虚函数表中存放基类所有虚函数的地址。
来看以下的代码
#include<iostream>using namespace std;class Base {public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func5() { cout << "Base::func5" << endl; }protected:int a = 1;};int main(){Base b;return 0;}
在以上代码中对象b内的虚函数内就存有虚函数func1和func2的指针
• 派生类由两部分构成,继承下来的基类和自己的成员,⼀般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派生类对象中的基类对象成员也独立
的。
• 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
来看以下的代码
#include<iostream>using namespace std;class Base {public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func5() { cout << "Base::func5" << endl; }protected:int a = 1;};class Derive : public Base{public:// 重写基类的func1 virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func1" << endl; } virtual void func4() { cout << "Derive::func4" << endl; }protected:int b = 2;};int main(){Base b;Derive d;return 0;}
在以上代码中b对象内的虚函数表中有func1和func2的指针,在对象d当中有虚函数func1重写的指针以及原本虚函数func2的指针
但其实以上调试窗口显示的还是不怎么准确,其实对象d的虚函数表内还有虚函数func3和func4的指针,在此我们通过内存窗口就可以看出func3和fun4指针就是在存储在func1和func2指针之后,在此推断的依据是
• 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
• 虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
• 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证⼀下。vs下是存在代码段(常量区)
在此我们可以通过以下的代码来观察
#include<iostream>using namespace std;class Base {public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func5() { cout << "Base::func5" << endl; }protected:int a = 1;};class Derive : public Base{public:// 重写基类的func1 virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func1" << endl; } virtual void func4() { cout << "Derive::func4" << endl; }protected:int b = 2;};int main(){int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";printf("栈:%p\n", &i);printf("静态区:%p\n", &j);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);Base b;Derive d;Base* p3 = &b;Derive* p4 = &d;printf("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);printf("普通函数地址:%p\n", &Base::func5);return 0;}
以上代码输出结果如下所示:
通过以上的输出结果就可以看出虚函数表的地址和常量区的数据地址相近,这就可以得出虚函数表的指针在VS中是放在常量区
以上就是本篇的本篇的全部内容了,希望能得到你的点赞和收藏