[C 的那些眉角]#define 的陷阱 — 沒用好會掉進坑

#define 可以說是 C 語言裡最早學到的東西之一。

#define MAX_SIZE 256
#define PI 3.14159

看起來很無害,對吧?

但用久了才發現,#define 其實是個很容易出事的工具。
它不是變數、不是函式、不遵守 scope、不做型別檢查,
就是單純的文字替換。

而「單純的文字替換」,在某些情況下會產生你完全沒預期到的結果。

這篇記錄幾個我自己踩過、或在 code review 看過的坑。


坑 #1:Macro 運算式沒加括號

這是最經典的,但還是一直有人踩。

#define SQUARE(x)  x * x

看起來沒問題,但:

int result = SQUARE(3 + 1);

展開之後變成:

int result = 3 + 1 * 3 + 1;  // = 7,不是 16

因為 preprocessor 只是做文字替換,不管運算子優先順序。

正確寫法是每個參數、整個運算式都加括號:

#define SQUARE(x)  ((x) * (x))

展開後:

int result = ((3 + 1) * (3 + 1));  // = 16 ✅

原則:Macro 的參數和整體,全部都加括號。


坑 #2:參數有副作用,被展開兩次

括號加好了,但還有另一個問題:

#define SQUARE(x)  ((x) * (x))

int i = 3;
int result = SQUARE(i++);

展開後:

int result = ((i++) * (i++));

i++ 被執行了兩次。

結果是 undefined behavior,不同編譯器、不同優化等級,
跑出來的結果可能都不一樣。

這種 bug 很難找,因為你根本不會去懷疑一個「看起來像函式」的東西
會把參數展開兩次。

原則:有副作用的運算式(i++、函式呼叫)不要傳進 Macro。


坑 #3:多行 Macro 沒用 do { } while(0)

有時候 Macro 會包含多行程式碼:

#define RESET_DEVICE()  \
    gpio_set_low(RESET_PIN);  \
    delay_ms(10);             \
    gpio_set_high(RESET_PIN);

這樣寫在大部分情況下沒問題,但遇到 if/else 就爆了:

if (error)
    RESET_DEVICE();
else
    do_something();

展開後:

if (error)
    gpio_set_low(RESET_PIN);
    delay_ms(10);              // 這行永遠執行!
    gpio_set_high(RESET_PIN);  // 這行也是!
else                           // 這個 else 找不到對應的 if,編譯錯誤
    do_something();

正確寫法是用 do { } while(0) 包起來:

#define RESET_DEVICE()        \
    do {                      \
        gpio_set_low(RESET_PIN);  \
        delay_ms(10);             \
        gpio_set_high(RESET_PIN); \
    } while(0)

這樣不管在什麼情況下使用,行為都是正確的,
而且結尾的分號也不會出問題。

第一次看到這個寫法的時候覺得很奇怪,
後來才理解這是 C 語言的慣用模式。


坑 #4:Macro 名稱跟變數撞名

#define 不管 scope,整個 translation unit 都有效。

#define SIZE 10

void some_function(void) {
    int SIZE = get_buffer_size();  // 編譯錯誤或奇怪的行為
    ...
}

preprocessor 會把 SIZE 直接換成 10
所以上面那行變成 int 10 = get_buffer_size();
直接編譯錯誤。

這個還算容易發現。更難發現的是:

#define MAX 100

// 某個第三方 library 的 header
typedef struct {
    int max;   // 這個 max 沒事
    int MAX;   // 這個會被展開成 int 100,編譯錯誤
} Config;

include 順序不同,結果就不同,很難 debug。

原則:Macro 名稱全大寫加底線,減少撞名機會。
原則:能用 constenum 的地方,就不要用 #define


什麼時候該用 const 取代 #define

定義常數的時候,const#define 好很多:

// ❌ #define 版本
#define MAX_RETRY_COUNT  3
#define TIMEOUT_MS       5000

// ✅ const 版本
const int MAX_RETRY_COUNT = 3;
const uint32_t TIMEOUT_MS = 5000;

const 的好處:

  • 有型別,編譯器會做型別檢查
  • 有 scope,可以限制在函式或檔案內
  • debugger 看得到#define 在 debug 的時候是看不到的
  • 不會有文字替換的副作用

唯一 #define 必須用的情況是:需要在編譯期做條件判斷的時候。

// 這種情況只能用 #define
#if defined(BOARD_VERSION) && BOARD_VERSION >= 2
    // 新版硬體的初始化
#else
    // 舊版硬體的初始化
#endif

什麼時候該用 enum 取代 #define

一組相關的常數,用 enum 比一堆 #define 好:

// ❌ 一堆 #define
#define STATE_IDLE        0
#define STATE_CONNECTING  1
#define STATE_CONNECTED   2
#define STATE_ERROR       3

// ✅ enum
typedef enum {
    STATE_IDLE = 0,
    STATE_CONNECTING,
    STATE_CONNECTED,
    STATE_ERROR
} DeviceState;

enum 的好處:

  • 有型別,switch 的時候編譯器可以警告你漏掉某個 case
  • debugger 可以顯示名稱,不只是數字
  • 語意更清楚,這些值是一組相關的狀態

那 Macro 函式呢?

有些情況下,Macro 函式還是有它的用途:

// 取最大值,支援任何型別
#define MAX(a, b)  (((a) > (b)) ? (a) : (b))

但如果編譯器支援 C99 以上,可以考慮用 static inline 函式取代:

static inline int max_int(int a, int b) {
    return (a > b) ? a : b;
}

static inline 的好處:

  • 有型別檢查
  • 沒有副作用問題
  • debugger 可以進去看
  • 大部分情況下效能跟 Macro 一樣(編譯器會 inline)

壞處是不支援多型別,每種型別要寫一個版本。
這時候 Macro 還是有它的價值。


說實話

我現在的習慣是:

能用 const 就用 const,能用 enum 就用 enum,能用 static inline 就用 static inline#define 留給真的需要 preprocessor 的地方。

但現實是,很多嵌入式專案的舊程式碼裡,
#define 滿天飛,也不可能全部重構。

所以至少要知道這些坑在哪裡,看到有副作用的 Macro 參數,
或是多行 Macro 沒有 do { } while(0)
要有那個直覺「這裡可能會出事」。


Checklist

  • [ ] Macro 的參數和整體運算式都有加括號
  • [ ] 沒有把有副作用的運算式(i++、函式呼叫)傳進 Macro
  • [ ] 多行 Macro 有用 do { } while(0) 包起來
  • [ ] Macro 名稱全大寫加底線,避免撞名
  • [ ] 定義常數優先用 const,一組相關常數用 enum
  • [ ] 簡單的 Macro 函式考慮改用 static inline
分類: 程式相關,標籤: , , , , , , , , , , , 。這篇內容的永久連結

發佈留言

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