由底层和逻辑说开去——c++之类与对象的深入剖析
类是什么,对象是什么, 这两个问题在各个c++书里面都以一种抽象的描述方式,给了我们近乎完美的答案,然后我好像就知道什么是类什么是对象了,但是当扪心自问,类在哪儿,对象在哪儿,成员方法在哪儿,成员变量在哪儿的时候,这些定义大概只能给出一个同样抽象的答案。
其实很大程度上我们不知道问题的答案的原因是我们没有弄清楚我们的问题究竟是什么. 类和对象是拥有一堆有访问权限的成员变量和成员方法的集合,那么我们的问题就可以跟着这个凑合的定义得出,我当然也回答不了这些问题,但是我准备在本文做三件事情,通过这三件事,更加近的认识对象和类:1.从底层实现上讲,对象以什么形式保存,对象名是什么 对象的成员变量怎么存储 2.从底层上讲成员方法是怎么的一种存在,怎样把它和全局函数区分开,以及怎么做到重载 3.从逻辑层面上讲怎么实现访问权限,private ,const,static这些,以及从底层上如果绕过编译器去突破这些权限的限制,比如在static方法里成功访问对象成员变量,比如在const方法里面成功修改成员变量,比如在外部成功修改private变量;
然后我们就会发现,所谓c++的类机制,只是编译器在c的脖子上套上一把枷锁,又把钥匙交给了它。
第一个问题:类和对象的内存表示,我以前没学c++的时候,很多人就说啊,c++是面向对象的,c语言是面向过程的,让我有一种有对象就是面向对象的感觉,然后我就问面向对象是什么,然后人家就又说了,面向对象是一种思想,(然后抬头望向远方,做沉思状,让我有一种一巴掌踹死他的冲动);本文不打算说明面向对象是什么,因为这个思想的明白过程不是一蹴而就的,那就来说说内存中的实实在在的东西吧,毕竟存在的东西才踏实; 类是说给编译器听的,在内存没有任何的存在,就像结构体,就像数组,而对象才是是在存在的东西,对象名就像结构体变量名,就像数组变量名一样(有人说你扯淡吧,数组名是地址,你那两个东西算是什么东西),嗯 数组名是地址,在底层实现上,名字不都是地址吗,结构体变量名和对象名也是地址;这三个类型是复合类型,结构体和对象可以是不同类型的复合,数组是相同类型的复合,所以你可以在逻辑上使用数组名加1找到第二个元素的地址(但你要明白这都是逻辑上的,是编译器的功劳),但是结构体名加1却不一定;下面我们看一下一个对象创建过程是怎么分配内存的;源码如下:
#include <iostream> using namespace std; class TextA { private: int a; int b; public: TextA(); }; TextA::TextA() { a=10; b=20; } int main() { TextA text; //cout<<sizeof(text); return 0; }
上面代码很简单,定义一个text(), 为它分配内存,让我们来看一下底层实现
.text:0040109F _main proc near ; CODE XREF: start+AFp .text:0040109F .text:0040109F _text = byte ptr -8 .text:0040109F argc = dword ptr 8 .text:0040109F argv = dword ptr 0Ch .text:0040109F envp = dword ptr 10h .text:0040109F .text:0040109F push ebp .text:004010A0 mov ebp, esp .text:004010A2 sub esp, 8
;上面都不用看;
.text:004010A5 lea ecx, [ebp+_text] .text:004010A8 call _TextA.text:004010AD xor eax, eax.text:004010AF mov esp, ebp.text:004010B1 pop ebp.text:004010B2 retn.text:004010B2 _main endp.text:004010B2
这段代码其实重要的也就两句lea ecx,[ebp+_text]这句话大致意思是把text的地址放在ecx里面,然后
call _TextA就是调用TextA()默认构造函数 我来再看看,这个构造函数对text做了什么;
.text:0040107E _TextA proc near ; CODE XREF: _main+9p .text:0040107E .text:0040107E var_4 = dword ptr -4 .text:0040107E .text:0040107E push ebp .text:0040107F mov ebp, esp .text:00401081 push ecx .text:00401082 mov [ebp+var_4], ecx ;这句意思就是把ecx里面存的也就是_text标识的那块内存的地址放进ebp-4的内存; .text:00401085 mov eax, [ebp+var_4] ;然后再放进eax里; .text:00401088 mov dword ptr [eax], 0Ah ;0Ah就是十进制的10 把10放进eax存的地址的内存也就是_text标识的text的第一个变量a里面; .text:0040108E mov ecx, [ebp+var_4] ;然后又一次把text标识的内存的地址放进ecx, .text:00401091 mov dword ptr [ecx+4], 14h ;然后ecx里的地址减去四,得到的内存里面放 十六进制为14h也就是20的东西,显然这块内存是b; .text:00401098 mov eax, [ebp+var_4] .text:0040109B mov esp, ebp .text:0040109D pop ebp .text:0040109E retn .text:0040109E _TextA endp
看吧 text还是标识它内存的首地址,也就是首元素a的地址,所以说从底层上讲结构题,数组和对象是一种东西; 在上面我们没有看到成成员方法啊,那成员方法在哪里呢?这就是我们的第二个问题了;
2.成员方法在哪里:这里面涉及一个命名粉碎机制,当然我也不懂命名粉碎原理,但是大概就像是在编译的时候 根据你的函数的一些特征,给你的一个函数里面的代码段的段首取一个名字,嗯这句话至少包含三个层面的信息,第一,这个机制是编译的时候用的,可以让函数名变过去也可以变回来 第二函数名应用这个机制的时候取的特征由编译器决定,不同语言选择的不同,第三:得到的名字将用来标识原函数里面代码段的首地址,代码也是在内存里哦; 还是有点抽象哈,那么我们举几个例子: c 语言里面 只要函数名一样 不管参数类型一样不一样 都不能编译通过 这就说明这个特质是函数名,所以我们就说 c语言的函数名就是函数的地址; c++里面呢 有了重载就不能这样了,而且有了类成员函数,所以就不能这样了,c++里面的函数特征包括,所属类名,函数名,参数类型,参数多少等;当然也有一些没有所属类的方法也就是全局方法; c++的函数呢就放在代码段里面,用函数名(其实是变化后的来标识首地址);所以这在逻辑层上解释了几种现象 <1>在逻辑层上一个对象通过 . 操作符只能访问到它自己所属类的方法;<2> 成员方法其实是属于类的 跟对象没有关系(这句话说的不严谨,可能会引出一些问题,我们在第三个话题里讨论) <3>如果在一个成员方法里面定义个static类型变量,另一个对象使用该方法时,这个静态变量依然在;
比如下面的代码
#include <iostream> using namespace std; class TextA { public: void show() { static int a=1; cout<<++a<<endl; } }; int main() { TextA ta; TextA tb; ta.show(); tb.show(); return 0; }
输出2之后输出的是3,说明两者对象访问的是同一个地址的代码,也就是说这些成员方法属于类而不是对象本身,这就引出几个问题了,比如static方法老师们说才是类方法啊 比如说成员方法修改对象变量的时候怎么办,this指针又是什么东西;嗯这些问题我们就不留给第三个话题了,就在这里分析分析;首先我们来看一个成员方法的调用过程
#include <iostream> using namespace std; class Text { private: int a; public: void set_a() { a=10; }; }; int main() { Text t; t.set_a(); return 0; }
为了便于理解我们把代码写的很简单;简单到连参数都没有传,简单到没有默认构造函数;(放心编译器也不会给你加默认构造函数的,虽然老师和很多书上说一定会加,不信看下面汇编代码,原理我会在下一个博客解释 )我们看看这个代码的底层实现是怎样的;
_main proc near var_4= byte ptr -4 argc= dword ptr 8 argv= dword ptr 0Ch envp= dword ptr 10h push ebp mov ebp, esp push ecx lea ecx, [ebp+var_4] call ??1facet@locale@std@@UAE@XZ ; std::locale::facet::~facet(void) xor eax, eax mov esp, ebp pop ebp retn _main endp
我们可以这到这个底层,只调用了一个函数
call ??1facet@locale@std@@UAE@XZ ; std::locale::facet::~facet(void)
??1facet@locale@std@@UAE@XZ就是名称粉碎后的结果,它标识了Text::set_a()的首地址;那么它是怎么得到this指针的呢,就是看
lea ecx, [ebp+var_4]这句话,这句话意思就是把t标识的地址放在寄存器ecx里面,也就是this指针,函数里面就可以用它找到a了,后面我们分析一下static方法就会发现它 没有有这句话 所以找不到this指针;
#include<iostream> using namespace std; class TextA { private: int a; public: static void show(); }; void TextA::show() { cout<<"dragonfive!"; } int main() { TextA ta; TextA::show(); return 0; }
我们来看看底层实现
_main proc near argc= dword ptr 8 argv= dword ptr 0Ch envp= dword ptr 10h push ebp mov ebp, esp push ecx call sub_40107E xor eax, eax mov esp, ebp pop ebp retn _main endp
看吧这里就没有lea这句话,就不能得到this指针(这是编译器的做法,我们可以自己传一个,这样就能突破限制了这就是我们第三部分的内容了;)
3. c++里面有许多规定啊,显得莫名奇妙,比如private的成员不能在外界被访问,今天咱们就来访问一下试试:
#include<iostream> using namespace std; class TextA { private: int a; public: TextA(){a=10;} void show_a(); }; void TextA::show_a() { cout<<a<<endl;; } int main() { TextA ta; ta.show_a(); int *b=NULL; __asm { lea eax,ta; mov [b],eax; } *b=20; ta.show_a(); return 0; }
是吧,第一次输出的是10,因为初始化为10,然后第二次输出20,为什么呢,因为我们得到了a的地址嘛,那是不是说private是假的 自然不是了,因为private是c++的编译器的限制,我们用的是汇编把a的地址偷偷取到
,汇编自然不会走c++编译器也就不会受private限制,因此我们就知道了这个private啊 只是编译器的事情,跟变量的存储没有任何的关系;
当然由此可以推知其它的一些限制词也是这样子的,比如我们可以让static方法访问到访问它的对象的属性;
#include<iostream> using namespace std; class TextA { private: int a; public: TextA(){a=10;}; static void show(); }; void TextA::show() { int b; __asm { mov eax,[ecx] mov [b],eax } cout<<b; } int main() { TextA ta; __asm { lea ecx,ta; } TextA::show(); return 0; }
lea ecx,ta;
只是因为我们在调用之前手动传递了一个地址进去额
所以如你所见 在底层实现上c++和其它语言没有什么区别 指针依然是那么强大而危险的存在着;
只是编译器通过对一些限制词的检测来保证一部分安全;为什么不能绝对安全,因为上一个博客里已经说了
c++的妥协性,指针的存在,让一切都只能把握在一个度内;使用c++便是为了通过这些限制词 让编译器尽可以地检测出不安全因素,
所以c++的函数是一种限制了的语言,对象就像皇宫,私有成员像是后宫部分...编译器就是把门的,只有拥有了指针也就是地址才能潜入后宫做各种友好访问。。。
最后发现好像说跑题了,因为说的貌似是指针的强大(这个可能会误导初学者),和限制词只是逻辑层的东西
作者:在河之博
出处:http://www.cnblogs.com/dragonfive/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。