順著箭頭走:* 運算子
在第 1 篇你蓋起了核心圖像:一個指標不過是個變數,只是它的值碰巧是一個位址——記憶體那條長長編號街道上某個變數的門牌號。我們用取址運算子取得位址,寫 `p = &x` 意思是「讓 p 裝著 x 所住的位址」。這只是故事的一半。一個你只能寫下、卻永遠去不了的門牌號毫無用處;持有位址的全部意義,就在於能夠去到那裡。這後半段——真正順著箭頭走、抵達它所指的值——就叫做解參考,正是它讓指標掙得自己的存在價值。
做解參考的運算子,是寫在指標前面的單一星號:`*p`。把 `*p` 唸成「p 裡所裝位址處的那個值」,或更俐落地說「p 所指的那個東西」。若 `p` 裝著 `&x`,那麼 `*p` 就是 x——不是副本,是同一塊儲存。這就是為什麼解參考能雙向運作。在賦值的右邊,`int y = *p;` 透過指標讀出 x 的值。在左邊,`*p = 42;` 把 42 寫進 x 本身,效果和你直接打 `x = 42;` 一模一樣。指標就像一個遙控器,遙控著一個你也許連名字都不知道的箱子。
指向無處的那個指標
有時候一個指標該表達「我現在沒有指向任何東西」。你不能讓它沒被初始化——一個未初始化的指標裝著那塊記憶體裡殘留的隨便什麼位元,這就是所謂的野指標,瞄準一棟隨機的房子,而對它解參考是一場等著發生的災難。於是 C 保留了一個特殊值來代表「刻意指向無處」:空指標,寫作 `NULL`(或在指標情境裡就用常數 `0`)。空指標保證永遠不會等於任何真實物件的位址,所以它是「這個指向無物」的通用旗標。
鐵則很短:你愛怎麼儲存、複製、比較一個空指標都行,但你絕不可以對它解參考。當 `p` 是 `NULL` 時去寫 `p`,等於要求機器在位址 0x0 處讀或寫,而作業系統刻意把這塊留成沒對映,正是為了讓這個錯誤被逮個正著。結果就是那著名的區段錯誤——你的程式當場被殺掉。聽起來很狠,但在這裡崩潰其實是個禮物*:另一條路——默默地從位址零讀出垃圾——會弄壞你的資料,並在很遠的地方冒出一個令人摸不著頭緒的臭蟲。
這正是為什麼 libc 的配置器交回一個它期待你去檢查的指標。當 malloc() 失敗時,它回傳 `NULL` 而非一塊可用的記憶體,而一個莽撞往前、對那個 `NULL` 解參考的程式,會以區段錯誤收場,而不是回報真正的問題(記憶體用盡)。誠實的寫法是每一次、毫無例外地先測試再信任——而同樣這套三步紀律也適用於 open()、fork() 以及每一個可能失敗的呼叫,因為學習者會照抄他們看到的:
- 呼叫配置器並留住它的結果,例如 `int *p = malloc(n * sizeof(int));`——注意我們以 sizeof(int) 來算大小,絕不用猜出來的位元組數。
- 立刻測試是否失敗:若 `p == NULL`,就回報它(perror("malloc"))並退出——回傳一個錯誤,絕不碰 p。
- 唯有越過那道檢查之後,解參考才是安全的:此時 `p[0] = 42;` 寫進真實、屬於你的記憶體,因為已知 p 非空。
指標算術以「元素」為單位,而非位元組
這裡是指標真正讓人意外的地方。你可以對一個指標加上一個整數,結果是另一個指標——這就是指標算術。但 `p + 1` 並不代表「下一個位元組」。它代表「p 所指型別的下一個元素」。若 `p` 是個 `int *`,而一個 `int` 寬 4 個位元組,那麼 `p + 1` 是位址 `&(*p) + 4`——編譯器悄悄把那個 `1` 乘上 `sizeof(int)`。對一個 `char *` 加 1,你移動 1 個位元組;對一個 `double *`(8 位元組)加 1,你一躍 8 個位元組。決定步幅的,是指標的型別。
一旦看懂這點,其餘的便如鐘錶般環環相扣。把指向同一塊的兩個指標相減,得到的是它們之間元素的個數,而非位元組:若 `q = p + 3`,那麼 `q - p` 是 3,無論元素多大。你可以用 `<` 和 `==` 比較指標,問哪個在前、或它們是否重合。而遞增——`p++`——只是走到下一個元素,這正是你如何一次一個地巡行過一排值。這套算術是「懂元素」的,好讓你幾乎永遠不必自己去乘 `sizeof`。
int a[4] = {10, 20, 30, 40};
int *p = &a[0]; /* p -> the first int */
*p == 10 /* the value at p */
*(p + 1) == 20 /* one element on: address moved 4 bytes */
*(p + 3) == 40 /* three elements on: 12 bytes from start */
(p + 3) - p == 3 /* pointer difference is in ELEMENTS, not bytes */待在界線之內
指標算術之所以強大,恰恰是因為它信任你,而那份信任也正是它的陷阱。C 只有在你待在單一陣列之內(或剛好越過尾端一格的位置——你可以形成它,但絕不可解參考它)時,才定義這套算術。算出一個落在陣列起點之前兩棟、或遠超尾端的指標,你就踏進了未定義行為——這不只是一個錯的數字,而是一個編譯器如今獲准當作「壞情況絕不會發生」來處理的程式。別被那句口號騙了:這不是「視平台而定」;最佳化器可以假設你越界的指標不可能存在,並悄悄刪掉本來能救你一命的那道檢查。
第二個比較安靜的限制是對齊。多數中央處理器期待一個 4 位元組的 `int` 坐落在 4 的倍數位址、一個 8 位元組的 `double` 在 8 的倍數位址,依此類推。編譯器會自動安排你的變數來滿足這點,這也是為什麼誠實地照型別做的指標算術會保持對齊。危險只在你做些奇特的事時才出現——比方把一個 `char *` 在任意偏移處轉型成 `int *`——這時你就可能製造出一個未對齊的位址。在某些機器上這只是跑得慢;在另一些機器上則直接出錯。尊重型別,對齊自會照料好自己。
這把你帶到了哪裡
盤點一下你現在會做的事。你能用單一個 `*` 順著指標走到它的值、並透過它寫回去;你能用 `NULL` 標記「這裡沒東西」,而且你已經——切身地——知道絕不要對它解參考;你也能讓一個指標一次一個型別元素地走過記憶體,由編譯器替你縮放步幅。要緊的是,你還誠實地見識了這會出錯的兩種方式:解參考空指標造成的區段錯誤,以及越界指標漫遊進那種最佳化器可隨意利用的未定義行為。
注意上面那個實作範例倚靠的是一個陣列,而 `&a[0]` 加上用 `p + 1` 來走,感覺幾乎可以和索引 `a[1]` 互換。這不是巧合——而它也不是全部的真相。在 C 裡,陣列與指標的關係是這門語言最安靜地令人困惑的角落之一,充滿了兩者表現得一模一樣的情況,以及少數幾個它們截然不同的情況。第 3 篇「陣列與指標:真相」會把這團糾結一縷一縷拆開來。你現在已握有它所需的每一樣工具。