class 关键字内存布局详解(三)

基类,单继承,多继承,虚继承,的内存布局已经前面已经跟大家讲解过了。细心的同学可能会发现,之前代码的析构函数,并非虚析构。这是因为虚析构函数有点特别,我们这里再讲一下虚析构。


#define _CRT_SECURE_NO_WARNINGS
#include <stdlib.h>
#include <stdio.h>
#include <string>
#include <memory>

class CBase
{
public:
	CBase() {
		printf("Init Base\r\n");
		strcpy(m_szText, "null");
	}

	virtual ~CBase() {
		printf("Uninit Base\r\n");
		printf("%s\n", m_szText);
	}

	virtual void SetText(const char* strText) {
		strcpy(m_szText, strText);
	}

	void ShowText() {
		printf("%s\n", m_szText);
	}


private:
	char m_szText[64];
};

class CDerive : public CBase
{
public:
	virtual ~CDerive() {
		printf("Uninit CDerive\r\n");
	}
};


typedef void(__thiscall* set_text_ptr)(void * _this_ptr, const char* strText);
typedef void(__thiscall* show_text_ptr)(void* _this_ptr);
typedef void(__stdcall* init_class)(void* _this_ptr);//注意是 __stdcall

struct TBaseVtbl {
	init_class pfnInit;
	set_text_ptr pfnSetText;
};

struct TBase {
	struct TBaseVtbl* pVtbl;
	char szText[64];
};


int main()
{
	CDerive* pDerive = new CDerive();
	
	CDerive* pBase = new CDerive();
	struct TBase* pBase2 = (struct TBase*)pBase;
	pBase2->pVtbl->pfnInit(pBase2); //这里只会调用 ~CDerive

	delete pDerive;//这里会调用 ~CDerive 和 ~CBase
	delete pBase2;//只会释放内存,不会进行析构

	getchar();
	return 0;
}


通过上面的代码我们可以得出以下结论:

  1. 析构函数的调用约定不是 __thiscall 而是 __stdcall。
  2. 对象销毁时,虚析构函数,是会从派生类一层层往基类方向调用的。但我们直接从虚表调用时,它却只会调用虚表所指向的函数。
  3. delete pDerive (class类型指针)会调用析构函数, delete pBase2 (struct类型指针) 却不会,说明 delete 指令根据对象的不同,动作也会有所不同。

关于第三点,delete 指令根据对象的不同,动作也会有所不同。这个动作时运行时动态执行的动作,还是编译时动态生成了不同的动作呢?其实很简单,一看汇编代码就知道了,如下:

	delete pDerive;//这里会调用 ~CDerive 和 ~CBase
00E161FB  mov         eax,dword ptr [pDerive]  
00E161FE  mov         dword ptr [ebp-128h],eax  
00E16204  cmp         dword ptr [ebp-128h],0  
00E1620B  je          main+172h (0E16232h)  
00E1620D  mov         esi,esp  
00E1620F  push        1  
00E16211  mov         ecx,dword ptr [ebp-128h]  
00E16217  mov         edx,dword ptr [ecx]  
00E16219  mov         ecx,dword ptr [ebp-128h]  
00E1621F  mov         eax,dword ptr [edx]  
00E16221  call        eax  
00E16223  cmp         esi,esp  
00E16225  call        __RTC_CheckEsp (0E112B2h)  
00E1622A  mov         dword ptr [ebp-13Ch],eax  
00E16230  jmp         main+17Ch (0E1623Ch)  
00E16232  mov         dword ptr [ebp-13Ch],0  
	delete pBase2;//只会释放内存,不会进行析构
00E1623C  mov         eax,dword ptr [pBase2]  
00E1623F  mov         dword ptr [ebp-134h],eax  
00E16245  push        44h  
00E16247  mov         ecx,dword ptr [ebp-134h]  
00E1624D  push        ecx  
00E1624E  call        operator delete (0E11087h)  
00E16253  add         esp,8  
00E16256  cmp         dword ptr [ebp-134h],0  
00E1625D  jne         main+1ABh (0E1626Bh)  
00E1625F  mov         dword ptr [ebp-13Ch],0  
00E16269  jmp         main+1BBh (0E1627Bh)  
00E1626B  mov         dword ptr [pBase2],8123h  
00E16272  mov         edx,dword ptr [pBase2]  
00E16275  mov         dword ptr [ebp-13Ch],edx  

或许你不懂汇编,但明显能够看出,同样是 delete 指针,生成的汇编代码却大相径庭。很明显是编译器帮我们识别了数据类型,然后帮 delete 指令生成了不同的代码。

再看下面的伪代码:

int main()
{
	//如果没有声明虚析构函数
	CBase* pDerive = new CDerive();
	delete pDerive;//调用的是 ~CBase

	getchar();
	return 0;
}

通过上面例子可以看出,当存在派生类时,基类声明虚析构函数是非常有必要的,否则某些对象的析构函数无法被调用,有发生内存泄漏的风险。

另外对于虚析构函数和虚函数的区别,虚函数我们已经知道就是覆盖了虚表的函数指针,这个很好理解。而析构函数虽说也是覆盖了函数指针,但是它却会主动调用父类的析构函数!而虚函数是不会调用父类的同名函数的!关于虚析构函数调用父类析构函数的机制,我找了不少资料,据说是跟 delete 指令一样,编译器在派生类的析构函数里面加了额外的代码。
但大家再看看这句代码:

pBase2->pVtbl->pfnInit(pBase2); //这里只会调用 ~CDerive

如果真的生成了额外的代码,那么这段代码也肯定不在派生类析构函数的内部,至于在哪个位置,又或者准确的原理是什么,我暂时没找到相关资料。这个知识点欢迎有了解的同学在留言区留下相关的资料。

再说说虚表指针顺序,虚表指针顺序,大家肉眼可见,就是按代码中虚函数的声明顺序排列的,一般确实如此,但有一个例外。再看代码:

#define _CRT_SECURE_NO_WARNINGS
#include <stdlib.h>
#include <stdio.h>
#include <string>
#include <memory>

class CBase
{
public:
	virtual void SetText(const char* strText) {
		strcpy(m_szText, strText);
	}

	virtual void SetText(int nNum) {
		sprintf(m_szText, "num=%d", nNum);
	}

	void ShowText() {
		printf("%s\n", m_szText);
	}

public:
	CBase() {
		printf("Init Base\r\n");
		strcpy(m_szText, "null");
	}

	virtual ~CBase() {
		printf("Uninit Base\r\n");
		printf("%s\n", m_szText);
	}

private:
	char m_szText[64];
};


typedef void(__thiscall* set_text_ptr)(void * _this_ptr, const char* strText);
typedef void(__thiscall* set_text_ptr2)(void* _this_ptr, int nNum);
typedef void(__thiscall* show_text_ptr)(void* _this_ptr);
typedef void(__stdcall* init_class)(void* _this_ptr);//注意是 __stdcall

struct TBaseVtbl {
	set_text_ptr pfnSetText;
	set_text_ptr2 pfnSetText2;
	init_class pfnInit;
};

struct TBase {
	struct TBaseVtbl* pVtbl;
	char szText[64];
};


int main()
{
	//如果没有声明虚析构函数
	
	CBase* pBase = new CBase();
	TBase* pBase2 = (TBase*)pBase;

	//实际进入:virtual void SetText(int nNum)
	pBase2->pVtbl->pfnSetText(pBase2, "HELLO");

	//实际进入:virtual void SetText(const char* strText)
	pBase2->pVtbl->pfnSetText2(pBase2, 2); //会崩溃

	//不想崩溃换成这样写
	//const char* str = "111222";
	//pBase2->pVtbl->pfnSetText2(pBase2, (int)str);

	delete pBase;//调用的是 ~CBase

	getchar();
	return 0;
}

我们抛开崩溃不谈,只谈虚函数定义顺序,class CBase 的顺序是:

virtual void SetText(const char* strText)virtual void SetText(int nNum)virtual ~CBase()

我们虚表的顺序是:

typedef void(__thiscall* set_text_ptr)(void * _this_ptr, const char* strText);
typedef void(__thiscall* set_text_ptr2)(void* _this_ptr, int nNum);
typedef void(__thiscall* show_text_ptr)(void* _this_ptr);
typedef void(__stdcall* init_class)(void* _this_ptr);//注意是 __stdcall

struct TBaseVtbl {
	set_text_ptr pfnSetText;
	set_text_ptr2 pfnSetText2;
	init_class pfnInit;
};

很明显,从写法上看我们的顺序是没错的,但是实际运行的时候, pfnSetText 和 pfnSetText2 进入的函数却弄反了。这也就说明,同名虚函数,顺序是不确定的,反正C++是没有对此进行规定。
一般情况下,我们使用同名虚函数是没问题的,主要是一些类似于windows-COM 这种技术,它对虚表的依赖很严重,我们必须重视虚函数带来的问题,类似COM这种接口,不要使用同名虚函数,这是最安全的做法。

<完>

class 关键字内存布局详解(二)
class 关键字内存布局详解(一)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值