本檔收錄本單元的練習題、完整解題思路與參考解答。
強烈建議先自己動手做:看完題目就闔上這份檔案,寫出你的版本、編譯、執行、卡住了再回來看「解題思路」;
真的做完了,再對照「參考解答」與「程式碼講解」。每題的完整程式碼也放在 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
解題思路
這題的目的是把「指標的讀與寫」各走一遍。一步一步想:
- 要有一個目標變數:
double price = 19.75;。它住在記憶體某處,有自己的位址。 - 要有一個指向它的指標:指標的型別必須和目標匹配——指向
double就要宣告double *p, 再用取址運算子把price的位址存進去:p = &price;(宣告時一次完成:double *p = &price;)。 - 透過指標「讀」:
*p出現在運算式右邊,就是「到 p 存的位址去把值讀出來」。 - 透過指標「寫」:
*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) 作對照,證明它改不到呼叫端。
解題思路
- 先理解問題的本質:C 的函式呼叫是傳值(call by value)——函式收到的是引數的複本。
add_ten_broken(score)裡的x是score的影分身,改它對本尊毫無影響。 - 怎麼讓函式改到本尊? 把「本尊的位址」傳進去。位址雖然也是用「複製」的方式傳遞, 但複製出來的位址仍然指向同一個地方——照著地址找到的房子是同一間。
- 函式簽名:要收位址就宣告指標參數
void add_ten(int *p); 函式內用*p += 10;對「p 指向的位置」加 10。 - 呼叫端:記得取址——
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 } 測試,
並額外測「只有一個元素」的邊界情況。
解題思路
- 為什麼要用指標參數?
return一次只能回傳一個值,而這題要同時回傳兩個結果。 C 的慣用解法:呼叫端準備好變數,把它們的位址傳進來,函式負責「填空」。 - 演算法:經典的一次走訪擂台法——
- 把
a[0]同時當作目前的最小值與最大值(所以題目要求n >= 1); - 從a[1]開始逐一挑戰:比目前最小的還小就更新*min,比目前最大的還大就更新*max。 - 為什麼從
a[0]起跳而不是 0 或某個魔術數字? 若初始化成*min = 0,遇到全正數陣列(如{3, 5, 9})會錯誤地回答 0。 用「陣列自己的第一個元素」當起點永遠正確。 const int a[]的意義:這個函式只讀不寫陣列,加上const讓編譯器幫你把關, 也向讀者宣告「我不會動你的資料」。- 邊界情況:
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 指標」測試。
解題思路
- 問題背景:解參考 NULL 指標是未定義行為,macOS 上會直接 segmentation fault。
實務上很多函式會回傳「指標或 NULL」(例如
strchr找不到就回 NULL), 使用這些回傳值之前必須檢查。這題就是把「檢查再用」包成一個小工具。 - 邏輯只有兩步:
-
if (p == NULL) return fallback;—— 沒有目標,回預設值; -return *p;—— 有目標,安全地解參考。 注意順序不能反:一定要先檢查、後解參考。 - 參數設計:
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)。
提示:最多三次「比較+交換」就夠。
用至少五組資料測試:一般情況、完全反序、已排序、全部相等、含重複值。
解題思路
- 先解決「怎麼交換兩個變數」:直接沿用 K&R 的
swap(int *px, int *py)。sort3收到的參數本來就是指標,所以呼叫時直接轉傳:swap(a, b);(不需要再取址!)。 - 再想「三個數怎麼排序」。把它想成「氣泡排序跑一輪半」:
- 第 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),所以前兩個要再確認一次。 - 手動驗證最壞情況
9 5 1: - 第 1 步:9>5交換 →5 9 1- 第 2 步:9>1交換 →5 1 9- 第 3 步:5>1交換 →1 5 9✓ - 邊界情況:全部相等時三次比較都不成立,一次都不交換——正確且不多做事。
比較用
>而不是>=,相等時不交換(穩定、少做事)。
參考解答
/*
* 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;
- 畫出
x、p、pp三者的記憶體關係圖; - 回答
*pp、**pp分別是什麼; - 寫程式印出三個變數的位址與內容驗證你的圖;
- 透過
**pp = 99;修改x,再透過*pp = &y;讓p改指向另一個變數y。
解題思路
- 先確定每個變數的型別:
-
x是int; -p是int *(指向 int); -pp是int **(指向「int 指標」)。pp = &p合法,因為&p的型別正是int **。 - 畫圖。三個變數各自佔有記憶體、各有位址;箭頭代表「內容是誰的位址」:
pp p x
+----------+ +----------+ +---------+
| &p --+-----> | &x --+-----> | 7 |
+----------+ +----------+ +---------+
型別 int** 型別 int* 型別 int
- 逐層解參考:每加一個
*就「沿著箭頭走一步」: -pp→ p 的位址 -*pp→ p 的內容,也就是&x(x 的位址) -**pp→ 沿著兩層箭頭走到底,得到x的值7 - 兩種不同層次的「修改」,這是本題精髓:
-
**pp = 99;:走到最底層,改的是 x 的值; -*pp = &y;:只走一層,改的是 p 的內容(讓 p 改指向 y),x 不受影響。 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 |