鏈結結構需要繼續存取先前操作建立的物件。複製 pointer 只保留地址,既不會 複製物件,也不會延長物件的生命週期。後續走訪能否到達有效節點、會否失去 部分結構、會否存取已釋放的儲存空間,都取決於這些區別。
C 把這些選擇明確地寫在程式中:pointer 連接物件,malloc 取得可在函數
返回後繼續存活的儲存空間,struct 則把操作必須共同維護的欄位組織起來。
ADT 可以向呼叫者隱藏這些欄位,但實作仍須在每次更新後維持正確狀態,
並明確界定儲存空間的擁有權。
Pointer 儲存的是地址,不是值
定義
Pointer
Pointer 變量儲存的是某個物件的地址,而不是該物件本身的值。
下面兩個運算符區分物件與地址:
&x表示「x的地址」;*p表示「pointerp所指向地址中的值」。
Pointer 與它指向的 object 必須分開理解:前者保存位置,後者才是該位置中的物件。
例題
逐行讀一個 pointer trace
考慮以下賦值過程:
int firstvalue = 5, secondvalue = 15;
int *p1, *p2;
p1 = &firstvalue;
p2 = &secondvalue;
*p1 = 10;
*p2 = *p1;
p1 = p2;
*p1 = 20;
應該逐行理解:
p1先指向firstvalue,p2指向secondvalue;*p1 = 10將firstvalue改成10;*p2 = *p1把10複製到secondvalue;p1 = p2並不是複製整數,而是把p1重新指向secondvalue;*p1 = 20現在就會改變secondvalue。
最後得到:
firstvalue = 10secondvalue = 20
邊讀邊試
追蹤一條 pointer 狀態序列
這個 tracer 讓你改動初始整數,再逐步重播 pointer tutorial 的狀態變化。
步驟 1
int firstvalue = ...; int secondvalue = ...; int *p1, *p2;firstvalue = 5
secondvalue = 15
p1 指向 unassigned
p2 指向 unassigned
兩個整數已存在,但兩個 pointer 仍未持有合法地址。
追蹤 pointer 狀態時,要分別記錄兩項資料:
- 每個 pointer 現在指向哪裡?
- 那個地址裡面現在存的是甚麼值?
Dereference 透過地址讀寫物件
當 pointer 已經持有合法地址之後,dereference 才有意義。
int x = 7;
int *p = &x;
*p = 12;
最後 x 會變成 12。*p = 12 不是建立一個新整數,而是經 p 所記錄
的地址,直接寫入原本的物件。
常見錯誤
混淆 pointer 賦值與透過 pointer 賦值
p = q 會改變 p 儲存的地址。
*p = *q 會把 q 指向位置中的值複製到 p 指向位置中。
兩句看起來很像,但實際作用完全不同。
區域物件與動態分配物件有不同的生命週期
地址只有在該地址中的物件仍然存活時才有意義。在 code block 內宣告的區域 物件通常具有 automatic storage duration;執行離開該 block 後,物件的 生命週期便結束。因此,回傳區域變量的地址,只會留下一個仍然記得舊位置、卻 不再指向存活物件的 pointer。
int *bad_address(void) {
int local = 7;
return &local; /* function 回傳後立即成為 dangling pointer */
}
由 malloc 取得的空間具有 allocated storage duration。即使建立它的
function 已經回傳,該空間仍然存在,直至程式把該次 allocation 交給 free。
這種較長的生命週期,令 linked structure 可以保留先前操作建立的 node。但它
並不代表 node 可以永久使用:free(x) 之後,x 及所有保存同一地址的 alias
都成為 dangling pointer。Scope 回答「這個 pointer 變量可以在哪裡被命名」,
lifetime 回答「它指向的物件現在是否仍然存在」;兩者不能混為一談。
malloc 在 heap 上分配空間
節點數量未必能在程式開始前確定。malloc 讓實作在需要新節點時取得儲存空間。
定義
用 malloc 做 heap allocation
malloc(n) 會向 runtime 請求 n bytes 的空間,並回傳該區塊起始位置的
pointer;如果請求失敗,則回傳 null pointer。新取得的 bytes 不會自動成為
已初始化的 struct 欄位。
典型寫法是:
struct node *x = malloc(sizeof *x);
if (x == NULL) {
/* 報告 allocation failure,或把失敗傳給 caller */
}
在成功路徑上,x 指向一段足以容納一個 struct node 的新空間。
sizeof *x 直接跟隨 x 的宣告型別,不必重複寫型別名稱。在 C 中,
<stdlib.h> 宣告 malloc,其回傳的 void * 不需要強制轉換。
分配後仍有三項必要工作:
- dereference 之前先檢查
x != NULL; - 在其他程式碼讀取欄位之前,逐一初始化所需欄位;
- 明確哪一部分程式擁有該 node,並負責最終釋放它。
例題
動態配置 linked structure 的節點
假設 stack 以 linked list 表示,並在每次 push 時建立一個新節點。新節點
必須在 push 返回後仍然存活,普通 automatic local variable 並不符合這項
生命週期要求。使用 malloc 可以分配所需空間,但完整的 push 還須處理
失敗、初始化欄位及維持 ownership 規則。
Ownership 令 allocation 成為完整流程
面對每一次 allocation,都應該問三個問題:誰建立它,哪些 pointer 只是暫時 借用存取權,最後由誰釋放它?Owner 負責恰好一次結束該 allocation。 Borrowed pointer 可以在 owner 的規則下讀寫存活物件,卻不能比物件活得更久, 亦不能在沒有轉移 ownership 的情況下自行釋放物件。
如果最後一個可用 pointer 被覆寫,仍然存活的 allocation 會變得不可到達, 形成 memory leak。如果 owner 已經釋放物件,其他 alias 再存取它便是 use-after-free。如果兩條路徑都誤以為自己是 owner,則可能發生 double free。 C 不會自動阻止這些錯誤,因此 ADT implementation 必須令每個 operation 的 ownership contract 保持清楚。
typedef 令介面更易讀
ADT 介面可以用 typedef 為較長的 pointer type 提供簡短名稱。
typedef struct node *nodePtr;
typedef int stackElementT;
typedef 不會建立新 runtime 物件;它只會建立新的型別名稱。好處是:
- function prototype 變短;
- header file 更易掃描;
- ADT 邊界更清楚。
使用 stackADT 這個名稱時,這個名字本身就是 abstraction 的一部分。它要求
client 將這個物件理解為「一個 stack」,而不是「某個具體 struct 的 pointer」。
不過 type alias 本身不會令表示自動變成私有。真正的 representation hiding
需要在 interface 中只宣告不完整的 structure type,再把欄位定義放進
implementation file。typedef 為 client 提供易讀的 handle,而 incomplete
type 才會阻止 client 直接存取內部欄位。
struct 把相關欄位組成一個 record
定義
Structure
struct 可以把多個欄位組成同一個 record type。
例如:
struct node {
int data;
struct node *next;
};
這就是 linked-list node 的典型形狀:一個欄位放 payload,一個欄位記錄下一 個 node 的地址。
這個定義可以 self-reference,是因為 node 並沒有直接包含另一個完整 node。
Compiler 讀取 struct node 期間,已經可以用該 tag 宣告
struct node *next,因為 object pointer 的大小是已知的。如果改成
struct node next;,每個 node 都要內含另一個完整 node,type 的大小便永遠
無法確定。
對 node pointer p 而言,p->next 等價於 (*p).next。更新這個欄位,是在
linked structure 的 memory graph 中改動一條 edge,而不是搬動 node 本身;
改變的是由 p 出發,下一步可以到達哪個 node。
重點不只是語法上的分組,而是它令你能夠精確建模資料結構需要維持的狀態。
例題
Queue node 如何支援 queue 操作
若 queue 使用 linked node,常見寫法是
struct cellT {
queueElementT element;
struct cellT *next;
};
而 queue 物件本身通常還會保存:
headpointertailpointer- 可能還有長度欄位
所以 enqueue、dequeue 與 QueueLength() 等 ADT 操作,實際上就是在更
新少量 pointer 欄位。非空 queue 的 enqueue 應先執行
tail->next = fresh,再執行 tail = fresh;空 queue 則要令 head 與
tail 同時指向 fresh。Dequeue 必須在釋放舊 head 前先保存
head->next,然後把所保存的 pointer 裝成新 head。若移除的是最後一個
node,亦必須令 tail = NULL。更新次序屬於 correctness,而不只是 code
style。
定理 / 命題
定理
存活物件的 dereference 邊界
只有當 p 指向一個型別相容、仍在生命週期內而且容許執行該次存取的物件時,
經 *p 或 p->field 讀寫才是有效的。Null、indeterminate、指向 array 末端
後一個位置的 pointer,以及 dangling pointer 都沒有跨過這條邊界,不能被
dereference。
定理
Linked-node 可到達性與更新次序不變量
假設 linked ADT 恰好擁有由指定 root pointer 可到達的 node。一次 mutation 要保持 ownership,就必須確保所有應繼續留在 structure 中的 node,在舊 link 被覆寫或舊 node 被釋放時仍然可到達。因此,破壞舊路徑之前必須先保存所需 successor,並先裝上維持可到達性所需的新 link。
證明思路
第一個命題來自 dereference 的含義:它要存取 pointer 所指明的物件。若沒有
一個型別相容而且仍存活的物件可供存取,C 的 execution model 便沒有合法
target,該操作是 undefined behavior。Function 回傳或 free 之後,即使
pointer 仍保存舊地址的數值,亦不會延長原物件的生命週期。
第二個命題可把每個 node 看成 vertex,把每個 pointer field 看成 directed
edge。覆寫通往某段仍需保留的 sublist 的唯一 edge,會令該 sublist 不可到達;
在 free(old) 之後才讀取 old->next,則違反第一個命題。因此 dequeue 要先
保存 successor 再釋放舊 head;enqueue 要先接上 fresh node 再推進 tail。
空 queue 與單 node queue 還要分別更新 root,因為這時 head 與 tail 指向同一
node。
記憶體表示與 ADT 介面
這些語言機制分別承擔 ADT 實作中的不同工作。
- Pointer 令一個 object 可以指向另一個 object;
malloc令節點可以按操作需要而動態建立;struct令多個相關欄位可以被當作一個整體維持;typedef令 ADT 介面可以把實作細節藏起來。
公開介面規定操作的行為;pointer、配置規則和私有 struct 則決定如何實現 這些行為。兩者的分離會在 stack 與 queue 的不同表示中具體呈現。
常見錯誤
常見錯誤
未初始化的 pointer 不是合法物件
寫 int *p; 只代表建立了一個 pointer 變量,並不代表它已經指向安全的空
間。在賦值之前就去 dereference,屬於未定義行為。
常見錯誤
malloc 不會替你建好節點內容
malloc 只提供 raw storage。節點欄位仍然要由你自己逐一初始化。
常見錯誤
兩個 pointer 可以看到同一個 object
若兩個 pointer 指向同一塊記憶體,經其中一個 pointer 寫入資料,另一個 pointer 之後讀到的也會是更新後的結果。
常見錯誤
NULL 是 sentinel,不是 object
測試 p == NULL 是安全的;當 p == NULL 時求值 *p 或 p->field 則不
安全。Linked structure 可以用 NULL 表示末端,正因為該處沒有 node 可供
存取。
常見錯誤
經一個 alias 釋放,會令所有 alias 失效
free(p) 後把 p = NULL,只能防止經這一個變量再次誤用;其他保存相同地址
的 pointer 不會隨之改變。它們仍是 dangling pointer,不能再被 dereference,
亦不能再次交給 free。
快速檢查
思考檢查
p = q 與 *p = *q 有甚麼分別?
用地址與值去回答。
解答 · 答案
p = q 會複製地址到 p;*p = *q 會複製 q 指向位置中的值到 p
所指向的位置。
思考檢查
為何 malloc(sizeof(struct node)) 比手寫 byte 數更可靠?
想想結構欄位若之後改變會發生甚麼。
解答 · 答案
因為 sizeof(struct node) 會自動跟隨真正結構大小。如果欄位之後改動,
allocation 大小仍然正確。
一個更完整的 struct trace
下面的 record 包含兩個固定大小的字串 buffer 及一個整數欄位。讀取流程先 檢查 allocation、檔案開啟及轉換結果,再使用 record,最後釋放取得的資源。
typedef struct Student_Data {
char name[50];
int age;
char address[50];
} Sdata;
Sdata *p = malloc(sizeof *p);
if (p == NULL) return EXIT_FAILURE;
FILE *fp = fopen("example.txt", "r");
if (fp == NULL) {
free(p);
return EXIT_FAILURE;
}
int fields = fscanf(fp, "%49s %d %49s",
p->name, &p->age, p->address);
fclose(fp);
if (fields != 3) {
free(p);
return EXIT_FAILURE;
}
/* 使用已經初始化的 record */
free(p);
p = NULL;
可以將 trace 拆成四個問題:
p指向哪個物件?p->name與p->address分別使用哪個 buffer?&p->age為何需要取地址?- 讀取完成後由誰負責釋放資源?
Allocation 成功後,p 指向一個存活的 Sdata object。兩個 array field
傳給 fscanf 時會提供首個元素的 pointer;integer conversion 則需要
&p->age。Width limit 保護各有 50 個元素的 array,避免過長 token 越界;
return value 確認三個 conversion 都成功。這個 format 讀取三個以 whitespace
分隔的 token,並不適合含空格的完整地址。fclose 釋放 file resource,
free 結束 dynamic object 的 lifetime。這樣閱讀,才是把 syntax 放進同一個
ownership-and-state trace,而不是逐句死記。
總結
- Pointer 保存地址;dereference 會在型別與存取規則容許時,讀寫該地址中的 存活物件。
- Automatic local object 在離開 code block 後消失;dynamic object 存活至
free。變量仍在 scope 內或地址數值仍被保存,都不會延長 object lifetime。 - 每次 allocation 都需要檢查失敗、初始化欄位、指定 owner,並且最終恰好 釋放一次。
- Self-referential node 用 pointer 保存 link;linked structure 的 operation 是 memory graph 更新,次序必須維持由 ADT root 出發的可到達性。
typedef改善 interface 用詞;真正隱藏 representation 的是 incomplete type 與分開的 implementation。
練習
思考檢查
追蹤這段 code 的最後結果:int x=1,y=2; int *p=&x; int *q=&y; *p=*q; q=p; *q=9;
先區分值的複製與地址的重新指定。
解答 · 引導解答
*p=*q 先令 x 變成 2。之後 q=p 令 q 也指向 x。最後 *q=9
是經 q 改寫 x,所以最終 x=9、y=2。
思考檢查
解釋 linked-list node 中指向下一個 node 的 pointer 有何作用。
考慮 traversal 與動態增長。
解答 · 引導解答
如果沒有 next pointer,一個 node 就無法連到下一個 node,list 亦無法沿着
鏈結逐步走訪。動態增加 node 時,也會失去把新 node 接到既有 structure 的
方法。