news 2026/6/8 14:04:10

C++ 多态:同一调用,不同行为

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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*ptr=&st;// 静态类型 Person*,动态类型 Student*Person&ref=st;// 静态类型 Person&,动态类型 Student&Person obj=st;// 切片!静态类型和动态类型都是 Person——多态对它无效ptr->BuyTicket();// 动态绑定 → 调用 Student::BuyTicket ✓obj.BuyTicket();// 静态绑定 → 调用 Person::BuyTicket ✗

理解这一对概念,才能理解为什么多态条件第一条必须是"基类的指针或引用"——值语义的对象,根本不存在动态类型。

📖 参考:《C++ Primer》第15章

多态的构成条件

要让运行时多态生效,必须同时满足两个条件:

  1. 调用方式:必须通过基类的指针或引用调用虚函数
  2. 函数定义:被调用的函数必须是虚函数,且派生类完成了重写(Override)

两个条件缺一个,多态就不生效——编译器会退回到静态绑定。

虚函数

在类成员函数前加virtual关键字,该函数就成为虚函数:

classPerson{public:virtualvoidBuyTicket(){cout<<"买票-全价"<<endl;}};

⚠️ 非成员函数不能加virtual

虚函数的重写

派生类中定义一个与基类虚函数返回值类型、函数名、参数列表完全相同的函数,就构成了重写/覆盖(Override)

classPerson{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()const=0;// 纯虚:你只需知道"Shape 可以绘制"virtualvoiderror(conststring&msg);// 普通虚:有默认错误处理,可重写intobjectID()const{return_id;}// 非虚:所有 Shape 共用,不可重写private:int_id;};

纯虚函数也可以有实现体——派生类通过Base::func()显式调用基类的默认实现。但这种情况极少见,了解即可。

📖 参考:《Effective C++》条款34

多态的另一种实现:NVI 模式

传统的多态是"public 虚函数 + 派生类重写"。但还有一种更安全的设计——NVI(Non-Virtual Interface,非虚接口),也即 Template Method 模式:

classGameCharacter{public:inthealthValue()const{// public 非虚函数——固定框架// 前置工作:加锁、日志、参数验证……intretVal=doHealthValue();// 调用派生类的重写// 后置工作:解锁、验证后置条件……returnretVal;}private:virtualintdoHealthValue()const=0;// 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(constAnimal&animal){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:"<<_p<<endl;delete_p;// 释放资源}protected:int*_p=newint[10];};intmain(){A*p2=newB;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:"<<_p<<endl;delete_p;}protected:int*_p=newint[10];};intmain(){A*p1=newA;A*p2=newB;deletep1;// ~A()deletep2;// ~B() → ~A() 正确!先派生类,后基类return0;}

为什么基类的析构函数和派生类析构函数函数名不同却构成重写?因为编译器会把所有析构函数名统一处理成destructor,所以它们本质上是同名函数——满足重写的条件。

📖 参考:《高质量C++/C编程指南》第9章

铁律:只要一个类被设计为基类(会被继承),它的析构函数就必须是 virtual。

override 和 final:C++11 的安全网

虚函数重写的要求很严格——返回值类型、函数名、参数列表必须完全一致。有时函数名拼错一个字母、参数列表差一个const,编译器不会报错,但你并没有真正完成重写,多态静悄悄地失效了。

C++11 提供了两个关键字来堵上这个口子:

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{cout<<"Benz-舒适"<<endl;}};classBMW:publicCar{public:virtualvoidDrive()override{cout<<"BMW-操控"<<endl;}};intmain(){// Car car; // ❌ 编译报错:抽象类不能实例化Car*pBenz=newBenz;pBenz->Drive();// Benz-舒适Car*pBMW=newBMW;pBMW->Drive();// BMW-操控return0;}

纯虚函数的作用:强制派生类重写该函数。不重写?派生类也是抽象类,照样实例化不了。这是一种契约——“想用这个继承体系,就必须实现这些接口。”

多态的原理:虚函数表

多态不是魔法。底层依赖的机制叫虚函数表(Virtual Function Table,简称虚表 / vtable)

虚函数表指针

一个包含虚函数的类,其每个对象中都会多出一个隐藏的指针——虚函数表指针(__vfptr,指向该类共享的虚函数表。用sizeof就能验证:

classBase{public:virtualvoidFunc1(){cout<<"Func1()"<<endl;}protected:int_b=1;char_ch='x';};intmain(){cout<<sizeof(Base)<<endl;// 32位环境输出 12(而不是 5+1+对齐=8)return0;}

多出来的空间就是__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) (假设不是虚函数)

派生类虚表的构成

派生类的虚函数表包含三个部分:

  1. 基类的虚函数地址(未被重写的,原样保留)
  2. 派生类重写的虚函数地址(覆盖掉基类对应位置)
  3. 派生类自己新增的虚函数地址
classBase{public:virtualvoidfunc1(){cout<<"Base::func1"<<endl;}virtualvoidfunc2(){cout<<"Base::func2"<<endl;}voidfunc5(){cout<<"Base::func5"<<endl;}// 普通函数——不在虚表中protected:inta=1;};classDerive:publicBase{public:virtualvoidfunc1()override{cout<<"Derive::func1"<<endl;}// 覆盖 Base::func1virtualvoidfunc3(){cout<<"Derive::func3"<<endl;}// 新增voidfunc4(){cout<<"Derive::func4"<<endl;}// 普通函数——不在虚表中protected:intb=2;};

Derive的虚表:[ Derive::func1, Base::func2, Derive::func3, 0x00000000 ]

几个关键事实:

  • 虚函数和普通函数一样,编译后都是代码段中的指令。区别只在于虚函数的地址又存到了虚表中。
  • 虚表在 VS 编译器下存在代码段(常量区),属于类级别数据——同类型的所有对象共享一张虚表。
  • VS 编译器的虚表末尾通常放0x00000000作为结束标记,g++ 不这样做——这是编译器实现细节,C++ 标准未规定。

运行时类型识别:dynamic_cast

多态让我们通过基类指针调用派生类的虚函数。但如果需要访问派生类独有的成员(不在基类接口中的),就必须把基类指针安全地转回派生类指针——这就是dynamic_cast的用武之地:

voidprocessPerson(Person*ptr){// 尝试安全转型为 Student*if(Student*sp=dynamic_cast<Student*>(ptr)){// 转型成功——ptr 确实指向 Studentcout<<"学号: "<<sp->_stuid<<endl;sp->study();// 调用 Student 独有成员}elseif(Soldier*sop=dynamic_cast<Soldier*>(ptr)){cout<<"代号: "<<sop->_codename<<endl;}else{// 普通 Personcout<<"全价购票"<<endl;}}

几个要点:

  • dynamic_cast指针失败时返回nullptr——用if判断即可
  • dynamic_cast引用失败时抛出std::bad_cast——需要用try/catch
  • dynamic_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(intcolor=0)const{cout<<"Shape::draw, color="<<color<<endl;}};classCircle:publicShape{public:virtualvoiddraw(intcolor=255)constoverride{// 重新定义了默认参数!cout<<"Circle::draw, color="<<color<<endl;}};intmain(){Shape*ps=newCircle;ps->draw();// 输出:Circle::draw, color=0// ^^^^^^ 动态绑定 → 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章(多态公有继承)


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/8 13:59:46

DeepSeek OCR:PDF语义解析替代传统图像OCR

1. 项目概述&#xff1a;当文档处理成本从“不敢细算”变成“随手可做”你有没有算过&#xff0c;公司里那台每天吞掉几百份扫描件的发票识别系统&#xff0c;背后每月烧掉多少真金白银&#xff1f;我上个月帮一家中型制造企业做流程审计&#xff0c;他们用的是主流云OCR大模型…

作者头像 李华
网站建设 2026/6/8 13:55:10

MC68HC05K3 EEPROM编程:解析AN1288 K3EEPROG工具与底层硬件操作

1. 项目概述与背景如果你手头还有一块飞思卡尔&#xff08;Freescale&#xff0c;现为NXP&#xff09;的MC68HC05K3老芯片&#xff0c;想给它内部的Personality EEPROM&#xff08;可编程电可擦除只读存储器&#xff09;烧点配置数据&#xff0c;可能会发现官方工具早已消失在历…

作者头像 李华
网站建设 2026/6/8 13:52:14

基于HC908KX8 MCU的冰箱智能温控系统:从机械温控到电子算法的跨越

1. 项目概述与核心价值十几年前&#xff0c;当我第一次拆开家里的老式冰箱&#xff0c;看到里面那个简单的双金属片温控器时&#xff0c;我就在想&#xff0c;能不能用更智能、更精准的方式来做这件事。后来接触了飞思卡尔&#xff08;现为NXP的一部分&#xff09;的HC908系列微…

作者头像 李华
网站建设 2026/6/8 13:51:10

告别IDE调试器适配噩梦:用DAP协议统一你的VSCode、PyCharm和GDB

告别IDE调试器适配噩梦&#xff1a;用DAP协议统一你的VSCode、PyCharm和GDB你是否经历过这样的场景&#xff1f;团队里前端用VSCode调试JavaScript&#xff0c;后端用PyCharm调试Python&#xff0c;偶尔还要切到CLI用GDB排查嵌入式C代码问题。每切换一次环境&#xff0c;就得重…

作者头像 李华