前置處理器指令(preprocessor directive)用於控制前置處理器行為,可處理C語言本身做不到的事情,或用於定義巨集(Macro),本質上,這也是消弭重複的一種手段。

前置處理器指令是伴隨著C語言的小型語言,嚴格說來並不是C語言的一部份,然而C89開始就規範了前置處理指令,標準程式庫也有不少以預定義巨集提供的功能。因此,想靈活地運用C語言,我們對於前置處理器指令的認識,不可或缺。

以程式產生程式

對C語言來說,巨集是基於前置處理器指令來定義的文字替換規則,若將定義巨集的過程視為程式設計,前置處理器對巨集進行文字替換後,就是產生另一程式碼,以程式產生程式,廣義而言,就是一種meta程式設計(meta-programming)。

meta程式設計目的之一,是可以創造語言本身沒有的語法。例如,C語言本身沒有不定長度引數,基本上,必須透過陣列收集引數來達到相同效果,然而,如果透過stdarg.h定義的va_list、va_start等巨集,就可以令呼叫函式的引數不受數量限制。

但是,談到巨集撰寫、除錯或者是維護,就不是一件容易的事情了,因為要讓巨集能適用諸多場合,往往必須加入更多額外的巨集程式碼,或者是特定環境的條件編譯,令巨集變得複雜。

另一方面,既然巨集到最後也是展開為C語言程式碼,這代表著不使用巨集也能達成任務,因而不少C語言的書籍特意忽略巨集的討論,或者以極少篇幅或簡單範例來帶過,多半只談些swap、max之類的簡單巨集。

不過,面對這類簡單巨集,很容易會有個疑問:為什麼我們不把swap、max定義為函式,而是要定義為巨集?

過去也許有個好理由將swap、pow等定義為巨集:「不會產生函式呼叫,比較有效率」。但是,在不用這麼斤斤計較的場合時,將swap、pow等定義為巨集的價值不大,反而容易因為代入巨集的項目具有副作用等問題,令程式容易出錯。

自定義的語法糖

對不少開發者來說,會覺得巨集撰寫很難,這往往是因為他們想憑空寫出巨集。

實際上,這樣的出發點就是錯的,我們不該憑空寫出巨集,因為,巨集的出發點,跟語法糖(Syntactic Sugar)會出現在語言之中,有著類似的道理,也就是「過去經驗中曾經大量出現這類(冗長)的撰寫模式」。

既然如此,不如在語言中添加某種語法,由編譯器展開為對應的撰寫模式,讓程式更加簡潔,具有更高的可讀性。

不少語言中出現的foreach語法糖,就是個很好的例子,那麼,在C語言中,要怎麼實現foreach?

其實,開發者要自問的是:「迭代陣列時有出現什麼樣的撰寫模式嗎?」察覺這類模式有時並不容易,因為跟函數式設計中察覺高階抽象的概念類似,開發者應令任務單純,模式才會單純而易於抽取。如果迭代陣列時在迴圈中做了太多事情,那是不可能抽出模式來定義巨集的。

也就是說,在抽取巨集前,也必須經過一些重構的過程,令模式單純,如此一來,就可以定義出適用該情境的巨集。

下一步要考慮的是,巨集展開後必須仍是合法的C語言程式碼,因此必須考慮其他場合可能會如何使用巨集,例如,若要能以{}自定義foreach的區塊,既有的巨集該如何調整,才能於展開後仍然是合法的程式碼,這往往不能一步到位,而必須要反覆地重構,才能令巨集靈活而穩固。

相對地,在閱讀巨集的程式碼時,若不瞭解理解最初進行這部份調整的原因,就會覺得巨集難以理解,更別說後續的維護了,若開發者不易理解某個巨集,可以試著自行建立類似的巨集原型,或許就能得到不錯的切入點。

定義好的巨集,本質上就是語法糖,但與語言直接添加的語法糖不同的是,C語言開發者可以透過巨集直接閱讀、調整語法糖展開後的結果,例如,想知道底下的foreach語法糖怎麼來的話,可以參考〈foreach與陣列〉中定義的foreach巨集:

int arr[] = {10, 20, 30, 40, 50};
foreach(int *v, arr) {
printf("%d ", *v);
}

本質是文字替換

就C語言來說,巨集的本質是文字替換。如果經常寫出某C語言片段,而該片段不適合封裝為函式,或者封裝為函式時使用上突冗,才是適用巨集的場合,foreach是其中一例,雖然也可以定義foreach函式,透過函式指標傳遞,以達到部份相同的任務,然而,如果定義為巨集,在foreach迭代陣列的過程當中,若需要引用外部變數等資源時,會比較具有彈性。

如果某個任務,以巨集或函式來實現都能有相同效果,而以文字替換方式來實現,會比函式來得簡單,巨集會是比較好的選擇。例如,如果我們將debug定義為函式,要使用到不定長度引數、字串串接等做法,相對來說,定義為巨集反而容易得多:

#define debug(fmt, ...) { \
fprintf(stderr, "(%s:%d) "fmt"\n", __FILE__, __LINE__, ##__VA_ARGS__); \
}

用簡單的方式來解決需求,這類巨集的定義是比較受到鼓勵的,因為我們不需要考慮太多適用的情境,而可以針對特定需求來撰寫,後續的調整維護也會容易許多。

除了方才的debug巨集外,另一個例子是針對特定函式實現預設引數,甚至關鍵字引數,令函式可以有(10)、foo(10, 20)、foo(.a = 5, .b = 30)等呼叫方式,有興趣的人可以參考〈預設引數〉。

除了單純的一對一文字替換之外,C11提供了_Generic選擇,其本質是類似switch的選擇陳述,不過,會是在編譯時期根據型態來選擇展開的對象,例如,#define cbrt(X) _Generic((X), long double: cbrtl, float: cbrtf, default: cbrt)(X),會根據項目型態而展開為cbrtl、cbrtf等名稱,藉此可在C語言中實現重載的概念,例如,cbrt來呼叫math.h中各個對應型態的函式:

double x = 8.0;
const float y = 3.375;
printf("cbrt(8.0) = %f\n", cbrt(x));
printf("cbrtf(3.375) = %f\n", cbrt(y));

本質上也是消弭重複

有興趣的話,你可以試著挑戰以巨集實現現代語言的一些特性,例如,try-catch-finally、throw等,都是可能實現的(可參考〈例外處理〉),然而,最好的方式並非憑空創造,而是觀察是否有重複流程出現,進一步重構為簡單的模式,就像foreach的情況,再來創造新語法比較好。

畢竟巨集的本質上就是在消弭重複,無根據地創造巨集,就跟無根據地進行抽象設計一樣,最後只會令巨集如同浮誇的架構,有如空中樓閣,難以使用與維護!

作者簡介


Advertisement

更多 iThome相關內容