[C 的那些眉角]一個函式只做一件事 — 聽起來簡單但很難

「一個函式只做一件事。」

這句話我很早就聽過,覺得自己懂了,然後繼續寫出這種東西:

int process_sensor_data(void) {
    // 讀取感測器
    uint8_t raw[8];
    i2c_read(SENSOR_ADDR, raw, sizeof(raw));

    // 解析資料
    int16_t temp = (raw[0] << 8) | raw[1];
    int16_t humidity = (raw[2] << 8) | raw[3];

    // 換算單位
    float temp_c = temp / 100.0f;
    float humi_pct = humidity / 100.0f;

    // 檢查是否超過閾值
    if (temp_c > 85.0f || humi_pct > 95.0f) {
        trigger_alarm();
    }

    // 存到全域變數
    g_temperature = temp_c;
    g_humidity = humi_pct;

    // 發送到 server
    mqtt_publish("sensor/data", temp_c, humi_pct);

    return 0;
}

這個函式做了幾件事?

讀取、解析、換算、判斷、存值、發送。六件事。

當時覺得「這樣寫很方便,一個函式搞定所有事情」。
直到需要修改的時候才發現,牽一髮動全身,
改個閾值要找半天,加個錯誤處理不知道要加在哪裡,
單元測試根本不知道從哪裡下手。


「一件事」到底是多大?

這是最難的部分。

沒有一個精確的定義說「一件事」是什麼,
但有幾個問題可以幫你判斷:

「你能不能用一句話描述這個函式在做什麼,而且不需要用『然後』或『還有』?」

process_sensor_data — 讀取感測器,然後解析,然後換算,然後發送
                                  ^^^^        ^^^^        ^^^^
                                  這些「然後」就是警訊
read_sensor_raw     — 從 I2C 讀取感測器原始資料
parse_sensor_raw    — 把原始位元組解析成數值
convert_to_celsius  — 把 ADC 值換算成攝氏溫度
check_alarm_condition — 檢查是否需要觸發警報

每個函式一句話說清楚,沒有「然後」。


拆開之後長什麼樣

把剛才那個函式拆開:

// 只負責 I2C 讀取
static int read_sensor_raw(uint8_t *buf, size_t len) {
    return i2c_read(SENSOR_ADDR, buf, len);
}

// 只負責解析位元組
static void parse_sensor_raw(const uint8_t *raw,
                              int16_t *temp_raw,
                              int16_t *humi_raw) {
    *temp_raw = (raw[0] << 8) | raw[1];
    *humi_raw = (raw[2] << 8) | raw[3];
}

// 只負責單位換算
static float raw_to_celsius(int16_t raw) {
    return raw / 100.0f;
}

static float raw_to_humidity(int16_t raw) {
    return raw / 100.0f;
}

// 只負責判斷警報條件
static bool is_alarm_condition(float temp_c, float humi_pct) {
    return (temp_c > TEMP_ALARM_THRESHOLD ||
            humi_pct > HUMI_ALARM_THRESHOLD);
}

// 上層函式負責組合
int update_sensor_reading(void) {
    uint8_t raw[8];

    if (read_sensor_raw(raw, sizeof(raw)) != 0) {
        return -1;
    }

    int16_t temp_raw, humi_raw;
    parse_sensor_raw(raw, &temp_raw, &humi_raw);

    g_temperature = raw_to_celsius(temp_raw);
    g_humidity    = raw_to_humidity(humi_raw);

    if (is_alarm_condition(g_temperature, g_humidity)) {
        trigger_alarm();
    }

    return 0;
}

程式碼變多了,但每個函式都很好理解,
而且可以獨立測試、獨立修改。


拆函式的實際好處

測試變得可能

// 可以單獨測試解析邏輯,不需要真實硬體
void test_parse_sensor_raw(void) {
    uint8_t raw[] = {0x09, 0xC4, 0x17, 0x70, 0, 0, 0, 0};
    int16_t temp_raw, humi_raw;

    parse_sensor_raw(raw, &temp_raw, &humi_raw);

    assert(temp_raw == 2500);   // 25.00°C
    assert(humi_raw == 6000);   // 60.00%
}

如果解析邏輯跟 I2C 讀取混在一起,
你就必須有真實硬體才能測試,非常麻煩。


修改只影響一個地方

感測器換了,原始資料格式不同?
只改 parse_sensor_raw,其他不動。

閾值邏輯要改?
只改 is_alarm_condition,其他不動。

換成不同的通訊介面?
只改 read_sensor_raw,其他不動。


錯誤處理更清楚

// 每個步驟的錯誤可以分開處理
int update_sensor_reading(void) {
    uint8_t raw[8];

    if (read_sensor_raw(raw, sizeof(raw)) != 0) {
        LOG_ERROR("Failed to read sensor via I2C");
        return ERR_SENSOR_READ;
    }

    // 解析不會失敗,所以不用檢查
    int16_t temp_raw, humi_raw;
    parse_sensor_raw(raw, &temp_raw, &humi_raw);

    // ...
    return 0;
}

什麼時候不應該拆

說了這麼多拆函式的好處,但也不是什麼都要拆。

拆過頭也是問題:

// ❌ 這就太誇張了
static int add_one(int x) {
    return x + 1;
}

static int multiply(int a, int b) {
    return a * b;
}

int calculate_area(int width, int height) {
    return multiply(width, height);
}

拆到這種程度,讀程式碼反而要一直跳來跳去,
比直接寫在一起更難讀。

我的判斷標準:

  • 有沒有獨立的測試價值? 如果這段邏輯值得單獨測試,就拆
  • 有沒有可能被複用? 如果其他地方也會用到,就拆
  • 有沒有不同的變化頻率? 如果這段邏輯比其他部分更常改,就拆
  • 看起來像一個完整的概念嗎? 憑感覺,但通常是對的

函式長度的問題

很多人說「函式不能超過 X 行」,
我覺得這個規則太死板。

有些函式天生就比較長,比如一個複雜的狀態機,
硬要拆成很多小函式反而更難理解。

但如果一個函式超過一個螢幕的高度(大概 40-50 行),
我會開始問自己:「這裡有沒有哪個段落可以獨立出去?」

通常都有。


說實話

這個原則我到現在還是常常違反。

趕功能的時候,最快的方式就是在現有函式裡面繼續加,
反正能動就好。

然後過了幾個月,那個函式變成 200 行的怪物,
沒有人敢動它,因為不知道改了哪裡會影響什麼。

我覺得這個習慣需要在寫的當下就刻意練習,
不能等到「之後有空再重構」,因為那個「之後」通常不會來。

每次寫新函式的時候,多問自己一句:
「這個函式在做幾件事?」


這篇的 Checklist

  • [ ] 函式能用一句話描述,不需要「然後」或「還有」
  • [ ] 函式名稱是「動詞 + 名詞」,清楚說明做什麼
  • [ ] 有獨立測試價值的邏輯有拆出來
  • [ ] 可能被複用的邏輯有拆出來
  • [ ] 函式超過 50 行時,有問自己是否可以拆
  • [ ] 沒有為了拆而拆,每個子函式都有存在的意義
分類: 程式相關,標籤: , , , , , , , , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *