【C++】多态原理剖析


前言

在编程语言和类型论中,多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口。多态类型(英语:polymorphic type)可以将自身所支持的操作套用到其它类型的值上。
计算机程序运行时,相同的消息可能会送给多个不同的类别之对象,而系统可依据对象所属类别,引发对应类别的方法,而有不同的行为。简单来说,所谓多态意指相同的消息给予不同的对象会引发不同的动作。–百度百科



一、预备知识


多态的条件

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
    解释1:因为子类和父类的虚表各自一份,倘若能够通过对象传递的方式同时传递虚表的话,那么父类就可能拿到子类的虚表,这样子就不合理了。
    解释2:有虚函数就有虚函数表,对象当中就会存放一个虚基表指针,通过虚基表指针指向的内容来访问对应的函数。若子类没有重写父类的虚函数内容,则子类也会调用父类的函数。

虚函数是什么?

class Base{
public:
	//虚函数
	virtual void func(){}
};

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

class Base{
public:
	virtual void func()
	{
		cout << "class Base" << endl;
	}
};
class Derive :public Base{
public:
	virtual void func()//重写
	{
		cout << "class Derive" << endl;
	}
};

int main()
{
	Base b;
	Derive d;

	system("pause");
	return 0;
}

上述代码,观察下述的对象模型,我们可以得知子类和父类指向的虚表是不相同的,即使子类没有对父类的虚函数进行重写!,并且子类的func的类域不是父类的了。
在这里插入图片描述


虚函数重写的两个例外:
1. 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class Base{
public:
	virtual Base* func()
	{
		cout << "class Base" << endl;
		return new Base;//无意义,只是演示
	}
};
class Derive :public Base{
public:
	virtual Derive* func()
	{
		cout << "class Derive" << endl;
		return new Derive;
	}
};

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

class Base{
public:
	virtual ~Base()
	{
		cout << "virtual ~Base()" << endl;
	}
};
class Derive :public Base{
public:
	~Derive()//子类的virtual可以不写,默认子类继承父类的虚函数
	{
		cout << "virtual ~Derive()" << endl;
	}
};



二、C++11 override 和 final


如何设计一个无法被继承的类:
1.在父类类名后面加入关键字final

ps:final还可以修饰虚函数,让虚函数不能被重写。在这里插入图片描述
2.父类构造函数私有化或者删除

class Base {
public:
	Base() = delete;//推荐
private:
	Base() //推荐
	{}
};
class Derive :public Base{
public:

};




override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

class Base{
public:
	virtual void func()
	{
		cout << "class Base" << endl;
	}
};
class Derive :public Base{
public:
	virtual void func() override //true
	{
		cout << "class Derive" << endl;
	}
	void func() override //true
	{
		cout << "class Derive" << endl;
	}
	virtual int func() override //err 不是协变
	{
		cout << "class Derive" << endl;
		return 1;
	}
	virtual void func(int) override//err
	{
		cout << "class Derive" << endl;
	}
};




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

在这里插入图片描述



三、抽象类


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

注意:接口类虽然不能定义对象,但是他是可以有成员变量和成员函数的!

对接口类不能定义的理解:
一般像抽象的东西我们现实生活中是没有实体的,所以定义出来也没有意义,一般是抽象的东西具体化之后富有意义之后我们生活才会有实体。

class Base{//接口类
public:
	virtual void func() = 0//纯虚函数
	{
		cout << "class Base" << endl;
	}
	void func(int){}
private:
	int a;
	int b;
};

注意:若是派生类对基类的纯虚函数没有重写,那么子类也是无法定义出对象的。

class Base{//纯虚类
public:
	virtual void func() = 0//纯虚函数
	{
		cout << "class Base" << endl;
	}
	void func(int){}
private:
	int a;
	int b;
};
class Derive :public Base{
public:
	//virtual void func() override //true
	//{
	//	cout << "class Derive" << endl;
	//}
};

int main()
{
	Derive d;
	return 0;
}

总结:
在虚函数后面写上=0,就是称之为纯虚函数,包含纯虚函数的类叫做抽象类(也就做接口类)。
抽象类即没有具体的对应实物,所以他没必要/也不可以实例化出对象,具有纯虚函数的类叫做纯虚类。
子类继承纯虚函数但是没有重写也可以认为子类有纯虚函数不能被实例化!
但是可以创建指针,指向子类的对象。正好实现了多态。


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


四、多态的原理


class Base{
public:
	virtual void func()
	{
		cout << "class Base" << endl;
	}
};
class Derive :public Base{
public:
	virtual void func()
	{
		cout << "class Derive" << endl;
	}
};
void Print(Base& b)
{
	b.func();
}
int main()
{
	Print(Base());
	Print(Derive());
	return 0;
}

结论一: 观察下图,我们可以得知在调用Base对象和Derive的func函数时的过程都是一样的。在这里插入图片描述



观察下面的代码,我们观察它的对象模型。

class Base{
public:
	virtual void func1()
	{
		cout << "class Base" << endl;
	}
	virtual void func2()
	{
		cout << "fun2()" << endl;
	}
	void fun3()
	{

	}
};
class Derive :public Base{
public:
	virtual void func1()
	{
		cout << "class Derive" << endl;
	}
};
void Print(Base& b)
{
	b.func1();
}
int main()
{
	Base b;
	Derive d;
	return 0;
}

结论二: 观察下图,我们可以得知的是父类的普通函数不会出现在父/子的虚函数表当中,并且子类没有重写的虚函数与父类指向的虚函数是相同的
重写之后派生类会覆盖虚表当中父类的那一部分。但由于父类和子类用的不是一张虚表,所以说子类对表的更改不会影响父类。
在这里插入图片描述
并且上图中基类和派生类的虚函数表是不相同的,我们发现func1函数实现了重写,所以d中存放的是Derive::func1,所以虚函数的重写也叫作覆盖,重写是语法层面的叫法,覆盖是原理层的叫法。



对于汇编代码的理解:我们可以得知是在运行之后在动态的在虚函数表当中找的

void Func(Base* b)
{
...
 b->func1();
// 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 
}




对于虚表位置的验证 :

1.平台为vs2013 x86

typedef void(*VFPTR)();
void PrintVFT(VFPTR vft[])
{
	printf("%p\n", vft);
	for (size_t i = 0; vft[i] != nullptr; ++i)
	{
		printf("vft[%d]:%p->", i, vft[i]);
		vft[i]();
	}
	printf("\n");
}
	class Base{
	public:
		virtual void func1()
		{
			cout << "class Base" << endl;
		}
		virtual void func2()
		{
			cout << "fun2()" << endl;
		}
		void fun3()
		{

		}
	};
	int main()
	{
		Base bb;

		int a = 0;
		int* p1 = new int;
		const char* p2 = "hello world";
		auto pf = PrintVFT;
		static int b = 1;

		printf("栈帧变量:%p\n", &a);
		printf("堆变量:%p\n", p1);
		printf("常量区变量:%p\n", p2);
		printf("函数地址变量:%p\n", pf);
		printf("静态区变量:%p\n", &b);
		printf("虚函数表地址:%p\n", *(int*)&bb);

		return 0;
	}

在这里插入图片描述
2.Linux下:
在这里插入图片描述
经过上面两个平台的验证,我们可以发现虚函数表是放在常量区的。
在这里插入图片描述


派生类的虚表生成规则:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。




父子类虚函数表不用写时拷贝的原因:
子类的虚表直接拷贝父类的一份,由于虚函数存在的意义就是想要形成多态,那么子类必定要重写虚函数,而写时拷贝用到的地方是读多写少的场景,所以这里没有用写时拷贝,子类和父类的对象用的就是不同的虚表。
ps:即使子类没有实现重写,也不会父子共用同一份。

一个类只有一份虚表,一个类的所有对象都在使用这一张虚表。在这里插入图片描述

重新反思多态的条件,为什么传对象就无法实现多态呢?
父类对象指向子类虚表是不合理的,子类对象赋值给父类对象的时候不会将虚表指针也一起传递!!


多态调用 vs 普通函数调用
编译时决议:普通函数在编译的时候就确定了调用的地址,最慢也在链接的时候会找到对应的函数。
在这里插入图片描述

运行时决议: 虚函数的要取头四个字节,找到对应的虚函数,再call那个函数,这个过程是在运行的时候完成的。若不满足多态条件就是按照普通函数的调用规则来。
在这里插入图片描述

虚函数表(虚表) vs 虚基表
虚函数存放在代码段当中,只是它的地址放到虚表当中,我们对象存放的是虚表指针,虚表里面放在虚函数的地址,虚表也是存放在代码段当中的,虚函数表里面只放虚函数,普通函数不会放入。
虚基表当中放着的就是8个字节,一个是多态的偏移量,一个是虚继承的基类成员在子类当中的偏移量。


五、打印虚基表


由于监视窗口看不到派生类的增加的派生类
所以这里我们了解一下如何打印虚表。
由于虚函数表在末尾会有个NULL,这个不是规定,但很多编译器都是这样做的。

class Base{
public:
	virtual void func1()
	{
		cout << "Base::func1" << endl;
	}
	virtual void func2()
	{
		cout << "Base::fun2" << endl;
	}
	void fun3()
	{

	}
};
class Derive :public Base{
public:
	virtual void func1()
	{
		cout << "Derive::func1" << endl;
	}
	virtual void func3()
	{
		cout << "Derive::func3" << endl;
	}
};
typedef void(*VFPTR)();
void PrintVFT(void* vft[])
{
	for (int i = 0; vft[i]; ++i)
	{
		VFPTR f = (VFPTR)vft[i];
		f();
	}
}
int main()
{
	Base b;
	Derive d;
	PrintVFT((void**)*(int*)&b);
	PrintVFT((void**)*(int*)&d);
	return 0;
}

PrintVFT( (void**)*(int*)&b);
(int*)&b将变量的地址强转为int*
(void**)* (int*)&b取b对象的头上四字节再强转为void**传参。

注意:
这里要先取b的地址是因为b不能直接强转成int传参。
编译器虚表的NULL有时候会没有加上,所以我们重新编译一下。
Base不能直接转成int,无关类型不支持强转!!!
在这里插入图片描述

typedef void(*VFPTR)();
void PrintVFT(VFPTR vft[])
{
	for (int i = 0; vft[i]; ++i)
	{
		vft[i]();
	}
}
int main()
{
	Base b;
	Derive d;
	PrintVFT((VFPTR*) *(int*)&b);
	PrintVFT((VFPTR*) *(int*)&d);
	return 0;
}

PrintVFT((VFPTR*) *(int*)&b);也可以取头上四字节转换为函数指针的指针。

多态的调用也是类似这个过程,不过他可以直接转成汇编,不会转换成这个代码就是了。


六、多继承下的虚表


多继承当中子类新增的虚函数会放在第一个虚表,也就是第一个声明的继承。
以及要理解虚基表的模型,先继承的在前面。

class A
{
public:
	virtual void func()
	{
		cout << "A:func" << endl;
	}
	int a = 10;
};
class B
{
public:
	virtual void func()
	{
		cout << "B:func" << endl;
	}
	int b = 20;
};
class C
{
public:
	virtual void func()
	{
		cout << "C:func" << endl;
	}
	int c = 30;
};
class D:public A,public B,public C
{
public:
	virtual void func()
	{
		cout << "D:func" << endl;
	}
	virtual void func2()
	{
		cout << "D:func2" << endl;
	}
	int d = 40;
};
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()
{
	D d;
	PrintVTable((VFPTR*)*(int*)&d);
	PrintVTable((VFPTR*)*(int*)((char*)&d + sizeof(A)));
	PrintVTable((VFPTR*)*(int*)((char*)&d + sizeof(A)+sizeof(B)));
	return 0;
}

对于派生类而言,当不考虑虚继承时,虚函数表指针在头上四字节,对象紧跟虚函数表指针其后。
在这里插入图片描述

对于调用的函数相同却地址不同的理解: 两个地址不相同但是调到的函数是一样的。也就是他会在调用之前有一个jmp指令才到最终的地址。 在这里插入图片描述

在这里插入图片描述

验证:根据先前的对象模型,我们分别调用A中的func和B中的func

int main()
{
	D d;
	VFPTR vf = (VFPTR)(*(int*)&d);
	vf();
	VFPTR vf2 = (VFPTR)(*(int*)((char*)&d+sizeof(A)));
	vf2();
	return 0;
}

我们可以发现结果调用的是同一个。
在这里插入图片描述

虚函数表在编译的时候就确定了,而类对象的虚函数指针vptr是在运行阶段确定的,这是实现多态的关键!

赋值兼容规则指的是子类对象的指针给父类指针的这个过程


总结

多态初识告一段落,之后还会更新多态的一些习题!!

一键三连一键三连一键三连一键三连一键三连一键三连一键三连一键三连

评论 22
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

^jhao^

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值