對於資源,這裡我分為兩類:
1. 內存資源
2. 非內存資源(文件、網絡資源等)
C++ 對於內存資源的管理部分是自動的:棧上的內存資源將被自動釋放,堆上的內存資源需要程序員自己釋放。
手動管理內存資源,總會讓我們在一些極端情況下犯錯。一個最簡單,最常見的例子如下:
void f()
{
A* a = new A();
B* b = new B();
C* c = new C();
// ...
if (某種條件)
{
// 失敗時
delete a;
delete b;
delete c;
// ...
return;
}
// 成功繼續處理
Z* z = new Z();
try
{
f2(); // f2 可能拋出異常
} catch (const xxx&) {
delete a;
delete b;
delete c;
// 忘記釋放 z
}
// 沒有異常繼續處理
}
不論是多麼優秀的程序員,他也不能保證永遠正確的釋放了內存資源,遇見上面情況的解決辦法之一就是使用 autoptr,我們可以這樣寫:
void f()
{
autoptr<A> a(new A());
autoptr<B> b(new B());
autoptr<C> c(new C());
// ...
if (某種條件)
{
// 失敗時,無需釋放資源
return;
}
// 成功繼續處理
autoptr<Z> z(new Z());
try
{
f2(); // f2 可能拋出異常
} catch (const xxx&) {
// 無需釋放資源
}
// 沒有異常繼續處理
}
不錯,這看起來很完美,我們解決了異常情況下內存資源釋放的問題。但是,對於非內存資源呢?例如:
void f()
{
FILE* pf1 = fopen("killercat.blog1", "w");
FILE* pf2 = fopen("killercat.blog2", "w");
FILE* pf3 = fopen("killercat.blog3", "w");
// ...
try
{
f2(); // f2 可能拋出異常
} catch (const xxx&) {
fclose(pf1); // 又回到了丑陋的代碼
fclose(pf2);
fclose(pf3);
}
}
很顯然,FILE 不是一個類,我們無法使用諸如 autoptr 這樣的管理器來進行資源的生命周期管理,對非內存資源的生命周期的管理,我們有另外一種方式可循:把非內存資源的生命周期的管理轉化為內存資源的生命周期的管理。
RAII(Resource Acquisition Is Initialization),資源(這裡的資源通常是指的是非內存資源)獲取即使初始化,換而言之,我們將利用 C++ 構造函數和析構函數來維護一個不變式(invariant):對象存在則資源(常常指非內存資源也可以只內存資源)有效。一個經典的例子就是:
// use an object to represent a resource ("resource acquisition is initialization")
class File_handle
{
// belongs in some support library
FILE* p;
public:
File_handle(const char* pp, const char* r)
{
p = fopen(pp,r);
if (p==0)
throw Cannot_open(pp);
}
File_handle(const string& s, const char* r)
{
p = fopen(s.c_str(),r);
if (p==0)
throw Cannot_open(pp);
}
~File_handle()
{
fclose(p);
} // destructor
// copy operations and access functions
};
void f(string s)
{
File_handle file(s, "r");
// use file
}
從上面的 File_handle 類可以看出,構造對象的時候,我們初始化非內存資源 FILE,當對象釋放時,FILE 同時被釋放。
通過 RAII 我們能夠將非內存資源生命周期管理轉換到內存資源生命周期的管理上來,那麼上面丑陋的代碼轉化為:
void f()
{
autoptr<File_handle> pf1(new File_handle("killercat.blog1", "w"));
autoptr<File_handle> pf2(new File_handle("killercat.blog2", "w"));
autoptr<File_handle> pf3(new File_handle("killercat.blog3", "w"));
// ...
try
{
f2(); // f2 可能拋出異常
} catch (const xxx&) {
// 資源能夠被正確釋放
}
}
RAII 的實質:
1. 通過把非內存資源的生命周期管理轉化為內存資源的生命周期管理,達到對非內存資源的安全釋放。
2. 通過維護一個 invariant(對象存在即資源有效),來簡化編程。
當然,這裡不僅僅可以使用 autoptr 也可以使用 shared_ptr 等智能指針。不過,除此之外,RAII 還維護了一個 invariant --- 對象存在即資源有效,invariant 的出現,必定帶來一個結果:簡化編程。看下面的例子:
class File_handle
{
FILE* p;
public:
File_handle(const char* pp, const char* r)
{
p = fopen(pp,r);
if (p==0)
throw Cannot_open(pp);
}
File_handle(const string& s, const char* r)
{
p = fopen(s.c_str(),r);
if (p==0)
throw Cannot_open(pp);
}
~File_handle()
{
fclose(p);
} // destructor
int GetChar()
{
// 無需檢查 p,因為 p 一定有效
return fgetc(p);
}
};
我們可以看到,在對象存在的時候,文件資源總是有效的,因為我們無需判斷指針 p 是否合法,這簡化了編程,使得代碼更加優雅。相比之下,這樣實現就不那麼美觀:
class File_handle
{
FILE* p;
public:
File_handle()
{
// 必須初始化為 NULL,否則下面的所有檢查將無效,代碼將非常脆弱
p = NULL;
}
~File_handle() {}
void Open(const char* pp, const char* r)
{
// 必須判斷,是否調用兩次 Open
if (p == NULL)
{
p = fopen(pp,r);
if (p==0)
throw Cannot_open(pp);
}
else
{
throw Close_first();
}
}
void Close()
{
if (p != NULL)
{
fclose(p);
// 必須置空,否則程序其他地方判斷將失效
p == NULL;
}
else
{
throw Open_first();
}
}
int GetChar()
{
// 類似於 GetChar 的函數都需要判斷,否則在調用 Open 之前調用程序將出錯
if (p != NULL)
{
return fgetc(p);
}
else
{
throw Open_first();
}
}
};
顯而易見,對於資源是否有效的判斷已經使得程序員是否疲憊,而且,他們還需要不停的維護資源的指針,只要任何一步出錯,程序都將變得脆弱,更作用的是,不但編寫這樣的程序不會有任何快感,使用這樣的代碼也十分郁悶,比如調用 GetChar 的時候,需要當心函數拋出的來異常,不斷的寫 try catch(如果使用 error codes 來表示錯誤,也需要不斷的判斷函數執行是否出錯),在一些復雜的情況下,更加讓人抓狂:
bool AddGroup(int idUser)
{
// 檢查 idUser 是否允許加入組
// ...
// 檢查成功,允許加入,扣去入會費
SpendUserMoney(idUser, 10000);
// 對於入會成功用戶增加一個等級
AddUserLevel(idUser);
// 等等操作
// 將其寫入文件中
try
{
// 在文件中寫入用戶當前等級,入會花費等信息
file.Write("xxx");
} catch (const Open_first&)
{
// 異常出現
// 恢復現場
AwardUserMoney(10000);
ReduceUserLevel(idUser);
// 等等
}
}
由上例可見,為了處理異常情況而做的工作太大,而且我們每次調用時都要進行相似的處理。雖然上例不一定完全合乎實際情況,或許你認為它還有改良的空間,不過無論如何,我們難免要做太多多余的工作。
自此,已經談完了 RAII 設計方式的好處,那麼下面談談應該注意一些什麼:
1. 如果沒有必要,應該禁止復制。
如果沒有必要應該禁用復制構造函數和賦值操作符,這點很重要,如果沒有按需要定義復制構造函數和賦值操作符,那麼得到的結果通常是:非內存資源被創建一次,釋放多次。
2. 如果需要復制,應該謹慎考慮。
重新定義復制構造函數和賦值操作符是必須的。怎麼寫它們是一個問題,具體的方案依賴於實際的需要,可以使用深拷貝,也可以使用類似於 shared_ptr 的引用計數機制,或者傳遞所有權。
總結一下:
1. 使用 RAII 能夠將非內存資源的生命周期管理轉為內存資源生命周期的管理,使得我們能夠使用管理內存資源的手段來管理非內存資源,例如 auto_ptr,shared_ptr。
2. 維護 invariant 能夠簡化編程,使的代碼簡潔優雅。RAII 設計方式維護了這麼一條 invariant:對象存在則資源有效。除此之外,我們還可以使用異常機制來維護另外一條 invariant:函數要麼執行成功得到正確的結果,要麼拋出異常。對此條,這裡就不做任何闡述了。
3. 使用 RAII 時,應該警惕復制行為。