3 資源管理
所謂資源就是,一旦用了它,將來必須還給系統。C++程序中最常使用的資源就是動態分配內存(如果分配內存卻從不歸還它, 就會導致內存泄漏),但內存只是你必須管理的眾多資源之一。其他常見的資源還包括文件描述器、互斥鎖、圖形界面中的字型和筆刷、數據庫連接以及網絡sockets。無論哪一種資源,重要的是,當你不再使用它時,必須將它還給系統。
當你考慮到異常、函數內多重回傳路徑、程序維護員改動軟件卻沒能充分理解隨之而來的沖擊,顯然,資源管理的特殊手段還不很充分利用。
條款13:以對象管理資源
void f()
{
Investment *pInv = createInvestment();
... //這里存在諸多“不定因素”,可能造成delete pInv;得不到執行,這可能就存在潛在的內存泄露。
delete pInv;
}
如上代碼,createInvestment的調用端使用了函數返回的對象后,有責任刪除之。但一些情況下delete可能無法執行:
- “...”區域內有一個過早的return語句;
- createInvestment和delete動作位于某循環內,而該循環由于某個continue或goto語句過早退出;
- “...”區域內的語句拋出異常,控制流不會臨幸delete。
把資源放進對象內,我們便可依賴C++的“析構函數自動調用機制”確保資源被釋放。
auto_ptr
許多資源被動態分配于heap(堆)內而后被用于單一區塊或函數內。它們應該在控制流離開那個區塊或函數時被釋放。標準程序庫提供的auto_ptr正是針對這種形勢而設計的特制產品。auto_ptr是個“類指針對象”,也就是所謂的“智能指針”,其析構函數自動對其所指對象調用delete。
void f()
{
std::auto_ptr<Investment> pInv(createInvestment());
...
} //函數退出,auto_ptr調用析構函數自動調用delete,刪除pInv;無需顯示調用delete。
上面的例子師范了“以對象管理資源”的兩個關鍵想法:
-
獲得資源后立刻放進管理對象(managing object)內。
“資源取得時機便是初始化時機”(Resource Acquisition Is Initialization;RAII)。每一筆資源都在獲得的同時立刻被放進管理對象中。 -
管理對象運用析構函數確保資源被釋放。
一旦對象被銷毀,其析構函數被自動調用來釋放資源。如果資源釋放動作可能導致拋出異常,參見條款8的解決方法。
由于auto_ptr被銷毀時會自動刪除它所指之物,所以不能讓多個auto_ptr同時指向同一對象。為了預防這個問題,auto_ptr有一個性質:auto_ptr若通過copy構造函數或copy assignment操作符復制它們,它們會變成NULL,而復制所得的指針將取得資源的唯一擁有權!但這一性質限制了元素不能發揮“正常的”復制行為,如在STL容器上就不適合,因此并非管理動態分配資源的神兵利器。
RCSP
auto_ptr的替代方案是“引用計數型智能指針”(reference-counting smart pointer;RCSP)、它可以持續跟蹤共有多少對象指向某筆資源,并在無人指向它時自動刪除該資源。RCSP類似垃圾回收,但不同的是它無法打破環狀引用(例如兩個其實已經沒被使用的對象彼此互指,因而好像還處在“被使用”狀態)。
TR1的tr1::shared_ptr就是一個"引用計數型智能指針",且可執行正常的復制行為,因此可以被用于STL容器以及其他“auto_ptr之非正統復制行為并不適用”的語境上。
void f()
{
...
std::tr1::shared_ptr<Investment> pInv1(createInvestment()); // pInv1指向createInvestment()返回物;
std::tr1::shared_ptr<Investment> pInv2(pInv1); //pInv1,pInv2指向同一個對象;
pInv1 = pInv2; //同上,無變化
... } // pInv1,pInv2被銷毀,它們所指的對象也被自動銷毀
auto_ptr和tr1::shared_ptr都在其析構函數內做delete而不是delete[],也就意味著在動態分配而得的數組身上使用auto_ptr或tr1::shared_ptr是個潛在危險,資源得不到釋放。還有,vector和string幾乎總是可以取代動態分配而得的數組。
note:
- 為防止資源泄漏,請使用RAII對象,它們在構造函數中獲得資源并在析構函數中釋放資源。
- 兩個常被使用的RAII類分別是auto_ptr和tr1::shared_ptr。后者通常是較佳選擇,因為其拷貝行為比較直觀。若選擇auto_ptr,復制動作會使他(被復制物)指向NULL。
條款14:在資源管理類中小心 copying 行為
我們在條款13中討論的資源表現在heap堆上申請的資源,而有些資源并不是heap-based,因此不適合被auto_ptr和tr1::shared_ptr所管理。此時,我們需要建立自己的資源管理類。
假設我們處理Mutex類型的互斥器對象,有兩種操作:
void lock(Mutex *pm); //鎖定pm所指的互斥量
void unlock(Mutex *pm); //將互斥器解除鎖定
建立遵循RAII守則(資源在構造期間獲得,在析構期間釋放)的資源管理類:
class Lock {
public:
explicit Lock(Mutex *pm)
: mutexPtr(pm)
{ lock(mutexPtr); } // 獲得資源
~Lock() { unlock(mutexPtr); } // 釋放資源
private:
Mutex *mutexPtr;
};
// 客戶對Lock的正確用法符合RAII方式
Mutex m; // 定義你需要的互斥器
....
{ // 建立一個區塊用來定義critical section
Lock ml(&m); // 鎖定互斥器
......
} // 在區塊最末尾,自動接觸互斥器鎖定
上面的使用沒有問題,但問題是,如果Lock對象被復制,會發生什么事呢?就像這樣:
Lock ml1(&m);
Lock ml2(ml1);
當一個RAII對象被復制,會發生什么事?
- 禁止復制。
如果復制動作對RAII class并不合理,就應該禁止它。詳見條款06:將copying操作聲明為private。 - 對底層資源祭出“引用計數法”。
有時候希望保有資源,直到它的最后一個使用者被銷毀,這種情況下復制RAII對象時,應該講資源的“被引用數”遞增。例如trl::shared_ptr。
通常只要內含一個tr1::shared_ptr成員變量,RAII類便可實現”引用計數“行為。
但是,tr1::shared_ptr缺省行為是”當引用計數為0時刪除其所指物“,因此,我們還需指定所謂“刪除器”(一個函數或對象),當引用計數為0時便被調用(此機能并不存在于auto_ptr,它總是將指針刪除)。
class Lock {
public:
explicit Lock(Mutex *pm) // 以某個Mutex初始化shared_ptr
: mutexPtr(pm, unlock) // 并以unlock函數為刪除器
{
lock(mutexPtr.get()); // get見條款15
}
private:
std::tr1::shared_ptr<Mutex> mutexPtr; 使用shared_ptr
};
本例中,并沒說明Lock class的析構函數,因為沒有必要。編譯器為我們生成的析構函數會自動調用其non-static成員變量(mutexPtr)的析構函數。而mutexPtr的析構函數會在互斥量”引用計數“為0時自動調用tr1::shared_ptr的刪除器(unlock)。
note:
- 復制RAII對象必須一并復制它所管理的資源,所以資源的copying行為決定RAII對象的copying行為。
- 普遍而常見的RAII類拷貝行為是:抑制拷貝,施行引用計數法。不過其它行為也可能被實現。
條款15:在資源管理類中提供對原始資源的訪問
前幾個條款提到的資源管理類是你對抗資源泄漏的堡壘。但許多APIs直接指涉資源,這時候我們需要直接訪問原始資源。
std::tr1::shared_ptr<Investment> pInv(createInvestment()); // 見條款13
int dayHeld(const Investment* pi); // 返回投資天數
int days = dayHeld(pInv); // 錯誤,dayHeld需要的是Investment*指針(原始指針,raw pointer),
// 傳給的卻是tr1::shared_ptr<Investment>對象
這時候需要一個函數可將RAII對象(如tr1::shared_ptr)轉換為其所內含之原始資源(Investment*)。有兩種做法可以達成目標:顯式轉換和隱式轉換。
顯式轉換
tr1::shared_ptr和auto_ptr都提供一個get成員函數,用來執行顯示轉換,也就是返回智能指針內部的原始指針(的復件)。
int days = dayHeld(pInv.get());
隱式轉換
像所有智能指針一樣, tr1::shared_ptr和auto_ptr重載了指針取值操作符(operator->和operator*),它們允許隱式轉換至底部原始指針。(即在對智能指針對象實施->和*操作時,實際被轉換為被封裝的資源的指針。)
class Investment {
public:
bool isTaxFree() const;
...
}
Investment * createInvestment();
std::tr1::shared_ptr<Investment> pi1(createInvestment()); // shared_ptr管理資源
bool taxable1 = !(pi1->isTaxFree()); // operator ->訪問資源
...
std::auto_ptr<Investment> pi2(createInvestment()); // auto_ptr管理資源
bool taxable2 = !((*pi2).isTaxFree()); //operator *訪問資源
顯式轉換函數和隱式轉換函數
FontHandle getFont(); // 這是個C API,參數已經簡化
void releaseFont(FontHandle fh); // 來自同一組C API
void changeFontSize(FontHandle f, int newSize); // 來自同一組C API
class Font {
public:
explicit Font(FontHandle fh) // 獲得資源
:f(fh)
{}
~Font() {releaseFont(f);} // 釋放資源
FontHandle get() const {return f;} // 顯式轉換函數
operator FontHandle() const{return f;} // 隱式轉換函數
private:
FontHandle f; // 原始(raw)字體資源
}
使用:
int newFontSize;
Font f1(getFont());
...
changeFontSize(f1.get(),newFontSize); // 顯式轉換
Font f2(getFont());
changeFontSize(f2,newFontSize); // 隱式轉換
這個隱式轉換會增加錯誤發生機會。例如客戶可能會在需要Font時意外得到一個FontHandle:
Font f1(getFont());
...
FontHandle f2 = f1; // 原意是要拷貝一個Font對象
// 卻反而將f1隱式轉換為其底部的FontHandle,
// 然后才復制它。
以上程序有個FontHandle由Font對象f1管理,但那個FontHandle也可以通過直接使用f2取得。如果f1被銷毀,字體被釋放,f2因此成為"虛吊的"(dangle)。
note:
- APIs往往要求訪問原始資源(raw resources),所以每一個RAII class應該提供一個“取得其所管理之資源”的方法。
- 對原始資源的訪問可能經由顯示轉換或隱式轉換。一般而言顯示轉換比較安全,但隱式轉換對客戶比較方便。
條款16:成對使用 new 和 delete 時要采取相同形式
當我們使用new,有兩件事情發生:第一,內存被分配出來;第二,針對此內存會有一個(或更多)構造函數被調用。當你使用delete,也有兩件事發生:針對此內存會有一個(或多個)析構函數被調用,然后內存才被釋放。delete的最大問題在于:即將被刪除的內存之內究竟有多少對象?這個問題的答案決定了有多少個析構函數必須被調用起來。
如果new數組時使用[],那么釋放資源時就要用delete[],這會調用多個析構函數去釋放資源;如果使用new對象不使用[],釋放時一定不要使用[]。保持兩者一致。
std::string str = new std::string;
std::string strArr = new std::string[20];
//釋放資源
delete str;
delete[] strArr;
最好盡量不要對數組形式作typedefs動作。C++標準庫的string,vector等template,可將數組的需求降至幾乎為零。
note:
如果你在new表達式中使用[],必須在相應的delete表達式中也使用[]。如果你在new表達式中不使用[],一定不要在相應的delete表達式中使用[]。
條款17:以獨立語句將 newed 對象置入智能指針
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());
在調用processWidget前,編譯器必須創建代碼,做以下三件事:
- 調用priority()
- 執行new Widget
- 調用tr1::shared_ptr構造函數
但C++的編譯器對這三個執行的次序并不固定。但C++中可以確定的是,new Widget一定比tr1::shared_ptr先執行,但對priority()函數的調用卻沒有限定。如果以下面的順序:
- 執行new Widget
- 調用priority()函數
- 調用tr1::shared_ptr構造函數
這就會引發一個問題,如果第二步priority()函數發生異常,那么new Widget就無法放入shared_ptr中,這樣就會造成資源泄漏(shared_ptr用來進行資源管理)。
正確的做法是將語句分離,先創建資源并放到資源管理器后,再進行下步操作。
std::tr1::shared_ptr<Widget> pw(new Widget); // 在單獨語句中以智能指針存儲newed所得對象
processWidget(pw, priority); // 這個調用動作絕不至于造成泄漏
note:
以獨立語句將newed對象存儲于(置入)智能指針內。如果不這樣做,一旦異常拋出,有可能導致難以察覺的資源泄漏。