深入剖析C++实现多态的原理

前言

要理解C++的多态,必须先学会使用多态:
多态的基本使用,我总结一篇文章:有需要可以看看
【传送门】:谈谈C++多态的基本使用和总结

测试平台:vs2013 32位;

并且都是单继承,没有分析多继承,原因是:多继承较为复杂,多继承还有虚继承再加虚表,对象模型比较复杂;实际工程运用可能相对比较少,由于笔者精力不够充足,所以不打算分析多继承体系下有虚函数的对象模型了;

有兴趣可以看看陈硕大佬的两篇文章,都分析了多继承体系下的对象模型;并且总结的也非常棒;

【传送门】C++ 对象的内存布局
【传送门】C++ 虚函数表解析


以下的分析是我观察调试程序观察的结果!谈谈我对多态的理解


1. 有虚函数的类对象模型

class Base {
public:
	Base(){
		cout << "Base::Base()的构造函数调用" << endl;
	}
	virtual void fun() { 
		cout << "Base::fun()被调用" << endl;
	}
private:
	int _b;
};
int main(){
	Base b;
	return 0;
}

通过监视窗口我们发现:有了虚函数,会在类的对象增加多一个指针,该指针就是虚函数指针_vfptr;
并且该虚函数指针的位置,是在类的最开始位置;
该虚函数指向一个虚表,虚表本质就是函数指针数组,虚表里面存放着该对象的虚函数的地址
目前该对象 b只有一个虚函数fun,所以说,虚表的第一个元素位置就是fun函数的地址
在这里插入图片描述


我们可以通过vs2013的开发人员命令行工具可以看得更清楚它的对象模型:
在这里插入图片描述


2. 派生类继承有虚函数的基类的对象模型

2.1. 无虚函数派生类继承有虚函数基类的对象模型

我们首先看一个派生类,没有任何成员,也就是不重写基类虚函数,并且派生类自己没有添加自己的虚函数,而是直接继承有虚函数的基类,看看其对象模型的样子:

class Base {
public:
	Base(){
		cout << "Base::Base()的构造函数调用" << endl;
	}
	virtual void fun() { 
		cout << "Base::fun()被调用" << endl;
	}
private:
	int _b;
};
class Derive :public Base{

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


观察b的对象模型;
在这里插入图片描述


通过观察子类的对象模型:
我们首先得知道,子类对象有父类虚函数指针,和成员我们可以理解,因为这是继承体系中,本来就会把父类的东西继承到子类中;
但我们有两个迫切想知道的问题:
第一:子类中的虚函数指针,是否和父类的虚函数指针是一样的地址呢?
第二:子类的虚函数表的内容是否和父类虚函数表的内容一致的呢?

为了回答第一个问题:我们观察父类的对象模型和子类的对象模型进行对比:


在这里插入图片描述


通过观察:我们得知:子类虽然继承了父类,但是但是子类的虚表指针,却和父类的虚表指针值不一样!!! 这和我们平时的认知有点偏差,因为我们平时知道,子类继承父类,是完完全全的照搬父类的成员到子类中的,而如今却得知,子类继承父类时候,不可以把父类的虚表指针以同样的值继承下来,而只是拷贝了一份父类的虚表指针给子类;

所以很重要的结论是:
子类继承父类时候,只是继承父类的虚表指针的拷贝,并不是继承父类虚表指针一样的值;


第二问题:子类的虚函数表的内容是否和父类虚函数表的内容一致的呢?

通过观察,我们发现,子类的虚函数表里面存放的虚函数,是和父类的虚函数一样的;(这也是在子类没有重写父类虚函数的前提下)因为子类会完完全全的照搬父类的虚函数表到子类中虚函数表中(注意这里我说是照搬到子类虚函数表中,意思是父类虚函数表和子类虚函数表本身就是两个不一样的表,子类虚函数表可以有其他的虚函数,但是它一定会包含父类的虚函数,所以说:子类虚函数表和父类虚函数表关系可以说是包含关系:子类包含父类)

所以在子类没有重写父类的虚函数时候,子类的虚函数表和父类是完全一样的(这是在子类没有自己的虚函数情况下)


2.2. 有虚函数派生类继承有虚函数的基类对象模型

上面说的是派生类没有重写任何父类虚函数,并且派生类没有自己虚函数的派生类对象模型;

这次我们试着在派生类重写父类虚函数,并且派生类有自己的虚函数,看看对象模型又是什么模样;

class Base {
public:
	Base(){
		cout << "Base::Base()的构造函数调用" << endl;
	}
	virtual void fun() { 
		cout << "Base::fun()被调用" << endl;
	}
private:
	int _b;
};
class Derive :public Base{
public:
	virtual void fun(){
		cout << "Derive::fun()被调用" << endl;
	}
	virtual void fun1(){
		cout << "Derive::fun1()被调用" << endl;

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

在这里插入图片描述
通过调试观察父类和子类的对象模型,我们发现:
在派生类中的对象模型里:
虚表发生了变化,里面的虚表不再是和父类虚表的内容一致了,当子类有了重写父类的虚函数,子类在自己的虚表中用重写的虚函数去覆盖原来父类的虚函数;
并且假如子类有自己的虚函数(这个虚函数不是重写父类的虚函数),子类的虚函数也会加入到子类虚表中的后面的位置;


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


注意:在vs2013的监视窗口,可能会优化,把派生类自己的虚函数地址,没有放到虚表中:如下图,
实际上是有的只不过被vs2013的窗口不显示
在这里插入图片描述


3. 多态原理剖析

有了上面的对象模型知识的储配,我们就来开始剖析多态的原理;
我们知道要构成多态必定要满足两个条件:
在继承体系中:
对于使用者来说:父类指针或引用调用虚函数;
对于设计类的人来说:子类必须重写父类的虚函数;


好了我们就按照这个多态条件,来写一段多态的代码:

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

这段代码我们都知道结果:运行时候,发生多态,根据传入的对象是什么,去调用谁的虚函数;
我们从对象模型原理去看看这个问题:
在这里插入图片描述
父类引用调用虚函数BuyTicket()的过程:
当我传递给父类引用Person& p 是 父类对象Mike时候,那么p.BuyTicket()就是去父类Person的对象Mike中找到虚函数指针,通过虚函数指针,找到虚函数的地址,然后就调用成功;

当我传递给父类引用Person& p 是 子类对象Johnson时候,那么p.BuyTicket()就是去子类Student的对象Johnson中找到虚函数指针,通过虚函数指针,找到虚函数的地址,然后就调用成功;


你凭什么说这是发生了多态调用,而不是编译时候就确定了地址,直接调用这虚函数的呢?
我们可以通过汇编观察:
在汇编代码中,我们发现,调用这p.BuyTicket()这句代码时候,是call eax,这说明什么意思:因为eax寄存器是变量,变量只有在运行时候才会分配地址,所以说call eax只有在运行时候才会发生;这个说明多态铁定是运行时候才确定要调用哪个函数的;
在这里插入图片描述


假如你还是不信,我可以给你看不发生多态时候,是如何调用函数的
在这里插入图片描述
父类对象直接调用虚函数函数,不是父类指针或者引用调用虚函数,这肯定不发生多态对吧!
在不发生多态时候,调用函数,也就是直接把地址写死了,根本不需要在运行时候,就知道函数的地址了,编译时候就确定要调用谁了;


总结:一句话:
多态的原理:基类的指针或者引用指向谁就去谁的虚函数表中找到对应的虚函数进行调用;


4. 汇编代码分析多态的过程

分析汇编代码中,多态时候是如何找到对应的虚函数的;

// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p)
{
...
p->BuyTicket();
// 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
}

分析没发生多态时候,调用虚构函数的汇编代码:

int main()
{
...
// 首先BuyTicket虽然是虚函数,但是Mike是对象,不满足多态的条件,
//所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
Mike.BuyTicket();
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
...
}

5. 有关多态的常见几个问题

  1. 问题1:同一个类的不同对象的虚函数指针是否一样?虚函数表是否一样?;

是一样的。它们的对象的虚函数指针都指向同一个虚表;虚函数指针都是一样的

这都是可以验证的,只要我们多创建几个同一个类不同对象,观察它们对象的模型就可以知道:
在这里插入图片描述


  1. 虚表在哪个阶段生成虚表存放在哪?

虚函数我们知道存放在虚表,但是虚表存放在哪呢?其实存放在字符常量区(vs验证得到的结果);
虚表在编译阶段就形成了;

我们依旧可以通过代码验证:只要打印出代码区,字符常量区,数据段区,栈区,堆区,我们在打印虚函数的地址,就可以观察,虚函数地址靠近哪个区就在哪个区存放着虚表了;
我就不验证了有兴趣可以试一试;


  1. 在发生多态时候,虚函数被private修饰了,还可以被调用吗?

答案是可以的,当该函数成为虚函数时候,该虚函数地址是被放入到了虚表中,当发生多态时候,是去虚函数表中找到该虚函数的,并不受访问限定符private限制;
假如没有发生多态,也就是不是父类指针引用调用该虚函数时候,是对象调用该虚函数时候,那么就会编译失败,因为此时会受访问限定符的限定;

验证也是可以的:
在这里插入图片描述


  1. 虚表指针什么时候被初始化?

虚表指针在构造函数的初始化列表初始化!

我们也可以验证,只要我们在有虚函数的类定义一个构造函数,然后再创建一个该类的对象,通过vs2013的窗口监视,调试可以看到虚函数指针,只有构造函数被调用时候,才会被初始化。(过于简单,我就不验证了,你们可以试一试)


  1. 静态成员函数可以为虚函数嘛?

不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。


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

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始 化的


6. 打印虚表的内容

我们可以再vs2013尝试打印虚表的内容,由于vs2013编译器会给每个类的最后存放一个nullptr,所以我们可以通过遍历虚表的方式,打印虚表的内容;


打印虚表的思路:

  1. 拿出虚表指针
  2. 遍历虚表指针的内容

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;
};
	int main(){
		Base b;
		Derive d;	
		return 0;
	}

我们通过vs2013的监视窗口查看一下 基类和派生类的对象模型:
在这里插入图片描述


观察下图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,
也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数。


typedef void(*VFPTR) (); //定义一个函数指针,这里是本类中的虚函数指针类型
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
	printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
	//由于该地址是虚函数的地址,那么通过函数指针f去调用一定能够成功
	//进一步验证这是虚函数表的虚函数指针
	VFPTR f = vTable[i];
	f();
}
	cout << endl;
}
int main()
{
	Base b;
	Derive d;
// 思路:取出b、d对象的头4bytes,就是虚表的指针,
//前面我们说了虚函数表本质是一个存虚函数指针的
//指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个void**的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有
//放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
	VFPTR* vTableb = (VFPTR*)(*(void**)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(void**)&d);
	PrintVTable(vTabled);
	return 0;
}

结果如下:

在这里插入图片描述

  • 7
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

呋喃吖

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

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

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

打赏作者

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

抵扣说明:

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

余额充值