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

物件生命期與誰擁有記憶體

第 1 篇教你用 malloc() 從堆積借一塊記憶體、再用 free() 還回去。現在我們追問那塊記憶體帶出的更難的問題:它能活多久,又該由誰負責釋放它?生命期與所有權,正是把一堆漏洞變成一支你能信賴的程式的那兩個觀念。

一塊記憶體不會因為名字消失就跟著消失

在第 1 篇你學會了那個機械式動作:`void *p = malloc(n)` 向堆積要 `n` 個原始位元組,而 `free(p)` 把它們還回去。但指標很小、記憶體區塊很大,兩者的命運完全不同。在更前面的章節,你見過一般變數的範圍與生命期——函式裡的一個 `int x` 在進入函式時誕生、在函式回傳的那一刻死去,它在堆疊上的儲存被自動回收。堆積區塊打破了這條讓人安心的規則。malloc() 交給你的那塊記憶體沒有名字、也沒有範圍;它一直活著,直到你親手對它呼叫 free()——不會早一道指令,也不會晚一道指令。

這正是儲存期在替它命名的東西。一個 `auto`(一般區域)變數有自動儲存期:編譯器替你釋放它在堆疊上的位置。一塊堆積區塊則有配置儲存期:編譯器什麼都不釋放——生命期由你來管。指標 `p` 與它所指的區塊,是兩個各自獨立、各有生命期的物件。一旦弄混了,你就會碰上兩種經典災難:區塊比指向它的每一個指標都活得久(漏洞——第 3 篇),或是指標比它的區塊活得久(懸空指標——第 4 篇)。兩者都源於把名字和東西本身混為一談。

所有權:編譯器無法替你強制的規則

如果說生命期是一塊區塊何時活著,那麼所有權就是誰負責終結它。每一塊 malloc 來的區塊都需要剛好一次 free(),而那個深層問題是:哪一段程式碼有義務去發出那個呼叫。這就是配置所有權,而 C 完全不替你追蹤它——它是一套你得記在自己腦中、更好是記在文件裡的紀律。一個回傳 malloc 來的指標的函式,等於默默地說「我把所有權交給你了,你必須釋放它」。一個只是借用指標來讀的函式,則說「我不擁有它,別釋放它」。把這兩份合約弄混,你就會碰上重複釋放或漏洞。

最有用的單一習慣,就是把所有權合約寫下來,就寫在函式宣告的旁邊,因為光看型別簽章是看不出來的。一個擁有並回傳區塊的 `char make_greeting(void)`,和一個只是借用*區塊的 `void print_line(char s)`,它們的指標型別根本分不出差別,然而其中一個把所有權交出去、另一個沒有。標準函式庫本身就這樣倚賴慣例:`strdup()` 回傳一塊必須釋放的區塊;`getenv()` 回傳一個你絕不可*釋放的指標,因為它仍歸 C 執行期擁有。兩者沒有任何語法上的差別——只有那份寫下來的合約,所以一行指名擁有者的註解(「由呼叫者釋放」、「只借用、絕不釋放」、「收下所有權」)比任何巧妙的型別都值錢。

calloc 與 realloc:同一塊記憶體,更完整的合約

malloc() 並不孤單。calloc(count, size) 配置 `count * size` 個位元組,而且——不像會交給你未初始化垃圾的 malloc()——它保證每一個位元組都被設成零。那兩個參數不只是裝飾:calloc() 在內部會檢查 `count * size` 是否溢位,所以當 `n` 來自你程式之外時,`calloc(n, sizeof(int))` 比 `malloc(n * sizeof(int))` 更安全。不過所有權的故事完全一樣:一塊 calloc 來的區塊歸你所有,用同一個 free() 釋放。歸零是一種便利,而非合約的改變。

realloc(p, newsize) 是微妙的那一個,也是生命期變得滑溜的地方。它把一塊既有區塊重新調整大小,而它的合約帶著一根刺:它可能把區塊搬到一個新位址。如果堆積能就地把區塊長大,它就回傳同一個指標;如果不能,它會配置一塊全新的區塊、把你的位元組複製過去、釋放掉舊的,然後回傳一個不同的指標。所以舊的 `p` 此刻可能已經懸空了。最要緊的規則是:把結果指派給另一個變數、並在覆寫 `p` 之前檢查它,因為 realloc() 也可能失敗而回傳 `NULL`——若你寫了 `p = realloc(p, ...)` 而它失敗了,你就會因為踩掉了原區塊唯一的指標而把它漏掉。

int *grow(int *p, size_t newcount) {
    int *tmp = realloc(p, newcount * sizeof(int));
    if (tmp == NULL) {       /* realloc failed; p is STILL valid */
        return NULL;         /* caller keeps the old block, no leak */
    }
    return tmp;              /* p may be dangling now; use the new one */
}

/* never do this:  p = realloc(p, ...);   <- leaks p if it returns NULL */
用一個暫存變數接住 realloc 的結果。失敗時原區塊還活著、不會被漏掉;成功時舊指標可能已死,所以只有回傳的指標才能安全使用。

設計 free() 該住在哪裡

好的 C 程式不會把 free() 呼叫到處亂撒;它們事先決定好所有權,並把對應的 free() 放在一個可預期的地方。最可靠的模式,是把配置與釋放配對在同一層:建立一塊區塊的那個函式(或結構),也擁有銷毀它的責任,對外提供互相對映的 `make_x()` 與 `free_x()`。呼叫者永遠不必猜。這預示了一個你日後會遇見的更深觀念——Rust 的所有權模型,正是把這套人類紀律拿過去、讓編譯器來強制執行,在擁有者離開範圍時自動釋放區塊,而且不需要垃圾收集器。

讓我老實說說代價。在 C 裡,這一切都得靠你。沒有垃圾收集器默默地回收你忘掉的東西——那是為了控制權與可預測的時序而刻意做出的取捨,也正是系統程式設計師選用 C 的理由。代價是:所有權的臭蟲很容易寫出來,而且在咬你之前都悄無聲息。底下那套紀律不是可有可無的潤飾;它是「跑上好幾年的程式」與「一夜之間膨脹崩潰的程式」之間的差別。

  1. 在配置之前先決定擁有者:指名那個負責最終 free() 的單一函式或結構,並把它寫在宣告旁的註解裡。
  2. 把動詞配對:對每一個回傳所有權的 make_x()/create_x(),提供一個對應的 free_x()/destroy_x(),好讓呼叫者用對的呼叫來釋放。
  3. 在 free(p) 之後,把 p = NULL,這樣稍後若不小心又用到它,會是一次乾淨的空指標解參考(被逮到的崩潰),而不是讀到已被重用記憶體的釋放後使用。
  4. 在每一條錯誤路徑上,回傳之前先釋放你已經配置的東西——用 goto-cleanup 或單一出口區塊,能讓這件事在函式變大時仍保持誠實。

這把你帶到了哪裡

你現在握有了這個章節其餘部分所倚靠的心智模型。一塊堆積區塊的生命期,始於 malloc()(或 calloc/realloc),終於你親手呼叫的 free()——這兩端編譯器都不管。而每一塊區塊都有一個擁有者:程式碼裡剛好有一處負責那最後一次 free()。生命期回答「多久」;所有權回答「誰的工作」。把這兩者弄清楚,你就能在函式之間傳遞指標、把它們存進結構、用 realloc() 調整它們的大小,而不致迷失頭緒。

這兩個觀念也為我們接下來要正面、誠實處理的失敗做好了鋪陳。當所有權不清時,一塊區塊最後會沒有擁有者、永遠不被釋放:那就是第 3 篇的記憶體漏洞,我們會學著用偵測器與 valgrind 把它逮住。當一塊區塊有兩個想當擁有者的人,或一個擁有者釋放了、卻還有個過時的指標賴著不走,你就會碰上第 4 篇的懸空指標、釋放後使用,以及重複釋放——那些最佳化器可隨意扭曲成記憶體損毀的未定義行為。生命期與所有權不是官僚作風;它們是那張讓這些臭蟲遠離你程式的地圖。