本檔收錄本單元的練習題、完整解題思路與參考解答。 強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」; 真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 exercises/ 資料夾,可直接編譯:

cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_double_via_ptr.c && /tmp/out

練習 1:透過指標把 double 加倍(難度:基礎)

題目

宣告一個 double 變數(初始值自訂,例如 19.75)與一個指向它的指標。 不直接使用變數名稱修改值,而是透過指標把值改為原本的兩倍,並印出修改前後的結果。

輸入:無(值寫死在程式中)。 輸出範例:

修改前:price = 19.75
修改後:price = 39.50

解題思路

這題的目的是把「指標的讀與寫」各走一遍。一步一步想:

  1. 要有一個目標變數:double price = 19.75;。它住在記憶體某處,有自己的位址。
  2. 要有一個指向它的指標:指標的型別必須和目標匹配——指向 double 就要宣告 double *p, 再用取址運算子把 price 的位址存進去:p = &price;(宣告時一次完成:double *p = &price;)。
  3. 透過指標「讀」:*p 出現在運算式右邊,就是「到 p 存的位址去把值讀出來」。
  4. 透過指標「寫」:*p 出現在等號左邊,就是「把結果寫到 p 存的位址」。 所以「加倍」一行就能完成:*p = *p * 2.0;——右邊先讀出 19.75、乘 2,再寫回同一個位置。

記憶體關係圖:

      p                       price
+------------+          +-----------+
| &price  ---+--------> |   19.75   |
+------------+          +-----------+
  存「位址」               存「實際的值」

*p = *p * 2.0;  之後:price 變成 39.50(透過 p 改到本尊)

最後用 price 本人印出結果,驗證「透過指標改的確實是同一塊記憶體」。

參考解答

/*
 * ex1_double_via_ptr.c
 * --------------------
 * 練習 1:透過指標把 double 變數加倍(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_double_via_ptr.c
 * 執行:/tmp/out
 */
#include <stdio.h>

int main(void)
{
    double price = 19.75;      /* 目標變數 */
    double *p = &price;        /* p 指向 price(存的是 price 的位址) */

    printf("修改前:price = %.2f\n", price);

    /* 透過指標讀出舊值、乘 2 後再寫回去。
     * *p 出現在右邊是「讀」,出現在左邊是「寫」。 */
    *p = *p * 2.0;

    printf("修改後:price = %.2f\n", price);
    printf("透過 *p 讀到:%.2f\n", *p);

    return 0;
}

程式碼講解

  • double *p = &price;:宣告與初始化一次完成。型別必須匹配—— 用 int * 指向 double 是錯的,編譯器會警告。
  • *p = *p * 2.0;:同一個 *p,右邊是讀、左邊是寫。也可以寫成 *p *= 2.0;。
  • 最後同時印 price 與 *p:兩者必然相同,因為它們是同一塊記憶體的兩種稱呼。
  • 易錯處:宣告 double *p; 之後忘了讓它指向任何東西就使用 *p,是未定義行為; 本題在宣告當下就初始化,養成好習慣。

範例輸出

修改前:price = 19.75
修改後:price = 39.50
透過 *p 讀到:39.50

練習 2:add_ten —— 讓函式改到呼叫端的變數(難度:基礎)

題目

寫一個函式 void add_ten(int *p),把指向的整數加 10。 在 main 中宣告 int score = 75;,呼叫 add_ten(&score) 兩次, 每次呼叫後印出 score 驗證確實被修改。 另外寫一個傳值的錯誤版本 void add_ten_broken(int x) 作對照,證明它改不到呼叫端。

解題思路

  1. 先理解問題的本質:C 的函式呼叫是傳值(call by value)——函式收到的是引數的複本。 add_ten_broken(score) 裡的 x 是 score 的影分身,改它對本尊毫無影響。
  2. 怎麼讓函式改到本尊? 把「本尊的位址」傳進去。位址雖然也是用「複製」的方式傳遞, 但複製出來的位址仍然指向同一個地方——照著地址找到的房子是同一間。
  3. 函式簽名:要收位址就宣告指標參數 void add_ten(int *p); 函式內用 *p += 10; 對「p 指向的位置」加 10。
  4. 呼叫端:記得取址——add_ten(&score);。忘記 & 的話編譯器會警告 (int 被當成指標),這正是開 -Wall -Wextra 的價值。
呼叫端:  score = 75
            ^
            | &score(位址被複製給參數 p)
add_ten:    p ----------+
                        v
               *p += 10  改到的就是 score 本人

參考解答

/*
 * ex2_add_ten.c
 * -------------
 * 練習 2:寫函式 add_ten(int *p),把指向的整數加 10(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_add_ten.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/*
 * 接收「int 的位址」,把該位址上的值加 10。
 * 因為拿到的是位址,改動會直接反映到呼叫端的變數。
 */
void add_ten(int *p)
{
    *p += 10;    /* 等同 *p = *p + 10; */
}

/* 對照組:傳值版本,改的只是複本,呼叫端不會變 */
void add_ten_broken(int x)
{
    x += 10;     /* x 是複本,函式結束就丟棄 */
    printf("  (函式內的複本 x = %d,但呼叫端不受影響)\n", x);
}

int main(void)
{
    int score = 75;

    printf("初始值           :score = %d\n", score);

    add_ten_broken(score);
    printf("傳值版呼叫後     :score = %d(沒變!)\n", score);

    add_ten(&score);
    printf("傳指標版呼叫後   :score = %d\n", score);

    add_ten(&score);
    printf("再呼叫一次       :score = %d\n", score);

    return 0;
}

程式碼講解

  • add_ten_broken 內的 x 在函式返回時就被丟棄,score 保持 75——這是「傳值」的鐵證。
  • add_ten(&score):&score 產生位址,函式的 p 收到這個位址的複本; *p += 10 寫入的位置就是 score 的家。
  • 兩次呼叫後 score 從 75 → 85 → 95,證明每次都改到本尊。
  • 易錯處:呼叫寫成 add_ten(score)(忘記 &)。開著 -Wall 時編譯器會報 「integer to pointer conversion」之類的警告,看到這種訊息幾乎都是忘了取址。

範例輸出

初始值           :score = 75
  (函式內的複本 x = 85,但呼叫端不受影響)
傳值版呼叫後     :score = 75(沒變!)
傳指標版呼叫後   :score = 85
再呼叫一次       :score = 95

練習 3:min_max —— 用指標參數回傳多個結果(難度:基礎)

題目

寫一個函式:

void min_max(const int a[], int n, int *min, int *max);

走訪陣列 a(長度 n,假設 n >= 1),把最小值與最大值分別寫入 *min 與 *max。 在 main 中用陣列 { 42, -7, 100, 3, 56, -21, 88 } 測試, 並額外測「只有一個元素」的邊界情況。

解題思路

  1. 為什麼要用指標參數? return 一次只能回傳一個值,而這題要同時回傳兩個結果。 C 的慣用解法:呼叫端準備好變數,把它們的位址傳進來,函式負責「填空」。
  2. 演算法:經典的一次走訪擂台法—— - 把 a[0] 同時當作目前的最小值與最大值(所以題目要求 n >= 1); - 從 a[1] 開始逐一挑戰:比目前最小的還小就更新 *min,比目前最大的還大就更新 *max。
  3. 為什麼從 a[0] 起跳而不是 0 或某個魔術數字? 若初始化成 *min = 0,遇到全正數陣列(如 {3, 5, 9})會錯誤地回答 0。 用「陣列自己的第一個元素」當起點永遠正確。
  4. const int a[] 的意義:這個函式只讀不寫陣列,加上 const 讓編譯器幫你把關, 也向讀者宣告「我不會動你的資料」。
  5. 邊界情況:n == 1 時迴圈一次都不執行,*min == *max == a[0],天然正確。

參考解答

/*
 * ex3_min_max.c
 * -------------
 * 練習 3:min_max —— 用指標參數一次回傳最小值與最大值(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_min_max.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/*
 * 走訪陣列一次,同時找出最小值與最大值。
 * C 的 return 一次只能回傳一個值;
 * 這裡讓呼叫端提供兩個變數的位址,函式把結果「填」進去。
 */
void min_max(const int a[], int n, int *min, int *max)
{
    *min = a[0];                 /* 先假設第一個元素同時是最小與最大 */
    *max = a[0];

    for (int i = 1; i < n; i++) {
        if (a[i] < *min)
            *min = a[i];
        if (a[i] > *max)
            *max = a[i];
    }
}

int main(void)
{
    int data[] = { 42, -7, 100, 3, 56, -21, 88 };
    int n = (int)(sizeof data / sizeof data[0]);
    int lo, hi;                  /* 準備兩個變數讓函式填寫 */

    min_max(data, n, &lo, &hi);  /* 傳入位址 */

    printf("陣列內容:");
    for (int i = 0; i < n; i++)
        printf("%d ", data[i]);
    printf("\n最小值 = %d\n最大值 = %d\n", lo, hi);

    /* 邊界情況:只有一個元素 */
    int single[] = { 5 };
    min_max(single, 1, &lo, &hi);
    printf("單一元素陣列:min = %d, max = %d\n", lo, hi);

    return 0;
}

程式碼講解

  • *min = a[0]; *max = a[0];:透過指標「初始化呼叫端的變數」。注意呼叫端的 lo、hi 完全不必先給值——它們的值由函式負責寫入。
  • 迴圈從 i = 1 開始:a[0] 已經當過初始值,再比一次只是浪費(不影響正確性)。
  • sizeof data / sizeof data[0] 是求「陣列元素個數」的慣用法; 它只在 data 的宣告作用域內有效(進了函式陣列會退化成指標,這招就失靈了)。
  • 易錯處一:把 *min 寫成 min——那是在改「指標本身」而不是指向的值,編譯器會警告型別不符。
  • 易錯處二:初始值用 INT_MAX/INT_MIN 也可以,但要多 include <limits.h>; 用 a[0] 更簡潔且不依賴極值。

範例輸出

陣列內容:42 -7 100 3 56 -21 88 
最小值 = -21
最大值 = 100
單一元素陣列:min = 5, max = 5

練習 4:safe_deref —— NULL 安全的解參考(難度:基礎)

題目

寫一個函式 int safe_deref(const int *p, int fallback):

  • 若 p 為 NULL,回傳 fallback(預設值);
  • 否則回傳 *p。

在 main 中分別用「有效指標」與「NULL 指標」測試。

解題思路

  1. 問題背景:解參考 NULL 指標是未定義行為,macOS 上會直接 segmentation fault。 實務上很多函式會回傳「指標或 NULL」(例如 strchr 找不到就回 NULL), 使用這些回傳值之前必須檢查。這題就是把「檢查再用」包成一個小工具。
  2. 邏輯只有兩步: - if (p == NULL) return fallback; —— 沒有目標,回預設值; - return *p; —— 有目標,安全地解參考。 注意順序不能反:一定要先檢查、後解參考。
  3. 參數設計:const int *p 表示函式只讀取不修改; fallback 讓呼叫端自訂「查無資料」時的預設值(-1、0、或任何業務上合理的值)。
p == NULL ?
   |是            |否
   v              v
回傳 fallback   回傳 *p(此時解參考必定安全)

參考解答

/*
 * ex4_safe_deref.c
 * ----------------
 * 練習 4:safe_deref —— NULL 安全的解參考(難度:基礎)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_safe_deref.c
 * 執行:/tmp/out
 */
#include <stdio.h>
#include <stddef.h>    /* NULL */

/*
 * 若 p 為 NULL 回傳 fallback(預設值),否則回傳 *p。
 * 「先檢查再解參考」是使用指標的基本紀律:
 * 解參考 NULL 是未定義行為,多數平台會直接 crash。
 */
int safe_deref(const int *p, int fallback)
{
    if (p == NULL)
        return fallback;
    return *p;
}

int main(void)
{
    int value = 42;
    int *good = &value;    /* 有效指標 */
    int *bad  = NULL;      /* 空指標:沒有目標 */

    printf("good 指向 42   -> safe_deref = %d\n", safe_deref(good, -1));
    printf("bad  是 NULL   -> safe_deref = %d(回傳 fallback)\n",
           safe_deref(bad, -1));

    /* fallback 可以是任何「查無資料」時想用的預設值 */
    printf("bad, fallback=0 -> safe_deref = %d\n", safe_deref(bad, 0));

    /* 也能直接對位址運算式使用 */
    printf("&value 直接傳入 -> safe_deref = %d\n", safe_deref(&value, -1));

    return 0;
}

程式碼講解

  • 檢查與解參考的順序是本題核心:return (p == NULL) ? fallback : *p; 也是正確寫法——條件運算子保證只有其中一邊會被求值。
  • int *good = &value; 傳給 const int *p 是合法的:「可寫的指標」可以隱式轉成「唯讀的指標」 (反方向則不行)。
  • 易錯處:先寫 int v = *p; 再檢查 NULL——已經來不及了,解參考在檢查之前就發生。
  • 這個模式在真實程式中隨處可見:查表函式、環境變數讀取(getenv 可能回 NULL)等, 都適合包一層 fallback。

範例輸出

good 指向 42   -> safe_deref = 42
bad  是 NULL   -> safe_deref = -1(回傳 fallback)
bad, fallback=0 -> safe_deref = 0
&value 直接傳入 -> safe_deref = 42

練習 5:sort3 —— 用 swap 排序三個變數(難度:進階)

題目

寫一個函式 void sort3(int *a, int *b, int *c), 利用 swap 讓三個變數由小到大排列(呼叫後 *a <= *b <= *c)。 提示:最多三次「比較+交換」就夠。 用至少五組資料測試:一般情況、完全反序、已排序、全部相等、含重複值。

解題思路

  1. 先解決「怎麼交換兩個變數」:直接沿用 K&R 的 swap(int *px, int *py)。 sort3 收到的參數本來就是指標,所以呼叫時直接轉傳:swap(a, b);(不需要再取址!)。
  2. 再想「三個數怎麼排序」。把它想成「氣泡排序跑一輪半」: - 第 1 步 if (*a > *b) swap(a, b);:確保前兩個有序。 - 第 2 步 if (*b > *c) swap(b, c);:把三者中的最大值送到 *c。 為什麼?經過第 1 步,*b 是前兩者的較大值;再和 *c 比,勝者是全場最大。 - 第 3 步 if (*a > *b) swap(a, b);:第 2 步可能把一個較小的值換進 *b (例如 3 5 1:第 2 步後變 3 1 5),所以前兩個要再確認一次。
  3. 手動驗證最壞情況 9 5 1: - 第 1 步:9>5 交換 → 5 9 1 - 第 2 步:9>1 交換 → 5 1 9 - 第 3 步:5>1 交換 → 1 5 9 ✓
  4. 邊界情況:全部相等時三次比較都不成立,一次都不交換——正確且不多做事。 比較用 > 而不是 >=,相等時不交換(穩定、少做事)。

參考解答

/*
 * ex5_sort3.c
 * -----------
 * 練習 5:sort3 —— 用 swap 讓三個變數由小到大排列(難度:進階)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex5_sort3.c
 * 執行:/tmp/out
 */
#include <stdio.h>

/* K&R §5.2 的經典 swap:透過指標交換呼叫端的兩個變數 */
void swap(int *px, int *py)
{
    int tmp = *px;
    *px = *py;
    *py = tmp;
}

/*
 * 讓 *a <= *b <= *c。
 * 策略(最多三次比較交換,像三顆氣泡往上冒):
 *   1. 先確保 *a <= *b
 *   2. 再確保 *b <= *c(此時最大值已就定位在 *c)
 *   3. 第 2 步可能把較小的值換進 *b,所以要再檢查一次 *a <= *b
 */
void sort3(int *a, int *b, int *c)
{
    if (*a > *b) swap(a, b);
    if (*b > *c) swap(b, c);
    if (*a > *b) swap(a, b);
}

/* 小工具:測試一組資料並印出前後對照 */
static void test(int x, int y, int z)
{
    printf("排序前:%2d %2d %2d  ->  ", x, y, z);
    sort3(&x, &y, &z);
    printf("排序後:%2d %2d %2d\n", x, y, z);
}

int main(void)
{
    test(3, 1, 2);     /* 一般情況 */
    test(9, 5, 1);     /* 完全反序 */
    test(1, 2, 3);     /* 已排序 */
    test(7, 7, 7);     /* 全部相等 */
    test(-4, 10, -4);  /* 含重複與負數 */

    return 0;
}

程式碼講解

  • sort3 內呼叫 swap(a, b) 不加 &:a、b 本身已經是指標了, 再取址會得到 int **,型別不符。這是本題最常見的錯誤。
  • 三次比較的順序有講究:1→2→3 缺一不可。若省略第 3 步,3 1 2 這種 「最小值躲在中間」的情況會排錯。
  • test 函式用傳值接收三個 int,再把區域變數的位址傳給 sort3—— 這樣每組測試資料互不干擾,也順便複習「函式參數也是有位址的普通變數」。
  • static void test(...):static 表示這個輔助函式只在本檔案使用(詳見第 09 單元)。

範例輸出

排序前: 3  1  2  ->  排序後: 1  2  3
排序前: 9  5  1  ->  排序後: 1  5  9
排序前: 1  2  3  ->  排序後: 1  2  3
排序前: 7  7  7  ->  排序後: 7  7  7
排序前:-4 10 -4  ->  排序後:-4 -4 10

練習 6:指標的指標 —— x、p、pp 三層關係(難度:挑戰)

題目

給定:

int x = 7;
int *p = &x;
int **pp = &p;
  1. 畫出 x、p、pp 三者的記憶體關係圖;
  2. 回答 *pp、**pp 分別是什麼;
  3. 寫程式印出三個變數的位址與內容驗證你的圖;
  4. 透過 **pp = 99; 修改 x,再透過 *pp = &y; 讓 p 改指向另一個變數 y。

解題思路

  1. 先確定每個變數的型別: - x 是 int; - p 是 int *(指向 int); - pp 是 int **(指向「int 指標」)。pp = &p 合法,因為 &p 的型別正是 int **。
  2. 畫圖。三個變數各自佔有記憶體、各有位址;箭頭代表「內容是誰的位址」:
     pp                  p                  x
+----------+       +----------+       +---------+
|   &p   --+-----> |   &x   --+-----> |    7    |
+----------+       +----------+       +---------+
 型別 int**          型別 int*           型別 int
  1. 逐層解參考:每加一個 * 就「沿著箭頭走一步」: - pp → p 的位址 - *pp → p 的內容,也就是 &x(x 的位址) - **pp → 沿著兩層箭頭走到底,得到 x 的值 7
  2. 兩種不同層次的「修改」,這是本題精髓: - **pp = 99;:走到最底層,改的是 x 的值; - *pp = &y;:只走一層,改的是 p 的內容(讓 p 改指向 y),x 不受影響。
  3. int ** 在真實世界哪裡用? main 的 argv(字串指標的指標)、 想讓函式修改「呼叫端的指標」時(例如讓函式配置記憶體後回填指標),都會遇到。

參考解答

/*
 * ex6_ptr_to_ptr.c
 * ----------------
 * 練習 6:指標的指標 —— 觀察 x、p、pp 的三層關係(難度:挑戰)
 *
 * 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_ptr_to_ptr.c
 * 執行:/tmp/out
 * 注意:位址每次執行都不同(ASLR),但三者的「指向關係」不變。
 */
#include <stdio.h>

int main(void)
{
    int x = 7;
    int *p = &x;      /* p 指向 x */
    int **pp = &p;    /* pp 指向 p(pp 是「指向 int 指標 的指標」) */

    /* 第一層:每個變數自己的位址與內容 */
    printf("x  的位址 = %p,內容 = %d\n",   (void *)&x, x);
    printf("p  的位址 = %p,內容 = %p\n",   (void *)&p, (void *)p);
    printf("pp 的位址 = %p,內容 = %p\n\n", (void *)&pp, (void *)pp);

    /* 驗證指向關係 */
    printf("p  == &x ? %s\n", (p == &x) ? "是" : "否");
    printf("pp == &p ? %s\n\n", (pp == &p) ? "是" : "否");

    /* 逐層解參考:
     *   pp   -> p 的位址
     *   *pp  -> p 的內容(也就是 x 的位址)
     *   **pp -> x 的內容 */
    printf("*pp  = %p(= p 的內容 = x 的位址)\n", (void *)*pp);
    printf("**pp = %d(= x 的值)\n\n", **pp);

    /* 透過兩層指標修改 x */
    **pp = 99;
    printf("執行 **pp = 99 之後,x = %d\n", x);

    /* 透過 pp 讓 p 改指向別的變數 */
    int y = -5;
    *pp = &y;                       /* 等同 p = &y; */
    printf("執行 *pp = &y 之後,*p = %d(p 改指向 y 了)\n", *p);

    return 0;
}

程式碼講解

  • 印指標值用 %p 並轉型成 (void *):這是 printf 對 %p 的規格要求, 不轉型在嚴格警告下會被抓。
  • 從輸出可驗證:p 的內容 等於 x 的位址;pp 的內容 等於 p 的位址—— 和關係圖完全一致。
  • **pp = 99 與 *pp = &y 的差別是「改哪一層」: 前者改最底層的 int,後者改中間層的指標。搞混這兩層是使用 int ** 時最典型的 bug。
  • 執行多次會看到位址不同(作業系統的位址空間隨機化), 但三者之間的指向關係每次都成立——這也是為什麼程式邏輯只能依賴關係、不能依賴具體位址。

範例輸出

x  的位址 = 0x16d205628,內容 = 7
p  的位址 = 0x16d205620,內容 = 0x16d205628
pp 的位址 = 0x16d205618,內容 = 0x16d205620

p  == &x ? 是
pp == &p ? 是

*pp  = 0x16d205628(= p 的內容 = x 的位址)
**pp = 7(= x 的值)

執行 **pp = 99 之後,x = 99
執行 *pp = &y 之後,*p = -5(p 改指向 y 了)

(位址每次執行都會不同,此為某次實際執行的結果。)


常見錯誤解法對照

錯誤寫法 症狀 正確做法
add_ten(score)(忘記 &) 編譯警告:int 轉指標;執行時未定義行為 add_ten(&score)
sort3 內寫 swap(&a, &b) 編譯錯誤:int ** 與 int * 不符 參數已是指標,直接 swap(a, b)
min_max 內把 *min = ... 寫成 min = ... 只改了區域指標,呼叫端拿不到結果 解參考後賦值:*min = a[i]
先 *p 再檢查 NULL 檢查形同虛設,NULL 時已經 crash 先 if (p == NULL) 再解參考
int *p; 未初始化就 *p = 42; 未定義行為:寫入垃圾位址 宣告時就指向目標或設 NULL