兩樣看起來很像、其實不同的東西
到這裡你已經知道指標是什麼——一個裝著位址的值——而從上一篇你也知道 `*p` 會讀出那個位址所指名的格子,而 `p + 1` 做的是指標運算,往前跨的是一整個元素,而不是一個位元組。現在我們來認識記憶體裡另一位初學者耳熟能詳的住戶:陣列。江湖傳言「陣列就是指標」,你會一再聽到這句話。它接近到夠好用,又錯到夠害你賠上一個下午,所以讓我們把真相從哪裡開始、到哪裡結束,釘得清清楚楚。
先從陣列到底是什麼說起。當你寫下 `int a[4];`,編譯器會劃出一整塊連續的記憶體,大到足以並排放下四個 `int` 值,中間沒有縫隙。並沒有一個藏著某個指標的「陣列變數」躲在背後;名字 `a` 本身就是那 16 個位元組(四個 4 位元組的 int)。相對地,指標自己是一個小盒子——在 64 位元機器上通常是 8 個位元組——盒子裡裝的內容是一個位址。所以 `int a[4]` 與 `int p` 是不同種類*的物件:一個是一排房子,另一個是一張寫著門牌號碼的小紙條。
int a[4] = { 10, 20, 30, 40 };
name 'a' IS these 16 bytes, in place:
+--------+--------+--------+--------+
| 10 | 20 | 30 | 40 |
+--------+--------+--------+--------+
a[0] a[1] a[2] a[3]
0x1000 0x1004 0x1008 0x100c
int *p = a; // p is a SEPARATE 8-byte box living elsewhere:
+-----------------+
| 0x1000 | p holds the address of a[0]
+-----------------+退化:傳說的由來
既然它們是不同種類的東西,那為什麼人人都搞混?因為有一條特定的規則,叫做陣列轉指標退化。在幾乎每一個你使用陣列名字的運算式裡,C 都會悄悄把那個名字轉換成指向陣列第一個元素的指標。所以在 `int *p = a;` 裡,右邊的 `a` 並不會複製 16 個位元組;它默默地變成了 `&a[0]`,一個裝著第一個元素位址的 `int *`。名字 `a` 在你於取值情境中用到它的那一瞬間,就「退化」成了一個指標。這一個轉換,正是「陣列就是指標」這句話背後全部的那一粒真相——它們不是,但在你目光所及的幾乎每個地方,它們都會變成指標。
退化也是為什麼把陣列傳進函式感覺起來像在傳指標。C 的參數一律是傳值的,而你沒辦法把一整個陣列以傳值方式傳遞——根本沒有把 16 個位元組複製進一個參數的規則。所以當你呼叫 `f(a)`,陣列會先退化,函式實際收到的是一個 `int *`。像 `void f(int arr[])` 這樣的宣告是一個有禮貌的謊言:編譯器會把它改寫成 `void f(int *arr)`。在 `f` 內部,`arr` 是一個如假包換的指標,而不是陣列,這也正是為什麼——我們馬上就會看到——在函式裡用 `sizeof` 給你的是一個指標的大小,而不是原本那個陣列的大小。
為什麼 a[i] 其實偷偷是指標運算
這裡是真相中最令人愉快的一塊。你從寫第一個迴圈就在用的方括號下標,根本不是什麼陣列專屬的特異功能——它是換了裝的、不折不扣的指標運算。語言把 `a[i]` 定義為恰好等於 `*(a + i)`:取位址、往前跨 `i` 個元素、然後解參考。這就是以指標運算來索引,一旦你看穿了它,就再也回不去了。要讀 `a[2]`,機器會取基底位址(比方 0x1000),加上 2 個各 4 位元組的元素到達 0x1008,再讀那裡的 `int`。方括號不過是加法與 `*` 之上的一層甜頭。
因為下標就字面上是 `*(a + i)`,而普通加法不在乎運算元的先後,`a + i` 等於 `i + a`,這表示 `a[i]` 與 `i[a]` 是同一個運算式。沒錯——`3[a]` 是合法的 C,讀的是 `a[3]`。沒人會故意這樣寫,但這是「方括號就是運算、別無其他」最乾淨的證明。元素型別在這裡很要緊:編譯器會自動把 `i` 乘上 `sizeof(元素)`,所以同樣的 `a[i]` 寫法,不論每一格是 1 位元組的 `char`、4 位元組的 `int`、還是 40 位元組的 `struct`,都管用——這個縮放看不見,卻永遠都在。
這種統一也是為什麼指標和陣列索引方式一模一樣。若 `int *p = a;`,那麼 `p[2]` 的作用與 `a[2]` 完全相同,因為 `p[2]` 就是 `*(p + 2)`,而 `p` 早已裝著基底位址。所以在大多數程式碼裡,你確實可以把這兩者互換著用——這正是那個傳說發揮其有用價值之處。陷阱在於相信這種互換是徹底的。它並不徹底,而下一節,就是它停下來的地方。
陣列拒絕當指標的場合
有幾個特定場合,名字不會退化,陣列就會露出它的真面目。最重要的是 sizeof 運算子。對一個真正的陣列使用,`sizeof(a)` 給的是整塊的大小——以我們的 `int a[4]` 來說就是 16 個位元組——而一個常見的慣用寫法用 `sizeof(a) / sizeof(a[0])` 來算出元素個數,得到 4。但把 `sizeof` 用在一個指標上,包括 `f(int arr[])` 裡的那個 `arr` 參數,你拿到的是指標自身的大小,通常是 8,不管原本的陣列有多大。這是「陣列就是指標」這個迷思最常咬人的一種方式:那個算個數的慣用法在函式內部會悄悄回傳 8/4 = 2,而一個本該走訪 4 個元素的迴圈只走訪了 2 個。
實際的後果是:一個收到陣列的函式,無法單從指標還原出它的長度——長度資訊在退化的那一刻就丟失了。這正是為什麼幾乎每個接收陣列的 C 函式,都會另外再收一個個數,像 `void sum(int arr, size_t n)`。並沒有一個像高階語言那樣替你存著長度的隱藏標頭;你必須自己把個數帶在身上。其餘不退化的場合範圍較窄,但值得知道:`&a` 給的是指向整個陣列*的指標(型別為 `int (*)[4]`,與 `int *` 是不同的型別),而陣列名字並不是一個可指派、可修改的值——你不能寫 `a = 某值;`,因為 `a` 不是一個裝著可重新繫結位址的變數,它本身就是那塊儲存空間。
字串、走在邊緣,以及誠實的但書
這一切在 C 的字串裡匯聚起來,而字串不過就是帶著一套約定的 `char` 陣列。一個空字元結尾字串是一連串以 `\0` 位元組(值為 0x00)收尾的字元,而一個像手寫的 `strlen()` 這樣的函式之所以能運作,正是因為它就是以指標運算來索引:讓一個 `char *` 從開頭起步,一次一個位元組往前走——`while (*p != '\0') p++;`——一路數到它落在那個結尾符上為止。並沒有存起來的長度;那個 `\0` 本身就是長度標記。這很優雅,但也恰恰是為什麼一個缺失的結尾符,會讓這趟行走衝出陣列的尾端,闖進它並不擁有的記憶體。
這就帶出了整篇導引一路鋪陳至此的誠實警告。指進陣列的指標運算,只在你停留於陣列內部、外加它最後一個元素之後緊鄰的那一個位址時才有定義(你可以計算那個「過尾端一格」的指標,但絕不可解參考它)。再往前一步——在我們四個元素的陣列裡讀 `a[4]`,或讓字串行走越過一個缺失的 `\0`——你就犯下了越界存取,也就是未定義行為。把它的意思講精確:這不是「你讀到某個隨機的鄰近值」。標準對程式可能做出的事完全不設任何約束,而現代的最佳化器被允許假設那次存取根本不會發生,這正是為什麼一個越界臭蟲在 `-O0` 下看似無害,到 `-O2` 卻會悄無聲息地毀損資料或當掉。
所以請同時握住真相的兩半。有用的那一半:陣列在幾乎所有地方都會退化成指標,`a[i]` 就是 `*(a + i)`,而你可以用一個普通的 `char *` 或 `int *` 來傳遞並走訪它們。危險的那一半:陣列不是指標,它不帶長度,而邊界唯獨由你自己來看守。這一切都不是「懂了就簡單」——走過尾端一格的差一錯誤,是整個 C 裡最常見、也最常被人利用的臭蟲之一。接下來的兩篇導引將正面迎戰其後果:這些陣列實際上住在哪裡(堆疊框架),以及當一個指標活得比它所指名的記憶體還久時,會出什麼差錯(懸置指標與記憶體區段錯誤)。