C++内存管理学习

来源:互联网 发布:707的医药软件 编辑:程序博客网 时间:2024/05/19 09:13

C++内存管理学习笔记(1)

/****************************************************************/

/*            学习是合作和分享式的!

/* Author:Atlas                   
/*  转载请注明本文出处:
 

/****************************************************************/

一 C++内存管理
1.内存分配方式
     在讲解内存分配之前,首先,要了解程序在内存中都有什么区域,然后在详细分析各种分配方式。

1.1 C语言和C++内存分配区
下面的三张图,图1图2是一种比较详细的C语言的内存区域分法。图3是典型的C++内存分布图,简单易懂;以下内存分配图,区别就是图1和2则分为初始化和未初始化静态变量区,图3中是全局变量区。

C语言(图1和图2):(由地地址到高地址)

a)正文段:用来存放程序执行代码。通常,正文段是可共享的。另外,正文段常常是只读的,一次防止程序由于意外修改其自身。

b)初始化数据段:用来存放程序中已初始化的全局变量。数据段属于静态内存分配。

c)非初始化数据段:通常称为BSS段, 用来存放程序中未初始化的全局变量。BSS是英文Block Started by Symbol(由符号开始的块)的简称。BSS段属于静态内存分配。 在程序开始执行之前,内核将此段中的数据初始化为0或者空指针。

d)堆:堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc/free等函数分配内存时,新分配的内存就被动态添加到堆上 (堆被扩张)/释放的内存从堆中被剔除(堆被缩减)。

e)栈:栈又称堆栈, 存放程序的局部变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,栈用来传递参数和返回值。由于栈 的先进先出特点,所以栈特别方便用来保存/恢复调用现场。

 

\ \

  图1  典型C语言内存分布区域 (UNIX高级环境编程)                                                                               图2 典型C语言内存分布区域

 

C++(图3):

根据《C++内存管理技术内幕》一书,在C++中,内存分成5个区,他们分别是堆,栈,自由存续区,全局/静态存续区,常量存续区。

a) 栈:内存由编译器在需要时自动分配和释放。通常用来存储局部变量和函数参数。栈运算分配内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

b) 堆:内存使用new进行分配使用delete或delete[]释放。如果未能对内存进行正确的释放,会造成内存泄漏。但在程序结束时,会由操作系统自动回收。

c) 自由存储区:使用malloc进行分配,使用free进行回收。和堆类似。

d) 全局/静态存储区:全局变量和静态变量被分配到同一块内存中,C语言中区分初始化和未初始化的,C++中不再区分了。

e) 常量存储区:存储常量,不允许被修改。

这里,在一些资料中是这样定义C++内存分配的,可编程内存在基本上分为这样的几大部分:静态存储区、堆区和栈区。他们的功能不同,对他们使用方式也就不同。

a)静态存储区:内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。它主要存放静态数据、全局数据和常量。

b)栈区:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

c)堆区:亦称动态内存分配。程序在运行的时候用malloc或new申请任意大小的内存,程序员自己负责在适当的时候用free或delete释放内存。动态内存的生存期可以由我们决定,如果我们不释放内存,程序将在最后才释放掉动态内存。 但是,良好的编程习惯是:如果某动态内存不再使用,需要将其释放掉,否则,我们认为发生了内存泄漏现象。

 

\

            图3 典型c++内存区域

总结:C++与C语言的内存分配存在一些不同,但是整体上就一致的,不会影响程序分析。就C++而言,不管是5部分还是3大部分,只是分法不一致,将5部分中的c)d)e)合在一起则是3部分的a)。

1.2区分堆、栈、静态存储区
我们通过代码段来看看对这样的三部分内存需要怎样的操作和不同,以及应该注意怎样的地方。

(1)静态存储区与栈区


   1: char* p = “Hello World1”;     2: char a[] = “Hello World2”;     3: p[2] = ‘A’;     4: a[2] = ‘A’;     5: char* p1 = “Hello World1;”
        这个程序是有错误的,错误发生在p[2] = ‘A’这行代码处,为什么呢,是变量p和变量数组a都存在于栈区的(任何临时变量都是处于栈区的,包括在main()函数中定义的变量)。但是,数据“Hello World1”和数据“Hello World2”是存储于不同的区域的。

       因为数据“Hello World2”存在于数组中,所以,此数据存储于栈区,对它修改是没有任何问题的。因为指针变量p仅仅能够存储某个存储空间的地址,数据“Hello World1”为字符串常量,所以存储在静态存储区。虽然通过p[2]可以访问到静态存储区中的第三个数据单元,即字符‘l’所在的存储的单元。但是因为数据“Hello World1”为字符串常量,不可以改变,所以在程序运行时,会报告内存错误。并且,如果此时对p和p1输出的时候会发现p和p1里面保存的地址是完全相同的。换句话说,在数据区只保留一份相同的数据。

(2)堆与栈区别

我们先通过例子1来直观的说明下栈与堆内存的区别,然后在细致分析例子2中的情况。

例子1:
   1: void fn(){   2:     int* p = new int[5];   3: }
        看到new,首先应该想到,我们分配了一块堆内存,那么指针p呢? 它分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中。

注意:这里为了简单并没有释放内存,那么该怎么去释放呢? 是deletep么? NO,错了,应该是delete [ ] p,这是告诉编译器:删除的是一个数组。


例子2:

   1: int a = 0; //全局初始化区   2: char *p1; //全局未初始化区   3: main()   4: {   5:     int b;  //栈   6:     char s[] = "abc"; //栈   7:     char *p2; //栈   8:     char *p3 = "123456";   // 123456\0在常量区,p3在栈上。   9:     static int c =0;     //全局(静态)初始化区  10:     p1 = (char *)malloc(10);  11:     p2 = (char *)malloc(20);  12:         //分配得来得10和20字节的区域就在堆区。  13:     strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。  14: }
例子3:


   1: char* f1()     2: {     3: char* p = NULL;     4: char a;     5: p = &a;     6: return p;     7: }     8: char* f2()     9: {    10: char* p = NULL:    11: p =(char*) new char[4];    12: return p;    13: }
       这两个函数都是将某个存储空间的地址返回,二者有何区别呢?f1()函数虽然返回的是一个存储空间,但是此空间为临时空间。也就是说,此空间只有短暂的生命周期,它的生命周期在函数f1()调用结束时,也就失去了它的生命价值,即:此空间被释放掉。所以,当调用f1()函数时,如果程序中有下面的语句:

   1: char* p ;     2: p = f1();     3: *p = ‘a’;
     此时,编译并不会报告错误,但是在程序运行时,会发生异常错误。因为,你对不应该操作的内存(即,已经释放掉的存储空间)进行了操作。但是,相比之下,f2()函数不会有任何问题。因为,new这个命令是在堆中申请存储空间,一旦申请成功,除非你将其delete或者程序终结,这块内存将一直存在。也可以这样理解,堆内存是共享单元,能够被多个函数共同访问。如果你需要有多个数据返回却苦无办法,堆内存将是一个很好的选择。但是一定要避免下面的事情发生:

   1: void f()     2: {     3: …     4: char * p;     5: p = (char*)new char[100];     6: …     7: }

        这个程序做了一件很无意义并且会带来很大危害的事情。因为,虽然申请了堆内存,p保存了堆内存的首地址。但是,此变量是临时变量,当函数调用结束时p变量消失。也就是说,再也没有变量存储这块堆内存的首地址,我们将永远无法再使用那块堆内存了。但是,这块堆内存却一直标识被你所使用(因为没有到程序结束,你也没有将其delete,所以这块堆内存一直被标识拥有者是当前您的程序),进而其他进程或程序无法使用。我们将这种不道德的“流氓行为”(我们不用,却也不让别人使用)称为内存泄漏(memory leak)。

综合以上两个例子,我们可以总结一下堆与栈到底有哪些区别!

(1)管理方式不同

         对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。

(2)空间大小不同

        空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6.0下面默认的栈空间大小是1M,可以修改这个值。

(3)能否产生碎片不同

        对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出。

(4)生长方向不同

       对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。(详见第一部分的内存分配图)

(5)分配方式不同

       堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

(6)分配效率不同

      栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

总结:

       堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,推荐大家尽量用栈,而不是用堆。

  虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

       无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难。

C++内存管理学习笔记(2)

上节内容回顾:传送门

1.C++内存管理

1.1c语言和C++内存分配

1.2区分堆、栈、静态存储区
 


1.3控制C++的内存分配
       在C++中一种常见的问题是对内存的分配,重点是new和delete的使用不当而失控。一般来说,C++对内存的管理非常的容易和安全,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。所以频繁的使用new和delete动态分配会出现一些问题和堆破碎的风险。

       所以,当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new 和delete来代替系统的内存分配符,并且一个类一个类的重载new 和delete。

  一个防止堆破碎的通用方法是从不同固定大小的内存池中分配不同类型的对象。对每个类重载new和delete就提供了这样的控制。

小问:为什么需要重载new和delete?

      虽然C++标准库已经为我们提供了new与delete操作符的标准实现,但是由于缺乏对具体对象的具体分析,系统默认提供的分配器在时间和空间两方面都存在着一些问题:分配器速度较慢,而且在分配小型对象时空间浪费比较严重,特别是在一些对效率或内存有较大限制的特殊应用中。比如说在嵌入式的系统中,由于内存限制,频繁地进行不定大小的内存动态分配很可能会引起严重问题,甚至出现堆破碎的风险;再比如在游戏设计中,效率绝对是一个必须要考虑的问题,而标准new与delete操作符的实现却存在着天生的效率缺陷。此时,我们可以求助于new与delete操作符的重载,它们给程序带来更灵活的内存分配控制。除了改善效率,重载new与delete还可能存在以下几点原因:

     a)检测代码中的内存错误。

     b)性能优化 

     b)获得内存使用的统计数据。

在《Effective c++》一书中也有讲解为什么需要重载,似乎重载是必须的?对于何种情况下需要重载new和delete的问题,以及如何重载的问题,需要继续研究学习。
 
详见文章-建议33:小心翼翼地重载operator new/ operator delete

(1)重载全局的new和delete

以下代码为《c++内存管理技术内幕》中是的,只限于简单原理学习

   1: void * operator new(size_t size)   2: {   3:     void *p = malloc(size);   4:     return (p);   5: }   6: void operator delete(void *p)   7: {   8:     free(p);   9: }
这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc()和free()。

也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配。

   1: class TestClass {   2: public:   3:     void * operator new(size_t size);   4:     void operator delete(void *p);   5:     // .. other members here ...   6: };   7: void *TestClass::operator new(size_t size)   8: {   9:     void *p = malloc(size); // Replace this with alternative allocator  10:     return (p);  11: }  12: void TestClass::operator delete(void *p)  13: {  14:     free(p); // Replace this with alternative de-allocator  15: }

         所有TestClass 对象的内存分配都采用这段代码。更进一步,任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new 和 delete 操作符。通过重载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。

(2)为单个类重载new[ ]和delete[ ]

         必须小心对象数组的分配。你可能希望调用到被你重载过的new 和 delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆。

        C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符。

   1: class TestClass {   2: public:   3:     void * operator new[ ](size_t size);   4:     void operator delete[ ](void *p);   5:     // .. other members here ..   6: };   7: void *TestClass::operator new[ ](size_t size)   8: {   9:     void *p = malloc(size);  10:     return (p);  11: }  12: void TestClass::operator delete[ ](void *p)  13: {  14:     free(p);  15: }  16: int main(void)  17: {  18:     TestClass *p = new TestClass[10];  19:     // ... etc ...  20:     delete[ ] p;  21: }

        但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。

另:对于重载new和delete或者new[ ] 和delete[ ],需要考虑诸多事宜,比如错误处理机制,继承、多态等问题。限于篇幅,将在以后的文章中详细讲解,在此买一个伏笔。

(可以参考一篇文章new、delete(new[]、delete[])操作符的重载)。

1.4 内存管理的基本要求
        如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复 delete,也不漏掉 delete。也就说我们常说的 new/delete 要配对,“配对”不仅是个数相等,还隐含了 new 和 delete 的调用本身要匹配,不要“东家借的东西西家还”。例如:

用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放;
用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并释放;
用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析构并释放;
用系统默认的 ::operator new() 分配的的内存要交给系统默认的 ::operator delete() 去释放;
用 placement new 创建的对象要用 placement delete (为了表述方便,姑且这么说吧)去析构(其实就是直接调用析构函数);
从某个内存池 A 分配的内存要还给这个内存池。
如果定制 new/delete,那么要按规矩来。见 Effective C++ 相关条款。做到以上是每个 C++ 开发人员的基本功。

1.5常见的内存错误及其对策
       发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。

常见的内存错误如下:

      (1)内存分配未成功,却使用了它。

  编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。

      (2) 内存分配虽然成功,但是尚未初始化就引用它。

  犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

      (3)内存分配成功并且已经初始化,但操作越过了内存的边界。

  例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

     (4)忘记了释放内存,造成内存泄露。

  含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

     (5)释放了内存却继续使用它。

  有三种情况:

  (a)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

  (b)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。

  (c)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

常见的内存错误对策如下:

        【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

  【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

  【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。

  【规则4】动态内存的申请与释放必须配对,防止内存泄漏。

  【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

 

--------------------------------------------------------------------------------


这部分学习笔记,其中有很多可以深究的知识,比如new和delete的重载、内存错误情况及处理机制等等,学无止境。

C++内存管理学习笔记(3)

以前学习笔记内容回顾:传送门,传送门2.

1.C++内存管理

1.1c语言和C++内存分配

1.2区分堆、栈、静态存储区
 1.3控制C++的内存分配

1.4内存管理的基本要求

1.5常见的内存错误及对策
 

--------------------------------------------------------------------------------

1.6指针与数组
       C/C++中对数组和指针的掌握是程序员基本的技术功底,对于数组和指针的定义、操作、它们间的区别等内容,详见我另一篇文章《C/C++数组和指针详解》。

这里以学习《c++内存管理技术内幕》为主,对其中的疑点,不明白或者没有说明清楚的地方会在学习笔记中讲解说明,需要特殊讲解的会发单独的文章。

       C++/C程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。

数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。<-------这句话说的不恰当!当数组通过new或者malloc创建是,数组内容是在堆中,如果有指针指向这个数组,则这个指针是在栈中。其他情况可以说是要么在静态区创建,或者在栈上申请。

       数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。

下面以字符串为例比较指针与数组的特性。

(1)修改内容的方式

        下面示例中,字符数组a的容量是6个字符,其内容为hello。a的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串“world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]= ‘X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。

   1: char a[] = “hello”;   2: a[0] = ‘X’;   3: cout << a << endl;   4: char *p = “world”; // 注意p指向常量字符串   5: p[0] = ‘X’; // 编译器不能发现该错误   6: cout << p << endl;
(2)复制内容以及内容比较的方式

       不能对数组名进行直接复制与比较。若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较b和a的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。

       语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数malloc为p申请一块容量为strlen(a)+1个字符的内存,再用strcpy进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp来比较。

   1: // 数组…   2: char a[] = "hello";   3: char b[10];   4: strcpy(b, a); // 不能用 b = a;   5: if(strcmp(b, a) == 0) // 不能用 if (b == a)   6: …   7:     8: // 指针…   9: int len = strlen(a);  10: char *p = (char *)malloc(sizeof(char)*(len+1));  11: strcpy(p,a); // 不要用 p = a;  12: if(strcmp(p, a) == 0) // 不要用 if (p == a)  13: …
(3)计算内存容量

         用运算符sizeof可以计算出数组的容量(字节数)。如下示例中,sizeof(a)的值是12(注意别忘了’’)。指针p指向a,但是sizeof(p)的值却是4。这是因为sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。

   1: char a[] = "hello world";   2: char *p = a;   3: cout<< sizeof(a) << endl; //12Bytes   4: cout<< sizeof(a) << endl; //4 Bytes
注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。

   1: void Func(char a[100])   2: {   3:     cout<< sizeof(a) << endl; // 4Bytes,not 100Bytes   4: }
1.7指针参数是如何传递内存?
      如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如下示例中,Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL,为什么?

   1: void GetMemory(char *p, int num)   2: {   3:    p = (char *)malloc(sizeof(char) * num);   4: }   5: void Test(void)   6: {   7:    char *str = NULL;   8:    GetMemory(str, 100); // str 仍然为 NULL   9:    strcpy(str, "hello"); // 运行错误  10: }
       问题出在函数GetMemory中。编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。

      如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见示例:

   1: void GetMemory2(char **p, int num)   2: {   3:    *p = (char *)malloc(sizeof(char) * num);   4: }   5: void Test2(void)   6: {   7:    char *str = NULL;   8:    GetMemory2(&str, 100); // 注意参数是 &str,而不是str   9:    strcpy(str, "hello");  10:    cout<< str << endl;  11:    free(str);  12: }
      由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存。这种方法更加简单,见示例:

   1: char *GetMemory3(int num)   2: {   3:    char *p = (char *)malloc(sizeof(char) * num);   4:    return p;   5: }   6:     7: void Test3(void)   8: {   9:    char *str = NULL;  10:    str = GetMemory3(100);  11:    strcpy(str, "hello");  12:    cout<< str << endl;  13:    free(str);  14: }
      用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡,见示例:

   1: char *GetString(void)   2: {   3:    char p[] = "hello world";   4:    return p; // 编译器将提出警告   5: }   6:     7: void Test4(void)   8: {   9:    char *str = NULL;  10:    str = GetString(); // str 的内容是垃圾  11:    cout<< str << endl;  12: }
      用调试器逐步跟踪Test4,发现执行str = GetString语句后str不再是NULL指针,但是str的内容不是“hello world”而是垃圾。

      如果把上述示例改写成如下示例,会怎么样?

   1: char *GetString2(void)   2: {   3:    char *p = "hello world";   4:    return p;   5: }   6:     7: void Test5(void)   8: {   9:    char *str = NULL;  10:    str = GetString2();  11:    cout<< str << endl;  12: }
     函数Test5运行虽然不会出错,但是函数GetString2的设计概念却是错误的。因为GetString2内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。

1.8杜绝“野指针”
“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。 “野指针”的成因主要有两种:

(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如

   1: char *p = NULL;   2: char *str = (char *) malloc(100);
(2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

(3)指针操作超越了变量的作用域范围。这种情况让人防不胜防,示例程序如下:

   1: class A   2: {   3: public:   4:   void Func(void){ cout << “Func of class A” << endl; }   5: };   6:     7: void Test(void)   8: {   9:    A *p;  10:    {  11:     A a;  12:     p = &a; // 注意 a 的生命期  13:    }  14:    15:    p->Func(); // p是“野指针”  16:    17: }
函数Test在执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。

1.9malloc/free和new/delete的区别
      malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。

对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

       因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。我们先看一看malloc/free和new/delete如何实现对象的动态内存管理,见示例:

   1: class Obj   2: {   3: public :   4:   Obj(void){ cout << “Initialization” << endl; }   5:   ~Obj(void){ cout << “Destroy” << endl; }   6:   void Initialize(void){ cout << “Initialization” << endl; }   7:   void Destroy(void){ cout << “Destroy” << endl; }   8: };   9:    10: void UseMallocFree(void)  11: {  12:    Obj *a = (obj *)malloc(sizeof(obj)); // 申请动态内存  13:    a->Initialize(); // 初始化  14:    //…  15:    16:    a->Destroy(); // 清除工作  17:    free(a); // 释放内存  18: }  19:    20: void UseNewDelete(void)  21: {  22:    Obj *a = new Obj; // 申请动态内存并且初始化  23:    //…  24:    delete a; // 清除并且释放内存  25: }
       类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由于malloc/free不能执行构造函数与析构函数,必须调用成员函数Initialize和Destroy来完成初始化与清除工作。函数UseNewDelete则简单得多。所以我们不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。

既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,结果也会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

1.10 当内存耗尽是怎么办?
       如果在申请动态内存时找不到足够大的内存块,malloc和new将返回NULL指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。

(1)判断指针是否为NULL,如果是则马上用return语句终止本函数。例如:

   1: void Func(void)   2: {   3:    A *a = new A;   4:    if(a == NULL)   5:    {   6:     return;   7:    }   8:     9:    …  10: }
     (2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。例如:

   1: void Func(void)   2: {   3:     4:    A *a = new A;   5:    if(a == NULL)   6:    {   7:     cout << “Memory Exhausted” << endl;   8:     exit(1);   9:    }  10:    11:    …  12: }
     (3)为new和malloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。详细内容请参考C++使用手册。

上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。

很多人不忍心用exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”

不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1) 把坏程序杀死,它可能会害死操作系统。

有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致“内存耗尽”。我在Windows 98下用Visual C++编写了测试程序,见示例7。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98已经累得对键盘、鼠标毫无反应。

我可以得出这么一个结论:对于32位以上的应用程序,“内存耗尽”错误处理程序毫无用处。这下可把Unix和Windows程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。我不想误导读者,必须强调:不加错误处理将导致程序的质量很差,千万不可因小失大。

   1: void main(void)   2: {   3:     float *p = NULL;   4:     while(TRUE)   5:     {   6:         p = new float[1000000];   7:         cout << “eat memory” << endl;   8:         if(p==NULL)   9:         exit(1);  10:     }  11: }
1.11malloc和free的使用要点
    函数malloc的原型如下:

   1: void * malloc(size_t size);
    用malloc申请一块长度为length的整数类型的内存,程序如下:

   1: int *p = (int *) malloc(sizeof(int) * length);
    我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。

malloc返回值的类型是void *,所以在调用malloc时要显式地进行类型转换,将void * 转换成所需要的指针类型。
malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。例如int变量在16位系统下是2个字节,在32位下是4个字节;而float变量在16位系统下是4个字节,在32位下也是4个字节。最好用以下程序作一次测试:
   1: cout << sizeof(char) << endl;   2: cout << sizeof(int) << endl;   3: cout << sizeof(unsigned int) << endl;   4: cout << sizeof(long) << endl;   5: cout << sizeof(unsigned long) << endl;   6: cout << sizeof(float) << endl;   7: cout << sizeof(double) << endl;   8: cout << sizeof(void *) << endl;
在malloc的“()”中使用sizeof运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。

    函数free的原型如下:

   1: void free( void * memblock );
     为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。

1.12new和delete使用要点
      运算符new使用起来要比函数malloc简单得多,例如:

   1: int *p1 = (int *)malloc(sizeof(int) * length);   2: int *p2 = new int[length];
       这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。例如

   1: class Obj   2: {   3: public :   4:   Obj(void); // 无参数的构造函数   5:   Obj(int x); // 带一个参数的构造函数   6:     7:   …   8: }   9:    10: void Test(void)  11: {  12:      Obj *a = new Obj;  13:      Obj *b = new Obj(1); // 初值为1  14:       15:      …  16:       17:       delete a;  18:      delete b;  19: }
     如果用new创建对象数组,那么只能使用对象的无参数构造函数。例如:

   1: Obj *objects = new Obj[100]; // 创建100个动态对象
    不能写成:

   1: Obj *objects = new Obj[100](1);// 创建100个动态对象的同时赋初值1
     在用delete释放对象数组时,留意不要丢了符号‘[]’。例如:

   1: delete []objects; // Correct   2: delete objects; // Wrong
       后者有可能引起程序崩溃和内存泄漏。
PS:C++内存管理部分详解已经学习完,做好了学习笔记。接下来需要学习C++中健壮的一种指针:一种智能指针,或者说是smart pointers,以及它的特性等,这部分内容需要对C++理解比较深些,会花一些时间学习。

C++内存管理学习笔记(4)

上期内容回顾:传送门1,传送门2,传送门3

1.C++内存管理

1.1c语言和C++内存分配

1.2区分堆、栈、静态存储区
 1.3控制C++的内存分配

1.4内存管理的基本要求

1.5常见的内存错误及对策
 1.6数组和指针
1.7指针参数是如何传递内存的
1.8杜绝“野指针“
1.9malloc、free和new、delete的区别
1.10-1.12 malloc,free,new,delete使用

--------------------------------------------------------------------------------

2.C++中的健壮指针及资源管理
      对于资源,就是一旦用了它,将来必须还给系统。我们最常见的资源是动态分配内存,其他常见的还有:文件描述器、互斥锁、图形界面中的字形和笔刷、数据库连接、以及网络socket等等。

2.1 引入      
        对于给定的资源的拥有着,是负责释放资源的一个对象或者是一段代码。所有权分立为两种级别——自动的和显式的(automatic and explicit),如果一个对象的释放是由语言本身的机制来保证的,这个对象的就是被自动地所有。例如,一个嵌入在其他对象中的对象,他的清除需要其他对象来在清除的时候保证。外面的对象被看作嵌入类的所有者。类似地,每个在栈上创建的对象(作为自动变量)的释放是在控制流离开了对象被定义的作用域的时候保证的。这种情况下,作用于被看作是对象的所有者。注意所有的自动所有权都是和语言的其他机制相容的,包括异常。无论是如何退出作用域的——正常流程控制退出、一个break语句、一个return、一个goto、或者是一个throw——自动资源都可以被清除。

     OK!,在引入指针、句柄和抽象的时候产生了问题。如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如说,如果一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。基本的资源管理的前提就是确保每个资源都有他们的所有者。

2.2 第一条规则RAII(Resource Acquisition Is Initialization)
       RAII是指C++语言中的一个惯用法(idiom),它是“Resource Acquisition Is Initialization”的首字母缩写。中文可将其翻译为“资源获取就是初始化”。我们怎么理解这句话呢,在一本c++书中有这么一句:“使用局部对象管理资源的技术通常称为“资源获取就是初始化(RAII)”。这种通用技术依赖于构造函数和析构函数的性质以及它们与异常处理的交互作用。”通俗的说一下就是:一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。

     为什么会有这个规则?为什么资源要在构造函数中申请及初始化以及析构函数中释放?让我们一个例子来说明,

下面的UseFile函数中:

   1: void UseFile(char const* fn)   2: {   3:     FILE* f = fopen(fn, "r"); // 获取资源   4:     // 在此处使用文件句柄f...    // 使用资源   5:     fclose(f); // 释放资源   6: }
      调用fopen()打开文件就是获取文件句柄资源,操作完成之后,调用fclose()关闭文件就是释放该资源。资源的释放工作至关重要,如果只获取而不释放,那么资源最终会被耗尽。上面的代码是否能够保证在任何情况下都调用fclose函数呢?请考虑如下情况:

   1: void UseFile(char const* fn)   2: {   3:     FILE* f = fopen(fn, "r"); // 获取资源   4:     // 使用资源   5:     if (!g()) return; // 如果操作g失败!   6:     // ...   7:     if (!h()) return; // 如果操作h失败!   8:     // ...   9:     fclose(f); // 释放资源  10: }
     在使用文件f的过程中,因某些操作失败而造成函数提前返回的现象经常出现。在操作g或h失败之后,UseFile函数必须首先调用fclose()关闭文件,然后才能返回其调用者,否则会造成资源泄漏。因此,需要将UseFile函数修改为:

   1:     void UseFile(char const* fn)   2:     {   3:     FILE* f = fopen(fn, "r");    4:     //获取资源   5:     //使用资源   6:     if (!g())    7:     {fclose(f);return; }   8:     // ...   9:     if (!h())   10:     {fclose(f);return; }  11:     // ...  12:     fclose(f);   13:     //释放资源  14: }
      现在的问题是:用于释放资源的代码fclose(f)需要在不同的位置重复书写多次。如果再加入异常处理,情况会变得更加复杂。

      假设UseResources函数要用到n个资源,则进行资源管理的一般模式为:

   1: void UseResources()   2: {   3:     //获取资源1   4:     //    5:     ...   6:     //获取资源n   7:     //使用这些资源   8:     //释放资源n   9:     //   10:     ...  11:     //释放资源1  12: }
      获取资源和释放资源要对应,这里就会面临上面示例的麻烦。释放的不彻底将会导致memory leak,致使程序臃肿、出错等。看到这里自然而然的可以想到C++中的一对特殊函数,构造函数和析构函数。在构造函数中申请资源,以及在析构函数中释放资源。类是C++中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。这就是RAII惯用法,RAII有效地实现了C++资源管理的自动化。

     当你按照RAII规则将所有资源封装的时候,可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。对于任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。

     下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。

   1: class CritSect   2: {   3:     friend class Lock;   4: public:   5:     CritSect () { InitializeCriticalSection (&_critSection); }   6:     ~CritSect () { DeleteCriticalSection (&_critSection); }   7: private:   8:     void Acquire ()   9:     {  10:         EnterCriticalSection (&_critSection);  11:     }  12:     void Release ()  13:     {  14:         LeaveCriticalSection (&_critSection);  15:     }  16: private:  17:     CRITICAL_SECTION _critSection;  18: };
  这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开。"进入"临界区的状态是一种资源,并应当被封装。封装器通常被称作一个锁(lock)。

   1: class Lock   2: {   3: public:   4:     Lock (CritSect& critSect) : _critSect (critSect)   5:     {   6:         _critSect.Acquire ();   7:     }   8:     ~Lock ()   9:     {  10:         _critSect.Release ();  11:     }  12: private  13:     CritSect & _critSect;  14: };
  锁一般的用法如下:

   1: void Shared::Act () throw (char *)   2: {   3:     Lock lock (_critSect);   4:     // perform action —— may throw   5:     // automatic destructor of lock   6: }
 注意无论发生什么,临界区都会借助于语言的机制保证释放。

    还有一件需要记住的事情——每一种资源都需要被分别封装。这是因为资源分配是一个非常容易出错的操作,是要资源是有限提供的。我们会假设一个失败的资源分配会导致一个异常——事实上,这会经常的发生。所以如果你想试图用一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源,你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失败抛出异常时会发生什么。因为构造函数还没有全部完成,析构函数不可能被调用,第一种资源就会发生泄露。

       这种情况可以非常简单的避免。无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中。每一个嵌入的构造都可以保证删除,即使包装类没有构造完成。这是对需要管理多个资源的复杂对象来说的,下面的例子说明了这样情形,

   1: class FileHandle {   2: public:   3:     FileHandle(char const* n, char const* a) { p = fopen(n, a); }   4:     ~FileHandle() { fclose(p); }   5: private:   6:     // 禁止拷贝操作   7:     FileHandle(FileHandle const&);   8:     FileHandle& operator= (FileHandle const&);   9:     FILE *p;  10: };
   1: class Widget {   2: public:   3:     Widget(char const* myFile, char const* myLock)   4:     : file_(myFile),     // 获取文件myFile   5:       lock_(myLock)      // 获取互斥锁myLock   6:     {}   7:     // ...   8: private:   9:     FileHandle file_;  10:     LockHandle lock_;  11: };
      Widget类的构造函数要获取两个资源:文件myFile和互斥锁myLock。每个资源的获取都有可能失败并且抛出异常。FileHandle和LockHandle类的对象作为Widget类的数据成员,分别表示需要获取的文件和互斥锁。资源的获取过程就是两个成员对象的初始化过程。在此系统会自动地为我们进行资源管理,程序员不必显式地添加任何异常处理代码。例如,当已经创建完file_,但尚未创建完lock_时,有一个异常被抛出,则系统会调用file_的析构函数,而不会调用lock_的析构函数。

      综合以上的内容,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。

2.3 Smart pointers(智能指针)
      在《C++内存管理技术内幕》中,是这么解释smart pointer的。

        如果我们用操作符new来动态申请一个对象,此后用指针访问的一个对象。我们需要为每个对象分别定义一个封装类吗?让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类,它十分坚固,甚至无法实现。

   1: template <class T>   2: class SmartPointer   3: {   4: public:   5:     ~SmartPointer () { delete _p; }   6:     T * operator->() { return _p; }   7:     T const * operator->() const { return _p; }   8: protected:   9:     SmartPointer (): _p (0) {}  10:     explicit SmartPointer (T* p): _p (p) {}  11:     T * _p;  12: };
  为什么要把SmartPointer的构造函数设计为protected呢?如果需要遵守第一条规则,那么就必须这样做。资源——在这里是class T的一个对象——必须在封装器的构造函数中分配。但是不能只简单的调用new T,因为我不知道T的构造函数的参数。因为,在原则上,每一个T都有一个不同的构造函数;我需要为他定义个另外一个封装器。模板的用处会很大,为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。

   1: class SmartItem: public SmartPointer<Item>   2: {   3: public:   4:     explicit SmartItem (int i)   5:     : SmartPointer<Item> (new Item (i)) {}   6: };
  为每一个类提供一个Smart Pointer真的值得吗?说实话——不!他很有教学的价值,但是一旦你学会如何遵循第一规则的话,你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但是只是是用它来做资源转换(Resource Transfer)。我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:

   1: SmartPointer<Item> item (new Item (i));
  这个方法明显更需要自控性,不只是你,而且包括你的程序小组的每个成员。他们都必须发誓出了作资源转换外不把构造函数用在人以其他用途。幸运的是,这条规矩很容易得以加强。只需要在源文件中查找所有的new即可。

      看到这里,你肯定和我一样会有很多疑问,不要着急,慢慢来看。下面以c++中的auto_ptr来说明。


2.4 auto_ptr类

      首先,什么是smart pointer?  智能指针(Smart pointer)是一种抽象的数据类型。在程序设计中,它通常是经由类模板(class template)来做,借由模板(template)来达成泛型,通常借由类型(class)的析构函数来达成自动释放指针所指向的存储器或对象。

什么是类模板和泛型编程?---《C++ primer》
1 类模板(class template)
     模板定义:模板就是实现代码重用机制的一种工具,它可以实现类型参数化,即把类型定义为参数, 从而实现了真正的代码可重用性。模版可以分为两类,一个是函数模版,另外一个是类模版。 
函数模板的一般形式如下:

   1: Template <class或者也可以用typename T>   2: 返回类型 函数名(形参表)   3: {//函数定义体 }
说明: template是一个声明模板的关键字,表示声明一个模板关键字class不能省略,如果类型形参多余一个 ,每个形参前都要加class <类型 形参表>可以包含基本数据类型可以包含类类型.

定义一个类模板:

   1: template<class 模板参数表>   2: class 类名{   3: // 类定义......   4: };
说明:其中,template是声明各模板的关键字,表示声明一个模板,模板参数可以是一个,也可以是多个。


2泛型编程
      泛型编程就是以独立于任何特定的方式编写代码。 泛型编程最初诞生于C++中,,由Alexander Stepanov和David Musser创立。目的是为了实现C++的STL(标准模板库)。其语言支持机制就是模板(Templates)。

      泛型编程的核心活动是抽象:将一个特定于某些类型的算法中那些类型无关的共性抽象出来,比如,在STL的概念体系里面,管你是一个数组还是一个链表,反正都是一个区间,这就是一层抽象。管你是一个内建函数还是一个自定义类,反正都是一个Callable(可调用)的对象(在C++里面通过仿函数来表示),这就是一层抽象。泛型编程的过程就是一个不断将这些抽象提升(lift)出来的过程,最终的目的是形成一个最大程度上通用的算法或类。
 
说了这么一大堆,肯定会茫茫然,这是正常的,想研究泛型编程的请仔细阅读《C++ Primer》一书。这里主要是为解释smart pointer而做的铺垫。

       在C++ primer上面提供了了两种解决方案,设置拥有权的转移和使用引用计数的方式。针对这个两个解决方案,出现了两种风格的智能指针,STL中的auto_ptr属于拥有权转移指针,boost中的shared_ptr属于引用计数型(boost里面的智能指针有6个,这里只是其中一个)。

       本文这里主要讲解其中的auto_ptr类方式,为了更好的理解后续笔记的内容提前做一个铺垫。

 (1)auto_ptr类

        C++标准模板库有一个模板类,叫做auto_ptr,其作用就是提供这种封装。它是上一节介绍的RAII规则的例子。auto_ptr类是接收一个类型形参的模板,它为动态分配的对象提供异常安全。我们来看一个例子,auto_ptr的部分实现,说明什么是auto_ptr:

   1: template <class T> class auto_ptr   2: {   3:     T* ptr;   4: public:   5:     explicit auto_ptr(T* p = 0) : ptr(p) {}   6:     ~auto_ptr()                 {delete ptr;}   7:     T& operator*()              {return *ptr;}   8:     T* operator->()             {return ptr;}   9:     // ...  10: };
     auto_ptr is a simple wrapper around a regular pointer. It forwards all meaningful operations to this pointer (dereferencing and indirection). Its smartness in the destructor: the destructor takes care of deleting the pointer.(auto_ptr 只是简单的包含一个常规指针T* p,它(间接的和非关联的)指向所有有意义的操作。在析构函数中更加智能化:析构函数负责删除指针。)

  (2)auto_ptr操作

auto_ptr<T> ap;               创建名为 ap 的未绑定的 auto_ptr 对象

auto_ptr<T>  ap(p);         创建名为 ap 的 auto_ptr 对象,ap 拥有指针 p 指向的对象。该构造函数为explicit                             

auto_ptr<T> ap1(ap2);   创建名为 ap1 的 auto_ptr 对象,ap1 保存原来存储在ap2 中的指针。将所有权转给 ap1,ap2 成为未绑定的auto_ptr 对象

ap1 = ap2                         将所有权 ap2 转给 ap1。删除 ap1 指向的对象并且使 ap1指向 ap2 指向的对象,使 ap2 成为未绑定的

~ap                                     析构函数。删除 ap 指向的对象

*ap                                      返回对 ap 所绑定的对象的引用

ap->                                   返回 ap 保存的指针

ap.reset(p)                        如果 p 与 ap 的值不同,则删除 ap 指向的对象并且将 ap绑定到 p

ap.release()                       返回 ap 所保存的指针并且使 ap 成为未绑定的

ap.get()                             返回 ap 保存的指针

注意:
auto_ptr只能用于管理从new返回的一个对象,它不能管理动态分配的数组。当auto_ptr被复制或赋值的时候,有不寻找的行为,因此不能将auto_ptr存储在标准库容器类中。

      每个auto_ptr对象绑定到一个对象或者指向一个对象。当auto_ptr对象指向一个对象的时候,可以说它“拥有”该对象。当auto_ptr对象超出作用域或者另外撤销的时候,就自动回收auto_ptr所指向的动态分配对象。

(3)内存分配中使用auto_ptr

    如果通过常规指针分配内存,而且在执行delete之前发生异常,就不会自动释放内存,

   1: void f()   2: {   3:     int *ip = ne int(42);   //dynamically allocate a new object   4:     //code that throws an exception that is not caugth inside f   5:     delete ip;             //return the memory before exiting   6: }
    如果在new和delet之间发生异常,并且该异常不被局部捕获,就不会执行delet,永远也收不回该内存,若使用auto_ptr对象来替代,将会自动释放内存,即使提早退出这个块,
   1: void f()   2: {   3:     auto_ptr<int> ap(new int(42)); // allocate a new object   4:     // code that throws an exception that is not caught inside f   5: }   // auto_ptr freed automatically when function ends
    这个例子中,编译器保证在展开栈越过f之前运行ap的析构函数。

(4)auto_ptr是可以保存任何类型指针的模板

     auto_ptr类是接收单个类型形参的模板,该类型指定auto_ptr可以绑定的对象类型,因此,可以创建任何类型的auto_ptr:

   1: auto_ptr<string> ap1(new string("Brontosaurus"));
(5)将auto_ptr绑定到指针

     在最常见的情况下,将auto_ptr对象初始化为由new表达式返回的对象的地址:

   1: auto_ptr<int> pi(new int(1024));
     注意,接受指针的构造函数为explicit构造函数,所以必须用初始化的直接形式来创建auto_ptr对象。

   1: auto_ptr<int> pi(new int(1024)); // ok: uses direct initialization   2: auto_ptr<int> pi = new int(1024);// error: constructor that takes a pointer is explicit and can't be used implicitly
     pi所指的由new表达式创建的对象在超出作用域时自动删除。

(6)使用auto_ptr对象

   1: auto_ptr<string> ap1(new string("Hellobaby!"));   2: *ap1 = "TRex"; // assigns a new value to the object to which ap1 points   3: string s = *ap1; // initializes s as a copy of the object to which ap1 points   4: if (ap1->empty()) // runs empty on the string to which ap1 points
     auto_ptr的主要目的是在保证自动删除auto_ptr对象引用的对象的同时,支持普通指针式行为。

(7)auto_ptr对象的赋值和复制是破坏性操作

      auto_ptr与普通指针的复制和赋值有区别。普通指针赋值或复制后两个指针指向同一对象,而auto_ptr对象复制或赋值后,将基础对象的所有权从原来的auto_ptr对象转给副本,原来的auto_ptr对象重置成为未绑定状态。

   1: auto_ptr<string> ap1(new string("stegosaurus"));   2: //after the copy ap1 is unbound   3: auto_ptr<string> ap2(ap1); //ownership transferred from ap1 to ap2
      auto_ptr的复制和赋值改变右操作数,因此,auto_ptr赋值的左右操作数必须是可修改的左值。auto_ptr不能存储在标准容器中,因为标准库容器要求在复制或赋值后两对象相等,auto_ptr不满足這条件,如果将ap2赋值给ap1,则在赋值后ap1!=ap2,复制也类似。

(8)赋值删除左操作数指向的对象

     除了将所有权从右操作数转给左操作数外,赋值还删除左操作数原来指向的对象--假如两个对象不同,通常自身赋值没有效果。

   1: auto_ptr<string> ap3(new string("pterodacty1"));   2: //object pointed to by ap3 is deleted and ownership transferred from ap2 to ap3;   3: ap3 = ap2; //after the assignment,ap2 is unbound
将ap2赋值给ap3后,1)删除了ap3指向的对象;2)将ap3置为指向ap2指向的对象;3)ap2是未绑定的auto_ptr对象

(9)auto_ptr的默认构造函数

     如果不给定初始式,auto_ptr对象是未绑定的,它不指向任何对象,默认情况下,auto_ptr的内部指针值置为0。

(10)测试auto_ptr对象

    例子中第一种条件测试是错误的, auto_ptr 类型没有定义到可用作条件的类型的转换,相反,要测试auto_ptr 对象,必须使用它的 get 成员,该成员返回包含在 auto_ptr 对象中的基础指针。

示例:

   1: // error: cannot use an auto_ptr as a condition   2: if (p_auto)   3:     *p_auto = 1024;   4:     5: // revised test to guarantee p_auto refers to an object   6: if (p_auto.get())   7:     *p_auto = 1024;
    应该只用 get 询问 auto_ptr 对象或者使用返回的指针值,不能用 get 作为创建其他 auto_ptr 对象的实参。(原因:使用get成员初始化其他auto_ptr对象,违反了auto_ptr类的设计原则,在任意时刻只有一个auto_ptr对象保存给定指针,如果两个auto_ptr对象保存相同指针,则该指针会被delete两次!!!)

(11)reset操作

     auto_ptr对象与内置指针的另一个区别是不能直接将一个地址(或其它指针)赋给auto_ptr对象。

   1: #include <iostream>   2: #include "memory"   3: using namespace std;   4: int main()   5: {   6:     auto_ptr<int> p_auto(new int(1024));   7:     //p_auto = new int(1024); // error: cannot assign a pointer to an auto_ptr   8:     if (p_auto.get())   9:         *p_auto = 1024;  10:     else  11:         p_auto.reset(new int(1042));  12:     return 1;  13: }
      正如自身赋值是没有效果的一样,如果调用该 auto_ptr 对象已经保存的同一指针的 reset 函数,也没有效果,不会删除对象。

(12)正确使用auto_ptr类的限制(auto_ptr的缺陷)

1)不要使用auto_ptr对象保存指向静态分配对象的指针,否则,当auto_ptr对象本身被撤销的时候,它将试图删除指向非动态分配对象的指针,导致未定义的行为。

2)永远不要使用两个 auto_ptrs 对象指向同一对象,导致这个错误的一种明显方式是,使用同一指针来初始化或者 reset 两个不同的 auto_ptr对象。另一种导致这个错误的微妙方式可能是,使用一个 auto_ptr 对象的 get 函数的结果来初始化或者 reset另一个 auto_ptr 对象。

3)不要使用 auto_ptr 对象保存指向动态分配数组的指针。当auto_ptr 对象被删除的时候,它只释放一个对象—它使用普通delete 操作符,而不用数组的 delete [] 操作符。

4)不要将 auto_ptr 对象存储在容器中。容器要求所保存的类型定义复制和赋值操作符,使它们表现得类似于内置类型的操作符:在复制(或者赋值)之后,两个对象必须具有相同值,auto_ptr 类不满足这个要求。

    讲到这里,相信大家对智能指针中的auto_ptr对象有了清晰的认识,想多研究或者学习的请自行查找资料。文章中详细解释智能指针auto_ptr,一是为了对第一规则RAII的理解,二是为了对智能指针有个清晰的认识,怎么使用,注意些什么等等,三是为了对学习笔记后续中strong pointer等知识的理解。

   如果你认为文章内容有不正确或者不准确的地方,请指出。互相学习!

参考文献详见《c++内存管理学习纲要》一文;

 

--------------------------------------------------------------------------------


PS:学习是延伸的,这个我毫不怀疑!文章主线是对C++中的健壮指针和资源管理的学习,在学习过程中不断延伸到各个知识点,比如c++中的模板和泛型编程,c++标准库中的智能指针,STL中相关部分的实现等等,这些需要读者自己学习了。

C++内存管理学习笔记(5)

上期内容回顾:

C++内存管理学习笔记(4)

     2.1-2.2 RAII规则(引入)  2.3 smart pointer   2.4 auto_ptr类 


--------------------------------------------------------------------------------

2.5 资源传递
       资源传递(Resource Transfer)主要是讲述在不同的作用域间安全的传递资源。这一问题在当你处理容器的时候会变得十分明显。你可以动态的创建一串对象,将它们存放至一个容器中,然后将它们取出,并且在最终安排它们。为了能够让这安全的工作——没有泄露——对象需要改变其所有者。

       这个问题的一个非常显而易见的解决方法是使用Smart Pointer,无论是在加入容器前还是还找到它们以后。这是他如何运作的,你加入Release方法到Smart Pointer中:

   1: template <class T>   2: T * SmartPointer<T>::Release ()   3: {   4:     T * pTmp = _p;   5:     _p = 0;   6:     return pTmp;   7: }

       注意在Release调用以后,Smart Pointer就不再是对象的所有者了——它内部的指针指向空。现在,调用了Release都必须是一个负责的人并且迅速隐藏返回的指针到新的所有者对象中。在我们的例子中,容器调用了Release,比如这个Stack的例子:

   1: void Stack::Push (SmartPointer <Item> & item) throw (char *)   2: {   3:     if (_top == maxStack)   4:     throw "Stack overflow";   5:     _arr [_top++] = item.Release ();   6: };
      同样的,你也可以再你的代码中用加强Release的可靠性。

      这部分内容可以参考学习《C++内存管理学习笔记(3)》中的auto_ptr智能指针,auto_ptr对象通过赋值、复制和reset操作改变对象的所有者。

2.6 共享所有权
        为每一个程序中的资源都找出或者指定一个所有者,对于共享所有权来说是最好的的选择方式。

共享的责任分配给被共享的对象和它的客户(client)。一个共享资源必须为它的所有者保持一个引用计数。另一方面,所有者再释放资源的时候必须通报共享对象。最后一个释放资源的需要在最后负责free的工作。

例子:最简单的共享的实现是共享对象继承引用计数的类RefCounted:

   1: class RefCounted   2: {   3: public:   4:     RefCounted () : _count (1) {}   5:     int GetRefCount () const { return _count; }   6:     void IncRefCount () { _count++; }   7:     int DecRefCount () { return --_count; }   8: private:   9:     int _count;  10: };
       按照资源管理,一个引用计数是一种资源。如果你遵守它,你需要释放它。当你意识到这一事实的时候,剩下的就变得简单了。简单的遵循规则--再构造函数中获得引用计数,在析构函数中释放。

      在上一个学习笔记(3)中提到过,智能指针有两种方式,分别为设置拥有权的转移和使用引用计数的方式。针对这个两个解决方案,出现了两种风格的智能指针,STL中的auto_ptr属于拥有权转移指针,boost中的shared_ptr属于引用计数型(boost里面的智能指针有6个,scoped_ptr、scoped_array、shared_array、intrusive_ptr、weak_ptr)。

小问:STL和boost?
1.STL
      标准库中提供了C++程序的基本设施。虽然C++标准库随着C++标准折腾了许多年,直到标准的出台才正式定型,但是在标准库的实现上却很令人欣慰得看到多种实现,并且已被实践证明为有工业级别强度的佳作。
    STL的最主要的两个特点:数据结构和算法的分离,非面向对象本质。访问对象是通过象指针一样的迭代器实现的;容器是象链表,矢量之类的数据结构,并按模板方式提供;算法是函数模板,用于操作容器中的数据。由于STL以模板为基础,所以能用于任何数据类型和结构.
       (1) STL是数据结构和算法的分离。尽管这是个简单的概念,但这种分离确实使得STL变得非常通用。例如,由于STL的sort()函数是完全通用的,你可以用它来操作几乎任何数据集合,包括链表,容器和数组。
       (2) STL它不是面向对象的。为了具有足够通用性,STL主要依赖于模板而不是封装,继承和虚函数(多态性)——OOP的三个要素。你在STL中找不到任何明显的类继承关系。这好像是一种倒退,但这正好是使得STL的组件具有广泛通用性的底层特征。另外,由于STL是基于模板,内联函数的使用使得生成的代码短小高效。
2.boost
      Boost库是一个经过千锤百炼、可移植、提供源代码的C++库,作为标准库的后备,是C++标准化进程的发动机之一。 Boost库由C++标准委员会库工作组成员发起,在C++社区中影响甚大,其成员已近2000人。 Boost库为我们带来了最新、最酷、最实用的技术,是不折不扣的“准”标准库。

Boost中比较有名气的有这么几个库:

Regex:正则表达式库;

Spirit LL parser framework,用C++代码直接表达EBNF

Graph:图组件和算法;

Lambda:在调用的地方定义短小匿名的函数对象,很实用的functional功能

concept check:检查泛型编程中的concept

Mpl:用模板实现的元编程框架

Thread:可移植的C++多线程库

Python:把C++类和函数映射到Python之中

Pool:内存池管理

smart_ptr

      Boost总体来说是实用价值很高,质量很高的库。并且由于其对跨平台的强调,对标准C++的强调,是编写平台无关,现代C++的开发者必备的工具。但是Boost中也有很多是实验性质的东西,在实际的开发中实用需要谨慎。并且很多Boost中的库功能堪称对语言功能的扩展,其构造用尽精巧的手法,不要贸然的花费时间研读。Boost另外一面,比如Graph这样的库则是具有工业强度,结构良好,非常值得研读的精品代码,并且也可以放心的在产品代码中多多利用。

区别:

    boost是一个准标准库,相当于STL的延续和扩充,它的设计理念和STL比较接近,都是利用泛型让复用达到最大化。不过对比STL,boost更加实用。 STL集中在算法部分,而boost包含了不少工具类,可以完成比较具体的工作。
 

     接下来对share_ptr进行讲解,share_ptr是可以共享所有权的智能指针。

2.7 share_ptr
(1)boost中的智能指针

Boost提供了下面几种智能指针(Smart Pointers to boost your code):

 


     将原文部分放上来,防止笔者翻译水平有限,影响大家阅读,请对照内容:

share_ptr<T> 使用一个引用计数器来判断此指针是不是需要被释放。是boost中最常用的智能指针了。
scope_ptr<T> 当这个指针的作用域消失之后自动释放,性能与内置的指针差不多
intrusive_ptr<T> 也维护一个引用计数器,比shared_ptr有更好的性能。但是要求T自己提供这个引用计数机制。
weak_ptr<T> 弱指针,要和shared_ptr 结合使用避免循环引用
share_array<T> 和shared_ptr相似,但是访问的是数组
scope_array<T> 和scoped_ptr相似,但是访问的是数组

(2)share_ptr引入

     首先,我们通过例子来了解这个智能指针,

   1: void Sample_Shared()   2: {   3:   // (A) create a new CSample instance with one reference   4:   boost::shared_ptr<CSample> mySample(new CSample);    5:   printf("The Sample now has %i references\n", mySample.use_count()); // should be 1   6:     7:   // (B) assign a second pointer to it:   8:   boost::shared_ptr<CSample> mySample2 = mySample; // should be 2 refs by now   9:   printf("The Sample now has %i references\n", mySample.use_count());  10:    11:   // (C) set the first pointer to NULL  12:   mySample.reset();   13:   printf("The Sample now has %i references\n", mySample2.use_count());  // 1  14:    15:   // the object allocated in (1) is deleted automatically  16:   // when mySample2 goes out of scope  17: }

     在代码块(A)中,在堆中创建一个CSample对象,通过绑定share_ptr指针到mySample,如下图示:

 


   (B)中我们通过另外一个mySample2指针指向这个对象,如下图示:

 


   之后(C),reset操作第一个指针对象(p=NULL),但是CSample对象没有被释放,因为它mySample2在引用。

 


    只有当最后的引用释放掉后,出了当前作用域时,CSample对象的内存被释放掉。

 


  下面是shared_ptr一些应用案例: 

use in containers
using the pointer-to-implementation idiom (PIMPL)
Resource-Acquisition-Is-Initialization (RAII) idiom
Separating Interface from Implementation
      1>在容器中使用;

      2>PIMPL(pointer to implementation)惯例,即“实现的指针较短”;

      3>RAII()惯例; (详细讲解见《学习笔记(4)》)

      4>类的使用接口和实现分离

小知识: PIMPL idiom与RAII idiom

1.RAII

   RAII是Bjarne Stroustrup教授用于解决资源分配而发明的技术,资源获取即初始化。RAII是C++的构造机制的直接使用,即利用构造函数分配资源,利用析构函数来回收资源.

2.PIMPL

    PIMPL是一种应用十分广泛的技术,它的别名也很多,如Opaque pointer, handle classes等。PIMPL是RAII的延展,籍由RAII对资源的控制,把具体的数据布局和实现从调用者视线内移开,从而简化了API接口,也使得ABI兼容变得有可能,Qt和KDE正是使用Pimpl来维护ABI的一致性,另外也为惰性初始化提供途径,以及隐式共享提供了基础。

详细介绍参考:http://c2.com/cgi/wiki?PimplIdiom或者wiki;

   PIMPL或者RAII是C++程序中众所周知的重要概念, 智能指针只是实现这两种惯用手法的一种方式.

(If you never heard of PIMPL (a.k.a. handle/body) or RAII, grab a good C++ book - they are important concepts every C++ programmer should know. Smart pointers are just one way to implement them conveniently in certain cases)
 


(3)share_ptr的特点

这里引用《Smart Pointers to boost your code》一文中对share_ptr特点的描述,

shared_ptr<T> works with an incomplete type:
     When declaring or using a shared_ptr<T>, T may be an "incomplete type". E.g., you do only a forward declaration usingclass T;. But do not yet define howT really looks like. Only where you dereference the pointer, the compiler needs to know "everything".

shared_ptr<T> works with any type:
    There are virtually no requirements towards T (such as deriving from a base class).

shared_ptr<T> supports a custom deleter
    So you can store objects that need a different cleanup than delete p. For more information, see the boost documentation.

Implicit conversion:
   If a type U * can be implicitly converted to T * (e.g., becauseT is base class ofU), a shared_ptr<U> can also be converted toshared_ptr<T> implicitly.

shared_ptr is thread safe
    (This is a design choice rather than an advantage, however, it is a necessity in multithreaded programs, and the overhead is low.)

Works on many platforms, proven and peer-reviewed, the usual things.
综合来说,shared_ptr 具有可以共享和转移所有权,可以被标准库的容器所使用 ,线程安全的,不能指向一块动态增长的内存(用share_array代替)等特点。

(4)举例:在容器中使用share_ptr

     在许多容器类包括标准STL容器中,都需要复制操作(inserting an existing element into a list, vector, or container)。然而,当这种复制操作很复杂或者难以实现可用的时候,指针容器是一种简单有效的解决方式。例如下面的例子:

   1: std::vector<CMyLargeClass *> vec;   2: vec.push_back( new CMyLargeClass("bigString") );
     上面这个程序将内存管理任务的交给其调用者,这里我们可以使用share_ptr来改写它,

   1: typedef boost::shared_ptr<CMyLargeClass>  CMyLargeClassPtr;   2: std::vector<CMyLargeClassPtr> vec;   3: vec.push_back( CMyLargeClassPtr(new CMyLargeClass("bigString")) );
    这样改写后对任务的内存管理就非常简单了,当容器被destroyed,其中的元素也随之自动的destroyed。

但是,如果还有其他智能指针在引用它,则引用的那个元素依然存在,而不被释放掉。如下程序,


   1: void Sample3_Container()   2: {   3:   typedef boost::shared_ptr<CSample> CSamplePtr;   4:     5:   // (A) create a container of CSample pointers:   6:   std::vector<CSamplePtr> vec;   7:     8:   // (B) add three elements   9:   vec.push_back(CSamplePtr(new CSample));  10:   vec.push_back(CSamplePtr(new CSample));  11:   vec.push_back(CSamplePtr(new CSample));  12:    13:   // (C) "keep" a pointer to the second:   14:   CSamplePtr anElement = vec[1];  15:    16:   // (D) destroy the vector:  17:   vec.clear();  18:    19:   // (E) the second element still exists  20:   anElement->Use();  21:   printf("done. cleanup is automatic\n");  22:    23:   // (F) anElement goes out of scope, deleting the last CSample instance  24: }

(5)使用share_ptr需要注意的地方

1. shared_ptr多次引用同一数据,如下:

   1: {   2:     int* pInt = new int[100];   3:     boost::shared_ptr<int> sp1(pInt);   4:     // 一些其它代码之后…   5:     boost::shared_ptr<int> sp2(pInt);   6: }
     这种情况在实际中是很容易发生的,结果也是非常致命的,它会导致两次释放同一块内存,而破坏堆。

2. 使用shared_ptr包装this指针带来的问题,如下:

   1: class tester   2: {   3: public:   4:     tester()   5:     ~tester()   6:     {   7:         std::cout << "析构函数被调用!\n";   8:     }   9: public:  10:     boost::shared_ptr<tester> sget()  11:     {  12:         return boost::shared_ptr<tester>(this);  13:     }  14: };  15: int main()  16: {  17:     tester t;  18:     boost::shared_ptr<tester> sp = t.sget(); // …  19:     return 0;  20: }

     也将导致两次释放t对象破坏堆栈,一次是出栈时析构,一次就是shared_ptr析构。若有这种需要,可以使用下面代码。

   1: class tester : public boost::enable_shared_from_this<tester>   2: {   3: public:   4:     tester()   5:     ~tester()   6:     {   7:         std::cout << "析构函数被调用!\n";   8:     }   9: public:  10:     boost::shared_ptr<tester> sget()  11:     {  12:         return shared_from_this();  13:     }  14: };  15: int main()  16: {  17:     boost::shared_ptr<tester> sp(new tester);  18:     // 正确使用sp 指针。  19:     sp->sget();  20:     return 0;  21: }
3. shared_ptr循环引用导致内存泄露,代码如下:

   1: class parent;   2: class child;   3: typedef boost::shared_ptr<parent> parent_ptr;   4: typedef boost::shared_ptr<child> child_ptr;   5: class parent   6: {   7: public:   8:     ~parent() {   9:         std::cout <<"父类析构函数被调用.\n";  10:     }  11: public:  12:     child_ptr children;  13: };  14: class child  15: {  16: public:  17:     ~child() {  18:         std::cout <<"子类析构函数被调用.\n";  19:     }  20: public:  21:     parent_ptr parent;  22: };  23: int main()  24: {  25:     parent_ptr father(new parent());  26:     child_ptr son(new child);  27:     // 父子互相引用。  28:     father->children = son;  29:     son->parent = father;  30:     return 0;  31: }
      如上代码,将在程序退出前,father的引用计数为2,son的计数也为2,退出时,shared_ptr所作操作就是简单的将计数减1,如果为0则释放,显然,这个情况下,引用计数不为0,于是造成father和son所指向的内存得不到释放,导致内存泄露。

4. 在多线程程序中使用shared_ptr应注意的问题。代码如下:

   1: class tester   2: {   3: public:   4:     tester() {}   5:     ~tester() {}   6:     // 更多的函数定义…   7: };   8: void fun(boost::shared_ptr<tester> sp)   9: {  10:     // !!!在这大量使用sp指针.  11:     boost::shared_ptr<tester> tmp = sp;  12: }  13: int main()  14: {  15:     boost::shared_ptr<tester> sp1(new tester);  16:     // 开启两个线程,并将智能指针传入使用。  17:     boost::thread t1(boost::bind(&fun, sp1));  18:     boost::thread t2(boost::bind(&fun, sp1));  19:     t1.join();  20:     t2.join();  21:     return 0;  22: }
       这个代码带来的问题很显然,由于多线程同是访问智能指针,并将其赋值到其它同类智能指针时,很可能发生两个线程同时在操作引用计数(但并不一定绝对发生),而导致计数失败或无效等情况,从而导致程序崩溃,如若不知根源,就无法查找这个bug,那就只能向上帝祈祷程序能正常运行。

可能一般情况下并不会写出上面这样的代码,但是下面这种代码与上面的代码同样,如下:

   1: class tester   2: {   3: public:   4:     tester() {}   5:     ~tester() {}   6: public:   7:     boost::shared_ptr<int> m_spData; // 可能其它类型。   8: };   9: tester gObject;  10: void fun(void)  11: {  12:     // !!!在这大量使用sp指针.  13:     boost::shared_ptr<int> tmp = gObject.m_spData;  14: }  15: int main()  16: {  17:     // 多线程。  18:     boost::thread t1(&fun);  19:     boost::thread t2(&fun);  20:     t1.join();  21:     t2.join();  22:     return 0;  23: }
     情况是一样的。要解决这类问题的办法也很简单,使用boost.weak_ptr就可以很方便解决这个问题。第一种情况修改代码如下:

   1: class tester   2: {   3: public:   4:     tester() {}   5:     ~tester() {}   6:     // 更多的函数定义…   7: };   8: void fun(boost::weak_ptr<tester> wp)   9: {  10:     boost::shared_ptr<tester> sp = wp.lock;  11:     if (sp)  12:     {  13:         // 在这里可以安全的使用sp指针.  14:     }  15:     else  16:     {  17:         std::cout << “指针已被释放!” << std::endl;  18:     }  19: }  20: int main()  21: {  22:     boost::shared_ptr<tester> sp1(new tester);  23:     boost.weak_ptr<tester> wp(sp1);  24:     // 开启两个线程,并将智能指针传入使用。  25:     boost::thread t1(boost::bind(&fun, wp));  26:     boost::thread t2(boost::bind(&fun, wp));  27:     t1.join();  28:     t2.join();  29:     return 0;  30: }
      boost.weak_ptr指针功能一点都不weak,weak_ptr是一种可构造、可赋值以不增加引用计数来管理shared_ptr的指针,它可以方便的转回到shared_ptr指针,使用weak_ptr.lock函数就可以得到一个shared_ptr的指针,如果该指针已经被其它地方释放,它则返回一个空的shared_ptr,也可以使用weak_ptr.expired()来判断一个指针是否被释放。

       boost.weak_ptr不仅可以解决多线程访问带来的安全问题,而且还可以解决上面第三个问题循环引用。Children类代码修改如下,即可打破循环引用:

   1: class child   2: {   3: public:   4:     ~child() {   5:         std::cout <<"子类析构函数被调用.\n";   6:     }   7: public:   8:     boost::weak_ptr<parent> parent;   9: };
     因为boost::weak_ptr不增加引用计数,所以可以在退出函数域时,正确的析构。

C++内存管理学习笔记(6)

上期内容回顾:

C++内存管理学习笔记(5)

     2.5 资源传递   2.6 共享所有权  2.7 share_ptr


--------------------------------------------------------------------------------

3 内存泄漏-Memory leak
3.1 C++中动态内存分配引发问题的解决方案
     假设我们要开发一个String类,它可以方便地处理字符串数据。我们可以在类中声明一个数组,考虑到有时候字符串极长,我们可以把数组大小设为200,但一般的情况下又不需要这么多的空间,这样是浪费了内存。很容易想到可以使用new操作符,但在类中就会出现许多意想不到的问题,本小节就以这么意外的小问题的解决来看内存泄漏这个问题。。现在,我们先来开发一个String类,但它是一个不完善的类。存在很多的问题!如果你能一下子把潜在的全找出来,ok,你是一个技术基础扎实的读者,直接看下一小节,或者也可以陪着笔者和那些找不到问题的读者一起再学习一下吧。

   下面上例子,

   1: /* String.h */   2: #ifndef STRING_H_   3: #define STRING_H_   4:     5: class String   6: {   7: private:   8:     char * str; //存储数据   9:     int len; //字符串长度  10: public:  11:     String(const char * s); //构造函数  12:     String(); // 默认构造函数  13:     ~String(); // 析构函数  14:     friend ostream & operator<<(ostream & os,const String& st);  15: };  16: #endif  17:    18: /*String.cpp*/  19: #include <iostream>  20: #include <cstring>  21: #include "String.h"  22: using namespace std;  23: String::String(const char * s)  24: {  25:     len = strlen(s);  26:     str = new char[len + 1];  27:     strcpy(str, s);  28: }//拷贝数据  29: String::String()  30: {  31:     len =0;  32:     str = new char[len+1];  33:     str[0]='"0';  34: }  35: String::~String()  36: {  37:     cout<<"这个字符串将被删除:"<<str<<'"n';//为了方便观察结果,特留此行代码。  38:     delete [] str;  39: }  40: ostream & operator<<(ostream & os, const String & st)  41: {  42:     os<<st.str;  43:     return os;  44: }  45:    46: /*test_right.cpp*/  47: #include <iostrea>  48: #include <stdlib.h>  49: #include "String.h"  50: using namespace std;  51: int main()  52: {  53:     String temp("String类的不完整实现,用于后续内容讲解");  54:     cout<<temp<<'"n';  55:     system("PAUSE");  56:     return 0;  57: }
    运行结果(运行环境Dev-cpp)如下图所示,表面看上去程序运行很正确,达到了自己程序运行的目的,但是,不要被表面结果所迷惑!

 


      这时如果你满足于上面程序的结果,你也就失去了c++中比较意思的一部分知识,请看下面的这个main程序,注意和上面的main加以区别,

   1: #include <iostream>   2: #include <stdlib.h>   3: #include "String.h"   4: using namespace std;   5:     6: void show_right(const String& a)   7: {   8:     cout<<a<<endl;   9: }  10: void show_String(const String a) //注意,参数非引用,而是按值传递。  11: {  12:     cout<<a<<endl;  13: }  14:    15: int main()  16: {  17:     String test1("第一个范例。");  18:     String test2("第二个范例。");  19:     String test3("第三个范例。");  20:     String test4("第四个范例。");  21:     cout<<"下面分别输入三个范例"<<endl;  22:     cout<<test1<<endl;  23:     cout<<test2<<endl;  24:     cout<<test3<<endl;  25:       26:     String* String1=new String(test1);  27:     cout<<*String1<<endl;  28:     delete String1;  29:     cout<<test1<<endl;   30:       31:     cout<<"使用正确的函数:"<<endl;  32:     show_right(test2);  33:     cout<<test2<<endl;  34:     cout<<"使用错误的函数:"<<endl;  35:     show_String(test2);  36:     cout<<test2<<endl; //这一段代码出现严重的错误!  37:       38:     String String2(test3);  39:     cout<<"String2: "<<String2<<endl;  40:       41:     String String3;  42:     String3=test4;  43:     cout<<"String3: "<<String3<<endl;  44:     cout<<"下面,程序结束,析构函数将被调用。"<<endl;  45:    46:     return 0;  47: }

      运行结果(环境Dev-cpp):程序运行最后崩溃!!!到这里就看出来上面的String类存在问题了吧。(读者可以自己运行一下看看,可以换vc或者vs等等试试)

 


     为什么会崩溃呢,让我们看一下它的输出结果,其中有乱码、有本来被删除的但是却正常打印的“第二个范例”,以及最后析构删除的崩溃等等问题。

通过查看,原来主要是复制构造函数和赋值操作符的问题,读者可能会有疑问,这两个函数是什么,怎会影响程序呢。接下来笔者慢慢结识。

     首先,什么是复制构造函数和赋值操作符?------>限于篇幅,详细分析请看《c++中复制控制详解(copy control)》

Tip:复制构造函数和赋值操作符

(1)复制构造函数(copy constructor)

         复制构造函数(有时也称为:拷贝构造函数)是一种特殊的构造函数,具有单个形参,该形参(常用const修饰)是对该类类型的引用.当定义一个新对象并用一个同类型的对象对它进行初始化时,将显示使用复制构造函数.当将该类型的对象传递给函数或者从函数返回该类型的对象时,将隐式使用复制构造函数。

       复制构造函数用在:

对象创建时使用其他相同类型的对象初始化;
   1: Person q("Mickey"); // constructor is used to build q.   2: Person r(p);        // copy constructor is used to build r.   3: Person p = q;       // copy constructor is used to initialize in declaration.   4: p = q;              // Assignment operator, no constructor or copy constructor.
复制对象作为函数的参数进行值传递时;

   1: f(p);               // copy constructor initializes formal value parameter.
复制对象以值传递的方式从函数返回。

      一般情况下,编译器会给我们自动产生一个拷贝构造函数,这就是“默认拷贝构造函数”,这个构造函数很简单,仅仅使用“老对象”的数据成员的值对“新对象”的数据成员一一进行赋值。使用默认的复制构造函数是叫做浅拷贝。

     相对应与浅拷贝,则有必要有深拷贝(deep copy),对于对象中动态成员,就不能仅仅简单地赋值了,而应该有重新动态分配空间。

     如果对象中没有指针去动态申请内存,使用默认的复制构造函数就可以了,因为,默认的复制构造、默认的赋值操作和默认的析构函数能够完成相应的工作,不需要去重写自己的实现。否则,必须重载复制构造函数,相应的也需要重写赋值操作以及析构函数。

2.赋值操作符(The Assignment Operator)

      一般而言,如果类需要复制构造函数,则也会需要重载赋值操作符。首先,了解一下重载操作符。重载操作符是一些函数,其名字为operator后跟所定义的操作符符号,因此,可以通过定义名为operator=的函数,进行重载赋值定义。操作符函数有一个返回值和一个形参表。形参表必须具有和该操作数数目相同的形参。赋值是二元运算,所以该操作符有两个形参:第一个形参对应的左操作数,第二个形参对应右操作数。

    赋值和赋值一般在一起使用,可将这两个看作一个单元,如果需要其中一个,几乎也肯定需要另一个。
 

         ok,现在分析上面的程序问题。

   a)程序中有这样的一段代码,


   1: String* String1=new String(test1);   2: cout<<*String1<<endl;   3: delete String1;

      假设test1中str指向的地址为2000,而String中str指针同样指向地址2000,我们删除了2000处的数据,而test1对象呢?已经被破坏了。大家从运行结果上可以看到,我们使用cout<<test1时,从结果图上看,显示的是乱码类似于“*”,而在test1的析构函数被调用时,显示是这样:“这个字符串将被删除:”,程序崩溃,这里从结果图上看,可能没有执行到这一步,程序已经奔溃了。

   b)另外一段代码,


   1: cout<<"使用错误的函数:"<<endl;   2: show_String(test2);   3: cout<<test2<<endl;//这一段代码出现严重的错误!

       show_String函数的参数列表void show_String(const String a)是按值传递的,所以,我们相当于执行了这样的代码:函数申请一个临时对象a,然后将a=test2;函数执行完毕后,由于生存周期的缘故,对象a被析构函数删除,这里要注意!从输出结果来看,显示的是“第二个范例。”,看上去是正确的,但是分析程序发现这里有漏洞,程序执行的是默认的复制构造函数,类中使用str指针申请内存的,默认的函数不能动态申请空间,只是将临时对象的str指针指向了test2,即a.str = test2.str,所以这块不能够正确执我们的复制目的。因为此时test2也被破坏了!

     这是就需要我们自己重载构造函数了,即定义自己的复制构造函数,

   1: String::String(const String& a)   2: {   3:     len=a.len;   4:     str=new char(len+1);   5:     strcpy(str,a.str);   6: }
      这里执行的是深拷贝。这个函数的功能是这样的:假设对象A中的str指针指向地址2000,内容为“I am a C++ Boy!”。我们执行代码String B=A时,我们先开辟出一块内存,假设为3000。我们用strcpy函数将地址2000的内容拷贝到地址3000中,再将对象B的str指针指向地址3000。这样,就互不干扰了。

     c)还有一段代码

   1: String String3;   2: String3=test4;
      问题和上面的相似,大家应该猜得到,它同样是执行了浅拷贝,出了同样的毛病。比如,执行了这段代码后,析构函数开始执行。由于这些变量是后进先出的,所以最后的String3变量先被删除:这个字符串将被删除:String:第四个范例。执行正常。最后,删除到test4的时候,问题来了:程序崩溃。原因我不用赘述了。

      那怎么修改这个赋值操作呢,当然是自己定义重载啦,

版本一,

   1: String& String::operator =(const String &a)   2: {   3:     if(this == &a)   4:         return *this;   5:     delete []str;   6:     str = NULL;   7:     len=a.len;   8:     str = new char[len+1];   9:     strcpy(str,a.str);  10:       11:     return *this;  12: } //重载operator=
版本二,

   1: String& String::operator =(const String& a)   2: {   3:     if(this != &a)   4:     {   5:         String strTemp(a);   6:            7:         len = a.len;   8:         char* pTemp = strTemp.str;   9:         strTemp.str = str;  10:         str = pTemp;  11:     }  12:     return *this;      13: }

 

 

 


    这个重载函数实现时要考虑填补很多的陷阱!限于篇幅,大概说下,返回值须是String类型的引用,形参为const 修饰的Sting引用类型,程序中要首先判断是否为a=a的情形,最后要返回对*this的引用,至于为什么需要利用一个临时strTemp,是考虑到内存不足是会出现new异常的,将改变Srting对象的有效状态,违背C++异常安全性原则,当然这里可以先new,然后在删除原来对象的指针方式来替换使用临时对象赋值。

    我们根据上面的要求重新修改程序后,执行程序,结果显示为,从图的右侧可以到,这次执行正确了。

 


3.2 如何对付内存泄漏
        写出那些不会导致任何内存泄漏的代码。很明显,当你的代码中到处充满了new 操作、delete操作和指针运算的话,你将会在某个地方搞晕了头,导致内存泄漏,指针引用错误,以及诸如此类的问题。这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧,它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。标准容器(standard containers)是一个优秀的例子。它们不是通过你而是自己为元素管理内存,从而避免了产生糟糕的结果。

    如果不考虑vector和Sting使用来写下面的程序,你大脑很会费劲的…..

   1: #include <vector>   2: #include <string>   3: #include <iostream>   4: #include <algorithm>   5:     6: using namespace std;   7:     8: int main() // small program messing around with strings   9: {  10:     cout<<"enter some whitespace-seperated words:"<<endl;  11:     vector<string> v;  12:     string s;  13:     while (cin>>s)  14:         v.push_back(s);  15:     sort(v.begin(),v.end());  16:     string cat;  17:     typedef vector<string>::const_iterator Iter;  18:     for (Iter p = v.begin(); p!=v.end(); ++p)   19:     {   20:         cat += *p+"+";  21:         std::cout<<cat<<'n';  22:     }  23:     return 0;  24: }

    运行结果:这个程序利用标准库的string和vector来申请和管理内存,方便简单,若是设想使用new和delete来重新写程序,会头疼的。

 


      注 意,程序中没有出现显式的内存管理,宏,溢出检查,显式的长度限制,以及指针。通过使用函数对象和标准算法(standard algorithm),我可以避免使用指针——例如使用迭代子(iterator),不过对于一个这么小的程序来说有点小题大作了。

  这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。  如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。模板和标准库实现了容器、资源句柄等等

  如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。

      这里有个例子:需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的auto_ptr,使需要为之负责的地方变得明确了。

   1: #include<memory>   2: #include<iostream>   3: using namespace std;   4:     5: struct S {   6:     S() { cout << "make an S"<<endl; }   7:     ~S() { cout << "destroy an S"<<endl; }   8:     S(const S&) { cout << "copy initialize an S"<<endl; }   9:     S& operator=(const S&) { cout << "copy assign an S"<<endl; }  10: };  11:    12: S* f()  13: {  14:     return new S; // 谁该负责释放这个S?  15: };  16:    17: auto_ptr<S> g()  18: {  19:     return auto_ptr<S>(new S); // 显式传递负责释放这个S  20: }  21:    22: void test()  23: {  24:     cout << "start main"<<endl;  25:     S* p = f();  26:     cout << "after f() before g()"<<endl;  27:     // S* q = g(); // 将被编译器捕捉  28:     auto_ptr<S> q = g();  29:     cout << "exit main"<<endl;  30:     // *p产生了内存泄漏  31:     // *q被自动释放      32: }  33: int main()  34: {  35:     test();  36:     system("PAUSE");  37:     return 0;  38: }


原创粉丝点击