我們爬到哪了,以及為什麼指標一直等到現在
在「學習 C 語言」那一階,我們刻意停在指標「之前」,並承諾給它們專屬的一階。這就是那一階。你學過的一切仍然成立:變數是記憶體裡一個具名、帶型別的盒子;盒子裡的位元在某個型別讀它之前毫無意義;而記憶體本身,從最早的基礎篇開始,就是一條長長、編了號的位元組陣列。請把最後這幅圖牢牢抓住,因為一個指標不過就是一種「為那個陣列裡的某個位置命名」、並把它當成一個值帶著走的方法。
那為什麼要貼這麼多警告標籤、要花一整階?因為一旦你能為某個記憶體位置命名,你也就能為「錯」的那個命名、為一個已不存在的那個命名、或一路走過你原本所指範圍的尾端。指標難,不是因為觀念深奧——這觀念簡單到幾乎令人不好意思。它們難,是因為它們毫不寬容:一個小小的閃失就變成一次當機,或者更糟,變成悄無聲息的損毀。本階的工作,是先在這一篇把這個簡單的觀念真正講清楚,再在接下來四篇裡誠實地走過各種失敗模式。我們從快樂、堅實的地面起步。
記憶體有位址,就像房子有門牌號
再想像一次那條信箱街,但這回去讀漆在它們上頭的號碼。記憶體的每一個位元組都坐在一個編了號的位置上,那個號碼就是它的位址。你的程式能合法使用的所有位址,整套合起來就是它的位址空間——在現代 64 位元機器上,那是一段巨大的範圍,從 0 一直編到將近 2^64。我們一律用小寫十六進位來寫位址,所以一個位元組可能住在 0x1F 或 0x7fffffffe3c0。位址只說「在哪裡」;那裡存了什麼,是另一個完全獨立的問題。
一個佔好幾個位元組的值——一個 `int` 通常是 4 個位元組——並不會有四個位址;依慣例它只有「一個」,也就是它第一個(最低)位元組的位址。當我們說變數 `x`「在」0x1000 時,意思是它的位元組從那裡開始:0x1000、0x1001、0x1002、0x1003。是編譯器在發放這些位址,決定你每個變數住在哪裡。你平常根本看不到它們——直到你去問。而在 C 裡,你問的方式,是一個單一的字元。
int x = 42; address bytes (little-endian) meaning 0x1000 2a 00 00 00 int x = 42 (0x2a == 42) 0x1004 .. the next variable... &x == 0x1000 <- the address OF x x == 42 <- the value AT that address
& 取出位址,* 順著它走過去
兩個運算子包辦了所有工作,而它們恰好相反。取址運算子,寫成變數前面一個單獨的 & 符號,是在問編譯器:「這個盒子住在哪裡?」所以 &x 不是值 42;它是那個 42 所坐的位址 0x1000。把 `&x` 唸成「x 的位址」。那個位址本身就是一個值——一個數——而和任何值一樣,它可以被存起來。你拿來存它的那個盒子,就是一個指標。
宣告一個指標要在型別裡用一顆星:`int p;` 讀作「p 是一個指向 int 的指標」。指派 `p = &x;`,現在 `p` 裝著 0x1000——它指著* `x`。要反過來走,從位址回到住在那裡的值,你用解參考運算子,同樣是一顆單星,但這回放在指標前面:*p 意思是「p 所指向的那個 int」,也就是 42。這份對稱就是全部的故事:`&` 把一個值換成它的位址,`*` 把一個位址換回那個值。它們互相抵消。
int x = 42; int *p = &x; /* p holds the address of x */ p == 0x1000 (the address) *p == 42 (the value at that address) *p = 99; /* writes through p: now x == 99 too */
為什麼指標還要有型別
這裡有個合理的問題:如果指標只是裝著一個位址,而位址全都是普通的數,那為什麼不只有一種指標?為什麼我們要寫 `int *p` 和 `char *s`,而不是一種萬用指標?答案是:光憑一個位址只告訴你「在哪裡」,卻沒告訴你「該怎麼讀」那裡的東西——而正確地讀位元組,正是資料那一階「樣式對解讀」的同一個觀念。指標的型別補上了缺的那一半:它說明目標佔了幾個位元組、又該怎麼解讀它們。
具體來說,當你對一個 `int *` 寫 `*p` 時,編譯器會從那個位址起讀 4 個位元組、並把它們當成一個 `int`;對同一個位址解參考一個 `char *`,它就只讀 1 個位元組、當成一個 `char`。同一個位址,不同的鏡片,不同的值。這也是為什麼,在下一篇裡,指標算術會以「所指型別」為單位移動,而不是以赤裸的位元組為單位。指標的型別不是裝飾;它是「該如何使用它所攜帶的位址」的指示。
有一個刻意設下的例外,現在值得點名:一個空指標,寫成常數 `NULL`(或在指標情境下就寫 0)。它是一個指標,依保證指向空無——一個保留的值,與每一個真實位址都不同,意思是「我沒有對準任何有效的地方」。你用它來表達「這個指標還沒有目標」,並在順著一個指標走之前先檢查它是不是它。我們之後會大量倚靠空指標;眼下只要記住它存在,而且解參考它,是程式死法的經典之一。
當指標出錯時
現在是誠實的部分,因為把它藏起來會讓你成為更危險、而非更安全的程式設計師。一個指標的好壞,全看它裡頭那個位址。如果 `p` 裝的是一個不屬於你所擁有的任何東西的數——一個空指標、一個來自已不存在盒子的過期位址、或一個未初始化指標裡的純粹垃圾——那麼 `*p` 就伸進了一個你無權碰觸的地方。當那個位址落在作業系統授予你程式的範圍之外,硬體會察覺,核心就用一個區段錯誤把你殺掉:那句直白的訊息 `Segmentation fault (core dumped)`,每個 C 程式設計師最終都學會把它讀成「我解參考了一個壞掉的指標」。
比當機更糟的,是根本沒有當機的情況。如果那個壞位址恰好落在你「確實」擁有的記憶體裡——只是不是你原本想要的那個盒子——那麼 `*p = 99` 就悄無聲息地覆寫了別的東西,程式帶著被汙染的資料一跛一跛地走下去,直到它在離真正起因很遠的地方才出狀況。區段錯誤是響亮且找得到的;悄無聲息的損毀才是惡夢。這正是接下來四篇導引存在的理由:可能踏過尾端的指標算術、指向已經消失盒子的迷途指標,以及讓它們每一個都對準某個真實之物的紀律。
這把你帶到了哪裡
退一步看。你現在掌握了整階所倚賴的那一個觀念:指標是一個裝著位址的值,`&` 從一個變數產生一個位址,而 `*` 順著一個位址走回它的值。這真的就是指標的全部了——後面沒有更深的魔法在等你。前方的一切,都是同一個觀念加上謹慎的運用:讓位址指向對的東西、讓它持續指在那裡、並且只在目標確實存在時,才透過它讀或寫。
下一篇正好從這一篇結束之處接起。我們在這裡把 `&` 和 `*` 走得很慢;第 2 篇讓它們真正上工——認真地解參考、把空指標當成一種紀律而非僅僅一個事實,以及指標算術,那條起初令人意外的規則:對一個 `int *` 加 1,會讓它前進一整個 `int`,而不是一個位元組。一旦那一刻通了,第 3 篇就揭開陣列與指標之間的真相,本階再從那裡往上爬,朝著堆疊、堆積,以及我們目前只瞥見一眼的那些失敗模式前進。地基你已經有了;現在我們在上頭蓋。