C++ 抽象类与多态原理深度解析:从纯虚函数到虚表机制(附高频面试题)

C++ 抽象类与多态原理深度解析:从纯虚函数到虚表机制(附高频面试题)
在这里插入图片描述

🔥草莓熊Lotso:个人主页
❄️个人专栏: 《C++知识分享》《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:

在这里插入图片描述

文章目录


前言:

在 C++ 多态体系中,纯虚函数与抽象类是实现 “接口规范” 的核心工具,而虚函数表(vtable)与虚表指针(vfptr)则是多态底层实现的关键。本文将基于实战代码,从纯虚函数与抽象类的定义、使用场景入手,逐步拆解多态的底层原理 —— 包括虚表指针的存在性、虚函数表的结构与存储位置,最终帮你打通 “多态怎么用” 到 “多态如何实现” 的认知链路。

一. 纯虚函数与抽象类:强制接口规范的“契约”

在实际开发中,我们经常需要定义一个“只规定行为,不提供具体实现”的类。C++ 通过纯虚函数抽象类实现这种 “接口契约”。

本文代码示例所需头文件

#include<iostream>usingnamespace std;

代码仓库多态收尾- Gitee.com

1.1 纯虚函数:没有实现的 “接口声明”

在虚函数的声明后加=0,该函数即为纯虚函数。纯虚函数无需在基类中实现(语法上允许实现,但无实际意义,因为会被派生类重写),其核心作用是 “强制派生类必须重写该函数”

1.2 抽象类:包含纯虚函数的 “不可实例化类”

包含纯虚函数的类称为抽象类,它有两个关键特性:

  • 无法直接实例化对象(编译器会报错);
  • 派生类若未重写基类的所有纯虚函数,自身也会成为抽象类,同样无法实例化。

有了上面的知识储备,我们来看下代码示例吧:

// 抽象类:包含纯虚函数Drive()classCar{public:// 纯虚函数:只声明接口,不提供实现virtualvoidDrive()=0;};// 派生类Benz:重写纯虚函数,成为“具体类”classBenz:publicCar{public:// 必须重写Drive(),否则Benz也是抽象类virtualvoidDrive(){ cout <<"Benz-舒适"<< endl;}};// 派生类BMW:重写纯虚函数,成为“具体类”classBMW:publicCar{public:virtualvoidDrive(){ cout <<"BMW-操控"<< endl;}};intmain(){// 抽象类无法实例化对象// Car car; // 用抽象类指针指向派生类对象(多态核心用法) Car* pBenz =new Benz; pBenz->Drive();// 多态调用:输出“Benz-舒适” Car* pBMW =new BMW; pBMW->Drive();// 多态调用:输出“BMW-操控”return0;}

二. 多态的底层原理:虚表指针与虚函数表

当我们用基类指针调用派生类的虚函数时,编译器如何 “知道” 该调用哪个类的函数?答案藏在虚表指针(vfptr)虚函数表(vtable) 中 —— 这是 C++ 实现动态绑定(运行时多态)的核心机制。

2.1 虚表指针(vfptr):对象中的 “导航器”

首先通过下面这个题目来验证一下虚表指针的存在
下面编译为32位程序的运行结果是什么(D
A. 编译报错 B. 运行报错 C. 8 D. 12

classBase{public:// 虚函数:触发编译器生成虚表指针virtualvoidFunc1(){ cout <<"Func1()"<< endl;}virtualvoidFunc2(){ cout <<"Func2()"<< endl;}// 普通函数voidFunc3(){ cout <<"Func3()"<< endl;}protected:int _b =1;char _ch ='x';};intmain(){ Base b;//除了我们能看到的_b和_ch,其实有虚函数的类就会有一个虚函数表指针(32位下4字节,64位下8字节)//因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。 cout <<sizeof(b)<< endl;//32位:4+4+1->12// 输出结果:32位环境下为12字节,64位环境下为16字节return0;}
在这里插入图片描述


关键结论

  • 只要类中包含虚函数(或继承自含虚函数的类),该类的对象就会额外存储一个虚表指针
  • 虚表指针通常位于对象内存的最前端(不同编译器可能有差异),其作用是 “指向该类的虚函数表”;
  • 同类型的对象共用一张虚函数表,但每个对象都有独立的虚表指针(指向同一张虚表)。

2.2 多态的实现原理

从底层的角度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调用Person::BuyTicket,ptr指向Student对象调用Student::BuyTicket的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。第⼀张图,ptr指向的Person对象,调用的是Person的虚函数;第二张图,ptr指向的Student对象,调用的是Student的虚函数。

在这里插入图片描述


在这里插入图片描述


在这里插入图片描述
classPerson{public:virtualvoidBuyTicket(){ cout <<"买票-全价"<< endl;}private: string _name;};classStudent:publicPerson{public:virtualvoidBuyTicket(){ cout <<"买票-打折"<< endl;}private: string _id;};voidFunc(Person ptr){// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket// 但是跟ptr没关系,而是由ptr指向的对象决定的。 ptr.BuyTicket();}intmain(){// 其次多态不仅仅发⽣在派⽣类对象之间,多个派⽣类继承基类,重写虚函数后// 多态也会发⽣在多个派⽣类之间。 Person ps; Student st;Func(ps);Func(st);////这三个的虚函数表是一样的,同类型的对象共用一虚表//Person p1;//Person p2;//Person p3;return0;}

2.3 虚函数表(vtable):存储虚函数地址的 “数组”

虚函数表(简称 “虚表”)是编译器为每个含虚函数的类生成的一张 “虚函数指针数组”,数组中存储的是该类所有虚函数的地址。其结构与生成规则如下:

  • 基类虚表:存储基类所有虚函数的地址(如Base类的虚表存储Func1Func2的地址);
  • 派生类虚表
    • 首先继承基类虚表的所有内容;
    • 若派生类重写了基类的虚函数,会用派生类自身的虚函数地址 “覆盖” 基表中 对应的位置;
    • 派生类新增的虚函数,其地址会追加到虚表的末尾;
  • 虚表结尾标记:部分编译器(如 VS)会在虚表末尾添加0x00000000作为结束标记(g++ 无此标记,C++ 标准未强制规定)。
  • 注意:同类型的对象共用同一张虚表,不同类型的对象都有各自独立的虚表。
在这里插入图片描述
classBase{public:virtualvoidfunc1(){ cout <<"Base::func1"<< endl;}virtualvoidfunc2(){ cout <<"Base::func2"<< endl;}// 普通函数:不存入虚表voidfunc5(){ cout <<"Base::func5"<< endl;}protected:int a =1;};classDerive:publicBase{public:// // 重写基类的func1:会覆盖虚表中func1的地址virtualvoidfunc1(){ cout <<"Derive::func1"<< endl;}// 派生类新增虚函数:会追加到虚表末尾virtualvoidfunc3(){ cout <<"Derive::func1"<< endl;}// 普通函数:不存入虚表voidfunc4(){ cout <<"Derive::func4"<< endl;}protected:int b =2;};intmain(){ Base b; Derive d;return0;}
在这里插入图片描述


在这里插入图片描述

2.4 动态绑定与静态绑定

  • 对不满足多态条件(指针或者引用+调用虚函数)的函数调用在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 满足多态条件的函数调用是在运行时绑定,也就是运行时到指定对象的虚函数表中找到调用函数的地址,也叫做动态绑定。

当用基类指针调用虚函数时,编译器会按以下步骤完成 “动态绑定”(运行时确定调用的函数):

  • 获取虚表指针:从基类指针指向的对象中,取出虚表指针(vfptr);
  • 查找虚表:通过虚表指针找到该对象所属类的虚函数表(vtable);
  • 定位函数地址:在虚表中找到目标虚函数对应的地址(若派生类重写过,此处就是派生类函数地址);
  • 调用函数:通过找到的函数地址,调用对应的虚函数。

以之前的 “买票” 场景为例,流程如下:

// 基类指针指向派生类对象 Person* ptr = new Student; // 动态绑定流程: 1. 从ptr指向的Student对象中,取出vfptr; 2. 通过vfptr找到Student类的虚表; 3. 在虚表中找到BuyTicket对应的地址(Student::BuyTicket的地址); 4. 调用该地址对应的函数,输出“买票-打折”。 

三. 关键问题辨析与总结

3.1 虚函数存在哪里?虚表又存在哪里?

  • 虚函数:与普通函数一样,编译后是一段机器指令,存储在代码段;虚表中仅存储虚函数的 “地址”,而非函数本身;
  • 虚表:本质是 “存储虚函数地址的指针数组”,在 VS 等编译器中存储在代码段(常量区)(因内容不可修改),C++ 标准未强制规定存储位置,不同编译器可能有差异。
intmain(){int i =0;staticint j =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;printf("Base虚函数表地址:%p\n",*((int*)&b));printf("Derive虚函数表地址:%p\n",*((int*)&d));printf("虚函数地址:%p\n",&Base::func1);printf("普通函数地址:%p\n",&Base::func5);}
运行结果
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Person虚表地址:0071AB44
Student虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF

3.2 实战注意事项:

  • 抽象类只能作为基类指针 / 引用使用,不可直接实例化;
  • 抽象类无法实例化,派生类必须重写所有纯虚函数才能成为 “具体类”;
  • 虚表指针会增加对象的内存开销(32 位下 4 字节,64 位下 8 字节);
  • 虚函数调用比普通函数多一次 “地址查找”,存在微小性能损耗,但通常可忽略(多态带来的灵活性远大于性能损失)。

四. 多态考察的一些常见问题(重点,面试高频题)

1. 面向对象的三大特性(这里重点讲讲什么是多态)

  • 封装:将数据和操作数据的方法封装在一起,隐藏对象的内部实现细节,仅对外暴露公共接口,降低耦合性(eg:迭代器的实现)
  • 继承:子类继承父类的属性和方法,子类可以直接使用父类的非私有成员和方法,实现代码的复用,同时子类可以对父类的方法进行扩展和重写
  • 多态:多态分为静态多态(编译时多态)和动态多态(运行时多态),静态多态比如函数的重载,模板。我们可以通过传不同的参数达到不同的效果。动态时多态就要求有继承关系,用基类的指针或者引用去调用虚函数,且派生类对基类的虚函数完成了重写,根据基类的指针指向的对象去调用对应的方法,做到不同对象执行不同逻辑。(eg.动物叫声的例子,不同的对象发出不同的叫声)

2. 什么是重载,重写(覆盖),重定义(隐藏)?

  • 重载:同一类中(同一作用域中),函数名相同,参数列表(参数类型、个数、顺序)不同,返回值类型可以相同也可以不同
  • 重写(覆盖):子类继承父类后(不同作用域),对父类的虚函数进行重新实现,函数名、参数列表、返回值类型(协变情况除外)完全相同。
  • 重定义(隐藏):(不同作用域)子类中定义了与父类同名(只需要同名就可以)的非虚函数,隐藏父类的该函数。
特性定义示例
重载同一类中,方法名相同,参数列表(参数类型、个数、顺序)不同,与返回值类型无关类中add(int a, int b)add(double a, double b)
重写(覆盖)子类继承父类后,对父类的虚函数进行重新实现,方法名、参数列表、返回值类型(协变情况除外)完全相同父类Animal的虚函数makeSound(),子类Dog重写为void makeSound() { cout << "汪汪" << endl; }
重定义(隐藏)子类中定义了与父类同名的非虚函数,隐藏父类的该函数父类有func(),子类也定义func(),子类对象调用func()时执行子类的,父类对象调用执行父类的

3. 多态的实现原理?
答:
多态通过 虚函数表(vtable)和虚表指针(vptr) 实现。每个包含虚函数的类都有一个虚函数表,表中存储着该类所有虚函数的地址。每个对象都有一个虚表指针,指向所属类的虚函数表(相同类型的对象指向同一张虚函数表)。当通过父类指针或者引用调用虚函数时,程序会根据指向的实际对象类型,通过其虚表指针找到虚函数表,再找到对应的虚函数地址(可以通过虚函数指针)并调用,从而实现运行时的多态。
4. inline函数可以是虚函数吗?inline属性和虚函数属性能同时存在吗?
:可以是虚函数,从语法上看,inline函数可以声明为虚函数,但实际上编译器会忽略inline属性(inline一般展开是不需要地址的),将其当作普通虚函数处理。因为虚函数要放在虚表中去,两者机制冲突,也就是说inline属性和虚函数属性是不同时存在的。
5. 静态成员可以是虚函数吗?
:不能,静态成员函数属于类,不属于某个对象,没有this指针,而虚函数的调用需要通过对象的虚函数指针来实现,所以静态成员函数不能是虚函数。
6. 构造函数可以是虚函数吗?
:不可以。因为对象中的虚函数指针是在构造函数初始化列表阶段才初始化的,所以构造函数不能是虚函数。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

  • 析构函数可以是虚函数。
  • 场景:当存在继承关系,且需要通过父类指针删除子类对象时,为了确保子类的析构函数被调用,需要将父类的析构函数声明为虚函数。例如:父类Base,子类Derived,若用Base* p = new Derived(); delete p;,如果Base的析构函数不是虚函数,只会调用Base的析构函数,导致子类资源未释放;若为虚函数,则会先调用Derived的析构函数,再调用Base的析构函数。

8. 对象访问普通函数快还是虚函数更快?
:首先如果是普通对象调用的话两者是一样快的,但如果是基类的指针或者引用去调用,且构成了多态调用,则调用的普通函数更快,运行时调用虚函数需要到虚函数表中去查找,有一定开销。

调用场景普通函数调用机制虚函数调用机制性能差异根源
普通对象调用编译时直接绑定函数地址,直接跳转执行编译时直接绑定函数地址,直接跳转执行无差异
基类指针/引用多态调用编译时直接绑定函数地址,直接跳转执行运行时通过vptr找vtable,再找函数地址执行虚函数多了查表的运行时开销

9. 虚函数表是在什么阶段生成的,存在哪里的?
:虚函数表是在编译阶段生成的,一般情况下是存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?
答:菱形继承会导致数据冗余和二义性
的问题,虚继承则是通过虚基类指针和虚基表(不要把虚函数表,虚函数指针和虚基表,虚基类指针搞混了) 实现的中间基类在继承时顶层基类时声明为虚继承,这样可以保证顶层基类的成员只会有一份,解决了数据冗余和二义性的问题。
11. 什么是抽象类?抽象类的作用?
: 包含纯虚函数(形如virtual void func() = 0;)的类,无法实例化对象。抽象类的作用是 作为接口规范,强制子类必须重写实现纯虚函数


结尾:

🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点: 👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用 💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑 🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解 技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标! 

结语:通过抽象类,我们能定义清晰的接口契约;通过虚表机制,C++ 实现了 “运行时动态绑定” 的多态能力。理解这两者,不仅能正确使用多态,更能在复杂场景中(如框架设计、模块扩展)写出更灵活、可维护的 C++ 代码。

✨把这些内容吃透超牛的!放松下吧✨ʕ˘ᴥ˘ʔづきらど

Read more

从 XMLHttpRequest 到 Fetch API:现代前端网络请求的演进与迁移指南

从 XMLHttpRequest 到 Fetch API:现代前端网络请求的演进与迁移指南

🧑 博主简介:ZEEKLOG博客专家,历代文学网(PC端可以访问:https://literature.sinhy.com/#/?__c=1000,移动端可关注公众号 “ 心海云图 ” 微信小程序搜索“历代文学”)总架构师,16年工作经验,精通Java编程,高并发设计,分布式系统架构设计,Springboot和微服务,熟悉Linux,ESXI虚拟化以及云原生Docker和K8s,热衷于探索科技的边界,并将理论知识转化为实际应用。保持对新技术的好奇心,乐于分享所学,希望通过我的实践经历和见解,启发他人的创新思维。在这里,我希望能与志同道合的朋友交流探讨,共同进步,一起在技术的世界里不断学习成长。 🤝商务合作:请搜索或扫码关注微信公众号 “ 心海云图 ” 从 XMLHttpRequest 到 Fetch API:现代前端网络请求的演进与迁移指南 引言:为什么我们需要新的网络请求方案? 在前端开发领域,XMLHttpRequest (XHR) 长期统治着浏览器端的网络请求。然而,随着 Web

By Ne0inhk
玩转ClaudeCode:使用Figma-MCP编写前端代码1:1还原UI设计图

玩转ClaudeCode:使用Figma-MCP编写前端代码1:1还原UI设计图

目录 本轮目标 具体实践 一、开启 Figma 的 MCP 服务器 二、Claude Code 连接 Figma MCP 三、Claude Code 代码实现 Figma 设计稿 本轮目标 本轮目标是制作数字化大屏的一个前端组件,要求和UI设计图还原度达到1:1。 本轮目标需要我们提前准备好figma客户端,且登录帐号具有开发模式的权限(没有可以去某夕)。Claude Code 就不必多说,没有安装的同学参考我的上一篇文章《玩转ClaudeCode:ClaudeCode安装教程(Windows+Linux+MacOS)》完成安装,通过专属链接注册,可以额外领取100美金的免费使用额度。 安装教程参考:玩转ClaudeCode:ClaudeCode安装教程(Windows+Linux+MacOS)_claude code安装-ZEEKLOG博客文章浏览阅读2.5w次,点赞67次,

By Ne0inhk
Flutter 组件 ews 的适配 鸿蒙Harmony 实战 - 驾驭企业级 Exchange Web Services 协议、实现鸿蒙端政企办公同步与高安通讯隔离方案

Flutter 组件 ews 的适配 鸿蒙Harmony 实战 - 驾驭企业级 Exchange Web Services 协议、实现鸿蒙端政企办公同步与高安通讯隔离方案

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net Flutter 组件 ews 的适配 鸿蒙Harmony 实战 - 驾驭企业级 Exchange Web Services 协议、实现鸿蒙端政企办公同步与高安通讯隔离方案 前言 在鸿蒙(OpenHarmony)生态进军政企办公领域的过程中,与现有企业信息化基础设施的深度集成是一道必答题。即便是在全连接、分布式的今天,微软的 Exchange 服务器依然是全球无数大厂与政务系统处理邮件、日历同步的核心底座。 对于习惯了简单 http.get 的移动开发者来说,Exchange Web Services(EWS)协议由于其复杂的 SOAP 封装、繁琐的 XML 数据结构以及极其严苛的身份认证机制,往往是一块难啃的“骨头”。 ews 库为 Dart 提供了成熟的、类型安全的

By Ne0inhk
Flutter for OpenHarmony: Flutter 三方库 fixnum 解决鸿蒙 Web 与原生端 64 位大整数精度失真难题(精准计算护卫)

Flutter for OpenHarmony: Flutter 三方库 fixnum 解决鸿蒙 Web 与原生端 64 位大整数精度失真难题(精准计算护卫)

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net 前言 在进行 OpenHarmony 的跨平台开发时,你可能会遇到一个诡异的 Bug:同样的 64 位长整数(如 Int64),在鸿蒙原生(Native)模式下运行正常,但编译为 Flutter Web 模式在浏览器运行时,数值却发生了精度漂移或溢出。 1. 产生原因:JavaScript 原生的数字类型实质上是 64 位浮点数,它能安全表示的最大整数只有 53 位( 2 53 − 1 2^{53}-1 253−1)。 2. 后果:大额订单 ID、高精度的金融分位值、或是底层硬件的 64 位地址位,在

By Ne0inhk