ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【 C++ 多态】虚函数、重写、虚析构、抽象类与虚表原理

【 C++ 多态】虚函数、重写、虚析构、抽象类与虚表原理

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,对象不同表现不同。


三、实现多态的两个条件

实现动态多态的本质,就是调用虚函数。要跑出多态效果,必须同时满足两个条件:

  1. 必须用基类的指针或引用去调用虚函数;
  2. 被调用的虚函数在派生类中完成了重写(覆盖)。

为什么第一条要求必须是基类指针或引用?还是上篇讲的切片原理:子类能赋值给父类,父类不能赋值给子类。只有基类指针/引用,才能既指向基类对象、又指向派生类对象,从而在同一条调用路径上接纳不同对象;反过来用派生类指针指向父类对象是不成立的。从解释中也能看出来,如果仅仅使用子类的指针或引用,去调用该子类中的虚函数是能行的通的,但是为了能一键接受不同对象,必须使用基类指针或引用这个规则其实是我的私活,这也是应用时的推荐写法。使用子类的指针或引用去调用该子类的虚函数,书写十分难受,简直是丧失了多态的意义。
为什么第二条要求重写?派生类必须对基类虚函数给出自己的实现,基类和派生类之间才有不同的函数体,"不同形态"的效果才出得来。

两个条件缺一不可:用基类指针但没重写,调的还是基类函数;重写了但没用基类指针/引用,触发不了动态绑定。


四、虚函数:用 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 的内存/监视窗口)里自己观察一遍,印象会深很多,但是基础学习还是不建议先看这个,没必要,本篇先把主干原理交代清楚,其实我也没太研究细致。

总之关于底层原理,我有机会再来扩充,看到这句话的时候就代表我还没扩充呢。


十二、总结

  1. 多态分两类:静态多态(重载、模板,编译期匹配);动态多态(本篇主角,运行期按对象表现不同行为)。
  2. 两个条件:基类指针或引用调用虚函数;派生类完成重写。缺一不可。
  3. 虚函数:virtual修饰的类成员函数;全局函数、静态成员函数、友元函数、构造函数都不能是虚函数。
  4. 重写:函数名、参数、const 属性一致,返回值一般相同;和隐藏不是一回事,隐藏不需要虚函数、只看名字相同。
  5. 默认参数静态绑定:函数体动态绑定、默认值静态绑定,重写时两边默认参数必须保持一致,否则出隐蔽 bug。
  6. 析构虚函数:基类析构声明为virtual,派生类析构自动构成重写;否则基类指针delete派生类对象会跳过派生析构、泄漏资源。
  7. override检查签名是否真的重写;final禁止继续重写虚函数或禁止类被继承。
  8. 纯虚函数= 0,含纯虚函数的类是抽象类,不能实例化;派生类不重写仍是抽象类,相当于强制接口规范。
  9. 底层原理:含虚函数的对象多一个 vfptr 指向类共享的虚表;虚表存只读常量区;函数体经虚表动态绑定,默认参数走静态绑定。

把买票示例和车辆抽象类示例自己敲一遍,再在调试窗口观察一次虚表,多态这关就吃透了。

如果这篇对你有帮助,别忘了点赞收藏关注三连~ 面向对象系列下一篇咱们接着深挖!


参考:C++ 运行时多态、虚函数与重写、默认参数静态绑定、虚析构、override/final、纯虚函数与抽象类、虚表指针与动态绑定

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进