C++ 之 多态 【虚函数表、多态的原理、动态绑定与静态绑定】

article/2025/7/31 20:36:52

目录

前言

1.多态的原理

1.1虚函数表

1.2派生类中的虚表

 1.3虚函数、虚表存放位置

 1.4多态的原理

1.5多态条件的思考 

2.动态绑定与静态绑定 

3.单继承和虚继承中的虚函数表 

3.1单继承中的虚函数表

 3.2多继承(非菱形继承)中的虚函数表

 4.问答题


前言

需要声明的,这期博客的代码及解释都是在vs202022下的x86程序中,涉及的指针都是4bytes。 如果要其他平台下,部分代码需要改动。比如:如果是x64程序,则需要考虑指针是8bytes问题 等等

1.多态的原理

1.1虚函数表

这里常考一道笔试题:sizeof(Base)是多少

class Base{public:virtual void Func1(){cout << "Func1()" << endl;}private:int _b = 1;};int main()
{Base b;cout << sizeof(b) << endl;return 0;
}

8字节说明了,对象中除了存储成员变量_b,还存储着些什么

有一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代 表virtual,f代表function)

一个含有虚函数的类中都至少有一个虚函数表指针

因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表

1.2派生类中的虚表

class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};class Derive : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}

通过监视窗口我们可以看到,

(1)基类对象包含一个虚函数表指针(vptr),指向基类的虚函数表

派生类对象同样包含一个vptr,指向派生类的虚函数表,

在派生类对象的内存布局上,

派生类对象的虚表指针覆盖了所包含的基类子对象的虚表指针,指向的是派生类的虚函数表

即派生类的虚表指针存放到了所包含的基类子对象虚表指针的位置

(2)基类b对象和派生类d对象虚表是不一样的,

这里我们发现Func1完成了重写,所以d的虚表中存储的是重写的Derive::Func1

所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。

重写是语法层的叫法,覆盖是原理层的叫法

(3)另外Func2继承下来后是虚函数,所以放进了虚表

Func3也继承下来了,但不是虚函 数,所以不会放进虚表

(4)虚函数表本质是一个存虚函数指针的函数指针数组

一般情况这个数组最后面放了一个nullptr

(5)总结一下派生类的虚表生成:

a.派生类首先会继承基类的虚函数表结构

b.派生类重写了基类中的某个虚函数

就用派生类的虚函数地址覆盖虚表中基类的虚函数地址 

c.派生类自己新增加的虚函数按其在派生类中的声明次序追加到派生类虚表的最后

 

 1.3虚函数、虚表存放位置

虚函数与普通函数一样,存放在代码段中

虚函数表的存放位置由编译器决定,不同的编译器可能有不同的实现方式

下面是在vs2022 X86环境下运行的代码及结果,推断虚表存放在常量区(只读数据段)

//验证虚表存放在常量区(代码段)
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:char _b = 1;
};int main()
{int a = 10;printf("栈:%p\n", &a);int* b = new int(5);printf("堆:%p\n", b);static int c = 20;printf("静态区:%p\n", &c);const char* p = "ncoa";printf("常量区:%p\n", p);Base b;printf("虚表地址:%p\n", *((int*)&b));return 0;
}

所以同一个类的对象共用同一张虚表 

 1.4多态的原理

 

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
protected: int a = 10;
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl;}
protected: int b = 20;
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}

 (1)观察下图的红色箭头我们看到,

p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数Person::BuyTicket

(2)观察下图的蓝色箭头我们看到

p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数Student::BuyTicket

这样就实现出了不同对象去完成同一行为时,展现出不同的形态

多态的原理就是根据指向的对象类型使用该对象的虚表指针找到到该对象的虚表 然后再在虚表中去找到并调用相应函数(如果有)

1.5多态条件的思考 

(1)多态要求必须通过父类的指针或引用实现:

因为父类的指针既可以指向父类对象也可以指向子类对象

指向父类对象时就在父类对象的虚表中去找到相应函数并调用(如果有)

指向子类对象时就在子类对象的虚表中去找到相应函数并调用(如果有)

同理父类的引用可以是父类对象的别名也可以是子类对象的别名

是父类对象的别名时就在父类对象的虚表中去找到相应函数并调用(如果有)

是子类对象的别名时就在子类对象的虚表中去找到相应函数并调用(如果有)

不能是父类对象的原因是:

赋值切片时,虚表并不进行拷贝,此时通过父类对象只能调用父类的虚函数

假设虚表要进行拷贝,那么会导致父类对象调用的虚函数不明确

(既可能是父类对象的虚函数也可能是子类对象的虚函数)

 (2)多态要求实现虚函数的重写:
只有实现了虚函数的重写,才能实现相同函数名调用时出现不同的形态

2.动态绑定与静态绑定 

class person {
public:virtual void buyticket() { cout << "买票-全价" << endl;}
protected:int a = 10;
};
class student : public person {
public:virtual void buyticket() { cout << "买票-半价" << endl;}
protected:int b = 20;
};
void func(person* p)
{p->buyticket();
}
int main()
{person mike;func(&mike);mike.buyticket();return 0;
}

满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的 

不满足多态的函数调用,在编译时确定

1.静态绑定又称为前期绑定(早绑定)

是指在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载、模板等

2. 动态绑定又称后期绑定(晚绑定)

是指在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,

也称为动态多态,比如:构成多态条件的虚函数的调用等

3.单继承和虚继承中的虚函数表 

3.1单继承中的虚函数表

class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a = 10;
};
class Derive :public Base {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }
private:int b = 20;
};int main()
{Base b1;Derive d1;return 0;
}

通过监视窗口,我们发现d1虚表当中似乎没有追加自身虚函数的地址

 但通过内存窗口,我们可以看到虚表中存放有四个指针,我们打印出来看看

接上面的代码
//给函数指针起个别名
typedef void(*FUNC_VFT)();void Print(FUNC_VFT* table)
{for (int i = 0; table[i] != nullptr; ++i){printf("[%d]:%p->", i, table[i]);FUNC_VFT f = table[i];f();}printf("\n");
}
int main()
{Base b1;Derive d1;//取到虚表指针,虚表指针本质是一个函数二级指针FUNC_VFT* table = (FUNC_VFT*)(*((int*)&d1));Print(table);return 0;
}

我们看到,派生类的虚表确实在末尾追加了自己定义的虚函数地址 

关于上面验证的细节:

(1) 认识到虚表本质上是一个函数指针数组,虚表指针就是一个函数二级指针

(1) 函数指针看起来比较复杂,起个别名更简便

(1) 对象的前四个字节存放的是虚表指针(不同平台不一样)

(1) for循环中,虚表末尾通常放一个nullptr(不同平台可能不一样,此时则需要定死循环次数)

同时,在vs下,你在编译好的情况下再次修改代码,nullptr可能就会消失,这时候只需要重写生成解决方案即可

(1) 关于函数调用,这里的情况非常特殊,成员函数没有参数,且没有调用this指针

不然的话就会大坑

(1) 当然,调用的虚函数打印的话也在此次验证中起到了举足轻重的作用

 3.2多继承(非菱形继承)中的虚函数表

class Base1 {
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};
class Base2 {
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};
class Derive : public Base1, public Base2 {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{cout << " 虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
int main()
{Derive d;return 0;
}

对象d分别继承两个基类的虚表结构,完成虚函数的覆盖

只是同样看不见自己的func3存放到哪里,打印出来看看

int main()
{Derive d;VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);//VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));Base2* p = &d;VFPTR* vTableb2 = (VFPTR*)(*(int*)p);PrintVTable(vTableb2);return 0;
}

存放到了Base1的虚表当中

结论就是:多继承派生类自身的虚函数追加第一个继承基类部分的虚函数表末尾

这保持了与单继承的一致性

简化了多态调用的实现(第一个基类指针直接调用无需偏移量调整)

调用d对象中的func1函数

 两张虚表当中都有func1函数,但fun1函数的地址只有一个

编译器针对func1函数的调用,首先需要传this指针,其次call 函数func1的地址

子类包含的第一个父类子对象的地址与派生类对象的地址相同,

使用第一个父类指针调用func1时,传完该指针之后可以直接调用func1

子类类包含的第一个父类子对象的地址与派生类对象的地址相同,

使用第二个父类指针调用func1时,传完该指针之后需修正使其指向派生类对象的地址,最后进行调用

 4.问答题

4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。

5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表 阶段才初始化的。

8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针 对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函 数表中去查找。

9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况 下存在代码段(常量区)的。


http://www.hkcw.cn/article/NAfuqkOgZq.shtml

相关文章

28 C 语言作用域详解:作用域特性(全局、局部、块级)、应用场景、注意事项

1 作用域简介 作用域定义了代码中标识符&#xff08;如变量、常量、数组、函数等&#xff09;的可见性与可访问范围&#xff0c;即标识符在程序的哪些位置能够被引用或访问。在 C 语言中&#xff0c;作用域主要分为三类&#xff1a; 全局作用域局部作用域块级作用域 需注意&am…

day03-Vue-Element

1 Ajax 1.1 Ajax介绍 1.1.1 Ajax概述 我们前端页面中的数据&#xff0c;如下图所示的表格中的学生信息&#xff0c;应该来自于后台&#xff0c;那么我们的后台和前端是互不影响的2个程序&#xff0c;那么我们前端应该如何从后台获取数据呢&#xff1f;因为是2个程序&#xf…

智慧交通设计方案

该文档是智慧交通设计方案,交通设计位于综合交通规划后、道路工程设计前,目标是优化交通系统及设施,实现交通安全、高效、可持续发展。内容涵盖区域交通组织优化(含需求管理、速度管理等)、平面交叉口设计(要素、改善措施)、专项交通设计(公共交通、慢行系统等)、智能…

SAP学习笔记 - 开发17 - 前端Fiori开发 Component 配置(组件化)

上一章讲了Fiori前端开发中的国际化。 SAP学习笔记 - 开发16 - 前端Fiori开发 Properties文件&#xff08;国际化&#xff09; &#xff0c;语言切换实例&#xff0c;Fiori 国际化&#xff08;常用语言列表&#xff0c;关键规则&#xff0c;注意事项&#xff09;-CSDN博客 本…

leetcode刷题日记——二叉树的层平均值

[ 题目描述 ]&#xff1a; [ 思路 ]&#xff1a; BFS&#xff0c;通过层次遍历求得每层的和&#xff0c;然后取平均数&#xff0c;存入结果数组树中节点个数在1-10000之间&#xff0c;那么结果数组最大为10000个结果&#xff0c;层数最多为 2n-1>10000&#xff0c;可以推…

Google Android 14设备和应用通知 受限制的设置 出于安全考虑......

重要提示&#xff1a; 文中部分步骤仅适用于 Android 13 及更高版本。了解如何查看 Android 版本。 启用受限制的设置后&#xff0c;应用将能够访问敏感信息&#xff0c;而这可能使您的个人数据面临风险。除非您信任该应用的开发者&#xff0c;否则我们不建议您允许访问受限制…

【小米拥抱AI】小米开源视觉大模型—— MiMo-VL

MiMo-VL-7B模型的开发包含两个序贯训练过程&#xff1a;&#xff08;1&#xff09;四阶段预训练&#xff0c;涵盖投影器预热、视觉-语言对齐、通用多模态预训练及长上下文监督微调&#xff08;SFT&#xff09;&#xff0c;最终生成MiMo-VL-7B-SFT模型&#xff1b;&#xff08;2…

自编码器Auto-encoder(李宏毅)

目录 编码器的概念&#xff1a; 为什么需要编码器&#xff1f; 编码器什么原理&#xff1f; 去噪自编码器: 自编码器的应用&#xff1a; 特征解耦 离散隐表征 编码器的概念&#xff1a; 重构&#xff1a;输入一张图片&#xff0c;通过编码器转化成向量&#xff0c;要求再…

Claude 4 升级:从问答助手到任务执行者 | AI大咖说

Claude 4 升级&#xff1a;从问答助手到任务执行者 Claude 4 升级历程 2025-05-22日&#xff0c;Anthropic 正式发布了他们的新 AI 模型 Claude 4。这标志着 AI 不再仅仅是一个智能问答系统&#xff0c;而是开始具备独立完成复杂任务的能力。CEO Dario Amodei 在发布会中强调…

Day42 Python打卡训练营

知识点回顾 1.回调函数 2.lambda函数 3.hook函数的模块钩子和张量钩子 4.Grad-CAM的示例 作业&#xff1a;理解下今天的代码即可 1.回调函数 Hook本质是回调函数&#xff0c;所以我们先介绍一下回调函数 回调函数是作为参数传递给其他函数的函数&#xff0c;其目的是在某个特…

2002-2022年 城市市政公用设施水平、环境、绿地等数据-社科经管实证数据

2002-2022年城市市政公用设施水平、环境、绿地等数据-社科经管https://download.csdn.net/download/paofuluolijiang/90880456 https://download.csdn.net/download/paofuluolijiang/90880456 《2002-2022年城市市政公用设水平、环境、绿地等数据-社科经管实证数据》整理自多源…

uni-app学习笔记十七-css和scss的使用

SCSS 和 CSS的异同点 我们可以使用css和scss来设置样式。其中SCSS&#xff08;Sassy CSS&#xff09;是 CSS 预处理器 Sass&#xff08;Syntactically Awesome Stylesheets&#xff09;的一种语法格式&#xff0c;而 CSS&#xff08;Cascading Style Sheets&#xff09;是标准…

达梦分布式集群DPC_分布式事务理解_yxy

达梦分布式集群DPC_分布式事务理解 1 分布式事务是什么&#xff1f;2 分布式事务怎么实现&#xff1f;2.1 两阶段提交保障一致性2.1.1 预提交2.1.2 提交 2.2 RAFT协议保障数据强一致2.3 全局事务管理2.3.1 全局事务信息的登记流程2.3.2 数据可见性判断规则 1 分布式事务是什么&…

性能优化 - 案例篇:缓冲区

文章目录 Pre1. 引言2. 缓冲概念与类比3. Java I/O 中的缓冲实现3.1 FileReader vs BufferedReader&#xff1a;装饰者模式设计3.2 BufferedInputStream 源码剖析3.2.1 缓冲区大小的权衡与默认值 4. 异步日志中的缓冲&#xff1a;Logback 异步日志原理与配置要点4.1 Logback 异…

【目标检测】检测网络中neck的核心作用

1. neck最主要的作用就是特征融合&#xff0c;融合就是将具有不同大小感受野的特征图进行了耦合&#xff0c;从而增强了特征图的表达能力。 2. neck决定了head的数量&#xff0c;进而潜在决定了不同尺度样本如何分配到不同的head&#xff0c;这一点可以看做是将整个网络的多尺…

基于机器学习的心脏病预测模型构建与可解释性分析

一、引言 心脏病是威胁人类健康的重要疾病之一&#xff0c;早期预测和诊断对防治心脏病具有重要意义。本文利用公开的心脏病数据集&#xff0c;通过机器学习算法构建预测模型&#xff0c;并使用 SHAP 值进行模型可解释性分析&#xff0c;旨在为心脏病的辅助诊断提供参考。 二、…

每日算法-250601

每日算法 - 250601 记录今天完成的算法题目。 1. 1749. 任意子数组和的绝对值的最大值 题目描述 思路 前缀和 解题过程 子数组的和 sum(nums[i..j]) 可以通过前缀和 prefixSum[j] - prefixSum[i-1] 来计算&#xff08;规定 prefixSum[-1] 0&#xff09;。 我们要求的是 ab…

算法打开13天

41.前 K 个高频元素 &#xff08;力扣347题&#xff09; 给你一个整数数组 nums 和一个整数 k &#xff0c;请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。 示例 1: 输入: nums [1,1,1,2,2,3], k 2 输出: [1,2]示例 2: 输入: nums [1], k 1 输出: …

【Centos7】最小化安装版本安装docker(无wget命令避坑)

文章目录 Centos7安卓docker1. 检查CentOS内核版本2. 一键将CentOs的yum源更换为国内阿里yum源3. 使用root权限登录CentOS。确保yum包更新到最新4.安装docker5.Docker阿里云镜像加速器 Centos7安卓docker 1. 检查CentOS内核版本 Docker要求CentOS系统的内核版本高于3.10&…