我们先通过一段代码来理解继承的底层实现。
class CBase {
public:
CBase() {};
~CBase() {};
void SetNumber(int nNum) { nNumber = nNum; };
public:
int nNumber;
};
class CChild : CBase {
public:
void ShowNumber(int nNum) {
SetNumber(nNum);
nNumberChild = nNum + 1;
printf("%d\n", nNumber);
}
public:
int nNumberChild;
};
int main()
{
CChild cChild;
cChild.ShowNumber(23);
}
上面的代码中子类虽然没有写构造函数和析构函数,但是编译器还是自动生成了它们,子类构造函数、析构函数和父类的构造函数、析构函数调用顺序如下:
父类构造函数 -> 子类构造函数 -> 子类析构函数 -> 父类析构函数
我们关注的重点并不在这里,而是子类对象和父类对象的关系。
走进ShowNumber函数:
......
0099198D mov eax,dword ptr [ebp+8] ;参数nNum
00991990 push eax ;参数压栈
00991991 mov ecx,dword ptr [ebp-8] ;获取this指针
00991994 call 0099102D ;调用父类的SetNumber
}
......
在子类调用父类函数时,直接传递了子类的this指针,我们走进这个SetNumber :
......
0099192D mov eax,dword ptr [ebp-8] ;获取this指针
00991930 mov ecx,dword ptr [ebp+8] ;取出nNum的值
00991933 mov dword ptr [eax],ecx ;将nNum赋值到this指针的前4个字节也就是代码中的nNumber变量
......
执行结束回到ShowNumber继续执行
00991994 call 0099102D ;调用父类的SetNumber
00991999 mov eax,dword ptr [ebp+8] ;取出参数nNum
0099199C add eax,1 ;临时nNum + 1
0099199F mov ecx,dword ptr [ebp-8] ;获取this指针
009919A2 mov dword ptr [ecx+4],eax ;赋值到nNumberChild
009919A5 mov eax,dword ptr [ebp-8] ;取出this指针
009919A8 mov ecx,dword ptr [eax] ;取出nNumber
009919AA push ecx ;压栈nNumber
009919AB push 998B30h ;压栈字符串
009919B0 call 00991050 ;调用printf
009919B5 add esp,8
由此我们看出父类的nNumber赋值到this的前4个字节,而子类的nNumberChild赋值到this的第四个字节开始的后面四个字节。
那么此时this的内存结构如下图:
由此,我们可以总结出,父类对象在子类对象开始处,那么将上例中的CChild的类修改为下面的样子,则他们的内存结构时完全一样的。
class CChild {
public:
void ShowNumber(int nNum) {
SetNumber(nNum);
nNumberChild = nNum + 1;
printf("%d\n", nNumber);
}
public:
CBase cBase;
int nNumberChild;
};
这种内存结构的优势是什么?
很明显,子类对象调用父类的函数,直接传递子类的对象地址就可以了,那么子类对象指针可以强制转换为父类对象指针来使用,反之则不行。
------------------->分割线
再来聊聊多态,上代码:
class cBase {
public:
cBase() {};
virtual ~cBase() {};
virtual void Print() { printf("I am cBase\n"); };
};
class cChild0 : cBase{
public:
cChild0() {};
virtual ~cChild0() {};
virtual void Print() { printf("I am cChild0\n"); };
};
class cChild1 : cBase{
public:
cChild1() {};
virtual ~cChild1() {};
virtual void Print() { printf("I am cChild1\n"); };
};
void GoPrint(cBase* pBase)
{
pBase->Print();
}
void main()
{
cChild0 cCCHild0;
cChild1 cCCHild1;
GoPrint((cBase*)&cCCHild0);
GoPrint((cBase*)&cCCHild1);
}
先来看看输出:
是不是意料之中的结果?
来看看内部实现吧,先从cChild0的构造函数开始吧:
......
00D7183F pop ecx
00D71840 mov dword ptr [this],ecx
00D7184D mov ecx,dword ptr [this] ;以上为this指针操作
00D71850 call cBase::cBase (0D713EDh) ;调用父类构造函数
00D71855 mov eax,dword ptr [this] ;取出this指针
00D71858 mov dword ptr [eax],offset cChild0::`vftable' (0D78B54h) ;虚表赋值
00D7185E mov eax,dword ptr [this] ;返回this指针
......
首先调用了父类的构造函数,然后赋值虚表为本类(cChild0)的虚表。
走进cBase的构造函数:
......
00D717DF pop ecx
00D717E0 mov dword ptr [this],ecx
00D717ED mov eax,dword ptr [this] ;以上为this指针操作
00D717F0 mov dword ptr [eax],offset cBase::`vftable' (0D78B34h) ;初始化虚表
00D717F6 mov eax,dword ptr [this] ;返回this指针
......
在构造函数中只做一件事,就是赋值虚表为本类(cBase)的虚表。
总结下,在cChild0的构造函数中做了以下的事情:
调用父类构造函数 -> 在父类的构造函数中设置虚表为本类(cBase)的虚表 -> 设置虚表为本类的(cChild0)虚表
需要注意的是,在上文中设置两次虚表都是cChild0 this指针的前四个字节。
在cChild1中做了同样的事情,就不再次赘述了。
那么现在已经很清晰了,这两个子类对象在构造函数调用之后会将虚表都设成自己的虚表。
现在我们来看看GoPrint函数吧:
......
00D725E8 mov eax,dword ptr [pBase] ;取出参数,传递进来的对象
00D725EB mov edx,dword ptr [eax] ;取出虚表
00D725ED mov esi,esp
00D725EF mov ecx,dword ptr [pBase] ;设置this指针
00D725F2 mov eax,dword ptr [edx+4] ;根据虚表偏移取出虚函数
00D725F5 call eax
......
GoPrint函数就很清晰了,直接取出虚表根据偏移调用虚函数,也就理解了程序上面的输出。
现在我们在说说在《我们来聊聊C++多态吧,理解它,并找到它》中我们没有说到的内容,为什么在虚构函数中,要对多态表重新赋值。
在上例中,析构函数的执行顺序如下:
子类析构函数 -> 父类析构函数
那么问题出现了,假设在这两个析构函数中同时调用虚函数,如果在析构函数中没有对虚函数表重新赋值,那么在父类的析构函数中就会调用子类的析构函数,而这个时候子类也许有一些资源已经释放了,那么问题就已经很清晰了,内存泄漏!
上文中的代码,在析构函数中使用虚函数,这是为什么?我们留在下一篇文章中吧,明天见
[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。
最后于 2019-10-16 01:25
被Hasic编辑
,原因: