C++对象的内存分析(2)
C++对象的内存分析(2)
Binhua Liu
前言
本章节讨论单继承情况下类对象的内存特性。阅读时请思考这几个问题:从子类到基类的类型转换,编译器做了什么?多态是怎么实现的?类的成员函数(包括虚函数)和普通函数有什么区别吗?
Subject2:从带虚函数的基类继承的子类
类CFinal是我们要分析的目标,它从CBasic中继承而来,重写(override)了虚函数add;增加了一个新的虚函数;增加了一个成员变量iFinal,类图如下:
代码:
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 37 | class CBasic { public : CBasic() { Array= new int [2]; } int i; int *Array; virtual int add( int a, int b) { return a+b; } virtual int minus( int a, int b) { return a-b; } void HelloWorld() { cout<< "hello world" <<endl; } }; class CFinal: public CBasic { public : int add( int a, int b) { cout<< "CFinal::add" <<endl; return a+b; } virtual int AVG( int a, int b) { return (a+b)/2; } int iFinal; }; |
构造CFinal类的对象
1 | CFinal *f= new CFinal; |
我们还是采用上一节中使用Wacth窗口来观察对象内存的办法,如下图所示:
仔细观察上图打印的元素的地址,我们发现:1)虽然CFinal增加了一个新的虚函数,但是子类并没有因此增加一个新的虚函数指针来指向新的虚函数表,CFinal类仍然只有一个虚函数指针位于对象的最前端,因此,虚函数表”应该“在表尾增加一个单元来存储新增加的虚函数AVG。 2)由于虚函数add被重写(override),虚函数表第一个元素也从指向CBasic::add方法覆盖为指向CFinal::add方法。我们很容易画出对象的内存结构图:
前面我们提到虚函数AVG被增加到了虚函数表的表尾单元时,我们说“应该”是这样的。这是因为我们现在还缺乏证据,在watch窗口中打印f->__vfptr[2]的值,显示错误码CXX0072:Error:type information missing or unknown。 难道我们的分析错误了吗? 不是的。现在我们来试图证明这点。
阅读下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 | typedef int (*Fun)( int a, int b); int _tmain( int argc, _TCHAR* argv[]); { CFinal *f= new CFinal; Fun fun1=NULL; Fun fun2=NULL; Fun fun3=NULL; fun1=(Fun)( int *)*(( int *)(*( int *)f)+ 0); fun2=(Fun)( int *)*(( int *)(*( int *)f)+ 1); fun3=(Fun)( int *)*(( int *)(*( int *)f)+ 2); return 0; } |
注意11行,我们知道,虚函数指针的地址和对象的地址是相同的,虚函数指针指向虚函数表的起始地址,然后我们再从虚表的起始地址加上2个4字节,就索引到虚函数表的第三个元素。
Watch窗口:
果然,虚函数表的第三个元素储存AVG方法的地址。
我们试着用函数指针fun3来调用AVG方法,看看是不是能成功:
1 | fun3(1,3); |
跟踪代码发现,断点确实进入了AVG函数中,但是很不幸,接着发生了异常。这是由于在C++中,类成员函数和普通函数的实现是有差别的,我们这里定义的是普通函数的函数指针来调用类成员函数,所以会失败。[1]
在总结本文之前,我们再来分析最后2个问题:
1)CFinal类的对象转换为CBasic类型时,编译器做了什么呢?
2)多态是怎么实现的?(在本课题的讨论范围之内)
先来看第一个问题。从CFinal类的内存结构图我们已经可以很清楚的看到,CFinal对象内存空间的前12个字节和虚函数表的前2个元素,正好是CBasic类对象的结构!很神奇不是嘛,这也正是C++编译器这么布局的目的。当CFinal类对象转换为CBasic类型时,对象指针地址不变,只是对象被限制只允许访问内存的前12个字节和虚函数表的前2个元素即完成了转换。
回答了第一个问题,第二个问题的答案就很简单了。当CFinal类对象转换为CBasic类型后,调用add方法时,由于事实上使用的是CFinal类的虚表,虚表的第一个元素指向CFinal::add而不是CBasic::add。于是,CBasic类对象调用了CFinal类的add实现,是为多态。
Subject2:结论
和上一节一样,让我们试着为本章做个结论:
对于一个从带虚函数的基类继承的子类:
1)对象内存中依然只有一个虚函数指针处于最前端,虚函数表的末尾将增加新的元素来储存子类新虚函数的地址。
2)对于重写(override)的虚函数,虚函数表中对应的元素将从指向基类方法覆盖为指向子类新实现的方法地址。
3)子类新添加的成员变量,将添加在对象内存的尾端。
4)当把子类对象转换为基类类型时,指针指向的地址不发生变化[2],但是对象访问的内存和虚表范围被缩小了,限制在基类对象能访问的范围内。编译器能做到这一点,是由于子类对象内存和虚函数表的前面部分刚好和基类对象完全吻合。
5)根据2)和4)我们很容易得到多态是如何实现的:子类对象转换为基类类型后,指向的虚函数表其实是子类的虚函数表,由于重写(override)的虚函数在该虚函数表中指向了子类新实现的方法,所以,基类对象(从子类转换得到)调用该方法即调用子类的实现,是为多态。
注释
[1]在默认情况下,C++类成员函数使用的函数调用约定是__thiscall,而普通函数使用的是__cdecl。__thiscall方式被使用时,调用者(caller)把this指针传递给ECX寄存器(当CPU是x86构架),然后从右向左把参数压入堆栈,函数结束时,由函数本身(被调用者,callee)清理堆栈;__cdecl方式,调用者从右向左把参数压入堆栈,函数结束时,由调用者清理堆栈。我们这里使用普通函数指针调用类成员函数,将会造成2个错误:1)this指针没有被调用者压入堆栈。 2)函数体内堆栈已经被清理,但是函数结束后,caller又试图清理堆栈。
解决方案:把AVG函数定义为
virtual int __cdecl AVG(int a, int b)
这时,将由caller清理堆栈,this指针将作为隐含的第一个参数传入。
把函数指针定义为:
typedef int (*Fun)(CFinal *f,int a,int b);
调用改为:
(*fun3)(f,1,3);
[2]当子类从不带虚函数的子类继承,或者多重继承的时,可能要进行指针调整,后2个章节将涉及到这2种情况。
授权声明
本文为Binhua Liu原创作品。本文允许复制,修改,传递。转载请注明出处。本文发表于2010年6月16日。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端