C/C++(六)多态

本文将介绍C++的另一个基于继承的重要且复杂的机制,多态

一、多态的概念

多态,就是多种形态,通俗来说就是不同的对象去完成某个行为,会产生不同的状态

多态严格意义上分为静态多态动态多态我们平常说的多态一般指动态多态。后文介绍的多态也是动态多态,只在本部分介绍一下静态多态)

1、静态多态

静态多态又称作静态绑定(早绑定、前期绑定),即在函数编译期间就决定了程序的行为(即函数名修饰规则,具体C/C++(二)中有详细描述)。

平常最经常用的静态多态就是函数重载

2、动态多态

动态多态又称作后期绑定,在程序运行期间再根据具体拿到的类型来调用具体的函数,确认程序的具体行为。

我们平常说的多态一般指动态多态,静态动态一般就说函数重载。

重载(静态多态)、虚函数重写(动态多态)、隐藏的区别

二、多态(动态多态) 

从技术方面来说,多态就是不同继承关系下的类对象,去调用同一函数(调用的函数必须是虚函数,后文会介绍),会产生不同行为

1、多态的构成条件

1、调用的函数必须是虚函数,且派生类必须为基类的虚函数进行重写。

2、必须用父类的指针 / 引用来调用虚函数。

(为什么必须传父类的指针 / 引用?这里初步解释,后面会在原理部分详细解释——因为父子类的赋值兼容原则,子类可以切片赋值给父类,父类却不能赋值给子类,因为可能会缺成员)

(那又为什么必须传指针 / 引用?因为传对象的话,子类只会把父类的那一部分成员拷贝过去,但是不会拷贝虚函数表指针,就不能成功调用对应的虚函数了)

2、虚函数

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

class Person 
{
public:
 // 虚函数
 virtual void BuyTicket() { cout << "买票-全价" << endl;}
};

2.1  虚函数的重写(多态的条件之一)

如果派生类中存在与父类完全相同(函数名、函数返回值、函数参数都完全相同)虚函数,就称作派生类的虚函数重写了父类的虚函数

#include <iostream>
using namespace std;

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "全价购票" << endl;
	}
};

class Student :public Person
{
public:
	/*
		子类重写父类虚函数时,如果不加 virtual 关键字,虽然也可以构成重写(子类继承下来父类的虚函数,仍旧保持虚函数属性)
		但是这种写法不规范,可读性较差,建议不要这么做
	*/
	virtual void BuyTicket()
	{
		cout << "半价购票" << endl;
	}
};

void Test(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person p;
	Student s;
	Test(p);
	Test(s);
	return 0;
}
运行结果可以发现,传父子类,分别调用父子类的虚函数

2.2  多态的两个特殊情况

2.1.1  协变(基类与派生类的虚函数返回值类型不同的时候)

当派生类重写父类虚函数的时候,基类与派生类的虚函数的返回值类型可以不同,但是必须是父类 / 子类的指针或引用

当派生类虚函数返回值是父类 / 子类的指针或引用时,称作协变

2.2.2  析构函数的重写

如果基类的析构函数也是虚函数,这个时候只要派生类定义了析构函数,不论是否加了 virtual 关键字,都视作对基类的析构函数构成重写

(虽然基类和派生类的析构函数名字不同,看似违背了虚函数的重写原则,实际上编译器会对析构函数的名称做特殊处理,在编译后,所有析构函数的名称都会统一处理成 destructor)

#include <iostream>
using namespace std;

class Person 
{
public:
	virtual ~Person() { cout << "~Person()" << endl; }
};

class Student : public Person 
{
public:
	virtual~Student() { cout << "~Student()" << endl; }
};

// 只有派生类Student的析构函数也定义了析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
	delete p1;
	delete p2;
	return 0;
}
父类调用析构函数,子类调用析构函数,先调用里面的子类析构,再调用父类析构

3、C++11检测虚函数是否重写的两个关键字

从上文的介绍可以看出,C++对虚函数的重写要求比较严格。在有些情况下(比如函数名、返回值字母写反写错),可能会无法构成重写,导致无法构成多态。

但是这种错误在编译期间是不会报出的,只有在程序运行时才会发现,与预期结果不符,这个时候才来debug,得不偿失。

因此C++11标准提供了两个帮助用户检测是否完成重写的关键字:final  和  override

3.1  final

final 修饰某个虚函数,则这个虚函数不能再被重写

3.2  override

override 修饰派生类虚函数,检查派生类的虚函数是否基类的某个虚函数的重写,如果不是(比如拼写错了),编译报错。

4、纯虚函数与抽象类

在虚函数的后面加上 =0 ,这样的虚函数称作纯虚函数

包含纯虚函数的类叫做抽象类(又叫接口类,在某类不代表具体实体的时候可以使用;另一个意义是说明多态想在其多个子类中实现)抽象类不能实例化出对象

继承抽象类的派生类也不能实例化出对象,只有当这个派生类对纯虚函数进行重写,这个派生类才能实例化出对象。

因此纯虚函数在某种程度上间接强制了派生类的重写,更体现了接口继承思想。

(接口继承与实现继承:

普通函数的继承是一种实现继承,继承的是函数的实现,目的是使用这个函数

虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写达成多态。

三、多态的实现原理(重点) 

1、代码引入

#include <iostream>
using namespace std;

class Test
{
public:
	virtual void test()
	{
		cout << _num << endl;
	}
private:
	int _num = 1;
};

int main()
{
    Test t;
	printf("%d", sizeof(Test));
}

让我们猜猜,sizeof(Test) 应该是多少?

很多人可能会说,函数储存在代码段里,不算在类大小里面,那就应该是4字节(32位系统) / 8字节(64位系统)

但实际上:

x86环境下

x64环境下

这是为什么?

通过内存窗口的观察我们可以看见,Test对象里面除了储存了_num 成员变量,还储存了一个叫做_vfptr的指针变量,而一切指针变量大小在32位系统下都是4字节,在64位系统下都是8字节。

这个_vfptr是什么?这个指针我们叫做虚函数表指针,指向虚函数表。(v代表virtual,f 代表function)

 2、虚函数表

虚函数表的本质,是储存着一个类里面的所有虚函数地址的一个指针数组一般情况下这个数组最后会放一个nullptr作为虚函数表的终止标记。)(注意:不是储存着虚函数,是储存着虚函数的地址,虚函数还是储存在代码段里的)

我们给出一个多态的代码:

#include <iostream>
using namespace std;


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;
};

// 派生类Derive继承Base并重写Func1
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、派生类对象 d 由两部分构成,继承自父类的成员,和自己的成员

2、派生类和父类都有一个虚函数表指针,指向各自的虚函数表,虚函数表里面储存着虚函数的地址。

3、派生类的虚函数表和父类的虚函数表不一样,由于Func1完成了重写,所以d的虚表

中存的是重写的Derive::Func1;派生类完成重写了的虚函数覆盖了原有的父类虚函数。

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

4、派生类其实把父类的三个函数都继承了下来,但是由于Func3不是虚函数,所以并未放到虚函数表中。

派生类虚函数表的生成流程:

1、先把基类的虚函数表拷贝到自己的虚函数表中

2、如果派生类重写了某个虚函数,在虚函数表中用这个虚函数地址覆盖原父类的虚函数地址3、派生类如果自己增加了虚函数,按照在派生类中的声明次序依次放到派生类虚函数表的后3、多态的原理

3、多态的实现原理

还是直接上代码: 

#include <iostream>
using namespace std;

class Person 
{
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票-全价" << endl; 
	}
};

class Student : public Person 
{
public:
	virtual void BuyTicket() 
	{
		cout << "买票-半价" << endl; 
	}
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person Mike;
	Func(Mike);
	Student Johnson;
	Func(Johnson);
	return 0;
}
多态实现概念图
多态实现代码图

观察多态实现代码图:

观察红色箭头可以看到,p在指向mike对象时,p->BuyTicket从mike的虚表中找到的虚

函数是Person::BuyTicket。

观察蓝色箭头可以看到,p在指向johnson对象时,p->BuyTicket在johson的虚表中

找到的虚函数是Student::BuyTicket。

这样就实现出了不同类的对象去调用同一函数时,展现出不同的形态

再看一下汇编代码:

// 与多态无关的汇编代码都已去除
void Func(Person* p)
{
...
 p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE  mov         eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1  mov         edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE  mov         eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来
以后到对象的中取找的。
001940EA  call        eax  
00头1940EC  cmp         esi,esp  
}
int main()
{
... 
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调
用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
 mike.BuyTicket();
00195182  lea         ecx,[mike]
00195185  call        Person::BuyTicket (01914F6h)  
... 
}

就可以明白,满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的,因此叫做动态多态。

4、多态是如何实现的?(一句话总结)

首先,多态是一种基于继承和虚函数实现的机制,派生类必须实现对虚函数的重写,用来调用虚函数的函数必须传父类的指针或引用;然后,基类和派生类各有一张虚函数表,通过传参的不同(父类直接传,子类切片),对象内部的虚函数表指针会去各自的虚函数表里面寻找对应的虚函数地址,从而实现调用同名函数时产生不同的行为,达到多态的效果。

5、有关多态的一些小问题:

如果子类不重写虚函数,父子类的虚函数表一样吗?

储存的虚函数的地址是一样的,但是虚函数表毕竟是两张表,储存虚函数表的地方不一样,是分开存储的!

如果有许多同类对象,它们的虚函数表一样吗?

一样!同类对象共用一张虚函数表!

也就是说,虚函数表本质其实是个静态常量,被所有同类对象共享!

四、多继承关系下的虚函数表

之前所说的是单继承关系下的虚函数表,那么多继承关系下的虚函数表是什么样的

(PS:菱形继承和菱形虚拟继承太过复杂,这里只介绍普通多继承)

继续上代码:

#include <iostream>
using namespace std;

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;
};

// 先给虚函数函数指针取个别名VFPTR
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;

    /*
        思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
        1、先取b的地址,强转成一个int*的指针
        2、再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
        3、再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
        4、虚表指针传递给PrintVTable进行打印虚表
        5、需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再
           编译就好了。
    */

    VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
    PrintVTable(vTableb1);

    VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
    PrintVTable(vTableb2);

    return 0;
}

可以发现,派生类继承了几个包含虚函数的父类,就有几个虚函数表。(派生类自己独有的虚函数,会存放在第一个继承的基类的虚表里,但是由于编译器的BUG,并没有展示在内存窗口里面,可以通过下图观察到)

派生类自己独有的虚函数,会存放在第一个继承的基类的虚表里

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/900308.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【博客节选】Unity角色异常抖动问题排查

本文截取自本人文章 &#xff1a;【Unity实战笔记】第二一 基于状态模式的角色控制——以UnityChan为例 发现出现角色抖动问题 尝试解决方法&#xff1a; 跳跃的loop time不要勾选&#xff1b; 相机aim添加垂直阻尼 还是不行&#xff0c;仔细查看是位移时震颤。 UnityCha…

两个mp3音频怎么合成一个?音频合成的多个好用方法教程

两个mp3音频怎么合成一个&#xff1f;在数字音频时代&#xff0c;随着各类音频内容的日益丰富&#xff0c;合并音频文件的需求也愈发突出。无论是为了制作连贯的音乐集&#xff0c;还是为了解决某些场合下音频播放的便利性&#xff0c;将两个或多个MP3音频合并在一起&#xff0…

【C++面试刷题】快排(quick_sort)和堆排(priority_queue)的细节问题

一、快排的快速选择算法两种思路&#xff08;面试会考&#xff09;O(N) 快排的三数取中思路&#xff1a; 重要的是将它三个数进行排序最左为最小&#xff0c;中间为次小&#xff0c;最右为最大的数。&#xff08;错误原因&#xff1a;我刚开始没有将这三个数进行排序&#xff…

Notepad++通过自定义语言实现日志按照不同级别高亮

借助Notepad的自定义语言可以实现日志的按照不同级别的高亮&#xff1b; 参考&#xff1a; https://blog.csdn.net/commshare/article/details/131208656 在此基础上做了一点修改效果如下&#xff1a; xml文件&#xff1a; <NotepadPlus><UserLang name"Ansibl…

洞察数据之美:用可视化探索销售与温度的关系

目录 数据可视化1.气温数据可视化图片展示将最高和最低气温合并绘制折线图&#xff1a;将最高和最低气温合并绘制散点图&#xff1a; 2.销售数据可视化几种常见的销售数据可视化方法及其适用场景&#xff1a;图片展示通过热力图和堆叠柱状图的直观展示&#xff0c;可以得出以下…

CAS简介

#1024程序员节&#xff5c;征文# CAS是什么&#xff1f; CAS&#xff08;Compare And Swap&#xff09;&#xff0c;即比较与交换&#xff0c;是一种乐观锁的实现方式&#xff0c;用于在不使用锁的情况下实现多线程之间的变量同步。 CAS操作包含三个操作数&#xff1a;内存位…

【Nginx】win10 安装Nginx

1.下载 nginx: download 2.安装 解压即可 3.启动 可以自己修改端口&#xff0c;conf/nginx.conf 确保端口不被占用cmd启动&#xff08;不要双击nginx.exe启动&#xff0c;至于原因我粘贴一下&#xff09; start nginx.exe 可以看到是后台运行&#xff0c;还不错 访问&…

易基因:Nat Commun:ATAC-seq等揭示恒河猴大脑高分辨率解剖区域的转录组和开放染色质图谱

大家好&#xff0c;这里是专注表观组学十余年&#xff0c;领跑多组学科研服务的易基因。 恒河猴是神经科学研究中常用的模型动物&#xff0c;其大脑结构和功能与人类大脑相似。大脑中复杂的遗传网络是灵长类动物行为、认知和情感的基础&#xff0c;一直是神经科学的核心。大脑…

全面了解MindSporeLite轻量化推理工具(概念版)

一、参考资料 技术干货&#xff5c;极速、极智、极简的昇思MindSpore Lite&#xff1a;助力华为Watch更加智能 二、相关概念 MCU MCU的全称是Microcontroller Unit&#xff0c;中文可以称为微控制器或者单片机。MCU既可用于汽车电子、工业控制等领域&#xff0c;也可应用于…

Docker入门之构建

Docker构建概述 Docker Build 实现了客户端-服务器架构&#xff0c;其中&#xff1a; 客户端&#xff1a;Buildx 是用于运行和管理构建的客户端和用户界面。服务器&#xff1a;BuildKit 是处理构建执行的服务器或构建器。 当您调用构建时&#xff0c;Buildx 客户端会向 Bui…

【纯血鸿蒙】安装hdc工具

这里我先写Mac版的,Windows的在下面 首先要知道你的SDK安装在哪里了,不知道的话,可以打开DevEco Studio,打开设置页面里的HarmonyOS SDK,这个我们之前配置环境变量的时候用过。 其实主要是用到这里toolchains下的hdc命令。 所以我们需要配置环境变量。 1、打开Mac下的…

RabbitMQ是一个开源的消息代理和队列服务器

RabbitMQ是一个开源的消息代理和队列服务器&#xff0c;它基于AMQP&#xff08;Advanced Message Queuing Protocol&#xff0c;高级消息队列协议&#xff09;协议实现&#xff0c;同时也支持其他消息协议如STOMP、MQTT等。作为一个可靠的消息传递服务&#xff0c;RabbitMQ在分…

Nginx+Tomcat 动静分离

1. NginxTomcat 环境 Nginx 处理静态资源的优势同样可以应用在 Tomcat 环境中 。从实现方法上来说&#xff0c;NginxTomcat 环境的搭建思路与前面完成的 NginxApache 环境是完全相同的&#xff0c;只需要将 Nginx 与 Tomcat 的站点文档目录配置到同一目录下&#xff0c;利用 N…

Python 打包成 EXE 的方法详解

#1024程序员节&#xff5c;征文# 日常开发中&#xff0c;python由于其便捷性成为了很多人的首选语言&#xff0c;但是python的环境配置也是有点麻烦的&#xff0c;那么我们如何让其变得更加友好呢&#xff1f;没错&#xff0c;就是打包成exe可执行文件。 一、PyInstaller 简介…

在使用 RabbitMQ 作为消息代理时,多个 Celery 实例(或应用)可以共享同一个 RabbitMQ 实例

在使用 RabbitMQ 作为消息代理时&#xff0c;多个 Celery 实例&#xff08;或应用&#xff09;可以共享同一个 RabbitMQ 实例。这样做可以简化基础设施管理&#xff0c;同时允许不同的 Celery 应用之间进行消息传递和协作。下面是如何配置多个 Celery 实例以使用同一个 RabbitM…

鸿蒙到底是不是纯血?到底能不能走向世界?

关注卢松松&#xff0c;会经常给你分享一些我的经验和观点。 2016年5月鸿蒙系统开始立项。 2018年美国开始经济战争&#xff0c;其中一项就是制裁华为&#xff0c;不让华为用安卓。 2019年8月9日华为正式发布鸿蒙系统。问题就出在这里&#xff0c;大家可以仔细看。 安卓一…

Java之bean操作【复制】

#1024程序员节 | 征文# 文章目录 一、深拷贝二、不为空拷贝三、List转换 1024 祝各位大佬 节日快乐&#xff01; 在Java项目开发中&#xff0c;对Java对象操作如bean复制等&#xff0c;可使用 一、深拷贝 private static final Map<String, BeanCopier> BEAN_COPIER_M…

【忍无可忍,无需再忍】永久解决xshell or xftp 更新问题

1 背景介绍 提示“要继续使用此程序,您必须应用最新的更新或使用新版本”&#xff0c;笔者版本是xshell 6 距离一段时间不使用&#xff0c;或者遇到更新场景&#xff0c;总是会弹出这个框&#xff0c;实在是忍无可忍&#xff0c;无需再忍。 2 思路介绍 笔者上一篇关于如何解…

No.21 笔记 | WEB安全 - 任意文件绕过详解 part 3

&#xff08;一&#xff09;空格绕过 原理 Windows系统将文件名中的空格视为空&#xff0c;但程序检测代码无法自动删除空格&#xff0c;使攻击者可借此绕过黑名单限制。基于黑名单验证的代码分析 代码未对上传文件的文件名进行去空格处理&#xff0c;存在安全隐患。相关代码逻…

24.redis高性能

Redis的单线程和高性能 Redis是单线程吗&#xff1f; Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的&#xff0c;这也是 Redis 对外 提供键值存储服务的主要流程。 Redis 的多线程部分&#xff0c;比如持久化、异步删除、集群数据同步等&#xff…