位元詩人 [C 語言] 程式設計教學:指標 (Pointer) 和記憶體管理 (Memory Management)

C 語言指標
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

指標 (pointer) 是 C 語言的衍生型別之一。指標的值並非資料本身,而是另一塊記憶體的虛擬位址 (address)。我們可利用指標間接存該指標所指向的記憶體的值。在 C 語言中,有些和記憶體相關的操作必需使用指標,實作動態資料結構時也會用到指標,所以我們學 C 時無法避開指標。

許多 C 語言教材將指標放在整本書的後半段,集中在一章到兩章來講。但我們很早就介紹指標,並在日後介紹其他 C 語言特性時,順便提到指標相關的內容。這樣的編排,是希望讀者能儘早習慣指標的使用方式。

記憶體階層 (Memory Layout)

使用指標,不必然要手動管理記憶體。記憶體管理的方式,得看資料在 C 程式中的記憶體階層而定。C 語言有三種記憶體階層:

  • 靜態記憶體配置 (static memory allocation)
  • 自動記憶體配置 (automatic memory allocation)
  • 動態記憶體配置 (dynamic memory allocation)

靜態記憶體儲存程式本身和全域變數,會自動配置和釋放,但容量有限。

自動記憶體儲存函式內的局部變數,會自動配置和釋放,在函式結束時自動釋放,容量有限。

動態記憶體儲存函式內的變數,需手動配置和釋放,可跨越函式的生命週期,可於執行期動態決定記憶體容量,可用容量約略等於系統的記憶體量。

雖然靜態記憶體和自動記憶體可自動配置,但各自受到一些限制,故仍要會用動態記憶體。

靜態記憶體配置 (Static Memory Allocation)

我們來看一個使用靜態記憶體配置的簡短範例:

#include <assert.h>                 /*  1 */

/* DON'T DO THIS IN PRODUCTION. */  /*  2 */
int i = 3;                          /*  3 */

int main(void)                      /*  4 */
{                                   /*  5 */
    int *i_p = &i;                  /*  6 */
    assert(*i_p == 3);              /*  7 */

    *i_p = 4;                       /*  8 */
    assert(i == 4);                 /*  9 */

    return 0;                       /* 10 */
}                                   /* 11 */

在本範例的第 3 行,我們宣告變數 i3 時,C 程式自動為我們配置記憶體。由於該變數屬於全域變數,會自動配置記憶體,不需人為介入。

在範例程式的第 6 行中,我們宣告了指向 int 的指標 i_p

int * 表示指向 int 型別的指標。C 語言需要知道指標所指向的型別,才能預知記憶體的大小。

&i 代表回傳變數 i 的虛擬記憶體位址。因為指標的值是記憶體位址,在本例中,我們的指標 i_p 指向一塊已經配置好的記憶體,即為變數 i 所在的位址。

接著,我們在範例的第 7 行確認確認指標 i_p 所指向的值的確是 3。在這行敘述中,*i_p 代表解址,解址後會取出該位址的值。在本例中即取回 3

接著,我們在第 8 行藉由修改指標 i_p 所指向的記憶體間接修改變數 i 的值。

最後,我們在第 9 行藉由 assert(i == 4); 敘述確認指標 i_p 確實間接修改到變數 i。代表兩者的確指向同一塊記憶體。

如果讀者用 Valgrind 或其他記憶體檢查軟體去檢查此程式,可發現此範例程式沒有配置動態記憶體,也沒有記憶體洩露的問題。代表使用指標不必然要手動管理記憶體。

附帶一提,在本範例中,我們使用了全域變數。這在撰碼上是不良的習慣,因全域變數很容易在不經意的情形下誤改。我們的程式只是為了展示靜態記憶體的特性,不建議在實務上使用全域變數。

自動記憶體配置 (Automatic Memory Allocation)

接著,我們來看一個使用自動記憶體配置的實例:

#include <assert.h>     /*  1 */

int main(void)          /*  2 */
{                       /*  3 */
    int i = 3;          /*  4 */
    int *i_p = &i;      /*  5 */

    assert(*i_p == 3);  /*  6 */

    *i_p = 4;           /*  7 */
    assert(i == 4);     /*  8 */

    return 0;           /*  9 */
}                       /* 10 */

在本例的第 4 行中,當我們宣告變數 i 的值為 3 時,同樣會自動配置記憶體。由於變數 i 存在於主函式中,故使用自動記憶體配置。

在本例的第 5 行中,我們宣告了指向變數 i 的指標 i_p。這時候 i 的記憶體已經配置好了。

這個範例除了記憶體配置的方式外,其他的指令和前一節的範例雷同,故不再詳細說明,請讀者自行閱讀。

如果讀者用 Valgrind 或其他記憶體檢查軟體檢查此範例程式,同樣可發現此程式沒有配置動態記憶體,也沒有記憶體洩露的問題。

動態記憶體配置 (Dynamic Memory Allocation)

我們來看一個動態記憶體配置的實例:

#include <assert.h>                                      /*  1 */
#include <stdio.h>                                       /*  2 */
#include <stdlib.h>                                      /*  3 */

int main(void)                                           /*  4 */
{                                                        /*  5 */
    int *i_p = (int *) malloc(sizeof(int));              /*  6 */
    if (!i_p) {                                          /*  7 */
        fprintf(stderr, "Failed to allocate memory\n");  /*  8 */
        goto ERROR;                                      /*  9 */
    }                                                    /* 10 */

    *i_p = 3;                                            /* 11 */
    if (!(*i_p == 3)) {                                  /* 12 */
        fprintf(stderr, "Wrong value: %d\n", *i_p);      /* 13 */
        goto ERROR;                                      /* 14 */
    }                                                    /* 15 */

    free(i_p);                                           /* 16 */

    return 0;                                            /* 17 */

ERROR:                                                   /* 18 */
    if (i_p)                                             /* 19 */
        free(i_p);                                       /* 20 */

    return 1;                                            /* 21 */
}                                                        /* 22 */

在第 6 行中,我們配置一塊大小為 int 的記憶體。

C 標準函式庫中配置記憶體的函式為 malloc(),該函式接收的參數為記憶體的大小。我們甚少手動寫死記憶體的大小,而會使用 sizeof 直接取得特定資料型別的大小。這幾乎是固定的手法了。

malloc() 回傳的型別是 void *,即為大小未定的指標。我們會自行手動轉型為指向特定型別的指標。有些 C 編譯器會自動幫我們轉型,就不用自行轉型。

由於配置記憶體是有可能失敗的動作,我們在第 7 行至第 10 行檢查是否成功地配置記憶體。

malloc() 失敗時,會回傳 NULL。而 NULL 在布林語境中會視為偽,故 !i_pi_p 的值為 NULL 時會變為真。

!i_p 為真,代表 malloc() 未成功配置記憶體,這時候我們會中止一般的流程,改走錯誤處理流程。我們先在標準錯誤印出錯誤訊息,然後用 goto 跳到標籤 ERROR 所在的地方。由於 C 沒有內建的錯誤處理機制,使用 goto 跳離一般程式流程算是窮人版的例外 (exception)。

fprintf() 敘述用到了標準輸出入和巨集的概念,稍微超出現在的範圍。先知道該敘述會在標準錯誤印出訊息所在的檔案名稱和行數即可。

如果 malloc() 成功地配置記憶體,我們就繼續一般的程式流程。我們在第 11 行將 i_p 指向的記憶體賦值為 3,然後在第 12 行至第 15 行檢查是否正確地賦值。一般來說,這行敘述是不需檢查的。這裡僅是展示這項特性。

由於 i_p 所指向的記憶體是手動配置的,我們在第 16 行釋放 i_p 所占用的記憶體。

基本上,malloc()free() 應成對出現。每配置一次記憶體就要在確定不使用時釋放回去。由於這個範例相當短,這似乎顯而易見。但是在撰寫動態資料結構時,會跨越多個函式,比這個範例複雜得多,就有可能會忘了釋放記憶體。

當程式發成錯誤時,我們會改走錯誤處理的流程。在本例中,錯誤流程在第 18 行至第 21 行。在進行錯誤處理時,我們同樣會釋放記憶體,但程式最後會回傳非零值 1,代表程式異常結束。

由於在錯誤發生時,我們無法確認 i_p 是否已配置記憶體,所以要用 if 敘述來確認。請讀者再回頭把整個程式運行的流程看一次,即可了解。

空指標 (Null Pointer)

當 C 程式試圖去存取系統資源時,該指令有可能失敗,故要撰寫錯誤處理相關的程式碼。以下範例程式試圖打開一個文字檔案 file.txt

#include <stdio.h>                                     /*  1 */

int main(void)                                         /*  2 */
{                                                      /*  3 */
    FILE *fp;                                          /*  4 */

    fp = fopen("file.txt", "r");                       /*  5 */
    if (!fp) {                                         /*  6 */
        fprintf(stderr, "Failed to open the file\n");  /*  7 */
        return 1;                                      /*  8 */
    }                                                  /*  9 */

    fclose(fp);                                        /* 10 */

    return 0;                                          /* 11 */
}                                                      /* 12 */

在第 5 行中,我們試圖用 fopen() 函式以讀取模式開啟 file.txt 。當檔案無法開啟時,會回傳空指標 NULL。所以我們在第 6 行至第 9 行檢查指標 fp 是否為空。

fp 為空指標時,!fp 會負負得正,這時候程式會中止一般流程,改走錯誤處理流程。在這個範例中,我們在終端機印出錯誤訊息,並回傳非零值 1 代表程式異常結束。

以下是另一種檢查空指標的寫法:

fp = fopen("file.txt", "r");
if (fp == NULL) {
    fprintf(stderr, "Failed to open the file\n");
    return 1;
}

基本上,兩者皆可使用,這僅是風格上的差異。

比較指標是否相等

當我們使用指標時,會處理兩個值,一個是指標所指向的位址,另一個是指標所指向的值。我們用以下範例來看兩者的差別:

#include <stdio.h>                                          /*  1 */

int main(void)                                              /*  2 */
{                                                           /*  3 */
    int a = 3;                                              /*  4 */
    int b = 3;                                              /*  5 */

    int *p = &a;                                            /*  6 */
    int *q = &a;                                            /*  7 */
    int *r = &b;                                            /*  8 */

    if (!(p == q)) {                                        /*  9 */
        fprintf(stderr, "Wrong addresses: %p %p\n", p, q);  /* 10 */
        goto ERROR;                                         /* 11 */
    }                                                       /* 12 */

    if (p == r) {                                           /* 13 */
        fprintf(stderr, "Wrong addresses: %p %p\n", p, r);  /* 14 */
        goto ERROR;                                         /* 15 */
    }                                                       /* 16 */

    if (!(*p == *q)) {                                      /* 17 */
        fprintf(stderr, "Wrong values: %d %d\n", *p, *q);   /* 18 */
        goto ERROR;                                         /* 19 */
    }                                                       /* 20 */

    if (!(*p == *r)) {                                      /* 21 */
        fprintf(stderr, "Wrong values: %d %d\n", *p, *r);   /* 22 */
        goto ERROR;                                         /* 23 */
    }                                                       /* 24 */

    return 0;                                               /* 25 */

ERROR:                                                      /* 26 */
    return 1;                                               /* 27 */
}                                                           /* 28 */

一開始,我們分別在第 4 行及第 5 行配置兩塊自動記憶體。雖然變數 a 和變數 b 的值是相同的,但兩者存在於不同的記憶體區塊。

接著,我們第 6 行至第 8 行宣告三個指標,分別指向這兩個變數。

我們可以預期 pq 同時會指向變數 a 所在的記憶體,而 r 則指向變數 b 所在的記憶體。但 pqr 所指向的值皆相等。從範例程式中即可確認這樣的狀態,讀者可自行閱讀一下。

野指標 (Wild Pointer)

若我們宣告了指標但未對指標賦值,這時候指標的位址是未定義的,由各家 C 編譯器自行決定其行為。宣告但未賦值的指標稱為野指標,這時候指標的值視為無意義的垃圾值,不應依賴其結果。

我們來看一個野指標的範例程式:

#include <stdio.h>                             /*  1 */
#include <stdlib.h>                            /*  2 */

#define PUTS(format, ...) { \
    fprintf(stdout, "(%s:%d) " format "\n", \
        __FILE__, __LINE__, ##__VA_ARGS__); \
}

#define PUTERR(format, ...) { \
    fprintf(stderr, "(%s:%d) " format "\n", \
        __FILE__, __LINE__, ##__VA_ARGS__); \
}

int main(void)                                 /*  3 */
{                                              /*  4 */
    int *i_p;  /* Wild pointer. */             /*  5 */

    if (i_p == NULL) {                         /*  6 */
        PUTS("i_p is NULL");                   /*  7 */
    }                                          /*  8 */

    if (!i_p) {                                /*  9 */
        PUTS("i_p is EMPTY");                  /* 10 */
    }                                          /* 11 */

    i_p = NULL;  /* NULL pointer. */           /* 12 */

    if (i_p == NULL) {                         /* 13 */
        PUTS("i_p is NULL");                   /* 14 */
    }                                          /* 15 */

    if (!i_p) {                                /* 16 */
        PUTS("i_p is EMPTY");                  /* 17 */
    }                                          /* 18 */

    i_p = (int *) malloc(sizeof(int));         /* 19 */
    if (!i_p) {                                /* 20 */
        PUTERR("Failed to allocate memory");   /* 21 */
        goto ERROR;                            /* 22 */
    }                                          /* 23 */

    if (i_p == NULL) {                         /* 24 */
        PUTS("i_p is NULL");                   /* 25 */
    }                                          /* 26 */

    if (!i_p) {                                /* 27 */
        PUTS("i_p is EMPTY");                  /* 28 */
    }                                          /* 29 */

    free(i_p);                                 /* 30 */

    return 0;                                  /* 31 */

ERROR:                                         /* 32 */
    if (i_p)                                   /* 33 */
        free(i_p);                             /* 34 */

    return 1;                                  /* 35 */
}                                              /* 36 */

在開頭的地方,我們宣告了兩個巨集 PUTSPUTERR。若讀者跟著我們這個系列的文章讀下來,到目前為止我們還沒講過巨集。我們這裡使用巨集是為了節省版面。請讀者暫時把巨集當成另類函式即可,後文會再說明。

在第 5 行時,指標 i_p 尚未賦值,其值為垃圾值。使用不同 C 編譯器編譯此範例程式時,會得到不同的結果。

在第 12 行時,我們將 i_pNULL 賦值,這時候 i_p 就不再是野指標,轉為空指標。

在第 19 行時,我們手動配置了一塊記憶體,這時候 i_p 就是一般的指標。

由這個例子可知,我們在宣告指標時,若未馬上配置記憶體或其他系統資源時,應該立即以 NULL 賦值,讓該指標成為空指標。

迷途指標 (Dangling Pointer)

原本指向某塊記憶體的指標,當該記憶體中途消失時,該指標所指向的位址不再合法,這時候的指標就成為迷途指標。如同野指標,迷途指標所指向的值視為垃圾值,不應依賴其結果。

以下是一個迷途指標的範例程式:

#include <stdio.h>                                /*  1 */

int main(void)                                    /*  2 */
{                                                 /*  3 */
    int *i_p = NULL;                              /*  4 */

    {                                             /*  5 */
        int i = 3;                                /*  6 */

        i_p = &i;                                 /*  7 */
    }                                             /*  8 */

    /* i_p is a dangling pointer now. */          /*  9 */
    if (*i_p == 3) {                              /* 10 */
        fprintf(stderr, "It should not be 3\n");  /* 11 */
        return 1;                                 /* 12 */
    }                                             /* 13 */

    return 0;                                     /* 14 */
}                                                 /* 15 */

在第 7 行時,指標 i_p 指向 i。但在第 8 行時,該區塊結束,i 的記憶體會自動釋放掉,這時候 i_p 所指向的記憶體不再合法,i_p 變成迷途指標。之後的運算基本上是無意義且不可靠的。

我們再來看另一個迷途指標的例子:

#include <stdio.h>                             /*  1 */
#include <stdlib.h>                            /*  2 */

#define PUTERR(format, ...) { \
    fprintf(stderr, "(%s:%d) " format "\n", \
        __FILE__, __LINE__, ##__VA_ARGS__); \
}

int main(void)                                 /*  3 */
{                                              /*  4 */
    int *i_p = (int *) malloc(sizeof(int));    /*  5 */
    if (!i_p) {                                /*  6 */
        PUTERR("Failed to allocate int");      /*  7 */
        goto ERROR;                            /*  8 */
    }                                          /*  9 */

    free(i_p);                                 /* 10 */

    /* i_p is a dangling pointer now. */       /* 11 */
    *i_p = 3;                                  /* 12 */

    if (*i_p == 3) {                           /* 13 */
        PUTERR("It should not be 3");          /* 14 */
        goto ERROR;                            /* 15 */
    }                                          /* 16 */

    return 0;                                  /* 17 */

ERROR:                                         /* 18 */
    if (i_p)                                   /* 19 */
        free(i_p);                             /* 20 */

    return 1;                                  /* 21 */
}                                              /* 22 */

在第 5 行時,我們配置一塊記憶體到 i_p。在第 10 行時這塊記憶體釋放掉了,這時候 i_p 所指的位址不再合法,故 i_p 成為迷途指標。即使之後的運算能夠成功,那也只是一時僥倖而已。

結語

在本文中,我們介紹了數個指標的基本用法。我們把指標放在前半部,是為了要讓大家及早適應指標。在後續介紹各種 C 語言的特性時,我們會再加入指標相關的使用方式。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。