C++ 多态:同一调用,不同行为
运行期才揭晓答案——这不是不确定性这是多态。为什么需要多态假设你在写一个售票系统。买票这个行为不同身份的人执行时逻辑不同普通人全价学生打折军人优先。没有多态的时候你会怎么写大概是一个switch判断身份类型然后分别处理。每增加一种身份就要找到所有switch、所有if-else——代码越加越散分支越铺越乱。多态要解决的问题就是传不同的对象同一个函数调用产生不同的行为。// 多态的目标不管 ptr 指向谁这一行代码自动调到正确的函数ptr-BuyTicket();// ptr 指向 Person → 全价// ptr 指向 Student → 打折// ptr 指向 Soldier → 优先C 中的多态分为两类类型别称机制绑定时机编译时多态静态多态函数重载、函数模板编译期确定运行时多态动态多态虚函数 继承运行期确定编译时多态前面已经讲过重载和模板本文聚焦运行时多态——这才是 C 面向对象的核心。插一个关键概念静态类型 vs 动态类型在深入多态之前必须理解一对基础概念。每个变量都有两种类型静态类型Static Type声明时写下的类型编译期确定永远不会变动态类型Dynamic Type实际指向/引用的对象类型运行期可变只有指针和引用才有动态类型的区分。普通对象被赋值给基类变量时派生类部分会被切掉Sliced Down——多态失效Student st;Person*ptrst;// 静态类型 Person*动态类型 Student*Personrefst;// 静态类型 Person动态类型 StudentPerson objst;// 切片静态类型和动态类型都是 Person——多态对它无效ptr-BuyTicket();// 动态绑定 → 调用 Student::BuyTicket ✓obj.BuyTicket();// 静态绑定 → 调用 Person::BuyTicket ✗理解这一对概念才能理解为什么多态条件第一条必须是基类的指针或引用——值语义的对象根本不存在动态类型。 参考《C Primer》第15章多态的构成条件要让运行时多态生效必须同时满足两个条件调用方式必须通过基类的指针或引用调用虚函数函数定义被调用的函数必须是虚函数且派生类完成了重写Override两个条件缺一个多态就不生效——编译器会退回到静态绑定。虚函数在类成员函数前加virtual关键字该函数就成为虚函数classPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}};⚠️ 非成员函数不能加virtual。虚函数的重写派生类中定义一个与基类虚函数返回值类型、函数名、参数列表完全相同的函数就构成了重写/覆盖OverrideclassPerson{public:virtualvoidBuyTicket(){cout买票-全价endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout买票-打折endl;}// 重写};classSoldier:publicPerson{public:virtualvoidBuyTicket(){cout买票-优先endl;}// 重写};voidFunc(Person*ptr){ptr-BuyTicket();// 多态调用——实际行为由 ptr 指向的对象决定}intmain(){Person ps;Student st;Soldier sr;Func(ps);// 输出买票-全价Func(st);// 输出买票-打折Func(sr);// 输出买票-优先return0;}派生类重写虚函数时virtual关键字可以省略基类的虚函数属性会被继承下来但不推荐省略——显式写出virtual让意图一目了然。接口继承 vs 实现继承三种虚函数的语义基类中的三种成员函数代表了三种不同的契约强度函数类型继承了什么派生类的义务纯虚函数 0只继承接口派生类必须提供实现除非它也是抽象类普通虚函数virtual继承接口 默认实现派生类可以重写也可以直接继承默认行为非虚函数继承接口 强制实现派生类不应重写——这是基类承诺的不变性classShape{public:virtualvoiddraw()const0;// 纯虚你只需知道Shape 可以绘制virtualvoiderror(conststringmsg);// 普通虚有默认错误处理可重写intobjectID()const{return_id;}// 非虚所有 Shape 共用不可重写private:int_id;};纯虚函数也可以有实现体——派生类通过Base::func()显式调用基类的默认实现。但这种情况极少见了解即可。 参考《Effective C》条款34多态的另一种实现NVI 模式传统的多态是public 虚函数 派生类重写。但还有一种更安全的设计——NVINon-Virtual Interface非虚接口也即 Template Method 模式classGameCharacter{public:inthealthValue()const{// public 非虚函数——固定框架// 前置工作加锁、日志、参数验证……intretValdoHealthValue();// 调用派生类的重写// 后置工作解锁、验证后置条件……returnretVal;}private:virtualintdoHealthValue()const0;// private 虚函数——派生类在此注入行为};classKnight:publicGameCharacter{private:virtualintdoHealthValue()constoverride{return100;}};NVI 的优势基类在调用虚函数前后执行固定的控制逻辑加锁、日志、前后置条件检查派生类只能填写具体行为无法绕过框架。这是一种框架调用你不是你调用框架的设计。 参考《Effective C》条款35同样的模式另一个经典示例classAnimal{public:virtualvoidtalk()const{}};classDog:publicAnimal{public:virtualvoidtalk()const{cout汪汪endl;}};classCat:publicAnimal{public:virtualvoidtalk()const{cout(^ω^)喵endl;}};voidletsHear(constAnimalanimal){animal.talk();// 引用调用——同样构成多态}intmain(){Cat cat;Dog dog;letsHear(cat);// (^ω^)喵letsHear(dog);// 汪汪return0;}析构函数的重写最容易忽视的坑面试必考题。先看一段有问题的代码classA{public:~A(){cout~A()endl;}// 注意没有 virtual};classB:publicA{public:~B(){cout~B()-delete:_pendl;delete_p;// 释放资源}protected:int*_pnewint[10];};intmain(){A*p2newB;deletep2;// 只调用了 ~A()B 的资源泄漏了return0;}delete p2只调用了基类A的析构函数B的析构函数没有被调用——_p指向的 10 个int永远泄漏了。原因编译器在编译期根据指针的静态类型A*决定调用哪个析构函数——静态绑定。解决方案基类析构函数加virtual。classA{public:virtual~A(){cout~A()endl;}// 加 virtual};classB:publicA{public:~B(){// 自动构成重写virtual 可省略cout~B()-delete:_pendl;delete_p;}protected:int*_pnewint[10];};intmain(){A*p1newA;A*p2newB;deletep1;// ~A()deletep2;// ~B() → ~A() 正确先派生类后基类return0;}为什么基类的析构函数和派生类析构函数函数名不同却构成重写因为编译器会把所有析构函数名统一处理成destructor所以它们本质上是同名函数——满足重写的条件。 参考《高质量C/C编程指南》第9章铁律只要一个类被设计为基类会被继承它的析构函数就必须是 virtual。override 和 finalC11 的安全网虚函数重写的要求很严格——返回值类型、函数名、参数列表必须完全一致。有时函数名拼错一个字母、参数列表差一个const编译器不会报错但你并没有真正完成重写多态静悄悄地失效了。C11 提供了两个关键字来堵上这个口子override声明我在重写classCar{public:virtualvoidDirve(){}// 拼写错误Dirve 而非 Drive};classBenz:publicCar{public:virtualvoidDrive()override{}// ❌ 编译报错没有重写任何基类方法};加上override后编译器会检查这个函数是否真的重写了基类的虚函数。如果没有——拼写错误、参数不匹配、const 不一致——直接报错。所有重写虚函数的地方都应该加 override。final声明到此为止classCar{public:virtualvoidDrive()final{}// 不允许派生类再重写};classBenz:publicCar{public:virtualvoidDrive(){}// ❌ 编译报错final 函数无法被重写};final也可以修饰类本身——class Base final {}——禁止任何类继承它。重载、重写、隐藏——三者的清晰边界这是考试和面试的高频考点。一张表说清楚重载Overload重写Override隐藏Hide作用域同一作用域内同一个类中基类与派生类之间基类与派生类之间函数名相同相同相同参数列表必须不同必须相同可以相同也可以不同返回值可以不同必须相同协变例外可以不同virtual不要求基类函数必须是 virtual不要求关系同一函数名的不同版本替换基类的实现派生类名字遮住基类名字 一个快速判断的口诀同一类内名字同参数不同 重载。跨类 virtual 重写。跨类非 virtual 或名字同 隐藏。纯虚函数与抽象类在虚函数声明末尾加上 0它就是纯虚函数Pure Virtual Function。包含纯虚函数的类叫抽象类Abstract Class——抽象类不能实例化。classCar{public:virtualvoidDrive()0;// 纯虚函数只声明接口不提供实现};classBenz:publicCar{public:virtualvoidDrive()override{coutBenz-舒适endl;}};classBMW:publicCar{public:virtualvoidDrive()override{coutBMW-操控endl;}};intmain(){// Car car; // ❌ 编译报错抽象类不能实例化Car*pBenznewBenz;pBenz-Drive();// Benz-舒适Car*pBMWnewBMW;pBMW-Drive();// BMW-操控return0;}纯虚函数的作用强制派生类重写该函数。不重写派生类也是抽象类照样实例化不了。这是一种契约——“想用这个继承体系就必须实现这些接口。”多态的原理虚函数表多态不是魔法。底层依赖的机制叫虚函数表Virtual Function Table简称虚表 / vtable。虚函数表指针一个包含虚函数的类其每个对象中都会多出一个隐藏的指针——虚函数表指针__vfptr指向该类共享的虚函数表。用sizeof就能验证classBase{public:virtualvoidFunc1(){coutFunc1()endl;}protected:int_b1;char_chx;};intmain(){coutsizeof(Base)endl;// 32位环境输出 12而不是 51对齐8return0;}多出来的空间就是__vfptr的大小32位下 4 字节64位下 8 字节。动态绑定如何运作当通过基类指针调用虚函数时ptr-BuyTicket();编译器不再在编译期确定函数地址而是生成这样的逻辑运行时去 ptr 指向的对象的虚表中查找BuyTicket的实际地址然后调用它。这就是动态绑定Dynamic Binding——函数地址在运行时才确定。与之相对的是静态绑定Static Binding——不满足多态条件的函数调用编译器在编译期就直接确定了跳转地址。从汇编码可以清楚地看到区别// 动态绑定——通过虚表间接调用ptr-BuyTicket();// mov eax, dword ptr [ptr] → mov edx, dword ptr [eax] → call eax// 静态绑定——直接调用已知地址ptr-BuyTicket();// call Student::Student (0EA153Ch) (假设不是虚函数)派生类虚表的构成派生类的虚函数表包含三个部分基类的虚函数地址未被重写的原样保留派生类重写的虚函数地址覆盖掉基类对应位置派生类自己新增的虚函数地址classBase{public:virtualvoidfunc1(){coutBase::func1endl;}virtualvoidfunc2(){coutBase::func2endl;}voidfunc5(){coutBase::func5endl;}// 普通函数——不在虚表中protected:inta1;};classDerive:publicBase{public:virtualvoidfunc1()override{coutDerive::func1endl;}// 覆盖 Base::func1virtualvoidfunc3(){coutDerive::func3endl;}// 新增voidfunc4(){coutDerive::func4endl;}// 普通函数——不在虚表中protected:intb2;};Derive的虚表[ Derive::func1, Base::func2, Derive::func3, 0x00000000 ]几个关键事实虚函数和普通函数一样编译后都是代码段中的指令。区别只在于虚函数的地址又存到了虚表中。虚表在 VS 编译器下存在代码段常量区属于类级别数据——同类型的所有对象共享一张虚表。VS 编译器的虚表末尾通常放0x00000000作为结束标记g 不这样做——这是编译器实现细节C 标准未规定。运行时类型识别dynamic_cast多态让我们通过基类指针调用派生类的虚函数。但如果需要访问派生类独有的成员不在基类接口中的就必须把基类指针安全地转回派生类指针——这就是dynamic_cast的用武之地voidprocessPerson(Person*ptr){// 尝试安全转型为 Student*if(Student*spdynamic_castStudent*(ptr)){// 转型成功——ptr 确实指向 Studentcout学号: sp-_stuidendl;sp-study();// 调用 Student 独有成员}elseif(Soldier*sopdynamic_castSoldier*(ptr)){cout代号: sop-_codenameendl;}else{// 普通 Personcout全价购票endl;}}几个要点dynamic_cast对指针失败时返回nullptr——用if判断即可dynamic_cast对引用失败时抛出std::bad_cast——需要用try/catchdynamic_cast有运行时开销——需要遍历继承树来验证类型。不要在高频循环中使用频繁出现dynamic_cast往往是设计不良的信号——考虑用虚函数替代类型分支// 更好的设计把行为放进虚函数消除 dynamic_castclassPerson{public:virtualvoiddisplayInfo()const{cout全价endl;}virtual~Person()default;};classStudent:publicPerson{public:virtualvoiddisplayInfo()constoverride{cout学号: _stuid半价endl;}protected:int_stuid;}; 参考《C Primer》第15章虚函数的默认参数一个隐蔽的陷阱C 中有一条规则让无数人踩过坑虚函数是动态绑定但默认参数值是静态绑定。classShape{public:virtualvoiddraw(intcolor0)const{coutShape::draw, colorcolorendl;}};classCircle:publicShape{public:virtualvoiddraw(intcolor255)constoverride{// 重新定义了默认参数coutCircle::draw, colorcolorendl;}};intmain(){Shape*psnewCircle;ps-draw();// 输出Circle::draw, color0// ^^^^^^ 动态绑定 → Circle 的函数体// ^^^ 静态绑定 → Shape 声明的默认值 0deleteps;return0;}ps-draw()做了两件事确定调用哪个函数体动态绑定 →Circle::draw确定默认参数值静态绑定 →Shape声明的0。两者来源不同——行为必然错乱。解决方案永远不要改变重写虚函数的默认参数值。如果确实需要不同默认行为用 NVI 模式替代。 参考《Effective C》条款37常见误区误区一“加 virtual 就会影响性能所以少用”虚函数调用的开销是一次额外的指针解引用通过虚表跳转。这跟多态带来的设计清晰度和可维护性相比完全值得。只有当 profiling 证明虚函数调用是热点时才考虑优化。误区二“派生类重写虚函数可以不写 virtual”语法上确实可以不写——基类的 virtual 属性会被继承。但缺少virtual让阅读者需要回溯到基类才能确认这是虚函数。统一写法重写虚函数时加上virtual再叠加override。误区三“析构函数没必要 virtual我又不用基类指针 delete”只要一个类可能被继承就有人会在未来某天用基类指针持有派生类对象。等到资源泄漏发生debug 的成本远比加一个virtual高昂。误区四“纯虚函数不能有实现”语法上纯虚函数可以有实现体——virtual void f() 0 { /* ... */ }。但调用它的方式只能是Base::f()这种显式限定调用。实际项目中几乎不需要这么做了解即可。本节要点多态 基类指针/引用 虚函数重写。两个条件缺一不可基类析构函数务必加virtual——否则delete基类指针时派生类资源永远泄漏重写虚函数一律加override——让编译器替你检查别相信自己的拼写重载/重写/隐藏是三个不同维度的概念同一作用域 vs 跨作用域、参数不同 vs 相同、virtual vs 非 virtual纯虚函数 接口契约。抽象类不能实例化派生类不重写就别想用多态的底层是虚表——编译期不用确定函数地址运行期查表 参考《高质量C/C编程指南》第9章虚析构函数《Effective C》条款34-37接口继承、虚函数替代方案、非虚函数、默认参数、第7-9章编译期多态 vs 运行期多态《C Primer》第15章面向对象程序设计《C Primer Plus》第13章多态公有继承