[C 的那些眉角]函式介面設計 — 呼叫時不易搞錯

話說有一種 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_writedata 應該加 const
  • sizelenint,傳負數不會報錯
  • rb_writerb_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
  • [ ] 函式入口有做參數合法性檢查
  • [ ] 有狀態依賴的函式,有明確的方式強制初始化順序
  • [ ] 常用設定有提供預設值,減少呼叫端的負擔
  • [ ] 站在呼叫端角度,試著用過一次自己的介面
分類: 程式相關,標籤: , , , , , , , , , , , 。這篇內容的永久連結

發佈留言

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