多态
简介: 面向对象的三大特性之一,多态顾名思义即具有多种形态,即去执行某个行为时,当不同的对象去执行时会产生不同的状态
构成多态的条件
条件一
必须通过基类(父类)的指针或者引用调用虚函数(函数被virtual所修饰)
tips:父类的指针或引用要指向或引用子类对象
virtual void test(){}
条件二
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
tips:破坏任意一个条件都会导致无法构成多态
- 虚函数的重写是接口继承(普通函数的重写是实现继承)
ps:普通函数的重写是外壳不同,将函数体(实现)继承下来 - 子类中的虚函数只是对父类的接口的一个声明(声明必须保持一致,故函数名,形参以及返回值都相同),重写只是将父类函数的“外壳”拿了下来,然后再在这个“外壳”内填充函数体(重写的是实现)
满足上述两个条件即构成多态:即通过基类(父类)的指针或者引用调用虚函数
子类没重写也会进行运行时决议(多态),但是由于没有进行覆盖,仍旧调用的是父类的虚函数
虚函数重写/覆盖的条件
虚函数+三同(函数名,形参以及返回值都相同),不符合重写的条件即构成隐藏
-
tips1:子类重写虚函数时,是否添加virtual修饰对多态不影响
-
tips2:重写的协变,协变即返回值可以不同,但要求父子函数的返回值必须分别是父子关系的指针或者引用
如下述两种方式都是可以构成多态(此种用途并不多,了解即可)class Person { public://virtual void BuyTicket(char) { cout << "买票-全价" << endl; }/*virtual Person* BuyTicket(int) { cout << "买票-全价" << endl;return this;}*///假设A是B的父类,即下述也构成多态virtual A* BuyTicket(int){cout << "买票-全价" << endl;return nullptr;} };class Student : public Person { public:// 虚函数重写/覆盖条件 : 虚函数 + 三同(函数名、参数、返回值)// 不符合重写,就是隐藏关系// 特例1:子类虚函数不加virtual,依旧构成重写 (实际最好加上)// 特例2:重写的协变。返回值可以不同,要求必须时父子关系的的指针或者引用/*virtual Student* BuyTicket(int){cout << "买票-半价" << endl;return this;}*/virtual B* BuyTicket(int){ cout << "买票-半价" << endl;return nullptr;} };
构成多态的原理
虚表(虚函数表)
-
虚表内存储着所有的虚函数(函数地址),虚表本质是一个数组,数组内存着的都为函数指针
- 父类对象和子类对象里各自都有各自的虚表
子类对象的虚表是拷贝父类对象得来 - 当子类重写虚函数时,则修改了子类自己的虚表对应的函数(覆盖成子类重写后的函数)
- 父类对象和子类对象里各自都有各自的虚表
虚表指针
当类内存在了virtual修饰的虚函数,则该类内会默认生成一个虚表指针(__vfptr)指向一张虚表
虚表指针在vs环境下,默认是在对象的头4个字节或者头8个字节(可以通过取出该字节的内容所指向的地址来打印虚表)
ps: 虚函数表是编译时即生成的,在构造函数中进行初始化虚表指针,对象中存储的为虚表指针,虚表存储位置大致在常量区(编译阶段即生成好了)
-
tips:可以按下述方式尝试打印虚表内的内容
class Person { public:virtual void BuyTicket() { cout << "Person::买票-全价" << endl;}virtual void Func1(){cout << "Person::Func1()" << endl;} };class Student : public Person { public:virtual void BuyTicket() { cout << "Student::买票-半价" << endl;}virtual void Func2(){cout << "Student::Func2()" << endl;} };typedef void(*VFPTR)();//void PrintVFTable(VFPTR table[]) //void PrintVFTable(VFPTR* table, size_t n) void PrintVFTable(VFPTR* table) {//vs下,虚表末尾会加上空指针作为标识for (size_t i = 0; table[i] != nullptr; ++i)//for (size_t i = 0; i < n; ++i){printf("vft[%d]:%p->", i, table[i]);//table[i]();VFPTR pf = table[i];pf();}cout << endl; } int main() {// 同一个类型的对象共用一个虚表Person p1;Person p2;// vs下 不管是否完成重写,子类虚表跟父类虚表都不是同一个Student s1;Student s2;//取到对象头四个字节的虚表指针中的函数地址,再强转成函数指针(因为本身就是函数地址)PrintVFTable((VFPTR*)*(int*)&s1);PrintVFTable((VFPTR*)*(int*)&p1); }
普通多继承下的情况
- 多继承中,子类新增的虚函数会被放到多继承下来的第一个对象的虚表里
多继承的对象有几个,则子类中有多少个虚表(从父类继承得来)- 多继承下,子类自身的虚函数会被放到多继承第一个对象的虚表中
- 多继承下,不同的父类指针指向子类对象并调用虚函数时,底层实现略有不同,不过到底也是相同的
因为调用函数时,本质也要传入指向对象的地址,继承的两个基类所在的地址不同,故此底层跳转步骤不尽相同- 如果是第一个继承的对象,则是直接进行call函数地址然后jump到函数实现
- 如果是第二个继承的对象,则会先call指令,然后会先偏移到子类对象的首地址处,再进行jump
菱形继承下的情况
-
最开始菱形继承中,如果菱形继承的两个父类没有额外的虚函数,则是共用基类的虚表(通过虚基表指向)
-
如果菱形继承下,两个父类还有额外的虚函数,则父类其还会拥有自己的虚表指针(指向虚表)
-
虚基表:虚继承中产生的虚基类表(解决数据冗余和二义性)
- 虚基表的记录的内容其一是当前派生类的虚基表与其虚表的偏移量(如果不存在额外的虚表则偏移量为0)
为了让派生类能够找到其虚表的位置 - 其二是记录虚基类与其派生类在当前对象模型中的偏移地址
- 虚基表的记录的内容其一是当前派生类的虚基表与其虚表的偏移量(如果不存在额外的虚表则偏移量为0)
-
只要是虚函数,函数地址都会放入虚表中,无论是否被重写,子类的虚表中既有父类的虚函数,也有子类的虚函数
tips:同一个类型的对象共用一个虚表,(vs环境)子类和父类的虚表不管是否完成重写,二者虚表都不是同一个
总结
多态的本质即当符合多态的两个条件,调用时则会到指向对象的虚表中找到对应的函数地址进行调用
故此多态的调用时运行时才通过虚表确定了函数的地址,编译时并不知道会自身会指向父类还是子类的对象,运行到了才会到实际指向对应对象的虚表内找到函数地址再进行调用
(普通函数的调用是调用call指令,在编译链接时确定了函数的地址(声明+定义),运行时直接调用)
析构函数的重写
父类的析构函数在继承中建议添加virtual修饰,完成虚函数的重写
class Person{public:virtual ~Person(){cout << "~Person()" << endl;}
}
class Student{public:virtual ~Student(){cout << "~Student()" << endl;}
}
int main(){Person* ptr1 = new Person();delete ptr1;//如果不构成多态,则是什么类型即调用什么类型的析构函数,不符合预期Person* ptr2 = new Student();delete ptr2;//构成多态则指向父类调用父类析构,指向子类调用子类析构,子类析构后再自动调用父类的析构函数
}
- 编译器生成的析构函数的作用(同上)
自己调用自己的析构,父类对象去调用父类的析构- 由于多态的需要,析构函数的名字会被统一处理成destructor()
所以析构函数也会与父类构成隐藏 - 由于语法与编译器要求,构造时需要先构造父类,再构成子类,析构则需要保证先析构子类,再析构父类,所以自定义析构函数时不需要显式调用父类的析构,编译器会在析构完子类后自动调用父类的析构
- 由于多态的需要,析构函数的名字会被统一处理成destructor()
override和final关键字
- 当一个虚函数不想被重写,则使用final进行修饰(使用场景极少)
- override用于修饰子类的虚函数,其对子类的虚函数是否重写进行了强制性语法检查
重载、覆盖(重写)、隐藏(重定义)的对比
- 重载
- 两个函数在同一作用域
- 函数名相同,参数不同(个数,类型,顺序)
- 重写(覆盖)
- 两个函数分别在基类和派生类的作用域
- 函数名,参数,返回值都必须相同(协变属于例外)
- 两个函数必须都是虚函数(用virtual修饰)
- 重定义(隐藏)
- 两个函数分别在基类和派生类的作用域
- 函数名相同
- 基类与派生类的同名函数不构成重写即为重定义(隐藏)
抽象类
在虚函数后面写上=0,则这个函数为纯虚函数,包含这个纯虚函数的类即为抽象类(也被成为接口类)
- 抽象类基类是无法实例化出对象的
- 子类继承了纯虚类后,子类必须得进行虚函数的重写,否则也无法实例化对象