当前位置: 首页 > article >正文

C++多态(超级详细版)

目录

 

一、什么是多态

二、多态的定义及实现

1.多态构成条件

2.虚函数的重写和协变

虚函数重写的两个例外:

2.1协变

2.2析构函数的重写  (析构函数名统一处理成destructor)

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

4.final 和 override

三、抽象类 

四.多态的原理

1.虚函数表

2.多态的原理 

2.1虚表指针里的内容

2.2引用和指针如何实现多态 

2.3普通类接收为什么实现不了多态

 3.虚函数表存放位置

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

1.单继承中的虚函数表

2.多继承中的虚函数表

3.菱形继承和菱形虚拟继承

做一道题吧


一、什么是多态

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

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

二、多态的定义及实现

1.多态构成条件

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

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

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

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 ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

注意:接受对象为父类的指针或者引用,你传递的是父类就调用父类的函数,传递的是子类就调用子类的函数

在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用

2.虚函数的重写和协变

上面例子中,我们实现了虚函数的重写(覆盖):

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

虚函数重写的两个例外:

2.1协变

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

这里不仅仅可以返回当前基类和子类的类型,还可以返回其他有继承关系的类和类型。

2.2析构函数的重写  (析构函数名统一处理成destructor)

首先,我们来看看析构函数不处理成virtual的情况

我们本义是想让p1调用Person的析构,p2先调用Person的析构在调用Student的析构,但是这里并没有调用Student的析构,只析构了父类,就可能发生内存泄漏。

这是为什么呢? 

因为这里发生了隐藏,~Person()变为 this->destructor()  ~Student()为this->destructor() 

编译器将他们两个的函数名都统一处理成了destructor,因此调用的时候只看自身的类型,是Person就调用Person的函数,是Student就调用Student的函数,根本不构成多态,这并不是我们期望的哪样。

 我们给析构函数添加上virtual

发现子类对象,Student对象就能正常析构了

注意::析构函数加virtual是在new场景下才需要, 其他环境下可以不用

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

4.final 和 override

在添加父类虚函数后面添加final代表不能再被重写

 

 final修饰类,代表不能被继承

override代表必须要重写虚函数,如果没有重写便会报错

三、抽象类 

在虚函数的后面写上 =0 ,则这个函数为纯虚函数包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

注意这里的包含,只要类里面有一个有纯虚函数,就是抽象类,就无法实例化对象,间接强制派生类重写。

 

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

四.多态的原理

1.虚函数表

以下代码环境在X86中,涉及到的指针是4个字节

我们定义一个Base类,里面有虚函数,还有一个变量int,按照我们之前学习到了,这里Base类的大小应该是4个字节,图中确是8个字节

为什么会发生这种现象呢?

用监视窗口看一下

除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数 的地址要被放到虚函数表中,虚函数表也简称虚表。

其实应该叫__vftptr(多个t代表table)

我们多添加几个虚函数,看看这个表里面的内容是怎么样的 

可以发现虚函数会放到虚函数表中,普通函数不会,并且表里面的内容是一个数组,是函数指针数组

2.多态的原理 

 有了虚函数表的概念,我们可以尝试通过虚函数表,去找到多态的原理

下面是测试代码

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void fun(){}
private:
	int a;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:
	int b;
};
void Func(Person* p)
{
	p->BuyTicket();
}
int main()
{
	Person p;
	Student s;
	Func(&p);
	Func(&s);
	return 0;
}

2.1虚表指针里的内容

从图中我们可以看到,在内存1里面输入&p可以找到p的地址, 因为p的第一个内容就是__vfptr,因此p的地址也是__vfptr的地址,那么我们通过__vfptr的地址就可以找到虚函数表里面的内容,因此我们在内存2里面输入__vfptr的地址,我们便找到了两个虚函数的地址。 

去找s的虚表虚函数也同理 

为什么我们要这么麻烦的去找呢?监视窗口不是可以看到吗?

这是因为VS2022的监视窗口可能会骗人(不一定百分百准确),使用内存是一定准确的。

通过上面的图片,我们可以提炼出如下内容

注意这里Student类和Teacher的类表里的第二个虚函数地址是一样的,因为B类没有重写第二个虚函数,因此继承下来了。

为什么第一个虚函数不一样呢?

因为子类重写后覆盖掉了(这也是为什么重写被称作覆盖的由来)

2.2引用和指针如何实现多态 

可以分析,为什么多态可以实现指向父类调用父类函数 ,指向子类调用子类函数?

传递父类,通过vftptr找到虚函数表的地址,再去找到虚函数的地址,有了虚函数的地址,便可以去call这个虚函数

传递子类,首先会进行切割

将子类的内容切割掉,父类再去接受这个数据了,一样会有vftptr(是子类的vftptr),再去找到虚函数的地址,有了虚函数的地址,便可以去call这个虚函数。

这样就完成了多态。

附加一句,对于下面指出的代码(他其实并不清楚自己所存放的虚函数表指针是父类的还是子类的,他只是蠢蠢的去调用这个虚函数而已)

2.3普通类接收为什么实现不了多态

依然是之前的代码,参数部分不再是指针和引用,而是用普通类,我们发现这里没有实现多态。

我们将代码做一个小改动,方便观看区别 

 给Person类添加上一个构造函数

class Person {
public:
	Person(int x = 0)
		:a(x)
	{}
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void fun(){}
private:
	int a;
};

 我们给Person类构建出的p对象传10,他会调用构造函数将10赋值给成员a。

当执行Func(p)函数时,注意观察此时的a的值为10,虚函数表地址为0x004f9bfc

当执行Func(s)函数时,注意观察此时的a的值为0,虚函数表地址也为0x004f9bfc。

从上面的分析可以看出,Func(s)传递时,切割出子类中父类的那一份,成员会拷贝给父类,但是没有拷贝虚函数表指针

为什么只拷贝成员,不拷贝虚函数表指针呢?C++祖师爷为何这么设计?

我们可以用反证法

假设 拷贝构造赋值重载 会拷贝虚函数表指针

那么我们写出如下代码,运行后输出结果就应该为 两个 买票-半价  了(因为不管指向的累人,只管你所存储的数据)

这样就不能保证多态调用时,指向父类,父类调用的是父类的虚函数。因为还有可能经过一些操作,变成子类的虚函数

也许上面的问题并不那么致命,你说你自己控制好一点不就行了。

那么析构呢?要知道虚函数表中还可能有析构函数,如果我写出如下代码,阁下又该如何应对?

	Person* p = new Person;
	Student s;
	*p = s;
	delete p;

这个时候,你会发现Person父类的对象delete会去调用子类Student类的析构函数,这样会引发很多不可控制的事情。因此祖师爷帮我们处理了

这里会有点绕,不理解也没关系, 只要知道只有引用和指针才能触发多态就行!!!

最后再补充两点:

同类对象的虚表一样。

如果子类没有重写父类的虚函数,那么他们的虚函数表指针不同,但里面的内容相同

 3.虚函数表存放位置

 我们通过代码来打印各个区地地址,可以判断虚函数表存放位置

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

void func()
{}

int main()
{
	Base b1;
	Base b2;
	static int a = 0;
	int b = 0;
	int* p1 = new int;
	const char* p2 = "hello world";
	printf("静态区:%p\n", &a);
	printf("栈:%p\n", &b);
	printf("堆:%p\n", p1);
	printf("代码段:%p\n", p2);
	printf("虚表:%p\n", *((int*)&b1));
	printf("虚函数地址:%p\n", & Base::func1);
	printf("普通函数:%p\n", func);
}

注意打印虚表这里,vs x86环境下的虚表的地址是存放在类对象的头4个字节上。因此我们可以通过强转来取得这头四个字节

b1是类对象,取地址取出类对象的地址,强转为(int*)代表我们只取4个字节,再解引用,就可以取到第一个元素的地址,也就是虚函数表指针的地址

从图中可以发现代码段和虚表地址非常接近,存在代码段的常量区。

虚函数和普通函数地址非常接近,存在代码段。

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

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;
};
class X : Derive
{
public:
	virtual void fun3(){ cout << "X::func3" << endl; }
};

int main()
{
	Base b;
	Derive d;
	X x;
	return 0;
}

明明Base类和X类应该有4个虚函数,监视窗口发现表里面竟然只有2个,这真的很奇怪。

VS下的监视窗口不一定准确,我们用之前的办法打开内存来看看 

通过输入__vfptr的地址,我们成功找到了里面虚函数的地址。并且我们还发现似乎下面那两个地址跟上面两个非常接近,我们可以合理的设想,下面两个地址也是虚函数指针。

在vs环境下,虚函数表里面的虚函数以0结尾, 也很符合之前我们观察到的。

我们可以通过这一点,来打印虚表。

下面我们typedef了虚函数表指针  typedef void(*VFTPTR)(); 可以通过这个函数指针数组来打印里面的虚函数,这个打印函数终止条件就是 !=0 ,传递的参数内容跟前面我们分析的差不多,只是躲了一个强转,PrintVFPtr((VFTPTR*)*(int*)&b)  ; 因为后面的  *(int*)&b 虽然内容是地址,但是表现形式是一个整形,需要强为  (VFTPTR*) 

 

在*((int*)&d) 就会取到vTableAddress指向的地址,就得到虚函数的地址了。

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;
};
class X : Derive
{
public:
	virtual void func3(){ cout << "X::func3" << endl; }
};

typedef void(*VFTPTR)();

void PrintVFPtr(VFTPTR a[])
{
	for (size_t i = 0; a[i] != 0; i++)
	{
		printf("a[%d]:%p\n", i, a[i]);
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	X x;
	PrintVFPtr((VFTPTR*)*(int*)&b);
	PrintVFPtr((VFTPTR*)*(int*)&d);
	PrintVFPtr((VFTPTR*)*(int*)&x);
	return 0;
}

我们运行一下如上代码,便可以打印出虚函数表里面的内容 

但是目前我们还是可以质疑这个地址到底是不是虚函数地址,我们可以打印虚函数的内容看一下。下面给打印代码略作修改,因为a[i]里面存放的就是函数指针,因此我们可以选择直接调用。 

void PrintVFPtr(VFTPTR a[])
{
	for (size_t i = 0; a[i] != 0; i++)
	{
		printf("a[%d]:%p->", i, a[i]);
		VFTPTR p = a[i];
		p();
	}
	cout << endl;
}

这下真的可以看到结果了可以确认虚函数都会放到虚表里面。并且监视窗口可能是个大骗子,要小心他!!!

2.多继承中的虚函数表

这里选择多继承,如图

这里我们代码

Base1有虚函数func1和func2,

Base2也有虚函数func1和func2。

derive继承了Base1和Base2,并重写了虚函数func1,还有虚函数func3

typedef void(*VFTPTR)();

void PrintVFPtr(VFTPTR a[])
{
	for (size_t i = 0; a[i] != 0; i++)
	{
		printf("a[%d]:%p->", i, a[i]);
		VFTPTR p = a[i];
		p();
	}
	cout << endl;
}
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;
	PrintVFPtr((VFTPTR*)(*(int*)&d));
	return 0;
}

使用监视窗口观察到Dervie类对象d有两个虚函数表,那么问题就来了,他自身的虚函数func3会放在哪一张表里面呢? 

我们还是选择打印来观看,发现这里只能打印出第一张虚表的内容,并且func3在第一张虚表里,第二张虚表有没有func3呢?好像也需要打印出来观看,而好像我们对于第二张虚表不好打印,我们有没有什么方法可以打印第二张虚表里面的内容呢?

答案是有的,有很多种方法,这里我们介绍两种

第一种:将&d强转为Base1*,这样+1就会跳过整个Base1,就刚好到达了Base2类的开始,再进行之前的强转便可以打印了。

第二种方法:直接将&d赋值给Base2* ptr;这样Base2会进行切片操作,于是ptr就直接指向了Base2的虚函数表,依然就行之前的强转操作便可以打印了。

主函数代码如下

int main()
{
	Derive d;
	PrintVFPtr((VFTPTR*)(*(int*)&d));
	//写法1
	//PrintVFPtr((VFTPTR*)(*(int*)((Base1*)&d +1)));
	//写法2
	Base2* ptr = &d;
	PrintVFPtr((VFTPTR*)(*(int*)ptr));
	return 0;
}

 我们打印出来看一看这两个表是什么情况

这里可以得出结论了,Derive类对象的虚函数会放在多继承中继承的第一个类的虚函数表里(即Base1类虚函数表) 

问题又来了,为什么多继承要搞多个虚表呢?

还是之前的继承关系,请看如下图和代码,如果不搞多个虚表,那么p1去调用func1(),p2也去调用func1(),如若d没有重写func1(),那么这个多态就会紊乱,调用的都是那一个func1()了,而不是p1调用Base1的func1(),p2调用Base2的func1()了。因此我们多继承就搞多个虚表才不会出现紊乱的问题

int main()
{
	Derive d;
	Base1* p1 = &d;
    PrintVFPtr((VFTPTR*)*(int*)p1);
	p1->func1();
	Base2* p2 = &d;
    PrintVFPtr((VFTPTR*)*(int*)p1);
	p2->func1();
	return 0;
}

运行一下代码 

 调用的func1()函数确实没问题,实现了多态,但是我们发现两张虚表里func1()函数的地址竟然不同,这是为什么,我们重写的func1()两个都实现了啊,调用的也肯定是同一个函数,按道理来说应该地址是一样的,为什么地址不一样呢?我们尝试用反汇编来看一下

p1调用func1的反汇编  call了eax,走到了eax里面的jmp指令,再走一步,就到了func1()函数

p2调用反汇编,首先也是call了eax,再jmp,但是这个jmp竟然没有走到func1()函数,而是先执行了 sub   ecx,8 指令,后面再jmp了两下,才走到了func1()函数。why???

 sub   ecx,8 指令代表了什么?为什么调用的同一个函数,汇编代码却不相同?

我们尝试破解一下这个指令到底是为什么,首先Derive类重写了func1()函数,既然是Derive类的func1()函数,那么他所存放的*this指针类型肯定也是Derive。那么在这个函数里,我是不是可以调用父类的非私有成员或者函数?

 

那如果我执行Base2* p2 = &d;   这就会对d对象进行切片,p2对象按道理来说只能看到Base2的虚函数表和自己存放的数据b2,但是又有可能在func1()函数访问Base1或者Base2的非私有成员和函数,这样好像就不太好调用了,如果我将p2的地址放在Derive类对象地址的地方,那是不是就会很方便访问了。

sub   ecx,8  此时,我们在俩看这句指令,从下面图片可以看出来exc存放的是p2的地址,sub 是减去的意思, 这局指令是 exc - 8,也就是p2的地址-8,那么我现在的地址是什么???没错啦,现在的地址就回到的&d的地址,这样一来是不是就可以调用整个Derive类还有继承下来的数据啦!!!!

这局代码的本质就是修正this指针,指向derive对象

那么为何 Base1* p1 = &d;  p1却没有减去值,回到&d的地方了呢?笨蛋,因为p1的地址就是d的地址,Derive首先继承的就是Base1,而d的首元素就是Base1的虚函数表指针啊。因此我自己就在这里,我还减去什么,我传过去直接开用就完事了!!!!

我们成功的管中窥豹,明白了多继承虚函数表的情况,还有为什么重写多个父类的同名虚函数,地址为何不一样了(如果地址一样,就不可能一起回到Derive类对象的地址,因为类里的元素总会有先后顺序)!!

3.菱形继承和菱形虚拟继承

我的建议是别碰,饶了我吧,投降了。

做一道题吧

 

 这道题选B,很难相信

首先,B类型的对象p去调用test(); test()是B类继承下来的,但是里面默认存放的this指针依然是A*,将一个B类型的指针传给A类型的指针,会发生多态,B类里面的func()是重写了A类的func()  (A类func()为虚函数,B类重写了可以不写virtual)。

注意重写的关键点,仅仅是重写了A类的实现,而前面的那些声明,依然是调用的A类的声明,因此给到的val默认值是1,调用了B类的函数实现!!! 所以输出B->1


http://www.kler.cn/a/108236.html

相关文章:

  • react的创建与书写
  • RHCE web解析、dns配置、firewalld配置实验
  • 【深度学习】LSTM、BiLSTM详解
  • 使用ookii-dialogs-wpf在WPF选择文件夹时能输入路径
  • 009_SSH_Mysql图书管理系统(学生注册 借书 还书 绵阳)——lwplus87(免费送)
  • Linux——简单认识vim、gcc以及make/Makefile
  • 28 行为型模式-中介者模式
  • 2023高频前端面试题-http
  • Spring Boot 优雅配置yml配置文件定义集合、数组和Map
  • PG物理备份与恢复之pg_basebackup
  • 深入探究ASEMI肖特基二极管MBR60100PT的材质
  • 【PWN · heap | Off-By-One】Asis CTF 2016 b00ks
  • css列表样式
  • Spring Authorization Server入门 (十九) 基于Redis的Token、客户端信息和授权确认信息存储
  • 前端小技巧: TS实现数组转树,树转数组
  • LeetCode--196. 删除重复的电子邮箱
  • C++ 笔记
  • 深入了解 Elasticsearch 8.1 中的 Script 使用
  • 【swagger动态配置输入参数忽略某些字段】
  • 如何确定Apache Kafka的大小和规模
  • Azure - 自动化机器学习AutoML Azure使用详解
  • ruoyi vue前后端分离功能介绍
  • 基于 Redis + Lua 脚本实现分布式锁,确保操作的原子性
  • Web APIs——事件流
  • 【CSDN 每日一练 ★★☆】【字符串】外观数列
  • golang连接池检查连接失败时如何重试