🔥个人主页:Forcible Bug Maker
🔥专栏:C++
目录
🌈前言
本篇博客主要内容:面向对象三大特性之一——多态。
面向对象(OO)有三大特性:封装,继承和多态。今天我们将介绍最后一个——多态。
🔥多态的概念
C++中的多态(Polymorphism) 是一种面向对象编程的特性,它允许不同的对象对同一消息作出响应,即允许一个接口(基类的指针或引用)在多种数据类型(派生类)上执行相同的操作。
多态通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
举个例子:普通人,学生和军人去买票。普通人买到的票是全价;学生是半价;而军人是优先买票。这里面体现的就是多态,面对不同的对象,产生不同的形态。
🔥多态的定义及实现
构成多态的条件
多态是在不同继承关系的类对象,去调用同一函数,产生不同的行。如Student继承Person。Person对象买票全价,Student买票半价。
在继承中构成多态有两个条件:
- 必须通过基类的指针或者引用调用虚函数(拷贝不可)。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
虚函数及其重写
虚函数:即被virtual修饰的类成员函数称为虚函数。
虚函数的重写(也叫做覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型
、函数名字
、参数列表
完全相同),称子类的虚函数重写了基类的虚函数。
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } /*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因 为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议 这样使用*/ /*void BuyTicket() { cout << "买票-半价" << endl; }*/ };
其中成员函数BuyTicket
就是虚函数*。而Student类中的BuyTicket
就是对父类BuyTicket
的重写,即为虚函数的重写。
虚函数重写的两个特例:
1. 协变(基类与派生类返回值类型的不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
注:只能是基类和派生类的指针和引用,返回拷贝无法构成协变。
class A {}; class B : public A {}; class Person { public: virtual A* f() { return new A; } }; class Student : public Person { public: virtual B* f() { return new B; } };
2. 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,如果不加virtual关键字,会与基类的析构函数构成覆盖关系,虽然基类与派生类析构函数名字不同,看起来违背了规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
。所以当将析构函数前加virtual定义成虚函数时,就可以使基类和派生类之间的析构函数可以构成重写。
class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual ~Student() { cout << "~Student()" << endl; } }; // 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函 // 数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。 int main() { Person* p1 = new Person; Person* p2 = new Student; delete p1; delete p2; return 0; };
将析构函数进行重写非常有意义,可以避免使用基类指针调用子类析构时导致的析构错误。所以在继承当中,非常建议将析构函数设计成虚函数。
C++11 override和final
经过上面的讲解可以看出,C++对重写的要求比较严格,但有些情况下难免因为疏忽,导致函数写错导致无法构成重载,而这种错误在编译期间并不会报错,在没有得到预期结果才来Debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
1. final:修饰虚函数,表示该虚函数不能再被重写。
class Car { public: virtual void Drive() final {} }; class Benz :public Car { public: virtual void Drive() // 此处会报错,无法重写 { cout << "Benz-舒适" << endl; } };
2. override:检查派生类虚函数是否重写了基类某个函数,如果没有重写编译报错。
class Car { public: virtual void Drive() {} }; class Benz :public Car { public: virtual void Drive() override { cout << "Benz-舒适" << endl; } };
重载,覆盖(重写),隐藏(重定义)间的对比
🔥抽象类
概念
在虚函数的后面写上 =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; } }; void Test() { Car* pBenz = new Benz; pBenz->Drive(); Car* pBMW = new BMW; pBMW->Drive(); }
上述代码中,由于Car
是纯虚函数,所以无法实例化出对象。但是可以作为指针和引用来接收Benz
和BMW
类的对象,也可以通过切片出的Car类型指针来调用重写的Drive方法。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
🔥多态的原理
虚函数表
// 一道常考的笔试题:sizeof(A)是多少? class A { public: virtual void Func1() { cout << "Func1()" << endl; } private: long long _b1 = 1; }; int main() { A a; cout << sizeof(a) << endl; return 0; }
a对象是16bytes,除了b1成员(8bytes),还多一个_vfptr放在对象的前面(有些平台可能会放到后面),对象中的这个指针我们叫虚函数表指针(v代表virtual,f代表function)。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
那如果重写多个虚函数呢?
// 1.我们实现一个派生类Derive去继承Base // 2.Derive中重写Func1 // 3.Base再增加一个虚函数Func2和一个普通函数Func3 class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base b; Derive d; return 0; }
我们通过虚函数表中的地址可以发现,Func1已经被成功重写;两张虚函数表中Func2未被重写,所以地址是相同的;Func3虽然被派生类继承,但是由于不是虚函数,所以没有出现在虚函数表中。
多态的原理
分析了这么多,其实多态的实现就是再基类和派生类中添加指向虚表的虚表指针(指向函数指针数组的指针),通过指向的虚表访问相应的函数。
如图:
图中简化了从对象跳转到实际函数中的过程,从虚表到函数的过程并不是直接的,而是需要经过几次跳转最终才能得到。
由于基类和派生类之间的虚表指针不同,所以经过基类指针或引用切片访问的也依然是通过相同虚表指针访问的虚表,所以找到的也是派生类重写的函数。
注:有几个父类,如父类有虚函数,则会有几张虚表,自身子类不会产生多余的虚表,子类增加的虚函数放在第一张虚表最后。虚表在编译阶段生成。同一类型的虚表是共享的。
虚函数和虚函数表存储在常量区(代码段)当中,而虚函数表指针存储在对象中。
动态绑定和静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载,函数模板。
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
🌈结语
本篇博客的内容到这里基本上就结束了,不过这里只是简单介绍了多态及其基础用法,对底层的逻辑并没有足够深度和细致的分析。如有机会,博主也会出一篇来专门介绍其中的逻辑。感谢大家的支持♥