C++ 内存对象大会战
如果一个人自称为程序高手,却对内存一无所知,那么我可以告诉你,他一定在吹牛。用 C或C++写程序,需要更多地关注内存,这不仅仅是因为内存的分配是否合理直接影响着程序的效率和性能,更为主要的是,当我们操作内存的时候一不小心就会出现问题,而且很多时候,这些问题都是不易发觉的,比如内存泄漏,比如悬挂指针。笔者今天在这里并不是要讨论如何避免这些问题,而是想从另外一个角度来认识C++内存对象。
我们知道, C++ 将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此,我称位于它们之中的对象分别为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了?这些便是今天的主题。
一.基本概念
先来看看栈。栈,一般用于存放局部变量或对象,如我们 在函数定义中 用类似下面语句声明的对象:
Type stack_object ;
stack_object 便是一个栈对象,它的生命期是从定义点开始,当所在函数返回时,生命结束。
另外,几乎所有的临时对象都是栈对象。比如,下面的函数定义:
Type fun ( Type object ) ;
这个函数至少产生两个临时对象,首先,参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象 object_copy1 ,在函数内部使用的不是使用的不是 object ,而是 object_copy1 ,自然, object_copy1 是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化( NRV ),那么也会产生一个临时对象 object_copy2 ,这个临时对象会在函数返回后一段时间内被释放。比如某个函数中有如下代码:
Type tt ,result ; // 生成两个栈对象
tt = fun ( tt ) ; // 函数返回时,生成的是一个临时对象 object_copy2
上面的第二个语句的执行情况是这样的,首先函数 fun 返回时生成一个临时对象 object_copy2 ,然后再调用赋值运算符执行
tt = object_copy2 ; // 调用赋值运算符
看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以,你也许明白了,为什么对于“大”对象最好用 const 引用传递代替按值进行函数参数传递了。
接下来,看看堆。堆,又叫自由存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是 动态性 。在 C++ 中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发生内存问题。如果分配了堆对象,却忘记了释放,就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为 NULL ,该指针就是所谓的“悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。
那么, C++ 中是怎样分配堆对象的?唯一的方法就是用 new (当然,用类 malloc 指令也可获得 C 式堆内存),只要使用 new ,就会在堆中分配一块内存,并且返回指向该堆对象的指针。
再来看看静态存储区。所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在 main() 函数执行前就分配好了的。其实,在 main() 函数中的显示代码执行之前,会调用一个由编译器生成的 _main() 函数,而 _main() 函数会进行所有全局对象的的构造及初始化工作。而在 main() 函数结束之前,会调用由编译器生成的 exit 函数,来释放所有的全局对象。比如下面的代码:
void main ( void )
{
… …// 显式代码
}
实际上,被转化成这样:
void main ( void )
{
_main () ; // 隐式代码,由编译器产生,用以构造所有全局对象
… … // 显式代码
… …
exit () ; // 隐式代码,由编译器产生,用以释放所有全局对象
}
所以,知道了这个之后,便可以由此引出一些技巧,如,假设我们要在 main() 函数执行之前做某些准备工作,那么我们可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样,在 main() 函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了我们的目的。
刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个 static 关键字。局部静态对象的生命期是从其所在函数第一次被调用,更确切地说,是当第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。
还有一种静态对象,那就是它作为 class 的静态成员。考虑这种情况时,就牵涉了一些较复杂的问题。
第一个问题是 class 的静态成员对象的生命期, class 的静态成员对象随着第一个 class object 的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个 class ,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该 class object ,那么也就不会产生该 class 所包含的那个静态对象。还有,如果创建了多个 class object ,那么所有这些 object 都共享那个静态对象成员。
第二个问题是,当出现下列情况时:
class Base
{
public:
static Type s_object ;
}
class Derived1 : public Base / / 公共继承
{
… …// other data
}
class Derived2 : public Base / / 公共继承
{
… …// other data
}
Base example ;
Derivde1 example1 ;
Derivde2 example2 ;
example.s_object = …… ;
example1.s_object = …… ;
example2.s_object = …… ;
请注意上面标为黑体的三条语句,它们所访问的 s_object 是同一个对象吗?答案是肯定的,它们的确是指向同一个对象,这听起来不像是真的,是吗?但这是事实,你可以自己写段简单的代码验证一下。我要做的是来解释为什么会这样?
我们知道,当一个类比如 Derived1 ,从另一个类比如 Base 继承时,那么,可以看作一个 Derived1 对象中含有一个 Base 型的对象,这就是一个 subobject 。一个 Derived1 对象的大致内存布局如下:
<shapetype id="_x0000_t75" stroked="f" filled="f" path="m@4@5l@4@11@9@11@9@5xe" o:preferrelative="t" o:spt="75" coordsize="21600,21600"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:connecttype="rect" gradientshapeok="t" o:extrusionok="f"></path><lock aspectratio="t" v:ext="edit"></lock></shapetype><shape id="_x0000_i1025" style="WIDTH: 193.5pt; HEIGHT: 169.5pt" type="#_x0000_t75"><imagedata o:title="subobject" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image001.jpg"></imagedata></shape>
让我们想想,当我们将一个 Derived1 型的对象传给一个接受非引用 Base 型参数的函数时会发生切割,那么是怎么切割的呢?相信现在你已经知道了,那就是仅仅取出了 Derived1 型的对象中的 subobject ,而忽略了所有 Derived1 自定义的其它数据成员,然后将这个 subobject 传递给函数(实际上,函数中使用的是这个 subobject 的拷贝)。
所有继承 Base 类的派生类的对象都含有一个 Base 型的 subobject (这是能用 Base 型指针指向一个 Derived1 对象的关键所在,自然也是多态的关键了),而所有的 subobject 和所有 Base 型的对象都共用同一个 s_object 对象,自然,从 Base 类派生的整个继承体系中的类的实例都会共用同一个 s_object 对象了。上面提到的 example 、 example1 、 example2 的对象布局如下图所示:
<shape id="_x0000_i1026" style="WIDTH: 405pt; HEIGHT: 198.75pt" type="#_x0000_t75"><imagedata o:title="subobject_share" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image002.jpg"><font size="3"></font></imagedata></shape>
二.三种内存对象的比较
栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用 operator new 操作, operator new 会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是 1MB ~ 2MB ,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。
堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。
接下来看看 static 对象。
首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如 C# ,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。 C++ 也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容 C 。
其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些 class 之间或这些 class objects 之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。
接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个 nonstatic 局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为 nonstatic 局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。
在递归函数设计中,可以使用 static 对象替代 nonstatic 局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放 nonstatic 对象的开销,而且 static 对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。
三.使用栈对象的意外收获
前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。
栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为 栈对象可以自动的释放资源,即使在所在函数发生异常的时候 。实际的过程是这样的:函数抛出异常时,会发生所谓的 stack_unwinding (堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针( auto_ptr )中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。
四.禁止产生堆对象
上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。
那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用 new 操作,如果我们禁止使用 new 不就行了么。再进一步, new 操作执行时会调用 operator new ,而 operator new 是可以重载的。方法有了,就是使 new operator 为 private ,为了对称,最好将 operator delete 也重载为 private 。现在,你也许又有疑问了 , 难道创建栈对象不需要调用 new 吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而 operator new 的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:
#include <stdlib.h> // 需要用到 C 式内存分配函数
class Resource ; // 代表需要被封装的资源类
class NoHashObject
{
private:
Resource* ptr ;// 指向被封装的资源
... ... // 其它数据成员
void* operator new(size_t size) // 非严格实现,仅作示意之用
{
return malloc(size) ;
}
void operator delete(void* pp) // 非严格实现,仅作示意之用
{
free(pp) ;
}
public:
NoHashObject()
{
// 此处可以获得需要封装的资源,并让 ptr 指针指向该资源
ptr = new Resource() ;
}
~NoHashObject()
{
delete ptr ; // 释放封装的资源
}
};
NoHashObject 现在就是一个禁止堆对象的类了,如果你写下如下代码:
NoHashObject* fp = new NoHashObject() ; // 编译期错误!
delete fp ;
上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道 在类 NoHashObject 的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为“暴力破解法”。 C++ 是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。
void main(void)
{
char* temp = new char[sizeof(NoHashObject)] ;
// 强制类型转换,现在 ptr 是一个指向 NoHashObject 对象的指针
NoHashObject* obj_ptr = (NoHashObject*)temp ;
temp = NULL ; // 防止通过 temp 指针修改 NoHashObject 对象
// 再一次强制类型转换,让 rp 指针指向堆中 NoHashObject 对象的 ptr 成员
Resource* rp = (Resource*)obj_ptr ;
// 初始化 obj_ptr 指向的 NoHashObject 对象的 ptr 成员
rp = new Resource() ;
// 现在可以通过使用 obj_ptr 指针使用堆中的 NoHashObject 对象成员了
... ...
delete rp ;// 释放资源
temp = (char*)obj_ptr ;
obj_ptr = NULL ;// 防止悬挂指针产生
delete [] temp ;// 释放 NoHashObject 对象所占的堆空间。
}
上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解 C++ 内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:
某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。
所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。
另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将 NoHashObject 的 ptr 指针成员安排在对象空间的头 4 个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:
Resource* rp = (Resource*)obj_ptr ;
但是,并不一定所有的编译器都是如此。
既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。
五.禁止产生栈对象
前面已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要 operator new/delete 操作的,所以将 operator new/delete 设置为 private 不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造 / 析构函数了,当然就不能在栈中生成对象了。
这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚 , 那就是,如果我们将构造函数设置为私有,那么我们也就不能用 new 来直接产生堆对象了,因为 new 在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为 private 。再进一步,将析构函数设为 private 除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。
如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为 private 。
为了限制栈对象,却不限制继承,我们可以将析构函数声明为 protected ,这样就两全其美了。如下代码所示:
class NoStackObject
{
protected:
~NoStackObject() { }
public:
void destroy()
{
delete this ;// 调用保护析构函数
}
};
接着,可以像这样使用 NoStackObject 类:
NoStackObject* hash_ptr = new NoStackObject() ;
... ... // 对 hash_ptr 指向的对象进行操作
hash_ptr->destroy() ;
呵呵,是不是觉得有点怪怪的,我们用 new 创建一个对象,却不是用 delete 去删除它,而是要用 destroy 方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为 private 或 protected 。这又回到了上面曾试图避免的问题,即不用 new ,那么该用什么方式来生成一个对象了? 我们可以用间接的办法完成,即让这个类提供一个 static 成员函数专门用于产生该类型的堆对象。(设计模式中的 singleton 模式就可以用这种方式实现。)让我们来看看:
class NoStackObject
{
protected:
NoStackObject() { }
~NoStackObject() { }
public:
static NoStackObject* creatInstance()
{
return new NoStackObject() ;// 调用保护的构造函数
}
void destroy()
{
delete this ;// 调用保护的析构函数
}
};
现在可以这样使用 NoStackObject 类了:
NoStackObject* hash_ptr = NoStackObject::creatInstance() ;
... ... // 对 hash_ptr 指向的对象进行操作
hash_ptr->destroy() ;
hash_ptr = NULL ; // 防止使用悬挂指针
现在感觉是不是好多了,生成对象和释放对象的操作一致了。
ok ,讲到这里,已经涉及了较多的东西,如果要把内存对象讲得更深入更全面,那可能需要写成一本书了,而就我自己的功力而言,可能是很难完全把握的。如果上面所写的能使你有所收获或启发,我就满足了。如果你要更进一步去了解内存对象方面的知识,那么我可以推荐你看看《深入探索 C++ 对象模型》这本书。