「C++」多态
目录
前言
本篇继承自「C++」继承-ZEEKLOG博客内容,继续介绍三大特性中的多态部分,而继承部分中提到的virtual关键字也将获得一个新功能,并在多态中有具有极其重要的地位。继承中所说析构函数名的特殊处理也将在本篇解释。

多态概念
多态分为编译时多态(静态多态)和运行时多态(动态多态)
编译时多态:主要指的就是我们之前学习的,函数重载、模板等内容。它们通过传递不同的参数类型,在编译时调用对应参数类型的重载函数、生成不同的模板函数,从而达到不同的多种形态。叫编译时多态,是因为这其中的实参与形参参数类型的匹配是在编译时就完成的。而编译时一般就归为静态,运行时归为动态;
运行时多态:主要指的就是在程序运行过程中,对于同一个行为(函数),对于不同类型的对象有不同的结果,以此达成多态。就比如买票这个行为,学生买票是学生票,普通人买票是正常的全价票。动物都可以发出叫声,对于猫会“喵喵”,对于狗会“旺旺”。这都是一种多态,这种多态是一种类之间的关系。
多态的定义及实现
情景导入
两个类Person和Student买票,当我们只有继承关系时:
class Person { public: void BuyTicket() { cout << "普通人:全价票" << endl; } }; class Student : public Person { public: void BuyTicket() { cout << "学生:半价票" << endl; } }; int main() { Person().BuyTicket(); Student().BuyTicket(); return 0; }可见,必须通过指定的类型对象来调用成员函数BuyTicket,此时的两个BuyTicket构成的时隐藏。
而当我们使用多态时:
class Person { public: virtual void BuyTicket() { cout << "普通人:全价票" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "学生:半价票" << endl; } }; void BuyTicket(Person& per) { per.BuyTicket(); } int main() { Person p; Student s; BuyTicket(p); BuyTicket(s); return 0; }实现一个外部的BuyTicket函数,直接将对象传给他,使用者无需考虑自己该调用哪个对象的哪个方法,多态直接就解决了这个问题。
此时p对象传过去就是调用的Person类域中的BuyTicket成员;而s对象传过去就是调用的Student类域中的BuyTicket成员。
多态构成条件
- 多态是在继承关系的条件下的一种类间关系,即多态的前提是继承;
- 必须是基类的指针或者引用来调用虚函数;
- 必须是虚函数成员,并且派生类中完成了虚函数重写/覆盖;
说明:1)要实现多态的效果,调用虚函数的,必须是一个基类的指针或者引用,因为多态实际上是利用了继承中提到的基类和派生类间的转换,而只有基类的指针或者引用才允许指向派生类,反之不允许。2)派生类函数必须对基类的虚函数完成重写/覆盖,不然达不到多态的效果(调用效果和基类一样)。
虚函数及重写/覆盖
虚函数:
在类成员函数的前面加上virtual关键字来修饰,就称为虚函数。非成员函数不能加virtual。注意和多继承中解决菱形继承问题的关键字是同一个,但意义不同。
虚函数重写/覆盖:
派生类中有一个和基类完全相同的虚函数(返回值、函数名、参数列表)时,就构成了派生类对基类虚函数的重写/覆盖。
注意点:派生类重写基类虚函数可以不加virtual关键字(因为这个函数在基类中已经是一个虚函数,继承后也是),但为了规范一般都会写出来。
class Person { public: virtual void BuyTicket() { cout << "普通人:全价票" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "学生:半价票" << endl; } }; class Animal { public: virtual void Cry() {} }; class Cat : public Animal { public: virtual void Cry() { cout << "猫:喵喵" << endl; } }; class Dog : public Animal { public: virtual void Cry() { cout << "狗:旺旺" << endl; } }; void BuyTicket(Person& per) { per.BuyTicket(); } void Cry(Animal& anim) { anim.Cry(); } int main() { Person p; Student s; BuyTicket(p); BuyTicket(s); Cat c; Dog d; Cry(c); Cry(d); return 0; }重写其他注意点
协变(了解)
派生类重写基类的虚函数时,两者返回值不同,基类返回基类的指针或者引用,派生类返回派生类的指针或者引用,这时称为协变。协变的实际意义不大,所以此处做了解即可。
class A { public: virtual A* Func() { cout << "A::Func()" << endl; return this; } }; class B : public A { public: virtual B* Func() { cout << "B::Func()" << endl; return this; } }; A* Func(A& a) { return a.Func(); } int main() { Func(a); Func(b); return 0; }协变也是多态的一种,也构成多态,只是不同对象的返回值类型不同
析构函数的虚函数重写
当基类析构函数是虚函数时,不论派生类析构函数是否添加virtual关键字修饰,都会构成析构函数的重写:
1)在继承部分我们就提到过,析构函数的函数名会经过特殊处理,在编译器看来析构函数名都是destructor,主要原因就是为了兼容此处多态的使用。
2)上文提到过,基类只要是virtual虚函数,派生类中也一定是虚函数。
class A { public: virtual ~A() { cout << "~A()" << endl; } }; class B : public A { public: //构成重写的原因: //析构函数名经过特殊处理为destructor,所以在编译器看来基类与派生类的析构函数名是完全相同的; //只要基类是虚函数,则派生类中相同函数也一定是虚函数。 ~B() { cout << "~B()" << endl; } protected: int* _p = new int[10]; }; int main() { A* pa = new A; A* pb = new B; delete pa; delete pb; }上述代码情景中,若A类和B类的析构没有构成多态,将导致delete对于pb的释放错误,原因为:
- delete会自动调用类的析构函数,若此处没有多态,则pb是一个A类型的指针,会跑去调用A类的析构,但显然此处pb指向B类型对象,从而导致析构函数调用的错误,此处代码就会导致内存泄漏;
- delete的使用实际上就可以类比为一个叫detele函数,其原本的操作数就是传给它的参数,当上述代码A类和B类析构构成多态时,就可以像上文中介绍多态构成时的示例中一样,pa、pb都是基类的指针,但由于析构构成了多态,所以delete指针pa时就会自动匹配到A类的析构,delete指针pb时就自动匹配到B类的析构。


可见构成多态时,delete就会调用到B类的析构函数,而派生类的析构结束时又会自动调用基类的析构,从而完成一套完整正确的析构流程。
override和final关键字
override:从重写的构成条件就可以看出,C++中对于虚函数的重写要求比较严格。而对于重写的一些错误,比如函数名参数书写错误,这些错误编译时是不会被检测出来的,这将会称为一种隐患。而override关键字就可以帮助我们检测重写是否成功。

此处B类中的Func函数,函数名实际上是错误的,没有构成多态,但编译器并不能分辨出这种错误(因为这完全有可能是要作为B的派生类来用的一个虚函数),所以在我们重写时加上override就可以帮我们防范这种不小心的错误。

final: final则和继承中类似,继承中final可以防止类被继承,而此处就是防止虚函数被派生类重写

重载/隐藏/重写的对比
相同点:都需满足函数名相同
区别:
- 作用域的区别:1)重载是两函数在同一作用域中的函数关系;2)重写和隐藏是两函数在不同作用域中(基类类域和派生类类域)的函数关系
- 返回值、函数名、参数列表的区别:1)重载要求函数名相同,参数列表不同,与返回值无关;2)隐藏只要求函数名相同;3)重写要求返回值、函数名、参数列表都完全相同(协变返回值不同)
- 使用场景:1)重载可以是在任意一个作用域中的两个函数构成;2)隐藏是继承体系下的一种关系;3)重写是多态场景下的一种函数关系,而多态基于继承
- 特殊:1)隐藏也可以是基类与派生类之间同名成员变量的一种关系,不止是函数关系

纯虚函数和抽象类
在虚函数后加上=0,则这个函数为纯虚函数,纯虚函数只需要声明即可,无需定义实现(实现了也会被重写掉)。
而包含了纯虚函数的类就叫抽象类,抽象类不能实例化出对象,派生类继承抽象类时,如果不重写纯虚函数,那这个派生类也会成为抽象类。
//抽象类Animal class Animal { virtual void sound() = 0;//纯虚函数 }; class Cat : public Animal { virtual void sound() override { cout << "cat:喵喵" << endl; } }; class Dog : public Animal { virtual void sound() override { cout << "dog:汪汪" << endl; } };就像现实世界中的"动物"是一个抽象的概念一样,抽象类就是一个用以表示抽象概念的类,就像上述代码一样,Animal用来表示一个为动物的抽象概念,而属于动物分支的其他类就区继承Animal,这样就可以很好的模拟现实世界中的一些关系。
多态的原理
虚表指针
题目引入:
//下⾯编译为32位程序的运⾏结果:A. 编译报错 B. 运⾏报错 C. 8 D. 12 class Base { public : virtual void Func1() { cout << "Func1()" << endl; } protected: int _a = 1; char _ch = 'x'; }; int main() { Base b; cout << sizeof(b) << endl; }上述答案为12;而我们知道,对于一个自定类型,其类型大小取决于成员变量,再结合内存对齐的规则得出。此处如果只看成员变量,那么大小应该为4+1=5,对齐到大小为8;而答案多出的4个字节,就是虚表指针。
虚表指针就是指向一个存储虚函数地址的指针数组的数组指针,存储在对象的最前面(也有可能在最后,和平台有关),而这个虚表指针全称为虚函数表指针_vfptr(v代表virtual,f代表function),而虚函数可能不止一个,所以将虚函数的所有地址组成一个数组存入对象中。

多态的原理
由上述的虚表指针就引出了多态的原理,多态的实现就是基于类中的虚表指针,如果没有虚表指针就不能实现多态的调用。
我们以Person、Student、Soldier三个类为示例:
class Person { public: virtual void BuyTicket() { cout << "普通人买票-全价" << endl; } private: string _name;//姓名 }; class Student : public Person { public: virtual void BuyTicket() { cout << "学生买票-打折" << endl; } private: string _id;//学号 }; class Soldier : public Person { public: virtual void BuyTicket() { cout << "军人买票-优先" << endl; } private: string _codename;//代号 }; void BuyTicket(Person& p) { p.BuyTicket(); } int main() { Person ps; Student st; Soldier sr; BuyTicket(ps); BuyTicket(st); BuyTicket(sr); }上述代码通过调试的监视窗口观察:


可见Student和Solider类的对象中,包含了来自Person的部分,这是在继承中我们就知道的。
而仔细观察我们可以发现,对于同样继承自Person的_vfptr虚表指针:
- 虚表指针的内容不同:说明三个对象的虚表各不相同各自独立;
- 虚表中的虚函数地址不同:说明三个虚表中存储的都是各自的虚函数(就像基类对象和派生类对象之间就算是继承的同名的属性,那也是基类对象中有一份,派生类对象中有一份)。
这样,当基类的指针或者引用指向派生类对象时,就可以找到派生类对象中的虚表指针,而派生类的虚表指针存储的是派生类虚表的地址,以此进一步找到其中同名的虚函数地址,而这个虚表中的虚函数地址就是派生类重写后的虚函数地址,以此达到调用派生类虚函数的目的。
由此实现了多态的效果:基类的指针或者引用指向基类就到基类中去找到对应虚函数,指向派生类就到对应的派生类中去找到对应的虚函数。
动态绑定与静态绑定
静态绑定: 对于不满足多态条件(基类指针引用+调用虚函数)的函数调用是在编译时绑定的,就是在编译时就确定了调用函数的地址,叫做静态绑定;
//不构成多态,BuyTicket不是虚函数时 class Person { public: void BuyTicket() { cout << "普通人买票-全价" << endl; } private: string _name = "aaa";//姓名 }; class Student : public Person { public: void BuyTicket() { cout << "学生买票-打折" << endl; } private: string _id;//学号 }; int main() { Person p; Student s; Person& pp = p; Person& ps = s; pp.BuyTicket(); ps.BuyTicket(); return 0; }
动态绑定: 对于满足多态的函数调用场景则是在运行时调用的,也就是在运行过程中,对象调用虚函数时,编译器在对象的虚表中去找到要调用的函数地址,这叫做动态绑定。
class Person { public: virtual void BuyTicket() { cout << "普通人买票-全价" << endl; } private: string _name = "aaa";//姓名 }; class Student : public Person { public: virtual void BuyTicket() { cout << "学生买票-打折" << endl; } private: string _id;//学号 }; int main() { Person p; Student s; Person& pp = p; Person& ps = s; pp.BuyTicket(); ps.BuyTicket();//多态 return 0; }
虚函数表
在多态原理中我们简单了解了虚函数表,接下来将解释更多相关的内容:
一个类的所有虚函数都存放在同一个虚函数表中;同类型的对象共用同一个虚表(就像静态成员一样,全体同类型对象共用),不同类型的对象有各自独立的虚表,所有基类和派生类都有各自独立的虚表;当基类中有虚函数时,就会有虚表,并产生虚表指针;这时派生类就一定会继承一个虚表指针,但需要注意的是,继承下来的虚表指针和基类中的不是同一个,指向的虚表也不是同一个(类似于深拷贝,指针和指向内容都不同),就像其他继承的成员和基类也相互独立一样。派生类重写基类的虚函数后,就会将自身虚表中对应的同名虚函数地址给覆盖成重写后的派生类虚函数地址,而派生类的虚表中存储的来自基类的虚函数地址默认和基类是同一个,这样在调用时,没有重写的虚函数就会调用到基类的虚函数。派生类的虚表中包含:1.继承自基类的虚函数地址;2.派生类重写后的虚函数地址(覆盖掉原本的基类同名虚函数地址);3.派生类自己的虚函数地址。虚函数表本质是一个存储虚函数地址的指针数组,一般情况下回在数组最后放一个0x00000000标记结束位置。

后记
本篇对于多态的学习就到这里,有不足的地方请各位读者指出,我们下篇博客再见~
本期专栏:C++_海盗猫鸥的博客-ZEEKLOG博客
个人主页:海盗猫鸥-ZEEKLOG博客