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

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

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

/* Author:Atlas                    Email:wdzxl198@163.com   

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

*   http://blog.csdn.net/wdzxl198/article/details/9120635

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

上期内容回顾:

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: }

      运行这个程序(dev-cpp),可以看到p产生内存泄漏,而通过auto_ptr智能指针,则内存管理自动化了 ---->为什么?详见《C++内存管理学习笔记(4)


     综合以上的内容,我们需要考虑更一般的意义上考虑资源,而不仅仅是内存。如果在你的环境中不能系统地应用这些技巧,那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。

3.3 浅谈C/C++内存泄漏及其检测工具

        对于一个c/c++程序员来说,内存泄漏是一个常见的也是令人头疼的问题。已经有许多技术被研究出来以应对这个问题,比如Smart Pointer,Garbage Collection等。Smart Pointer技术比较成熟,STL中已经包含支持Smart Pointer的class,但是它的使用似乎并不广泛,而且它也不能解决所有的问题;Garbage Collection技术在Java中已经比较成熟,但是在c/c++领域的发展并不顺畅,作为一个c/c++程序员,内存泄漏是你心中永远的痛。不过好在现在有许多工具能够帮助我们验证内存泄漏的存在,找出发生问题的代码。

3.3.1 内存泄漏定义

      一般常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显示释放的内存。应用程序一般使用malloc,realloc,new等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。

      以下这段小程序演示了堆内存发生泄漏的情形:

   1: void MyFunction(int nSize)
   2: {
   3:      char* p= new char[nSize];
   4:      if( !GetStringFrom( p, nSize ) ){
   5:      MessageBox(“Error”);
   6:      return;
   7:      }
   8:      …//using the string pointed by p;
   9:      delete p;
  10: }

       当函数GetStringFrom()返回零的时候,指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形。程序在入口处分配内存,在出口处释放内存,但是c函数可以在任何地方退出,所以一旦有某个出口处没有释放应该释放的内存,就会发生内存泄漏。  

       内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET, Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重

3.3.2 内存泄漏的发生方式

     以发生的方式来分类,内存泄漏可以分为4类:

1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。

2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。另一个例子:

   1: char* g_lpszFileName = NULL;
   2: void SetFileName( const char* lpcszFileName )
   3: {
   4:     if( g_lpszFileName ){
   5:         free( g_lpszFileName );
   6:     }
   7:     g_lpszFileName = strdup( lpcszFileName );
   8: }
   9: /*如果程序在结束的时候没有释放g_lpszFileName指向的字符串,
  10: 那么,即使多次调用SetFileName(),总会有一块内存,而且仅有一块内存发生泄漏。*/

4. 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。举一个例子:

   1: class Connection
   2: {
   3: public:
   4:     Connection( SOCKET s);
   5:     ~Connection();
   6:
   7: private:
   8:     SOCKET _socket;
   9:
  10: };
  11: class ConnectionManager
  12: {
  13: public:
  14:     ConnectionManager(){}
  15:     ~ConnectionManager(){
  16:         list::iterator it;
  17:         for( it = _connlist.begin(); it != _connlist.end(); ++it ){
  18:         delete (*it);
  19:         }
  20:     _connlist.clear();
  21:   }
  22:     void OnClientConnected( SOCKET s ){
  23:         Connection* p = new Connection(s);
  24:         _connlist.push_back(p);
  25:     }
  26:     void OnClientDisconnected( Connection* pconn ){
  27:         _connlist.remove( pconn );
  28:         delete pconn;
  29:     }
  30: private:
  31:     list _connlist;
  32: };
  33: /*假设在Client从Server端断开后,Server并没有呼叫OnClientDisconnected()函数,
  34: 那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候,
  35: 所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、
  36: 断开时隐式内存泄漏就发生了。*/

        从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。


参考资料详见《 c++内存管理学习纲要

Edit by Atlas

Time:2013/6/18 14:51

原文地址:https://www.cnblogs.com/snake-hand/p/3143096.html