所謂資源就是,一旦使用了它,將來必須歸還給系統!C++ 最常見的資源就是動態分配記憶體,若不歸還就會記憶體洩漏。
我們通常希望有一個物件幫助解決資源管理問題(自動呼叫解構函式),本節討論 auto_ptr 與 shared_ptr。
假設使用工廠方法:
class Investment { ... };
Investment* createInvestment() { ... }
void f() {
Investment* pInv = createInvestment();
...
delete pInv;
}
乍看無問題,但中間可能出現異常(提前 return、例外拋出、continue 等),導致控制流無法執行到 delete,造成記憶體洩漏。單純依賴 f 總會執行 delete 是行不通的。
將資源放入物件,使物件離開作用域時自動呼叫解構函式。使用 auto_ptr:
void f() {
std::auto_ptr pInv(createInvestment());
...
} // 經由 auto_ptr 解構函式自動刪除 pInv
這示範了「以物件管理資源」兩個關鍵想法:
* 獲得資源後立刻放進管理物件內(RAII 準則)。
* 管理物件運用解構函式確保資源被釋放,不管控制流如何離開作用域。
但使用 auto_ptr 需注意:絕不能讓多個 auto_ptr 指向同一物件! 每個 auto_ptr 都會呼叫解構函式,不像智能指標管理引用計數。複製相關操作會使原指標指向 null,確保物件只有一個 auto_ptr 指向。因此不能在 STL 容器中使用 auto_ptr。
shared_ptr 則解決此問題:
void f() {
std::shared_ptr pInv(createInvestment());
...
} // 經由 shared_ptr 解構函式自動刪除
幾乎同 auto_ptr,但複製行為正常許多。
auto_ptr 動態分配物件並在不再需要時自動清理。使用需 #include <memory>。源碼見原文。
詳情見 shared_ptr 專文。
並非所有資源都是 heap-based,對其他資源需建立自己的資源管理類別。
處理 Mutex 互斥器,用類別管理它:
void lock(Mutex* pm); void unlock(Mutex* pm);
class Lock {
public:
explicit Lock(Mutex* pm) : mutexPtr(pm) { lock(mutexPtr); }
~Lock() { unlock(mutexPtr); }
private:
Mutex* mutexPtr;
};
// 使用時:
Mutex m;
Lock m1(&m);
Lock m11(&m);
Lock m12(m11); // 複製 m11 到 m12 會發生什麼?
當 RAII 物件被複製會發生什麼?兩種選擇:
* 禁止複製:許多時候允許 RAII 物件複製不合理。對 Lock 類別可能,因很少合理擁有「同步化器物件」的副本。若複製不合理,讓類別繼承 uncopyable。
* 對底層資源使用「引用計數法」:有時希望保有資源直到最後一個使用者被銷毀,複製 RAII 物件時資源引用計數遞增。
內含 shared_ptr 成員變數即可實現 reference-counting copying 行為。shared_ptr 允許指定「刪除器」(deleter),引用計數歸零時被呼叫,而非執行解構函式。刪除器對 shared_ptr 建構函式是可選的第二參數。
class Lock {
public:
explicit Lock(Mutex* pm) : mutexPtr(pm, unlock) { lock(mutexPtr.get()); }
private:
shared_ptr mutexPtr;
};
此處不需宣告解構函式,類別解構函式會自動呼叫 non-static 成員變數解構函式(即刪除器函式)。
複製底部資源:有時可為一份資源擁有任意數量副本,資源管理類別唯一理由是不再需要某副本時確保其被釋放。複製資源管理物件應同時複製其所含資源(深拷貝)。
轉移底部資源擁有權:罕見情況下,希望永遠只有一個 RAII 物件指向未加工資源,即使 RAII 物件被複製依然如此,資源擁有權從被複製物轉移到目標物(如 auto_ptr 複製語意)。
API 常要求存取原始資源,每個 RAII class 應提供「取得其所管理之資源」的方法。
工廠函式回傳 Investment*,但我們用 shared_ptr 管理:
int daysHeld(const Investment* pi);
int days = daysHeld(pInv); // 錯誤!型別不匹配
需將 RAII class 物件轉換為其內含原始資源。兩種方法:
shared_ptr 和 auto_ptr 提供 get() 函式執行顯式轉換:daysHeld(pInv.get())。同樣重載了 operator-> 和 operator*。
提供重載轉換運算子完成隱式轉換。
是否提供顯式轉換函式或隱式轉換,取決於 RAII class 設計目的與使用情況。最佳設計通常堅持「讓介面容易被正確使用,難以被誤用」。通常顯式轉換函式如 get() 較好,因它將「非故意型別轉換」可能性最小化。
new 兩件事:分配記憶體、呼叫建構函式。delete 兩件事:呼叫解構函式、釋放記憶體。問題:被刪除記憶體內究竟有多少物件?決定呼叫多少解構函式。單一物件與陣列記憶體分配不同,delete 時必須告訴編譯器用單一物件還是陣列形式,方法加 []。此規則對愛用 typedef 的人很重要,作者必須說明 typedef 是什麼、用什麼 delete。為避免錯誤,最好不用 typedef 定義陣列,改用 vector、string 等樣板替代陣列。
我們無法知道編譯器會按照什麼順序執行一條代碼語句,其中可能隱含異常。
int priority();
void processWidget(std::shared_ptr pw, int priority);
processWidget(std::shared_ptr(new Widget), priority());
問題隱蔽:編譯器可能按順序 1) new Widget 2) priority() 3) shared_ptr 建構函式。若 priority() 拋出異常,new Widget 回傳指標遺失,導致記憶體洩漏。
強迫編譯器按預想順序執行:
std::shared_ptr pw(new Widget);
processWidget(pw, priority());
編譯器對「跨越語句的各項操作」沒有重新排列自由,故不會出問題。盡可能單獨把 new 物件直接放入智能指標總是有道理的。
管理方式不同:堆疊由編譯器自動管理;堆由程式設計師控制產生與釋放。
空間大小不同:堆疊空間有限;堆可達 4GB。
碎片產生不同:堆疊不會產生碎片(先進後出佇列);堆易產生碎片(多次 new/delete 造成不連續)。
成長方向不同:堆向上成長;堆疊向下。
分配方式不同:堆動態分配;堆疊可靜態/動態分配(動態由編譯器釋放)。
分配效率不同:堆疊有硬體支援(專用暫存器、指令);堆由函式庫演算法搜尋可用空間,效率較低。推薦盡量用堆疊而非堆。但堆疊靈活度較低,大量記憶體分配仍用堆較好。無論堆或堆疊,都要防止越界。
對於 C++ 這類技巧較高的語言,資源管理從來不是簡單之事,掌握一定管理方法總有用。本文根據《Effective C++》總結資源管理準則,附加少量補充。