0.1 概述&序言
这里是白杨。上一篇继承的结尾留了一句话:想搞多态,必须继承。这一篇就来兑现------多态。
如果把继承比作"类怎么长"(谁是谁的爸),多态解决的就是"同一份调用代码,怎么跑出不同的行为"。这两章是绑在一起考的,面试里也常被连招:先问继承的坑,再问多态的构成,最后甩一道"输出什么"的选择题。
这篇要过四件事:
- 多态的三个构成条件(附一个"值传递翻车"的反例)
- 重写的细节:override / final / 协变,以及重载、重写、隐藏三兄弟的区别
- 抽象类:纯虚函数
= 0 - 把原理摊开:虚函数表(vfptr / vftable)+ 三个经典坑(默认参数、虚析构、构造中调虚函数)
老规矩,跑得通的结果才写进来(环境见下);完整代码文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ/poly-sim。
实测环境:w64devkit g++ 15.2.0(
-std=c++11 -O2 -Wall -Wextra)+ VS2022;教学简化版。文中代码均为本人手写并实测;官方文档:cppreference·virtual 说明符。
让我们开始吧。
一、什么是多态:同一份代码,三种票
多态这个词听着玄,场景很朴素------买票。普通人全价、学生打折、军人优先:动作都叫"买票",不同的人做,行为不同。
C++ 里的多态分两种:
- 静态多态(编译时):函数重载、函数模板------编译阶段就把调用的函数定好了;
- 动态多态(运行时):虚函数 + 继承------运行时根据对象的"真实身份"决定调谁。
这篇讲的都是后者。先看一段代码:
cpp
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
void BuyTicket() override { cout << "买票-打折" << endl; }
};
class Soldier : public Person
{
public:
void BuyTicket() override { cout << "买票-优先" << endl; }
};
void Func(Person& ptr) // 基类引用调用
{
ptr.BuyTicket();
}
Func 收到的都是 Person&,代码里也只有一句 ptr.BuyTicket()------可传进去的对象不同,结果完全不同。实测:
text
买票-全价
买票-打折
买票-优先
【一句话记】多态 = 同一份调用代码、不同行为:指向谁(谁的对象),就调谁(谁的函数)。
二、构成条件:三个都要满足

- 必须通过基类的指针或引用调用------不能是值传递;
- 被调用的函数必须是虚函数 (基类里加
virtual); - 派生类必须完成重写(覆盖)------返回值、函数名、参数全相同。
补一句虚函数的定义:类成员函数前面加 virtual 就是虚函数;非成员函数不能加 virtual 修饰------后半句是选择题常点的名。
第 1 条为什么强调"不能值传递"?来看一组对照实验。同一个虚函数、同样的重写,区别只在 Func 的参数:
cpp
void FuncRef(Person& ptr) { ptr.BuyTicket(); } // 引用:构成多态
void FuncValue(Person ptr) { ptr.BuyTicket(); } // 值传递:不构成
实测输出:
text
[引用版 ] 买票-全价
[引用版 ] 买票-打折
[值传递版] 买票-全价
[值传递版] 买票-全价
(值传递把 st 切成了 Person,多态消失)
值传递版把 Student 对象"切"成了一个 Person 对象(切片,上篇讲过的老朋友)------ptr 里根本没有 Student 的部分,多态从哪来?
【必背】三条件:基类指针/引用 + 虚函数 + 重写,缺一不可。
三、重写的细节:三同、override 与协变
重写(覆盖)的"三同":返回值、函数名、参数列表完全相同。
有个考试爱考的坑:派生类重写时不写 virtual 也构成重写------因为虚函数的属性会一路继承下去。但规范写法建议写上,语义更清楚。
写重写最容易出的事故是"差一点没写上":函数名敲错一个字母、参数少个 const......编译器不报错 ,运行时才发现"多态没生效"。C++11 给的解法是 override:
cpp
class Benz : public Car
{
public:
void Drive() override { cout << "Benz-舒适" << endl; }
// 如果 Drive 没真的重写上任何基类虚函数,编译器直接报错(C3668)
};
配套的还有 final:加在虚函数后面,禁止派生类再重写它;加在类名后面,禁止整个类被继承。
还有个冷门细节:协变------派生类虚函数返回"派生类对象的指针/引用"、基类虚函数返回"基类对象的指针/引用"时,也算重写:
cpp
class PBase { public: virtual A* BuyTicket() { ... } };
class PStudent : public PBase
{
public:
B* BuyTicket() override { ... } // B 是 A 的派生类 → 协变
};
实测输出 买票-全价(返回 A*) / 买票-打折(返回 B*)。协变实际意义不大,了解即可。
【面试】override 的价值一句话:把"运行时的莫名其妙"变成"编译期的明确报错"。
四、重载 / 重写 / 隐藏:一表定三兄弟

这三个概念是面试的常客,判断标准其实就一张表:
| 对比项 | 重载 | 重写(覆盖) | 隐藏 |
|---|---|---|---|
| 作用域 | 同一个 | 父类 / 子类各一个 | 父类 / 子类各一个 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 不同(类型或个数) | 相同 | 不同(不构成重写) |
| 是否虚函数 | 无关 | 都必须是虚函数 | 不构成重写就是隐藏 |
| 附加规则 | 返回值可同可不同 | 返回值相同(协变例外) | 成员变量同名也算隐藏 |
跑一遍实测(每个概念一个例子):
text
[重载] 3 / 4
Derive::func
Derive::other(int): 10
Base::other
基类 _num=111,派生 _num=999(变量同名隐藏)
- 重载:
Calc::Add(int,int)和Calc::Add(double,double),同作用域、参数不同; - 重写:
Derive::func重写了Base::func,通过Base*调用照样走到派生类版本; - 隐藏:
Derive::other(int)和Base::other()同名------它俩不构成重写 (参数不同),基类版本被藏起来。想调基类版必须写d.Base::other()。
【必背】判断口诀:同作用域看参数 → 重载;父子都虚且三同 → 重写;剩下同名的一律叫隐藏。隐藏就是"重写没写成"的事故现场。
五、抽象类:纯虚函数 = 0
虚函数后面写上 = 0,就成了纯虚函数 ------只声明、没有实现,等着派生类去重写。含有纯虚函数的类叫抽象类,它有两个规矩:
- 不能实例化对象 (
Car car;会报error C2259:无法实例化抽象类); - 派生类如果不重写 纯虚函数,那它也是抽象类。
cpp
class Car
{
public:
virtual void Drive() const = 0; // 纯虚函数
};
void Func(const Car& p) { p.Drive(); }
int main()
{
Func(Benz());
Func(BMW());
Func(WENJIE());
}
实测输出:
text
Benz-舒适
BMW-操控
WENJIE-智能
纯虚函数某种程度上是"强制重写":不写,连对象都造不出来。它划出的是接口------基类只规定"必须有这个行为",具体怎么做各派生类自己定。
【一句话记】抽象类 = 一份接口规范;纯虚函数 = 必须交作业。
六、原理:vfptr 与虚函数表
到最硬核的一节了。多态到底怎么实现的?------虚函数表。

先说对象的内存布局。一个含虚函数的类,编译器会在对象里塞一个隐藏指针------虚函数表指针 __vfptr(v=virtual,f=function),一般放在对象最前面。它指向一张表,表里存着这个类所有虚函数的地址。
拿一道经典题实测:sizeof 多少?
text
sizeof(NoVirt) = 8 字节(无虚函数)
sizeof(HasVirt) = 16 字节(有虚函数,多出一个 vfptr)
x64 平台下就是"多出一个 8 字节指针"。再验证虚表的两条规律(用 memcpy 安全地读对象头 8 字节):
text
vfptr(s1) == vfptr(s2) ? 1(同类型的对象共用一张虚表)
vfptr(p1) == vfptr(s1) ? 0(各类型各有独立的虚表)
虚表的构成 :派生类的虚表 = 从基类继承的虚函数地址 + 被重写函数的地址被覆盖 + 自己新增的虚函数地址。(表尾要不要放 0x00000000 之类的终止标记,是各编译器自己的安排------后面多继承的实测里,第二张表的越界槽位读到的就是 0。属实现细节,不用背。)
运行时发生了什么?ptr->BuyTicket() 的步骤:
- 取
ptr指向对象头部的__vfptr; __vfptr找到这张虚表;- 取表里对应位置的函数地址;
call这个地址。
满足多态条件的调用叫动态绑定 (运行时才定地址,汇编里是"间接 call");不满足的(比如非虚函数、值传递对象调用)叫静态绑定 (编译时就写死 call 函数地址)。

【面试】多态调用为什么比普通调用"慢一点点"?------多了一次取指针、查表、间接跳转;这也解释了为什么 C++ 不把所有函数都设成虚函数。
先回答一个追问题:虚表存在哪? C++ 标准说"没规定"(这是编译器实现细节),那就实测看看------顺带把栈、静态区、堆、常量区一起打出来对比:
text
栈 : 0x3daadff74c
静态区: 0x7ff7975fa000
堆 : 0x159268513d0
常量区: 0x7ff7975fe625
虚表 : 0x7ff7976096c0(数值上贴近常量区------C++ 标准没规定虚表存哪,这是编译器实现细节)
栈和堆各占一段,虚表地址贴着常量区/静态数据段------所以通常说它放在"只读数据段"。记住一句就够:虚表不在栈上、也不在堆上。
再分清一对东西:虚函数本体是一段指令,编译后存在代码段;虚表是数据,存的是这些指令的地址------代码和数据,别混。
多继承下的虚函数表:一个对象,两张表
继承篇聊过多继承:class MDerive : public MBase1, public MBase2。当两个基类都有虚函数时,对象里就会塞两个 __vfptr------每份基类子对象各带一张虚表。

探针安排(安全读取两张虚表的槽位地址):
cpp
sec7::MBase2* pb2 = &d; // 隐式转换:编译器算好 Base2 子对象偏移
void* vptr1 = vfptr_of(&d); // Base1 部分的虚表
void* vptr2 = vfptr_of(pb2); // Base2 部分的虚表
void** vt1 = (void**)vptr1;
void** vt2 = (void**)vptr2;
实测输出:
text
vptr1(Base1 部分)= 0x7ff7976096e0
vptr2(Base2 部分)= 0x7ff797609708,偏移 16 字节
两张虚表地址不同: 1
vt1: slot0=0x7ff797598650 slot1=0x7ff7975984a0 slot2=0x7ff7975986e0
vt2: slot0=0x7ff7975f7520 slot1=0x7ff797598530 slot2=0
vt1[0] == vt2[0] ? 0(第二张表里的覆盖函数常是 thunk:先修正 this 偏移再跳转)
vt1[1] != vt2[1] ? 1(两边的 func2 各留各的版本)
MDerive::func1
MDerive::func1
地址里读出三条结论:
- 两张表分家:Base1、Base2 子对象各带一张虚表,Base2 那份在对象里还占了 16 字节的偏移;
- 重写全覆盖,但入口形态不同 :
func1被 Derive 重写后,两张表的第一槽都成了"跑向MDerive::func1的入口"------第一张表里是直接地址,第二张表里是 thunk (一小段先修正this偏移、再跳转的代码:MBase2*指向的是对象中间,要转成MDerive*得先把这 16 字节的偏移减回去),所以两个槽的地址不一样,但调用效果完全一致:经MBase1*或MBase2*调func1,都输出MDerive::func1; - 新虚函数只挂主表 :Derive 新增的
func3只出现在第一张表(vt1[2]有值),第二张表对应位置是 0。
【面试】多继承对象的内存布局 + 虚表安排,是"多态 × 多继承"的交叉考点,记一条线:每个多态基类子对象带一张表;重写在各自的表里落地;新增虚函数挂主表。
七、三个经典坑
7.1 默认参数坑:输出到底是几?
这道题流传很广,实测安排上。基类虚函数默认参数 = 1,派生类重写时默认参数 = 0,问 p->test() 输出什么?
cpp
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) override { cout << "B->" << val << endl; }
};
实测三条调用,对照着看:
text
B->1 ← p->test()(经典题答案:B 的实现 + A 的默认参数)
B->0 ← b.func()(直接调用,默认参数看 B 自己的)
B->1 ← A* pa = &b; pa->func()(多态调用,默认参数看编译时类型 A)
【必背】一句话拆解:函数体走动态绑定 (运行时看对象,调的是 B 的 func),默认参数走静态绑定 (编译时看指针类型,用的是 A 的 val = 1)。两套机制拧在一起,就拧出了这个"B->1"。
7.2 虚析构:delete 基类指针,谁在泄漏?
上一篇埋的扣子(析构函数名被特殊处理成 destructor)在这里收网。

派生类 B 里有个 _p 指向堆内存、需要在析构里释放。现在用基类指针 new 一个 B,再 delete:
cpp
sec5::A_nv* p1 = new sec5::B_nv; // ~A 不虚
delete p1;
sec5::A_v* p2 = new sec5::B_v; // ~A 加了 virtual
delete p2;
实测对照输出:
text
--- 反例:~A 不虚,delete 基类指针 ---
~A_nv()
(~B_nv() 根本没被调用:_p 指向的堆内存泄漏)
--- 正例:~A 加 virtual,delete 基类指针 ---
~B_v()->delete:0x2408bd813f0
~A_v()
(先析构派生、再析构基类,资源正确释放)
析构不虚时,delete p1 只调了 ~A,~B 从头到尾没执行------_p 的内存泄漏了,而且不报错、不崩溃,跑测试都未必发现。这也是"基类析构建议一律加 virtual "的原因:delete 基类指针 + 虚析构 = 多态析构(先派生、后基类,正好是继承篇里那条析构顺序)。
7.3 构造 / 析构中调用虚函数:不构成多态
cpp
class Base
{
public:
Base() { func(); } // 构造里调虚函数
virtual void func() { cout << "Base::func(虚表还没切到派生类)" << endl; }
};
class Derived : public Base
{
public:
void func() override { cout << "Derived::func" << endl; }
};
实测:
text
Base 构造中调用 func():Base::func(虚表还没切到派生类)
Derived::func
构造派生类时,先跑基类构造------这期间对象的虚表还没"切"到派生类版本,func() 只会调到 Base::func;等对象完整构造完,再调用才是 Derived::func。析构阶段同理(先还原成基类虚表)。所以构造和析构函数里调用虚函数,多态不生效------想用派生类版本,只能老老实实构造完再调。
【必背】三个坑一句话:默认参数静态绑定(B->1);基类析构要 virtual;构造/析构里调虚函数不构成多态。
八、速记卡
考前扫一眼,面试不卡壳:

九、经典题集
五道题覆盖这一章的"事故多发地段"。先自己默答,再看对照:
题 1(选择题):下面程序输出什么?
cpp
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) override { cout << "B->" << val << endl; }
};
int main()
{
B* p = new B;
p->test();
}
A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确
答案
B. B->1 。p->test() 里的 this 是 A* 类型,func() 满足多态 → 函数体走 B 的实现(动态绑定);默认参数是静态绑定,取编译时类型 A 的 val = 1。所以是"B 的实现 + A 的参数"。完整实测(含另两条调用对照)见 7.1。
题 2(选择题) :编译为 32 位程序,sizeof(b) 的结果是?
cpp
class Base
{
public:
virtual void Func1() { cout << "Func1()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
}
A. 编译报错 B. 运行报错 C. 8 D. 12
答案
D. 12 。32 位下:vfptr 4 字节 + int 4 字节 + char 1 字节 + 对齐填充 3 字节 = 12。同理,x64 下我们用同一份代码实测是 16 字节 (vfptr 8 + int 4 + char 1 + 填充 3)------这类题的答案跟着平台和指针大小走。再附两个变体实测:{virtual + 3 个 int} = 24 字节;{virtual + 1 个 char} = 16 字节(哪怕只有 1 字节数据,也要按 8 字节对齐)。
题 3(判断题):下面这个值传递版本是不是多态?输出什么?
cpp
void Func(Person ptr) // 注意:值传递
{
ptr.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
}
答案
不是多态,输出两行"买票-全价" 。值传递把 st 切片成了 Person 对象,函数里的 ptr 已经不含 Student 部分------三条件里"基类指针/引用"这一条被破坏,静态绑定到 Person::BuyTicket。对照实验见第二节。
题 4(判断题) :下面两句 delete 分别会调用哪些析构函数?
cpp
class A
{
public:
~A() { cout << "~A()" << endl; } // 没有 virtual
};
class B : public A
{
public:
~B() { delete[] _p; }
protected:
int* _p = new int[10];
};
A* p = new B;
delete p;
答案
只输出 ~A() ------析构不虚,delete p 按基类析构调用,~B() 根本不执行,_p 指向的堆内存泄漏。把 ~A 加上 virtual 后输出变成 ~B()(先释放资源)再 ~A()。这就是"基类析构建议 virtual"的由来,实测对照见 7.2。
题 5(判断题) :构造 Derived d 的过程中,基类构造函数里调用的虚函数走哪个版本?
cpp
class Base
{
public:
Base() { func(); }
virtual void func() { cout << "Base::func" << endl; }
};
class Derived : public Base
{
public:
void func() override { cout << "Derived::func" << endl; }
};
int main() { Derived d; }
答案
输出 Base::func 。构造派生类时先跑基类构造,这期间对象的虚表还没切到派生类版本,虚函数不构成多态;等对象完整构造完再调,才轮到 Derived::func。析构阶段同理,实测见 7.3。
【一句话总结】五道题拉到底,就是两条线:多态三条件(缺一条就静态绑定) 和 虚表 / 对象模型(谁的表、谁的 this)。
附录 A:完整代码仓库
完整工程(单文件版,全部实验一次跑完)在 Gitee:
https://gitee.com/BaiYang_SQ/poly-sim
| 仓库文件 | 说明 |
|---|---|
poly_sim.cpp |
单文件:全部实验;章节 ↔ 函数名对照见仓库 README 导览表 |
output.txt |
全部实验的真实运行输出(含虚析构对照、sizeof、vfptr 数据) |
build.bat |
Windows 一键编译 + 运行 + 刷新 output.txt |
README.md |
一分钟上手 + 代码导览 |
面试前手写练手,照这个骨架压考点(完整版见仓库):
cpp
// 多态·考点骨架(完整可跑版见 Gitee 仓库)
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket() { cout << "全价" << endl; }
virtual ~Person() {} // 基类析构加 virtual
};
class Student : public Person
{
public:
void BuyTicket() override { cout << "打折" << endl; }
};
void Func(Person& p) { p.BuyTicket(); } // 指针/引用 + 虚函数 + 重写 = 多态
int main()
{
Person ps;
Student st;
Func(ps); // 全价
Func(st); // 打折
Person* p = new Student;
delete p; // 多态析构:先 ~Student 再 ~Person
return 0;
}
好了,多态这篇到此完结!下一站------二叉搜索树 (预告:插入查找一条龙、删除的四种情况,以及为什么 map / set 的底层都要给它留位置)。如果对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。