C++多态深度剖析

news2024/9/28 9:23:17

文章目录

  • 1. 前言
  • 2. 多态的概念及定义
    • 2.1 概念
    • 2.2 多态的构成条件
    • 2.3 虚函数
    • 2.4 虚函数的重写
    • 2.5 override 和 final
    • 2.5 重载、覆盖(重写)、隐藏(重定义)的对比
  • 3. 抽象类
    • 3.1 概念
    • 3.2 接口继承和实现继承
  • 4. 多态的原理
    • 4.1 虚函数表
    • 4.2 多态的原理
    • 4.3 动态绑定与静态绑定
  • 5. 单继承和多继承关系的虚函数表
    • 5.1 单继承中的虚函数表
    • 5.2 多继承中的虚函数表
    • 5.3 菱形继承及菱形虚拟继承


1. 前言

在继承的基础上,我们再来对多态进行学习。继承和多态往往同时出现,继承是多态的前提。

本篇文章重点讲解了多态的概念及定义,抽象类,多态的原理,单继承和多继承关系的虚函数表。

注意:

本篇文章的测试代码都是在32位环境下运行的。

2. 多态的概念及定义

2.1 概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

举个例子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。

2.2 多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。

那么在继承中要构成多态要有两个条件:

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

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

2.3 虚函数

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

例如:

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

2.4 虚函数的重写

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

例如:

class Person
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因
	为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议
	这样使用*/
	/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

输出结果:

在这里插入图片描述

虚函数重写的两个例外:

  1. 协变(基类与派生类虚函数返回值类型不同)

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

    例如:

    class A {};
    class B : public A {};
    class Person
    {
    public:
    	virtual A* f() { return new A; }
    };
    class Student : public Person
    {
    public:
    	virtual B* f() { return new B; }
    };
    
  2. 析构函数的重写(基类与派生类析构函数的名字不同)

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

    例如:

    class Person
    {
    public:
    	virtual ~Person() { cout << "~Person()" << endl; }
    };
    class Student : public Person
    {
    public:
    	virtual ~Student() { cout << "~Student()" << endl; }
    };
    // 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,
    // 才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
    int main()
    {
    	Person* p1 = new Person;
    	Person* p2 = new Student;
    	delete p1;
    	delete p2;
    	return 0;
    }
    

    输出结果:

    在这里插入图片描述

2.5 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

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

例如:

class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; } // error
};
  1. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

例如:

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

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

在这里插入图片描述

3. 抽象类

3.1 概念

在虚函数的后面写上=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;
	}
};
void Test()
{
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
}
int main()
{
	Test();
	return 0;
}

输出结果:

在这里插入图片描述

3.2 接口继承和实现继承

  • 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。

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

4. 多态的原理

4.1 虚函数表

提问:以下代码sizeof(Base)是多少?(在32位下)

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

在这里插入图片描述

通过观察测试我们发现b对象是8bytes。根据上图,b对象除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,。那么派生类中这个表放了些什么呢?我们接着往下分析。

针对上面的代码我们做出以下改造:

  1. 我们增加一个派生类Derive去继承Base
  2. Derive中重写Func1
  3. Base再增加一个虚函数Func2和一个普通函数Func3
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;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

在这里插入图片描述

通过观察和测试,我们发现了以下几点问题:

  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成。一部分是父类继承下来的成员,另一部分是自己的成员。

  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

  3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。

  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr

总结:

派生类的虚表生成:

  1. 先将基类中的虚表内容拷贝一份到派生类虚表中。

  2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。

  3. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的?

错误示范:答:虚函数存在虚表中,虚表存在对象中。

正确理解:虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。在VS下,虚表是存在代码段的

4.2 多态的原理

上面分析了这个半天了那么多态的原理到底是什么?请看下面代码,这里Func函数传Person调用的Person::BuyTicket,传Student调用的是Student::BuyTicket

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;
}
  1. 观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicketmike的虚表中找到虚函数是Person::BuyTicket

  2. 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicketjohson的虚表中找到虚函数是Student::BuyTicket

在这里插入图片描述

  1. 这样就实现了不同对象去完成同一行为时,展现出不同的形态。

  2. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。

  3. 再通过下面的汇编代码分析,可以看出,满足多态的函数调用,不是在编译时确定的,是运行起来以后到对象的虚表中取找的不满足多态的函数调用是编译时确认好的

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
001940EC cmp esi, esp
}
int main()
{
...
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址,
// 是在编译时已经从符号表确认了函数的地址,直接call 地址
	mike.BuyTicket();
00195182 lea ecx, [mike]
00195185 call Person::BuyTicket(01914F6h)
...
}

4.3 动态绑定与静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。比如:函数重载。

  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

5. 单继承和多继承关系的虚函数表

5.1 单继承中的虚函数表

以此代码为例:

class Base
{
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};

在这里插入图片描述

观察上图中的监视窗口中我们发现看不见func3func4。这里是编译器的监视窗口故意隐藏了这两个函数。下面我们使用代码打印出虚表中的函数。

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()
{
	Base b;
	Derive d;
	// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,
	// 这个数组最后面放了一个nullptr
	// 1.先取b的地址,强转成一个int*的指针
	// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
	// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
	// 4.虚表指针传递给PrintVTable进行打印虚表
	// 5.虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
	VFPTR * vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);
	return 0;
}

在这里插入图片描述

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;
};
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;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

在这里插入图片描述

5.3 菱形继承及菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇,可以去看下面的两篇链接文章:

  1. C++ 虚函数表解析

  2. C++ 对象的内存布局

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

《30天自制操作系统》 第一周(D1-D7) 笔记

前言&#xff1a;这是我2023年5月份做的一个小项目&#xff0c;最终是完成了整个OS。笔记的话&#xff0c;只记录了第一周。想完善&#xff0c;却扔在草稿箱里许久。最终决定&#xff0c;还是发出来存个档吧。 一、汇编语言 基础指令 MOV: move赋值&#xff0c;数据传送指令…

UE4 CustomDepthMobile流程小记

原生UE opaque材质中获取CustomDepth/CustomStencil会报错 在其Compile中调用的函数中没有看到报错逻辑 材质节点的逻辑都没有什么问题&#xff0c;所以看一下报错 在HLSLMaterialTranslator::Translate中 修改之后 mobile流程的不透明材质可以直接获取SceneTexture::customd…

Pyroch中transforms 图像增强发方法的应用

1 应用场景 在我们训练模型的时候&#xff0c;有的时候数据不够&#xff0c;就需要通过水平翻转、垂直翻转、镜像、旋转、改变亮度、标准化等方式增加图像的多样性&#xff0c;此时可以调用Pytorch 中的Transforms完成这些操作 2 导入相应的库 from torchvision import tran…

蓝桥杯-sort排序(上)

sort排序 &#x1f388;1.算法说明&#x1f388;2.例题&#x1f52d;2.1例题一&#x1f52d;2.2例题二&#x1f52d;2.3例题三&#x1f52d;2.4例题四&#x1f52d;2.5例题五&#x1f52d;2.6例题六 &#x1f388;1.算法说明 &#x1f50e;对于一个数组&#xff0c;通过对数组中…

【html+css+js】实现图片轮播效果

简单实现 <html><head><style type"text/css">*{background-color:green;}#lunbo{ width:600px; height:600px;position:relative;left:100px;top:50px;} //定义轮播图片所显示的位置ul{width:100%; height:100%; position:relative;left:0px;top…

基于 SpringBoot+Vue 的读书笔记共享平台的研究与实现,附源码,文档

博主介绍&#xff1a;✌程序员徐师兄、7年大厂程序员经历。全网粉丝30W、csdn博客专家、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取源码联系&#x1f345; &#x1f447;&#x1f3fb; 精彩专栏推荐订阅&#x1f447;…

K8s 安装部署-Master和Minion(Node)文档

K8s 安装部署-Master和Minion(Node)文档 操作系统版本&#xff1a;CentOS 7.4 Master &#xff1a;172.20.26.167 Minion-1&#xff1a;172.20.26.198 Minion-2&#xff1a;172.20.26.210&#xff08;后增加节点&#xff09; ETCD&#xff1a;172.20.27.218 先安装部署ETC…

如何用GPT快速写论文?

详情点击链接&#xff1a;如何用GPT快速写论文&#xff1f; 第一&#xff1a;2024年AI领域最新技术 1.OpenAI新模型-GPT-5 2.谷歌新模型-Gemini Ultra 3.Meta新模型-LLama3 4.科大讯飞-星火认知 5.百度-文心一言 6.MoonshotAI-Kimi 7.智谱AI-GLM-4 第二&#xff1a;Op…

CVE-2024-23897 Jenkins 任意文件读取漏洞

项目介绍 Jenkins是一个开源软件项目&#xff0c;是基于Java开发的一种持续集成工具&#xff0c;用于监控持续重复的工作&#xff0c;旨在提供一个开放易用的软件平台&#xff0c;使软件项目可以进行持续集成。Jenkins是开源CI&CD软件领导者&#xff0c; 提供超过1000个插…

以太网的 MAC 层

目录 1. MAC 层的硬件地址 48 位的 MAC 地址 2. MAC 帧的格式 以太网 V2 的 MAC 帧格式 无效的 MAC 帧 IEEE 802.3 MAC 与以太网 V2 MAC 帧格式的区别 1. MAC 层的硬件地址 硬件地址又称为物理地址&#xff0c;或 MAC 地址。 IEEE 802 标准为局域网规定了一种 48 位…

多维数组的使用

1. 数组的概念 数组(Array)&#xff1a;是多个相同类型数据按一定顺序排列的集合&#xff0c;并使用一个名字命名&#xff0c;并通过编号的方式对这些数据进行统一管理。 数组中的概念&#xff1a; – 数组名 – 下标&#xff08;或索引&#xff09; – 元素 – 数组的长度 …

flink-java使用介绍,flink,java,DataStream API,DataSet API,ETL,设置 jobname

1、环境准备 文档&#xff1a;https://nightlies.apache.org/flink/flink-docs-release-1.17/zh/ 仓库&#xff1a;https://github.com/apache/flink 下载&#xff1a;https://flink.apache.org/zh/downloads/ 下载指定版本&#xff1a;https://archive.apache.org/dist/flink…

【Leetcode】2861. 最大合金数

文章目录 题目思路代码结果 题目 题目链接 假设你是一家合金制造公司的老板&#xff0c;你的公司使用多种金属来制造合金。现在共有 n 种不同类型的金属可以使用&#xff0c;并且你可以使用 k 台机器来制造合金。每台机器都需要特定数量的每种金属来创建合金。 对于第 i 台…

Redis(八)哨兵机制(sentinel)

文章目录 哨兵机制案例认识异常 哨兵运行流程及选举原理主观下线(Subjectively Down)ODown客观下线(Objectively Down)选举出领导者哨兵选出新master过程 哨兵使用建议 哨兵机制 吹哨人巡查监控后台master主机是否故障&#xff0c;如果故障了根据投票数自动将某一个从库转换为新…

深入理解STM32中断处理机制

深入理解STM32中断及其使用方法&#xff08;基于HAL库&#xff09; STM32微控制器作为一款强大的嵌入式系统芯片&#xff0c;在各种应用中都需要使用中断来实现实时响应和处理各种事件。本文将深入讨论STM32中断的概念、HAL库的中断处理机制以及如何在STM32CubeMX中配置和使用…

AI编译器的后端优化策略

背景 工作领域是AI芯片工具链相关&#xff0c;很多相关知识的概念都是跟着项目成长建立起来&#xff0c;但是比较整个技术体系在脑海中都不太系统&#xff0c;比如项目参与中涉及到了很多AI编译器开发相关内容&#xff0c;东西比较零碎&#xff0c;工作中也没有太多时间去做复盘…

特征点匹配 harris

算法的核心是利用局部窗口在图像上进行移动&#xff0c;判断灰度是否发生较大的变化。如果窗口内的灰度值&#xff08;在梯度图上&#xff09;都有较大的变化&#xff0c;那么这个窗口所在区域就存在角点。 这样就可以将 Harris 角点检测算法分为以下三步&#xff1a; 当窗口…

java/node代码 破解“滑动验证码”的移动距离

1.直接上代码结论 import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL;p…

【C++中的STL】函数对象

函数对象 函数对象概念谓词概念 内建函数对象算术仿函数关系仿函数逻辑仿函数&#xff08;基本用不到&#xff09; 函数对象概念 重载函数调用操作符的类&#xff0c;其对象常称为函数对象&#xff0c;函数对象使用重载的()时。行为类似函数调用&#xff0c;也叫仿函数。 函数…

2024年2月最新python批量下载快手个人主页短视频代码(带翻页)

python批量下载快手个人主页短视频代码&#xff0c;2024年1月27日测试过了&#xff0c;如果日后更新了&#xff0c;私聊我 快手个人主页分视频和直播回放&#xff0c;直播回放的地址是m3u8&#xff0c;视频的地址是mp4 m3u8我采用的是hm3u8dl_cli这个库就行下载&#xff0c;带…