C++多态的认识与理解

news2024/9/20 18:38:54

多态的概念

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

比方说买高铁票时,如果你是学生的话,买票就有优惠。如果你是军人的话,就可以优先买票。普通人的话,那买票就是正常价了。

多态定义和实现

首先我们要知道没有继承就没有多态,多态是建立在继承之上的。多态是在不同继承关系的类对象中去调用同一函数,产生不同结果的行为。


构成多态的条件

  1. 虚函数重写
  2. 必须通过父类的指针或引用去调用虚函数

虚函数重写

虚函数就是被virtual关键字修饰的成员函数

虚函数的重写就是派生类中有一个跟基类完全相同的虚函数,这就称子类的虚函数重写了基类的虚函数。而这里的完全相同是派生类虚函数与基类虚函数的返回值类型、函数名、参数列表类型完全相同 

class Person 
{
public:
	virtual void test() { ... }//虚函数
};
class Student : public Person 
{
public:
	virtual void test() { ... }//虚函数重写
};

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也是可以构成重写,因为继承时基类的虚函数被继承下来了,而在派生类依旧保持虚函数属性,所以可以不加virtual关键字修饰。但是不建议

 父类的指针或引用调用虚函数

为什么是父类的指针不是子类的指针或引用???

首先假设如果是子类的指针的话,首先创建子类的对象,指针调用虚函数,不用想,肯定调用子类的虚函数,如果想要调用父类就显示调用了呗。但是如果是父类指针的话,那么该父类指针可以进行分割处理(不会中间生成临时对象,属于自然赋值),接受子类传的对象,此时调用虚函数就变得有意义了。如果是子类传的对象,父类引用,就会调用子类的虚函数,反之就调用父类的虚函数。那么此时调用虚函数的时候就形成了多态。

虚函数重写的两个例外

协变

协变就是虚函数重写的时候,基类和派生类的函数返回值类型可以不同但是必须是父子类关系的指针或引用。

class A {};
class B :public A{};

class Person 
{
public:
	virtual A* test() {return nullptr; }//
};
class Student : public Person 
{
public:
	virtual B* test() {return nullptr; }//
};

就像上面的例子,返回值 必须是父子类关系的指针或引用,因为A类与B类也是父子关系,所以虚函数的返回值也应该将person类与student类的父子关系对应起来。如果返回值类型反着写的话编译器是会报错的。

析构函数的重写

其实基类与派生类的析构函数也是可以构成虚函数重写的。

看以下代码运行:

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类,创建student的对象时,别忘了内部还有一部分属于preson的空间,此时子类可以被分割从而可以被父类接收。

我们的目的是想要delete函数调用该指针空间所属对象的析构函数并释放空间。但是delete是根据类型去调用的,是父类的指针就调用父类的析构,是子类的指针就调用子类的析构。但是我们创建的是子类的对象:父类的指针不仅仅是源于父类也可能经过子类分割源于子类。所以此时析构的调用实际上就与我们意想中的不同了,因为并没有调用子类成员的析构函数,仅仅并且指针类型去调用父类的析构函数。这种情况就极有可能造成了内存泄漏。

 

解决:

首先我们要知道在子类中是不能显示调用析构函数的,其原因就是父子类的析构函数构成隐藏,又由于多态,析构函数的名称其实是被统一处理成destructor。所以delete的功能就相当于是两步:p->destructor()+ operator delete()。此时不难发现父子类的析构函数名其实是相同的,不加virtual修饰的话父子类的析构函数就构成隐藏关系,所以将父子类的析构函数+virtual修饰就形成了虚函数。至此上代码的情况就得以解决。

(调用两次是因为子类继承了父类的成员,所以析构时会先析构子类再析构父类)

使用

class Person 
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; return nullptr; }//
};
class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; return nullptr; }//
};
void Func(Person& p)//父类的引用为形参
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

多态调用虚函数时看的并不是调用参数的类型,而看的是该参数(指针或引用)的指向,指向父类对象就调用父类的虚函数,指向子类对象就调用子类的虚函数。而且在派生类中将基类虚函数重写时,在函数前面不加关键字virtual也是没问题的。


 例题解析

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

首先分析p->test(),这里p指向的是B类,而B类将A类继承了下来,而A类中test()函数内部又调用了func()函数,这时不就直接调用B类中的func函数了吗。


解析:其实在调用test函数时,test有一个隐藏的参数(this指针),指向类型是A*但是是B对象的指针调用的,所以就发生切割,可以构成多态。但最关键的一点是,子类在虚函数重写时重写的是函数内部的实现,而函数声明接口的部分是从父类继承下来的

所以参数部分就取决于A类的func()函数,而内部的实现就看B类的func()函数。

重写隐藏重载的区别 

函数重载:重载函数发生在同一作用域里,函数名相同,参数不同。

函数隐藏(重定义):发生在基类和派生类中,只要求函数名相同即可。

函数重写(覆盖):发生在基类和派生类中,函数名相同,函数参数类型相同,返回值相同(除了协变)


基类和派生类的同名函数不是重写就是隐藏(绝不是函数重载)。

抽象类 

在了解抽象类之前要知道什么是纯虚函数,纯虚函数就是在虚函数后面写上=0;而该纯虚函数所在的类就是抽象类。(包含纯虚函数的类就是抽象类)

抽象类的特点:不能实例化出对象,而且继承了抽象类的子类也不能实例化对象,除非该子类重写纯虚函数        

多态的原理

虚函数表

虚函数是形成多态的重要条件,我们知道类的成员函数是存在公共代码区的,其实虚函数也不例外,但是虚函数的地址是单独被拿出来了,放到了一个虚函数表(相当于一个函数指针数组)当中,而一个类中不仅仅存放成员变量,还存放这个虚函数表的地址

在VS的X64环境下演示

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
	void Func3()//非虚函数
	{
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
};
int main()
{
	Base b;
	cout << sizeof(Base) << endl;
}

多态下的虚函数表

 我们只知道,有成员虚函数就有虚函数表,在多态中我们知道虚函数的重写是构成多态的重要条件,下面我们就来看一下多态下的虚函数表是什么样子:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()//非虚函数
	{
		cout << "Func2()" << endl;
	}
private:
	int _a = 1;
};
class Child:public Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 3;
};

首先我们能发现父子类的虚函数表不是共有的,是各自独有一份的。

父类的虚函数表就中规中矩,因为父类中有两个虚函数,所以虚函数表中就有两个数据(指针)。而子类继承了父类,但是子类的func1()函数与父类的func1()函数构成重写,子类的虚函数表就是上图的样子,虚函数表中子类的非重写虚函数继承了父类的非重写虚函数,所以地址不变,所以可以说子类重写虚函数就相当于父类虚函数被子类覆盖。

编译器在编译时给类创建一个虚函数表,去虚表里面找函数地址的过程其实是在编译器运行时(构造函数初始化列表阶段)所执行的(虚函数在运行时动态绑定)


所以言归正传,当我们用指向子类的父类指针调用虚函数时,首先就类型而言父子类不匹配,所以会进行切片处理,就相当于将子类的空间数据切成与父类相同的部分,此时调用虚函数,访问的自然就是子类的虚函数表,所以此时自然调用的就是子类重写好的虚函数。

 其实尽管子类中没有虚函数,全是继承父类的虚函数的情况下(父子类虚表存放的函数指针是一模一样的),父类也不会套用子类的虚表,也是单独存一份虚函数表。而且一个类的虚表与对象个数无关。    


为什么必须是父类指针或引用调用才能形成多态???

有了上面的理解,回答这个问题就比较容易了,我们父类指针或者引用指向谁就去谁的虚函数表中调用谁的虚函数。

那么我们用子类对象初始化父类再去调用虚函数就为什么行不通了呢???假如我们让父类对象去初始化子类,我们知道这是一个赋值的过程,并不会发生切片处理(切片处理通常发生在通过基类指针或引用操作派生类对象时),而赋值就要发生拷贝,但是我们的虚表会发生拷贝吗???

如果虚表不发生拷贝的话自然是无法形成多态的,所以我们假如虚表指针会发生拷贝。首先我们将子类对象赋值给父类,此时就发生值拷贝,该对象的虚表就和子类的虚表是一模一样的,此时调用该对象自然是可以达到目的。

但是 这样就完全不符合逻辑,因为就单独父类对象调用虚函数时,其虚表可能并不是父类的虚表,极可能是经过子类对象赋值给父类,而拷贝的父类的虚表。从而就无法满足父类对象调用父类的虚表,子类对象调用子类的虚表。尤其是在析构函数重写时这样调用的话就十分危险了。

实际上是不允许拷贝虚函数表指针这种情况的。

 

所有虚函数都是存在虚表内吗?

我们知道如果一个类中有虚函数的话就存在虚函数表。所以而我们监视窗口中还可以看到类中是存放有一个指向虚表首元素地址的指针,虚表中存放的是虚函数的指针,所以当我们计算类的大小时要将虚指针也计算在内。

回到问题,我们先用监视窗口查看一下虚表的内容:

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

此时从监视窗口中可以看出Derive类中虚表只存放了两个虚函数一个是继承并重写的func1 虚函数,另一个是继承下来的func2 函数,但是为什么不见Derive类中的func3和func4呢。其实监视窗口并不是检验的标准,不得已时还是直接看虚拟内存空间的内容:(X64平台)

 很明显全0的位置就是分界线的地方(仅猜测),而且虚表中存放了四个函数指针的值,自然知道分别是重写的func1继承的func2,以及原有的func3和func4.为了验证这一问题我们可以先初步的通过函数指针去调用对应的虚函数,如果调用没问题则证明我们的猜想是正确的,反之则是错误的:

typedef void(*VF)();//将无参的函数指针类型重命名成VF
void Print(VF* p)//该函数是为了实现打印虚表内的函数指针并且直接调用
{
	int i = 1;
	while (*p)
	{
		printf("func%d = %p->",i++, *p);
		(*p)();//*p就是虚表里的函数指针,可以代替函数名直接调用函数
		p++;
	}

}
int main()
{
	Base b;
	Derive d;
	Print((VF*)*(long long*)&b);//传虚表的地址
	cout << endl;
	Print((VF*)*(long long*)&d);
	//*(long long*)&b 是拿到该对象前八个字节的内容,也就是虚表指针
	//防止传参时类型不匹配,强转

 	return 0;
}

所以可以证明:一个类中所有的虚函数都会存在该类对应的虚函数表中

 多继承下的虚表

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(*VF)();//将无参的函数指针类型重命名成VF
void Print(VF* p)//该函数是为了实现打印虚表内的函数指针并且直接调用
{
	int i = 1;
	while (*p)
	{
		printf("func%d = %p->", i++, *p);
		(*p)();//*p就是虚表里的函数指针,可以代替函数名直接调用函数
		p++;
	}

}

首先我们要了解,多继承的派生类中不是只有一个虚表。就拿上面代码而言就可以知道多继承下不仅仅将基类成员变量继承下来了还将基类各自的虚表也继承下来了。因为如果只有一张虚函数表的话会很会乱,并且造成不必要的麻烦,例如在父子类赋值切片处理的时候就无法直观地有效切片,而且多态调用时也会出问题。 

但是此时问题来了,在Derive类中的func1函数重写了其父类的func1函数,但是func3函数是存在哪张虚表里的呢??? 

其实经过虚表内容的打印后,不难看出func3其实是放在第一个继承下来的虚表内部的。对于重写的func1函数,我们可以看到Derive里重写的func1虚函数在两个虚表上的地址是不一样的,但是我们重写的的可是同一个函数啊。

其实我们忽略了一点,类中成员虚函数也是成员函数,而且成员函数都有一个默认的参数:指向类对象的this指针,所以尽管我们在多态调用虚函数时,调用的是哪个类的成员函数传的就是哪个this指针,所以说,当我们多态调用时如果是第一个父类指针调用子类重写虚函数时,此时的this指针指向的也是起始位置,调用的也就是第一个虚表的重写函数。但是第二个父类指针调用子类重写虚函数时,此时的this指针指向的依旧也是起始位置,但是需要调用的是第二个虚表的重写函数,此时其实底层汇编代码自动发生了指针偏移,使得this指针指向第二张虚表,再调用对应的虚函数,所以汇编代码的jmp指令就在不同的位置,所以函数的指针也不同


 疑问

1.构造函数可以是虚函数吗?

编译报错,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的,虚函数调用要在虚表中去寻找函数指针,但此时虚表指针还未初始化。

2.inline函数可以是虚函数吗?

可以,内联函数没有函数地址,但是多态调用时会忽略inline的作用,只有普通调用inline才会起作用。
3.虚函数表是在什么阶段生成的,存在哪的?

虚函数表是在编译阶段就生成的,但是虚函数表指针是在构造函数初始化列表阶段初始化的。一般情况下存在代码段(常量区)的


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

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

相关文章

聚观早报 |2024年春节连休8天;RTE2023开幕

【聚观365】10月26日消息 2024年春节连休8天 RTE2023开幕 一加12首发“东方屏” 微软公布2024财年第一财季财报 Alphabet Q3业绩好于预期 2024年春节连休8天 国务院办公厅发布关于2024年部分节假日安排的通知。2024年春节&#xff0c;2月10日至17日放假调休&#xff0c;共…

Linux ———— 用户-组

Linux是一个多用户多任务的操作系统。 用户&#xff08;user&#xff09;&#xff1a; 在Linux系统中&#xff0c;用户是一个拥有独立空间、权限和身份的实体。每个用户都有一个唯一的用户名和用户ID。用户可以登录到系统、读取、写入、执行文件&#xff0c;并按照预设的权限进…

Pytorch使用torch.utils.data.random_split拆分数据集,拆分后的数据集状况

对于这个API,我最开始的预想是从 猫1猫2猫3猫4狗1狗2狗3狗4 中分割出 猫1猫2狗4狗1 和 猫4猫3狗2狗3 ,但是打印结果和我预想的不一样 数据集文件的存放路径如下图 测试代码如下 import torch import torchvisiontransform torchvision.transforms.Compose([torchvision.tran…

算法通关村-黄金挑战K个一组反转

大家好我是苏麟 , 今天带来K个一组反转 , K个一组反转 可以说是链表中最难的一个问题了&#xff0c;每k 个节点一组进行翻转&#xff0c;请你返回翻转后的链表。k 是一个正整数&#xff0c;它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍&#xff0c;那么请将最后…

索引模型的常见数据结构

索引的出现是为了提高查询效率,三种常见、也比较简单的数据结构 哈希表有序数组搜索树 哈希表 哈希表是一种以键 - 值&#xff08;key-value&#xff09;存储数据的结构&#xff0c;我们只要输入待查找的键即 key&#xff0c;就可以找到其对应的值即 Value。哈希的思路很简单…

Python实验项目4 :面对对象程序设计

1&#xff1a;运行下面的程序&#xff0c;回答问题。 &#xff08;1&#xff09;说明程序的执行过程&#xff1b; &#xff08;2&#xff09;程序运行结果是什么&#xff1f; # &#xff08;1&#xff09;说明程序的执行过程&#xff1b; # &#xff08;2&#xff09;程序运行…

Python在不同场景下的并发编程方案选择

目录 一、多线程 二、多进程 三、异步IO 四、优缺点分析 五、注意事项 总结 并发编程是软件开发中的重要一环&#xff0c;它允许程序同时处理多个任务&#xff0c;提高程序的运行效率和响应速度。Python作为一种流行的编程语言&#xff0c;提供了多种并发编程方案。 一、…

source insight 使用过程中问题点总结

1. //1 //2 不现实大小的注释。选中Special comment styles即可。

vector详解

迭代器 vector维护的是一个连续线性空间。普通指针可以满足条件作为vector的迭代器。 template <typename T, typename Allocalloc> class vector { public: using value_type T; using iterator value_type*; }; vector::iterator //int* vector::iterator //char* …

HFP协议分析

HFP 全称为Hands-Free Profile&#xff0c;通俗的说就是蓝牙电话协议&#xff0c;可以通过指定好的AT command来控制通话的接听、挂断、拒接等 看协议的一些约定格式 在HFP协议文档里面有一个约定&#xff0c;这里贴出来&#xff0c;每种不同的标识代表不同的意思&#xff0c…

2023年中国高尔夫用品产值、市场规模及细分产品现状分析[图]

高尔夫用品市场是指个人的高尔夫用品&#xff0c;主要包括高尔夫球具、高尔夫球包、高尔夫用球、高尔夫服装、高尔夫鞋、高尔夫帽子、高尔夫手套及相关配件等方面。 随着高尔夫产业的逐步兴起&#xff0c;高尔夫运动受到了越来越多人们的青睐&#xff0c;与此同时&#xff0c;也…

Loop Copilot:AI驱动,小白也能自己生成音乐?

01 项目介绍 Loop Copilot是一个使用自然语言生成音乐的系统。它不仅允许你使用自然语言来生成你想要的音乐风格、节奏或旋律&#xff0c;还支持通过多轮对话对已生成的音乐进行进一步的编辑和修改。包括对生成的音乐进行编辑修改、添加或删除乐器、加入音效等。 02 工作流程…

0027Java程序设计-房屋出租管理系统

文章目录 摘 要目 录系统设计开发环境 摘 要 随着我国市场经济的快速发展和人们生活水平的不断提高&#xff0c;简单的房屋出租服务已经不能满足人们的需求。如何利用先进的管理手段&#xff0c;提高房屋出租的管理水平&#xff0c;是当今社会所面临的一个重要课题。 本文采用…

vue重修之Vuex【上部】

文章目录 版权声明Vuex 概述Vuex 的主要概念和组件 vuex的使用状态 &#xff08;state&#xff09;Vuex特点 访问vuex中数据$store访问mapState辅助函数访问 开启严格模式及Vuex的单项数据流突变&#xff08;mutations&#xff09;mutations初识带参 mutations辅助函数 mapMuta…

Redis快速上手篇(三)(事务+Idea的连接和使用)

Redis事务 可以一次执行多个命令&#xff0c;本质是一组命令的集合。一个事务中的 所有命令都会序列化&#xff0c;按顺序地串行化执行而不会被其它命令插入&#xff0c;不许加塞。 单独的隔离的操作 官网说明 https://redis.io/docs/interact/transactions/ MULTI、EXEC、…

数据结构,及分类(存储分类、逻辑分类)介绍

一、数据结构&#xff1a; 数据是软件开发的核心。在软件开发过程中基本上就是对数据的新增、删除、修改、查看的操作。 如何合理存储数据&#xff0c;如何有效提升数据操作开发效率&#xff0c;都是软件开发中的重中之重。使用合理的数据结构是非常重要的。 1.1简介&#xff…

【Leetcode】【每日一题】【简单】2520. 统计能整除数字的位数

力扣&#xff08;LeetCode&#xff09;官网 - 全球极客挚爱的技术成长平台备战技术面试&#xff1f;力扣提供海量技术面试资源&#xff0c;帮助你高效提升编程技能&#xff0c;轻松拿下世界 IT 名企 Dream Offer。https://leetcode.cn/problems/count-the-digits-that-divide-a…

分享53个ASP.NET源码总有一个是你想要的

分享53个ASP.NET源码总有一个是你想要的 链接&#xff1a;https://pan.baidu.com/s/1xvqgPHSty70VGlQHoy9NYw?pwd8888 提取码&#xff1a;8888 项目名称 ASP.Net 4.5 论坛源码&#xff0c;支持多数据库 Asp.Net Core 3.x博客同步应用案例 ASP.NET Core MVC SqlSugerCore…

安防监控项目---概要

文章目录 前言一、项目需求二、环境介绍三、关键点四、主框架分析总结 前言 各位小伙伴&#xff0c;在蛰伏了将近有半年的时间又要和大家分享新的知识了&#xff0c;这次和大家分享的是一个项目&#xff0c;因此呢我准备分项目阶段去和大家分享&#xff0c;希望大家都能够在每…