C 逼你親手寫出的那個臭蟲
你在前面幾級已經老老實實學過 C,所以這個問題的形狀早已刻進你的骨子裡。每次呼叫 malloc() 你就欠一次 free();每次呼叫 open() 你就欠一次 close();每次呼叫 pthread_mutex_lock() 你就欠一次 unlock()。麻煩從來不在那條一切按順序成功的快樂路徑上——而在所有其他路徑上。驗證失敗時的提早 return、跳過某一行的 goto、兩步之後從函式逃出的錯誤:每一個都是忘記你所欠清理的機會,結果就是記憶體洩漏、卡住的鎖,或一個你再也拿不回來的檔案描述符。
C 對此最好的解答是一種紀律,而非一項功能。經典模式是用單一出口搭配goto 清理階梯:每個錯誤都往下跳到一組標籤,以相反順序釋放東西。它有效,好的 C 程式碼到處都這樣用——但留意它的代價。你,這個人類,必須記得為每個資源加上標籤、把每個錯誤跳轉放對位置、以正確順序釋放。編譯器不會檢查你做對了沒。C 裡的資源清理是你承諾要做的事,而承諾正是疲憊的人最容易違背的那種東西。
把資源綁在區域物件上
C++ 不是給你一個更聰明的 free()。它給你一個新的保證,而 RAII 就是你蓋在這保證之上的東西。保證是這樣的:當一個區域物件離開範圍時,語言會執行一個叫做解構子(destructor)的特殊函式,自動地、每一次都執行。所以你不再去記得清理,而是把資源包進一個微小的物件裡,它的建構子取得資源、它的解構子釋放資源。建構子呼叫 open();解構子呼叫 close()。建構子鎖住互斥鎖;解構子解開它。現在清理不再是你在呼叫處許下的承諾——它是某個型別的性質,而編譯器替你執行它。
這名字很笨拙:「資源取得即初始化」只描述了它的一半,也就是你在建構子裡抓住資源的那一半。重要的另一半是——釋放即解構。看下面這張小圖:`File` 是個類別,持有一個 `int fd`,在建構子裡開檔,在解構子裡關檔。這個包裝你只要寫一次,往後每個用到 `File` 的函式就自動不會洩漏。沒有 goto 階梯、沒有標籤、沒有反序釋放清單。你只要建立物件,然後就不必再去想清理了。
class File {
int fd;
public:
explicit File(const char *path) {
fd = open(path, O_RDONLY); // acquire in the constructor
if (fd < 0) throw std::runtime_error("open failed");
}
~File() { if (fd >= 0) close(fd); } // release in the destructor
int handle() const { return fd; }
};
void use() {
File f("/etc/hostname"); // opens here
parse(f.handle()); // may throw, may return early — does not matter
} // f.~File() runs here, no matter what: close(fd)為什麼它總是會執行:確定性解構
RAII 之所以值得信賴、而不只是方便,原因在於確定性解構。區域變數具有自動儲存期,它的生存期在所屬區塊的右大括號處結束——在原始碼裡一個你指得出來的位置。解構子就在那裡執行,每一次都是,而物件以建構順序的相反順序被銷毀。這和垃圾回收器天差地遠,後者會在背景回收器自行決定的「未來某時」才執行清理。對系統程式設計師而言這個差別就是一切:當你持有一把鎖或一個檔案描述符時,你非常在意它究竟何時被釋放,而一次不確定的停頓來釋放它會毀掉你的延遲。
接下來這部分讓 RAII 真正安全、而不只是整齊:即使丟出例外,解構子仍會執行。如果例子裡的 `parse()` 丟出例外,C++ 會輾轉展開(unwind)堆疊——而在展開過程中,它會為途中每一個已完整建構的區域物件執行解構子,以相反順序。於是你的 `File` 關閉、你的鎖釋放、你的緩衝區釋放,全都在錯誤路徑上發生,而你不必為此寫任何一行。C 的 goto 階梯只為你記得處理的那些錯誤提供這份保障;RAII 則為你從未預料到的錯誤也提供它。
走一遍實際發生的事
讓我們追蹤一次呼叫,把這份魔法變得具體。假設一個函式持有一個緩衝區和一個檔案,兩者都包裝成 RAII 物件,而檔案的解析步驟在中途失敗並丟出例外。以下是語言依序執行的精確順序——而重點是:丟出例外之後的每一步都是自動的。
- 進入函式。第一個區域物件 `Buffer b` 被建構:它的建構子配置記憶體。`b` 現在已完全存活。
- 第二個區域物件 `File f` 被建構:它的建構子呼叫 open()。建構順序是先 `b` 後 `f`。
- 執行本體。parse() 偵測到壞輸入並丟出例外。正常執行停止;堆疊開始輾轉展開。
- 展開以相反順序銷毀區域物件:先執行 `f.~File()`,呼叫 close(fd)。沒有洩漏的描述符。
- 接著 `b.~Buffer()` 執行並釋放記憶體。沒有洩漏的配置。直到此刻例外才往上傳播給呼叫者。
把它和你會親手寫的 C 版本比一比:你會需要一個 goto 目標來關閉 fd 並釋放緩衝區,而且你必須特別記得在解析失敗那條路徑上跳過去。有了 RAII,清理邏輯就住在兩個只寫一次的解構子裡,而反序展開是語言的工作。這也是為什麼 RAII 型別的解構子本身不該丟出例外——若它在堆疊已因另一個例外而展開時又丟出,程式就會終止。正是這個限制,讓你越深入就越會反覆遇到 noexcept 與例外安全。
為什麼這讓 C++ 成為系統語言
現在來為標題裡的主張辯護。許多語言把你從手動清理中解放,但它們用的是垃圾回收器和執行期環境——一個有著自己記憶體和不可預測停頓的背景行程。那對應用程式沒問題,對核心、配置器、交易引擎或嵌入式控制器卻是致命傷,那些地方你承受不起停頓,甚至可能根本沒有堆積。RAII 給你自動清理,卻既不用回收器、也不用執行期環境:解構子呼叫只是編譯器在右大括號處插入的一個函式呼叫。這正是零開銷原則在運作——對於你親手寫 close() 同樣得付出的那份安全,你在執行期不必額外付任何代價。RAII 和正確的 C 一樣便宜,卻遠遠更難寫錯。
一旦你看見了 RAII,現代 C++ 的其餘部分就不再像一袋雜物般的功能,而開始看起來像同一個觀念被到處套用。std::unique_ptr 是針對堆積配置的 RAII——它的解構子呼叫 delete,於是裸 new/delete 這一類臭蟲消失了。std::lock_guard 是針對互斥鎖的 RAII。std::fstream 是針對檔案的 RAII。std::vector 是針對可成長陣列的 RAII。每一個都是一個小包裝,它的建構子取得、解構子釋放,而因為每個標準容器都是這樣造的,用它們組成的程式碼天生就不會洩漏。你接下來會遇到的整套零法則/五法則指引,存在的唯一目的就是在你真的得親手寫一個包裝時,讓那個所有權的故事保持連貫。