上期內容回顧:
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: }