/****************************************************************/
/* 學習是合作和分享式的!
/* Author:Atlas
/****************************************************************/
上期內容回顧:傳送門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中相關部分的實現等等,這些需要讀者自己學習了。