C++进阶篇(二):多态——虚函数、重写、虚函数表与三个经典坑(附完整代码仓库)

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 复制代码
买票-全价
买票-打折
买票-优先

【一句话记】多态 = 同一份调用代码、不同行为:指向谁(谁的对象),就调谁(谁的函数)。

二、构成条件:三个都要满足

  1. 必须通过基类的指针或引用调用------不能是值传递;
  2. 被调用的函数必须是虚函数 (基类里加 virtual);
  3. 派生类必须完成重写(覆盖)------返回值、函数名、参数全相同。

补一句虚函数的定义:类成员函数前面加 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() 的步骤:

  1. 取 ptr 指向对象头部的 __vfptr;
  2. __vfptr 找到这张虚表;
  3. 取表里对应位置的函数地址;
  4. 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

地址里读出三条结论:

  1. 两张表分家:Base1、Base2 子对象各带一张虚表,Base2 那份在对象里还占了 16 字节的偏移;
  2. 重写全覆盖,但入口形态不同 :func1 被 Derive 重写后,两张表的第一槽都成了"跑向 MDerive::func1 的入口"------第一张表里是直接地址,第二张表里是 thunk (一小段先修正 this 偏移、再跳转的代码:MBase2* 指向的是对象中间,要转成 MDerive* 得先把这 16 字节的偏移减回去),所以两个槽的地址不一样,但调用效果完全一致:经 MBase1* 或 MBase2* 调 func1,都输出 MDerive::func1;
  3. 新虚函数只挂主表 :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 的底层都要给它留位置)。如果对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。

相关推荐
syagain_zsx1 小时前
spdlog实战:日志分级、滚动与性能调优
linux·c++·日志库·二次开发日志库
WJ.Polar18 小时前
Java封装、继承、多态
java·开发语言
摇滚侠19 小时前
Java 中如何表示负数,有符号和无符号的区别,补码的计算规则,char byte short int long 的范围
java·开发语言
weixin_3077791319 小时前
C++代码实现MATLAB中的fitrtree函数功能
开发语言·c++·算法·matlab
AI wins19 小时前
c++新增頭文件
c++
Shadow(⊙o⊙)19 小时前
仿muduo实现高并发服务器——事件就绪管理Channel.hpp
服务器·c++
爱读书的小胖19 小时前
Codewars:Rot13(C)解题分析
c语言·开发语言
鸽鸽程序猿19 小时前
【项目】【在线判题系统】b端题目增删改
开发语言·python
by2099919 小时前
哈希表——快速找到数据:冲突、删除与扩容的实现
数据结构·c++·哈希算法·散列表