【C++进阶】多态:虚函数、重写/覆盖、纯虚函数与虚函数表底层原理
文章目录1.多态的概念2.多态的定义及实现2.1 多态的构成条件2.1.1 实现多态的两个必须重要条件2.1.2 虚函数2.1.3 虚函数的重写/覆盖2.1.4 多台场景下的一道选择题2.1.5 虚函数重写的一些其他问题2.1.6 override 和 final 关键字2.1.7 重载/重写/隐藏的对比3.纯虚函数和抽象类4.多态的原理4.1 虚函数表指针4.2 多态的原理4.2.1 多态是如何实现的4.2.2 静态绑定与动态绑定4.2.3 虚函数表1.多态的概念多态Polymorphism的概念多种形态。多态分为编译时多态静态多态和运行时多态动态多态。编译时多态静态多态主要是函数重载和函数模板通过传不同类型的参数可以调用不同的函数通过参数不同达到多种形态。之所以叫编译时多态是因为实参传给形参的参数匹配是在编译时完成的。一般把编译时归为静态运行时归为动态。运行时多态动态多态完成某个行为函数传不同的对象就会完成不同的行为就达到多种形态。比如买票这个行为普通人是全价买票学生为优惠买票2.多态的定义及实现2.1 多态的构成条件多态是一个继承关系下的类对象调用同一个函数产生了不同的行为。比如Student继承了PersonPerson对象全价买票Student对象优惠买票2.1.1 实现多态的两个必须重要条件必须是基类的指针或者引用调用虚函数被调用的函数必须是虚函数并且完成了虚函数的重写/覆盖说明要实现多态效果第一必须是基类的指针或者引用因为只有基类的指针或者引用才能既指向基类对象又指向派生类对象第二派生类必须对基类的虚函数进行重写/覆盖重写或覆盖后基类和派生类之间才能有不同的函数多态的不同形态效果才能达到。2.1.2 虚函数类成员函数前面加 virtual 修饰那么这个成员函数被称为虚函数。注意非成员函数不能使用 virtual 修饰classPerson{public:virtualvoidBuyTicket(){cout买票全价endl;}};基类的 virtual 不能省略派生类的虚函数可以不加 virtual但不规范2.1.3 虚函数的重写/覆盖虚函数的重写/覆盖派生类中有一个跟基类完全相同的虚函数即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同称派生类的虚函数重写了基类的虚函数注意在重写基类虚函数时派生类的虚函数在不加 virtual 关键字时虽然也可以构成重写因为基类的虚函数被继承后在派生类依旧保持虚函数属性但这种写法不规范。#includeiostreamusingnamespacestd;classPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout买票-打折endl;}};voidFunc(Person*ptr){// 这里可以看到虽然都是 Person 指针 Ptr 在调用 BuyTicket// 但是跟 ptr 没关系而是由 ptr 指向的对象决定的ptr-BuyTicket();}// 也可以使用引用//void Func(Person people)//{// people.BuyTicket();//}intmain(){Person ps;Student st;Func(ps);Func(st);return0;}2.1.4 多台场景下的一道选择题以下程序的输出结果是什么A: A-0 B: B-1 C: A-1 D: B-0 E: 编译出错 F: 以上都不正确#includeiostreamclassA{public:virtualvoidfunc(intval1){std::coutA-valstd::endl;}virtualvoidtest(){func();};};classB:publicA{public:voidfunc(intval0){std::coutB-valstd::endl;}};intmain(){B*pnewB;p-test();return0;}答案B因为在多态调用时重写的虚函数是父类的声明部分子类的实现部分本质是重写虚函数的实现如果是普通调用输出结果就是 B-02.1.5 虚函数重写的一些其他问题协变派生类重写基类虚函数时与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或引用派生类虚函数返回派生类对象的指针或引用时称为协变。可以是自己的类也可以是其他类只要满足基类和派生类的关系即可#includeiostreamusingnamespacestd;classA{};classB:publicA{};classPerson{public:// 这里的 A* 可以换成 Person*// 子类中 BuyTicket 的返回值可以换成 Student*virtualA*BuyTicket(){cout买票-全价endl;returnnullptr;}};classStudent:publicPerson{public:virtualB*BuyTicket(){cout买票-打折endl;returnnullptr;}};voidFunc(Person*ptr){ptr-BuyTicket();}intmain(){Person ps;Student st;Func(ps);Func(st);return0;}析构函数的重写基类的析构函数为虚函数此时派生类的虚函数只要定义无论是否加 virtual 关键字都与基类的析构函数构成重写。虽然基类和派生类析构函数的名字不同看起来不符合重写的规则实际上编译器对析构函数的名称做了特殊处理编译后析构函数的名称统一成 destructor所以基类的析构函数加了 virtual 修饰派生类的析构函数就构成重写。编译器会将析构函数的名称统一成 destructor但不构成隐藏隐藏编译时决定只看源代码中的函数名析构函数源代码中函数名不同所以不构成隐藏重写编译时生成虚函数表运行时通过虚函数表调用对应函数下面的代码可以看到如果 ~A() 不加 virtual 那么 delete p2 时只调用 A 的析构函数没有调用 B 的析构函数就会导致内存泄露问题因为 B 中在释放资源。#includeiostreamusingnamespacestd;classA{public:virtual~A()//~A(){cout~A()endl;}};classB:publicA{public:~B(){cout~B()-delete:_pendl;}protected:int*_pnewint[10];};intmain(){A*p1newA;A*p2newB;deletep1;deletep2;return0;}基类的析构函数加 virtual 时基类的析构函数不加 virtual 时可以看出基类的析构函数如果不加 virtualB中的资源就无法释放就会造成内存泄露所以基类中的析构函数建议设计为虚函数2.1.6 override 和 final 关键字C对虚函数重写的要求比较严格但有些情况下由于疏忽比如函数名写错或参数写错等导致无法构成重写而这种错误在编译期间是不会报出的只有在程序运行时没有得到预期结果时才来 debug 会得不偿失因此 C11 提供了override可以帮助用户检测是否重写。如果不想派生类重写这个虚函数可以用 final 修饰。在三层继承时中间某层需要继承而其子类不需要可以使用final 。基类/中间派生类的虚函数用 final 修饰后后续所有派生类都不能再重写该虚函数// “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法classcar{public:// 这里的函数名为 Dirve与派生类的函数名不同virtualvoidDirve(){}};classBenz:publiccar{public:virtualvoidDrive()override{coutBenzendl;}};intmain(){return0;}2.1.7 重载/重写/隐藏的对比重载/重写/隐藏都是函数名相同的函数之间的关系3.纯虚函数和抽象类在虚函数后面写上 0则这个函数为纯虚函数纯虚函数不需要定义实现可以定义但没必要只要声明即可。包含纯虚函数的类叫做抽象类抽象类不能实例化出对象如果派生类继承后不重写纯虚函数那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数因为不重写无法实例化出对象。#includeiostreamusingnamespacestd;// Car 包含纯虚函数是抽象类classCar{public:virtualvoidDrive()0;};classBenz:publicCar{public:virtualvoidDrive(){coutBenzendl;}};classBMW:publicCar{public:virtualvoidDrive(){coutBMWendl;}};intmain(){//编译报错“Car”: 无法实例化抽象类//Car car;// 父类的指针指向子类调用子类的虚函数Car*pBenznewBenz;pBenz-Drive();Car*pBMWnewBMW;pBMW-Drive();return0;}4.多态的原理4.1 虚函数表指针下面编译为32位程序的运行结果是什么A. 编译报错 B.运行报错 C. 8 D. 12#includeiostreamusingnamespacestd;classBase{public:virtualvoidfunc1(){coutfunc1()endl;}protected:int_b1;char_chx;};intmain(){Base b;coutsizeof(b)endl;return0;}运行结果上面题目的运行结果是12bytes在对象 b 中除了 _b 和 _ch 成员还多了一个 __vfptr 放在对象的前面对象中的这个指针叫做虚函数表指针简称虚表指针v 代表 virtualf 代表function。一个含有虚函数的类中至少都有一个虚函数表指针因为一个类所有虚函数的地址要被放在这个类对象的虚函数表中虚函数表也简称虚表。虚函数表是一个函数指针数组。4.2 多态的原理4.2.1 多态是如何实现的通过下图可以看到满足多态条件后底层不再是编译时通过调用对象确定函数地址而是运行时指向对应对象的虚表中确定对应的虚函数的地址这样就实现了指针或引用指向基类就调用基类的虚函数指向派生类就调用派生类的虚函数。ptr 指向 Person 对象调用的是 Person 的虚函数ptr 指向 Student 对象调用的是 Student 的虚函数#includeiostreamusingnamespacestd;classPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}private:string _name;};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout买票-打折endl;}private:string _id;};classSoldier:publicPerson{public:virtualvoidBuyTicket(){cout买票-优先endl;}private:string _codename;};voidFunc(Person*ptr){// 这里可以看到虽然都是 Person 的指针 ptr 在调用 BuyTicket// 但是和 ptr 没关系是由 ptr 的对象决定的ptr-BuyTicket();}intmain(){Person ps;Student st;Soldier sr;Func(ps);Func(st);Func(sr);return0;}总结多态的原理指向谁调用谁运行时到指向对象父类对象或切割出的子类中的父类对象的虚函数表中找到对应的虚函数的地址进行调用4.2.2 静态绑定与动态绑定对不满足多态条件指针或者引用调用虚函数的函数调用是在编译时绑定也就是编译时确定调用函数的地址叫做静态绑定满足多态条件的函数调用是在运行时绑定也就是在运行时指向对象的虚函数表中找到调用函数的地址叫做动态绑定4.2.3 虚函数表基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用一张虚表不同类型的对象各自有独立的虚表所以基类和派生类有各自独立的虚表。同类型对象虚表共用不同类型对象虚表各自独立派生类由两部分组成继承下来的基类和自己的成员。一般情况下继承下来的基类中有虚函数表指针自己就不会再生成虚函数表指针。但是要注意这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个就像基类对象的成员和派生类对象中的基类成员也是独立的派生类中重写的基类的虚函数派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址派生类的虚函数表中包含1基类的虚函数地址2派生类重写的虚函数地址完成覆盖3派生类自己的虚函数地址 三个部分虚函数本质是一个存虚函数指针的指针数组一般情况这个数组后面放了一个 0x00000000标记在 VS 中虚函数和普通函数一样编译好后是一段指令都是存在代码段的。只是虚函数的地址又存放在了虚表中虚函数表的存储地址C标准没有规定。在 VS 中虚函数表是存放在代码段常量区的。子类的虚表先拷贝父类的虚表子类重写虚函数就覆盖虚表中对应虚函数的地址子类新增的虚函数就添加到虚函数表的末尾