我们先通过一段代码来理解继承的底层实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| 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函数:
1 2 3 4 5 6 7
| ...... 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 :
1 2 3 4 5
| ...... 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继续执行
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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的类修改为下面的样子,则他们的内存结构时完全一样的。
1 2 3 4 5 6 7 8 9 10 11 12
| class CChild { public: void ShowNumber(int nNum) { SetNumber(nNum); nNumberChild = nNum + 1; printf("%d\n", nNumber); }
public: CBase cBase; int nNumberChild; };
|
这种内存结构的优势是什么?
很明显,子类对象调用父类的函数,直接传递子类的对象地址就可以了,那么子类对象指针可以强制转换为父类对象指针来使用,反之则不行。
——————->分割线
再来聊聊多态,上代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| 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的构造函数开始吧:
1 2 3 4 5 6 7 8 9
| ...... 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的构造函数:
1 2 3 4 5 6 7
| ...... 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函数吧:
1 2 3 4 5 6 7 8
| ...... 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++多态吧,理解它,并找到它》中我们没有说到的内容,为什么在虚构函数中,要对多态表重新赋值。
在上例中,析构函数的执行顺序如下:
子类析构函数 -> 父类析构函数
那么问题出现了,假设在这两个析构函数中同时调用虚函数,如果在析构函数中没有对虚函数表重新赋值,那么在父类的析构函数中就会调用子类的析构函数,而这个时候子类也许有一些资源已经释放了,那么问题就已经很清晰了,内存泄漏!