「一個函式只做一件事。」
這句話我很早就聽過,覺得自己懂了,然後繼續寫出這種東西:
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 行時,有問自己是否可以拆
- [ ] 沒有為了拆而拆,每個子函式都有存在的意義