基类,单继承,多继承,虚继承,的内存布局已经前面已经跟大家讲解过了。细心的同学可能会发现,之前代码的析构函数,并非虚析构。这是因为虚析构函数有点特别,我们这里再讲一下虚析构。
#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;
}
通过上面的代码我们可以得出以下结论:
- 析构函数的调用约定不是 __thiscall 而是 __stdcall。
- 对象销毁时,虚析构函数,是会从派生类一层层往基类方向调用的。但我们直接从虚表调用时,它却只会调用虚表所指向的函数。
- 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这种接口,不要使用同名虚函数,这是最安全的做法。
<完>