摘要:
本文从内存的角度去了解C++对象,明白C++的一些解析机制,从而达到更加准确使用C++的目的。
1)程序的内存分布
2)C++的对象
3)C++对象的内存分布
程序的内存分布
一个程序占用的内存区一般分为五个区:
l 代码区-存放程序的代码
l 全局/静态数据区-存储全局变量和静态变量(包括全避静态变量和局部静态变量)
l 常量数据区-存储常量字符串等
l 堆-存储动态产生的数据,由用户自己控制
l 栈-存储自动变量或者局部变量,及传递的参数等
说明:
1. 静态变量只有在第1次使用时被初始化,以后使用时不再初始化。即初始化只执行一次。
2. 堆和栈在使用和释放上有所不同,有如下一些区别:
1) 大小:一般说来,一个程序使用栈的大小是固定的,由编译器决定,如DVR目前arm平台的栈空间定义32KB。而堆的大小一般只受限于虚拟内存的大小。
2) 效率:栈上的内存是系统自动分配的,压栈和出栈都有相应的指令进行操作,因此效率较高,并且分配的内在空间是连续的,不会产生碎片;而堆上的内存是由开发人员动态分配和回收的,当申请时,系统需要按一定的算法在堆空间中寻找合适大小的空闲堆,并修改相应的维护堆空间的链表,然后返回地址给程序。因此效率比栈低,还容易产生内存碎片。
C++对象
构造函数是一个递归操作,从顶层基类开始,在每一层中,首先调用父类的构造函数,然后调用成员对象的构造函数,再执行构造函数体。当构造该类自己的成员变量时,严格按照成员变量在该类中的声明顺序即可,而与其在初始化列表中出现的顺序无关。(如果和初始化程序顺序有关的,那么析构函数的顺序应该如何指定呢?)对两类成员变量,即“常量”(const)型和“引用”(reference)的变量的初始化必须在初始化列表中进行,而不能将其放在构造函数体内。
析构则严格按照与构造相反的次序执行,该次序是唯一的。
由于对象的创建必须调用构造函数和析构函数,所以针对堆和栈的分配,我们可以自己控制对象的创建。
禁止产生堆对象
产生堆对象的唯一方法是使用new操作, new操作执行时会调用operator new,而operator new是可以重载的。于是使new operator 为private就可以了。为了对称,最好将operator delete也重载为private。
难道创建栈对象不需要调用new吗?是的,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间。
Class CTestHash
{
…
private:
void* operator new(size_t size) //非严格实现,仅作示意之用
{
return malloc(size) ;
}
void operator delete(void* pp) //非严格实现,仅作示意之用
{
free(pp) ;
}
…
}
如果你执行:
CTestHash*pTest = new CTestHash (); // 编译器错误
deletepTest;
在类CTestHash的定义不能改变的情况下,有没有其它办法产生该类型的堆对象吗?有,指针类型的强制转换。
char* temp = newchar[sizeof(CTestHash)] ;
//强制类型转换,现在ptr是一个指向CTestHash对象的指针
CTestHash *obj_ptr = (CTestHash *)temp ;
delete[] temp;
说明:不建议使用
禁止产生栈对象
创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。
将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。可是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我只能将析构函数设置为private。将析构函数设为private除了会限制栈对象生成外,还会限制继承。所以,如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。 为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:
class CTestStack
{
public:
void destroy()
{
deletethis;
}
protected:
~ CTestStack();
}
可以这样使用:
CTestStack*pTest = new CTestStack();
…
pTest-> destroy();
我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。从面向对象的角度来考虑,是不习惯这种怪异的使用方式的。
做一下改进,用静态成员函数来产生该类型的对象(设计模式中的singleton模式就可以用这种方式实现)。
class CTestStack
{
public:
static CTestStack* createInstance()
{
return new CTeststack();
}
void destroy()
{
delete this;
}
protected:
CTestStack();
~ CTestStack();
}
可以这样使用:
CTestStack*pTest = CTestStack:: createInstance ();
…
pTest-> destroy();
C++对象的内存布局
简单对象
C++对象中包含成员数据和成员函数,成员数据可以分为静态成员数据和非静态成员数据,成员函数可以分为静态成员函数、非静态成员函数和虚函数。
首先看一个空类
sizeof(CEmpty)的大小会是多少呢,可能有些人会理解为0,但实际上会是1。那是被编译器安插进去的一个char,这使这个类的两个对象得以在内存中配置独一无二的地址。
sizeof(CMemory)的值应该是多少呢?根据输出值为12个字节。
由于静态数据成员 static int m_sCount存储在全局/静态数据区,所以sizeof()的大小不包括m_sCount所占用的内存大小。sizeof(m_iValue)+sizeof(m_cChar)应该是5个字节,但考虑到对齐,就会占用8个字节。还有的4个字节用于哪了呢?
虚函数是C++中的一个重要特性,用来实现面向对象中的多态性。为了实现这一特性,每个类产生出一堆指向虚函数的指针,放在表格中,这个表格被称为virtual table(vtbl)。每一个类对象被添加了一个指针,指向相关的virtual table,通常这个指针被称为vptr。这4个字节就是虚函数表指针所占据的4个字节。而且位于一个对象开始的4个字节。
对于静态成员函数和非静态成员函数,C++编译器采用与普通C函数类似的方式进行编译,只不过对函数名进行了名称修饰,用来支持重载。并且在参数列表中增加了一个this指针,用来表示是哪一个对象调用的该函数。
如果对象中包含虚函数,会增加4个字节的空间,而不论有多少个虚函数。
单继承
构造一个派生类的实例时要首先构造一个基类的实例,而这个基类的实例在派生类的实例销毁之后被销毁。所以针对单继承的情况下,派生类实例的头部存在一个基类的实例,派生类的实例使用的是在创建基类实例时建立的虚函数表。但需要注意的是,虚函数表的内容在派生类的实例时发生了变化。
生成的对象内存如下
多继承
多继承是C++语言中比较受争议的语法特性。许多后来的面向对象语言都取消了多继承,而使用接口的概念。
由于是多继承,因此创建其类的对象时要遵循一定的顺序,这个顺序是由派生类声明时决定的。如
classCDerivedClase : public CMemory, public CBase
则会先创建CMemory,再创建CBase。
由于CMemory的大小是12个字节,CBase的大小是8个字节,而CDerivedClass有一个成员变量,所以大小应该是24个字节。
每个派生类的实例中包含所有基类的实例,每个基类的实例有自己的虚函数表。
由于派生类CDerivedClass没有重载CMemory的虚函数foo(),因此在基类CMemory的虚函数表里,foo()的指针仍然是指向CMemory的实现,而CDerivedClass重载了test(),因此CBase的虚函数表中,test()的指针被指向了CDerivedClass实现的test()。如果CDerivedClass还有其他虚函数,则应该放在CMemory的vptr指针所指向的vptb。
菱形继承
其中继承代码为
class CDerivedClass : public CMidClass1,public CMidClass2
根据继承的对象内存布局,得到如下图
这样的方案不仅会造成调用上的歧义,而且也造成了内存的重复,为了避免这种情况,C++语言提供虚拟继承。
虚拟继承
当使用虚拟继承时,公共的基类只存在一个实例。如
class CMidClass1 : virtual public CMemory
class CMidClass2 : virtual public CMemory
1) CMemory只创建了一个实例,并且CMemory的实例放在CDerivedClass实例的内存空间的最后部分
2) 没有了歧义。
3) 其对象布局也不一样
n (VC)不使用虚继承时,CMidClass1和CMidClass2的大小都是16个字节,CDerivedClass的大小是36个字节。而使用了虚拟继承后,CMidClass1和CMidClass2的大小变为24个字节,而CDerivedClass的大小变为40个字节。为了支持虚拟继承,不同的编译器做法会有所不同。在VC中通过添加一个虚基类表(virtual base table)的指针来实现(类似于虚函数表指针)(图a)
n (GNU)使用在虚函数中放置虚基类的偏移。VPTR的方式,只不过在VBTL的第一个值用来表示基类的地址(图b)
资料:
《深度探索C++对象模型》
《C++应用程序性能优化》