話說有一種 bug 很特別。
不是邏輯寫錯,不是演算法有問題,
而是呼叫端用錯了你的函式。
參數順序傳反了、單位搞錯了、忘記先初始化就呼叫、
buffer 大小傳錯了……
這種 bug 有時候很難發現,因為程式可能還是跑起來,
只是結果不對,或是偶爾 crash。
我以前遇到這種情況,第一反應是「呼叫端的問題,他用錯了」。
但後來慢慢體會到:如果很多人都用錯,問題通常在介面設計,不在使用者。
一個好的函式介面,應該讓正確的用法變得自然,
讓錯誤的用法在編譯時就被抓到,或至少在執行時立刻爆炸,
而不是默默產生錯誤結果。
壞介面長什麼樣
問題一:參數太多,順序容易搞混
// 這個函式你能一眼看出參數順序嗎?
int uart_init(int port, int baud, int data_bits,
int stop_bits, int parity, int flow_ctrl);
呼叫端:
// ❌ 看起來沒問題,但 data_bits 和 stop_bits 傳反了
uart_init(1, 115200, 1, 8, 0, 0);
兩個都是 int,編譯器不會報錯,
執行時可能還是跑起來,只是 UART 設定是錯的。
問題二:用裸數字,意義不明
// 這個 2 是什麼意思?
set_led(2, 1);
// 這個 0 是關閉還是成功?
if (sensor_init() == 0) { ... }
呼叫端要去翻文件或看實作,才知道這些數字代表什麼。
問題三:參數單位不明確
// timeout 是毫秒?秒?ticks?
int network_connect(const char *ssid, const char *password, int timeout);
我在不同專案裡看過同樣名稱的函式用不同單位,
這種 bug 很難發現,因為程式邏輯上沒有錯。
問題四:狀態依賴沒有明確
// 呼叫端需要先呼叫 sensor_init() 才能用這個
// 但介面上看不出來
float sensor_read_temperature(void);
如果沒有初始化就呼叫,可能 crash,可能回傳垃圾值,
取決於實作細節,呼叫端不知道。
讓介面更難用錯的方法
方法一:用 enum 取代裸數字
// ❌ 裸數字
set_led(2, 1);
// ✅ 用 enum,意義一目瞭然
typedef enum {
LED_RED = 0,
LED_GREEN = 1,
LED_BLUE = 2,
} LedId;
typedef enum {
LED_OFF = 0,
LED_ON = 1,
} LedState;
set_led(LED_BLUE, LED_ON);
多打幾個字,但呼叫端不需要查文件,
而且傳錯型別的時候編譯器會警告。
方法二:把單位寫進參數名稱
// ❌ timeout 是什麼單位?
int network_connect(const char *ssid, const char *password,
int timeout);
// ✅ 單位寫清楚
int network_connect(const char *ssid, const char *password,
uint32_t timeout_ms);
這個方法很簡單,但效果很好。
我在嵌入式專案裡習慣這樣命名:
timeout_ms— 毫秒delay_us— 微秒size_bytes— 位元組freq_hz— 赫茲voltage_mv— 毫伏特
方法三:用 struct 包裝複雜參數
參數超過三四個,就考慮用 struct:
// ❌ 參數太多,順序容易搞混
int uart_init(int port, int baud, int data_bits,
int stop_bits, int parity, int flow_ctrl);
// ✅ 用 struct 包裝
typedef struct {
uint32_t baud_rate;
uint8_t data_bits; // 7 or 8
uint8_t stop_bits; // 1 or 2
uint8_t parity; // UART_PARITY_NONE / ODD / EVEN
uint8_t flow_ctrl; // UART_FLOW_NONE / RTS_CTS
} UartConfig;
int uart_init(int port, const UartConfig *config);
呼叫端:
UartConfig cfg = {
.baud_rate = 115200,
.data_bits = 8,
.stop_bits = 1,
.parity = UART_PARITY_NONE,
.flow_ctrl = UART_FLOW_NONE,
};
uart_init(UART_PORT_1, &cfg);
用 designated initializer,每個欄位名稱都寫出來,
幾乎不可能搞混順序。
方法四:提供預設設定
承接上面的例子,
大部分情況可能都是用 8N1 115200,
每次都要填一個 struct 很麻煩:
// 提供常用的預設設定
#define UART_CONFIG_DEFAULT \
{ \
.baud_rate = 115200,\
.data_bits = 8, \
.stop_bits = 1, \
.parity = UART_PARITY_NONE, \
.flow_ctrl = UART_FLOW_NONE, \
}
呼叫端:
// 用預設值,只改需要的部分
UartConfig cfg = UART_CONFIG_DEFAULT;
cfg.baud_rate = 9600; // 只改這個
uart_init(UART_PORT_1, &cfg);
這個模式在 Linux kernel 裡很常見,
讓呼叫端不用每次都填所有欄位。
方法五:在函式入口做參數檢查
int uart_init(int port, const UartConfig *config) {
// 先檢查參數,早點爆炸比較好
if (config == NULL) {
return ERR_INVALID_PARAM;
}
if (port < 0 || port >= UART_PORT_MAX) {
return ERR_INVALID_PARAM;
}
if (config->data_bits != 7 && config->data_bits != 8) {
return ERR_INVALID_PARAM;
}
if (config->stop_bits != 1 && config->stop_bits != 2) {
return ERR_INVALID_PARAM;
}
// 參數都合法,繼續初始化
// ...
return ERR_OK;
}
「早點爆炸」是我很喜歡的概念。
與其讓錯誤的參數進去,在某個深層函式裡產生奇怪的行為,
不如在入口就擋下來,明確告訴呼叫端「你的參數有問題」。
方法六:狀態依賴要明確
// ❌ 呼叫端不知道需要先初始化
float sensor_read_temperature(void);
// ✅ 方法一:handle 設計,強迫先初始化才能拿到 handle
typedef struct SensorHandle SensorHandle;
SensorHandle *sensor_init(uint8_t i2c_addr);
float sensor_read_temperature(SensorHandle *handle);
void sensor_deinit(SensorHandle *handle);
呼叫端:
SensorHandle *sensor = sensor_init(SENSOR_I2C_ADDR);
if (sensor == NULL) {
// 初始化失敗
return ERR_FAIL;
}
float temp = sensor_read_temperature(sensor);
sensor_deinit(sensor);
這種 handle 設計,讓呼叫端沒辦法跳過初始化,
因為沒有 handle 就沒辦法呼叫其他函式。
// ✅ 方法二:在函式內部檢查狀態
static bool g_sensor_initialized = false;
ErrorCode sensor_init(void) {
// ...初始化...
g_sensor_initialized = true;
return ERR_OK;
}
ErrorCode sensor_read_temperature(float *temperature) {
if (!g_sensor_initialized) {
LOG_ERROR("sensor not initialized, call sensor_init() first");
return ERR_NOT_READY;
}
// ...
}
這個方法比較簡單,但錯誤要到執行時才會發現。
Handle 設計可以在編譯時期就強制正確的使用順序。
一個真實的例子
我之前寫過一個 ring buffer,
早期的介面長這樣:
// 早期版本
void rb_init(RingBuffer *rb, uint8_t *buf, int size);
int rb_write(RingBuffer *rb, uint8_t *data, int len);
int rb_read(RingBuffer *rb, uint8_t *buf, int len);
問題:
rb_write的data應該加constsize和len用int,傳負數不會報錯rb_write和rb_read回傳值意義不一樣(一個是寫入數量,一個是讀取數量),但看介面看不出來
後來改成這樣:
// 改進版本
ErrorCode rb_init(RingBuffer *rb, uint8_t *buf, size_t capacity);
// 回傳實際寫入的位元組數,-1 代表錯誤
int rb_write(RingBuffer *rb, const uint8_t *data, size_t len);
// 回傳實際讀取的位元組數,-1 代表錯誤
int rb_read(RingBuffer *rb, uint8_t *buf, size_t buf_size);
// 查詢目前有多少資料可以讀
size_t rb_available(const RingBuffer *rb);
改動不大,但介面清楚很多:
const標示哪個參數是唯讀的size_t取代int,不能傳負數rb_available讓呼叫端可以先查再讀,不用猜
說實話
好的介面設計,需要站在呼叫端的角度想。
但我們寫函式的時候,通常是站在實作端的角度,
想的是「怎麼把這個功能做出來」,
不是「呼叫端怎麼用這個函式」。
這兩個角度有時候差很多。
我的習慣是,寫完函式之後,
假裝自己是第一次看到這個介面的人,
試著呼叫它,看看有沒有哪裡不直覺。
或是讓同事 review,
他們用錯的地方,通常就是介面需要改進的地方。
介面一旦對外公開,要改就很麻煩,
因為所有呼叫端都要跟著改。
所以在設計的時候多花一點時間,
之後可以省掉很多麻煩。
這篇的 Checklist
- [ ] 參數超過三個有考慮用 struct 包裝
- [ ] 裸數字有用 enum 取代
- [ ] 時間、大小等有單位的參數,名稱有標示單位
- [ ] 唯讀的指標參數有加
const - [ ] 函式入口有做參數合法性檢查
- [ ] 有狀態依賴的函式,有明確的方式強制初始化順序
- [ ] 常用設定有提供預設值,減少呼叫端的負擔
- [ ] 站在呼叫端角度,試著用過一次自己的介面