
文章目录 第一部分多态的初探与核心概念1. 简短的前置知识准备2. 什么是多态Polymorphism3. 核心比喻生活场景与游戏架构 第一部分高频面试题与解析 第二部分多态的构成条件与代码实现1. 前提引入万能遥控器与隐藏身份2. 构成多态的两个“硬性条件”3. 什么是虚函数与重写4. C 核心 Demo 演示 第二部分高频面试题与解析 第三部分虚函数重写覆盖的深水区1. 虚函数重写的特例协变了解即可2. 最危险的陷阱析构函数的重写3. 语法护盾override 和 final 关键字 第三部分高频面试题与解析 第四部分重载、重写与隐藏的终极辨析 抽象类1. 概念大乱斗重载 vs 重写 vs 隐藏2. 纯虚函数与抽象类强制规则的制定者 第四部分高频面试题与解析 第五部分多态的底层原理解剖1. 隐藏在对象开头的秘密虚函数表指针_vfptr2. 多态的核心引擎虚函数表vftable与“覆盖”机制3. 动态绑定与静态绑定的终极定义4. 灵魂拷问虚函数和虚表到底存在哪里 第五部分高频面试题与解析 第一部分多态的初探与核心概念1. 简短的前置知识准备在接触多态之前我们需要明确两个前提继承关系类与类之间存在父子关系基类与派生类。比如“学生”继承自“人”。指针与对象通过指针去指向或者操作一个具体的对象。2. 什么是多态Polymorphism通俗来说多态就是“多种形态”。在C中多态分为两大类编译时多态静态多态这主要指我们以前学过的函数重载和函数模板。为什么叫“编译时”因为你传不同类型的参数调用不同函数时编译器在编译代码的阶段就已经精准匹配好了实参和形参一切都已经“静态”地定死了。运行时多态动态多态这是我们接下来要死磕的重点。它是指去完成某个相同的行为调用同一个函数但传入不同的对象就会产生截然不同的结果。3. 核心比喻生活场景与游戏架构比喻一火车站的售票窗口想象一下火车站的“买票”行为。无论谁去动作都是“买票”。当普通人买票时行为结果是全价买票。当学生买票时行为结果是优惠买票5折或75折。当军人买票时行为结果是优先买票。[12306 售票系统 (买票行为 BuyTicket)] | -------------------------- | | | 传入[普通人] 传入[学生对象] 传入[军人对象] | | | v v v 【全价出票】 【半价/75折出票】 【优先出票通道】比喻二动物的叫声与回合制宠物对战同样是“叫”这个行为函数。传猫对象过去结果就是(^w^)喵。传狗对象过去结果就是汪汪。在构建一个回合制宠物对战游戏的架构时这种思想尤为重要。你可以设计一个通用的“攻击Attack”指令。当你按下攻击键时系统并不需要知道当前是哪一种宠物。如果系统接收到的是“火系宠物”对象它自然会释放火焰技能如果接收到的是“水系宠物”对象它就会喷水。同一个指令函数调用因为背后的对象不同展现出了多种不同的形态这就是运行时多态的魅力。 第一部分高频面试题与解析面试题 1请简述什么是多态C 中多态分为哪几种详细解析与答案多态通俗来说就是事物的多种形态。在C中多态主要指不同对象接收到相同的消息调用相同的函数时会产生不同的行为。C中的多态分为两种静态多态编译时多态在程序编译阶段就确定了具体的调用目标。典型代表是函数重载通过参数列表不同来区分和泛型编程模板。动态多态运行时多态在程序运行阶段根据具体传入或绑定的对象类型来决定调用哪个具体的函数。典型代表是继承关系下的虚函数virtual重写。面试题 2为什么函数重载被称为“静态多态”详细解析与答案“静态”一词在这里指的是“在编译阶段就已经确定”。当我们在代码中调用一个被重载的函数时编译器会根据我们传入的实参类型和数量在编译期就去匹配对应的函数版本。如果匹配成功编译器直接将该函数的地址绑定到调用处。因为这个决策过程不需要等到程序真正运行起来所以在编译时就已经静态完成了。 第二部分多态的构成条件与代码实现1. 前提引入万能遥控器与隐藏身份多态是一个继承关系下的类对象去调用同一个函数从而产生了不同的行为。比如Student继承了PersonPerson对象买票是全价而Student对象则是优惠买票。但在代码层面程序是怎么知道现在到底该全价还是优惠呢这就引出了实现多态的核心规则。2. 构成多态的两个“硬性条件”要实现多态效果必须同时满足以下两个重要条件调用载体必须是基类的指针或者引用调用虚函数。核心操作被调用的函数必须是虚函数并且派生类必须完成了对该虚函数的重写也叫覆盖。为什么要用基类的指针或引用打个通俗的比方基类的指针或引用就像是一个“万能遥控器”。只有基类的指针或引用才能做到“向下兼容”——它既能指向基类对象本身也能指向派生类对象。如果是直接传对象的值比如按值传参在传递过程中会发生拷贝和截断派生类特有的那部分属性就丢了多态也就无从谈起。3. 什么是虚函数与重写虚函数virtual在类成员函数的前面加上virtual修饰那么这个成员函数就被称为虚函数。注意非成员函数类外部的全局函数是不能加virtual修饰的。虚函数的重写/覆盖派生类中有一个跟基类完全相同的虚函数这就称作派生类的虚函数重写了基类的虚函数。“完全相同”意味着这三个要素必须一模一样返回值类型、函数名字、参数列表。[ 基类 Person ] | --- 虚函数virtual void BuyTicket() { 买票全价 } | (继承 重写) | v [ 派生类 Student ] | --- 虚函数virtual void BuyTicket() { 买票半价 } 运行时多态的发生地 (动态绑定) [万能遥控器] --- Person* ptr | ------------------------------------ | | ptr 指向 Person 对象 ptr 指向 Student 对象 | | 调用基类买票全价 调用派生类买票半价4. C 核心 Demo 演示我们把火车站买票的场景转化为具体的 C 代码。仔细观察Func函数这正是多态发生的舞台。#includeiostreamusingnamespacestd;// 基类普通人classPerson{public:// 1. 定义虚函数virtualvoidBuyTicket(){cout买票-全价endl;}};// 派生类学生classStudent:publicPerson{public:// 2. 派生类完成虚函数的重写函数名、参数、返回值完全一致// 注意派生类这里如果不加virtual关键字其实也构成重写但不规范建议加上。virtualvoidBuyTicket(){cout买票-打折endl;}};// 3. 必须是基类的指针或引用调用虚函数voidFunc(Person*ptr){// 此时虽然是Person类型的指针在调用BuyTicket// 但最终执行谁的代码取决于ptr在运行时真正指向的是哪个对象[cite: 1]ptr-BuyTicket();}intmain(){Person ps;Student st;// 传入基类对象的地址ptr指向基类调用全价Func(ps);// 传入派生类对象的地址ptr指向派生类调用打折Func(st);return0;}在上述代码中同一句ptr-BuyTicket()根据传入对象的不同展现出了不同的购票策略完美契合了多态的定义。 第二部分高频面试题与解析面试题 1派生类在重写基类虚函数时如果不写virtual关键字还能构成多态吗为什么详细解析与答案能构成多态。在重写基类虚函数时派生类的虚函数即使不加virtual关键字也可以构成重写。因为基类的虚函数被继承下来后在派生类中依旧保持着虚函数属性。但是这种写法在工程实践中不是很规范容易造成代码可读性变差不建议这样使用。在笔试或面试的选择题中经常会利用这一点故意挖坑让你判断是否构成多态。面试题 2以下代码中能成功实现多态吗如果不可以请说明理由。voidCallTicket(Person p){p.BuyTicket();}详细解析与答案不能实现多态。多态的构成条件明确要求必须是基类的指针或者引用调用虚函数。上述代码使用的是基类对象的“按值传递”。当传入派生类对象时会发生切片Slicing现象派生类特有的部分会被截断丢弃仅仅拷贝并构造出一个单纯的基类对象因此只能调用基类的函数无法展现多态。 第三部分虚函数重写覆盖的深水区1. 虚函数重写的特例协变了解即可在上一节中我们提到虚函数重写要求三同函数名、参数列表、返回值完全相同。但是有一个特例叫做协变。通俗比喻假设基类提供了一个“获取交通工具”的虚函数。基类返回的是一个普通的“车”钥匙基类指针而派生类重写这个函数时返回的是更具体的“跑车”钥匙派生类指针。虽然返回的钥匙类型不同但本质上都是车钥匙这种“向下细化”是被允许的。核心规则派生类重写基类虚函数时与基类虚函数返回值类型可以不同。前提是基类虚函数返回基类对象的指针或者引用派生类虚函数返回派生类对象的指针或者引用。2. 最危险的陷阱析构函数的重写这是C多态中最容易引发严重Bug的地方。如果基类的析构函数不设置为虚函数极易导致内存泄漏。比喻粗心的拆迁大队假设有一栋别墅派生类对象它是在普通地基基类上扩建出来的并且别墅里有一座专属的豪华泳池派生类动态申请的内存。现在你要把这栋别墅拆除但是你给拆迁大队的图纸只是一个“普通地基”的图纸基类指针。如果不加虚析构函数拆迁大队只看图纸把地基拆了就走人结果豪华泳池派生类的资源留在了原地变成了无人管理的废墟内存泄漏。如果加上虚析构函数virtual相当于告诉拆迁大队“看清楚实际建筑再拆”他们会先拆除别墅的泳池调用派生类析构再拆除地基调用基类析构完美清理现场。[ 基类指针 A* p2 new B; ] | 不加 virtual | 加了 virtual (静态绑定) | (动态绑定) | 只调用 ~A() | 先调用 ~B()再调用 ~A() ⚠️ 内存泄漏 | ✅ 完美释放代码演示classA{public:// 强烈建议基类的析构函数必须加 virtualvirtual~A(){cout~A()endl;}};classB:publicA{public:B(){_pnewint[10];}// 派生类申请了资源// 派生类析构函数重写了基类析构函数~B(){cout~B()-deleteendl;delete[]_p;// 释放资源}protected:int*_p;};intmain(){A*p2newB;// 如果基类析构不是虚函数这里只会调用 ~A()导致 B 中的 _p 无法释放deletep2;return0;}特殊机制说明基类与派生类的析构函数名字明明不同~A和~B为什么能构成重写因为编译器在底层做了特殊处理编译后析构函数的名称会被统一处理成destructor。因此只要基类加了virtual派生类的析构函数就自动构成重写。3. 语法护盾override 和 final 关键字C11 提供了两个关键字用来在编译阶段帮助我们进行多态的语法检查。override检查官修饰派生类的虚函数。它的作用是检查该虚函数是否成功重写了基类的某个虚函数如果没有重写成功比如函数名拼错了、参数写错了编译器会直接报错。这极大地避免了运行后才发现预期多态未生效的尴尬。final终结者修饰基类的虚函数。顾名思义它表示这个虚函数是“最终版”绝不允许任何派生类去重写它。如果派生类强行重写编译器会报错。 第三部分高频面试题与解析面试题 1为什么在涉及到继承和多态时基类的析构函数通常建议设计为虚函数高频核心题详细解析与答案在多态场景下我们经常使用基类指针去操作指向派生类对象。当使用delete释放这个基类指针时如果基类的析构函数不是虚函数编译器会采用静态绑定只调用基类的析构函数。如果派生类内部申请了额外的内存等资源派生类的析构函数未能被调用就会导致内存泄漏。将基类析构函数设计为虚函数virtual可以使其构成虚函数重写编译器会将析构函数统一命名为destructor。这样在使用delete基类指针时就会触发多态先调用派生类的析构函数清理派生类资源再自动调用基类析构函数从而保证对象被安全且彻底地销毁。面试题 2什么是协变它违背了虚函数重写的规则吗详细解析与答案协变是虚函数重写的一个特例它并没有违背规则而是规则允许的特殊情况。普通的虚函数重写要求基类和派生类的函数返回值完全相同。而协变允许两者返回值不同前提是基类虚函数返回基类对象的指针或引用派生类重写的虚函数返回派生类对象的指针或引用。这种设计在某些需要细化返回类型的场景下提供了便利。 第四部分重载、重写与隐藏的终极辨析 抽象类1. 概念大乱斗重载 vs 重写 vs 隐藏我们先用通俗的比喻来区分它们重载Overload——“一个人换工具”同一个木匠同一个作用域他有一个技能叫“砍”。当你给他一把斧头参数A他就劈柴当你给他一把菜刀参数B他就切菜。名字相同但工具参数不同。重写/覆盖Override——“子承父业且升级核心技术”父亲基类和儿子派生类都会“祖传手艺”同名虚函数。儿子不仅继承了手艺还用全新的方式实现了它把父亲的老方法给覆盖了。隐藏Hide——“年轻人的专属黑话屏蔽了长辈”父亲和儿子在各自的圈子不同作用域。只要儿子定义了一个和父亲同名的东西不管参数相不相同在儿子的地盘里父亲的那个同名事物就被“屏蔽/隐藏”了。下面是基于这三个概念的严谨对比请务必理解记忆概念作用域要求函数名要求参数与返回值要求虚函数要求重载两个函数在同一作用域函数名相同参数不同类型或个数不同返回值可同可不同无要求重写/覆盖两个函数分别在继承体系的父类和子类不同作用域函数名相同参数、返回值都必须相同协变例外两个函数都必须是虚函数隐藏两个函数分别在继承体系的父类和子类不同作用域函数名相同无要求只要不构成重写就是隐藏无要求特别注意除了函数如果父子类定义了名字相同的成员变量这也叫隐藏。2. 纯虚函数与抽象类强制规则的制定者在实际开发中有些基类是非常抽象的概念。比如“汽车Car”在现实中你买不到一辆名叫“汽车”的具体车你只能买到“奔驰”或者“宝马”。因此“汽车”这个类其实不应该被创建出具体的对象它存在的意义是为了定义规范。如何实现这种规范呢这就需要引入纯虚函数。纯虚函数的定义在虚函数的后面写上 0则这个函数为纯虚函数。纯虚函数只要声明即可不需要定义实现虽然语法上可以实现但没啥意义因为它注定要被派生类重写。抽象类的概念包含纯虚函数的类叫做抽象类。抽象类的两大特性抽象类不能实例化出对象你不能Car car;这样写编译器会报错。如果派生类继承了抽象类后不重写纯虚函数那么派生类也会变成抽象类同样无法实例化。这种机制某种程度上强制了派生类必须重写虚函数因为不重写就实例化不出对象。#includeiostreamusingnamespacestd;// 抽象类汽车概念模型制定规则classCar{public:// 纯虚函数只需声明后面加 0virtualvoidDrive()0;};// 派生类奔驰classBenz:publicCar{public:// 强制重写纯虚函数否则Benz也是抽象类virtualvoidDrive(){coutBenz-舒适endl;}};// 派生类宝马classBMW:publicCar{public:// 强制重写纯虚函数virtualvoidDrive(){coutBMW-操控endl;}};intmain(){// Car car; // ❌ 编译报错无法实例化抽象类// 但是可以定义抽象类的指针用来指向具体的派生类对象实现多态Car*pBenznewBenz;pBenz-Drive();// 输出Benz-舒适Car*pBMWnewBMW;pBMW-Drive();// 输出BMW-操控deletepBenz;deletepBMW;return0;} 第四部分高频面试题与解析面试题 1请详细对比 C 中的重载Overload、重写Override与隐藏Hide的区别。详细解析与答案这是C面向对象中最核心的考点之一。作用域不同重载发生在同一作用域内而重写和隐藏都发生在继承体系下即分别在基类和派生类的不同作用域中。条件要求不同重载要求函数名相同但参数列表必须不同类型或个数。重写要求极其严格必须是虚函数且函数名、参数列表、返回值必须完全相同协变除外。隐藏的要求最宽泛只要在派生类和基类中有同名的函数且不满足重写条件或者有同名的成员变量基类的同名成员就会在派生类作用域中被隐藏。多态绑定重写是实现动态多态的基础动态绑定而重载属于静态多态静态绑定。面试题 2什么是纯虚函数和抽象类它们在实际开发中有什么意义详细解析与答案在虚函数声明的末尾加上 0该函数即为纯虚函数。包含至少一个纯虚函数的类被称为抽象类。意义防止非法实例化抽象类代表一种抽象的概念如“形状”、“动物”本身不具备完整的实现细节因此语法上禁止其实例化出对象防止了无意义的操作。接口规范与强制重写纯虚函数相当于定义了一套标准的“接口协议”。它强制要求任何继承该抽象类的派生类都必须提供具体的实现逻辑否则派生类也会变成抽象类无法使用。这种机制极大地提升了代码的规范性和扩展性。 第五部分多态的底层原理解剖1. 隐藏在对象开头的秘密虚函数表指针_vfptr如果你在一个类里写了虚函数这个类的对象在内存中的大小会发生意想不到的变化。我们来看一个测试#includeiostreamusingnamespacestd;classBase{public:virtualvoidFunc1(){coutFunc1()endl;}protected:int_b1;char_chx;};intmain(){Base b;// 在32位系统下输出结果是 12coutsizeof(b)endl;return0;}为什么是 12 字节按照常规理解一个int占 4 字节一个char占 1 字节加上内存对齐应该是 8 字节。那么多出来的 4 字节是什么答案是对象的前面多了一个指针叫做虚函数表指针_vfptr。注v 代表 virtualf 代表 function。一个含有虚函数的类中都至少有一个虚函数表指针。因为一个类所有虚函数的地址需要被统一存放到一个“表格”里而这个指针就是用来寻找这个表格的。2. 多态的核心引擎虚函数表vftable与“覆盖”机制这个“表格”就是虚函数表简称虚表。它的本质是一个存虚函数指针的指针数组。多态底层的核心精髓就在于派生类重写的虚函数在派生类的虚函数表中会把继承自基类的对应虚函数地址给覆盖掉。比喻公司的任务执行手册假设基类是一份“通用任务手册”基类虚表派生类是一份“定制任务手册”派生类虚表。如果派生类没有重写某个任务它的手册里就抄一份基类的做法如果派生类重写了任务它的手册里就会把原本那一页撕掉贴上自己的新做法覆盖。我们用字符画来直观展示前面买票例子的内存布局[基类 Person 对象 ps] ------------------- | _vfptr (指向虚表) | -------- [ Person 虚表 ] ------------------- | [0] Person::BuyTicket() 的地址 | | _name 张三 | ------------------------------- ------------------- [派生类 Student 对象 st] ------------------- | _vfptr (指向虚表) | -------- [ Student 虚表 ] ------------------- | [0] Student::BuyTicket() 的地址 | --- 发生覆盖 | _name 张三 | ------------------------------- | _id 1 | -------------------当执行ptr-BuyTicket()时底层不再是编译时直接确定函数地址而是运行时到指向的对象的虚表中确定对应的虚函数的地址。如果ptr指向Person对象它就顺着Person对象的_vfptr找到基类虚表调用全价买票。如果ptr指向Student对象它就顺着Student对象的_vfptr找到派生类虚表此时表里存的是被覆盖后的打折买票地址所以调用打折买票。3. 动态绑定与静态绑定的终极定义理解了底层我们就可以给出最严谨的定义了静态绑定对不满足多态条件的函数调用比如不是通过指针/引用调用或者调用的不是虚函数编译器在编译时就直接写死了要调用函数的内存地址。动态绑定满足多态条件的函数调用是在运行时绑定也就是程序跑起来之后动态地去对象身上的虚函数表里翻找需要调用的函数地址。4. 灵魂拷问虚函数和虚表到底存在哪里虚函数存在哪虚函数和普通函数一样编译好后是一段指令存在代码段。虚函数表存在哪C 标准其实没有硬性规定但在主流的 VS 系列编译器下虚函数表是存在代码段常量区的。 第五部分高频面试题与解析面试题 1请简述 C 中多态的底层实现原理。详细解析与答案C 运行时多态的底层主要依靠虚函数表vftable和虚函数表指针_vfptr来实现。如果一个类包含了虚函数编译器在实例化该类的对象时会在对象的内存布局中通常是头部偷偷塞入一个虚表指针_vfptr。这个指针指向该类专属的虚函数表。虚表本质上是一个函数指针数组里面存放着该类所有虚函数的内存地址。当派生类继承基类并重写了某个虚函数时派生类虚表中的该虚函数地址会被更新替换为派生类自己的函数地址这个过程称为“覆盖”。当通过基类的指针或引用调用虚函数时程序在运行时会通过该指针实际指向的对象的_vfptr找到对应的虚表并在虚表中查找真正的函数地址来执行。这就是动态绑定。面试题 2同一个类的不同对象它们拥有几张虚函数表基类和派生类的虚函数表是同一张吗详细解析与答案同类型的对象共用同一张虚表。不管你实例化了多少个Base对象它们内部的_vfptr都指向内存中唯一的那一张Base类的虚表。不同类型的对象各自有独立的虚表。基类和派生类拥有各自独立的虚表。即使派生类没有重写任何虚函数编译器也会为派生类单独生成一张属于它自己的虚表只是表里的内容暂时和基类一样罢了。