C++ 多态:虚函数、重写、虚析构、抽象类与虚表原理
哈喽大家好,面向对象系列上一篇讲完了继承,这篇接着啃面向对象三大特性里最核心的一块——多态。
本篇从"什么是多态"讲起,落到实现多态的两个条件、虚函数与重写、默认参数的坑、析构函数为什么要设成虚函数、override/final、纯虚函数与抽象类,最后再钻进内存看看虚表指针和动态绑定到底怎么运转。口语化讲解,代码均可直接编译,放心食用~
目录
- 一、先分清:静态多态与动态多态
- 二、运行时多态:同一个动作,不同对象不同表现
- 三、实现多态的两个条件
- 四、虚函数:用 virtual 标记出来的函数
- 五、重写(覆盖):虚函数在子类里重新实现
- 5.1 重写要满足什么条件
- 5.2 重写和继承里的隐藏有什么区别
- 5.3 派生类里可以省略 virtual
- 六、完整多态演示:买票
- 七、专题:绝不在虚函数重写时使用默认参数
- 八、为什么基类析构函数一定要设为虚函数
- 九、C++11 两个关键字:override 与 final
- 十、纯虚函数与抽象类
- 十一、多态底层原理:虚表指针、虚表与绑定
- 11.1 对象内存布局里多了一个 vfptr
- 11.2 sizeof 为什么是 16 字节
- 11.3 虚表到底存在哪里
- 11.4 静态绑定与动态绑定
- 十二、总结
一、先分清:静态多态与动态多态
"多态"字面意思就是多种形态。C++ 里多态分两大类:
1. 静态多态(编译时多态)
就是我们之前学过的函数重载和函数模板:传不同类型、不同个数的参数,就调用到不同的函数版本。参数与函数的匹配是在编译期完成的,编译完调用关系就定死了,所以归为"静态"。
2. 动态多态(运行时多态)
到了运行阶段,传不同的对象,就表现出不同的行为,形成多种形态。这才是本篇要讲的主角,下文提到"多态"默认都指运行时多态。
区分要点:静态多态靠参数匹配,编译期定死;动态多态靠对象类型,运行期才决定调谁。
二、运行时多态:同一个行为,不同对象不同表现
生活里到处都是多态:
- 买票:学生优惠、普通人全价、军人优先购票——都是"买票"这个动作,身份不同结果不同;
- 动物叫:小狗"汪汪"、小猫"喵喵"、小鸭"嘎嘎"——都是"叫",物种不同叫声不同。
放到代码里,多态是指:一组存在继承关系的类对象,调用同一个函数,却产生了不同的行为。比如 Student 继承 Person,Person 对象买票全价,Student 对象优惠买票——同一个BuyTicket,对象不同表现不同。
三、实现多态的两个条件
实现动态多态的本质,就是调用虚函数。要跑出多态效果,必须同时满足两个条件:
- 必须用基类的指针或引用去调用虚函数;
- 被调用的虚函数在派生类中完成了重写(覆盖)。
为什么第一条要求必须是基类指针或引用?还是上篇讲的切片原理:子类能赋值给父类,父类不能赋值给子类。只有基类指针/引用,才能既指向基类对象、又指向派生类对象,从而在同一条调用路径上接纳不同对象;反过来用派生类指针指向父类对象是不成立的。从解释中也能看出来,如果仅仅使用子类的指针或引用,去调用该子类中的虚函数是能行的通的,但是为了能一键接受不同对象,必须使用基类指针或引用这个规则其实是我的私活,这也是应用时的推荐写法。使用子类的指针或引用去调用该子类的虚函数,书写十分难受,简直是丧失了多态的意义。
为什么第二条要求重写?派生类必须对基类虚函数给出自己的实现,基类和派生类之间才有不同的函数体,"不同形态"的效果才出得来。
两个条件缺一不可:用基类指针但没重写,调的还是基类函数;重写了但没用基类指针/引用,触发不了动态绑定。
四、虚函数:用 virtual 标记出来的函数
在类成员函数前面加virtual修饰,这个函数就叫虚函数:
classPerson{public:virtualvoidBuyTicket(){cout<<"买票-全价"<<endl;}};注意:virtual只能修饰类的普通成员函数。下面这些都不能加virtual:
- 全局普通函数;
- 静态成员函数(静态函数不属于对象,没有 this 指针,无法进虚表);
- 友元函数(友元不是成员函数);
- 构造函数(对象还没构造完,虚表指针尚未建立)。
析构函数可以而且常常需要是虚函数,这在第八节专门讲。
五、重写(覆盖):虚函数在子类里重新实现
5.1 重写要满足什么条件
重写(也叫覆盖,override):继承关系中,派生类对基类的某个虚函数给出全新的实现。重写要求派生类虚函数与基类虚函数的下列要素一致:
- 函数名相同;
- 参数列表相同;
const属性相同(是否 const 成员函数要一致);- 返回值类型一般相同。
函数体功能不同,这就是重写。
5.2 重写和继承里的隐藏有什么区别
上篇讲过隐藏:派生类与基类出现同名成员函数时,派生类函数自动屏蔽基类同名函数。它和重写是两回事:
| 对比项 | 隐藏(name hiding) | 重写(override) |
|---|---|---|
| 适用函数 | 普通成员函数即可,不要求是虚函数 | 必须是基类虚函数 |
| 匹配要求 | 只看函数名相同就屏蔽,参数不同也隐藏 | 要求函数名、参数、const 全部一致 |
| 意图 | 编译器的名称遮蔽规则,避免意外重载干扰 | 开发者有意识地为不同对象提供不同实现 |
| 调用方式 | 调被隐藏的基类函数要显式写基类::函数名 | 靠基类指针/引用 + 虚表动态绑定,自动调对应版本 |
工程上如果靠显式写类域去调被隐藏的函数,代码会又难写又难维护,基类指针很多场景下还不支持,所以上篇才建议:尽量不要在派生类里定义和基类同名的非虚函数。而多态要的"同名",是建立在虚函数和重写之上的、主动设计的同名。
5.3 派生类里可以省略 virtual
重写时,派生类的虚函数不加virtual关键字也算重写——virtual属性会随基类成员函数一起继承下来,视为函数声明的一部分。反过来,基类不加virtual就构不成虚函数,自然谈不上重写。
不过规范写法仍然建议派生类和基类都写上virtual,再配合下一节的override关键字,一眼能看出这是重写关系。
六、完整多态演示:买票
下面用 Person、Student、Soldier 三个类演示多态:基类指针分别指向不同对象,调用同一个BuyTicket,各自表现不同。
#include<iostream>usingnamespacestd;classPerson{public:virtualvoidBuyTicket(){cout<<"买票-全价"<<endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout<<"买票-优惠价"<<endl;}};classSoldier:publicPerson{public:virtualvoidBuyTicket(){cout<<"买票-优先购票"<<endl;}};intmain(){Person*p;Student s;Soldier so;p=&s;p->BuyTicket();// 指向学生:优惠价p=&so;p->BuyTicket();// 指向军人:优先购票Person&ref=s;// 引用同样能触发多态ref.BuyTicket();return0;}运行结果:
买票-优惠价 买票-优先购票 买票-优惠价同一条p->BuyTicket(),p 指向谁就调谁的版本,这就是运行时多态。
七、专题:绝不在虚函数重写时使用默认参数
先看一个经典示例:
#include<iostream>usingnamespacestd;classA{public:virtualvoidfunc(intval=1){std::cout<<"A->"<<val<<std::endl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(intval=0)override{std::cout<<"B->"<<val<<std::endl;}};intmain(){B*p=newB;p->test();deletep;return0;}按直觉分析:p 指向 B 对象,test()自己没写、继承自 A,test()内部调func();func()被 B 重写了,应该走 B 的版本,B 的默认参数是 0,于是输出B->0。
但是下面是运行结果,答案是B->0。
—![]()
图 1:运行结果
实际运行结果是B->1。 为什么?
虚函数的声明部分是继承自基类函数,也就是声明和基类函数一模一样。
继承首要的就是把声明部分全搬过来,然后有实现就搬实现,是虚函数就自己实现,但是声明部分不变,还是搬过来,而不是自己实现
那搬的什么呢? virtual void func(int val = 1) 这是基类A中func的全部声明
B类中结合上我们的实现,B中的func函数到底是什么呢?
virtualvoidfunc(intval=1){std::cout<<"B->"<<val<<std::endl;}这才是B的func函数的真身,所以答案是B->1 。
所以:函数体跟着对象走(动态),缺省参数跟着基类定义类型走(静态),两者错开,才出现"B 的函数体、A 的默认值"这种割裂结果。
**结论:绝不在虚函数重写时给缺省默认参数。给也要给相同值。
八、为什么基类析构函数一定要设为虚函数
8.1 父子析构函数的关系
上篇讲过,基类和派生类的析构函数构成隐藏关系:底层编译器把两个析构函数的名字统一处理成destructor,名字相同,在名称查找上构成隐藏。
如果基类析构不是虚函数,就构不成多态:用基类指针delete一个派生类对象时,只能调用到基类析构,派生类析构被跳过,派生类里申请的资源就没人释放,造成内存泄漏。
8.2 基类析构设为 virtual 后的规则
当基类析构函数是虚函数时,派生类析构函数只要定义出来,无论有没有写virtual、也无论两者名字不同,都与基类析构构成重写。虽然~A()和~B()看起来名字不一样,但编译器对析构函数名字做了特殊处理,编译后统一成destructor,所以仍然满足重写规则。
8.3 正确写法与反例对比
正确写法(基类析构加 virtual):
#include<iostream>usingnamespacestd;classA{public:virtual~A(){cout<<"~A()"<<endl;}};classB:publicA{public:~B()override{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;}delete p2时构成多态,先正确调用派生类~B()释放数组资源,再调用基类~A(),顺序为先子后父。
反例(把virtual去掉):
classA{public:~A(){cout<<"~A()"<<endl;}// 不是虚函数};此时A* p2 = new B; delete p2;只会调用~A(),~B()被跳过,_p指向的数组得不到释放,直接泄漏。
工程准则:只要一个类可能被当作基类、并且派生类自己申请了资源,就把基类析构函数声明为
virtual。
九、C++11 两个关键字:override 与 final
C++11 提供了两个辅助重写与继承控制的关键字。
9.1 override:帮你检查是否真的重写成功
C++ 对虚函数重写的签名要求很严格,但手写时偶尔会因为笔误(参数写错、const 漏写、名字拼错)以为自己重写了,实际只是形成了隐藏,编译器又不一定报错。
在派生类函数后面加override,编译器就会去检查:这个函数是否真的重写了基类的某个虚函数;签名对不上就直接报错:
classBase{public:virtualvoidShow(){}};classDerived:publicBase{public:voidShow()override{}// 确认重写 Base::Show;签名写错这里就会报错};9.2 final:禁止继续重写或禁止被继承
final有两种用法:
- 修饰虚函数:派生类不能再重写这个虚函数;
- 修饰类:该类成为最终类,不能被继承(上一篇继承讲过)。
classBase{public:virtualvoidShow(){}virtualvoidHelper()final{}// 派生类不能再重写 Helper};classDerivedfinal:publicBase// Derived 不能再被别的类继承{public:voidShow()override{}};小提示:
final写在类名后面、override写在函数参数列表后面,位置别搞混。
十、纯虚函数与抽象类
10.1 纯虚函数与抽象类的规则
在虚函数后面加= 0,这个函数就叫纯虚函数。纯虚函数可以只声明、不给出实现(基类确实没必要实现它)。
- 包含纯虚函数的类叫抽象类,抽象类不允许被实例化;
- 派生类继承后,如果没有重写这个纯虚函数,那么派生类仍然是抽象类,照样不能实例化;
- 纯虚函数相当于强制派生类去重写——不重写就造不出对象。
10.2 完整示例:车辆
#include<iostream>usingnamespacestd;classCar{public:virtualvoidDrive()=0;// 纯虚函数};classBenz:publicCar{public:voidDrive()override{cout<<"Benz-舒适"<<endl;}};classTesla:publicCar{public:voidDrive()override{cout<<"Tesla-操控"<<endl;}};intmain(){// Car car; // error C2259: 无法实例化抽象类 "Car"Car*pBenz=newBenz;pBenz->Drive();// Benz-舒适Car*pTesla=newTesla;pTesla->Drive();// Tesla-操控deletepBenz;deletepTesla;return0;}Car作为抽象类只负责定义"车都能开"这个接口规范,具体怎么开由 Benz、Tesla 各自实现。这正是接口设计思路的基础。
十一、多态底层原理:虚表指针、虚表与绑定
前面是使用层面的多态,掌握前面的知识,使用多态就不会有太大问题了
但多态的内存也十分吸引人,现在我们开始看内存,看看运行时凭什么能"对象不同调不同版本"。
11.1 对象内存布局里多了一个 vfptr
#include<iostream>usingnamespacestd;classA{public:virtualvoidfunc(){}protected:int_num1=1;char_ch1='c';};intmain(){A a;cout<<sizeof(a)<<endl;return0;}一个类只要含有虚函数,对象里就会多出一个成员:虚函数表指针(v 代表 virtual,f 代表 function,简称 vfptr)。它指向一张虚函数表(虚表,vtable)——本质是一个函数指针数组,存放该类各个虚函数的地址。
分清两个概念:
- vfptr:属于对象,是对象内存里的一个指针;
- vtable(虚表):属于类,是一张函数指针数组,同一个类的所有对象共享这一张表。
11.2 sizeof 为什么是 16 字节
在 64 位环境下,A对象的布局大致是:
| 成员 | 字节 |
|---|---|
| vfptr 虚表指针 | 8 字节 |
_num1(int) | 4 字节 |
_ch1(char) | 1 字节 |
| 对齐填充 | 3 字节 |
| 合计 | 16 字节 |
_num1和_ch1按对齐规则凑成 8 字节,加上 8 字节的 vfptr,正好 16。如果只看普通成员,怎么对齐都凑不到 16,多出来的 8 字节正是这个 vfptr。
补充:vfptr 在对象中的位置与平台有关,多数平台放在对象最前面,有些平台放在最后面;32 位环境下指针只有 4 字节,算出来的
sizeof会不一样,以实际调试窗口为准。
11.3 虚表到底存在哪里
虚表不属于某个对象,而是类级别的一份静态数据,存放在程序的只读常量区(.rdata,属于全局静态存储区的只读部分)。同一个类的所有对象,vfptr 都指向这同一张虚表;派生类重写虚函数后,自己的虚表中对应位置存放重写后的函数地址。
11.4 静态绑定与动态绑定
把第一节和底层原理串起来:
- 静态绑定(编译期绑定):函数调用在编译阶段就确定好,普通函数、函数重载、普通成员函数,以及前面讲的默认参数都属于这一类;
- 动态绑定(运行期绑定):通过对象内的 vfptr 找到虚表,再按虚表中的函数地址决定调用谁,虚函数 + 多态走的就是这条路径。
调用虚函数时,大致过程是:先从对象里拿到 vfptr → 找到对应虚表 → 按函数在表中的位置取出地址 → 跳转执行。基类对象和派生类对象的虚表内容不同,于是动态绑定到了不同版本,多态就此实现。
底层汇编和虚表布局可以在调试器(VS 的内存/监视窗口)里自己观察一遍,印象会深很多,但是基础学习还是不建议先看这个,没必要,本篇先把主干原理交代清楚,其实我也没太研究细致。
总之关于底层原理,我有机会再来扩充,看到这句话的时候就代表我还没扩充呢。
十二、总结
- 多态分两类:静态多态(重载、模板,编译期匹配);动态多态(本篇主角,运行期按对象表现不同行为)。
- 两个条件:基类指针或引用调用虚函数;派生类完成重写。缺一不可。
- 虚函数:
virtual修饰的类成员函数;全局函数、静态成员函数、友元函数、构造函数都不能是虚函数。 - 重写:函数名、参数、const 属性一致,返回值一般相同;和隐藏不是一回事,隐藏不需要虚函数、只看名字相同。
- 默认参数静态绑定:函数体动态绑定、默认值静态绑定,重写时两边默认参数必须保持一致,否则出隐蔽 bug。
- 析构虚函数:基类析构声明为
virtual,派生类析构自动构成重写;否则基类指针delete派生类对象会跳过派生析构、泄漏资源。 - override检查签名是否真的重写;final禁止继续重写虚函数或禁止类被继承。
- 纯虚函数
= 0,含纯虚函数的类是抽象类,不能实例化;派生类不重写仍是抽象类,相当于强制接口规范。 - 底层原理:含虚函数的对象多一个 vfptr 指向类共享的虚表;虚表存只读常量区;函数体经虚表动态绑定,默认参数走静态绑定。
把买票示例和车辆抽象类示例自己敲一遍,再在调试窗口观察一次虚表,多态这关就吃透了。
如果这篇对你有帮助,别忘了点赞收藏关注三连~ 面向对象系列下一篇咱们接着深挖!
参考:C++ 运行时多态、虚函数与重写、默认参数静态绑定、虚析构、override/final、纯虚函数与抽象类、虚表指针与动态绑定