本檔提供本單元練習題的完整詳解。先自己動手做:每題先只看「題目」, 寫完、編譯、執行後再對照「解題思路」與「參考解答」。 完整程式碼都在 exercises/ 資料夾,可直接編譯:

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

練習 1:平方與立方表(難度:基礎)

題目

寫兩個函式 int square(int x) 與 int cube(int x), 在 main 中用迴圈印出 1~10 的平方與立方對照表(三欄對齊)。 要求:cube 內部重用 square,不要再寫一次 x * x。

解題思路

  1. 函式三件套:原型(檔頭宣告)→ 呼叫(main 裡用)→ 定義(檔尾實作)。 本題就是把這個標準格局完整走一遍。
  2. 設計函式介面:輸入一個 int、輸出一個 int—— int square(int x) 的簽名把「做什麼」說完了。
  3. 重用而非複製:cube(x) = x * square(x)。 雖然 x * x * x 也只有幾個字,但「立方 = x × 平方」表達了兩者的關係; 函式呼叫函式是組合邏輯的基本手段。
  4. main 的角色:只負責跑迴圈與排版;「算」的事全部委託出去。 這種分工讓表格範圍要改成 1~20 時,只動 main 一行。

參考解答

#include <stdio.h>

/* 原型:讓 main 在定義之前就能正確呼叫 */
int square(int x);
int cube(int x);

int main(void)
{
    printf("  x   x^2    x^3\n");
    printf("---  ----  -----\n");
    for (int x = 1; x <= 10; x++) {
        printf("%3d  %4d  %5d\n", x, square(x), cube(x));
    }
    return 0;
}

/* 平方:一件事、一句話說得清楚 */
int square(int x)
{
    return x * x;
}

/* 立方:重用 square,避免重複 x * x 的邏輯 */
int cube(int x)
{
    return x * square(x);
}

(完整檔案:exercises/ex1_square_cube.c)

程式碼講解

  • 原型結尾是分號、定義接大括號——初學者常把兩者搞混。
  • printf 的引數裡直接寫 square(x):函式呼叫是運算式,值算出來直接填進 %4d 的洞。
  • 若刪掉檔頭原型,clang 會報 call to undeclared function(C99 起隱式宣告已移除)—— 可以故意試一次,認識這個錯誤訊息。

範例輸出

  x   x^2    x^3
---  ----  -----
  1     1      1
  2     4      8
  3     9     27
  4    16     64
  5    25    125
  6    36    216
  7    49    343
  8    64    512
  9    81    729
 10   100   1000

練習 2:溫度轉換函式化(難度:基礎,K&R 習題 1-15)

題目

把單元 05 的溫度對照表改寫成「呼叫函式」的版本: 寫 double c_to_f(double c),main 用它印出攝氏 −20 到 100、間隔 20 的攝氏→華氏對照表。

解題思路

  1. 重構的動機:原版把公式直接寫在迴圈裡;公式與排版攪在一起, 哪天要改公式(或在別處也要換算)就得到處找。把「換算」抽成函式,公式只存在一處。
  2. 介面設計:攝氏進、華氏出,都是溫度值 → double c_to_f(double c)。 函式名用「輸入_to_輸出」的模式自我說明。
  3. 原型為什麼特別重要(K&R §4.2 的重點):這個函式回傳 double。 古早 C 裡沒看到宣告的函式一律假設回傳 int,拿到的值會是垃圾。 現代 C 直接把「沒有原型」當錯誤——檔頭那行 double c_to_f(double c); 不能省。
  4. 呼叫端的型別轉換:迴圈變數 c 是 int(步進 20 是整數), 傳給 double 參數時,原型讓編譯器自動做 int → double 轉換——這是原型的另一個功勞。

參考解答

#include <stdio.h>

/* 原型:回傳 double 的函式一定要讓呼叫端先看到宣告(K&R §4.2) */
double c_to_f(double c);

int main(void)
{
    printf("Celsius -> Fahrenheit\n");
    printf("---------------------\n");

    /* main 只負責「排表」,換算細節全部交給函式 */
    for (int c = -20; c <= 100; c += 20) {
        printf("%8d %10.1f\n", c, c_to_f(c));
    }

    return 0;
}

/* 攝氏轉華氏:公式只寫在這一個地方。
 * 之後公式要修(或要加華氏轉攝氏),呼叫端一行都不用動。 */
double c_to_f(double c)
{
    return c * 9.0 / 5.0 + 32.0;
}

(完整檔案:exercises/ex2_c_to_f_func.c)

程式碼講解

  • c_to_f(c):引數 c 是 int,經原型自動升為 double——呼叫端不需要手動 cast。
  • 驗算已知點:0°C → 32°F、100°C → 212°F,表中都正確。
  • 公式裡用 9.0 / 5.0:函式內部同樣要防整數除法陷阱(參數是 double, 但 9 / 5 這種「常數對常數」的子運算式不受參數型別影響)。

範例輸出

Celsius -> Fahrenheit
---------------------
     -20       -4.0
       0       32.0
      20       68.0
      40      104.0
      60      140.0
      80      176.0
     100      212.0

練習 3:判斷函式(難度:基礎)

題目

寫兩個判斷函式:int is_even(int n) 與 int is_leap_year(int y), 用寫死的測試資料驗證,輸出格式形如 is_leap_year(1900) = 0。 測資要求:is_even 至少覆蓋偶數、奇數、零、負數; is_leap_year 覆蓋 1900、2000、2024、2025 四種規則路徑。

解題思路

  1. 判斷函式(predicate)的慣例:回傳 int,1 = 是、0 = 否; 名字用 is_ / has_ 開頭,讓呼叫端讀起來像句子:if (is_even(n))。
  2. 最精簡的實作:比較與邏輯運算式的值本身就是 0 或 1,直接 return: return n % 2 == 0;——不需要 if (...) return 1; else return 0; 的迂迴。
  3. is_even 的負數陷阱:有人寫 return n % 2 == 1; 判斷奇數—— 對負奇數 -3 % 2 是 −1 不是 1,會判錯! 用「等於 0」判偶數(或用 != 0 判奇數)就完全避開符號問題。
  4. 測資的完備性:0 是偶數(0 % 2 == 0)、負偶數/負奇數都要測—— 邊界與負值是判斷函式最常翻車的地方。
  5. is_leap_year:直接搬單元 03 練習 2 的運算式進函式—— 之前寫過的邏輯,包成函式之後才真正「可重用」。

參考解答

#include <stdio.h>

int is_even(int n);
int is_leap_year(int y);

int main(void)
{
    /* --- 測 is_even:偶數、奇數、零、負數都要覆蓋 --- */
    int even_tests[] = { 4, 7, 0, -2, -3 };
    int n1 = (int)(sizeof even_tests / sizeof even_tests[0]);

    for (int i = 0; i < n1; i++) {
        printf("is_even(%2d)        = %d\n",
               even_tests[i], is_even(even_tests[i]));
    }
    printf("\n");

    /* --- 測 is_leap_year:四條規則路徑各給一個代表 --- */
    int year_tests[] = { 1900, 2000, 2024, 2025 };
    int n2 = (int)(sizeof year_tests / sizeof year_tests[0]);

    for (int i = 0; i < n2; i++) {
        printf("is_leap_year(%d) = %d\n",
               year_tests[i], is_leap_year(year_tests[i]));
    }

    return 0;
}

/* 判斷函式的慣例:回傳 1 表示「是」、0 表示「否」,
 * 名字用 is/has 開頭,讓 if (is_even(n)) 讀起來像英文。 */
int is_even(int n)
{
    return n % 2 == 0;      /* 比較運算式的值就是 0 或 1,直接回傳 */
}

int is_leap_year(int y)
{
    return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}

(完整檔案:exercises/ex3_predicates.c)

程式碼講解

  • 兩個函式的本體都只有一行 return 運算式;——小函式不是浪費, 它們給邏輯一個名字,呼叫端從此不必重新理解那條運算式。
  • is_even(-2) = 1、is_even(-3) = 0:負數行為正確, 因為判斷式是 == 0(-3 % 2 是 −1,不等於 0 → 回傳 0)。
  • 輸出把「呼叫的樣子」原樣印出(is_leap_year(1900) = 0), 這種格式本身就是一份可讀的測試報告。

範例輸出

is_even( 4)        = 1
is_even( 7)        = 0
is_even( 0)        = 1
is_even(-2)        = 1
is_even(-3)        = 0

is_leap_year(1900) = 0
is_leap_year(2000) = 1
is_leap_year(2024) = 1
is_leap_year(2025) = 0

練習 4:支援負指數的 power(難度:進階)

題目

K&R 的 power 只支援非負指數。寫 double power_d(double base, int n): n < 0 時回傳 1 / base^(-n)。 測試至少包含:power_d(2.0, -3) 應為 0.125、power_d(7.0, 0) 應為 1、 負底數 power_d(-2.0, 3) 應為 -8。

解題思路

  1. 數學定義先行:base⁻ⁿ = 1 / baseⁿ。所以演算法是: 「若指數為負 → 記住這件事、把指數翻正 → 照常算正指數次方 → 最後取倒數」。
  2. 回傳型別必須是 double:負指數的結果是分數(0.125),int 版根本裝不下—— 這正是 K&R §4.2「Functions Returning Non-integers」的主題。
  3. 傳值呼叫的自由:n = -n; 直接改參數——n 是呼叫端引數的複本, 函式內把它當草稿紙用完全安全(K&R 特別強調這是傳值的優點)。
  4. 旗標變數:int negative 記住「原本是負的」,最後用 ?: 決定要不要取倒數。
  5. 邊界檢查: - n == 0:迴圈跑 0 次,p 保持初始值 1.0——「任何數的 0 次方是 1」自然成立; - 負底數:(-2)³ = -8,逐次相乘自然帶出正確符號,不需特殊處理。

參考解答

#include <stdio.h>

double power_d(double base, int n);

int main(void)
{
    printf("power_d(2.0,   10) = %g\n", power_d(2.0, 10));    /* 1024 */
    printf("power_d(2.0,   -3) = %g\n", power_d(2.0, -3));    /* 0.125 */
    printf("power_d(10.0,  -2) = %g\n", power_d(10.0, -2));   /* 0.01 */
    printf("power_d(1.5,    4) = %g\n", power_d(1.5, 4));     /* 5.0625 */
    printf("power_d(7.0,    0) = %g\n", power_d(7.0, 0));     /* 1 */
    printf("power_d(-2.0,   3) = %g\n", power_d(-2.0, 3));    /* -8 */

    return 0;
}

/* 負指數的定義:base^(-n) = 1 / base^n。
 * 策略:先把指數轉成正的算出 base^|n|,最後視需要取倒數。 */
double power_d(double base, int n)
{
    int negative = 0;

    if (n < 0) {
        negative = 1;
        n = -n;             /* n 是複本(傳值呼叫),儘管改 */
    }

    double p = 1.0;         /* 任何數的 0 次方是 1,迴圈跑 0 次時直接回傳它 */
    for (int i = 0; i < n; i++) {
        p *= base;
    }

    return negative ? 1.0 / p : p;
}

(完整檔案:exercises/ex4_power_neg.c)

程式碼講解

  • p 初始化為 1.0 而不是 1:雖然會自動轉換,但浮點變數配浮點字面值,型別意圖一致。
  • return negative ? 1.0 / p : p;——?: 選值的典型用法, 等價的 if/else 要四行。
  • 已知極端案例:base = 0 且 n < 0 會產生除以零(inf)—— 完整的錯誤處理需要之後的知識(錯誤碼或 errno),本題先聚焦主邏輯。
  • 驗證:2⁻³ = 1/8 = 0.125、10⁻² = 0.01,輸出吻合。

範例輸出

power_d(2.0,   10) = 1024
power_d(2.0,   -3) = 0.125
power_d(10.0,  -2) = 0.01
power_d(1.5,    4) = 5.0625
power_d(7.0,    0) = 1
power_d(-2.0,   3) = -8

練習 5:最大公因數(難度:進階)

題目

實作輾轉相除法(Euclidean algorithm):gcd(a, b) = gcd(b, a % b),b == 0 時答案是 a。

  1. 先寫迴圈版 int gcd_loop(int a, int b);
  2. 再寫遞迴版 int gcd_recursive(int a, int b);
  3. 用測資對照兩版結果:(48,18)、(17,5)、(100,25)、(0,9)、(42,0)。

解題思路

  1. 先懂原理再寫碼:為什麼 gcd(a, b) = gcd(b, a % b)? 任何同時整除 a 與 b 的數,也整除 a - kb(特別是 a % b);反之亦然。 所以「a 與 b 的公因數集合」=「b 與 a%b 的公因數集合」→ 最大公因數相同。 每一步 b 都嚴格變小,最終必到 0,此時 gcd(a, 0) = a。
  2. 手走一遍 (48, 18): (48,18) → (18, 48%18=12) → (12, 18%12=6) → (6, 12%6=0) → 答案 6。✓
  3. 迴圈版:while (b != 0) 內做三步舞——存餘數、a←b、b←餘數。 注意順序:先算 a % b 存進暫存變數,再搬動 a、b,否則舊值被蓋掉。
  4. 遞迴版:數學定義逐字翻譯成程式——基底情況(b == 0 回傳 a)在前, 遞迴呼叫(縮小問題規模)在後。遞迴版更貼近定義、迴圈版不吃呼叫堆疊,各有所長。
  5. 測資設計:一般情況、互質(答案 1)、整除(答案是小的那個)、 兩個含 0 的邊界——gcd(0,9)=9、gcd(42,0)=42 都由「b==0 回傳 a」規則自然涵蓋 (gcd(0,9) 第一步變成 gcd(9, 0))。

參考解答

#include <stdio.h>

int gcd_loop(int a, int b);
int gcd_recursive(int a, int b);

int main(void)
{
    /* 測資:一般情況、互質、整除、含 0 的邊界 */
    int pairs[][2] = {
        { 48, 18 },     /* gcd = 6 */
        { 17, 5  },     /* 互質:gcd = 1 */
        { 100, 25 },    /* b 整除 a:gcd = 25 */
        { 0, 9 },       /* gcd(0, b) = b */
        { 42, 0 },      /* gcd(a, 0) = a */
    };
    int n = (int)(sizeof pairs / sizeof pairs[0]);

    printf("   a    b   loop  recursive\n");
    printf("----  ---   ----  ---------\n");
    for (int i = 0; i < n; i++) {
        int a = pairs[i][0], b = pairs[i][1];
        printf("%4d  %3d   %4d  %9d\n",
               a, b, gcd_loop(a, b), gcd_recursive(a, b));
    }

    return 0;
}

/* 迴圈版:每一圈把 (a, b) 換成 (b, a % b),b 變 0 時 a 就是答案。
 * 原理:a 與 b 的公因數集合 = b 與 a%b 的公因數集合。 */
int gcd_loop(int a, int b)
{
    while (b != 0) {
        int remainder = a % b;
        a = b;
        b = remainder;
    }
    return a;
}

/* 遞迴版:把數學定義直接寫成程式——
 *   gcd(a, 0) = a
 *   gcd(a, b) = gcd(b, a % b)
 * 每次遞迴 b 嚴格變小,最終必到 0(保證會停)。 */
int gcd_recursive(int a, int b)
{
    if (b == 0) {
        return a;                       /* 基底情況:不再遞迴 */
    }
    return gcd_recursive(b, a % b);     /* 縮小問題,交給「下一層的自己」 */
}

(完整檔案:exercises/ex5_gcd.c)

程式碼講解

  • 迴圈版的參數 a、b 被直接改寫——又一次傳值呼叫的自由: 呼叫端的變數毫髮無傷。
  • 遞迴版沒有任何區域變數:狀態全部裝在參數裡往下傳,這是遞迴思維的特徵。
  • 兩版輸出全部一致——「兩種實作互相驗證」是很實用的測試策略; 遞迴的深入探討(呼叫堆疊、停機條件)在中階單元。
  • pairs[][2] 是二維陣列(下一單元的主題),這裡只需看懂 pairs[i][0]、pairs[i][1] 是第 i 組的兩個數。

範例輸出

   a    b   loop  recursive
----  ---   ----  ---------
  48   18      6          6
  17    5      1          1
 100   25     25         25
   0    9      9          9
  42    0     42         42

練習 6:重構練習——拆分 main(難度:挑戰)

題目

下面是一段「什麼都做」的成績統計 main(30 行的縮影): 一個迴圈同時做加總、找極值、數及格人數、列印。 請把它重構成至少三個「一句話(不含『和』『並且』)說得清楚」的函式, 輸出保持不變。比較拆分前後:哪個版本更容易改「及格線從 60 變 70」?

int main(void)
{
    int scores[6] = { 72, 89, 65, 94, 58, 77 };
    int sum = 0, max = scores[0], min = scores[0], pass = 0;
    printf("scores : ");
    for (int i = 0; i < 6; i++) {
        printf("%d ", scores[i]);
        sum += scores[i];
        if (scores[i] > max) max = scores[i];
        if (scores[i] < min) min = scores[i];
        if (scores[i] >= 60) pass++;
    }
    printf("\n\naverage: %.2f\n", (double)sum / 6);
    printf("highest: %d\nlowest : %d\npassing: %d of 6\n", max, min, pass);
    return 0;
}

解題思路

  1. 找出「職責」:讀原始碼,用一句話描述每件事—— 算平均「和」找最大「和」找最小「和」數及格「和」印報表。 每出現一個「和」,就是一個該拆出去的函式。
  2. 命名即設計:average_of、max_of、min_of、count_passing、print_report—— 名字取好,函式的輸入輸出幾乎自動決定:前四個收 (const int[], int n) 回傳數值, 最後一個負責全部排版。
  3. 計算與列印分離(本題最重要的原則): 計算函式絕不 printf,列印函式絕不計算。 這樣計算函式可以被任何地方重用(也更好測試),排版要改也只動一處。
  4. 效率的取捨要誠實:拆分後陣列被走訪了四次(原版一次)。 對 6 個元素毫無差別;對億級資料才需要合併迴圈——先求清楚,再求快, 且要快的時候也只改函式內部,介面不動。
  5. 魔術數字:原版的 6 出現三次;重構版用 #define NUM_SCORES 6 一處定義。 及格線 60 被關進 count_passing——要改 70 只改一行,答案揭曉。

參考解答

#include <stdio.h>

#define NUM_SCORES 6

/* ---- 原型區:每個函式的名字就是它的「一句話描述」 ---- */
double average_of(const int scores[], int n);   /* 計算平均 */
int    max_of(const int scores[], int n);       /* 找最高分 */
int    min_of(const int scores[], int n);       /* 找最低分 */
int    count_passing(const int scores[], int n);/* 數及格人數(>= 60) */
void   print_report(const int scores[], int n); /* 印完整報表 */

int main(void)
{
    /* main 瘦身後只剩兩件事:準備資料、叫人做事 */
    int scores[NUM_SCORES] = { 72, 89, 65, 94, 58, 77 };

    print_report(scores, NUM_SCORES);
    return 0;
}

/* 計算平均分(回傳 double 保住小數) */
double average_of(const int scores[], int n)
{
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += scores[i];
    }
    return (double)sum / n;
}

/* 找最高分:先假設第一個最大,其餘逐一挑戰 */
int max_of(const int scores[], int n)
{
    int max = scores[0];
    for (int i = 1; i < n; i++) {
        if (scores[i] > max) {
            max = scores[i];
        }
    }
    return max;
}

/* 找最低分:與 max_of 對稱 */
int min_of(const int scores[], int n)
{
    int min = scores[0];
    for (int i = 1; i < n; i++) {
        if (scores[i] < min) {
            min = scores[i];
        }
    }
    return min;
}

/* 數及格人數:判斷條件只出現在這裡一處 */
int count_passing(const int scores[], int n)
{
    int count = 0;
    for (int i = 0; i < n; i++) {
        if (scores[i] >= 60) {
            count++;
        }
    }
    return count;
}

/* 印報表:只管「排版」,所有數字跟計算函式要 */
void print_report(const int scores[], int n)
{
    printf("scores : ");
    for (int i = 0; i < n; i++) {
        printf("%d ", scores[i]);
    }
    printf("\n\n");

    printf("average: %.2f\n", average_of(scores, n));
    printf("highest: %d\n", max_of(scores, n));
    printf("lowest : %d\n", min_of(scores, n));
    printf("passing: %d of %d\n", count_passing(scores, n), n);
}

(完整檔案:exercises/ex6_refactor.c,檔尾附拆分前的原始版本供對照)

程式碼講解

  • main 從 15 行變 4 行:main 是目錄,不是內文。讀 main 就知道程式做什麼, 想看細節再進對應函式。
  • 參數 const int scores[]:這些函式只讀不寫,const 讓編譯器把關—— 陣列傳函式的完整機制在下一單元,這裡先照用。
  • 及格線改 70?——只改 count_passing 裡的一個數字。 原版則要在一坨迴圈裡先「找到」那行再確定不會誤傷別的邏輯。
  • 每個函式都通過「一句話測試」:計算平均。找最高分。印報表。沒有「和」。

範例輸出

scores : 72 89 65 94 58 77 

average: 75.83
highest: 94
lowest : 58
passing: 5 of 6

常見錯誤解法對照

錯誤寫法 症狀 正解
呼叫點之前沒有原型 call to undeclared function 錯誤 檔頭集中放原型
int 版 power 算負指數 0.125 變 0 回傳型別改 double
n % 2 == 1 判斷奇數 負奇數判錯(−3 % 2 是 −1) 用 n % 2 != 0 或以 == 0 判偶數
gcd 迴圈內先蓋掉 a 餘數算錯,結果錯亂 先存 remainder 再搬動
非 void 函式少一條 return 路徑 -Wall 警告;用到回傳值是 UB 每條路徑都要 return
計算函式裡夾 printf 無法重用、無法測試 計算與列印分離