C++57个入门知识点_33_深入理解虚函数的原理-重点(间接调用:先查虚表地址,再查虚表中的虚函数指针;编译器先取对象的前4个字节地址,再取对应地址下函数指针;查看内存、反汇编的方法;成员函数指针)

上篇C++57个入门知识点_32 初识多态及虚函数-核心(加virtual的函数称为虚函数;某一个函数在父子类有不同实现,运行时对象自行决定调用哪一类实现;没virtual调父类同名函数,否则调子类函数)中我们对虚函数有了一个初步的认识,上篇中我们看到:使用虚函数可以在某一个函数在父类子类有不同的实现,运行时对象自行决定调用哪一个类下的实现的效果就称为多态。具体可以参考上篇第三部分“虚函数和多态”,其中实现了客户端调用函数的写法未发生改变ary[0] = &chs;,但是调用的结果却不同。本篇将会介绍C++实现多态的原理。

比较好的参考博文:C++虚函数详解

总结:

1.使用virtual关键字带来的内存变化:加了virtual会比不加virtual大了四个字节;
2.虚函数原理总结:

  • 虚函数的调用方法是间接调用(先查虚表地址,再查虚表中的虚函数指针):编译器先取对象的前四个字节地址,再取对应地址下的函数指针
  • 增加了虚函数virtual关键字的对象头部4个字节是虚表地址(某些情况:单继承)
  • 原理图如下:
    在这里插入图片描述

1. 从内存角度查看虚函数带来的变化

编译器如何实现客户端调用函数的写法未发生改变,但是调用的结果却不同?

需要从内存的角度分析函数的调用,首先看一下virtual加和不加对内存大小的影响

整体代码:

#include <iostream>

//面向对象:多态
class CPerson
{
public:
	CPerson() {
		m_nType = 0;//普通的人
	}

    virtual void speak() {
		printf("说人话\r\n");
	}

	int m_nType;
};

class CChinese :public CPerson {
public:
	CChinese() {
		m_nType = 1;//中国人
	}
	void speak() {
		printf("说中文\r\n");
	}
};

class CEnglish :public CPerson {
public:
	CEnglish() {
		m_nType = 2;//英国人 
	}

	void speak() {
		printf("说英语\r\n");
	}
};

int main(int argc, char* argv[])
{
	CChinese chs;
	chs.speak();

	CChinese chs2;
	CEnglish eng;

	int nPersonsize = sizeof(CPerson);
	int nChinesesize = sizeof(CChinese);
	int nEnligshsize = sizeof(CEnglish);

	CPerson* ary[3]; //用父类指针指向子类

//把子类指针强转为父类,父类的三个指针调用的都是父类的
	ary[0] = &chs;
	ary[1] = &eng;
	ary[2] = &chs2;
	
	//模拟每个人说话
	for (int i = 0; i < 3; i++)
	{
		printf("%d:", i + 1);
		ary[i]->speak();
	}

	return 0;
}

1.1 不加virtual的内存大小

CPerson父类的函数中不加virtual

 class CPerson 
{ 
...
	 void speak() {
		printf("说人话\r\n");
		}
		int m_nType;
...
};

运行结果:因为三个类都只包含了int数据类型,三个类的大小均为4字节
在这里插入图片描述
查看其对象内存:
在这里插入图片描述

1.2 加virtual的内存大小

 class CPerson 
{ 
...
	 virtual void speak() {
		printf("说人话\r\n");
				}
		int m_nType;
...
};

运行结果:类的大小变为8个字节
在这里插入图片描述
加了virtual会比不加virtual大了四个字节就会使得ary[i]->speak();表达出不同的意思,也就是多态!

1.3 为什么加了virtual会比不加virtual大了四个字节

先看看加了virtual之后多了什么东西,从下图可以看出chs对象地址"0x00bcfba0"下中多出了一个地址"0x00287b3c",并且地址指向的内容里面还包含一个函数地址"0x002813d9"
在这里插入图片描述

  • 查看内存的方法

为了查看指定内存的内容,只需要像下图将相应的地址拖入左侧的内存1从中即可,既可以改变内存地址,又可以改变内容
在这里插入图片描述如图所示调整地址下内存值显示的字节数
由于使用的是32位进行debug,地址长度为4个字节,并设置一行显示4个字节作为显示内存值的最小单位,例如下图中"4c 7b 84 00"代表4个字节,“4c”表示一个字节。
在这里插入图片描述

  • 验证"0x002813d9"CChinese下的函数地址,使用汇编查看
    为了探究这个问题,我们需要从汇编的角度去看,但怎么去查看呢?

查看的方法如下:
在这里插入图片描述
chs.speak();位置设置好断点

...
int main(int argc, char* argv[])
{
	CChinese chs;
	chs.speak();

	CChinese chs2;
	CEnglish eng;
	return 0;
}

程序运行至chs.speak();"ctrl+alt+d"切换到汇编界面即可看到C++代码转化为汇编代码后的形式,在下图CChinese::speakF11跳转。
在这里插入图片描述
F11之后就会跳入指向的函数
在这里插入图片描述
再找一个CChinese对象看看,发现两个指向函数的地址都是一样的,这个时候是不是感觉到虚函数作用的原理了?

简单一些总结就是:间接调用
在这里插入图片描述
上面代码中CPerson* ary[3]; ary[0] = &chs;ary[0] 虽然是父类指针,但是指向的仍然是子类对象。

上述的过程可以总结为:编译器先取对象的前四个字节地址,再取对应地址下的函数指针

2. 虚函数原理总结

  • 虚函数的调用方法是间接调用(先查虚表地址,再查虚表中的虚函数指针):编译器先取对象的前四个字节地址,再取对应地址下的函数指针
  • 增加了虚函数virtual关键字的对象头部4个字节是虚表地址(某些情况:单继承)
  • 原理图如下:
    在这里插入图片描述

3. 模拟上述虚函数过程

根据上面的结论,可以自己去写代码,实现同样的效果

  • 获取对象首四字节的地址
void* pObjtAddr = ary[0];//获取对象地址
unsigned int pAddr = *(unsigned int*)pObjtAddr;//获取对象首四字节的地址

在这里插入图片描述

  • 获取对象首四字节所指向的函数指针的地址
#include <iostream>

//面向对象:多态
class CPerson
{
public:
	CPerson() {
		m_nType = 0;//普通的人
	}

    virtual void speak() {
		printf("说人话\r\n");
	}

	int m_nType;
};

class CChinese :public CPerson {
public:
	CChinese() {
		m_nType = 1;//中国人
	}
	void speak() {
		printf("说中文\r\n");
	}
};

class CEnglish :public CPerson {
public:
	CEnglish() {
		m_nType = 2;//英国人 
	}

	void speak() {
		printf("说英语\r\n");
	}
};

//定义一个类的成员函数指针的类型
typedef void(CPerson::*PFN_SPEAK)();
//Union中的所有成员共享同一个内存空间,它们的起始地址都是相同的:同一地址的不同解释
//MyFuncAddr共用体既可以解释成n也可以解释成函数指针pfn
//n的值被解释为函数指针,指针即为地址,按照n指向该地址,再按照函数指针进行解释
union MyFuncAddr {
	unsigned int n;
	PFN_SPEAK pfn;
};

int main(int argc, char* argv[])
{
	CChinese chs;

	CChinese chs2;
	CEnglish eng;

	int nPersonsize = sizeof(CPerson);
	int nChinesesize = sizeof(CChinese);
	int nEnligshsize = sizeof(CEnglish);

	CPerson* ary[3]; //用父类指针指向子类

//把子类指针强转为父类,父类的三个指针调用的都是父类的
	ary[0] = &chs;
	ary[1] = &eng;
	ary[2] = &chs2;
	
	//模拟每个人说话
	for (int i = 0; i < 3; i++)
	{
		printf("%d:", i + 1);
		//ary[i]->speak();

		void* pObjtAddr = ary[i];//获取对象地址
		unsigned int pAddr = *(unsigned int*)pObjtAddr;//获取对象首四字节的地址
		MyFuncAddr nFunAddr;
		nFunAddr.n = *(unsigned int*)pAddr;//获取对象首四字节所指向的函数指针的地址
		//Union中的所有成员共享同一个内存空间,它们的起始地址都是相同的:同一地址的不同解释
		//n的值为什么和函数地址一致?
		//为什么此步就可以令pfn的地址指向对应的函数指针?与union有关,见下面的解释?
		(ary[i]->*nFunAddr.pfn)(); 
	}

	return 0;
}

运行结果:
在这里插入图片描述

在这里插入图片描述
上述代码的以下部分

//定义一个类的成员函数指针的类型
typedef void(CPerson::*PFN_SPEAK)();
union MyFuncAddr {
	unsigned int n;
	PFN_SPEAK pfn;
};
...
		void* pObjtAddr = ary[i];//获取对象地址
		unsigned int pAddr = *(unsigned int*)pObjtAddr;//获取对象首四字节的地址
		MyFuncAddr nFunAddr;
		nFunAddr.n = *(unsigned int*)pAddr;//获取对象首四字节所指向的函数指针的地址
		(ary[i]->*nFunAddr.pfn)();

可以参考以下内容理解:

(1)C++57个入门知识点_18_ 类的大小+成员函数性质+this指针(类大小由成员变量决定;类对象数据独立,成员函数共用;this指针:谁创建指向谁,谁使用成员函数指向谁;类成员函数指针写法及调用)的第三部分“类的成员函数指针写法及调用”进行理解。

(2)关于上面提到的n的值为什么和函数地址一致?的问题,解释如下:

union本质上是把同一个内存地址存放的数据做不同的解释。 数据本身都是存放在该地址空间的特定位数的0和1而已。

#include <iostream>
using namespace std;

union {
	long a;
	long b;
} x;


int main()
{
	x.a = 1;
}

运行结果:
在这里插入图片描述
union x申请一个地址空间,四个字节,a 把同一个地址空间解释为存放了一个长整型,b也把这个地址空间解释为存放了一个长整型数。所以假设你用a修改了这个地址空间的数据,那么你用b 去看这个地址,你会发现存放了a修改的数据。这是因为a、b都把这个地址解释为整数。

仿照上面存在疑问的代码,当把union x中的另一个成员变为一个函数指针,a的值就等于pTest,即函数地址为a,而pTest又会按照函数指针解释a的值,即对应了函数,这样就解释了上面的疑问。简单来说就是:union中成员共用内存和内存中的数据,但是会根据自己的数据类型数据进行解释

#include <iostream>
using namespace std;

//将函数指针定义为一种类型,可以类似普通类型int等进行使用
typedef void(*PTEST)();
union {
	long a;
	PTEST pTest;
} x;

void test()
{
	int n = 0;
}

int main()
{
	x.a = 4592916;
}

运行结果:
在这里插入图片描述

(3)关于union的扩展:只关心虚函数的可不看

union{
long a;
float b;
} x;

假设 float是 4字节, 那么同一个空间,a 解释为存放的是 整数,b解释为存放的是浮点数。由于浮点数的存放格式和整数不一样。所以通常我们会认为 通过a修改这个内存之后,b读取数据就变得没有太大意义。b修改内存这个,用a读取可以用来审查浮点数存储格式,就是比如说第一位是符号位,第二到第八(可能记错了)是指数位等等。

这种格式完全不一样的数作为union其实很少见。常见是这种

union data{
    long long a;
    struct{
        int low;
        int high;
    };
} mm;

这里union mm申请了一个八字节地址空间,a把这个地址空间解释为一个八位长整数,而struct解释为里面连续存放了两个4字节整数。 比如我们修改low可以认为修改了八位长整数a的一半部分。 当然对于具体是那一半又跟硬件有关了(这也是为什么union不常用原因把,可移植性差。),涉及到 内存里面数据存好的字节序(Endianness)。我们常用的个人pc x86系列都是小端序(little-endian),就是 数据的最低位存放在内存低地址。
我们举个简单例子,比如说

a = 0x0123456789abcdef

那么在这八字节内存中数据是这样存好的(小端序为例,其他序可能存放格式不一样)

1111 1110 1101 1100 1011 1010 1001 1000 0111 0110 0101 0100 0011 0010 0001 0000

struct把这八个字节解释为 两个4字节整数low和high
那么low对应的前4个字节就是

1111 1110 1101 1100 1011 1010 1001 1000

high对应的后四个字节

0111 0110 0101 0100 0011 0010 0001 0000

那我们如果用low 和high读取数据就应该会吧这个内存里面的01解释为对应的long整数

a = 0x89abcdef;

b=0x1234567

所以 union 只是复用同一片内存区,并做不同的解释。对于数据格式完全不同的解释,我们通常会认为其他格式修改之后,再读取就变得毫无意义,就是你理解的覆盖。
在这里插入图片描述

上面的模拟只是多态的精华部分,下一篇将会完全使用C语言来模拟多态。

4.学习视频地址:C++57个入门知识点_33_深入理解虚函数的原理-重点

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

十月旧城

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

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

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

打赏作者

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

抵扣说明:

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

余额充值