JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

做好清理:goto-cleanup 與 RAII

檢查每一次呼叫只完成了一半的工作;另一半,是當後面的呼叫失敗時,把你先前取得的東西一一還回去。本篇從清理問題會孳生的臭蟲講起,一路走到它的兩大解法——有紀律的 C 所採用的 goto-cleanup 模式,以及 C++ 與 Rust 直接烙進語言本身的 RAII 理念。

「檢查每一次呼叫」留下的尾巴

上一篇把一個習慣鑿進了你心裡:檢查每一次可能失敗的呼叫。你學會了去讀回傳值與 errno、拿它和失敗哨兵值比對、然後分支處理而不是莽撞地往前衝。但檢查一次呼叫,只告訴你「有」東西失敗了——它遞給你的是一個岔路口,而不是一條乾淨的回頭路。一旦某個函式做的事不只一件,那個岔路口就會冒出第二個、也更棘手的問題,而本篇要談的,正是如何馴服它。

情況是這樣的。一個貼近真實的函式,會按順序取得好幾樣東西:它先 open() 一個檔案、再在堆積上配置一塊緩衝區、再拿一把鎖、然後才做它真正的工作。這每一步,都是你盡責地檢查過的呼叫。但你撐過的每一次取得,都變成一筆債:一個之後必須 close() 的檔案描述符、一塊之後必須 free() 的堆積記憶體、一把之後必須解開的互斥鎖。這就是資源管理的核心——把每一次取得,和恰好一次相應的釋放配成對,而且要在函式可能走的「每一條」路徑上都配好。

麻煩在於,中間的任何一步都可能失敗,而當它失敗時,你必須把你「到目前為止」所取得的東西「恰好」還原——不多,不少。如果拿鎖失敗了,你必須釋放緩衝區並關閉檔案,但你不可以解鎖,因為你根本沒鎖過。如果緩衝區配置失敗了,你必須關閉檔案,卻不該去 free 一塊從未配置出來的緩衝區。在這裡清理得「不足」,你就洩漏掉一個描述符、一塊記憶體、或一把還握著的鎖;清理得「過頭」,你就會重複釋放某樣東西、或解開一把你從未握住的互斥鎖——而那是不折不扣的未定義行為

天真的修法,以及它為何會腐爛

幾乎每個人的第一反應,都是在發現失敗的「當地」就地清理。你 open() 了檔案;如果 malloc() 失敗,你就在那裡關檔、返回;如果拿鎖失敗,你就在那裡 free 緩衝區、關檔、返回。這管用——大概管用一個星期。如今清理程式碼被複製貼上到好幾個返回點,每一份都是同一串釋放序列略有不同的前綴,而隨著函式長大,這些副本便逐漸彼此走樣。

另一種本能是用巢狀:寫成 `if (open ok) { if (malloc ok) { if (lock ok) { ... } } }`,把清理塞在每個右大括號之後。這至少讓每一段釋放都待在一個地方,但它把你真正的邏輯一路往右推進一道深深的縮排階梯——四次取得,真正的工作就被埋在四層深處。這兩種形狀都是誠實的嘗試,也都不耐放大:複製貼上版重複了「清理」,巢狀版重複了「結構」。C 不給你解構子、不給你 `finally` 區塊、什麼自動的東西都不給——所以這兩種做法,都無法倚靠語言來幫忙。

於是慣用的 C,伸手去拿一個你大概被警告過絕不要碰的工具。乾淨的答案,是把每一條失敗路徑都送往函式底部「同一個共用出口」,讓那些釋放動作集中在一個容易稽核的地方。而 C 跳到那一個地方的方式,就是關鍵字 `goto`。

goto-cleanup:給所有人的同一道後門

想像一棟有好幾道門的建築,但規定是:所有人都從同一道後門離開,而在那裡,燈、警報、門鎖全都在一個地方被處理好——正確地、每一次都如此。goto-cleanup 模式,把這個想法原封不動地套到一個函式上。你不再把清理散落在每一個提早返回處,而是在底部標上一個清理區段、失敗時就 `goto` 過去。這些標籤以「取得的相反順序」堆疊,每一個標籤做它自己的一次釋放、再往下落入下一個——於是跳到對的標籤,就恰好還原了到目前為止所取得的東西。這就是你會在真實 C 裡(包括 Linux 核心)看到被推薦使用的、有紀律的 `goto`。

int do_work(const char *path) {
    int   rc = -1;          /* assume failure until proven otherwise */
    int   fd = -1;          /* -1 means "no descriptor acquired"     */
    char *buf = NULL;       /* NULL means "no buffer acquired"        */

    fd = open(path, O_RDONLY);
    if (fd == -1)           goto out;          /* nothing to undo yet */

    buf = malloc(4096);
    if (buf == NULL)        goto close_fd;     /* only the fd is open */

    if (read(fd, buf, 4096) == -1)  goto free_buf;  /* fd + buf live  */

    /* ... real work here ... */
    rc = 0;                 /* success */

free_buf:
    free(buf);              /* free(NULL) is safe, so this is fine    */
close_fd:
    close(fd);              /* fd is -1 only if we jumped before open */
out:
    return rc;
}
骨架:標籤以取得的相反順序堆疊,每個釋放一樣資源後往下落入下一個;變數預先初始化為安全的「尚未取得任何東西」之值。

把這段程式碼讀成一個正在被退繞的堆疊。如果 open() 失敗,你 `goto out`、跳過每一個釋放——正確,因為你什麼都沒握著。如果 malloc() 失敗,你 `goto close_fd`,它關閉檔案、卻完全不碰緩衝區。如果 read() 失敗,你 `goto free_buf`,它釋放緩衝區、然後「往下落入」close_fd 去關閉檔案。成功時你從底部掉出去,同樣跑完整套清理——這恰恰正確,因為順利的路徑也一樣想要關檔、釋放緩衝區。每樣資源只有一個釋放點,讓你要驗證「什麼都沒洩漏、什麼都沒被釋放兩次」變得輕而易舉。不過要誠實看待它的極限:這是「結構化」的 goto——永遠向前、永遠只跳到清理標籤、絕不是亂跳的許可證——而它仍然完全倚賴你,讓標籤的順序和取得的順序保持同步。它移除的是重複,不是責任。而它也乾淨地把棒子交給下一個想法:當函式失敗時,它回傳 rc,好讓它的呼叫者決定要處理、傳遞、還是中止,而此時每樣資源都已在離開的途中被釋放掉了。

RAII:讓「離開房間」就等於「鎖門」

goto-cleanup 是 C 勇敢地繞過一個語言缺失的功能。C++ 與 Rust 提供了這個功能,而它改變了一切。想像一張旅館門卡,在你踏出房門的那一刻就把房間鎖上——你不可能忘記鎖門,因為「離開」就是「鎖門」。這就是 RAII,全寫是 resource acquisition is initialization(資源取得即初始化,唸的時候是把字母逐個拼出來)。這個想法是:把一樣資源的生命期綁到一個變數的作用域上,於是當那個變數離開作用域時——以「任何」方式離開,無論是正常返回還是出錯——資源都會自動被釋放。

C++ 裡的機制是解構子。你寫一個小類別,讓它「擁有」一樣資源:它的建構子取得資源(開檔、配置記憶體、拿鎖),它的解構子釋放資源(關檔、free、解鎖)。當你把這樣一個物件建立成區域變數時,編譯器保證解構子會在控制權離開作用域的那一刻執行——無論你是抵達右大括號、撞上 `return`、還是有例外退繞穿過這個函式。清理不再是你要在每個出口記得寫的事;它附著在物件身上,自動發生、恰好一次。std::lock_guard 是教科書級的例子:建構它,鎖就被拿起;讓它掉出作用域,鎖就被釋放——就算三行之後有個錯誤跳出去,也一樣。

Rust 拿同一個想法、把它焊進語言裡,化為所有權加上 Drop trait。每個值都恰好有一個擁有者;當那個擁有者離開作用域時,Rust 自動執行該值的清理——這稱為 drop(捨棄)。對一個 String 或 Vec,drop 釋放堆積記憶體;對一個檔案 handle,它關閉檔案;對一個 MutexGuard,它釋放鎖。你不寫 `free`,通常也不寫 `drop`——編譯器會在擁有者死去的那個右大括號處,以宣告的相反順序,把那個呼叫產生出來。因為所有權保證只有單一擁有者,drop 恰好在一個定義明確的時刻執行一次,於是沒有重複釋放、也沒有什麼好忘記的。

同一個問題,兩種誠實的取捨

把兩個答案並排攤開,取捨就很清楚了。在 C 裡,清理問題是「你」要解的問題——每個函式、靠手、用 goto-cleanup 加上初始化好的哨兵值與保持同步的標籤。在 C++ 與 Rust 裡,清理問題在很大程度上被語言「溶解」掉了:沒有一個要和取得保持同步的獨立清理標籤,也沒有「新加的提早返回忘了釋放」的風險,因為釋放是解構子或 drop 的工作,不是你的。這是在資源密集的系統程式碼裡、那些語言勝過樸素 C 的最有力論據之一——也是 Rust 所有權模型在實務上的回報。

所以兩件工具都帶著,並且清楚你身處哪個世界。寫 C 時,函式一旦取得超過一樣資源,就伸手去拿 goto-cleanup——它是讓你的錯誤路徑保持誠實、釋放保持可稽核的慣用法。寫 C++ 或 Rust 時,倚靠 RAII 與所有權,讓編譯器替你了結那份義務,把你省下的注意力,改花在把所有權「設計」對。無論哪一條路,底下的原則都一模一樣,而它正是本整階段的脊梁:你取得的每一樣資源,都必須在每一條離開的路徑上被釋放,而臭蟲,就住在你忘掉的那些路徑裡。