位元詩人 [C 語言] 程式設計教學:如何使用結構 (Struct)

C 語言結構
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

說明

除了 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;

這並不是硬性規定,只是一種個人風格。筆者目前沒有採用這種方式,因為這樣的別名容易讓人忽略該型別其實是指標;若採用此做法,建議在名稱尾端加上 _pPtr 等能辨識為指標的型別名稱。

存取結構指標的屬性

存取結構指標中的成員通常有兩種方式:

  • 解指標 (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->topNULL 時,可以透過 break 直接離開此區塊。

在取出節點後,需要更新 st->top 指向下一個節點。同時,由於該節點之後不再被任何指標引用,因此必須立即釋放其記憶體。

最後,我們展示釋放堆疊物件的程式碼:

STACK_FREE:
{
    node_t *curr = st->top;
    node_t *temp;
    while (curr) {
        temp = curr;
        curr = curr->next;
        free(temp);
    }

    free(st);
}

如同先前提到的,釋放記憶體通常要由內而外進行:先逐一釋放節點,再釋放外層的堆疊物件。

透過本節的範例,我們不僅能熟悉指標與結構的使用,也能以另一種方式練習資料結構與演算法。不過,隨著程式逐漸變長,可以看到重複程式碼開始出現,這正是將邏輯封裝成函式(或方法)的原因。本節的寫法較適合作為早期練習;在實際專案中,仍應透過函式與模組等方式來組織程式碼。

關於作者

位元詩人 (ByteBard) 是資訊領域碩士,喜歡用開源技術來解決各式各樣的問題。這類技術跨平台、重用性高、技術生命長。

除了開源技術以外,位元詩人喜歡日本料理和黑咖啡,會一些日文,有時會自助旅行。