為什麼堆疊不夠用
到目前為止你宣告的每個變數,它的生命週期都是替你決定好的。一個區域 `int x` 在它的函式被呼叫時誕生,並在那函式返回的瞬間死去——它住在一個自動推入又彈出的堆疊框裡。這非常方便,卻藏著一道硬性的限制:堆疊只能給你大小在編譯期就已知、且生命隨呼叫結束的記憶體。如果你還不知道一個陣列該開多大呢——比方一個你還沒讀的檔案有幾行?如果一個值必須活得比創造它的函式更久,返回一個呼叫者要留用好幾個鐘頭的結果呢?
正是為了這些情況,存在著第二塊記憶體區域,叫做堆積,而向它請求儲存空間這件事就是動態記憶體配置。動態這個詞正是重點所在:不像堆疊框那樣大小與生命週期被你程式碼的結構釘死,一塊堆積記憶體的大小是在程式執行時才挑定的,而它的生命週期則是你說了算。這份自由的代價是責任——堆積不會在你身後收拾。你借走的每一個位元組,最終都得親手歸還。某種意義上,這整個學習階段談的就是如何把這件事做對。
malloc:請求一塊記憶體
最常用的請求是 malloc()——是 memory allocate(配置記憶體)的縮寫。你用單一個引數呼叫它,也就是你想要的位元組數,它便回傳一個指標,指向一塊至少那麼大的全新記憶體的起點:`void *malloc(size_t n)`。這個引數的型別 `size_t` 是個無號整數,寬到足以表達你機器上任何物件的大小,這也是為什麼你用 `sizeof` 來計算請求量。想要容納 100 個 int 的空間?請求 `malloc(100 * sizeof(int))`,絕不要寫 `malloc(100)`——後者給你 100 個位元組,在典型機器上只有 25 個 int。
注意它的回傳型別:`void *`,一個無型別的指標,意思是「某些位元組的位址,不屬於任何特定種類」。malloc() 處理的是原始記憶體,它根本不知道你會在那裡存什麼,所以它交回最中性的那種指標。你藉由把它賦值給一個有型別的指標來賦予它型別——`int p = malloc(...)`——之後 `p[0]`、`p[1]` 等等就把它當成一連串 int 來索引。在 C 裡這個轉換是自動的,你不該*去轉型它;明寫一個 `(int *)` 轉型不僅多餘,甚至可能掩蓋忘記 `#include` 的臭蟲。
malloc() 有一個它明確不做的承諾:它回傳的位元組是未初始化的。它們裝著那塊記憶體裡上一次殘留的隨便什麼值——不是零,也不是任何你能預測的東西。在你寫入之前就讀取它們是未定義行為,正是那種在 `-O0` 下看起來好端端、卻在 `-O2` 下默默損毀的臭蟲。所以節奏永遠是:先配置、再初始化、然後使用。如果你想要一塊事先歸零的記憶體,下一節有個替你做這件事的呼叫。
一家人:calloc 與 realloc
malloc() 有兩個親戚,更安全地處理常見的需求。calloc()——clear allocate(清空配置)——接受兩個引數,一個個數與一個元素大小,例如 `calloc(100, sizeof(int))`,給你容納 100 個 int 的空間,且每一個位元組都設為零。這種兩引數的形式不只是糖衣:calloc() 在內部會檢查 `count * size` 不會溢位,所以對於長度來自你程式之外的陣列,它是比較安全的計算方式。當你想要一塊歸零的記憶體時就用它,而對於數字或指標的陣列,這往往正是你要的。
第三個呼叫 realloc(),會改變一塊你已持有記憶體的大小:`realloc(p, new_size)`。這正是一個成長中的陣列倍增容量的方式——你保留舊資料、請求更多空間,而 realloc() 要嘛就地撐大那塊記憶體,要嘛悄悄把它搬到一個更寬敞的位置並把你的位元組複製過去。最後那種可能性帶著一個尖銳的陷阱:realloc() 可能回傳一個不同的位址,所以舊指標 `p` 現在可能變成懸空的了。你必須接住它的回傳值,而且必須接到一個暫存變數裡,因為如果 realloc() 失敗,它會回傳 `NULL` 卻保持原本那塊記憶體完好無損——直接賦值回 `p` 會讓你失去指向它的唯一把手,從而漏掉那塊記憶體。
/* grow an int array from n to n*2 elements, safely */
size_t new_n = n * 2;
int *tmp = realloc(p, new_n * sizeof(int));
if (tmp == NULL) { /* realloc failed: p is still valid */
perror("realloc");
free(p); /* clean up the block we still own */
return -1;
}
p = tmp; /* success: adopt the (maybe new) address */
n = new_n;free:把它歸還
有借就有還。當你用完一塊堆積記憶體,你用 free() 把它歸還,傳入 malloc()、calloc() 或 realloc() 給你的那個確切指標:`free(p)`。這不會抹除那塊記憶體、也不會改變變數 `p`——它只是告訴配置器「這塊又可以用了」,於是之後的某次配置可能會重複利用那些位元組。忘了呼叫它,那塊記憶體就會在程式的整個生命期裡保持被佔用,即便你早已失去抵達它的一切手段;這就是記憶體洩漏,第 3 篇的主題,而在長時間執行的程式裡,洩漏會不斷累積直到你的記憶體耗盡。
free() 附帶一份你必須分毫不差遵守的契約。一塊記憶體你只能、且僅能釋放一次:對同一個指標呼叫 free() 兩次會弄壞配置器的帳目,是未定義行為,也是第 4 篇的主題。你可以放心地傳 `free(NULL)`——它被定義為什麼都不做,這在清理路徑裡很方便。但在 `free(p)` 之後,指標 `p` 仍然裝著那個舊位址,儘管那塊記憶體已經不在了;現在去碰它就是釋放後使用,這是整個 C 裡最危險的臭蟲之一,恰恰因為它常常看起來能運作,直到那些被釋放的位元組在你腳下被重新利用為止。
掀開引擎蓋瞄一眼
這些記憶體實際上是從哪裡來的?配置器不會為了每一個小小的請求去打擾作業系統。相反地,C 程式庫一開始就向核心要一大片位址空間,然後從一份空閒串列裡把一塊塊分出去——那是一本私人帳簿,記著哪些區塊正在使用、哪些可用。一次 `malloc(24)` 會在那本帳簿裡走訪、找一塊夠大的空閒區塊,把它標記為已用,並回傳一個剛好越過它替自己保留的幾個位元組帳目資料之後的指標;一次 `free()` 則把那塊翻回可用。這就是為什麼 `free()` 不需要大小引數:配置器早已把那塊記憶體的大小寫進它給你那個位址正前方的那些隱藏位元組裡了。
那本帳簿也解釋了一個更微妙的代價。當你隨著時間配置與釋放大小混雜的記憶體,空閒空間會被切成一塊塊小洞、被存活中的區塊隔開——這就是碎裂。你可能落得,比方說,總共有 10 KiB 空閒,卻連單單一個 4 KiB 的請求都滿足不了,因為沒有任何一個洞那麼大。好的配置器會藉由切分與重新合併相鄰的空閒區塊來對抗這點,但碎裂是親手配置記憶體一個真實、固有的代價,而非一個你能單靠寫程式就消滅的臭蟲。本階段最後一篇會把配置器整個掀開;目前你只需知道堆積是一座受管理的資源池,而非無盡的紙帶。
你現在握有親手管理記憶體的四個動詞——malloc()、calloc()、realloc()、free()——以及每一個各自要求的契約。要緊的是,你誠實地認識了這些契約,它們的失敗模式就攤在眼前:未經檢查的 `NULL`、洩漏、釋放後使用、重複釋放、碎裂的緩慢蔓延。這些不是罕見的奇珍異獸;它們是寫 C 每一天的危害,而本階段其餘的篇章存在的意義,就是讓每一個都熟悉到你能憑反射處理。接下來我們會更用力地看那個悄悄墊在它們全體之下的問題——對任何一塊給定的記憶體,它歸誰所有,又是誰的職責去呼叫 free()?