【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+... 目录引言问题描述代码讲解代码解析栈的状态表示测试总结引言在编程中,括号匹配是一个常见问题,尤其是在

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

C++初始化数组的几种常见方法(简单易懂)

《C++初始化数组的几种常见方法(简单易懂)》本文介绍了C++中数组的初始化方法,包括一维数组和二维数组的初始化,以及用new动态初始化数组,在C++11及以上版本中,还提供了使用std::array... 目录1、初始化一维数组1.1、使用列表初始化(推荐方式)1.2、初始化部分列表1.3、使用std::

C++ Primer 多维数组的使用

《C++Primer多维数组的使用》本文主要介绍了多维数组在C++语言中的定义、初始化、下标引用以及使用范围for语句处理多维数组的方法,具有一定的参考价值,感兴趣的可以了解一下... 目录多维数组多维数组的初始化多维数组的下标引用使用范围for语句处理多维数组指针和多维数组多维数组严格来说,C++语言没

c++中std::placeholders的使用方法

《c++中std::placeholders的使用方法》std::placeholders是C++标准库中的一个工具,用于在函数对象绑定时创建占位符,本文就来详细的介绍一下,具有一定的参考价值,感兴... 目录1. 基本概念2. 使用场景3. 示例示例 1:部分参数绑定示例 2:参数重排序4. 注意事项5.

使用C++将处理后的信号保存为PNG和TIFF格式

《使用C++将处理后的信号保存为PNG和TIFF格式》在信号处理领域,我们常常需要将处理结果以图像的形式保存下来,方便后续分析和展示,C++提供了多种库来处理图像数据,本文将介绍如何使用stb_ima... 目录1. PNG格式保存使用stb_imagephp_write库1.1 安装和包含库1.2 代码解

C++实现封装的顺序表的操作与实践

《C++实现封装的顺序表的操作与实践》在程序设计中,顺序表是一种常见的线性数据结构,通常用于存储具有固定顺序的元素,与链表不同,顺序表中的元素是连续存储的,因此访问速度较快,但插入和删除操作的效率可能... 目录一、顺序表的基本概念二、顺序表类的设计1. 顺序表类的成员变量2. 构造函数和析构函数三、顺序表

使用C++实现单链表的操作与实践

《使用C++实现单链表的操作与实践》在程序设计中,链表是一种常见的数据结构,特别是在动态数据管理、频繁插入和删除元素的场景中,链表相比于数组,具有更高的灵活性和高效性,尤其是在需要频繁修改数据结构的应... 目录一、单链表的基本概念二、单链表类的设计1. 节点的定义2. 链表的类定义三、单链表的操作实现四、

C#多线程编程中导致死锁的常见陷阱和避免方法

《C#多线程编程中导致死锁的常见陷阱和避免方法》在C#多线程编程中,死锁(Deadlock)是一种常见的、令人头疼的错误,死锁通常发生在多个线程试图获取多个资源的锁时,导致相互等待对方释放资源,最终形... 目录引言1. 什么是死锁?死锁的典型条件:2. 导致死锁的常见原因2.1 锁的顺序问题错误示例:不同

使用C/C++调用libcurl调试消息的方式

《使用C/C++调用libcurl调试消息的方式》在使用C/C++调用libcurl进行HTTP请求时,有时我们需要查看请求的/应答消息的内容(包括请求头和请求体)以方便调试,libcurl提供了多种... 目录1. libcurl 调试工具简介2. 输出请求消息使用 CURLOPT_VERBOSE使用 C