【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

相关文章

Windows下C++使用SQLitede的操作过程

《Windows下C++使用SQLitede的操作过程》本文介绍了Windows下C++使用SQLite的安装配置、CppSQLite库封装优势、核心功能(如数据库连接、事务管理)、跨平台支持及性能优... 目录Windows下C++使用SQLite1、安装2、代码示例CppSQLite:C++轻松操作SQ

C++中RAII资源获取即初始化

《C++中RAII资源获取即初始化》RAII通过构造/析构自动管理资源生命周期,确保安全释放,本文就来介绍一下C++中的RAII技术及其应用,具有一定的参考价值,感兴趣的可以了解一下... 目录一、核心原理与机制二、标准库中的RAII实现三、自定义RAII类设计原则四、常见应用场景1. 内存管理2. 文件操

C++中零拷贝的多种实现方式

《C++中零拷贝的多种实现方式》本文主要介绍了C++中零拷贝的实现示例,旨在在减少数据在内存中的不必要复制,从而提高程序性能、降低内存使用并减少CPU消耗,零拷贝技术通过多种方式实现,下面就来了解一下... 目录一、C++中零拷贝技术的核心概念二、std::string_view 简介三、std::stri

C++高效内存池实现减少动态分配开销的解决方案

《C++高效内存池实现减少动态分配开销的解决方案》C++动态内存分配存在系统调用开销、碎片化和锁竞争等性能问题,内存池通过预分配、分块管理和缓存复用解决这些问题,下面就来了解一下... 目录一、C++内存分配的性能挑战二、内存池技术的核心原理三、主流内存池实现:TCMalloc与Jemalloc1. TCM

C++ 函数 strftime 和时间格式示例详解

《C++函数strftime和时间格式示例详解》strftime是C/C++标准库中用于格式化日期和时间的函数,定义在ctime头文件中,它将tm结构体中的时间信息转换为指定格式的字符串,是处理... 目录C++ 函数 strftipythonme 详解一、函数原型二、功能描述三、格式字符串说明四、返回值五

C++作用域和标识符查找规则详解

《C++作用域和标识符查找规则详解》在C++中,作用域(Scope)和标识符查找(IdentifierLookup)是理解代码行为的重要概念,本文将详细介绍这些规则,并通过实例来说明它们的工作原理,需... 目录作用域标识符查找规则1. 普通查找(Ordinary Lookup)2. 限定查找(Qualif

C/C++ chrono简单使用场景示例详解

《C/C++chrono简单使用场景示例详解》:本文主要介绍C/C++chrono简单使用场景示例详解,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友... 目录chrono使用场景举例1 输出格式化字符串chrono使用场景China编程举例1 输出格式化字符串示

C++/类与对象/默认成员函数@构造函数的用法

《C++/类与对象/默认成员函数@构造函数的用法》:本文主要介绍C++/类与对象/默认成员函数@构造函数的用法,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录名词概念默认成员函数构造函数概念函数特征显示构造函数隐式构造函数总结名词概念默认构造函数:不用传参就可以

C++类和对象之默认成员函数的使用解读

《C++类和对象之默认成员函数的使用解读》:本文主要介绍C++类和对象之默认成员函数的使用方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、默认成员函数有哪些二、各默认成员函数详解默认构造函数析构函数拷贝构造函数拷贝赋值运算符三、默认成员函数的注意事项总结一

C/C++中OpenCV 矩阵运算的实现

《C/C++中OpenCV矩阵运算的实现》本文主要介绍了C/C++中OpenCV矩阵运算的实现,包括基本算术运算(标量与矩阵)、矩阵乘法、转置、逆矩阵、行列式、迹、范数等操作,感兴趣的可以了解一下... 目录矩阵的创建与初始化创建矩阵访问矩阵元素基本的算术运算 ➕➖✖️➗矩阵与标量运算矩阵与矩阵运算 (逐元