1. 預處理器與巨集的本質
如果 C++ 是一部影集,預處理器就像那種在第一集只出場兩句台詞、結果最後才發現整個劇情其實一直都是它在背後操控的角色。問題在於,預處理器的工作發生在編譯器之前,而且做的是一件程式設計師常常低估的事:就是直接改文字。沒有型別、沒有 C++ 的作用域、沒有多載——只有「找到某個詞 → 換成另一段文字」。
把它想成一條流水線會很方便:
flowchart LR
A["你的 .cpp/.hpp 文字"] --> B["預處理器<br/>(#include, #define, ... )"]
B --> C["替換後的最終文字"]
C --> D["C++ 編譯器<br/>(型別、多載、檢查)"]
也正因如此,巨集造成的錯誤常常看起來像是「罵錯地方」:編譯器抱怨的是已經被轉換過的文字,而不是你親手寫下的原始內容。
這裡有一個重要的術語細節:在「一般」C++ 裡,name(名稱)這個詞,指的是語言在較後處理階段中的實體;而巨集則是另一個宇宙,它有自己的 macro names。在標準化文件的表述中也會直接強調,嚴格意義上的「名稱」屬於較後面的處理階段,而巨集擁有的是macro names。
這也很好地說明了,為什麼 namespace 對巨集完全沒作用,以及為什麼 #define max 10 可以讓整個專案都很痛苦(連隔壁專案也可能一起遭殃)。
2. Object-like 巨集
當你第一次看到 #define,很容易以為它「只是個常數」。但實際上,它更像是編輯器裡的一條自動取代規則:遇到 NAME ——就把一段文字塞進去。而新手的大腦通常會在這裡得出一個危險結論:「那我就用這個來存常數吧。」技術上可以。實務上——幾乎總有更好的做法(好的替代方案我們會在後面的講座談到),但此刻更重要的是先理解它的機制與風險。
Object-like 巨集就是「名稱 → 文字」:
#define APP_NAME "TinyTasks"
#define DEFAULT_LIMIT 10
在我們的小型應用程式裡(就把它想成一個簡單的主控台工具 TinyTasks),你可以這樣做:
#include <iostream>
#define APP_NAME "TinyTasks"
int main() {
std::cout << APP_NAME << '\n'; // TinyTasks
}
在預處理階段,APP_NAME 這一段文字只會單純變成 "TinyTasks"。
接著說最主要的陷阱:運算子優先順序,以及沒有括號。巨集就是文字;如果那段文字是一個運算式,運算子優先順序就會照常運作——但運作的對象已經是「展開後」的文字了。
來看一個乍看很無害的例子:
#include <iostream>
#define TWO_PI 2 * 3.14159
int main() {
double r = 10.0;
std::cout << TWO_PI * r << '\n';
}
人類讀它時,通常會把它理解成 (2 * 3.14159) * r。編譯器在這個例子裡也會這樣讀,因為 * 的優先等級相同,所以會從左到右處理。但如果巨集裡的內容更複雜一些(例如帶有 + 或 -),沒有括號時,你很快就會得到來自平行宇宙的數學。
所以,對於本身代表一個運算式的 object-like 巨集,第一條安全規則就是:用括號把它包起來。不是因為「大家都這樣寫」,而是因為巨集是文字插入,不是語言層面的運算式實體。
3. Function-like 巨集
Function-like 巨集,是那些長得像函式呼叫的東西:NAME(x)。新手通常很喜歡它們:「喔!我現在就做一個 SQUARE(x),人生就圓滿了。」問題在於,這不是函式。它只是「把引數塞進一個文字模板裡」。
先從經典範例開始:
#include <iostream>
#define SQUARE(x) (x * x)
int main() {
std::cout << SQUARE(3) << '\n'; // 9
}
看起來沒問題。但如果這樣:
#include <iostream>
#define SQUARE(x) (x * x)
int main() {
std::cout << SQUARE(1 + 2) << '\n'; // 5(哎呀)
}
為什麼是 5?因為它會展開成 (1 + 2 * 1 + 2)。 乘法優先順序更高,所以結果就不是你「腦中以為的那個」。
因此,function-like 巨集有一條堪稱「護身公式」的寫法,能避開大量地雷:
#define SQUARE(x) ((x) * (x))
也就是:每次使用引數時都加括號,整個結果外面再加一層括號。這不是美學,而是想辦法讓「文字複製貼上」稍微更像一個真正的運算式。
4. 副作用與重複求值
如果上一個陷阱談的是運算子優先順序,那這一個談的就是:function-like 巨集可能會把同一個引數用好幾次,也就代表——它可能會把同一個引數算好幾次。函式的引數只會在呼叫前求值一次;但巨集的引數會被插入到文字裡你寫了幾次,它就出現幾次。
先用最簡單的方式界定一下「副作用」:就是某個運算式不只是算出一個值,還會改變狀態。例如,i++ 會改變 i。
看這裡:
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int i = 2;
int y = SQUARE(i++);
std::cout << "i=" << i << " y=" << y << '\n'; // i=4 y=6(或者更精彩)
}
巨集會展開成 ((i++) * (i++))。也就是說,遞增會發生兩次。接下來就進入「標準遊樂園」:運算式各部分的求值順序,可能完全不是你預期的那樣,對沒有心理準備的大腦而言,結果會變得非常不直覺。
這裡值得記住一條很實務的結論:如果 function-like 巨集會使用某個引數超過一次,就不要把帶副作用的運算式傳進去。而新手幾乎總是會忘記:這個巨集到底只用了一次,還是用了兩次。所以在真實專案裡,除非真的沒辦法,大家通常都不想濫造 function-like 巨集。
5. 多行巨集與 do { ... } while(false)
有時候,你想把巨集做成的不是運算式,而是一條「命令」:例如輸出日誌、檢查條件、列印訊息。這時就會出現下一級痛苦:如果巨集展開成多個敘述,它可能會把 if/else 搞壞(然後你會盯著編譯錯誤很久,懷疑是不是 else 被誰霸凌了)。
想像一個很天真的日誌巨集:
#define LOG(msg) std::cout << msg << '\n'; std::cout << "----\n";
如果你這樣寫:
if (ok)
LOG("start");
else
std::cout << "error\n";
那麼 else 就可能會「黏」到錯的地方。因為巨集展開之後會變成兩個敘述,而 if 只會綁定第一個。
所以有一個標準慣用法:把巨集包進 do { ... } while(false),這樣它永遠都會是一個敘述:
#include <iostream>
#define LOG(msg) do { \
std::cout << (msg) << '\n'; \
std::cout << "----\n"; \
} while (false)
int main() {
bool ok = true;
if (ok)
LOG("start"); // start
// ----
else
std::cout << "error\n";
}
對,這看起來很像某種「魔法儀式」。但它的意義非常務實:do { ... } while(false) 是一個敘述,因此 if/else 的行為就會變得可預測。
6. 作用範圍限制與巨集名稱
#undef 與影響範圍
巨集根本不懂 C++ 的作用域。它們不知道什麼是 {} 區塊,不尊重 namespace,也不會因為弄壞別人的程式碼而感到羞愧。所以一個很好用的技巧就是:限制它的「影響範圍」。
當你寫下 #define 之後,它會沿著後面的文字一路生效,直到檔案結束(包括所有 #include 插入後的內容),或者直到你用 #undef 把它取消為止。
例如:
#include <iostream>
#define TMP_VALUE 123
int main() {
std::cout << TMP_VALUE << '\n'; // 123
}
#undef TMP_VALUE
在實務上,#undef 更常不是出現在 main.cpp 裡,而是用在這種情境:我們為某個標頭檔或某段程式暫時定義了一個巨集,但不希望它一路外洩到後面。這在巨集位於標頭檔時尤其重要:一個標頭檔可能會被數百個 .cpp 檔包含,而每一個都會無條件收到這份「禮物」。
命名慣例
面對巨集名稱時,你必須偏執一點。不是因為「這是風格問題」,而是因為巨集本質上是全域的文字替換,和別人的程式碼發生命名衝突的機率真的很高。在標準化文件中也會特別強調:巨集有自己的一套「命名系統」(macro names),而那不是 C++ 在後期處理階段操作的那種名稱。
我們替 TinyTasks 專案固定一條簡單的約定:所有巨集都以專案前綴 TT_ 開頭,並且使用 ALL_CAPS。這有兩個好處:第一,你一眼就能看出「這是巨集」;第二,命名衝突的機率會大幅下降。
下面是一張「壞名字 vs 好名字」的小表:
| 不好的巨集名稱 | 為什麼不好 | 好的名稱 |
|---|---|---|
|
幾乎會跟世上一切東西衝突 | |
|
看起來像函式,破壞可讀性 | |
|
太泛,常常早就被占用 | |
/ |
命名衝突的經典款(尤其在 Windows 上) | |
還有一點:標頭檔中的巨集就像香料。加一點點,可能讓菜更好吃;但如果直接倒半罐下去,那道菜就沒法吃了。如果巨集真的得住在 .hpp 裡,它的名字就必須非常小心,而且足夠獨特。
7. 實務範例:巨集記錄器 TT_LOG
現在我們來做一個範例,展示巨集一個「合理」的使用場景:傳遞呼叫位置(檔案與行號)。這正是一般函式沒辦法「自己」取得而不失去意義的資訊:真正重要的是你在哪裡呼叫了它。
我們會在 TinyTasks 裡加上一個簡單的 logger,輸出格式如下:
file:line message
標頭檔 logger.hpp
先穩妥地開始:標頭檔宣告函式,並提供一個巨集包裝。
// logger.hpp
#pragma once
#include <string>
namespace tt {
void log(const std::string& message, const char* file, int line);
}
#define TT_LOG(msg) tt::log((msg), __FILE__, __LINE__)
請注意兩件事。
第一,巨集 TT_LOG(msg) 最終會變成一個函式呼叫,因此引數 msg 只會被求值一次(就像普通函式引數那樣)。這比那種「花俏」的 SQUARE(x) 類巨集安全得多。
第二,我們把 __FILE__ 與 __LINE__ 直接用在巨集呼叫的位置上,而這也正是它必須是巨集的原因:它會把 __FILE__/__LINE__ 插入到你寫下 TT_LOG(...) 的那個地方。
實作 logger.cpp
實作非常簡單:印出一行文字。
// logger.cpp
#include "logger.hpp"
#include <iostream>
namespace tt {
void log(const std::string& message, const char* file, int line) {
std::cout << file << ":" << line << " " << message << '\n';
}
}
在 main.cpp 中使用
現在把 logger 接進來,並在我們的應用程式裡使用它。
// main.cpp
#include "logger.hpp"
#include <iostream>
int main() {
TT_LOG("TinyTasks started"); // main.cpp:6 TinyTasks started(大致如此)
std::cout << "Hello from app\n"; // Hello from app
}
當然,行號會取決於呼叫實際寫在哪裡。而這正是重點:日誌包含的是事件發生的真實位置。
注意這裡的巨集是如何「做對事」的:它不是拿來取代正常語言機制,而只是幫你把呼叫位置的中繼資料帶進去。接下來的講座中,我們會討論如何減少巨集的使用;但這個範例,正好就是巨集看起來很合理的一種情況。
8. 使用 #define 時的典型錯誤
錯誤 1:把巨集當成有型別的變數或常數。
新手寫下 #define LIMIT 10,接著開始推理:「LIMIT 是 int。」不對:它只是文字 10,之後會被插進程式碼裡。型別只會在展開之後才出現,也就是編譯器看到最終文字的那一刻。也因此,巨集可能會在你根本沒打算的地方意外「生效」(例如不夠謹慎時,跑進另一個識別字相關的情境裡)。
錯誤 2:沒有括號的 function-like 巨集。
像 #define SQUARE(x) x*x 這種寫法,遲早會在 SQUARE(a + b) 上出事,然後搖身一變成你根本沒訂閱的「運算子優先順序教科書」。如果你真的要寫這種巨集,那麼引數外面和整體結果外面的括號,至少是最基本的保險。
錯誤 3:把帶副作用的運算式傳進巨集。
SQUARE(i++) 是經典案例:引數會被代入兩次,狀態也就改變兩次。即使它在某一次編譯中「看起來能跑」,也不代表這樣的程式碼值得信任。巨集就是複製貼上;而複製貼上不懂什麼叫做「只執行一次」。
錯誤 4:多行巨集沒有包在 do { ... } while(false) 裡。
如果巨集展開後變成多個敘述,它可能會把 if/else 搞壞,然後你會收到像「else without a previous if」這樣的編譯錯誤,明明你的 if 就寫在那裡。do { ... } while(false) 這個慣用法,會把巨集變成單一敘述,把可預測性還給你。
錯誤 5:名稱太泛,且沒有專案前綴。
巨集不理會 namespace,所以像 #define MAX ... 或 #define log ... 這種寫法,就是在主動邀請自己和函式庫、平台標頭,甚至一個月後的你自己的程式碼發生命名衝突。專案前綴(TT_...)加上 ALL_CAPS,是一種非常簡單卻能省下好幾小時除錯時間的紀律。
GO TO FULL VERSION