【C++】拆分详解 - 多态
文章目录
- 一、概念
- 二、定义和实现
- 1. 多态的构成条件
- 2. 虚函数
- 2.1 虚函数的重写/覆盖
- 2.2 虚函数重写的两个例外
- 3. override 和 final关键字
- 4. 重载/重写/隐藏的对比
- 5. 例题
- 三、纯虚函数和抽象类
- 四、多态的原理
- 1. 虚函数表
- 2. 实现原理
- 3. 动态绑定和静态绑定
- 总结
一、概念
多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态。
编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时⼀般归为静态,运⾏时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的⼀个行为(函数),传猫对象过去,就是"喵喵",传狗对象过去,就是"汪汪"。
二、定义和实现
1. 多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
必须通过基类的指针或者引用调用虚函数
- 因为只有基类的指针或引⽤才能既指向基类对象⼜指向派⽣类对象
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
- 派⽣类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派⽣类之间才能有不同的函数,多态的不同形态效果才能达到。
- 派生类重写虚函数时,不加
virtual
修饰也会被认为是基类虚函数的重写,虽然这是不规范的写法,但是确实存在这个效果
2. 虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
2.1 虚函数的重写/覆盖
派生类中有⼀个跟基类完全相同的虚函数(三同:返回值类型、函数名、参数列表) ,称派⽣类的虚函数重写了基类的虚函数。
- 注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-打折" << endl; }
};void Func(Person* ptr)
{ptr->BuyTicket(); // 传参为基类对象指针,则调用基类的虚函数// 传参为派生类对象指针切割后 转换的基类对象指针,则调用派生类虚函数
}
int main()
{Person ps;Student st;Func(&ps);Func(&st);return 0;
}
2.2 虚函数重写的两个例外
- 协变
派生类重写基类虚函数时,与基类虚函数返回值类型可以不同,但返回值类型必须为各自的指针或引用类型。
即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解⼀下即可。
class A {};
class B : public A {};class Person {
public:virtual A* BuyTicket() //基类虚函数返回基类对象指针或引用{cout << "买票-全价" << endl;return nullptr;}
};
class Student : public Person {
public:virtual B* BuyTicket() //派生类虚函数返回派生类对象指针或引用{cout << "买票-打折" << endl;return nullptr;}
};void Func(Person* ptr)
{ptr->BuyTicket();
}
int main()
{Person ps;Student st;Func(&ps);Func(&st); return 0;
}
- 析构函数的重写
基类的析构函数一般需要修饰为虚函数,构成多态,否则使用切割 即子类对象指向父类指针/引用时,会去调用父类的析构函数,如果子类对象中有申请资源,就会造成内存泄漏。注意:我们使用切割时,把子类中的父类部分切给父类指针/引用后,剩余的子类部分是没人要的,但是不能没人管,最终必须要对其资源回收,所以必须要构成多态,用子类自己的析构函数去释放资源。
构成多态的条件是派生类重写基类的虚函数,基类和派生类的析构函数名都不一样,怎么重写?
虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以满足同名。 所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。
下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,没有释放子类切割后剩余的部分(如果没有子类剩余部分中没有进行资源申请,那无所谓,如果有就会内存泄漏)
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};
class B : public A {
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10]; //子类独有部分进行了资源申请,且使用了切割时,必须使用多态,否则内存泄漏
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,
// 才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
3. override 和 final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。
class Car {
public:virtual void Dirve(){}
};
class Benz :public Car {
public:// 报错:使用“override”声明的成员函数不能重写基类成员virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
int main()
{return 0;
}
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:// 无法重写“final”函数 "Car::Drive" (已声明 所在行数:xxx)virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{return 0;
}
4. 重载/重写/隐藏的对比
同一作用域
重载 (同名,不同参)
不同作用域
重写 (三同:同名,同参,同返回,协变例外)
隐藏 (同名,包含重写)
5. 例题
-
以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
B->1
基类指针调用基类成员函数
p->test();
this指针为基类指针,所以构成多态,调用派生类虚函数
this.func();
重写的“大坑”
- 缺省值不同仍视为参数相同,所以
func()
构成重写- 重写的是实现(函数体),相当于使用基类的参数 + 派生类的函数体, 所以使用基类参数的缺省值
class A
{
public:virtual void func(int val = 1) // 3. 基类的缺省值参数{ std::cout << "A->" << val << std::endl;}virtual void test(){ func(); //2. this指针为基类指针,所以构成多态,调用派生类虚函数}
};class B : public A
{
public:void func(int val = 0) { // 3. 派生类的函数体std::cout << "B->" << val << std::endl;}
};int main(int argc, char* argv[])
{B* p = new B;p->test(); // 1.基类指针调用基类成员函数return 0;
}
三、纯虚函数和抽象类
- 在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。
- 包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
class Car
{
public:virtual void Drive() = 0; //纯虚函数,抽象类
};
class Benz :public Car
{
public:virtual void Drive() //派生类重写后纯虚函数后可以实例化{cout << "Benz-舒适" << endl;}
};class BMW :public Car
{
public:virtual void Drive(){cout << "BMW-操控" << endl;}
};
int main()
{// 编译报错:error C2259: “Car”: 无法实例化抽象类Car car;Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}
四、多态的原理
1. 虚函数表
虚函数表本质是一个存虚函数指针的指针数组,只要类中声明了虚函数就会生成该类独有的虚函数表,将类中声明的虚函数地址都放入虚表。
派生类会继承基类的虚函数,所以也会生成自己的虚函数表
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
类对象中并不直接存储虚表,而是存储虚表指针
下方代码运⾏结果12bytes(32位),除了_b和_ch成员,还多⼀个__vfptr放在对象的偏移量为0的区域(不同平台可能放置位置不同),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
class Base{ public:virtual void Func1(){cout << "Func1()" << endl;} protected:int _b = 1;char _ch = 'x'; }; int main() {Base b;cout << sizeof(b) << endl;return 0; }
虚函数和虚函数表存在哪?
虚函数和普通函数一样,存储在代码段中
虚函数表存储的位置C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。vs下是
存在代码段的(常量区)。
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func5() { cout << "Base::func5" << endl; }
protected:int a = 1;
};
class Derive : public Base
{
public:// 重写基类的func1virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func1" << endl; }void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};int main()
{int i = 0;static int j = 1;int* p1 = new int;const char* 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("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);printf("普通函数地址:%p\n", &Base::func5);return 0;
}
2. 实现原理
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:string _name;
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:string _id;
};
class Soldier : public Person {
public:virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:string _codename;
};void Func(Person* ptr)
{// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket// 但是跟ptr没关系,而是由ptr指向的对象决定的。ptr->BuyTicket();
}
int main()
{// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后// 多态也会发生在多个派生类之间。Person ps;Student st;Soldier sr;Func(&ps);Func(&st);Func(&sr);return 0;
}
从底层的角度Func函数中
ptr->BuyTicket()
,是如何根据传参指针的不同 指向不同类的虚函数的呢?
- 先回顾一下切割的定义,基类指针指向派生类对象,实际上是将派生类对象的父类部分切割给基类指针,而上文我们验证过虚函数表指针
__vptr
会放置在指定偏移量的区域,这样基类对象指针可以通过偏移量找到派生类对象的这个虚函数表指针- 通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚函数表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
第⼀张图,ptr指向的Person对象,调用的是Person的虚函数;第⼆张图,ptr指向的Student对象,调用的是Student的虚函数
3. 动态绑定和静态绑定
对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
// ptr是指针+BuyTicket是虚函数满⾜多态条件。
// 这里就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax
// BuyTicket不是虚函数,不满足多态条件。
// 这里就是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)
总结
本文讲解了C++多态相关知识,尤其是虚函数和虚函数表部分涉及类对象的内存布局,比较深入底层。
尽管文章修正了多次,但由于水平有限,难免有不足甚至错误之处,敬请各位读者来评论区批评指正。
相关文章:

【C++】拆分详解 - 多态
文章目录 一、概念二、定义和实现1. 多态的构成条件2. 虚函数2.1 虚函数的重写/覆盖2.2 虚函数重写的两个例外 3. override 和 final关键字4. 重载/重写/隐藏的对比5. 例题 三、纯虚函数和抽象类四、多态的原理1. 虚函数表2. 实现原理3. 动态绑定和静态绑定 总结 一、概念 多态…...
Python世界:力扣题解875,珂珂爱吃香蕉,中等
Python世界:力扣题解875,珂珂爱吃香蕉,中等 任务背景思路分析代码实现坑点排查测试套件本文小结 任务背景 问题来自力扣题目875 Koko Eating Bananas,大意如下: Koko loves to eat bananas. There are n piles of bana…...

Java设计模式 —— Java七大设计原则详解
文章目录 前言一、单一职责原则1、概述2、案例演示 二、接口隔离原则1、概述2、案例演示 三、依赖倒转原则1、概述2、案例演示 四、里氏替换原则1、概述2、案例演示 五、开闭原则1、概述2、案例演示 六、迪米特法则1、概述2、案例演示 七、合成/聚合复用原则1、概述2、组合3、聚…...

SpringBoot学习记录(六)配置文件参数化
SpringBoot学习记录(六)配置文件参数化 一、参数提取到配置文件中二、yml配置文件三、ConfigurationProperties注解实现批量属性注入 一、参数提取到配置文件中 定义在代码中的参数的值分散在各个不同的文件中,不便于后期维护管理࿰…...

android 使用MediaPlayer实现音乐播放--获取音乐数据
前面已经添加了权限,有权限后可以去数据库读取音乐文件,一般可以获取全部音乐、专辑、歌手、流派等。 1. 获取全部音乐数据 class MusicHelper {companion object {SuppressLint("Range")fun getMusic(context: Context): MutableList<Mu…...

.net 8使用hangfire实现库存同步任务
C# 使用HangFire 第一章:.net Framework 4.6 WebAPI 使用Hangfire 第二章:net 8使用hangfire实现库存同步任务 文章目录 C# 使用HangFire前言项目源码一、项目架构二、项目服务介绍HangFire服务结构解析HangfireCollectionExtensions 类ModelHangfireSettingsHttpAuthInfoUs…...
第 22 章 - Go语言 测试与基准测试
在Go语言中,测试是一个非常重要的部分,它帮助开发者确保代码的正确性、性能以及可维护性。Go语言提供了一套标准的测试工具,这些工具可以帮助开发者编写单元测试、表达式测试(通常也是指单元测试中的断言)、基准测试等…...

VB.Net笔记-更新ing
目录 1.1 设置默认VS的开发环境为VB.NET(2024/11/18) 1.2 新建一个“Hello,world”的窗体(2024/11/18) 1.3 计算圆面积的小程序(2024/11/18) 显示/隐式 声明 (2024/11/18&…...

centos 服务器 docker 使用代理
宿主机使用代理 在宿主机的全局配置文件中添加代理信息 vim /etc/profile export http_proxyhttp://127.0.0.1:7897 export https_proxyhttp://127.0.0.1:7897 export no_proxy"localhost,127.0.0.1,::1,172.171.0.0" docker 命令使用代理 例如我想在使用使用 do…...
python语言基础
1. 基础语法 Q: Python 中的变量与数据类型有哪些? A: Python 支持多种数据类型,包括数字(整数 int、浮点数 float、复数 complex)、字符串 str、列表 list、元组 tuple、字典 dict 和集合 set。每种数据类型都有其特定的用途和…...
Python中的Apriori库详解
文章目录 Python中的Apriori库详解一、引言二、Apriori算法原理与Python实现1、Apriori算法原理2、Python实现1.1、数据准备1.2、转换数据1.3、计算频繁项集1.4、提取关联规则 三、案例分析1、导入必要的库2、准备数据集3、数据预处理4、应用Apriori算法5、生成关联规则6、打印…...
MongoDB比较查询操作符中英对照表及实例详解
mongodb比较查询操作符中英表格一览表 NameDescription功能$eqMatches values that are equal to a specified value.匹配值等于指定值。$gtMatches values that are greater than a specified value.匹配值大于指定值。$gteMatches values that are greater than or equal to…...

掌上单片机实验室 – RT-Thread + ROS2 初探(25)
在初步尝试RT-Thread之后,一直在琢磨如何进一步感受它的优点,因为前面只是用了它的内核,感觉和FreeRTOS、uCOS等RTOS差别不大,至于它们性能、可靠性上的差异,在这种学习性的程序中,很难有所察觉。 RT-Threa…...
Kotlin中的?.和!!主要区别
目录 1、?.和!!介绍 2、使用场景和最佳实践 3、代码示例和解释 1、?.和!!介绍 Kotlin中的?.和!!主要区别在于它们对空指针的处理方式。 ?.(安全调用操作符):当变量可能为null时,使用?.可以安全地调用其方法或属性…...

iframe嵌入踩坑记录
iframe嵌入父子页面token问题 背景介绍 最近在做在平台A中嵌入平台B某个页面的需求,我负责的是平台B这边,使这个页面被嵌入后能正常使用。两个平台都实现了单点登录。 其实这是第二次做这个功能了,原本以为会很顺利,但没想到折腾…...
面试小札:Java的类加载过程和类加载机制。
Java类加载过程 加载(Loading) 这是类加载过程的第一个阶段。在这个阶段,Java虚拟机(JVM)主要完成三件事: 通过类的全限定名来获取定义此类的二进制字节流。这可以从多种来源获取,如本地文件系…...
Spring 上下文对象
1. Spring 上下文对象概述 Spring 上下文对象(ApplicationContext)是 Spring 框架的核心接口之一,它扩展了 BeanFactory 接口,提供了更多企业级应用所需的功能。ApplicationContext 不仅可以管理 Bean 的生命周期和配置࿰…...

Wireshark抓取HTTPS流量技巧
一、工具准备 首先安装wireshark工具,官方链接:Wireshark Go Deep 二、环境变量配置 TLS 加密的核心是会话密钥。这些密钥由客户端和服务器协商生成,用于对通信流量进行对称加密。如果能通过 SSL/TLS 日志文件(例如包含密钥的…...

测试人员--如何区分前端BUG和后端BUG
在软件测试中,发现一个BUG并不算难,但准确定位它的来源却常常让测试人员头疼。是前端页面的问题?还是后台服务的异常?如果搞错了方向,开发人员之间的沟通效率会大大降低,甚至导致问题久拖不决。 那么&#…...

【Vue】指令扩充(指令修饰符、样式绑定)
目录 指令修饰符 按键修饰符 事件修饰符 双向绑定指令修饰符 输入框 表单域 下拉框 单选按钮 复选框 样式绑定 分类 绑定class 绑定style tab页切换示例 指令修饰符 作用 借助指令修饰符,可以让指令的功能更强大 分类 按键修饰符:用来…...

eNSP-Cloud(实现本地电脑与eNSP内设备之间通信)
说明: 想象一下,你正在用eNSP搭建一个虚拟的网络世界,里面有虚拟的路由器、交换机、电脑(PC)等等。这些设备都在你的电脑里面“运行”,它们之间可以互相通信,就像一个封闭的小王国。 但是&#…...
SkyWalking 10.2.0 SWCK 配置过程
SkyWalking 10.2.0 & SWCK 配置过程 skywalking oap-server & ui 使用Docker安装在K8S集群以外,K8S集群中的微服务使用initContainer按命名空间将skywalking-java-agent注入到业务容器中。 SWCK有整套的解决方案,全安装在K8S群集中。 具体可参…...

RocketMQ延迟消息机制
两种延迟消息 RocketMQ中提供了两种延迟消息机制 指定固定的延迟级别 通过在Message中设定一个MessageDelayLevel参数,对应18个预设的延迟级别指定时间点的延迟级别 通过在Message中设定一个DeliverTimeMS指定一个Long类型表示的具体时间点。到了时间点后…...
golang循环变量捕获问题
在 Go 语言中,当在循环中启动协程(goroutine)时,如果在协程闭包中直接引用循环变量,可能会遇到一个常见的陷阱 - 循环变量捕获问题。让我详细解释一下: 问题背景 看这个代码片段: fo…...

.Net框架,除了EF还有很多很多......
文章目录 1. 引言2. Dapper2.1 概述与设计原理2.2 核心功能与代码示例基本查询多映射查询存储过程调用 2.3 性能优化原理2.4 适用场景 3. NHibernate3.1 概述与架构设计3.2 映射配置示例Fluent映射XML映射 3.3 查询示例HQL查询Criteria APILINQ提供程序 3.4 高级特性3.5 适用场…...

Debian系统简介
目录 Debian系统介绍 Debian版本介绍 Debian软件源介绍 软件包管理工具dpkg dpkg核心指令详解 安装软件包 卸载软件包 查询软件包状态 验证软件包完整性 手动处理依赖关系 dpkg vs apt Debian系统介绍 Debian 和 Ubuntu 都是基于 Debian内核 的 Linux 发行版ÿ…...
基于服务器使用 apt 安装、配置 Nginx
🧾 一、查看可安装的 Nginx 版本 首先,你可以运行以下命令查看可用版本: apt-cache madison nginx-core输出示例: nginx-core | 1.18.0-6ubuntu14.6 | http://archive.ubuntu.com/ubuntu focal-updates/main amd64 Packages ng…...

基于SpringBoot在线拍卖系统的设计和实现
摘 要 随着社会的发展,社会的各行各业都在利用信息化时代的优势。计算机的优势和普及使得各种信息系统的开发成为必需。 在线拍卖系统,主要的模块包括管理员;首页、个人中心、用户管理、商品类型管理、拍卖商品管理、历史竞拍管理、竞拍订单…...

【网络安全】开源系统getshell漏洞挖掘
审计过程: 在入口文件admin/index.php中: 用户可以通过m,c,a等参数控制加载的文件和方法,在app/system/entrance.php中存在重点代码: 当M_TYPE system并且M_MODULE include时,会设置常量PATH_OWN_FILE为PATH_APP.M_T…...

破解路内监管盲区:免布线低位视频桩重塑停车管理新标准
城市路内停车管理常因行道树遮挡、高位设备盲区等问题,导致车牌识别率低、逃费率高,传统模式在复杂路段束手无策。免布线低位视频桩凭借超低视角部署与智能算法,正成为破局关键。该设备安装于车位侧方0.5-0.7米高度,直接规避树枝遮…...