0.1 概述序言这里是白杨。上一篇继承的结尾留了一句话想搞多态必须继承。这一篇就来兑现——多态。如果把继承比作类怎么长谁是谁的爸多态解决的就是同一份调用代码怎么跑出不同的行为。这两章是绑在一起考的面试里也常被连招先问继承的坑再问多态的构成最后甩一道输出什么的选择题。这篇要过四件事多态的三个构成条件附一个值传递翻车的反例重写的细节override / final / 协变以及重载、重写、隐藏三兄弟的区别抽象类纯虚函数 0把原理摊开虚函数表vfptr / vftable 三个经典坑默认参数、虚析构、构造中调虚函数老规矩跑得通的结果才写进来环境见下完整代码文末自取也可以直接去仓库gitee.com/BaiYang_SQ/poly-sim。实测环境w64devkit g 15.2.0-stdc11 -O2 -Wall -Wextra VS2022教学简化版。文中代码均为本人手写并实测官方文档cppreference·virtual 说明符。让我们开始吧。一、什么是多态同一份代码三种票多态这个词听着玄场景很朴素——买票。普通人全价、学生打折、军人优先动作都叫买票不同的人做行为不同。C 里的多态分两种静态多态编译时函数重载、函数模板——编译阶段就把调用的函数定好了动态多态运行时虚函数 继承——运行时根据对象的真实身份决定调谁。这篇讲的都是后者。先看一段代码classPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}};classStudent:publicPerson{public:voidBuyTicket()override{cout买票-打折endl;}};classSoldier:publicPerson{public:voidBuyTicket()override{cout买票-优先endl;}};voidFunc(Personptr)// 基类引用调用{ptr.BuyTicket();}Func收到的都是Person代码里也只有一句ptr.BuyTicket()——可传进去的对象不同结果完全不同。实测买票-全价 买票-打折 买票-优先【一句话记】多态 同一份调用代码、不同行为指向谁谁的对象就调谁谁的函数。二、构成条件三个都要满足必须通过基类的指针或引用调用——不能是值传递被调用的函数必须是虚函数基类里加virtual派生类必须完成重写覆盖——返回值、函数名、参数全相同。补一句虚函数的定义类成员函数前面加virtual就是虚函数非成员函数不能加virtual修饰——后半句是选择题常点的名。第 1 条为什么强调不能值传递来看一组对照实验。同一个虚函数、同样的重写区别只在Func的参数voidFuncRef(Personptr){ptr.BuyTicket();}// 引用构成多态voidFuncValue(Person ptr){ptr.BuyTicket();}// 值传递不构成实测输出[引用版 ] 买票-全价 [引用版 ] 买票-打折 [值传递版] 买票-全价 [值传递版] 买票-全价 值传递把 st 切成了 Person多态消失值传递版把 Student 对象切成了一个 Person 对象切片上篇讲过的老朋友——ptr里根本没有 Student 的部分多态从哪来【必背】三条件基类指针/引用 虚函数 重写缺一不可。三、重写的细节三同、override 与协变重写覆盖的三同返回值、函数名、参数列表完全相同。有个考试爱考的坑派生类重写时不写virtual也构成重写——因为虚函数的属性会一路继承下去。但规范写法建议写上语义更清楚。写重写最容易出的事故是差一点没写上函数名敲错一个字母、参数少个const……编译器不报错运行时才发现多态没生效。C11 给的解法是overrideclassBenz:publicCar{public:voidDrive()override{coutBenz-舒适endl;}// 如果 Drive 没真的重写上任何基类虚函数编译器直接报错C3668};配套的还有final加在虚函数后面禁止派生类再重写它加在类名后面禁止整个类被继承。还有个冷门细节协变——派生类虚函数返回派生类对象的指针/引用、基类虚函数返回基类对象的指针/引用时也算重写classPBase{public:virtualA*BuyTicket(){...}};classPStudent:publicPBase{public:B*BuyTicket()override{...}// B 是 A 的派生类 → 协变};实测输出买票-全价返回 A*/买票-打折返回 B*。协变实际意义不大了解即可。【面试】override的价值一句话把运行时的莫名其妙变成编译期的明确报错。四、重载 / 重写 / 隐藏一表定三兄弟这三个概念是面试的常客判断标准其实就一张表对比项重载重写覆盖隐藏作用域同一个父类 / 子类各一个父类 / 子类各一个函数名相同相同相同参数列表不同类型或个数相同不同不构成重写是否虚函数无关都必须是虚函数不构成重写就是隐藏附加规则返回值可同可不同返回值相同协变例外成员变量同名也算隐藏跑一遍实测每个概念一个例子[重载] 3 / 4 Derive::func Derive::other(int): 10 Base::other 基类 _num111派生 _num999变量同名隐藏重载Calc::Add(int,int)和Calc::Add(double,double)同作用域、参数不同重写Derive::func重写了Base::func通过Base*调用照样走到派生类版本隐藏Derive::other(int)和Base::other()同名——它俩不构成重写参数不同基类版本被藏起来。想调基类版必须写d.Base::other()。【必背】判断口诀同作用域看参数 → 重载父子都虚且三同 → 重写剩下同名的一律叫隐藏。隐藏就是重写没写成的事故现场。五、抽象类纯虚函数 0虚函数后面写上 0就成了纯虚函数——只声明、没有实现等着派生类去重写。含有纯虚函数的类叫抽象类它有两个规矩不能实例化对象Car car;会报error C2259无法实例化抽象类派生类如果不重写纯虚函数那它也是抽象类。classCar{public:virtualvoidDrive()const0;// 纯虚函数};voidFunc(constCarp){p.Drive();}intmain(){Func(Benz());Func(BMW());Func(WENJIE());}实测输出Benz-舒适 BMW-操控 WENJIE-智能纯虚函数某种程度上是强制重写不写连对象都造不出来。它划出的是接口——基类只规定必须有这个行为具体怎么做各派生类自己定。【一句话记】抽象类 一份接口规范纯虚函数 必须交作业。六、原理vfptr 与虚函数表到最硬核的一节了。多态到底怎么实现的——虚函数表。先说对象的内存布局。一个含虚函数的类编译器会在对象里塞一个隐藏指针——虚函数表指针__vfptrvvirtualffunction一般放在对象最前面。它指向一张表表里存着这个类所有虚函数的地址。拿一道经典题实测sizeof 多少sizeof(NoVirt) 8 字节无虚函数 sizeof(HasVirt) 16 字节有虚函数多出一个 vfptrx64 平台下就是多出一个 8 字节指针。再验证虚表的两条规律用 memcpy 安全地读对象头 8 字节vfptr(s1) vfptr(s2) ? 1同类型的对象共用一张虚表 vfptr(p1) vfptr(s1) ? 0各类型各有独立的虚表虚表的构成派生类的虚表 从基类继承的虚函数地址 被重写函数的地址被覆盖 自己新增的虚函数地址。表尾要不要放0x00000000之类的终止标记是各编译器自己的安排——后面多继承的实测里第二张表的越界槽位读到的就是 0。属实现细节不用背。运行时发生了什么ptr-BuyTicket()的步骤取ptr指向对象头部的__vfptr__vfptr找到这张虚表取表里对应位置的函数地址call这个地址。满足多态条件的调用叫动态绑定运行时才定地址汇编里是间接 call不满足的比如非虚函数、值传递对象调用叫静态绑定编译时就写死call 函数地址。【面试】多态调用为什么比普通调用慢一点点——多了一次取指针、查表、间接跳转这也解释了为什么 C 不把所有函数都设成虚函数。先回答一个追问题虚表存在哪C 标准说没规定这是编译器实现细节那就实测看看——顺带把栈、静态区、堆、常量区一起打出来对比栈 : 0x3daadff74c 静态区: 0x7ff7975fa000 堆 : 0x159268513d0 常量区: 0x7ff7975fe625 虚表 : 0x7ff7976096c0数值上贴近常量区——C 标准没规定虚表存哪这是编译器实现细节栈和堆各占一段虚表地址贴着常量区/静态数据段——所以通常说它放在只读数据段。记住一句就够虚表不在栈上、也不在堆上。再分清一对东西虚函数本体是一段指令编译后存在代码段虚表是数据存的是这些指令的地址——代码和数据别混。多继承下的虚函数表一个对象两张表继承篇聊过多继承class MDerive : public MBase1, public MBase2。当两个基类都有虚函数时对象里就会塞两个__vfptr——每份基类子对象各带一张虚表。探针安排安全读取两张虚表的槽位地址sec7::MBase2*pb2d;// 隐式转换编译器算好 Base2 子对象偏移void*vptr1vfptr_of(d);// Base1 部分的虚表void*vptr2vfptr_of(pb2);// Base2 部分的虚表void**vt1(void**)vptr1;void**vt2(void**)vptr2;实测输出vptr1Base1 部分 0x7ff7976096e0 vptr2Base2 部分 0x7ff797609708偏移 16 字节 两张虚表地址不同: 1 vt1: slot00x7ff797598650 slot10x7ff7975984a0 slot20x7ff7975986e0 vt2: slot00x7ff7975f7520 slot10x7ff797598530 slot20 vt1[0] vt2[0] ? 0第二张表里的覆盖函数常是 thunk先修正 this 偏移再跳转 vt1[1] ! vt2[1] ? 1两边的 func2 各留各的版本 MDerive::func1 MDerive::func1地址里读出三条结论两张表分家Base1、Base2 子对象各带一张虚表Base2 那份在对象里还占了 16 字节的偏移重写全覆盖但入口形态不同func1被 Derive 重写后两张表的第一槽都成了跑向MDerive::func1的入口——第一张表里是直接地址第二张表里是thunk一小段先修正this偏移、再跳转的代码MBase2*指向的是对象中间要转成MDerive*得先把这 16 字节的偏移减回去所以两个槽的地址不一样但调用效果完全一致经MBase1*或MBase2*调func1都输出MDerive::func1新虚函数只挂主表Derive 新增的func3只出现在第一张表vt1[2]有值第二张表对应位置是 0。【面试】多继承对象的内存布局 虚表安排是多态 × 多继承的交叉考点记一条线每个多态基类子对象带一张表重写在各自的表里落地新增虚函数挂主表。七、三个经典坑7.1 默认参数坑输出到底是几这道题流传很广实测安排上。基类虚函数默认参数 1派生类重写时默认参数 0问p-test()输出什么classA{public:virtualvoidfunc(intval1){coutA-valendl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(intval0)override{coutB-valendl;}};实测三条调用对照着看B-1 ← p-test()经典题答案B 的实现 A 的默认参数 B-0 ← b.func()直接调用默认参数看 B 自己的 B-1 ← A* pa b; pa-func()多态调用默认参数看编译时类型 A【必背】一句话拆解函数体走动态绑定运行时看对象调的是 B 的func默认参数走静态绑定编译时看指针类型用的是 A 的val 1。两套机制拧在一起就拧出了这个B-1。7.2 虚析构delete 基类指针谁在泄漏上一篇埋的扣子析构函数名被特殊处理成 destructor在这里收网。派生类 B 里有个_p指向堆内存、需要在析构里释放。现在用基类指针 new 一个 B再 deletesec5::A_nv*p1newsec5::B_nv;// ~A 不虚deletep1;sec5::A_v*p2newsec5::B_v;// ~A 加了 virtualdeletep2;实测对照输出--- 反例~A 不虚delete 基类指针 --- ~A_nv() ~B_nv() 根本没被调用_p 指向的堆内存泄漏 --- 正例~A 加 virtualdelete 基类指针 --- ~B_v()-delete:0x2408bd813f0 ~A_v() 先析构派生、再析构基类资源正确释放析构不虚时delete p1只调了~A~B从头到尾没执行——_p的内存泄漏了而且不报错、不崩溃跑测试都未必发现。这也是基类析构建议一律加 virtual的原因delete基类指针 虚析构 多态析构先派生、后基类正好是继承篇里那条析构顺序。7.3 构造 / 析构中调用虚函数不构成多态classBase{public:Base(){func();}// 构造里调虚函数virtualvoidfunc(){coutBase::func虚表还没切到派生类endl;}};classDerived:publicBase{public:voidfunc()override{coutDerived::funcendl;}};实测Base 构造中调用 func()Base::func虚表还没切到派生类 Derived::func构造派生类时先跑基类构造——这期间对象的虚表还没切到派生类版本func()只会调到Base::func等对象完整构造完再调用才是Derived::func。析构阶段同理先还原成基类虚表。所以构造和析构函数里调用虚函数多态不生效——想用派生类版本只能老老实实构造完再调。【必背】三个坑一句话默认参数静态绑定B-1基类析构要 virtual构造/析构里调虚函数不构成多态。八、速记卡考前扫一眼面试不卡壳九、经典题集五道题覆盖这一章的事故多发地段。先自己默答再看对照题 1选择题下面程序输出什么classA{public:virtualvoidfunc(intval1){coutA-valendl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(intval0)override{coutB-valendl;}};intmain(){B*pnewB;p-test();}A. A-0 B. B-1 C. A-1 D. B-0 E. 编译出错 F. 以上都不正确答案B. B-1。p-test()里的this是A*类型func()满足多态 → 函数体走 B 的实现动态绑定默认参数是静态绑定取编译时类型 A 的val 1。所以是B 的实现 A 的参数。完整实测含另两条调用对照见 7.1。题 2选择题编译为 32 位程序sizeof(b)的结果是classBase{public:virtualvoidFunc1(){coutFunc1()endl;}protected:int_b1;char_chx;};intmain(){Base b;coutsizeof(b)endl;}A. 编译报错 B. 运行报错 C. 8 D. 12答案D. 12。32 位下vfptr 4 字节 int 4 字节 char 1 字节 对齐填充 3 字节 12。同理x64 下我们用同一份代码实测是16 字节vfptr 8 int 4 char 1 填充 3——这类题的答案跟着平台和指针大小走。再附两个变体实测{virtual 3 个 int} 24 字节{virtual 1 个 char} 16 字节哪怕只有 1 字节数据也要按 8 字节对齐。题 3判断题下面这个值传递版本是不是多态输出什么voidFunc(Person ptr)// 注意值传递{ptr.BuyTicket();}intmain(){Person ps;Student st;Func(ps);Func(st);}答案不是多态输出两行买票-全价。值传递把st切片成了 Person 对象函数里的ptr已经不含 Student 部分——三条件里基类指针/引用这一条被破坏静态绑定到Person::BuyTicket。对照实验见第二节。题 4判断题下面两句delete分别会调用哪些析构函数classA{public:~A(){cout~A()endl;}// 没有 virtual};classB:publicA{public:~B(){delete[]_p;}protected:int*_pnewint[10];};A*pnewB;deletep;答案只输出~A()——析构不虚delete p按基类析构调用~B()根本不执行_p指向的堆内存泄漏。把~A加上virtual后输出变成~B()先释放资源再~A()。这就是基类析构建议 virtual的由来实测对照见 7.2。题 5判断题构造Derived d的过程中基类构造函数里调用的虚函数走哪个版本classBase{public:Base(){func();}virtualvoidfunc(){coutBase::funcendl;}};classDerived:publicBase{public:voidfunc()override{coutDerived::funcendl;}};intmain(){Derived d;}答案输出Base::func。构造派生类时先跑基类构造这期间对象的虚表还没切到派生类版本虚函数不构成多态等对象完整构造完再调才轮到Derived::func。析构阶段同理实测见 7.3。【一句话总结】五道题拉到底就是两条线多态三条件缺一条就静态绑定和虚表 / 对象模型谁的表、谁的 this。附录 A完整代码仓库完整工程单文件版全部实验一次跑完在 Giteehttps://gitee.com/BaiYang_SQ/poly-sim仓库文件说明poly_sim.cpp单文件全部实验章节 ↔ 函数名对照见仓库 README 导览表output.txt全部实验的真实运行输出含虚析构对照、sizeof、vfptr 数据build.batWindows 一键编译 运行 刷新 output.txtREADME.md一分钟上手 代码导览面试前手写练手照这个骨架压考点完整版见仓库// 多态·考点骨架完整可跑版见 Gitee 仓库#includeiostreamusingnamespacestd;classPerson{public:virtualvoidBuyTicket(){cout全价endl;}virtual~Person(){}// 基类析构加 virtual};classStudent:publicPerson{public:voidBuyTicket()override{cout打折endl;}};voidFunc(Personp){p.BuyTicket();}// 指针/引用 虚函数 重写 多态intmain(){Person ps;Student st;Func(ps);// 全价Func(st);// 打折Person*pnewStudent;deletep;// 多态析构先 ~Student 再 ~Personreturn0;}好了多态这篇到此完结下一站——二叉搜索树预告插入查找一条龙、删除的四种情况以及为什么 map / set 的底层都要给它留位置。如果对你有帮助不要忘记点赞三连一波哦我是白杨我们下期见。
阅读完成 · 觉得有帮助?