一台盲目的文字替換機器
在第一篇裡,我們把 `gcc hello.c` 的四個階段像生產線上的工作站一樣排好:前置處理、編譯、組譯、連結。本篇就停在最前面那個工作站,往裡頭看。幾乎每個初學者都會被絆倒的意外是:前置處理器並不懂 C。它不知道 `int` 是什麼、函式在做什麼,也不知道分號結束一個敘述。它是一支獨立、更單純的程式,在真正的編譯器看到你的檔案「之前」就先跑起來,而它做的事就只是搬動文字。
前置處理器只留意以 `#` 符號開頭的那幾行——那些是給它的命令,稱為前置處理指令。其餘每一行,它都原封不動地照抄過去,就像一位影印整疊紙的辦事員,只在看到便利貼時才停下來照辦。`#` 行不是 C:它不以分號結尾,而且等編譯器開跑時它早已消失。前置處理器收工時,會把一大塊純文字交給下一個工作站,其中每一行 `#` 都不見了、每一次貼上都已完成。
#include 到底做了什麼
你最先遇到的指令是 `#include`,它的工作幾乎滑稽地直白:找到一個檔案,把它的全部內容貼到「這裡」,彷彿那每一個字元都是你親手打的。當前置處理器讀到 `#include <stdio.h>`,它會找到名為 stdio.h 的系統標頭檔,把整份東西丟進你檔案的那個確切位置。角括號的意思是「去系統的標準位置找」;雙引號,如 `#include "utils.h"`,意思是「先在我自己的檔案旁邊找」。這就是兩種寫法之間的全部差別。
為什麼要把標頭檔貼進來呢?因為 C 是由許多檔案組裝起來的,這個概念你在「學習 C」那一階見過,下一篇還會看到完整的樣貌。標頭檔(以 .h 結尾)裝的是「宣告」——像 `int add(int, int);` 這樣簡短的承諾,說明某個函式存在、長什麼形狀,卻不說它怎麼運作。與它配對的原始檔(.c)才裝真正的程式碼。當你的 main.c 把 utils.h 貼進來,它就獲得了那些承諾,於是即便真正的 `add` 住在另一個檔案裡,編譯器仍能檢查你的呼叫。標頭檔是菜單;原始檔是廚房。
這種貼上,正是單一個 .c 檔加上它所有的引入,如何變成一塊自成一體的文字——也就是編譯器真正讀到的那個編譯單元。你的 main.c 不只是你打的那幾行;而是你的那幾行「連同」拼接進來的 stdio.h 與 utils.h,每一道指令都已解析完畢。記住這幅畫面:「編譯 main.c」其實是「編譯 main.c 展開成的那個編譯單元」,而這個區別,能解釋後面許多令人困惑的行為。
#define 與巨集的陷阱
第二個重要指令是 `#define`,它設立一個巨集:一段具名的文字,讓前置處理器在那個名字之後出現的地方,把它替換進去。寫下 `#define BUFSIZE 1024`,從此你程式碼裡每一個 `BUFSIZE`,都會在編譯器看到任何東西之前,被逐字替換成 `1024`。這跟你文書軟體的自動更正是同一種尋找與取代——不計算任何東西,不求出任何值。它是純粹的文字替換,這既是它好用的原因,也是它會咬人的原因。
巨集有兩種樣子。物件式巨集是代表某段文字的單純名字,像上面的 `BUFSIZE`。函式式巨集帶括號與引數,像 `#define SQUARE(x) ((x)*(x))`,把引數的文字貼進樣式裡。但它仍是文字貼上,而非真正的呼叫:引數被逐字抄進去,「之後」編譯器才讀那個結果。忘了這點,你就會得到課本上的那個 bug。如果你粗心地寫 `#define SQUARE(x) x*x`、不加括號,那麼 `SQUARE(a+b)` 會展開成 `a+b*a+b`,按運算子優先順序來看,這跟平方根本沾不上邊。
同一個陷阱還有更尖銳的版本。因為引數文字是照字面貼進去的,用那個不安全的巨集寫 `SQUARE(i++)` 會變成 `i++ * i++`,於是 `i` 被遞增「兩次」——這個 bug 在真正的函式裡並不存在,因為那裡引數只會被求值一次。該守的紀律是:把整個本體與每個引數都加上括號,且絕不傳入有副作用的運算式。不過老實說,老練的 C 程式設計師對前置處理器是省著用的:`const int` 或 `enum` 會遵守 C 的型別與範圍規則,而巨集無視它們;一個小小的 `inline` 函式則能完全避開重複求值的陷阱。把 `#define` 留給那少數只有它能做的工作。
條件文字與引入防護
前置處理器的第三個把戲,是依某個名字是否已被定義,來保留或丟棄整段文字。`#ifdef NAME ... #endif` 的意思是「只有 NAME 被定義時才保留中間的文字,否則刪除它」,而 `#ifndef NAME` 是它的鏡像:只有 NAME「沒」被定義時才保留。因為這些指令在任何 C 被理解之前,就作用在原始文字上,它們讓同一份原始檔在 Windows 與 Linux 上能編譯成不同樣子,或只在設定了 `DEBUG` 旗標時才納入除錯記錄。陷阱在於:不成對的 `#ifdef` 或漏掉的 `#endif` 會產生莫名其妙的錯誤,因為編譯器只看到由此產生、錯誤地被保留或刪除的文字。
這套條件機制,驅動了你在真實標頭檔最上方最常見到的那個樣式:引入防護。它要解決的問題是這樣的。標頭檔會引入其他標頭檔,所以同一個檔案很容易被貼進來兩次——比方說你的兩個檔案都 `#include` 同一個共用的 types.h,而第三個檔案把兩者都拉了進來。對像 `struct` 定義這樣的東西來說,抵達兩次是個硬性錯誤:「redefinition(重複定義)」。引入防護讓一個標頭檔可以被引入任意多次而安全,只在第一次貼入它的本體,之後就跳過。
#ifndef MYPROJECT_TYPES_H /* if this name is not yet defined... */
#define MYPROJECT_TYPES_H /* ...define it (as a side effect)... */
struct Point { int x; int y; }; /* ...and keep the body */
#endif /* second time around, the body is skipped */把它讀成一句話:「如果這個唯一的名字還沒被定義,現在就定義它並保留下面的一切;否則跳到底部。」標頭檔第一次被貼入時,名字尚未定義,所以本體被保留、名字也被當作副作用定義了。之後每一次,名字已被定義,所以整個本體被丟棄。這個名字只需對該標頭檔唯一即可——慣例上寫成 `MYPROJECT_TYPES_H`。許多編譯器也接受在檔案最上方寫一行非標準的 `#pragma once` 來達到同樣效果;它被廣泛支援,卻不是官方 C 的一部分,所以講求可攜性的程式碼,往往仍用經典的三行防護。
強大,但說實話也危險
退一步看,整個前置處理器的性格就清晰起來。它之所以強大,正因為它笨:它會欣然產出「看起來」正確卻其實不對的文字,因為它無視編譯器會強制執行的每一條意義規則。一個不匹配的巨集、一個漏掉的 `#endif`、一個被貼了兩次的標頭檔——這些都沒有違反前置處理器知道的規則,所以它默不作聲,而那令人困惑的錯誤要等到下一個工作站、也就是編譯器,才浮上水面。明白前置處理器最先跑、而且是盲目地跑,在你讀那些錯誤時就已贏了一半。
所以現代的建議,也是老練的 C 與 C++ 程式設計師確實遵循的,是節制。能用真正的語言特性就用——常數用 `const` 與 `enum`、小片段用 `inline` 函式——因為這些遵守 C 的型別與範圍規則,不會給你意外。把前置處理器留給那少數只有它能做的工作:用 `#include` 拉進標頭檔、防止重複引入,以及用條件指令讓程式碼跨平台適應。這樣用,它是不可或缺的工具。反射性地隨手就用,它則是一個編譯器幫不了你找出來的、安靜的 bug 來源。