原文網址: http://eli.thegreenplace.net/2016/c-raii-without-exceptions/
我已讀遍了"RAII只能用在異常(exception)機制上"相關琳瑯滿目的文章,但我忍受不了了。
TL; DR: 這篇文章並不探討異常機制(exception)到底是好還是壞。而是關於RAII這類的「動態資源管理技術」在有無異常機制底下,都能做出有用的自我管理。尤其我想解釋,在你的C++程式碼中即使關閉了異常機制,為何RAII還是很有用。
基礎
我們寫個RAII能包裹住 FILE*並自動做出關閉檔案的handle。
class FileHandle { public: FileHandle(const char* name, const char* mode) { f_ = fopen(name, mode); } FILE* file() { return f_; } ~FileHandle() { if (f_ != nullptr) { fclose(f_); } } private: FILE* f_; };我們有個範例能利用它:
std::string do_stuff_with_file(std::string filename) { FileHandle handle(filename.c_str(), "r"); int firstchar = fgetc(handle.file()); if (firstchar != '$') { return "bad bad bad"; } return std::string(1, firstchar); }請記得:這裡沒有異常機制了-已事先使用-fno-exceptions 且無try敘述。
FileHandle這類RAII仍很重要,do_stuff_with_file有兩個脫離點(譯:可立即離開函式的觸發點),此檔案能在任一點關閉起來。do_stuff_with_file是個又短又簡單的函式。在大型函式中會有很多個脫離點,這使得管理資源的釋放容易出錯,RAII技術在此變得極為重要。
RAII本質上用堆疊配置物件(譯:區域物件也是此類物件)的建構式來獲取一些資源,並由解構式來釋放資源。不論引發異常或是函式返回的情況下,編譯器保證離開這類物件的作用域時,物件的解構式會做出正確的資源釋放。
RAII並不是指你得在建構式中配置或增加任何東西。而是指操控「邏輯執行」所需的"鬆綁"。參考計數器(reference counting)是個好例子。很多資料庫跟軟體庫擁有"游標"這類抽象來藉此提供資料存取。這裡我們做出如何安全地前進(increase)和退回(decrease )游標的參考計數:
class CursorGuard { public: CursorGuard(Cursor* cursor) : cursor_(cursor) { cursor_->incref(); } Cursor* cursor() { return cursor_; } ~CursorGuard() { cursor_->decref(); } private: Cursor* cursor_; }; void work_with_cursor(Cursor* cursor) { CursorGuard cursor_guard(cursor); if (cursor_guard.cursor()->do_stuff()) { // ... do something return; } // ... do something else return; }再一次地,RAII確保了work_with_cursor不會讓游標的參考計數器出現脫軌的情況:一旦前進(incref( ) )後,無論函式從哪意外返回,保證一定會有退回(decref( ))動作。
標準程式庫裡的RAII
像"哨兵"一樣的RAII classes是極普遍且實用的技術,也紮根在標準程式庫中。如C++11執行緒程式庫針對mutex鎖定而設計的lock_guard,如下例子:
void safe_data_munge(std::mutex& shared_mutex, Data* shared_data) { std::lock_guard<std::mutex> lock(shared_mutex); shared_data->munge(); if (...) { shared_data(); return; } shared_data->munge_less(); return; }std::lock_guard的建構式鎖定mutex,然後在解構式中解鎖mutex,確保在safe_data_munge裡能從頭到尾保護好共享資料的存取完整性,真正的解鎖動作也總是確實執行。
RAII與C++11
有關係到標準程式庫的話,我不得不提最具RAII特色的型別- std::unique_ptr。在C和C++中,資源管理(Resource management)是個龐大複雜的課題;在C++普遍常見的資源管理都跟堆積(heap)記憶體有關。前面提到的C++11中,有很多靈活指標(smart pointers)這類的第三方解決方案,加上C++11搬移語意(move semantics)最後讓語言有了以RAII方式所創造出堅固的靈活指標:
void using_big_data() { std::unique_ptr<VeryVeryBigData> data(new VeryVeryBigData); data->do_stuff(); if (data->do_other_stuff(42)) { return; } data->do_stuff(); return; }我們無論怎樣使用data,無論怎樣返回函式,所配置的記憶體都將釋放回去。假如你的編譯器支援C++14,在創造指標那行使用std::make_unique會變得更簡潔:
// Good usage of 'auto': removes the need to repeat a (potentially long) // type name, and the actual type assigned to 'data' is trivially obvious. auto data = std::make_unique<VeryVeryBigData>();std::unique_ptr功能多且還有其他應用 ,但這裡我只關注堆積記憶體上的RAII"如值(value)一般"的處理方式。
這裡強調C++11為何對RAII而言如此的重要:假如沒有搬移語意,會導致copy語意跟可觀的開銷,那我們只能為"靈活"的指標而寫出令人瞠目結舌的東西。有了搬移語意,就能從函式中輕易"轉移物件的擁有權"到其他地方,而不需要多大的開銷。過去以來C++程式員時常為了從程式碼中擠出一些效能,大部份都選擇讓自己在艱困的環境中處理原始(raw)指標。使用C++11和std::unique_ptr的話,會得到更有效率的搬移且無需額外記憶體佔用,使得這問題不太需顧慮嚴謹跟安全,不用得付出效能的代價。
其他語言的RAII理念
一個常被問到的C++問題:"為何C++不能像其他語言(Java,C#,Python)一樣享有finally建構方式呢?"。Stroustrup給的答案是,RAII是更好的替代方案。Stroustrup認為(怒我直言這就是現實) 現實程式碼中「獲取跟釋放資源」的出現次數遠高於「明確區別各類資源」,所以RAII就使程式碼更簡短了。除此之外,你一旦用RAII把程式碼包裹住後,將不容易出錯,也不需手動釋放資源。下面的work_with_cursor以假設的finally方式來重寫前面提過的例子(這不是真實的C++程式碼):
// Warning: this is not real C++ void work_with_cursor(Cursor* cursor) { try { cursor->incref(); if (cursor->do_stuff()) { // ... do something return; } // ... do something else return; } finally { cursor->decref(); } }是的,它使得程式碼變得多餘。較重要是必須記得使用cursor->decref()。大型程式不斷改變資源的取捨,在實踐上你將用try...finally區塊來當作每個涵式本體的骨架,然後得記住要做出釋放資源的動作。假使是用前面的CursorGuard來當幫手的話,只需定義一次哨兵class(CursorGuard)就能省下先前所付出的全部開銷。
另一個好例子是在Python語言。即使Python有finally的建構方式,現代的Python程式碼則偏好用途更多廣的with敘述。with支援 "context managers",非常類似C++的RAII。with比finally好用且更具通用性,這就是為何你在程式碼中能大量看到它們的身影。
那RAII如果是在異常機制底下呢?
講到這,即使是在非異常機制下,我希望你已信服RAII在C++中是重要且實用的技術。
RAII跟異常之間的親密關係已經受到人們的認可,不管怎樣,因為撰寫異常安全性(exception-safe)程式碼卻不用RAII是幾乎不可能地。在啟用異常機制下,我們不會只檢查函式中每個return敘述來診斷出何處會導致資源洩漏。每一行程式碼都可能藏有地雷。函式或方法的呼叫處呢?也是會丟出異常。在堆疊區建構非POD物件呢?也是會丟出異常。copy物件到別處呢?也是一樣...丟出異常。a + b?可藉由+operator丟出異常。
另外異常跟建構式中的RAII也有緊密的關係。由於建構式無法返回數值(value)。因此,建構式遇到錯誤的情況下,你可以丟出異常或製造物件內部的錯誤狀態。後者有它自身的問題(非異常機制的程式碼中使用此建構方法是較好的選擇),所以丟出異常是最常見的手段。自此RAII對異常機制是如此重要,也是因為RAII跟建構式常常相伴而行(記住-RAII在物件被建構時就開始了),這種緊密的關係就烙印在C++學習者心中。
RAII不只在異常下有用處。它更在C++資源管理技術中扮演重要角色。因此,去假設有RAII就表示某種程度上你的程式碼是把異常(exception)給泛濫亂用的地方,這是無意義的。甚至真的使用了異常也是如此。批判C++的異常安全性是情有可原,但批判RAII就說不過去了,因為RAII只是種解決手段,而非問題的源頭所在。
最後,在我個人筆記將加上,我並不是C++的異常機制的粉絲,而是RAII的大粉絲。當我用C++時,我寧可不用異常機制,或至少把它給約束和限制在程式裡的極小區域內。可用RAII的話,我會採用標準庫中的class像std::unique_ptr或者是自己撰寫的RAII程式碼。RAII在我心目中是優良且具實用性的C++特色之一,幫助維持大型程式碼的穩健跟安全。
沒有留言:
張貼留言