C++ 虚表与虚析构

在C++中,多态性的实现和联编(也称绑定)这一概念有关。一个源程序经过编译、链接,成为可执行文件的过程是把可执行代码联编(或称装配)在一起的过程。其中在运行之前就完成的联编成为静态联编(前期联编);而在程序运行之时才完成的联编叫动态联编(后期联编)。

静态联编支持的多态性称为编译时多态性(静态多态性)。在C++中,编译时多态性是通过函数重载和模板实现的。利用函数重载机制,在调用同名函数时,编译系统会根据实参的具体情况确定索要调用的是哪个函数。

动态联编所支持的多态性称为运行时多态(动态多态)。在C++中,运行时多态性是通过虚函数来实现的。

一个动态联编的例子:
场景:码农小A为银行后天做开发,需要设计两个类,一个是没有透支的类,一个是透支的类,两个类都有一个展示数据的功能show,但透支的类会展示透支的数据。会计大妈业务繁忙,需要可以查看上万人的账户情况,想让会计通过小A开发的软件简便去查看数据,只需要输入1查看没有透支的账户,输入2查看透支的账户。
解决:因为透支类成员包含未透支类的成员,且有额外的透支金额等成员,故透支类继承未透支的类,将show在基类中声明为虚,在各自类中定义其功能。定义一个指向基类的指针数组同时管理未透支的类和透支的类,其中每个指针指向一个账户。
用for循环遍历整个指针数组,当会计大妈输入1时将当前指针指向未透支的类,输入2时指向透支的类,每次输入后根据指向的对象类型立即调用对应的show函数。
分析:程序运行前编译器不知道指针指向什么类型的成员,即不知道会计大妈想看透支的类还是未透支的类。因此编译器在程序运行时根据用户的输入选择正确的虚方法,这就是动态联编。
评价:动态联编虽然给予了更多的灵活性,但相较于静态联编增加了额外的开销,因此只将那些预期被重新定义的方法声明为虚的。
01,动态多态性的实现
声明虚函数只用在父类对应函数处声明为虚,子类中相应的函数自动变成虚的

#include<iostream>
using namespace std;

class father
{
public:
	virtual void show();
};
class son:public father
{
public:
 void show();
};
void father::show()
{
	cout << "father" << endl;
}
void son::show()
{
	cout << "son" << endl;
}
int main()
{
	father f1;
	father* pt = &f1;
	pt->show();
	son s1;
	pt = &s1;
	pt->show();
}

在这里插入图片描述
可以看到,虽然pt为指向father类型的指针,但调用的虚函数所属的类是根据当前pt指向的类决定的

2.为什么C++ 中空类的大小是1个字节?

1、对于结构体和空类大小是 1个字节 这个问题,首先这是一个C++问题,在C语言下空结构体大小为0 (当然这是编译器相关的)。这里的空类和空结构体是指类或结构体中没有任何成员。

2、在C++下,空类和空结构体的大小是1(编译器相关),这是为什么呢?为什么不是0?

3、这是因为,C++标准中规定,“no object shall have the same address in memory as any other variable” ,就是任何不同的对象不能拥有相同的内存地址。 如果空类大小为0,若我们声明一个这个类的对象数组,那么数组中的每个对象都拥有了相同的地址,这显然是违背标准的。

3.查看空类大小,然后添加虚函数再查看
定义一个空类(没有显式的任何成员)

class kong
{

};
int main()
{
	kong k1;
	cout << sizeof(k1)<<endl;
	cout << sizeof(kong)<<endl;
}

在这里插入图片描述
可以看到类与其实例化后的对象所占空间大小一样,都为一个字节
为其添加一个非虚函数,并使用同样的main函数:

class kong
{
	void kong1() {};
};

在这里插入图片描述
结果也是如此
添加虚函数:

class kong
{
	void kong1() {};
	virtual void kongv() {};
};

在这里插入图片描述
添加int型(64位系统下为4字节)数据成员:

class kong
{
	void kong1() {};
	virtual void kongv() {};
	int a;
};

在这里插入图片描述
由此我们可以猜想类的内存模型为虚函数表指针与数据成员:
在这里插入图片描述
其中vtptr占了四个字节,i为其数据成员

4.验证内存模型
先为father类添加一个数据成员a和一个虚函数show2()

class father
{
public:
	virtual void show1();
	virtual void show2();
	int a=1;
};
void father::show2()
{
	cout << "father2" << endl;
}
int main()
{
	father f1;
	father* pt = &f1;
	pt->show1();
	cout << *(unsigned int*)(&f1) << endl;
	father f2;
	cout << *(unsigned int*)(&f2) << endl;
	cout << *((unsigned int*)(&f2)+1) << endl;	
}

(&f1)为取内存模型的地址,再解引用即是取其中第一个成员,+1再解引用就是取第二个成员
f1内存模型中第一个成员,即虚函数表指针,显示出来的即为虚表的首地址
在这里插入图片描述
可以看到,同一个类的不同实例化的对象,其虚表的地址一样

int main()
{
	father f1;
	father* pt = &f1;
	cout << (unsigned int*)(&f1) << endl;
	father f2;
	cout << (unsigned int*)(&f2) << endl;
}

在这里插入图片描述
可以看到,即使是同一个类的对象,他们模型存放的地址也不同(00EFFB74和00EFFB58),但其中放的指向虚表的指针是同一个(15571964)(画图时重新跑了代码,和上面贴出的运行结果略有不同,但原理一样)
由此我们得出结论,虚函数表指针与对象一一对应,虚表与类一一对应

示意图如下:
在这里插入图片描述
5.查看虚函数表内存模型
由于void类型函数的地址无法转换并打印,因此先将上述show1,show2换成int型的
并在父类中添加虚函数show2(),并为其增加一个子类son:

class father
{
public:
	virtual int show1();
	virtual int show2();
	int a=9;
};
class son:public father
{
public:
	virtual int show1();
	
};
int main()
{
	father f1;
	father* pt = &f1;
	son s1;
	cout << "f1内存模型地址"<<(unsigned int*)(&f1) << endl;
	cout << "s1内存模型地址" << (unsigned int*)(&s1) << endl;
	cout <<"father类虚表地址"<< *(unsigned int*)(&f1) << endl;
	cout << "son类虚表地址" << *(unsigned int*)(&s1) << endl;
	cout << "通过father类虚函数表查看father类中show1()函数地址" << *(unsigned int*)*(unsigned int*)(&f1)<< endl;
	cout << "通过father类虚函数表查看father类中show2()函数地址"<<*(unsigned int*)(*(unsigned int*)(&f1)+1) << endl;
	cout << "通过son类虚函数表查看father类中show1()函数地址" << *(unsigned int*)*(unsigned int*)(&s1) << endl;
	cout << "通过son类虚函数表查看father类中show2()函数地址" << *(unsigned int*)(*(unsigned int*)(&s1)+1) << endl;
}

在这里插入图片描述

可以通过访问虚函数表的第二个元素查看的父类和派生类对象的虚函数表访问的同一个虚函数地址相同。
这是因为在派生类son中没有重新定义虚函数show2(),但虚表中需要保存该对象的所有虚函数,故将show2的原始版本保存在表中。
示意图如下:
在这里插入图片描述

6.虚函数的使用
再回头分析虚函数的使用

int main()
{
	father f1;
	father* pt = &f1;
	pt->show();
	son s1;
	pt = &s1;
	pt->show();
}

pt指向f1时,此时调用虚函数show1()将先通过f1的虚函数表指针找到虚函数表,再通过虚函数表的show1()的地址找到Father类的show1
在这里插入图片描述

当pt指向s1时,此时的虚函数表为son类的虚函数表,将调用son的show1();

补充:
类的对象的内存中只保存虚函数表指针与非静态成员变量,类的普通成员函数不保存的类的对象的内存块中,而是分开存储的。
C++ 普通成员函数本质上是一个包含隐式形参(this指针)的普通函数,成员函数的地址,在编译期就已确定。对象调用成员函数时,编译器可以确定这些函数的地址,并通过传入this指针和其他参数,完成函数的调用,所以类中就没有必要存储成员函数的信息。
比如:A.test() ,A对象调用其成员函数test()。事实上可以理解成 test(&A)。
当通过指针来调用成员函数时,若成员函数为普通成员函数,那么成员函数的地址在编译期间就会确定为是该指针类型对应的成员函数的地址,在调用时会再将指针类型对应的this指针传给该成员函数;若是虚函数的话则会在该指针指向的内存中的虚函数表中找到要使用的虚函数。

CPU眼里的虚函数
在这里插入图片描述
可以看到调用普通成员函数时,在汇编阶段就能确定要call的函数的地址,如果是调用虚函数的话会增强三行,这三行的意思是查找虚函数表中虚函数的地址,放在rdx寄存器中,在调用时将调用rdx寄存器中的值,而这是在运行时才会确定的,即运行时多态

rdx寄存器:
在这里插入图片描述

7.虚析构

#include<iostream>
using namespace std;
class parent
{
public:
	parent()
	{
		cout << "父类构造" << endl;
	}
		~parent()
	{
		cout << "父类析构" << endl;
	}
};
class son:public parent
{
public:
	son()
	{
		cout << "子类构造" << endl;
	}
		~son()
	{
		cout << "子类析构" << endl;
	}
};
int main()
{
	son s1;
}

当直接通过实例化构造时,调用顺序如下:
在这里插入图片描述
不会出现问题
当通过new一个子类对象动态分配内存时

int main()
{
	son* s1 = new son;
}

将不会自动调用析构函数,将会造成内存泄漏:
在这里插入图片描述
因此需要手动删除

int main()
{
	son* s1 = new son;
	delete s1;
}

可以看到没啥问题
在这里插入图片描述

若是通过父类指针来new

int main()
{
	parent* p1 = new son;
	delete p1;
}

在这里插入图片描述
可以看到没有调用子类的析构函数,
这是因为delete p1时调用析构函数的类取决于p1的类型,直接调用父类析构,子类比父类多的东西没有删除,将造成内存泄漏

为了解决这个父类指针动态开辟内存后删除该指针会造成内存泄漏这个问题,引入虚析构

#include<iostream>
using namespace std;
class parent
{
public:
	parent()
	{
		cout << "父类构造" << endl;
	}
	virtual ~parent()
	{
		cout << "父类析构" << endl;
	}

};
class son:public parent
{
public:
	son()
	{
		cout << "子类构造" << endl;
	}
	~son()
	{
		cout << "子类析构" << endl;
	}
};
int main()
{
	//son* s1 = new son;
	parent* p1 = new son;
	delete p1;
}

将不会造成内存泄漏
在这里插入图片描述
这是因为将析构函数声明为虚函数后,析构函数会放到虚函数表中
示意图如下:(内存模型用的上面例子的,修改麻烦就没有修改)
在这里插入图片描述
可以看到即使p1的类型的指向parent指针,但他指向了子类对象,将根据子类对象的虚函数表指针找到子类的虚函数表,调用子类析构,随后调用父类析构,不会造成内存泄漏

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值