在前面总结C语言的语法规则时,我们总是以内存为根本进行说明。这样也使得面向过程的问题处理变得更加容易理解。而在C++中,由于语言设计的本身就是以现实对象的观点来看待要处理的问题,如果仍然以内存为核心来进行说明,反而会导致偏离面向对象的本意。但是仍然要说明的一点就是面向对象的语言设计的程序和面向过程设计的程序,在计算机真正执行的时候是没有任何区别的,所以我们在理解一些面向对象的语法时,仍然可以从它的内存使用方式以及语法的功能实现上加以说明。
以下的程序和反汇编代码都是基于VS2013,如有差异也没关系^^
以下分为无继承简单类,有继承简单类, 含有静态成员的类三个方面说明
1、一个简单的类所生成的对象,简单的类是指:只有基本类型的成员以及一些普通的函数。
classA
{
public:
A()
{
a= 3;
}
voidfun()
{
intb = a + 3;
return;
}
private:
inta;
};
//执行以下语句
A*pa = new A();
00F3968D push 4 // 首先 A对象只占4个字节的空间,也可以sizeof(A)看到
00F3968F call operator new(0F31415h) //调用new 运算符(一种特殊函数),参数就是之前压栈的数据
00F39694 add esp,4
00F39697 mov dword ptr[ebp-0E0h],eax // 把申请到的内存空间的首地址临时保存在栈中
00F3969D mov dword ptr [ebp-4],0
00F396A4 cmp dword ptr[ebp-0E0h],0 // 对申请到的内存空间进行检验,如果是NULL的化就不调用构造函数
00F396AB je wmain+70h (0F396C0h)
00F396AD mov ecx,dword ptr [ebp-0E0h]
00F396B3 call A::A (0F314F6h) //正常情况下调用构造函数进行初始化。
00F396B8 mov dword ptr[ebp-0F4h],eax // 赋值给pa
00F396BE jmp wmain+7Ah (0F396CAh)
00F396C0 mov dword ptr [ebp-0F4h],0
00F396CA mov eax,dword ptr [ebp-0F4h]
00F396D0 mov dword ptr [ebp-0ECh],eax
00F396D6 mov dword ptr [ebp-4],0FFFFFFFFh
00F396DD mov ecx,dword ptr [ebp-0ECh]
00F396E3 mov dword ptr [pa],ecx
pa->fun();
00F396E6 mov ecx,dword ptr [pa]
00F396E9 call A::fun (0F31519h) // 跳转到fun 函数所在的空间。
我们先看一下0F31519h处的数据,
_wmain:
00F314F1 jmp wmain (0F39650h)
A::A:
00F314F6 jmp A::A (0F36870h)
std::operator<<<std::char_traits<char>>:
00F314FB jmp std::operator<<<std::char_traits<char> >+4B0h(0F32CF0h)
std::operator<<<std::char_traits<char>>:
00F31500 jmp std::operator<<<std::char_traits<char> >+560h(0F32DA0h)
std::operator<<<std::char_traits<char>>:
00F31505 jmp std::operator<<<std::char_traits<char> >+500h(0F32D40h)
_GetModuleHandleW@4+18:
00F3150A jmp _GetModuleHandleW@4+12h (0F394F0h)
A::A:
00F3150F jmp A::A (0F36870h)
_GetModuleHandleW@4+114:
00F31514 jmp _GetModuleHandleW@4+72h (0F39550h)
A::fun:
00F31519 jmp A::fun (0F368D0h)
可以看到在这个函数入口表中,保存了很多函数的入口地址,并且直接是一条跳转指令,执行这条指令之后,跳转到函数中执行。
---h:\workspace\c++\testcplus\testcplus\testcplus.cpp -------------------------
voidfun()
{
00F368D0 push ebp
00F368D1 mov ebp,esp
00F368D3 sub esp,0D8h
00F368D9 push ebx
00F368DA push esi
00F368DB push edi
00F368DC push ecx
00F368DD lea edi,[ebp-0D8h]
00F368E3 mov ecx,36h
00F368E8 mov eax,0CCCCCCCCh
00F368ED rep stos dword ptr es:[edi]
00F368EF pop ecx
00F368F0 mov dword ptr [this],ecx
intb = a + 3;
00F368F3 mov eax,dword ptr [this]
00F368F6 mov ecx,dword ptr [eax]
00F368F8 add ecx,3
00F368FB mov dword ptr [b],ecx
return;
}
这里面主要有三点内容:
1、申请对象空间时,其实只是保存成员所占的空间,在内存对齐时,与结构体分配空间的方式一致。
2、函数入口表,观察函数入口表的地址可以看到,在地址较低的地方保存了所有函数的入口地址。除了我们定义的函数,还有许多运行时函数。
3、类中真正的函数区:里面存储着函数的操作代码。需要说明的是,类代码的载入顺序是在编译时已经确定的。
2、简单类的继承情况
classA
{
public:
A()
{
a= 3;
}
voidfun()
{
intb = a + 3;
return;
}
private:
inta;
};
classB : public A
{
public:
voidg()
{
inta = 2 + 3;
}
private:
intb;
};
//main 函数中代码
B*pb = new B();
pb->g();
pb->fun();
VS 2013中反汇编代码如下:
pb->g();
002A2D86 mov ecx,dword ptr [pb]
002A2D89 call B::g (02A151Eh)
pb->fun();
002A2D8E mov ecx,dword ptr [pb]
002A2D91 call A::fun (02A1519h)
我们可以看一下函数入口表的内容:
A::fun:
002A14F1 jmp A::fun (02A9650h)
A::A:
002A14F6 jmp A::A (02A6870h)
_wmain:
002A14FB jmp wmain (02A2CF0h)
_wmain+176:
002A1500 jmp wmain+0B0h (02A2DA0h)
_wmain+80:
002A1505 jmp wmain+50h (02A2D40h)
B::g:
002A150A jmp B::g (02A94F0h)
A::A:
002A150F jmp A::A (02A6870h)
B::g:
002A1514 jmp B::g+60h (02A9550h)
A::fun:
002A1519 jmp A::fun (02A9650h)
B::g:
002A151E jmp B::g (02A94F0h)
B::B:
002A1523 jmp B::B (02A68D0h)
仔细观察的话,可以发现一些有意思的事情,首先,这里面对于fun()函数只有A::fun(),而没有B::fun()
其次是,A::fun()函数入口有两个,并且他们是一样的。
由此,我们就可以推测出,在载入A类的时候把它的函数入口保存在入口表中,由于B继承A,B中就有A中的所有方法。所以在载入B的函数入口时,就把B自身的函数和从A继承来的函数又重新载入一遍。这里我们可以会觉得有点重复,浪费了内存空间。实际上这样设置函数入口表还有很多其他的妙用。
另外,可能你会想到如果用A的对象调用fun()函数,就会通过上面的入口进入了(002A14F1h),但实际上,编译器并没有进行区分是A的对象调用fun,还是B的对象调用fun(),都用最后一个入口(002A1519)。
前面主要讨论了方法的执行问题,现在我们需要说明一下,一个对象的存储问题,在上面的例子中,基类A占4个字节,子类B占8个字节,其中有4个字节是因为继承了A的属性。查看内存可以发现它们在内存中的存放顺序是从基类继承的成员在前,子类特有的在后。
ps:在我试验的过程中,发现每次载入程序时,函数入口地址都有一些规律,
比如:fun的地址,第一次是02A 9650h,而第二次是 016 9650h,可以发现,他们的地址后4位都是一样的(二进制就是16位),如果你看过王爽老师的汇编的化,就会发现,现在的程序依然是通过内存段的方式来使用内存。这样对于程序的装载很方便。段式的内存使用和页式的内存管理是有区别的,一个是程序方面的,一个是系统方面的,这一点,我在操作系统的笔记中会详细说明。
3、关于静态方法的讨论
首先需要明确几点基本语法:
- 静态方法中不能使用非静态的成员变量和成员方法。
- 静态成员变量是在类载入时分配空间,与对象的定义无关。
在之前观察的基础上,我们直接以含有静态成员的类的继承为例,同时为了说明静态成员变量的一些性能,我们定义了全局变量。
classA
{
public:
A()
{
a= 3;
}
void fun() // 普通方法
{
intb = a + 3;
return;
}
static void func()//静态方法
{
intc = b ^ 3;
}
private:
inta;
static int b; // 静态成员
};
classB : public A
{
public:
voidg()
{
inta = 2 + 3;
}
};
int A::b = 0; // 静态成员初始化
int c = 5; // 全局变量
int_tmain(int argc, _TCHAR* argv[])
{
A* pa = new A();
pa->fun();
pa->func();
A::func();
B*pb = new B();
pb->g();
pb->fun();
pb->func();
B::func();
return0;
}
查看反汇编代码如下:
A* pa = new A();
01076A3D push 4 // 申请空间的大小,可见静态成员所使用的空间跟普通成员不在一起。
01076A3F call operator new(0107141Fh) //申请空间
01076A44 add esp,4
01076A47 mov dword ptr [ebp-104h],eax
01076A4D mov dword ptr [ebp-4],0
01076A54 cmp dword ptr [ebp-104h],0
01076A5B je wmain+70h (01076A70h)
01076A5D mov ecx,dword ptr [ebp-104h]
01076A63 call A::A (01071249h) //调用构造函数
01076A68 mov dword ptr[ebp-118h],eax //赋值给pa
01076A6E jmp wmain+7Ah (01076A7Ah)
01076A70 mov dword ptr [ebp-118h],0
01076A7A mov eax,dword ptr [ebp-118h]
01076A80 mov dword ptr [ebp-110h],eax
01076A86 mov dword ptr [ebp-4],0FFFFFFFFh
01076A8D mov ecx,dword ptr [ebp-110h]
01076A93 mov dword ptr [pa],ecx
申请空间的大小,可见静态成员所使用的空间跟普通成员不在一起,在查看变量的地址后这一点会更加清晰。其他的前面都已说明
pa->fun();
01076A96 mov ecx,dword ptr [pa]
01076A99 call A::fun (01071483h)
pa->func();
01076A9E call A::func (0107100Ah)
A::func();
01076AA3 call A::func (0107100Ah)
关于这几行,首先是静态方法的调用方式,可以通过对象调用,也可以直接用类名调用,编译器把他们当做同样的内容进行处理。其次,静态方法的入口表和普通方法的入口表是在一起的,说明他们是一起载入的。也就是说,在载入一个类的时候,它的所有方法都是一起载入的,他们的区别也就是编译器所能识别的方式,普通方法只能通过对象调用。
A::fun:
01071483 jmp A::fun (01076930h)
A::func:
0107100A jmp A::func (01076980h)
从这里可以看出来,函数内容载入时,也是一起顺序载入的。
B*pb = new B();
pb->fun();
01076B09 mov ecx,dword ptr [pb]
01076B0C call A::fun (01071483h)
pb->func();
01076B11 call A::func (0107100Ah)
B::func();
01076B16 call A::func (0107100Ah)
由于B继承A, 所以我们同样可以用B的对象来调用从A继承来的静态方法。可以认为B中有这些方法。
另外,我们看一下A::b的地址和全局变量c的地址
&A::b = 0x01080334h
&c = 0x0108000Ch
可以看出他们的保存位置还是很接近的。
根据程序中保存的数据的功能,我们可以把内存分为以下几部分
栈区:用来存储 局部变量和参数变量
堆区域:用来存放用new 关键字分配的对象
代码区域:存放方法中的操作代码
方法入口表:存放方法的入口
全局(静态)变量区:存放静态变量和全局变量