【C++ 多态】虚函数 · 虚表 · 重写,一篇彻底弄明白!
C 多态详解C多态是面向对象的核心灵魂本文将由浅入深带你循序渐进地掌握多态的方方面面全程干货坐稳发车~ ദ്ദി˶̀֊́ )✧文章目录C 多态详解1. 什么是多态2. 运行时多态的实现前提3. 虚函数与虚函数的重写3.1 虚函数3.2 虚函数的重写覆盖3.3 一个小小的选择题测一下你是否真正理解4. 重写中的特殊情况4.1 协变4.2 析构函数的重写5. override 和 final5.1 override5.2 final6. 重载/重写/隐藏对比7. 纯虚函数与抽象类8. 多态的核心原理虚函数表vtable8.1 对象中隐藏的指针__vfptr8.2 多态到底是怎么实现的8.3 深入虚函数表重要细节一览8.4 虚函数和虚表存放在内存的哪个区结语1. 什么是多态“多态”这个词字面上就是“多种形态”。现实中这种例子很多同样是“买票”这件事普通人买是全价学生可能打五折军人则享受优先服务。同样是“动物叫”猫发出“喵喵”狗发出“汪汪”。不同对象对同一个消息给出不同的响应这就是多态。在 C 中多态可以分为两类编译时多态静态多态在编译阶段就确定调用哪个函数。典型代表是函数重载和函数模板。你传一个int编译器匹配f(int)传一个double匹配f(double)。这个过程在编译时就已经搞定了。运行时多态动态多态直到程序运行时才根据实际指向的对象来决定调用哪个函数。这也是本文的重点。它依赖于继承、虚函数、基类指针或引用。我们可以通过一个简单的例子感受一下// 买票的例子classPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout买票-打折endl;}};voidFunc(Personptr){ptr.BuyTicket();// 到底调用哪个BuyTicket运行时才知道}intmain(){Person ps;Student st;Func(ps);// 输出买票-全价Func(st);// 输出买票-打折}同样一个ptr.BuyTicket()当ptr引用的是Person对象时就执行全价逻辑引用的是Student对象时就执行打折逻辑。这就是运行时多态的效果。2. 运行时多态的实现前提要触发运行时多态必须同时满足两个条件必须通过基类的指针或引用来调用虚函数。被调用的函数必须是虚函数且派生类完成了对该虚函数的重写覆盖。为什么必须是指针或引用因为只有指针或引用才能既指向基类对象又指向派生类对象。如果是普通对象值传递就会发生“对象切片”只保留基类部分永远调用的都是基类的函数。3. 虚函数与虚函数的重写3.1 虚函数在类的成员函数前加上virtual关键字这个函数就是虚函数。例如virtualvoidBuyTicket(){...}注意只有类的非静态成员函数才可以声明为虚函数全局函数、静态成员函数、构造函数都不能是虚函数。3.2 虚函数的重写覆盖重写要求派生类中提供一个与基类完全相同的虚函数返回值类型相同函数名相同参数列表完全相同参数个数、类型、顺序三者都一致派生类的这个虚函数就重写了基类的虚函数。一个细节在派生类中重写时可以省略virtual关键字因为函数从基类继承下来时已经保持虚函数属性了即使你不写virtual它依然是虚函数并构成重写。但在实际开发中强烈建议还是写上virtual可读性更好。面试选择题里偶尔会出现故意不写virtual来考察你是否理解重写的条件务必当心!3.3 一个小小的选择题测一下你是否真正理解看这段代码你认为输出是什么A: A-0 B : B-1 C : A-1 D : B-0 E : 编译出错 F : 以上都不正确classA{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重写了func没有写virtual但依然构成重写。注意重写与参数名、缺省值无关。A::func和B::func满足重写条件函数名都是 func参数类型都是 int都是虚函数A里的是virtualB里的自动继承virtual属性哪怕它们的默认值一个是1、一个是0也不影响重写关系。test是继承下来的内部调用func()。此时this指向的是B对象而test是基类的成员函数它在基类中调用了func()这里发生多态调用由于this是A*类型指向了B对象最终调用的是B::func。关键点来了 函数的默认值是在编译阶段就确定好的不是运行时动态决定的。A::test()里写的是func();编译器在编译这行代码时会根据A::func的声明把它直接替换成func(1)因为A::func的默认值是1。哪怕运行时实际调用的是B::func它拿到的参数也已经是 1 了和它自己定义的默认值 0 没有关系。所以 B::func 最终拿到的参数是 1 输出就是B-1而不是很多人误以为的 B-0 。如果直接通过p-func()调用呢这时p的静态类型是B*默认参数绑定的就是B中定义的val 0输出B-0。4. 重写中的特殊情况4.1 协变有时基类和派生类的返回值并不完全相同但依然能构成重写这就是协变。协变要求基类虚函数返回基类类型的指针或引用派生类的重写函数返回派生类类型的指针或引用。示例classPerson{public:virtualPerson*BuyTicket(){cout买票-全价endl;returnnullptr;}};classStudent:publicPerson{public:virtualStudent*BuyTicket(){cout买票-打折endl;returnnullptr;}};这里虽然返回值类型不一样Person*vsStudent*但它们是具有继承关系的指针编译器允许这种重写。协变在实际项目中用得不多了解即可。4.2 析构函数的重写如果类中定义了虚函数那么它的析构函数最好也声明为虚函数。这不是可选项而是防止内存泄漏的重要原则。看看为什么不加virtual会出问题classA{public:~A(){cout~Aendl;}};classB:publicA{public:~B(){cout~B-delete:_pendl;delete_p;}protected:int*_pnewint[10];};intmain(){A*p2newB;deletep2;// 只调用了~A没有调用~B_p泄漏return0;}当使用基类指针delete派生类对象时如果析构函数不是虚函数编译器只根据指针的静态类型A*调用A的析构函数不会执行B的析构函数导致B里申请的资源无法释放。解决办法把A的析构函数加上virtualclassA{public:virtual~A(){cout~Aendl;}};这时B的析构函数无论是否写virtual都会自动和A的析构函数构成重写因为编译器底层把析构函数统一命名为destructor。释放时就会走正常的析构流程先调~B()再调~A()(析构完子类后会自动调用基类的析构)资源安全释放。~A()是虚函数delete p2时会先找到真实类型 B 调用B::~B()。B::~B()执行清理B自己的资源。B::~B()执行完编译器自动帮你调用A::~A()清理套在里面的A的资源。这里还要注意一下派生类析构完自动调用基类析构是因为派生类对象里嵌套了一个基类子对象必须先析构外层再析构内层而我们自己没法手动调用基类析构所以编译器会自动调用基类析构的代码。总结只要一个类可能被继承或者其中已有虚函数就把它的析构函数声明为虚的。面试时也经常问到“为什么基类的析构函数要写成虚函数”答案就是这个内存泄漏的风险。5. override 和 final虚函数重写要求非常严格参数列表差一个const、函数名拼错一个字母都不会构成重写编译的时候也不会报错只有在程序运行时没有得到预期结果才来debug会得不偿失。因此C11 引入了两个关键字来帮我们5.1 override在派生类虚函数后面加上override告诉编译器“这个函数是用来重写基类虚函数的如果没构成重写请直接报错。”classCar{public:virtualvoidDrive(){}};classBenz:publicCar{public:virtualvoidDrive()override{coutBen-舒适endl;}// 拼写错误立即报错};如果你把Drive写成了Dirve编译器会立刻指出你并没有重写任何基类虚函数。这大大减少了调试时间。5.2 finalfinal修饰虚函数表示该虚函数不能被后续的派生类再次重写。classCar{public:virtualvoidDrive()final{}};classBenz:publicCar{public:virtualvoidDrive(){}// 编译错误Drive被final禁止重写};此外final也可以修饰类表示这个类不能被继承。6. 重载/重写/隐藏对比现象作用域函数名参数列表返回值virtual发生时期重载同一作用域相同不同可同可不同不要求编译时重写基类与派生类相同相同相同协变除外基类必须 virtual运行时隐藏基类与派生类相同不同或基类非虚任意非虚或参数不同编译时7. 纯虚函数与抽象类在虚函数后面加上 0这个函数就变成了纯虚函数。含有纯虚函数的类叫做抽象类。classCar{public:virtualvoidDrive()0;// 纯虚函数};抽象类不能实例化对象。这很合理——一个“车”的概念太抽象了你不知道它是怎么开的只有具体到“奔驰”、“宝马”才能理解驾驶行为。Car car;// 编译错误无法实例化抽象类派生类继承了抽象类后必须重写所有纯虚函数否则它自己也还是一个抽象类无法实例化。这实际上是在强制派生类实现某些接口。classBenz:publicCar{public:virtualvoidDrive(){coutBenz-舒适endl;}};classBMW:publicCar{public:virtualvoidDrive(){coutBMW-操控endl;}};父类对象不能实例化但是可以作为指针类型来使用intmain(){Car*pBenznewBenz;pBenz-Drive();Car*pBMWnewBMW;pBMW-Drive();return0;}虽然纯虚函数通常不需要实现因为会被重写但语法上你依然可以给它一个定义不过必须在类外定义。8. 多态的核心原理虚函数表vtable8.1 对象中隐藏的指针__vfptr先来看一个题下面编译为32位程序的运行结果是什么A.编译报错 B.运行报错 C.8 D.12classBase{public:virtualvoidFunc1(){coutFunc1()endl;}protected:int_b1;char_chx;};intmain(){Base b;coutsizeof(b)endl;return0;}直观来看int占 4 字节char占 1 字节还有内存对齐可能是 8。但实际结果是12多出来的 4 字节就是一个指针——虚函数表指针__vfptrv 代表 virtualf 代表 functionptr 代表 pointer。这个指针比较特殊它通常放在对象的最前面有些编译器可能放在末尾但主流放在前面指向一个虚函数表。只要一个类含有虚函数那么该类的每个对象中都至少有一个虚函数表指针从基类继承下来的也算。基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表不同类型的对象各自有独立的虚表所以基类和派生类有各自独立的虚表。。8.2 多态到底是怎么实现的我们把多态的例子用更完整的版本来演示classPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}protected:string _name;};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout买票-打折endl;}protected:int_id;};classSoldier:publicPerson{public:virtualvoidBuyTicket(){cout买票-优先endl;}protected:string _codename;};voidFunc(Person*ptr){ptr-BuyTicket();// 这里发生了什么}当我们这样调用时Person ps;Student st;Soldier sr;Func(ps);// 买票-全价Func(st);// 买票-打折Func(sr);// 买票-优先即使Func内部是通过同一个Person*指针调用BuyTicket最终还是产生了不同的行为。这个过程编译器帮我们做了什么呢在满足多态条件指针虚函数的情况下函数调用不再像普通函数那样在编译时直接确定地址而是在运行时到对象的虚表中去查找应该调用哪个函数。具体来说当ptr指向Person对象时ptr-BuyTicket()会根据该对象的虚表找到Person::BuyTicket的地址并调用。当ptr指向Student对象时就会调用派生类的版本。这就是动态绑定。静态绑定编译时就能确定函数地址比如普通函数调用、非虚函数的对象调用。动态绑定编译时不确定运行时查虚表确定调用函数的地址虚函数 指针/引用。8.3 深入虚函数表我们再用一个包含多个虚函数的例子详细剖析虚表的内部结构。classBase{public:virtualvoidfunc1(){coutBase::func1endl;}virtualvoidfunc2(){coutBase::func2endl;}voidfunc5(){coutBase::func5endl;}protected:inta1;};classDerive:publicBase{public:virtualvoidfunc1(){coutDerive::func1endl;}virtualvoidfunc3(){coutDerive::func3endl;}voidfunc4(){coutDerive::func4endl;}protected:intb2;};这个继承关系中的虚函数表是什么样子的基类Base的虚表存储Base::func1的地址存储Base::func2的地址。派生类Derive的虚表首先它也有一个虚表。因为Derive继承了Base基类部分中的虚函数表指针不再指向基类的虚表而是指向Derive自己的虚表。Derive的虚表包含重写的Base::func1被替换成了Derive::func1的地址覆盖。未重写的Base::func2依然保留基类的地址。派生类独有的虚函数Derive::func3的地址被追加到表中。VS 编译器下虚表最后通常有一个0x00000000作为结束标记但这不是标准规定g 就没有。所以虚函数表本质就是一个函数指针数组存放着该类所有需要动态调用的虚函数地址。重要细节一览普通函数如func5、func4不在虚表中它们直接由类型决定编译时绑定。派生类的虚表与基类的虚表是完全不同的两个表。派生类对象中并不会额外生成一个新的虚函数表指针而是沿用从基类继承下来的那个指针只不过现在它指向的是派生类自己的虚表。通过 VS 的内存窗口可以直观地看到这些函数地址有些在监视窗口看不到的虚函数如func3在内存中却是真实存在的。8.4 虚函数和虚表存放在内存的哪个区这是一个开放性问题因为 C 标准没有硬性规定。但我们可以通过打印不同区域的地址来进行对比验证。intmain(){inti0;staticintj1;int*p1newint;constchar*p2xxxxxxxx;printf(栈:%p\n,i);printf(静态区:%p\n,j);printf(堆:%p\n,p1);printf(常量区:%p\n,p2);Base b;Derive d;Base*p3b;Derive*p4d;printf(Base虚表地址:%p\n,*(int**)p3);// 解引用对象首地址得到虚表指针printf(Derive虚表地址:%p\n,*(int**)p4);printf(虚函数地址:%p\n,Base::func1);printf(普通函数地址:%p\n,Base::func5);}输出中你会发现虚表地址通常与常量区的地址比较接近在 VS 中甚至就在代码段而普通函数地址也在代码段。虚函数存在哪虚函数本身是代码存储在代码段只是虚函数的地址又存到了虚表中虚函数表存在哪 虚表是一个存放这些函数地址的数组通常也放在代码段常量区C标准并没有规定到底应该存在哪不过VS下是存在代码段的结语今天的内容到这里就结束了希望你能有所收获~干货整理到手抖觉得有用的话赏个三连回回血__(:ᗤ」ㄥ)_ _
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2606775.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!