【C++哲学】面向对象的三大特性之 多态
🔥拾Ծ光:个人主页
👏👏👏欢迎来到我的专栏:《C++》,《数据结构》,《C语言》
目录

一、多态的概念及实现
1、什么是多态?
多态顾名思义就是有多种形态。多态是C++面向对象编程的最重要的特性之一,多态分为:编译时多态(静态多态)和运行时多 态(动态多态),这里我们重点讲运行时多态。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过不同的参数类型来达到多种形态。而之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在 编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点讲就是去完成某个行为(函数),可以传不同的对象来完成不同的行为,就达到了多种形态。比如买票这个行为,当对象是普通人时,是全价买票;对象是学生买时,是优惠买票(5折或75折);当对象是军人时,是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<) 喵“,传狗对象过去,就是"汪汪"。
2、虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
class Person { public: // 这里在成员函数BuyTicket前面加了virtual,则BuyTicket就是虚函数 virtual void BuyTicket() { cout << "买票-全价" << endl; } };• 虚函数的重写/覆盖
虚函数的重写 (Override) - 发生在派生类,在基类中一定要有一个被virtual修饰的虚函数。
重写是实现多态的关键。它的定义是:在派生类中提供一个与基类中某个虚函数具有完全相同函数名、参数列表、返回值类型的函数。
虚函数重写的目就是为了在运行时,当通过基类指针或引用指向派生类对象时,能够调用派生类的版本,而不是基类的版本。这就是 “动态绑定”。
//---------------------Person类与Student类中的buyticket构成重写------------------------------ class Person { public: virtual void buyticket() { cout << "全价票" << endl; } }; class Student:public Person { public: virtual void buyticket() { cout << "半价票" << endl; } };还需要注意一点:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写(因为子类继承基类后,基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样 使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
//---------------------A类与B类中的func构成重写------------------------------ class A { public: virtual void func() {} }; class B:public A { public: void func() {} };• override和final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。
class Person { public: virtual void buyticket() {} }; class Student:public Person { public: virtual void buyticket()override {} };

如果我们不想让 派生类重写这个虚函数,那么可以用final去修饰。

3、多态的实现⭐️
• 多态实现的条件
💦必须是基类的指针或者引用调用虚函数
💦被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
说明:第一,只有基类的指针或者引用才能同时指向基类和派生类的对象;第二,派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖之后,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
//-------------------------------------基类---------------------------------------------- class Person { public: virtual void buyticket() { cout << "全价票" << endl; } }; //-------------------------------------派生类---------------------------------------------- class Student :public Person { public: virtual void buyticket() override { cout << "半价票" << endl; } }; void Func(Person* ptr) //基类的指针来接收参数 { ptr->buyticket(); // 用基类的指针调用虚函数 } int main() { Person p; // 基类对象 Student s; // 派生类对象 Func(&p); // 将基类对象作为实参,则调用基类的虚函数 Func(&s); // 将派生类对象作为实参,则调用派生类的虚函数 return 0; }

• 多态场景下的一个经典面试题💥
以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
答案:B
我们也许都会对答案感到不可思议,这不就应该从A或者D中选一个吗?为什么最后的答案是B呢,下面我们来分析:
首先题目中创建了一个指向B类类型对象的指针p,通过指针p来调用test函数,由于test函数是基类A的成员函数,所以隐含的参数this指针是A*类型的(即基类的指针),同时基类与子类中func函数构成虚函数的重写,虽然,派生类的func函数并没有加virtual关键字,但是由于继承的原因,任然构成重写,所以满足多态的条件,此时,就会根据真正的实参的类型来决定调用哪个虚函数(动态绑定),而指针p是派生类类型的,所以,就会调用派生类B中 的func函数,这时候我们肯定就会毫不犹豫的选择D选项,相信大多数人都能分析到这一步,
但是,最终的答案确选B,这就是这个题经典的原因之一,也是最坑的地方之一 。因为还有一点就是:
• 默认参数是静态绑定的(编译时根据调用者的类决定):test()属于基类A,因此func()的默认参数val会使用A中声明的val = 1(而非B中声明的val = 0)。
也可以理解为(形象),虚函数重写后,派生类中的虚函数其实是由基类虚函数的函数名和参数列表与派生类的虚函数的实现部分组成的。
所以,选择B:B -> 1

4、虚函数重写的特殊场景
• 析构函数的重写⭐️
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析 构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析 构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了 vialtual修饰,派生类的析构函数就构成重写。
💬那么问题就来了,为什么要这样设计呢?这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
当一个基类的指针 ptr 指向派生类对象时,如果基类的析构函数不实现为虚函数,那么当delete ptr释放该对象时,由于静态绑定(ptr是什么类类型的指针就调用哪个类的析构函数),就只调用基类的析构函数,如果派生类对象中有额外的资源,这时候就会导致内存泄漏。所以,C++中只要基类的析构函数实现为虚函数,无论派生类析构函数加不加virtual关键字,都会与基类的析构函数构成重写,即此时满足多态,编译器会根据ptr指针真正指向的类类型对象来调用相应的析构函数(动态绑定),而在前面的继承中已经讲到:子类的析构函数会自动调用基类的析构函数,所以,此时即使基类中有资源,也会被释放。
//-------------------------------------基类--------------------------------------------- class A { public: virtual ~A() // 虚函数 {} protected: int _a; }; //-------------------------------------子类--------------------------------------------- class B :public A { public: ~B() { delete[]_b; _b = nullptr; } protected: int* _b = new int[10]; // 派生类中有额外的资源 }; // 用一个基类的指针指向派生类对象 int main() { A* a1 = new B; delete a1; return 0; }可视化分析:

• 重载/重写/隐藏的区别
💥这个问题面试中也有可能会考到:

• 协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class Person { public: virtual Person* BuyTicket() // 返回值为基类的指针 { cout << "买票-全价" << endl; return nullptr; } }; class Student : public Person { public: virtual Student* BuyTicket() // 返回值为派生类的指针 { cout << "买票-打折" << endl; return nullptr; } }; void Func(Person* ptr) { ptr->BuyTicket(); } int main() { Person ps; Student st; Func(&ps); Func(&st); return 0; }
5、纯虚函数和抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现,只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了 派生类重写虚函数,因为不重写实例化不出对象。
//-------------------------------------基类(抽象类)------------------------------------- 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; } 
二、多态的原理‼️
1、虚函数表指针
下面编译为32位程序的运行结果是什么()
A. 编译报错 B. 运行报错 C. 8 D. 12
答案:D
看到这个题,我们肯定就会选C,因为一个int占4个字节,一个char占1个字节,然后对齐到4的整数倍,不就是8吗。但是,在b对象中不仅仅放了_b和_ch成员,还有一个_vfptr的指针在这两个成员的前面,我们知道在32位机器下一个指针占4个字节,所以最后b对象的大小就是12个字节。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
2、虚函数表
• 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
class A { public: virtual void func() {} }; class B :public A { public: virtual void func() {} }; class C :public A { public: virtual void func() {} }; int main() { A a1; // 基类 A a2; // 基类 B b1; // 派生类 B b2; // 派生类 C c; // 派生类 return 0; }
• 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表 指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,因为基类对象的成员和派生类对象中的基类对象成员是独立的。
• 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函 数地址。这也是为什么,当满足多态时编译器会根据真正的对象调用相应的虚函数的原因。就是因为,派生类与基类的虚函数表中放着各自独立的虚函数的指针。
• 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类 自己的虚函数地址三个部分。
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; } void func4() { cout << "Derive::func4" << endl; } protected: int b = 2; }; int main() { Base b; Derive d; return 0; } 
• 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。vs下是存在代码段(常量区)
可以看到,Person虚表地址与Student虚表地址更加接近常量区
3、多态的底层实现
满足多态条件后,底层 不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函 数。
class Person { public: virtual void buyticket() { cout << "全价票" << endl; } protected: string _name; }; class Student :public Person { public: virtual void buyticket() { cout << "半价票" << endl; } protected: int _id; }; class Soldier :public Person { public: virtual void buyticket() { cout << "优先买票" << endl; } protected: int _codename; };

4、动态绑定和静态绑定
• 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
• 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数 的地址,也就做动态绑定。
三、总结
多态是C++面向对象编程非常重要的一个特性,多态在我们处理一些具有相似特性的问题时,有着非常重要的作用,同时,在面试中也有许多的考点和细节。
