C++ 多态详解:虚函数、虚表、override、final、抽象类一篇讲透

张开发
2026/4/3 18:50:44 15 分钟阅读
C++ 多态详解:虚函数、虚表、override、final、抽象类一篇讲透
C 多态完全讲解从概念到原理讲透虚函数、重写、override、final、抽象类、虚析构、虚表这里是张有志这篇文章带你系统梳理 C 多态。如果你刚学完这部分内容建议先收藏再慢慢看。 C 进阶专栏入口本文适合谁看刚学完 C 多态想系统梳理一遍的人知道“虚函数”“重写”这些词但一做题就乱的人总分不清重载 / 重写 / 隐藏的人想把“多态的底层调用过程”真正搞明白的人看完你应该能掌握什么什么是多态运行时多态成立的条件虚函数、重写、override、final纯虚函数、抽象类、虚析构vfptr、虚函数表、动态绑定到底是怎么回事文章目录一、什么是多态二、C 中的多态分为哪两种1. 编译时多态2. 运行时多态编译时多态和运行时多态的区别一句话理解三、运行时多态成立的条件先来看一个标准例子四、什么是虚函数五、什么是重写这里有个非常容易踩的坑重写成立与不成立的对比重写成立与不成立的核心判断六、为什么必须通过基类指针或引用才能触发多态七、静态绑定和动态绑定到底是什么意思1. 静态绑定2. 动态绑定3. 用一段代码彻底看懂p-f() 为什么调用 Derive::f()p-g() 为什么调用 Base::g()八、override 和 final 有什么用1. override2. final九、什么是纯虚函数什么是抽象类抽象类最重要的特点抽象类的意义是什么十、为什么基类析构函数通常要写成虚函数问题出在哪正确写法十一、重载、重写、隐藏这三个到底怎么区分1. 重载2. 重写3. 隐藏一张表记住三者区别十二、虚函数表和 vfptr 到底是什么1. vfptr 是什么2. vtable 是什么3. 为什么它能实现多态十三、对象到底是怎么调用成员函数的1. 普通成员函数怎么调用2. 虚函数怎么调用3. 再回到经典代码十四、学习多态时最容易犯的错误1. 以为只要继承了就一定有多态2. 以为基类指针指向派生类对象就一定调用派生类版本3. 把隐藏当成重写4. 以为对象里直接存着普通成员函数5. 以为虚表里存的是函数本体十五、总结十六、最后说几句一、什么是多态多态英文叫polymorphism字面意思就是“多种形态”。放到 C 里可以把它理解成一句话同一个接口传入不同对象表现出不同的行为。举个最常见的例子普通人买票全价学生买票打折军人买票优先虽然调用的动作都叫“买票”但因为对象不同最终表现出来的行为也不同这就是多态。很多同学刚开始学到这里时会觉得这不就是“同名函数”吗还真不是。多态强调的不是“名字一样”而是同一套调用方式面对不同对象表现出不同结果这才是它的核心。二、C 中的多态分为哪两种C 里的多态主要分成两类1. 编译时多态也叫静态多态。典型例子有函数重载函数模板它们的共同点是调用哪个函数在编译阶段就已经确定了。来看个简单例子#includeiostreamusingnamespacestd;voidFunc(intx){coutintendl;}voidFunc(doublex){coutdoubleendl;}intmain(){Func(10);Func(3.14);return0;}这里Func(10)调用Func(int)Func(3.14)调用Func(double)编译器在编译时就已经决定好了所以它属于编译时多态。2. 运行时多态也叫动态多态。它的特点是在编译阶段不能完全确定最终调用哪个函数要等到程序运行时再决定。通常我们平时说“C 多态”重点指的就是这一种。而这篇文章后面也主要围绕运行时多态来展开。编译时多态和运行时多态的区别对比项编译时多态静态多态运行时多态动态多态另一个名字静态多态动态多态决定调用目标的时间编译阶段运行阶段实现方式函数重载、函数模板继承、虚函数、基类指针或引用是否依赖继承不一定必须依赖继承是否依赖虚函数不需要需要是否依赖基类指针/引用不需要需要绑定方式静态绑定动态绑定调用效率通常更高略低需要运行时查虚表灵活性较低更高典型场景参数类型不同调用不同函数同一接口对不同对象表现出不同行为常见例子Func(int)、Func(double)Base* p d; p-f();一句话理解编译时多态编译器在编译时就知道该调用哪个函数。运行时多态编译器先保留“动态决定”的能力等程序运行时再根据对象真实类型决定调用哪个函数。三、运行时多态成立的条件这一部分非常重要也是考试和面试里最爱问的点。很多人误以为只要基类指针指向派生类对象就一定是多态。这是错的。运行时多态要成立至少要同时满足下面几个条件存在继承关系必须通过基类指针或者基类引用调用函数被调用的函数必须是虚函数派生类必须对基类虚函数完成重写缺一个都不行。先来看一个标准例子#includeiostreamusingnamespacestd;classPerson{public:virtualvoidBuyTicket(){cout普通人全价买票endl;}};classStudent:publicPerson{public:voidBuyTicket()override{cout学生打折买票endl;}};voidFunc(Personp){p.BuyTicket();}intmain(){Person p;Student s;Func(p);Func(s);return0;}运行结果普通人全价买票 学生打折买票这里为什么会发生多态因为它刚好满足了前面说的四个条件Student继承PersonFunc用的是PersonBuyTicket是虚函数Student重写了BuyTicket所以同样是p.BuyTicket()这套调用形式传入不同对象结果不同。四、什么是虚函数在类成员函数前面加上virtual这个函数就叫虚函数。例如classPerson{public:virtualvoidBuyTicket(){cout普通人全价买票endl;}};这里的BuyTicket就是虚函数。虚函数的意义是让程序可以在运行时根据对象的真实类型决定最终调用哪个函数版本。你可以先把它理解成一句最实用的话普通成员函数通常编译时就决定调谁虚函数运行时再决定调谁五、什么是重写如果派生类中定义了一个函数它和基类中的虚函数函数名相同参数列表相同返回值类型相同协变除外那么就称派生类重写了基类的虚函数。例如classBase{public:virtualvoidf(){coutBase::fendl;}};classDerive:publicBase{public:voidf()override{coutDerive::fendl;}};这里Derive::f就重写了Base::f。这里有个非常容易踩的坑很多人会把“同名函数”都当成重写。其实不是。只有基类函数本身是虚函数并且签名匹配才叫重写。比如下面这段就不构成重写classBase{public:virtualvoidf()const{}};classDerive:publicBase{public:voidf(){}};因为基类是f() const派生类是f()const不同函数签名已经不一样了。重写成立与不成立的对比在 C 中重写Override指的是派生类重新定义了基类中的某个虚函数并且函数签名能够对应上。重写成立与不成立的核心判断判断条件成立时不成立时基类函数必须是虚函数不是虚函数就不行函数名必须相同写错就不行参数列表必须一致不一致就不行const修饰必须一致不一致就不行结果构成重写不构成重写六、为什么必须通过基类指针或引用才能触发多态这个问题是很多人真正开始理解多态的关键。还是用刚才的例子classBase{public:virtualvoidf(){coutBase::fendl;}};classDerive:publicBase{public:voidf()override{coutDerive::fendl;}};如果你这样写Derive d;d.f();输出当然是Derive::f。但这并不是多态最核心的场景。多态真正想解决的问题是我手里统一拿着“基类接口”但运行时可以适配不同派生类对象。所以更典型的写法是Derive d;Base*pd;p-f();或者Baserefd;ref.f();因为只有这样程序才有“运行时再判断到底调用哪个版本”的意义。如果每次都直接拿派生类对象去调用自己的函数那其实根本不需要多态机制。七、静态绑定和动态绑定到底是什么意思理解多态时有两个词一定绕不过去静态绑定动态绑定很多人一看到这两个词就头大其实没那么复杂。1. 静态绑定静态绑定指的是在编译阶段就已经确定函数调用目标。一般来说非虚函数调用就是静态绑定。2. 动态绑定动态绑定指的是在运行阶段才确定最终调用哪个函数。一般来说虚函数在满足多态条件时就是动态绑定。3. 用一段代码彻底看懂#includeiostreamusingnamespacestd;classBase{public:virtualvoidf(){coutBase::fendl;}voidg(){coutBase::gendl;}};classDerive:publicBase{public:voidf()override{coutDerive::fendl;}voidg(){coutDerive::gendl;}};intmain(){Derive d;Base*pd;p-f();p-g();return0;}输出结果Derive::f Base::g这题为什么这么经典因为它能一下子把“虚函数”和“非虚函数”的区别讲清楚。p-f()为什么调用Derive::f()因为f()是虚函数所以它参与动态绑定。虽然p的类型是Base*但它实际指向的是Derive对象因此运行时最终会调用Derive::f()。p-g()为什么调用Base::g()因为g()不是虚函数所以它不参与多态。编译器只会看p的静态类型是Base*于是直接把调用目标定为Base::g()。这就是p-f()动态绑定p-g()静态绑定八、override 和 final 有什么用这两个关键字是 C11 引入的和虚函数关系非常密切。1. overrideoverride用来明确告诉编译器这个函数是我打算用来重写基类虚函数的。例如classBase{public:virtualvoidf(){}};classDerive:publicBase{public:voidf()override{}};它最大的好处是防止你以为自己重写成功了实际上根本没有。比如你一不小心写错函数名classBase{public:virtualvoidDrive(){}};classBenz:publicBase{public:voidDirve()override{}};这里会直接报错。因为Dirve并没有重写Drive。这就是override的价值帮你把错误尽早暴露在编译期。2. finalfinal的作用是禁止某个虚函数继续被派生类重写。例如classBase{public:virtualvoidf()final{}};classDerive:publicBase{public:voidf()override{}// 报错};final在平时刷题中没有override用得多但它本质上是一个“限制扩展”的控制手段。九、什么是纯虚函数什么是抽象类如果一个虚函数写成这样virtualvoidDrive()0;那么它就是纯虚函数。而只要一个类中包含纯虚函数这个类就叫抽象类。抽象类最重要的特点抽象类不能实例化对象。例如classCar{public:virtualvoidDrive()0;};下面这样写是错误的Car c;因为Car是抽象类。抽象类的意义是什么你可以把抽象类理解成“统一的接口规范”。它不是为了直接创建对象而是为了告诉派生类“这个行为你必须实现。”比如classCar{public:virtualvoidDrive()0;};classBenz:publicCar{public:voidDrive()override{coutBenz舒适驾驶endl;}};classBMW:publicCar{public:voidDrive()override{coutBMW操控驾驶endl;}};这里Car更像一个“约束”规定所有汽车都必须有Drive()这个行为。十、为什么基类析构函数通常要写成虚函数这一点是多态里非常高频、也非常重要的知识点。来看下面这段代码#includeiostreamusingnamespacestd;classPerson{public:~Person(){cout~Person()endl;}};classStudent:publicPerson{public:Student(){_pnewint[10];}~Student(){cout~Student()endl;delete[]_p;}private:int*_p;};intmain(){Person*pnewStudent;deletep;return0;}看起来没问题但实际上这里埋了一个坑。问题出在哪p的类型是Person*但它实际指向的是Student对象。当执行deletep;如果基类析构函数不是虚函数那么析构时只会按Person*去处理结果就是只调用Person::~Person()不调用Student::~Student()这就意味着Student中申请的资源可能得不到释放。正确写法classPerson{public:virtual~Person(){cout~Person()endl;}};一旦基类析构函数是虚函数删除派生类对象时就会形成“多态析构”先调用派生类析构再调用基类析构这样资源释放顺序才是正确的。注意因为字类的析构函数牵扯到多态所以编译器会默认对Person()进行virtual处理并在student()中自动调用十一、重载、重写、隐藏这三个到底怎么区分这三个概念特别容易混而且一混就容易整章全乱。1. 重载重载发生在同一个作用域中。要求函数名相同参数不同例如voidFunc(intx);voidFunc(doublex);2. 重写重写发生在继承关系中。要求基类函数必须是虚函数派生类函数与之匹配例如classBase{public:virtualvoidf();};classDerive:publicBase{public:voidf()override;};3. 隐藏隐藏也发生在继承关系中。只要派生类定义了和基类同名的函数不管参数相不相同都会把基类同名函数隐藏掉。例如classBase{public:voidg(){}};classDerive:publicBase{public:voidg(){}};这里Derive::g只是隐藏了Base::g并不构成多态。一张表记住三者区别对比项重载重写隐藏是否需要继承否是是函数名是否相同是是是参数是否必须不同是否通常要求一致可同可不同是否要求虚函数否是否是否和多态有关否是否十二、虚函数表和 vfptr 到底是什么很多人学到这里会开始接触两个词vfptrvtable先说结论对象里通常会有一个隐藏指针vfptr它指向虚函数表vtable。而虚函数表中存放的是虚函数地址。1. vfptr 是什么vfptr可以理解成“虚函数表指针”。如果一个类中有虚函数那么编译器通常会在对象中安排一个隐藏成员它就是vfptr。2. vtable 是什么vtable就是“虚函数表”。你可以把它理解成一张表表里存着虚函数对应的地址。如果派生类重写了某个虚函数那么派生类虚表中对应位置就会换成派生类自己的函数地址。3. 为什么它能实现多态因为当我们通过基类指针调用虚函数时程序不会直接把函数写死而是会先从对象里找到vfptr再通过vfptr找到虚表再从虚表里找到要调用的虚函数地址最后执行调用也就是说多态的关键就在于“运行时查表”这一步。十三、对象到底是怎么调用成员函数的这一节非常重要因为很多人学完多态后最困惑的其实不是概念而是函数明明在代码段里对象到底怎么调用它这就要分成普通成员函数和虚函数两种情况来看。1. 普通成员函数怎么调用先记住一句话普通成员函数的代码在代码段里对象中通常不存这类函数本体。对象之所以能调用成员函数靠的是一个隐藏参数this指针。例如b.g();你可以把它近似理解成Base::g(b);也就是说函数代码本身在代码段调用时把对象地址传进去这个隐藏参数就是this所以对象调用普通成员函数并不是因为对象里存了函数而是因为编译器在调用时偷偷传了this。2. 虚函数怎么调用虚函数就不一样了。因为它要支持多态所以不能在编译阶段把目标函数完全写死而是要在运行时从对象里取出vfptr找到虚表再从虚表中取出对应虚函数地址最后调用这个地址并传入this所以你可以这样记普通成员函数目标函数通常编译期确定虚函数目标槽位编译期确定最终地址运行期从虚表取3. 再回到经典代码classBase{public:virtualvoidf(){coutBase::fendl;}voidg(){coutBase::gendl;}};classDerive:publicBase{public:voidf()override{coutDerive::fendl;}voidg(){coutDerive::gendl;}};intmain(){Derive d;Base*pd;p-f();p-g();}这段代码里p-f()查虚表最后调到Derive::fp-g()不查虚表编译器直接按Base*解析成Base::g所以最后输出Derive::f Base::g这也是为什么很多题会同时把虚函数和非虚函数放在一起考。十四、学习多态时最容易犯的错误这一节建议认真看因为很多同学不是不会知识点而是卡在误区里。1. 以为只要继承了就一定有多态错。继承只是前提之一不代表自动发生多态。2. 以为基类指针指向派生类对象就一定调用派生类版本也错。只有调用的是虚函数才会发生动态绑定。3. 把隐藏当成重写错。隐藏只是名字查找层面的事和多态不是一回事。4. 以为对象里直接存着普通成员函数错。普通成员函数代码在代码段对象调用它靠的是this。5. 以为虚表里存的是函数本体也错。虚表里存的是函数地址不是函数本体。函数本体仍然在代码段。十五、总结如果要把 C 多态这一章压缩成几句话我觉得最重要的是下面这些多态的本质是同一接口多种表现形式C 多态分为编译时多态和运行时多态运行时多态成立的关键条件是继承基类指针或引用虚函数派生类重写非虚函数是静态绑定虚函数在满足条件时是动态绑定override用来帮助我们检查是否真正重写成功final用来禁止继续重写纯虚函数和抽象类更像是一种接口约束基类析构函数通常应该写成虚函数虚函数调用背后依赖的是vfptr vtable this十六、最后说几句多态这一章很多人第一次学都会觉得“词很多、概念很绕、题也容易错”。但只要你把下面这几件事真正想通多态到底什么时候成立重写和隐藏有什么区别为什么析构函数要写成虚函数普通成员函数和虚函数的调用流程差在哪那这一章其实就已经吃下来了。

更多文章