【C++】多态:编程中的“一人千面”艺术

2024-05-27 22:52

本文主要是介绍【C++】多态:编程中的“一人千面”艺术,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

  • 一、多态的概念
  • 二、多态的定义及实现
    • 1.多态的构成条件
    • 2.虚函数的重写
      • 2.1 什么是虚函数?
      • 2.2 虚函数的重写是什么?
      • 2.3 虚函数重写的两个例外
      • 2.4 C++11 override 和 final
      • 2.5 重载、覆盖(重写)、隐藏(重定义)的对比
  • 三、抽象类
    • 3.1 概念
    • 3.2 接口继承和实现继承
  • 多态绝命题(超级坑)
  • 四、多态的原理
    • 4.1虚函数表
    • 4.2多态的原理
      • 4.2.1虚基表和虚表
  • 五、单继承和多继承关系的虚函数表
    • 5.1 单继承中的虚函数表
    • 5.2 多继承中的虚函数表
  • 没有彩蛋有🥚

前言

本篇我们来探索一下C++中的多态,欢迎大家和我一起走进多态

一、多态的概念

概念:通俗说就是多种形态,不同的对象去完成同一件事情,会产生不同的结果(多种形态)

例子:就拿买火车票来说,普通人和学生还有军人买票是不一样,普通人买票是全价,学生买票是半价,军人买票是优先买,他们都是人,但是买票的结果不一样,体现了不同的对象干同一件事结果不同

二、多态的定义及实现

1.多态的构成条件

构成多态还有两个条件:

① 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

② 必须通过基类的指针或者引用调用虚函数

2.虚函数的重写

2.1 什么是虚函数?

虚函数:即被virtual修饰的类成员函数称为虚函数

class Person
{
public://被virtual修饰的函数叫虚函数virtual  void BuyTicke(){cout << "Person->全价" << endl;}
};

2.2 虚函数的重写是什么?

虚函数的重写(覆盖)派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数

代码演示:

class Person
{
public:virtual void BuyTicket(){cout<<"Person->"<<"买票-全价"<<endl;}
};
class Student:public Person
{
public://void BuyTicket()virtual void BuyTicket(){cout<<"Student->"<<"买票-全价"<<endl;}
};
void func(Person& p)//构成多态的必要条件之一,在调用虚函数时必须通过基类的指针或者引用来调用
{p.BuyTicket();
}
int main()
{Person p;Student s;func(p);func(s);return 0;
}

在这里插入图片描述
总结:传Person的对象就调用Person类的虚函数,传Student对象就调用Student类的虚函数,传谁的对象就调用谁对应的虚函数,指向谁调用谁的虚函数

2.3 虚函数重写的两个例外

1.协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A
{};
class B :public A
{};
class Person
{
public:virtual  A* BuyTicke(){cout << "Person->全价" << endl;}
};
class Student :public Person
{
public:virtual B* BuyTicke(){cout << "Student->半价" << endl;}
};

注意:协变时,相对应的虚函数的返回值要么是指针要么是引用,不能一个指针一个引用,这样不匹配,另外,返回类型要是一对父子类

2.析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。看起来违背了重写的则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor这里也和前面讲的继承那块的析构函数隐藏相呼应

class Person
{
public:~Person() { cout << "~Person()" << endl; }
};
class Student :public Person
{
public:~Student() { cout << "~Student()" << endl; delete[] _ptr;}
private:int* _ptr=new int[10];
};
int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}

大家看一下上面的代码有什么问题?
在这里插入图片描述
我们的期望是:指向父类调用父类析构,指向子类调用子类析构,这不就正是多态的特性嘛

我们直接让析构函数变成虚函数就可以实现我们的期望了

class Person
{
public://destructor()virtual ~Person() { cout << "~Person()" << endl; }
};
class Student :public Person
{
public://destructor()virtual ~Student() { cout << "~Student()" << endl; delete[] _ptr;}
private:int* _ptr=new int[10];
};

总结:建议析构函数写成虚函数,防止发生内存泄漏的问题

其实除了上面的两个例外还有第三个例外,就是派生类的虚函数可以不加virtual,它也构成重写,但是我们不建议不加,一般还是加上

为什么派生类的虚函数可以不加virtual呢?

正所谓重写,子类是继承了对应虚函数的声明(函数名参数返回值等),只是重写了它的定义,所以不加也可以

2.4 C++11 override 和 final

C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

1.final:修饰虚函数,表示该虚函数不能被重写

class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; }//这里直接报红色波浪线了,上面已经用final修饰虚函数了,所以他不能被重写
};

final修饰类,叫最终类,表示该类不能被继承(了解一下)

2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

class Car {
public:virtual void Drive() {}
};
class Benz :public Car {
public:virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

通俗来说,这个override就是用来帮助我们检查这个虚函数有没有被正确重写

2.5 重载、覆盖(重写)、隐藏(重定义)的对比

在这里插入图片描述
在这里插入图片描述

三、抽象类

3.1 概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

class Car//包含纯虚函数的类就是抽象类--抽象类不能被实例化
{
public:virtual void Drive() = 0;//纯虚函数
};

在这里插入图片描述

3.2 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

多态绝命题(超级坑)

class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}//A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

首先说一下,这道题选B,大家一定觉得很奇怪,我做的时候选的D,我们现在一起来看一下他为什么选B

在这里插入图片描述

四、多态的原理

4.1虚函数表

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

这里计算Base类的大小,在x86的环境下计算的结果是8,在我们之前的认知里,类对象模型中,类不应该是把成员函数放在了公共的代码段区域吗,成员函数不在类里面,不参与计算,只需要计算他的成员变量就行,按理来说的话这里的计算结果应该是4

那我们就来看一下他为什么会计算出8这个结果来
在这里插入图片描述

通过调试我们可以看到,b对象里面不仅有成员_b而且还有一个_vfptr,这个_vfptr又是干什么的呢?b对象中的这个_vfptr我们叫做虚函数表指针(v代表virtual,f代表function),在x86环境下,一个指针的大小是4个字节,加上这个int类型的_b,算上内存对齐的规则,它们刚好是8个字节

一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表

4.2多态的原理

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

多态调用:
运行时,到指向对象的虚表中找虚函数调用,指向父类调用父类的虚函数,指向子类调用子类的虚函数
在这里插入图片描述
我们转到反汇编看
在这里插入图片描述
call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来
以后到对象的中取找的。

普通调用:
编译时,调用对象是那个类型,就调用它的函数
在这里插入图片描述
普通调用在编译链接时就已经确定了call地址,首先BuyTicket虽然是虚函数,但是Mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call地址

4.2.1虚基表和虚表

虚表(虚函数表):存放的是虚函数的地址,目标是实现多态
虚基表:存的是当前位置距离虚基类部分的偏移量,目的是为了解决菱形继承数据冗余和二义性问题

注意:

问题1:虚函数存在哪?

虚函数和普通函数一样,都是存在代码段(常量区)中,不是存在虚函数表中,虚函数表中仅仅存放的是虚函数的地址

问题2:虚表存在那个区域?
我们打印出各个区域的数据地址来观察一下,虚表和那个区域的地址最接近,以此来判断虚表存在那个区域

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
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);Person p;Student s;Person* p3 = &p;Student* p4 = &s;printf("Person虚表地址:%p\n", *(int*)&p);printf("Student虚表地址:%p\n", *(int*)&s);return 0;
}

在vs x86的环境下,_vfptr存放在头四个字节的位置, 我们取出Person类对象的地址,然后将其强制转化为int类型,再解引用得到头四个字节的数据,就可以取到第一个元素的地址,也就是虚函数表指针的地址
在这里插入图片描述
根据运行得到的结果可知,虚函数表的地址和常量区最接近,所以虚函数表应该存放在常量区

五、单继承和多继承关系的虚函数表

5.1 单继承中的虚函数表

以下面的代码为例来进行分析:

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; }virtual void Func4() { cout << "Derive::Func4()" << endl; }
private:int _d = 2;
};

我们调试来观察一下虚表中有哪些变化
在这里插入图片描述
①基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法

②另外Func2继承下来后是虚函数,所以放进了虚表,Func2没有重写,所以派生类的虚表中依旧是Base::Func2,Func3也继承下来了,但是不是虚函数,所以不会放进虚表

③总结一下派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后

为什么Derive自己的虚函数Func4没有在虚函数表指针中看见?

在这里插入图片描述

通过调试观察内存窗口,我们可以看到_vfptr地址下肯定是存放了Derive::Func1Base::Func2的地址的,它下面还有一个地址00b014c9,我们不确定这个地址是不是Func4的地址,我们现在来验证一下我们的猜想

//函数指针
typedef void (*VFPTR) ();
//依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
void PrintVFT(VFPTR* vft)
{for (size_t i = 0; i < 3; i++){printf("%p->", vft[i]);VFPTR pf = vft[i];(*pf)();//pf();调用函数}
}
int main()
{Base b;Derive d;//函数指针数组VFPTR p[4];VFPTR* ptr = (VFPTR*)(*(int*)(&d));PrintVFT(ptr);return 0;
}

PrintVFT函数的思想:
在这里插入图片描述
我们可以通过这个函数来打印出_vfptr中的存放的那个地址到底是不是Func4
在这里插入图片描述
事实上,第三个位置存放的就是Func4的地址,只是vs只显示前两个,咱也不知道为什么,你也可以认为这是vs下的一个bug

总结:单继承虚函数对象模型如下
在这里插入图片描述
补充:

同类型的对象的虚表是同一个虚表
在这里插入图片描述

5.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;
};
int main()
{Derive d;return 0;
}

在监视窗口下我们来看一下对象d的结构,我们发现他有两个虚函数表,两个直接父类各自都有一个虚函数表,那Derive 类自生的虚函数func3应该存放在那个虚函数表中呢?
在这里插入图片描述
我们还是和之前一样,把虚函数打印出来看看,func3到底存在了那个虚表中,我们发现打印第一个虚函数表容易,但是第二个不好打印

下面提供两种方法打印第二个虚表的內容:
方法1:
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));

(char*)&d – 先取出d整体的地址来,然后强转成char*类型,这样+N就跳过N个字节,再+sizeof(Base1),加上Base1大小的字节,就可以跳过Base1,到Base2的位置,后面的操作就和以前的一样

缺陷:
这个方法有个缺陷,就是万一跳过Base1之后,后面紧挨着的不是Base2的地址怎么办,说不定有别的变量在Base1后面挨着,我们的方法2,就可以完美避开这个缺陷

方法2:
Base2* ptr = &d;
VFPTR* vTableb2 = (VFPTR*)(*(int*)ptr);

Base2* ptr = &d – 这一步直接就是取出d整体的地址,然后赋值给Base2的指针ptr,这里就发生了切片,ptr直接就是指向了Base2处,然后再进行之前的这个操作(VFPTR*)((int)ptr)就🆗的很
在这里插入图片描述

看打印的结果我们可以知道,Derive自己的虚函数func3放在了第一个继承的父类虚函数表中
在这里插入图片描述
所以多继承对象虚函数模型大体都是下面这样
在这里插入图片描述


没有彩蛋有🥚

来个小题收收尾

class A{
public:A(char *s) { cout<<s<<endl; }~A(){}
};
class B:virtual public A
{
public:B(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class C:virtual public A
{
public:C(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class D:public B,public C
{
public:D(char *s1,char *s2,char *s3,char *s4):B(s1,s2),C(s1,s3),A(s1){ cout<<s4<<endl;}
};
int main() {D *p=new D("class A","class B","class C","class D");delete p;return 0;
}

在这里插入图片描述

这题我们首先就可以排除B,C,对于派生类对象来说,在构造时,遵循的原则是先父后子,所以不可能最后初始化A,至于初始化class B和class C的顺序肯定是按照继承的顺序来,所以选A


多态暂且到这里,我后续会继续进行补充,谢谢大家的观看哦✋

这篇关于【C++】多态:编程中的“一人千面”艺术的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1008826

相关文章

C++中实现调试日志输出

《C++中实现调试日志输出》在C++编程中,调试日志对于定位问题和优化代码至关重要,本文将介绍几种常用的调试日志输出方法,并教你如何在日志中添加时间戳,希望对大家有所帮助... 目录1. 使用 #ifdef _DEBUG 宏2. 加入时间戳:精确到毫秒3.Windows 和 MFC 中的调试日志方法MFC

使用Python绘制蛇年春节祝福艺术图

《使用Python绘制蛇年春节祝福艺术图》:本文主要介绍如何使用Python的Matplotlib库绘制一幅富有创意的“蛇年有福”艺术图,这幅图结合了数字,蛇形,花朵等装饰,需要的可以参考下... 目录1. 绘图的基本概念2. 准备工作3. 实现代码解析3.1 设置绘图画布3.2 绘制数字“2025”3.3

深入理解C++ 空类大小

《深入理解C++空类大小》本文主要介绍了C++空类大小,规定空类大小为1字节,主要是为了保证对象的唯一性和可区分性,满足数组元素地址连续的要求,下面就来了解一下... 目录1. 保证对象的唯一性和可区分性2. 满足数组元素地址连续的要求3. 与C++的对象模型和内存管理机制相适配查看类对象内存在C++中,规

在 VSCode 中配置 C++ 开发环境的详细教程

《在VSCode中配置C++开发环境的详细教程》本文详细介绍了如何在VisualStudioCode(VSCode)中配置C++开发环境,包括安装必要的工具、配置编译器、设置调试环境等步骤,通... 目录如何在 VSCode 中配置 C++ 开发环境:详细教程1. 什么是 VSCode?2. 安装 VSCo

C#反射编程之GetConstructor()方法解读

《C#反射编程之GetConstructor()方法解读》C#中Type类的GetConstructor()方法用于获取指定类型的构造函数,该方法有多个重载版本,可以根据不同的参数获取不同特性的构造函... 目录C# GetConstructor()方法有4个重载以GetConstructor(Type[]

C++11的函数包装器std::function使用示例

《C++11的函数包装器std::function使用示例》C++11引入的std::function是最常用的函数包装器,它可以存储任何可调用对象并提供统一的调用接口,以下是关于函数包装器的详细讲解... 目录一、std::function 的基本用法1. 基本语法二、如何使用 std::function

【C++ Primer Plus习题】13.4

大家好,这里是国中之林! ❥前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。有兴趣的可以点点进去看看← 问题: 解答: main.cpp #include <iostream>#include "port.h"int main() {Port p1;Port p2("Abc", "Bcc", 30);std::cout <<

C++包装器

包装器 在 C++ 中,“包装器”通常指的是一种设计模式或编程技巧,用于封装其他代码或对象,使其更易于使用、管理或扩展。包装器的概念在编程中非常普遍,可以用于函数、类、库等多个方面。下面是几个常见的 “包装器” 类型: 1. 函数包装器 函数包装器用于封装一个或多个函数,使其接口更统一或更便于调用。例如,std::function 是一个通用的函数包装器,它可以存储任意可调用对象(函数、函数

C++11第三弹:lambda表达式 | 新的类功能 | 模板的可变参数

🌈个人主页: 南桥几晴秋 🌈C++专栏: 南桥谈C++ 🌈C语言专栏: C语言学习系列 🌈Linux学习专栏: 南桥谈Linux 🌈数据结构学习专栏: 数据结构杂谈 🌈数据库学习专栏: 南桥谈MySQL 🌈Qt学习专栏: 南桥谈Qt 🌈菜鸡代码练习: 练习随想记录 🌈git学习: 南桥谈Git 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象