C++【深入理解多态】

一、多态概念与实现

(1)多态的概念

通俗来说,就是多种形态,简单来说就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
具体来说,派生类对象的地址可以赋值给基类指针。对于通过基类指针或引用调用基类和派生类中都有的同名、同参数表的虚函数的语句,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。这种机制就叫作多态。

我们先看一个多态场景:
先看如下图:这个运行结果是没问题的,先调用子类析构,在子类里面,子类析构函数完成之后会自动调用父类析构函数;最后父类析构。后定义的先析构。
在这里插入图片描述
然后我们再换场景:
我有个父类指针,可以指向父类对象,也可以指向子类对象,但是这个析构函数并没有调对,指向父类对象调用父类的析构函数,指向子类的析构函数还是调用父类的,如果子类里面有资源要释放,没有调到就会导致内存泄漏。
原因:delete有两部分构成,p1->destructor(),operator delete(p1);p2->destructor,operator delete(p2),所以父类和子类的析构函数构成隐藏关系。如果没有多态我们的类型是谁就调用谁,类型是父类指针就调用父类的,这是没问题的,但是在这里我们是不期望按类型调的,我们期望它按指针的对象调:指向父类对象调用父类的析构,指向子类对象调用子类的析构。
在这里插入图片描述
为什么第一部分函数名相同,就是为多态场景做准备,只要相同才能统一调destructor函数,怎么能做到加一个虚函数,如下图:
在这里插入图片描述

(2)怎么构成多态

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如child继承了Person。Person对象买衣服买大码,chlid对象买买衣服买小码。
在继承中要构成多态还有两个条件
1.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(函数名,参数,返回值)。缺省参数不同也构成三同,它说的是类型。
2.必须通过基类的指针或者引用调用虚函数。
虚函数:即被virtual修饰的类成员函数称为虚函数

class Person {
    
    
public:
	virtual void BuyClothes() {
    
     cout << "买衣服-大码" << endl; }
};
class child : public Person {
    
    
public:
	virtual void BuyClothes() {
    
     cout << "买衣服-小码" << endl; }
};
int main()
{
    
    
	Person p;
	child c;
	Person& p1 = p;
	Person& c2 = c;
	p.BuyClothes();
	c.BuyClothes();
	return 0;
}

在这里插入图片描述
1、如果它不满足多态,就看类型,意思就是看到调用者的类型,调用这个类型的成员函数,不管是指针还是引用,是父类就调父,是派生类就掉派生。
2、如果满足多态,就看调用的对象,指向的对象。
3、如果父类带virtual,子类带virtual,就不是多态,就是隐藏;

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

第一个:子类可以不写virtual
如果父类带virtual,子类不带virtual,也是多态。这就不符合三同了,他是这样的,父类不写virtual肯定就不是虚函数了,当父类是虚函数的时候,子类不加virtual,但它重写了这个虚函数,重写体现的是接口继承(函数声明),把virtual void BuyClothes() 给继承下来,继承父类的接口,去重写父类函数的实现,所以不加virtual也认为是虚函数,因为父类是虚函数,把它继承下来了。
第二个:协变,返回值可以不同前提父子关系的指针或引用
协变:(基类与派生类虚函数返回值类型不同)派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。如图:
在这里插入图片描述

(4)经典剖析巩固知识点

看如下题:

class A
{
    
    
public:
	virtual void func(int val = 1) {
    
     cout << "A=" << val << endl; }
	virtual void test() {
    
     func(); }
};
class B :public A
{
    
    
public:
	void func(int val=0) {
    
     cout << "B=" << val << endl; }
};
int main()
{
    
    
	B* p = new B;
	p->test();
	return 0;
}

A,B中的func是构成重写的,满足三同,又因为里面是缺省参数,而三同讲的是类型,也是重写,前面说过基类加virtual,子类不管加不加也是虚函数,因为继承的是父类接口,重写的实现,满足多态第一个条件;因为B继承A,p调用A中的test,谁去调用,是this,它的类型是A* this,虽然是继承,但是不会把函数的参数改了,这就相当于p是个子类指针即子类对象传给一个父类指针,满足多态第二个条件。如果满足多态,指向谁调用谁,指向子类就调子类的成员函数。
综上,在子类的成员函数当中,func是虚函数,重写的是实现,继承的是接口,同时继承缺省值,最终结果是:B=1;

(5) override 和 final

C++对函数重写的要求比较严格,但在有些情况下可能会疏忽,可能会导致函没有构成重写,比如父类忘了加virtual,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
1.final:修饰虚函数,表示该虚函数不能再被重写,如果搞了个虚函数,不想被重写就用final。

class P
{
    
      public: virtual void sleep() final {
    
    }};
class s :public P
{
    
      public: virtual void sleep() {
    
    cout << "睡觉" << endl;}};

2.override: 检查子类虚函数是否重写了父类某个虚函数,如果没有重写编译报错。本质上说这个关键字是让我们强制重写。

class P
{
    
    public:virtual void sleep(){
    
    }};
class S :public P
{
    
    public:virtual void sleep() override {
    
    cout << "睡觉" << endl;}};

(6)小总结

普通函数的继承是一种整体继承,子类继承了父类的函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,构成多态,继承的是接口。如果不实现多态,不要把函数定义为虚函数。
虚函数为了重写而生,重写为了多态而生。

二、多态的原理

(1)认识虚函数表

先观察一下带有虚函数的类大小:
在这里插入图片描述
为什么是8字节,不是4字节,因为对象的头部多了一个指针__vfpt,这个指针叫虚函数表指针,v代表virtual,f代表function。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。但派生类中这个表又放了什么。
如图:
在这里插入图片描述
看下面代码:

class Person {
    
    
public:
	virtual void BuyClothes() {
    
     cout << "买衣服-大码" << endl; }


};
class child : public Person {
    
    
public:
	virtual void BuyClothes() {
    
     cout << "买衣服-小码" << endl;  }
};
int main()
{
    
    
	Person p;
	child c;
	Person& p1 = p;
	Person& c2 = c;
	p1.BuyClothes();
	c2.BuyClothes();
	return 0;
}

在这里插入图片描述
观察上面可以看到,p1是指向p对象时,p->BuyClothes在p的虚表中找到虚函数是Person::BuyClothes。c2是指向c对象时,c2->BuyClothes在c的虚表中找到虚函数是child::BuyClothes。

(2)深入理解原理

分析:以上怎么做到的?我们可以看出,父类对象的虚表存的父类的虚函数,子类对象的虚表存的子类的虚函数。编译器也是判定构不构成多态:
如果不构成多态,编译时就直接确定调用地址,和指向的是谁没有关系,是父类的指针或引用,就调父类的函数,就去父类里面找到这个函数,确定这个地址,简单来说就是一个普通调用,和类型有关;
如果构成多态,编译器编译不知道调用的是谁,先生成各自的虚函数表,在运行时是去指向的虚表去找即去看指向的对象,指向父类在父类对象里面找的是父类虚函数,指向子类,就会去切割,找子类的虚函数。
而且我们所用的Person引用或指针,并不知道自己指向的是父类还是子类,传父类对象给我,看的是父类对象的虚表,找到这个虚函数,如果传的子类对象给我,会在这切割,其实看到的也是一个父类对象,只不过这不是一个纯粹的父类对象,是子类对象里面的父类的那一部分,p1和c2调用都是同一个函数,最终导致的调用结果不一样,就是因为传不同的对象,不同对象的虚表有各自的虚函数,指向父类调父类,指向子类调子类,而且这两行代码是无感的,看到的都是一个父类对象,只是是直接的父类对象,还是子类对象里面的父类对象。
再分析:
重写还有个名字叫覆盖,重写是把父类的接口继承下去,重写它的实现,是语法层面的上概念;覆盖是如果在子类里面重写了虚函数,子类对应的虚表位置,会把就像拷贝过来,覆盖成自己的虚函数,是原理层面的概念。
多态是运行到对象的虚表里去找,所以能达到传什么就执行什么,因为表里就已经生成好了,父类虚表存的是父类虚函数,子类虚表存的是子类虚函数,传给我父类在里面就去表里找的是父类虚函数,传给我子类就找的是子类虚函数。是一个被设计的执行。
再来深入理解覆盖:
如果在上面的函数中在父类再增加一个虚函数结果:
在这里插入图片描述
第一个虚函数完成了重写,子类先把父类的表现拷贝过来,重写的那个就覆盖成的自己的,没有重写的不叫覆盖。意思就是说父类函数有虚函数时,先将父类中的虚表内容拷贝一份到子类虚表中 ,如果子类重写了父类中某个虚函数,子类就用自己的虚函数覆盖虚表中父类的虚函数 ,子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。

(3)疑难解答

多态的条件为什么是重写?
因为只有重写了,子类的虚表才会覆盖对应的虚函数,因为要覆盖虚表里那个虚函数的位置。
为什么是指针或引用?
因为指针引用既可以指向父类对象,又可以子类对象,就算指向子类,看到的也是一个父类,只是在子类切割的。
为什么不把虚函数地址直接存对象头前面?
因为一个对象里面可能会有多个虚函数,而且同类型的虚表是一样的,虚函数表是一个虚函数指针数组,在编译时就已经确定好了。多态就是去找。

(4)为什么对象不能实现多态?

如果是指针或引用,指向父类或引用这个父类,如果是指针或引用,指向子类时,是把子类对象父类那一部分切出来,来指向那一部分,看到的还是子类中的那一部分,那一部分的虚表还是子类的。
在这里插入图片描述

如果是对象,如果是父类没有问题,如果是子类,子类给父类的切片,成员会拷贝过去,虚表如果不会拷过去,父类对象中的虚表里面永远是父类的虚函数,除非传子类对象给c2的时候,除了拷贝成员还拷贝子类的虚表,但子类切片的时候不敢拷贝虚表,因为指针或引用是切那一部分变成它指向的那一部分或引用的别名,但是敢拷贝就能实现多态,但是事实上不敢拷贝,拷贝了就会乱,一个父类对象的虚表到底是父类的虚函数还是子类的虚函数,分不清楚,有可能这是一个父类对象,指向的就是父类,找的就是父类的虚表,没有问题,但是也有可能是一个父类的对象,被子类对象拷贝过或赋值过,就彻底分不清楚,给一个父类对象就不敢确认这个父类对象里面的虚表是谁的,并且多态的时候如果再用指针调用,指针指向父类也有可能调用子类,因为父类对象里面也有子类虚表,这不合理。我一个父类对象就是一个纯粹的父类对象,父类对象一定是一个父类的虚表。
如果是对象,切片的时候只拷成员不拷虚表,如下图,c是子类,c2是父类:
在这里插入图片描述

(5)动态绑定与静态绑定

1.静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载,cin,cout。
2.动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的指向类型确定程序的具体行为,调用具体的函数,也称为动态多态。

(6)补充

虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是它的指针又存到了虚表中。对象中存的不是虚表,存的是虚表指针。那么虚表存经过去验证会发现在vs下是存在代码段的。代码段是编译代码生成汇编转二进制指令。虚表是在编译阶段生成的。对象中虚表指针是在运行时构造函数初始化列表阶段生成的。

三、单继承和多继承关系的虚函数表

(1)如何打印单继承中的虚函数表

class A {
    
    
public:
	virtual void func1() {
    
     cout << "A::func1" << endl; }
	virtual void func2() {
    
     cout << "A::func2" << endl; }
private:
	int a;
};
class B :public A{
    
    
public:
	virtual void func1() {
    
     cout << "B::func1" << endl; }
	virtual void func3() {
    
     cout << "B::func3" << endl; }
	virtual void func4() {
    
     cout << "B::func4" << endl; }
private:
	int b;
};
int main()
{
    
    
    A a;
	B b;
	return 0;
}

在这里插入图片描述
在这里插入图片描述

观察监视窗口发现,看不到派生类的func3和func4,没有进派生类的虚表,但从内存中是可以看到的。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是一个bug。可以查看d的虚表确认到底是不是。
我们可以使用代码打印出虚表中的函数:

class A {
    
    
public:
	virtual void func1() {
    
     cout << "A::func1" << endl; }
	virtual void func2() {
    
     cout << "A::func2" << endl; }
private:
	int a;
};
class B :public A {
    
    
public:
	virtual void func1() {
    
     cout << "B::func1" << endl; }
	virtual void func3() {
    
     cout << "B::func3" << endl; }
	virtual void func4() {
    
     cout << "B::func4" << endl; }
private:
	int b;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
    
    
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
    
    
		printf(" [%d]:%p\n", i, vTable[i]);
		VFPTR f = vTable[i];
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
    
    
	A a;
	B b;
	VFPTR* vTableb = (VFPTR*)(*(int*)&a);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&b);
	PrintVTable(vTabled);
	return 0;
}

如上代码:先typedef一个函数指针,声明一个函数指针,定义的变量必须在里面。
在PtinfVFtable()函数里面,VF_PTR[]是函数指针数组,打印这个数组就用for循环,在VS下,虚函数数组后面放一个空,所以遇到空则停止。
关键是在main函数里面,要把虚表的地址取出来再传过去。
两种方法:
第一种:是先取出a,b对象的头4bytes,就是虚表的指针,虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。
先取b的地址,强转成一个int的指针;再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针;再强转成VFPTR,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组;虚表指针传递给PrintVTable进行打印虚表。需要注意的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。只需要点目录栏的-生成-清理解决方案,再编译就可以。
第二种:就是把(VFPTR*)(*(int*)&a);换成(*(VF_PTR**)&a)。先说如果有一个int* 的数组,它首元素地址应该是一个int**,那类比到虚函数表指针数组就是VF_PTR*,,但传过去的时候先转2级也就是**,再解引用才变成*,这样类型才匹配。为什么不直接写成((VF_PTR*)&a),因为要传过去的地址在对象的头四个字节,需要一次解引用间接地取了四个字节,对象头四个字节是VF_PTR*,怎么获得只能解引用,而如果改成这样传的是整个对象首地址,不是我们想要的。
第二种比第一种移植性更强,因为换了平台int大小可能要改变。第二种虽然指针也要改变,它是自适应的。
最终运行结果:
在这里插入图片描述

(2)多继承中的虚函数表

(1)多继承虚函数表遇到的虚地址不同问题

首先观察如下代码和监视窗口:

class A {
    
    
public:
	virtual void func1() {
    
     cout << " A::func1" << endl; }
	virtual void func2() {
    
     cout << " A::func2" << endl; }
private:
	int _a;
};
class B :public A {
    
    
public:
	virtual void func1() {
    
     cout << " B::func1" << endl; }
	virtual void func2() {
    
     cout << " B::func2" << endl; }
private:
	int _b;
};
class C :public A, public B
{
    
    
public:
	virtual void func1() {
    
     cout << " B::func1" << endl; }
	virtual void func3() {
    
     cout << " B::func3" << endl; }
private:
	int _c;
};
//typedef void(*VFPTR) ();
//void PrintVTable(VFPTR vTable[])
//{
    
    
//	for (int i = 0; vTable[i] != nullptr; ++i)
//	{
    
    
//		printf(" [%d]:%p:\n", i, vTable[i]);
//		VFPTR f = vTable[i];
//		f();
//	}
//	cout << endl;
//}
int main()
{
    
    
	C c;
//VFPTR* vTableb = (VFPTR*)(*(int*)&c);
//PrintVTable(vTableb);
//VFPTR* vTabled = (VFPTR*)(*(int*)((char*)&c+sizeof(A)));
//PrintVTable(vTabled);

	return 0;
}

在这里插入图片描述
先不打印虚表,从监视窗口看,c有在、两张虚表,因为同时继承A和B,重写func1,但func3放哪了,这时就可以先借助打印两张虚表:
怎么打印:
第一张虚表好打印,主要是第二长,A和B在C不是连续的,需要先跳过A,加上A字节的大小,因为这里取地址是A*,加1是加一个A,这里需要把&c强转char*,加上一个字节。也可以用偏移:B* b=&c;PrintVFTable((VF_PTR*)(*(int*)(b))),切片它的指针会自动偏。如图结果,发现func3存到了第一张虚表
在这里插入图片描述
我们还发现,多继承以后,虚表中重写的func1地址不一样。
在这里插入图片描述

(2)多继承中虚地址不同的原理

根据上面观察的现象,思考为什么要搞成不一样?

class A {
    
    
public:
	virtual void func1() {
    
     cout << "  A::func1" << endl; }
	virtual void func2() {
    
     cout << "  A::func2" << endl; }
private:
	int _a;
};
class B :public A {
    
    
public:
	virtual void func1() {
    
     cout << "  B::func1" << endl; }
	virtual void func2() {
    
     cout << "  B::func2" << endl; }
private:
	int _b;
};
class C :public A, public B
{
    
    
public:
	virtual void func1() {
    
     cout << "  B::func1" << endl; }
	virtual void func3() {
    
     cout << "  B::func3" << endl; }
private:
	int _c;
};
int main()
{
    
    
	C c;
	A* a = &c;
	B* b = &c;
	a->func1();
	b->func1();
	return 0;
}

在这里插入图片描述

先看如上代码,a和b调用func1,调的是同一个函数,最终的结果一样,可以认为里面有一个地址被封装过。
我们简单来看看它们各自的反汇编:如图:
在这里插入图片描述

第一个先是call一个地址,这个地址是从虚表取出来的,这个地址就是jmp的指令,jmp指令再jmp一个地址,就来到函数真正的地址。是正常调用。
第二个是,连续jmp了几次,相当于绕了好几次,因为中间有一个关键指令sub,ecx,8:ecx存的是this指针,a和b调的是子类函数,a去调用子类函数,指针没问题,a指向对象的开始,this指针指向子类对象,没有做处理恰好指向子类对象的开始;但b去调用this指针并没有指向对象的开始,所以这个指令就是在修正this指针,减8是减A的大小。谁后继承谁修正。
最终它们走到一同一个地址。

四、抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数,不写实现。包含纯虚函数的类叫做抽象类或接口类,它没有对应的实体,不想让它实例化对象,一个类型在现实中没有对应的实体,就可以把一个类定义为抽象类。
抽象类的特点是不能实例化出对象,派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数的一个意义是强制派生类重写,另外纯虚函数更体现出了接口继承。

class P
{
    
    
public:
virtual void sleep() = 0;
};
class S :public P
{
    
    
public:
virtual void sleep()
{
    
    
cout << "Benz-舒适" << endl;
}
};
class T:public P
{
    
    
public:
virtual void sleep()
{
    
    
cout << "走路" << endl;
}
};
void Test()
{
    
    
P* s1 = new s;
s1->sleep();
P* t1 = new T;
t1->sleep();
}

五、多态问题总结

inline函数可以是虚函数吗?
理论上来说,内联函数会在调用的地方展开,是没有地址的,如果是虚函数,就得放虚表得有地址,而内联函数没有地址就不能成为虚函数。但是实际操作,是可以编译通过的,是因为编译器忽略了inline属性,加了lnline不一定就是内联,内联只是对编译器起建议作用,所以这个函数不再是lnline,这样就可以放到虚表中。如果不符合多态,可能会按内联规则走,如果是多态,因为虚函数要把地址放到虚表里,肯定有地址了,就不能是内联了。实际跑的过程只能是一个属性。
静态成员可以是虚函数吗?
不可以,把静态成员函数放到虚表是不合适的,因为虚表是通过指向的对象去找的,静态成员函数没有this指针,只能用对象类型去访问,但是这样并不能访问虚函数表,就实现不了多态。
构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
如果构造函数实现多态的,调用就要去找虚函数,怎么去找,要有虚函数表指针,而虚函数表指针是在调用构造函数初始化阶段才有的,所以得等构造函数初始化完成之后,也就是实例化对象之后才能调用,但是构造函数的调用是在实例化对象之前就调用了,所以矛盾,不能是虚函数且无意义。
拷贝构造和赋值可以是虚函数吗?
拷贝构造和构造类似,赋值最好不要定义为虚函数,编译器是可以支持的。虚函数是为了完成重写,意思是指向父类调父类,指向子类调子类,多态之后非父即子。如果基类赋值函数被定义为虚函数,不会影响子类赋值操作符的使用,它们的返回值和形参都不一样,如果硬是和基类一样,且赋值函数被定义为虚函数时,派生类不能做到非父即子,它的子反过来要调父,没有意义。
友元函数可以是虚函数吗?
不能,因为它不属于成员函数,无法继承
析构函数可以是虚函数吗?
可以,并且最好把基类的析构函数定义成虚函数,会根据动态对象调用合适的析构函数。可以让delete指向子类的父类指针能够析构子类。如果基类指针指向子类对象时,会发生切片,这个过程是切掉基类没有的那些成员,如果析构函数没有定义为虚函数,当delete基类时,析构的是基类那部分,而子类的并没有析构,如果子类析构有资源释放,就会造成内存泄漏。
对象访问普通函数快还是虚函数更快?
如果是对象,不管是不是虚函数,一样快,因为都是普通调用;如果是父类指针或引用,只带了虚函数,普通函数快,因为这里不管构不构成重写都是多态调用不是普通调用,编译器不想把它识别为普通调用,因为对它的成本太高,需要去判断。所以多态调用时需要到虚函数表中去找虚函数,导致慢。
没有重写的虚函数去哪
只要是虚函数就进虚表,单继承中子类没有重写的虚函数进自己的虚表,多继承中没有重写的虚函数进第一个父类的虚表。
虚函数表和虚基表
虚函数存的是虚函数地址,实现的是多态,指向谁调用谁;虚基表存的是偏移量,解决的是数据冗余和二义性。
重载|覆盖(重写)|隐藏的对比
重载:两个函数在统一作用域,函数名相同,参数不同。
重写/覆盖:两个函数分别在父子类的作用域,函数名、参数、返回值必须相同(协变除外),两个函数必须是虚函数。
重定义/隐藏:两个函数分别在父子类的作用域,函数名相同,父子类的同名函数不构成重写就是重定义。

猜你喜欢

转载自blog.csdn.net/m0_59292239/article/details/130131722