前置處理器在編譯之前對原始碼做純文字處理:引入檔案(#include)、 巨集替換(#define)、條件編譯(#if 家族)。 它功能強大但只是「文字取代」——不懂型別、不懂語法,因此陷阱特別多。 本單元教你安全地駕馭它。

學習目標

  • 理解 #include <> 與 "" 的搜尋差異
  • 會定義物件式與函式式巨集,並掌握「參數加括號」等安全守則
  • 能辨識巨集的經典陷阱:優先權問題、副作用重複求值
  • 會使用 # 字串化與 ## 連接運算子
  • 熟悉條件編譯(#if/#ifdef/#ifndef/#elif)與預定義巨集
  • 會使用 assert 做執行期檢查
  • 能判斷什麼時候該用巨集、什麼時候該用函式或 const

對照 K&R 章節

本單元主題 K&R 2nd Edition
巨集替換、#、## §4.11.2 Macro Substitution
檔案引入 §4.11.1 File Inclusion
條件編譯 §4.11.3 Conditional Inclusion
assert Appendix B6

觀念講解

1. #include

#include <stdio.h>      /* <>:只搜尋系統標頭目錄 */
#include "myutils.h"    /* "":先搜尋原始檔所在目錄,再退回系統目錄 */

慣例:標準庫用 <>,自己專案的標頭用 ""。

2. 物件式巨集

#define MAXLINE 1000            /* 常數命名:慣例全大寫 */
#define PI      3.14159265358979323846

前置處理器單純把 MAXLINE 換成 1000。 現代 C 中,型別安全的 const 與 enum 常是更好的選擇, 但陣列大小(C89)與條件編譯條件仍需要巨集。

3. 函式式巨集與兩大陷阱

#define SQUARE(x) ((x) * (x))   /* 每個參數、整個本體都要加括號 */

陷阱一:忘了加括號 → 優先權災難

#define BAD_SQUARE(x) x * x
BAD_SQUARE(a + 1)      /* 展開成 a + 1 * a + 1,不是 (a+1)^2 ! */
100 / SQUARE(5)        /* 若本體沒外層括號:100 / 25 變成 100/5*5 = 100 */

陷阱二:副作用重複求值

#define MAX(a, b) ((a) > (b) ? (a) : (b))
int m = MAX(i++, j);   /* i++ 出現兩次:i 可能被加兩次! */

守則:巨集參數絕不放 ++、--、函式呼叫等有副作用的運算式。

4. # 字串化與 ## 連接

#define STRINGIFY(x)  #x            /* 把參數變成字串字面值 */
#define CONCAT(a, b)  a ## b        /* 把兩個 token 黏成一個 */

STRINGIFY(hello + 1)   /* -> "hello + 1" */
CONCAT(var, 3)         /* -> var3(一個識別字) */

實用範例——除錯印出「運算式文字 = 值」:

#define DEBUG_INT(expr) printf(#expr " = %d\n", (expr))
DEBUG_INT(x * y + 1);   /* 印出:x * y + 1 = 13 */

5. 條件編譯

指令 意義
#if 運算式 常數運算式非 0 就編譯
#ifdef NAME NAME 有定義就編譯(= #if defined(NAME))
#ifndef NAME NAME 沒定義就編譯(include guard 的基礎)
#elif / #else / #endif 分支與收尾

用途:平台差異、除錯開關、include guard、暫時停用程式碼:

#if 0
    /* 大段暫時停用的程式碼:比 /* */ 好,因為可以巢狀 */
#endif

除錯開關的慣用法(用 -DDEBUG 從編譯指令開啟):

#ifdef DEBUG
#define dprintf(...) fprintf(stderr, __VA_ARGS__)
#else
#define dprintf(...) ((void)0)      /* 關閉時完全不產生程式碼 */
#endif

6. 預定義巨集

巨集 內容 範例值
__FILE__ 目前檔名(字串) "main.c"
__LINE__ 目前行號(整數) 42
__DATE__ 編譯日期(字串) "Aug 1 2026"
__TIME__ 編譯時刻(字串) "19:40:00"
__func__ 目前函式名(C99,其實是變數不是巨集) "main"
__STDC_VERSION__ C 標準版本 201112L(C11)

7. assert

<assert.h> 的 assert(條件):條件為假就印出檔名、行號、條件文字並中止程式。 定義 NDEBUG 後所有 assert 消失(零成本):

#include <assert.h>
assert(n > 0 && "n must be positive");   /* 掛上訊息的小技巧 */

用途:檢查「不可能發生」的內部邏輯錯誤;不要用它驗證使用者輸入 (release 版可能被 NDEBUG 拿掉)。

8. 巨集 vs 函式

巨集 函式
型別檢查 無(文字取代) 有
對任何型別可用 是(泛型效果) 否(C11 前)
副作用安全 否 是
除錯難度 高(展開後才報錯) 低
呼叫負擔 無 極小(現代編譯器多會 inline)

原則:預設用函式;需要泛型(任何型別可用)、需要字串化/行號資訊、 或做編譯期常數時才用巨集。

範例程式

檔案 內容
01_macros.c 物件式/函式式巨集、括號守則的實際災難重演、MAX(i++) 副作用實測
02_stringify_conditional.c # 與 ## 運算子、DEBUG_INT 除錯巨集、條件編譯(#if/#ifdef/#elif、#if 0)
03_predefined_assert.c 預定義巨集全展示、自製 LOG 巨集(__FILE__/__LINE__/__func__)、assert 用法

編譯與執行(以第一個為例):

cc -std=c11 -Wall -Wextra -o /tmp/demo 01_macros.c
/tmp/demo

試試開啟除錯巨集:

cc -std=c11 -Wall -Wextra -DDEBUG -o /tmp/demo 02_stringify_conditional.c && /tmp/demo

常見錯誤與陷阱

1. 巨集參數或本體忘了加括號

#define DOUBLE(x) x + x
int y = 3 * DOUBLE(2);     /* 3*2 + 2 = 8,不是 12 */

修正:#define DOUBLE(x) ((x) + (x))——參數個個加括號、本體整體加括號。

2. 有副作用的引數

#define MAX(a,b) ((a)>(b)?(a):(b))
m = MAX(i++, j);           /* i++ 被求值兩次 */

修正:先存到變數再傳入,或改用函式。

3. 巨集定義結尾加了分號

#define N 100;             /* 多了分號 */
int a[N];                  /* 展開成 int a[100;]; -> 編譯錯誤 */

修正:#define N 100。巨集是文字取代,分號也會照抄。

4. 多敘述巨集沒有用 do-while(0) 包裹

#define SWAP(a,b) int t = a; a = b; b = t
if (x > y) SWAP(x, y);     /* 只有第一句在 if 內! */

修正:#define SWAP(a,b) do { int t = (a); (a) = (b); (b) = t; } while (0)。

5. 用 assert 檢查使用者輸入或有副作用的運算式

assert(scanf("%d", &n) == 1);   /* NDEBUG 下 scanf 根本不執行! */

assert 內的運算式在 release 版會整個消失;有副作用的呼叫絕不能放進去。

練習題

  1. 寫 #define ABS(x) 巨集,測試 ABS(-5)、ABS(3-7)、10 - ABS(-2) 都正確。
  2. 寫 #define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0])), 用它走訪陣列;再說明為什麼它對「函式參數的陣列」失效(提示:退化)。
  3. 寫 #define CLAMP(x, lo, hi):把 x 夾在 [lo, hi] 區間內,注意括號。
  4. 用 #、## 寫 #define DECLARE_GETTER(name), 展開成 int get_##name(void) { return name; },為三個全域變數生成 getter。
  5. 用條件編譯寫一支程式:-DLANG_ZH 時印中文問候、-DLANG_EN 時印英文、 都沒定義時印預設訊息。
  6. (進階)K&R 習題 4-14:寫 #define swap(t, x, y) 交換兩個 t 型別的引數 (提示:用 ## 或區塊變數避免名稱衝突)。

延伸閱讀

  • K&R 2nd Edition,§4.11(本單元主軸)、Appendix A12(前置處理器完整規格)
  • GCC 文件《The C Preprocessor》:最完整的前置處理器參考
  • 下一單元 11-file-io:檔案輸入輸出