【C++ 多态】虚函数 · 虚表 · 重写,一篇彻底弄明白!
C++ 多态详解
C++多态是面向对象的核心灵魂,本文将由浅入深,带你循序渐进地掌握多态的方方面面,全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧
文章目录
- C++ 多态详解
- 1. 什么是多态?
- 2. 运行时多态的实现前提
- 3. 虚函数与虚函数的重写
- 3.1 虚函数
- 3.2 虚函数的重写(覆盖)
- 3.3 一个小小的选择题,测一下你是否真正理解
- 4. 重写中的特殊情况
- 4.1 协变
- 4.2 析构函数的重写
- 5. override 和 final
- 5.1 override
- 5.2 final
- 6. 重载/重写/隐藏对比
- 7. 纯虚函数与抽象类
- 8. 多态的核心原理:虚函数表(vtable)
- 8.1 对象中隐藏的指针:`__vfptr`
- 8.2 多态到底是怎么实现的?
- 8.3 深入虚函数表
- 重要细节一览:
- 8.4 虚函数和虚表存放在内存的哪个区?
- 结语:
1. 什么是多态?
“多态”这个词,字面上就是“多种形态”。
现实中这种例子很多:同样是“买票”这件事,普通人买是全价,学生可能打五折,军人则享受优先服务。同样是“动物叫”,猫发出“喵喵”,狗发出“汪汪”。不同对象对同一个消息给出不同的响应,这就是多态。
在 C++ 中,多态可以分为两类:
编译时多态(静态多态):在编译阶段就确定调用哪个函数。典型代表是函数重载和函数模板。你传一个
int,编译器匹配f(int);传一个double,匹配f(double)。这个过程在编译时就已经搞定了。运行时多态(动态多态):直到程序运行时,才根据实际指向的对象来决定调用哪个函数。这也是本文的重点。它依赖于继承、虚函数、基类指针或引用。
我们可以通过一个简单的例子感受一下:
// 买票的例子classPerson{public:virtualvoidBuyTicket(){cout<<"买票-全价"<<endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout<<"买票-打折"<<endl;}};voidFunc(Person&ptr){ptr.BuyTicket();// 到底调用哪个BuyTicket,运行时才知道}intmain(){Person ps;Student st;Func(ps);// 输出:买票-全价Func(st);// 输出:买票-打折}同样一个ptr.BuyTicket(),当ptr引用的是Person对象时就执行全价逻辑,引用的是Student对象时就执行打折逻辑。这就是运行时多态的效果。
2. 运行时多态的实现前提
要触发运行时多态,必须同时满足两个条件:
- 必须通过基类的指针或引用来调用虚函数。
- 被调用的函数必须是虚函数,且派生类完成了对该虚函数的重写(覆盖)。
为什么必须是指针或引用?
因为只有指针或引用才能既指向基类对象,又指向派生类对象。如果是普通对象(值传递),就会发生“对象切片”,只保留基类部分,永远调用的都是基类的函数。
3. 虚函数与虚函数的重写
3.1 虚函数
在类的成员函数前加上virtual关键字,这个函数就是虚函数。例如:
virtualvoidBuyTicket(){...}注意:只有类的非静态成员函数才可以声明为虚函数,全局函数、静态成员函数、构造函数都不能是虚函数。
3.2 虚函数的重写(覆盖)
重写要求派生类中提供一个与基类完全相同的虚函数:
- 返回值类型相同
- 函数名相同
- 参数列表完全相同(参数个数、类型、顺序)
三者都一致,派生类的这个虚函数就重写了基类的虚函数。
一个细节:在派生类中重写时,可以省略virtual关键字,因为函数从基类继承下来时已经保持虚函数属性了,即使你不写virtual,它依然是虚函数,并构成重写。
但在实际开发中,强烈建议还是写上virtual,可读性更好。面试选择题里偶尔会出现故意不写virtual来考察你是否理解重写的条件,务必当心!
3.3 一个小小的选择题,测一下你是否真正理解
看这段代码,你认为输出是什么?
A: A->0 B : B->1 C : A->1 D : B->0 E : 编译出错 F : 以上都不正确
classA{public:virtualvoidfunc(intval=1){std::cout<<"A->"<<val<<std::endl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(intval=0){std::cout<<"B->"<<val<<std::endl;}};intmain(){B*p=newB;p->test();return0;}公布答案——这题选B!
分析:
B重写了func,没有写virtual,但依然构成重写。
- 注意重写与参数名、缺省值无关。
A::func和B::func满足重写条件:- 函数名都是 func
- 参数类型都是 int
- 都是虚函数(A里的是virtual,B里的自动继承virtual属性)
哪怕它们的默认值一个是1、一个是0,也不影响重写关系。
test是继承下来的,内部调用func()。此时this指向的是B对象,而test是基类的成员函数,它在基类中调用了func(),这里发生多态调用:由于this是A*类型,指向了B对象,最终调用的是B::func。
- 关键点来了: 函数的默认值是在编译阶段就确定好的,不是运行时动态决定的。
A::test()里写的是func();,编译器在编译这行代码时,会根据A::func的声明,把它直接替换成func(1)(因为A::func的默认值是1)。- 哪怕运行时实际调用的是
B::func,它拿到的参数也已经是 1 了,和它自己定义的默认值 0 没有关系。- 所以 B::func 最终拿到的参数是 1 ,输出就是
B->1,而不是很多人误以为的 B->0 。
如果直接通过p->func()调用呢?这时p的静态类型是B*,默认参数绑定的就是B中定义的val = 0,输出B->0。
4. 重写中的特殊情况
4.1 协变
有时基类和派生类的返回值并不完全相同,但依然能构成重写,这就是协变。
协变要求:
- 基类虚函数返回基类类型的指针或引用;
- 派生类的重写函数返回派生类类型的指针或引用。
示例:
classPerson{public:virtualPerson*BuyTicket(){cout<<"买票-全价"<<endl;returnnullptr;}};classStudent:publicPerson{public:virtualStudent*BuyTicket(){cout<<"买票-打折"<<endl;returnnullptr;}};这里虽然返回值类型不一样(Person*vsStudent*),但它们是具有继承关系的指针,编译器允许这种重写。协变在实际项目中用得不多,了解即可。
4.2 析构函数的重写
如果类中定义了虚函数,那么它的析构函数最好也声明为虚函数。这不是可选项,而是防止内存泄漏的重要原则。
看看为什么不加virtual会出问题:
classA{public:~A(){cout<<"~A"<<endl;}};classB:publicA{public:~B(){cout<<"~B->delete:"<<_p<<endl;delete_p;}protected:int*_p=newint[10];};intmain(){A*p2=newB;deletep2;// 只调用了~A,没有调用~B,_p泄漏!return0;}当使用基类指针delete派生类对象时,如果析构函数不是虚函数,编译器只根据指针的静态类型(A*)调用A的析构函数,不会执行B的析构函数,导致B里申请的资源无法释放。
解决办法:把A的析构函数加上virtual:
classA{public:virtual~A(){cout<<"~A"<<endl;}};这时,B的析构函数无论是否写virtual,都会自动和A的析构函数构成重写(因为编译器底层把析构函数统一命名为destructor)。释放时就会走正常的析构流程:先调~B(),再调~A()(析构完子类后会自动调用基类的析构),资源安全释放。
~A()是虚函数,delete p2时会先找到真实类型 B ,调用B::~B()。B::~B()执行,清理B自己的资源。B::~B()执行完,编译器自动帮你调用A::~A(),清理套在里面的A的资源。
这里还要注意一下:派生类析构完自动调用基类析构,是因为派生类对象里嵌套了一个基类子对象,必须先析构外层再析构内层,而我们自己没法手动调用基类析构,所以编译器会自动调用基类析构的代码。
总结:只要一个类可能被继承,或者其中已有虚函数,就把它的析构函数声明为虚的。面试时也经常问到“为什么基类的析构函数要写成虚函数”,答案就是这个内存泄漏的风险。
5. override 和 final
虚函数重写要求非常严格,参数列表差一个const、函数名拼错一个字母,都不会构成重写,编译的时候也不会报错,只有在程序运行时没有得到预期结果才来debug会得不偿失。
因此,C++11 引入了两个关键字来帮我们:
5.1 override
在派生类虚函数后面加上override,告诉编译器:“这个函数是用来重写基类虚函数的,如果没构成重写,请直接报错。”
classCar{public:virtualvoidDrive(){}};classBenz:publicCar{public:virtualvoidDrive()override{cout<<"Ben-舒适"<<endl;}// 拼写错误?立即报错};如果你把Drive写成了Dirve,编译器会立刻指出你并没有重写任何基类虚函数。这大大减少了调试时间。
5.2 final
final修饰虚函数,表示该虚函数不能被后续的派生类再次重写。
classCar{public:virtualvoidDrive()final{}};classBenz:publicCar{public:virtualvoidDrive(){}// 编译错误,Drive被final禁止重写};此外final也可以修饰类,表示这个类不能被继承。
6. 重载/重写/隐藏对比
| 现象 | 作用域 | 函数名 | 参数列表 | 返回值 | virtual | 发生时期 |
|---|---|---|---|---|---|---|
| 重载 | 同一作用域 | 相同 | 不同 | 可同可不同 | 不要求 | 编译时 |
| 重写 | 基类与派生类 | 相同 | 相同 | 相同(协变除外) | 基类必须 virtual | 运行时 |
| 隐藏 | 基类与派生类 | 相同 | 不同(或基类非虚) | 任意 | 非虚或参数不同 | 编译时 |
7. 纯虚函数与抽象类
在虚函数后面加上= 0,这个函数就变成了纯虚函数。含有纯虚函数的类叫做抽象类。
classCar{public:virtualvoidDrive()=0;// 纯虚函数};抽象类不能实例化对象。这很合理——一个“车”的概念太抽象了,你不知道它是怎么开的,只有具体到“奔驰”、“宝马”,才能理解驾驶行为。
Car car;// 编译错误!无法实例化抽象类派生类继承了抽象类后,必须重写所有纯虚函数,否则它自己也还是一个抽象类,无法实例化。这实际上是在强制派生类实现某些接口。
classBenz:publicCar{public:virtualvoidDrive(){cout<<"Benz-舒适"<<endl;}};classBMW:publicCar{public:virtualvoidDrive(){cout<<"BMW-操控"<<endl;}};父类对象不能实例化,但是可以作为指针类型来使用:
intmain(){Car*pBenz=newBenz;pBenz->Drive();Car*pBMW=newBMW;pBMW->Drive();return0;}虽然纯虚函数通常不需要实现(因为会被重写),但语法上你依然可以给它一个定义,不过必须在类外定义。
8. 多态的核心原理:虚函数表(vtable)
8.1 对象中隐藏的指针:__vfptr
先来看一个题:
下面编译为32位程序的运行结果是什么()
A.编译报错 B.运行报错 C.8 D.12
classBase{public:virtualvoidFunc1(){cout<<"Func1()"<<endl;}protected:int_b=1;char_ch='x';};intmain(){Base b;cout<<sizeof(b)<<endl;return0;}直观来看,int占 4 字节,char占 1 字节,还有内存对齐,可能是 8。
但实际结果是12!多出来的 4 字节就是一个指针——虚函数表指针(__vfptr,v 代表 virtual,f 代表 function,ptr 代表 pointer)。
这个指针比较特殊,它通常放在对象的最前面(有些编译器可能放在末尾,但主流放在前面),指向一个虚函数表。
只要一个类含有虚函数,那么该类的每个对象中都至少有一个虚函数表指针(从基类继承下来的也算)。基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。。
8.2 多态到底是怎么实现的?
我们把多态的例子用更完整的版本来演示:
classPerson{public:virtualvoidBuyTicket(){cout<<"买票-全价"<<endl;}protected:string _name;};classStudent:publicPerson{public:virtualvoidBuyTicket(){cout<<"买票-打折"<<endl;}protected:int_id;};classSoldier:publicPerson{public:virtualvoidBuyTicket(){cout<<"买票-优先"<<endl;}protected:string _codename;};voidFunc(Person*ptr){ptr->BuyTicket();// 这里发生了什么?}当我们这样调用时:
Person ps;Student st;Soldier sr;Func(&ps);// 买票-全价Func(&st);// 买票-打折Func(&sr);// 买票-优先即使Func内部是通过同一个Person*指针调用BuyTicket,最终还是产生了不同的行为。这个过程编译器帮我们做了什么呢?
在满足多态条件(指针+虚函数)的情况下,函数调用不再像普通函数那样在编译时直接确定地址,而是在运行时到对象的虚表中去查找应该调用哪个函数。
具体来说:
当
ptr指向Person对象时,ptr->BuyTicket()会根据该对象的虚表找到Person::BuyTicket的地址并调用。当
ptr指向Student对象时,就会调用派生类的版本。
这就是动态绑定。
静态绑定:编译时就能确定函数地址(比如普通函数调用、非虚函数的对象调用)。
动态绑定:编译时不确定,运行时查虚表确定调用函数的地址(虚函数 + 指针/引用)。
8.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(){cout<<"Derive::func1"<<endl;}virtualvoidfunc3(){cout<<"Derive::func3"<<endl;}voidfunc4(){cout<<"Derive::func4"<<endl;}protected:intb=2;};这个继承关系中的虚函数表是什么样子的?
基类
Base的虚表:
存储Base::func1的地址,存储Base::func2的地址。派生类
Derive的虚表:
首先,它也有一个虚表。因为Derive继承了Base,基类部分中的虚函数表指针不再指向基类的虚表,而是指向Derive自己的虚表。Derive的虚表包含:- 重写的
Base::func1被替换成了Derive::func1的地址(覆盖)。 - 未重写的
Base::func2依然保留基类的地址。 - 派生类独有的虚函数
Derive::func3的地址被追加到表中。 - (VS 编译器下)虚表最后通常有一个
0x00000000作为结束标记,但这不是标准规定,g++ 就没有。
- 重写的
所以虚函数表本质就是一个函数指针数组,存放着该类所有需要动态调用的虚函数地址。
重要细节一览:
- 普通函数(如
func5、func4)不在虚表中,它们直接由类型决定,编译时绑定。 - 派生类的虚表与基类的虚表是完全不同的两个表。
- 派生类对象中并不会额外生成一个新的虚函数表指针,而是沿用从基类继承下来的那个指针(只不过现在它指向的是派生类自己的虚表)。
- 通过 VS 的内存窗口可以直观地看到这些函数地址,有些在监视窗口看不到的虚函数(如
func3),在内存中却是真实存在的。
8.4 虚函数和虚表存放在内存的哪个区?
这是一个开放性问题,因为 C++ 标准没有硬性规定。但我们可以通过打印不同区域的地址来进行对比验证。
intmain(){inti=0;staticintj=1;int*p1=newint;constchar*p2="xxxxxxxx";printf("栈:%p\n",&i);printf("静态区:%p\n",&j);printf("堆:%p\n",p1);printf("常量区:%p\n",p2);Base b;Derive d;Base*p3=&b;Derive*p4=&d;printf("Base虚表地址:%p\n",*(int**)p3);// 解引用对象首地址得到虚表指针printf("Derive虚表地址:%p\n",*(int**)p4);printf("虚函数地址:%p\n",&Base::func1);printf("普通函数地址:%p\n",&Base::func5);}输出中你会发现,虚表地址通常与常量区的地址比较接近(在 VS 中甚至就在代码段),而普通函数地址也在代码段。
虚函数存在哪?虚函数本身是代码,存储在代码段,只是虚函数的地址又存到了虚表中;
虚函数表存在哪? 虚表是一个存放这些函数地址的数组,通常也放在代码段(常量区)(C++标准并没有规定到底应该存在哪,不过VS下是存在代码段的)
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _
