指針:位址模型與間接存取
R — 原文
"由於透過位址能找到所需的變數單元,可以說,位址指向該變數單元。……因此,將位址形象化地稱為'指針'。意思是透過它能找到以它為位址的記憶體單元。" ——第8章 §8.1 指針是什麼
"首先要準確理解指針的含義。……明確了'指針就是位址',就比較容易理解了,許多問題也迎刃而解了。" ——第8章 §8.9 有關指針的小結(1)
"要區別指針和指針變數。指針就是位址,而指針變數是用來存放位址的變數。" ——第8章 §8.9 有關指針的小結(3)
"應注意,p 的值為 NULL 與未對 p 賦值是兩個不同的概念。前者是有值的(值為0),不指向任何變數;後者雖未對 p 賦值但並不等于 p 無值,只是它的值是一個無法預料的值……因此,在引用指針變數之前應對它賦值。" ——第8章 §8.9 有關指針的小結(8)
I — 自述
指針之所以難,是因為它同時涉及兩層東西:位址(一個位置編號)和該位置存的是什麼類型的資料。把這兩層分開,整章就通了。
第一層:位址。 程式裡的每個變數在編譯時都被分配了幾個位元組的記憶體,每個位元組有編號,這個編號就是位址——相當於旅館的房間號碼。透過房間號碼能找到房間,所以"位址指向該單元",把位址形象地叫做"指針"。這就是全書最核心的一句:指針就是位址。
存取一個變數有兩種方式:直接存取是按變數名存取(編譯器內部有"變數名→位址"的對應表,所以按名字能找到位址);間接存取是先把位址存進另一個變數,再經它找到目標——那個"專門存位址的變數"就是指針變數。
第二層:類型。 光有位址是不夠的。假設告訴你"從 2000 號單元取資料",你仍然不知道該取 1 個位元組(字元)、2 個位元組(短整數)還是 4 個位元組(整數),也不知道按二補數還是按浮點格式解釋。所以 C 的位址是帶類型的位址:它不只是位置,還包含"指向的資料是什麼類型"。這就是為什麼定義指針變數時必須指定基類型(int *p 裡的 int):它決定了 *p 讀幾個位元組、按什麼格式解釋,也決定了 p+1 是往後移 1 位元組還是 4 位元組。
由此推出解參考 *p 的兩個要點:*p 代表"p 所指向的那個變數",既能讀也能寫(*p=3 是把 3 存到 p 指向的單元,不是把 3 存進 p)。而 p 本身的值是位址,改 p 只是改變"指向誰"。
最容易混的三個概念必須嚴格分開:
- 指針=位址(一個值,如 2000);
- 指針變數=存位址的變數(它的值就是那個位址);
- 基類型=它指向什麼類型的資料。
所以可以說「變數 i 的指針是 2000」,但不能說「i 的指針變數是 2000」——指針變數是 p 本身,不是 2000。
安全用法只有一條鐵律:用之前先讓它指向一個合法物件。 指針變數定義後如果不賦值,它的值是記憶體裡的殘留垃圾,可能指向任何地方;此時 *p = 3 會隨機改壞一塊無關記憶體,症狀可能很久以後才出現。必須區分三種狀態:①未賦值(髒值,危險);②值為 NULL(明確不指向任何物件,是合法的、可判斷的狀態);③指向有效物件(唯一可以解參考的狀態)。NULL 在 stdio.h 裡定義為 0,系統保證 0 號單元不作他用。
指針真正的用途有三個(原書 §8.9 結尾總結):提高程式效率;讓函式能改呼叫者的變數、帶回多個結果;實現動態儲存配置(鏈結串列等)。同時原書也提醒:指針"太靈活",出錯往往隱蔽且可能破壞整個程式,使用時要十分小心。
A1 — 書中案例
- 例8.2 交換兩個指針的指向(原書給出執行結果):輸入 a=5、b=9,
if(a<b){p=p1;p1=p2;p2=p;}之後,a、b 的值並沒有交換(仍是 5 和 9),但p1、p2的指向換了,所以輸出*p1、*p2得到 9 和 5。原書原話:「a 和 b 的值並未交換,它們仍保持原值,但 p1 和 p2 的值改變了。」這個案例精確區分了「改變指針指向」和「改變目標變數的值」。 - 例8.3 swap 傳指針交換目標值(原書給出結果):形參是
int *p1,int *p2,函式內temp=*p1; *p1=*p2; *p2=temp;改的是*p1、*p2,因此 a、b 真的交換了。同一個"交換",例8.2 換指向、例8.3 換值,是理解p與*p差異的最佳對照。 - §8.1 位址與內容的區別(原書強調):假設 int 變數 i 配置在 2000
2003、j 在 20042007、k 在 2008~2011,則k=i+j是"從 2000 起取出 i、從 2004 起取出 j,相加後送到 2008 起"。若用 scanf 則scanf("%d",&i)把值送到 2000 起的單元。這段話把"變數名→位址→依類型取數"的全過程講透了。
A2 — 何時觸發
使用者在這些情境下會觸發:
- 學指針學到一半卡住,說不清
*p、&a、p各是什麼; - 函式想改呼叫者的變數,別人說"要傳指針",但不知道為什麼;
- 程式隨機崩潰,或者某個不相干的變數莫名改了值;
- 分不清「指針」和「指針變數」兩個詞;
- 想知道
int *p裡的*和*p裡的*是不是一回事; - 陣列、字串、鏈結串列裡到處是指針,看不透底層在做什麼;
- 用了指針但沒初始化,出現了難以重現的故障。
語言訊號:「指針到底是什麼」「指針就是位址怎麼理解」「*p 和 &a 區別」「指針變數和指針有什麼不同」「為什麼要指定基類型」「p+1 為什麼加了4不是1」「野指標/空指標」「segmentation fault」「函式傳指針為什麼能改實參」。
與相鄰能力區分:本能力管「位址模型 + 指針語意 + 安全規則」。如果問題是「用指針走訪陣列的寫法」→ 可先用 array-and-string 再回到本能力;是「鏈結串列怎麼建」→ 用本包內的《結構體與鏈結串列》能力卡;是「函式參數怎麼寫」→ 用 function-and-recursion(參數傳遞機制部分與本能力互補)。
E — 執行步驟
輸入契約:需要①使用者當前的困惑點(概念不清/某段程式碼行為不符預期/崩潰);②相關程式碼片段;③若涉及崩潰,需重現條件與報錯資訊。僅憑「指針用不好」這類籠統描述,應先讓使用者給出具體程式碼或具體問題,否則只能講概念無法定位。
輸出契約:概念場景交付「三層拆解(位址/指針變數/基類型)+ 一個可執行的對照範例」;除錯場景交付「問題歸類(野指標/類型不匹配/整數當位址/指針與指針變數混淆)→ 原因 → 改法」。
步驟:
- 分離兩層:先確認使用者卡在「位址這一層不清楚」還是「類型這一層不清楚」。完成標準:能說出使用者具體不理解哪一句。
- 建立位址模型:用「房間號碼」「抽屜鑰匙放另一個抽屜」這類比喻講清 直接存取 vs 間接存取。完成標準:使用者能自己說出
&a是什麼、p=&a做了什麼。 - 區分三個概念:明確「指針(位址,是個值)/指針變數(存位址的變數)/基類型(指向什麼)」。完成標準:使用者能正確回答「p 的值是什麼」「*p 是什麼」「p 的類型是什麼」三個問題。
- 說清
*的兩義(判斷停點):定義語句int *p;裡的*只是標記"這是指針變數";表達式*p裡才是"解參考"。若使用者混淆這兩處 → 回到第 3 步重講,不往下走。 - 檢查基類型匹配:
int *p只能接int變數的位址;把float的位址賦給int*會依錯誤格式解釋位元組。完成標準:賦值號兩側類型一致。 - 安全檢查(必做):確認每個被解參考的指針 ①已賦值指向合法物件,或 ②顯式設為
NULL且有判空。完成標準:找不到「定義後未初始化就被*p使用」的路徑。 - 給出可執行的對照:用「交換指向 vs 交換值」這組對照(例8.2 / 例8.3)演示
p與*p的差別。完成標準:使用者能預測兩段程式碼各自的輸出。
B — 邊界
- 不適用:如果使用者只是要「用指針走訪陣列」而不關心原理,直接給寫法即可,不必強推整套位址模型。
- 動態記憶體的涵蓋範圍要分清(本卡 A2 提到 malloc/free,但深淺有邊界):
- 涵蓋:
malloc申請、free釋放、忘記 free 會導致記憶體洩漏(程式越跑佔用越大)、程式退出時作業系統會回收整塊行程記憶體(所以小程式的洩漏不影響下次執行,但長期執行的服務會耗盡)。 - 不涵蓋:記憶體所有權模型(誰該負責釋放)、懸盪指標(free 之後繼續用同一指標)、重複 free、指標別名導致的最佳化問題、
restrict、嚴格別名規則、多執行緒共享指標的競爭條件。 - 使用者若問「free 之後還能不能用這個指針」→ 本卡只能回答「不能,行為未定義,需設為 NULL」,但更深的所有權設計需說明超出本書範圍。
- 涵蓋:
- 作者盲點(階段 0 批判):本書的指針講解以「位址=房間號碼」為核心比喻,這套講法對理解
&/*極為有效,但不涉及記憶體所有權、生命週期、懸盪指標、指標別名導致的最佳化問題。使用者若在真實工程中遇到 free 後使用、多執行緒共享指標等問題,本卡只能給出基礎判斷,需說明超出範圍。 - 易混淆:①
*在定義語句與表達式中含義不同;②&既是"取位址"又是"按位元與",靠上下文區分;③NULL(有值,為0)≠ 未賦值(無值,是髒值);④陣列名是首元素位址(指針),但不是指針變數——不能對它賦值(a = p非法)。 - 失敗模式:把指針當成「進階技巧」炫技,寫出別人看不懂的
*(*(p+i)+j)之類表達式。原書態度明確:初學階段"不宜過早地使用 C 語言的某些容易引起錯誤的細節",應優先寫清晰易讀的程式碼。 - 安全紅線:絕不在未確認指針有效的情況下建議使用者執行解參考或寫入操作;涉及系統敏感位址(如 0 號單元、硬體位址)時,只解釋原理,不給可直接執行的寫操作範例。