【C++进阶】多态详解(上)

news2024/11/18 18:29:40

在这里插入图片描述

文章目录

  • 一、多态的概念
  • 二、多态的定义及实现
    • 1.多态的构成条件
    • 2.虚函数
    • 3.虚函数的重写
      • (1)虚函数重写概念
      • (2)虚函数重写的两个例外:
      • (3)析构函数是否要定义为虚函数
      • (4)C++11 override 和 final
  • 三、抽象类
    • 1.概念
    • 2.接口继承和实现继承
  • 四、多态的原理
    • 1.虚函数表
    • 2.多态的原理探究
    • 3.多态原理总结

一、多态的概念

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

例如在生活中,我们帮别人的拼多多砍一刀时,如果是新用户就会砍很多,如果是老用户就只能砍一点,对于不同的对象有不同的状态。

二、多态的定义及实现

1.多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。
要实现多态,必须满足以下的几个条件:

1.子类函数对父类函数实现重写,也就是覆盖。
2.必须通过父类的指针或者引用去调用虚函数。

2.虚函数

我们在前边提到过virtual关键字,加在类的后边,使公有的类成为虚基类,来解决菱形继承产生的问题。而在这里virtual关键字来修饰函数,可以让函数成为虚函数,下边来看虚函数定义:

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

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

上边的函数就是一个虚函数。

3.虚函数的重写

(1)虚函数重写概念

在上边提到,实现多态的一个条件就是子类的虚函数对父类的虚函数进行重写,那么什么是重写呢?重写又需要哪些条件,这是我们关心的问题。

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

下边的程序中实现了两个类,父类是person类,买票全价,而子类是student类买票半价,他们中的虚函数的函数名,参数,返回值都相同,符合重写的要求,所以子类函数对父类函数进行了重写。

class Person
{
public:
	virtual void buyTicket()
	{
		cout << "person::买票全价" << endl;
	}
};
class Student : public Person
{
	virtual void buyTicket()
	{
		cout << "student::买票半价" << endl;
	}
};
void func(Person& p)
{
	p.buyTicket();
}

(2)虚函数重写的两个例外:

  1. 协变(基类与派生类虚函数返回值类型不同)
    派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class Person
{
public:
	virtual Person* buyTicket()
	{
		cout << "person::买票全价" << endl;
		return this;
	}
};
class Student : public Person
{
	virtual Student* buyTicket()
	{
		cout << "student::买票半价" << endl;
		return this;
	}
};
void func(Person& p)
{
	p.buyTicket();
}
int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

我们将父子类中的虚函数的返回值改变,但是返回值也是父子关系,我们发现,他们照样构成重写。
在这里插入图片描述
2. 析构函数的重写(基类与派生类析构函数的名字不同)

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

所以另外一种特殊情况就是只要父类中的函数加上virtual,子类的函数可以不加virtual关键字,也可以构成重写,但是建议加上关键字。

class Person
{
public:
	virtual Person* buyTicket()
	{
		cout << "person::买票全价" << endl;
		return this;
	}
};
class Student : public Person
{
	Student* buyTicket()
	{
		cout << "student::买票半价" << endl;
		return this;
	}
};
void func(Person& p)
{
	p.buyTicket();
}
int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

在这里插入图片描述


(3)析构函数是否要定义为虚函数

以下来探究析构函数为什么要定义为虚函数:
先来看以下的代码:

class Person {
public:
	~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	~Student() { cout << "~Student()" << endl; }
};
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;
	return 0;
}

如果不将析构函数定义为虚函数,会出现以下的结果:
在这里插入图片描述
我们发现我们在堆上开辟了两份空间,却没有释放子类函数中子类的那一部分,所以这会造成内存泄露,而当我们将析构函数定义为虚函数之后,会有以下的结果:
在这里插入图片描述
我们发现内存泄露的问题解决了,那么这到底是为什么呢?
这是因为,在实现了多态之后,我们传入什么类型的对象,就去调用该类的中函数,例如我们new了一个Student类的对象,就回去该类中去找函数,这也就是多态的意义,如果不实现多态,当我们传入一个Person对象类型的指针p时,delete会转化为p->destructor(),所以只会去调用Person类的析构函数。只有子类的析构函数重写父类的析构函数,才会传入什么类型对象,就去调用该类的析构函数,指向子类调用子类,指向父类调用父类。

(4)C++11 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; }
};

在这里插入图片描述
2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
正确写法:

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

错误写法

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

int main()
{
	Car c;
	Benz b;
	b.Drive(1);

在这里插入图片描述

三、抽象类

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

对父类的纯虚函数进行重写,相当于强制重写,如果不重写,就不能实例化出对象。

2.接口继承和实现继承

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

四、多态的原理

1.虚函数表

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
private:
 int _b = 1;
};

在这里插入图片描述
我们会发现这和我们之前学习的类与对象的内容不同了,不只是去计算成员变量的大小,而是再加入了一个指针,这个指针是指向虚函数表的指针。
在这里插入图片描述
我们发现b对象有两个内容,一个是成员变量,一个就是指针,这个指针就是指向虚函数表的,虚函数表中存储的就是该类中的虚函数的指针,虚函数表就是一个函数指针数组。

2.多态的原理探究

接着上边的代码我们继续通过加入一个子类来分析:

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

在这里插入图片描述
我们发现子类和父类都有虚函数表,但是子类的虚函数表中的第一个函数指针与父类不同,但是第二个是相同的,这是因为第一个函数满足重写规则,子类对父类的函数进行了重写,所以地址改变,是另外一个函数,而第二个虚函数在子类中并没有,所以没有构成重写,就直接继承了。


接下来继续进行验证:
在这里插入图片描述

那么同时使用父类的指针或者引用去调用虚函数时,当父类的对象使用父类指针调用,不会发生切片,是直接去调用,但是当子类的对象使用父类的指针或者引用去调用时会发生切片,在切片之后,只能看到子类中继承下来父类的那一部分,但是在子类的虚函数表中,如果满足重写,那么虚函数表中的函数指针就是子类中的函数重新生成的,所以是不同的函数,所以调用虚函数时,指向哪一个对象,就去调用该对象的函数。


下边通过反汇编来观察一下实现多态和不实现多态的区别:
在这里插入图片描述
在这里插入图片描述
我们发现,当构成多态时,在调用函数时,先去判断是指针指向哪个对象,然后在对象的虚函数表中去寻找函数的地址,再通过函数的地址去调用对应的函数。这也就是为什么多态调用被称为运行时决议,而普通调用就是直接在对象中寻找函数指针,找到对应函数,被称为编译时决议。
在这里插入图片描述


那么函数指针在虚函数表中是怎么存储的呢?我们可以来通过内存窗口观察一下:
在这里插入图片描述
在这里插入图片描述
我们发现虚函数表中,存储的是虚函数的地址,在子类中有几个虚函数就会有几个虚函数的地址,但是在虚表的末尾也会有一个空指针代表虚表的结束。


我们再来看下边一段代码:

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

前边提到虚函数都会存储在虚表中,那么上边代码中子类的虚函数是否都会存储在虚表中呢?
在这里插入图片描述

通过调试,我们发现编译器显示并没有将子类中第二个虚函数显示出来,那么到底会不会加载到虚表中,那么能通过什么方式来验证一下呢?我们可以通过一段代码来验证:

//typedef定义函数指针为VFPTR
typedef void(*VFPTR)();
//由于是一个函数指针数组,所以只要传入数组首元素的地址,就可以通过循环顺序访问数组中每一个元素
//也就是函数的指针,通过函数指针来调用函数就可以知道虚函数表中分别存储了哪些函数的指针
typedef void(*VFPTR)();

void PrintVFTable(VFPTR* table,size_t n)
{
	for (size_t i = 0; i < n; ++i)
	{
		printf("vft[%d]:%p->", i, table[i]);
		//table[i]();
		VFPTR pf = table[i];
		pf();
	}
	cout << endl;
}
int main()
{
	Base b;
	Derive d;
	PrintVFTable((VFPTR*)*(int*)&d,3);
	PrintVFTable((VFPTR*)*(int*)&b,2);

	//func(b);
	//func(d);
	//return 0;
}

在这里插入图片描述
我们可以通过地址来读取函数,发现虚函数表中有哪些函数。

3.多态原理总结

上面分析了这个半天了那么多态的原理到底是什么?还记得这里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->BuyTicket在mike的虚表中找到虚
    函数是Person::BuyTicket。
  2. 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
    找到虚函数是Student::BuyTicket。
  3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
  4. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
    用虚函数。

在这里插入图片描述

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

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

相关文章

各种常见的word格式符号(回车字符、软回车、分页符等)

一、如何显示编辑符号 1、打开WORD-选项-显示&#xff0c;勾选格式标记 2、如下图所示。在【开始】一【段落】选项卡中&#xff0c;它可以帮助我们识别编辑过程中的格式符号。 二、各种常见的word格式符号 第一种 描述&#xff1a;向下的箭头 样式&#xff1a;↓ 名字&#xff…

Esbuild基本使用与插件开发

作为Vite的双引擎之一&#xff0c;Esbuild在很多关键的构建阶段(如依赖预编译、TS语法转译、代码压缩)让Vite获得了相当优异的性能&#xff0c;是Vite高性能的得力助手。无论是在Vite的配置项还是源码实现&#xff0c;都包含了不少Esbuild的基本概念和高阶用法。因此&#xff0…

idea线上debug

idea线上debug 1. 为什么需要线上debug2. 基本原理3.远程调试配置3.1 1. 融合云增加JVM参数3.2 idea配置 4. 注意附录 1. 为什么需要线上debug 在微服务开发中&#xff0c;开发的服务可能会依赖数据库、消息队列等资源&#xff0c;也有可能依赖其他的服务&#xff0c;这些服务…

PCB 布线技术~PCB 基础

PCB量测的单位 • PCB设计起源于美国&#xff0c;所以其常用单位是英制&#xff0c; 而非公制 – 版子的大小通常使用英尺 – 介质厚度&导体的长宽通常使用英尺及英寸 • 1 mil 0.001 inches • 1 mil .0254 mm – 导体的厚度常使用盎司(oz) • 一平方英尺金属的重量 •…

redis学习(十八) 部署redis哨兵模式

文章目录 前言一、搭建主从数据库二、搭建哨兵三、验证哨兵 前言 哨兵模式核心还是主从复制&#xff0c;只不过在相对于主从模式在主节点宕机导致不可写的情况下&#xff0c;多了一个竞选机制&#xff1a;在所有的从节点竞选出新的主节点。每一个哨兵都是一个独立的sentinel进…

PCB 布线技术~PCB结构:Traces,电源平面

PCB导体:Traces • 铜是PCB中最常用的导体 – 走线或连接器一般通过镀金来提供一个抗腐蚀的电传导特性 – 走线的宽度和长度-由PCB布线工程师控制 • 在通常的制造工艺下&#xff0c;走线的宽度和之间的间距一般要≥5 mil – 走线厚度-制造工艺的变量 • 典型值 0.5oz – 3oz •…

Linux---目录结构、绝对路径与相对路径、命令基础格式、ls命令

1. Linux的目录结构 Linux的目录结构是一个树型结构。 Windows 系统可以拥有多个盘符, 如 C盘、D盘、E盘。 Linux没有盘符这个概念, 只有一个根目录 /, 所有文件都在它下面。 在Linux系统中&#xff0c;路径之间的层级关系&#xff0c;使用&#xff1a;/ 来表示。 Linux只…

Inodb引擎 内存+磁盘+MVCC(多版本并发控制)

目录 逻辑存储结构 Innodb引擎内存结构介绍 Innodb引擎磁盘结构介绍 内存和磁盘交互 MVCC(多版本并发控制)原理 预备知识 mvcc基本概念 mvcc的具体实现 总的来说mvcc原理&#xff1a; 逻辑存储结构 Innodb引擎内存结构介绍 Buffer Pool(缓冲池) 缓冲池是内存的一个区域&am…

001 hive简介

一. hive概述 1. hive的产生背景 mapreduce程序大部分解决的问题是结构化数据&#xff0c;而解决结构化数据最佳方案是一条sql语句 hive出现的主要原因是解决mapreduce开发成本高的问题。但hive不能完全替代mr&#xff0c;只能处理mr中的结构化数据。 2. hive是什么 hive提…

【数据结构】常见数据结构汇总

文章目录 前言一、数组二、链表三、栈四、队列五、哈希表--散列表六、堆七、树八、图参考与感谢 前言 数据结构是计算机存储、组织数据的方式。一种好的数据结构可以带来更高的运行或者存储效率。数据在内存中是呈线性排列的&#xff0c;但是我们可以使用指针等道具&#xff0…

hive学习入门

第四章 HQL基础语法 Hive中的语句叫做HQL语句,是一种类似SQL的语句,基本上和SQL相同但是某些地方也是有很大的区别. 4.1 数据库操作 创建数据库 1.创建一个数据库,数据库在HDFS上的默认存储路径是/hive/warehouse/*.db。 create database hive01; 避免要创建的数据库已经存…

算法设计与分析:分治法

目录 第1关&#xff1a;分治法介绍 任务描述&#xff1a; 相关知识&#xff1a; 基本概念&#xff1a; 解题步骤&#xff1a; 实例演示&#xff1a; 关键代码&#xff1a; 编程要求&#xff1a; 测试说明&#xff1a; 第2关&#xff1a;归并排序 任务描述&#xff1a;…

网安行业「iPhone时刻」!深信服首秀安全GPT技术应用

5月18日&#xff0c;深信服正式对外首秀安全GPT技术应用。深信服科技董事长何朝曦在现场分享了安全GPT技术应用的研发背景、技术应用特点及未来设想。深信服科技研发总经理梁景波、深信服安全攻防专家演示了安全GPT技术应用在XDR平台上的效果&#xff0c;包括高级威胁检测、安全…

Midjourney8种风格介绍+使用场景(2)

引言 我相信大家都或多或少玩过Midjourney&#xff0c;但是要形成自己独特的个人IP&#xff0c;那么有必要知晓画作的一些基础知识&#xff0c;如果你没有时间实践&#xff0c;没有关系&#xff0c;我来操作&#xff0c;定期分享画作相关知识&#xff0c;既简单又方便&#xff…

软件测试面试常常遇到的十大“套路”

面试中&#xff0c;如何回答HR提出的问题很大程度上决定了面试能不能成功。 下面是软件测试人员在面试过程中经常被问到的10个问题&#xff0c;告诉你怎么回答才不会被面试官套路...... 一、请你做一个自我介绍 误区&#xff1a; 一般人回答这个问题过于平常&#xff0c;只说…

5th-Generation Mobile Communication Technology(四)

目录 一、5G/NR 1、 快速参考&#xff08;Quick Reference&#xff09; 2、5G Success 3、5G Challenges 4、Qualcomm Videos 二、PHY and Protocol 1、Frame Structure 2、Numerology 3、Waveform 4、Frequency Band 5、BWP 6、Synchronization 7、Beam Management 8、CSI Fra…

闭包?什么是闭包?--JavaScript前端

大厂面试题分享 面试题库 前后端面试题库 &#xff08;面试必备&#xff09; 推荐&#xff1a;★★★★★ 地址&#xff1a;前端面试题库 web前端面试题库 VS java后端面试题库大全 闭包的背景 由于js中只有两种作用域&#xff0c;全局作用域和函数作用域&#xff08;模块…

模拟封装C标准库

文章目录 1. 准备工作2. my_fopen3. my_fwrite4. my_fclose和my_fflush5. syncfs 1. 准备工作 举个例子&#xff1a; 这里我们要实现my_fopen&#xff0c;my_fwrite和my_fclose这三个函数&#xff0c;并封装MyFILE。 这是MyFILE的封装&#xff0c;然后我们把三个函数接口完成…

Seaborn 可视化学习

Abstract 主要讲述绘制强化学习结果时遇到的seaborn操作。因此&#xff0c;本文主要讲述Lineplot的用法&#xff0c;以及图片的相关设置 线条绘制 import seaborn as sns import pandas as pd import matplotlib.pyplot as plt import numpy as np# 单线绘制 data pd.DataF…

接口测试全流程扫盲,让我看看有哪些漏网之鱼

目录 扫盲内容&#xff1a; 1.什么是接口&#xff1f; 2.接口都有哪些类型&#xff1f; 3.接口的本质及其工作原理是什么&#xff1f; 4.什么是接口测试&#xff1f; 5.问什么要做接口测试&#xff1f; 6.怎样做接口测试&#xff1f; 7.接口测测试点是什么&#xff1f;…