Evanalysis
0.1預計閱讀時間: 17 分鐘

0.1 Pointer、記憶體與 struct

重溫資料結構課會反覆用到的 C 工具:address、dereference、malloc、typedef 與 struct 佈局。

課程目錄

鏈結結構需要繼續存取先前操作建立的物件。複製 pointer 只保留地址,既不會 複製物件,也不會延長物件的生命週期。後續走訪能否到達有效節點、會否失去 部分結構、會否存取已釋放的儲存空間,都取決於這些區別。

C 把這些選擇明確地寫在程式中:pointer 連接物件,malloc 取得可在函數 返回後繼續存活的儲存空間,struct 則把操作必須共同維護的欄位組織起來。 ADT 可以向呼叫者隱藏這些欄位,但實作仍須在每次更新後維持正確狀態, 並明確界定儲存空間的擁有權。

Pointer 儲存的是地址,不是值

定義

Pointer

Pointer 變量儲存的是某個物件的地址,而不是該物件本身的值。

下面兩個運算符區分物件與地址:

  • &x 表示「x 的地址」;
  • *p 表示「pointer p 所指向地址中的值」。

Pointer 與它指向的 object 必須分開理解:前者保存位置,後者才是該位置中的物件。

例題

逐行讀一個 pointer trace

考慮以下賦值過程:

int firstvalue = 5, secondvalue = 15;
int *p1, *p2;

p1 = &firstvalue;
p2 = &secondvalue;
*p1 = 10;
*p2 = *p1;
p1 = p2;
*p1 = 20;

應該逐行理解:

  1. p1 先指向 firstvalue,p2 指向 secondvalue;
  2. *p1 = 10 將 firstvalue 改成 10;
  3. *p2 = *p1 把 10 複製到 secondvalue;
  4. p1 = p2 並不是複製整數,而是把 p1 重新指向 secondvalue;
  5. *p1 = 20 現在就會改變 secondvalue。

最後得到:

  • firstvalue = 10
  • secondvalue = 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 物件本身通常還會保存:

  • head pointer
  • tail pointer
  • 可能還有長度欄位

所以 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 拆成四個問題:

  1. p 指向哪個物件?
  2. p->name 與 p->address 分別使用哪個 buffer?
  3. &p->age 為何需要取地址?
  4. 讀取完成後由誰負責釋放資源?

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 的 方法。

練習

先自行作答,再檢查答案。你可以修改後重試。

載入中…

先備知識

這一節可以獨立閱讀。

本單元重點詞彙