[C 的那些眉角] switch 記得加 default — 防禦未來的自己

有一種 bug,不是今天的你造成的,
是三個月後的你造成的。

你現在寫了一個 switch
處理三種狀態,邏輯完全正確,
測試也都過了。

三個月後,需求改了,
新增了第四種狀態。
你在 enum 裡加了一個值,
但忘記去更新那個 switch

編譯過了,沒有警告,
程式跑起來,遇到第四種狀態的時候,
switch 什麼都不做,
靜靜地繼續往下執行。

症狀可能是某個功能沒有反應,
可能是某個變數沒有被更新,
可能要跑很久才會觸發那個狀態,
然後你花了半天才找到原因。

default 加一行,可以讓這種問題立刻現形。


最簡單的例子

typedef enum {
    STATE_IDLE = 0,
    STATE_RUNNING = 1,
    STATE_ERROR = 2,
} SystemState;

void handle_state(SystemState state) {
    switch (state) {
        case STATE_IDLE:
            do_idle();
            break;
        case STATE_RUNNING:
            do_running();
            break;
        case STATE_ERROR:
            do_error();
            break;
        // 沒有 default
    }
}

現在加了一個新狀態:

typedef enum {
    STATE_IDLE = 0,
    STATE_RUNNING = 1,
    STATE_ERROR = 2,
    STATE_SHUTDOWN = 3, // 新增
} SystemState;

handle_state 沒有更新,
STATE_SHUTDOWN 進來之後,
switch 靜靜地什麼都不做。


default 的兩種用途

用途一:抓到不應該出現的值

如果 switch 應該處理所有合法的情況,
default 就是用來抓「不合法」或「沒預期到」的值:

void handle_state(SystemState state) {
    switch (state) {
        case STATE_IDLE:
            do_idle();
            break;
        case STATE_RUNNING:
            do_running();
            break;
        case STATE_ERROR:
            do_error();
            break;
        default:
            // 走到這裡代表有 bug:
            // 要嘛 state 的值不合法,
            // 要嘛 enum 新增了值但這裡沒有更新
            assert(0); // 讓問題立刻現形
            LOG_ERROR("Unhandled state: %d", state);
            break;
    }
}

這樣,STATE_SHUTDOWN 進來之後,
assert(0) 立刻觸發,
你馬上知道哪裡沒有更新。


用途二:處理「其他所有情況」

有時候 switch 本來就不需要處理所有值,
default 是正常的 fallback:

void process_command(uint8_t cmd) {
    switch (cmd) {
        case CMD_START:
            do_start();
            break;
        case CMD_STOP:
            do_stop();
            break;
        case CMD_RESET:
            do_reset();
            break;
        default:
            // 收到不認識的指令,正常情況,忽略或回錯誤
            LOG_WARN("Unknown command: 0x%02X", cmd);
            send_error_response(ERR_UNKNOWN_CMD);
            break;
    }
}

這裡 cmd 來自外部(網路封包、UART),
收到不認識的指令是正常情況,
default 是合理的錯誤處理,不是 assert


兩種情況怎麼判斷用哪個

判斷標準很簡單:

switch 的值來自哪裡?

來源 適合的 default
內部的 enum,理論上只有合法值 assert(0) + log
外部輸入(封包、使用者、感測器) 正常的錯誤處理
混合(內部 enum 但可能有非法值) 範圍檢查 + assert

讓編譯器幫你抓

GCC 和 Clang 有一個很有用的警告:

gcc -Wswitch your_code.c
# 或是
gcc -Wall your_code.c # -Wall 包含 -Wswitch

-Wswitch 的效果:
switch 的對象是 enum
而且有 enum 的值沒有對應的 case
編譯器會警告。

warning: enumeration value 'STATE_SHUTDOWN' not handled in switch

這個警告非常有用,
但有一個前提:switch 不能有 default

如果有 default,編譯器會認為你已經處理了所有情況,
就不會警告了。


所以要怎麼兩全其美?

這裡有一個矛盾:

  • default → 編譯器不警告漏掉的 enum
  • 沒有 default → 漏掉的值靜靜地什麼都不做

解法是把兩件事分開:

void handle_state(SystemState state) {
    switch (state) {
        case STATE_IDLE:
            do_idle();
            break;
        case STATE_RUNNING:
            do_running();
            break;
        case STATE_ERROR:
            do_error();
            break;
        case STATE_SHUTDOWN:
            do_shutdown();
            break;
        // 不加 default,讓編譯器幫你警告漏掉的 enum 值
    }

    // 在 switch 之外加一個防禦
    // 如果有不合法的值(不是 enum 裡的任何一個),
    // 上面的 switch 什麼都不做,這裡可以加額外的檢查
}

或是用 GCC 的 __attribute__

void handle_state(SystemState state) {
    switch (state) {
        case STATE_IDLE: do_idle(); break;
        case STATE_RUNNING: do_running(); break;
        case STATE_ERROR: do_error(); break;
        case STATE_SHUTDOWN:do_shutdown(); break;
        default:
            // 這裡只會被不合法的值觸發
            // (不是 enum 裡的任何一個,例如被強制轉型塞進來的值)
            assert(0);
            break;
    }
}

然後開啟 -Wswitch-enum(比 -Wswitch 更嚴格):

gcc -Wswitch-enum your_code.c

-Wswitch-enum 即使有 default
也會警告漏掉的 enum 值,
兩全其美。


幾個常見的壞習慣

壞習慣一:default 裡什麼都不做

switch (state) {
    case STATE_IDLE: do_idle(); break;
    case STATE_RUNNING: do_running(); break;
    default:
        break; // ❌ 什麼都不做,等於沒有 default
}

這樣的 default 沒有意義,
漏掉的狀態還是靜靜地被忽略。


壞習慣二:忘記 break 造成 fall-through

switch (state) {
    case STATE_IDLE:
        do_idle();
        // ❌ 忘記 break,會繼續執行 STATE_RUNNING 的程式碼
    case STATE_RUNNING:
        do_running();
        break;
    case STATE_ERROR:
        do_error();
        break;
}

C 的 switch 不加 break 會 fall-through,
有時候是刻意的,但更多時候是忘記加。

如果是刻意的 fall-through,加個註解說明:

switch (state) {
    case STATE_IDLE:
        do_idle();
        /* fall through */ // 明確說明這是刻意的
    case STATE_RUNNING:
        do_running();
        break;
}

GCC 支援 __attribute__((fallthrough))[[fallthrough]](C23),
可以讓編譯器知道這是刻意的,不要警告:

case STATE_IDLE:
    do_idle();
    __attribute__((fallthrough));
case STATE_RUNNING:
    do_running();
    break;

壞習慣三:switch 裡宣告變數沒有加大括號

switch (state) {
    case STATE_IDLE:
        int x = 10; // ❌ 某些編譯器會報錯或行為奇怪
        do_something(x);
        break;
    case STATE_RUNNING:
        // x 在這裡的作用域是什麼?
        break;
}

case 裡宣告變數,要用大括號包起來:

switch (state) {
    case STATE_IDLE: {
        int x = 10; // ✅ 作用域明確
        do_something(x);
        break;
    }
    case STATE_RUNNING:
        break;
}

狀態機的 switch 特別重要

嵌入式開發裡,狀態機幾乎無所不在,
switch 是實作狀態機最常見的方式。

狀態機的 switch 如果沒有處理好,
系統可能進入一個「沒有任何 case 處理」的狀態,
然後靜靜地卡在那裡,什麼都不做,
或是繼續往下執行,做出奇怪的行為。

typedef enum {
    CONN_STATE_DISCONNECTED = 0,
    CONN_STATE_CONNECTING = 1,
    CONN_STATE_CONNECTED = 2,
    CONN_STATE_RECONNECTING = 3,
} ConnState;

void connection_fsm(ConnState state, Event event) {
    switch (state) {
        case CONN_STATE_DISCONNECTED:
            handle_disconnected(event);
            break;
        case CONN_STATE_CONNECTING:
            handle_connecting(event);
            break;
        case CONN_STATE_CONNECTED:
            handle_connected(event);
            break;
        case CONN_STATE_RECONNECTING:
            handle_reconnecting(event);
            break;
        default:
            // 不合法的狀態,這不應該發生
            LOG_ERROR("Invalid connection state: %d", state);
            assert(0);
            break;
    }
}

每次新增狀態,
-Wswitch-enum 會提醒你更新這個 switch
defaultassert 會在漏掉的時候立刻告訴你。

雙重保護。


說實話

default 這件事,
說起來是一個很小的習慣,
但我見過不少因為沒有 default 而產生的 bug,
都是「新增了 enum 值,但某個 switch 沒有更新」。

這種 bug 特別難找,
因為程式不會 crash,
只是某個功能在特定情況下沒有反應,
或是行為不符合預期。

我現在的習慣是:

  • 對內部 enum 的 switch,一定加 default: assert(0)
  • 同時開 -Wswitch-enum,讓編譯器也幫我盯著
  • 對外部輸入的 switchdefault 是正常的錯誤處理

兩種情況分開對待,
不會因為「這個 switch 有 default 了」就放鬆警惕。


實戰 Checklist

  • [ ] 所有 switch 都有 default
  • [ ] 對內部 enum 的 switchdefault 有加 assert(0) 或 log
  • [ ] 對外部輸入的 switchdefault 有正常的錯誤處理
  • [ ] 開啟 -Wswitch-Wswitch-enum 警告
  • [ ] 刻意的 fall-through 有加 /* fall through */[[fallthrough]] 註解
  • [ ] case 裡宣告變數有用大括號包起來
  • [ ] 狀態機的 switch 有涵蓋所有合法狀態

發佈留言