說明
除了 C 語言提供的基本型態(primitive data type)之外,結構(structure)是一種複合型態(derived data type),用來表示由多個屬性所組成的資料。這些屬性可以是基本型態,也可以是另一個複合型態。透過結構,程式設計者可以定義新的資料型態,用來描述具有多個欄位的資料。
由於 C 語言本身並沒有內建的物件導向語法,因此在實務上,常會利用「指向結構的指標」來模擬 C++(或 Java、C#)中的 this 指標。
宣告結構
使用 struct 保留字可以宣告結構,如下例:
struct person_t {
char *name;
unsigned age;
};
int main(void)
{
struct person_t p = { "ByteBard", 37 };
return 0;
}
然而,這種初始化方式將結構成員的位置寫死;若日後調整成員順序或新增欄位,相關程式碼也需要一併修改,在軟體工程的觀點上較不理想。較常見的做法是使用指定初始化(designated initializer):
struct person_t {
char *name;
unsigned age;
};
int main(void)
{
struct person_t p = {
.name = "ByteBard",
.age = 37
};
return 0;
}
透過這種方式,不需要依賴結構成員的相對位置;日後若調整結構內容,也能將需要修改的程式碼降到最低。
此外,也可以搭配 typedef 來簡化結構型別的名稱:
/* Forward declaration. */
typedef struct person_t person_t;
struct person_t {
char *name;
unsigned age;
};
int main(void)
{
person_t p = {
.name = "ByteBard",
.age = 37
};
return 0;
}
若希望避免在全域命名空間中再引入一個結構名稱,也可以使用以下方式宣告:
typedef struct {
char *name;
unsigned age;
} person_t;
此時宣告的是匿名結構(anonymous structure),不會額外佔用結構名稱的命名空間。
存取結構內屬性
使用 . (點號) 可存取結構內的屬性,如下例:
#include <assert.h>
typedef struct point_t point_t;
struct point_t {
double x;
double y;
};
int main(void)
{
point_t pt = {
.x = 3.0,
.y = 4.0
};
assert(pt.x == 3);
assert(pt.y == 4);
return 0;
}
內嵌在結構內的結構
由於結構本身也是一種型別,因此可以在結構中嵌入另一個結構,例如:
#include <assert.h>
#include <string.h>
typedef struct person_t person_t;
struct person_t {
char *name;
unsigned age;
};
typedef struct employee_t employee_t;
struct employee_t {
person_t p;
double salary;
};
int main(void)
{
employee_t ee = {
.p = { .name = "ByteBard", .age = 37 },
.salary = 100.0
};
assert(strcmp(ee.p.name, "ByteBard") == 0);
assert(ee.p.age == 37);
assert(ee.salary == 100.0);
return 0;
}
但結構本身不能直接嵌入同一型別的結構,也就是說結構不能以值的形式遞迴宣告。不過,結構內可以放入「指向同一結構型別的指標」,這在資料結構的設計中相當常見。
儲存結構的陣列
由於結構在型別系統中被視為一個新的型別,因此也可以用陣列來儲存多個結構,例如:
#include <assert.h>
#include <stddef.h>
#include <stdio.h>
typedef struct point_t point_t;
struct point_t {
double x;
double y;
};
int main(void)
{
point_t pts[] = {
{ .x = 0.0, .y = 0.0 },
{ .x = 1.0, .y = 2.0 },
{ .x = 3.0, .y = 4.0 }
};
for (size_t i = 0; i < 3; i++) {
printf("(%.2f, %.2f)\n", pts[i].x, pts[i].y);
}
return 0;
}
宣告指向指標的結構
以下是一個宣告「指向結構的指標」(pointer to struct)的簡單例子:
#include <stdlib.h>
typedef struct point_t point_t;
struct point_t {
double x;
double y;
};
int main(void)
{
point_t *pt = malloc(sizeof(point_t));
if (!pt)
return 1;
free(pt);
return 0;
}
由於記憶體是從堆積(heap)動態配置而來,因此在程式結束前必須記得將其釋放。
有些 C 語言教材會將型別名稱進一步簡化,如下例:
typedef struct point_t point_t;
typedef point_t * point_p;
這並不是硬性規定,只是一種個人風格。筆者目前沒有採用這種方式,因為這樣的別名容易讓人忽略該型別其實是指標;若採用此做法,建議在名稱尾端加上 _p 或 Ptr 等能辨識為指標的型別名稱。
存取結構指標的屬性
存取結構指標中的成員通常有兩種方式:
- 解指標 (dereference)
- 使用
->(箭號)
-> 的寫法在語法上較為簡潔,因此在實務上較常使用,可以視為一種語法糖。在以下例子中,我們兩種方法都示範一次,供讀者比較:
#include <assert.h>
#include <stdlib.h>
typedef struct point_t point_t;
struct point_t {
double x;
double y;
};
int main(void)
{
point_t *pt = malloc(sizeof(point_t));
if (!pt)
return 1;
/* Init x and y with dereference. */
(*pt).x = 0.0;
(*pt).y = 0.0;
/* Access fields of pt with dereference. */
assert((*pt).x == 0.0);
assert((*pt).y == 0.0);
/* Mutate x and y with `->` */
pt->x = 3.0;
pt->y = 4.0;
/* Access fields of pt with `->` */
assert(pt->x == 3.0);
assert(pt->y == 4.0);
free(pt);
return 0;
}
(選讀案例) 無函式的堆疊操作
本節屬於選讀內容;若覺得暫時沒有需求或理解上較為困難,可以先略過。
在學習資料結構與演算法時,我們通常會把程式碼包裝成函式(或方法),因為這些邏輯往往會被重複使用。這樣的做法在實務上確實很常見。不過,對初學者而言,也可以嘗試先把資料結構或演算法的步驟直接寫在主程式中;在某些情境下這樣反而更容易理解。等到熟悉流程後,再將其重構為函式,也能更清楚兩者的差異。如果讀者已經熟悉 C 的核心語法,其實不必刻意採用這種練習方式。
由於整個程式碼略長,我們將完整的程式碼在這裡展示,有需要的讀者可自行前往觀看。接下來,我們會分段說明。
堆疊(stack)是一種先進後出(FILO, first‑in, last‑out)的線性(linear)資料結構。可以把它想像成一個桶子:先放進去的元素會在底部,後放進去的元素則堆在上方。
典型的 C 資料結構會採取以下的方式來宣告堆疊型別:
typedef struct node_t node_t;
struct node_t {
int data;
node_t *next;
};
typedef struct stack_t stack_t;
struct stack_t {
node_t *top;
};
node_t 物件是用來儲存資料的盒子,而 stack_t 型別會將 node_t 物件包在裡面,日後用函式寫資料結構時,node_t 物件不會外露。
在我們這個例子中,我們採用單向連結串列 (singly-linked list) 將堆疊串起來,如果讀者一開始不知道這是什麼意思也無妨,這只是描述某種串連節點的方式的術語。
由這個例子可以發現,雖然結構不能內嵌同一結構,但可內嵌指向同一結構的指標。
有些 C 語言教材會採用以下策略:
typedef struct node_t node_t;
struct node_t {
int data;
node_t *next;
};
typedef node_t *stack_t;
這種做法等於直接將指向 node_t 的指標作為堆疊型別。在本例中是可行的;然而,在許多資料結構中往往需要多個欄位來描述狀態,此方法便不再適用。讀者可以嘗試用這種方式改寫本範例作為練習。
首先,我們建立堆疊物件 st:
/* Program state. */
bool failed = false;
/* Create a `stack_t` object. */
stack_t *st = malloc(sizeof(stack_t));
if (!st)
return EXIT_FAILURE;
st->top = NULL;
同樣地,我們使用 malloc 搭配 sizeof 來配置記憶體。至於 failed 只是用來表示整個程式運作狀態的旗標,與堆疊操作本身無關。
接著,建立並插入第一個節點:
/* Insert an element. */
{
// Create a new node.
node_t *node = malloc(sizeof(node_t));
if (!node) {
perror("Failed to allocate a node");
failed = true;
goto STACK_FREE;
}
node->data = 9;
node->next = NULL;
// Point st->top to node.
st->top = node;
}
在這段程式碼中,我們刻意將程式碼包在一個區塊中,以減少命名空間的汙染。簡單來說,變數 node 的作用範圍只存在於此區塊內,區塊結束後便不再有效,也不會影響後續程式碼。
需要注意的是,記憶體配置有可能失敗,因此必須考慮失敗時的處理方式。在本程式中,一旦配置失敗,就會先釋放先前已配置的記憶體,並以失敗狀態結束程式。在這個例子裡,我們使用 goto 來集中錯誤處理,以避免重複的清理程式碼。讀者可參考完整範例了解其實際運作方式。
接著,我們檢查堆疊頂端的資料:
/* Peek top element. */
if (st->top->data != 9) {
perror("It should be 9");
failed = true;
goto STACK_FREE;
}
這段程式碼帶有測試程式的精神:當結果不符合預期時,立即進行錯誤處理並結束程式。
中間略去了一些輔助程式碼。接下來看看如何從堆疊中取出節點:
/* Pop top element. */
do {
node_t *curr = st->top;
if (!curr)
break;
// Update st->top.
st->top = curr->next;
if (curr->data != 5) {
perror("It should be 5");
failed = true;
goto STACK_FREE;
}
free(curr);
} while (0);
這裡使用只會執行一次的 do ... while 區塊包住程式碼,因為當 st->top 為 NULL 時,可以透過 break 直接離開此區塊。
在取出節點後,需要更新 st->top 指向下一個節點。同時,由於該節點之後不再被任何指標引用,因此必須立即釋放其記憶體。
最後,我們展示釋放堆疊物件的程式碼:
STACK_FREE:
{
node_t *curr = st->top;
node_t *temp;
while (curr) {
temp = curr;
curr = curr->next;
free(temp);
}
free(st);
}
如同先前提到的,釋放記憶體通常要由內而外進行:先逐一釋放節點,再釋放外層的堆疊物件。
透過本節的範例,我們不僅能熟悉指標與結構的使用,也能以另一種方式練習資料結構與演算法。不過,隨著程式逐漸變長,可以看到重複程式碼開始出現,這正是將邏輯封裝成函式(或方法)的原因。本節的寫法較適合作為早期練習;在實際專案中,仍應透過函式與模組等方式來組織程式碼。