在C/C++項目中合理使用宏

C++項目中常使用宏來做跨平臺、功能實現隔離、變量定義的功能,這篇文章來討論下是否所有情況下都適合用宏

小D的故事

程序員小D接到一個任務,需要給同事A提供一個複雜公式的實現。輸入爲一組參數,輸出一個計算結果。大致如下:

double computeSomeThing(double paramA,double paramB,double paramC);

小D很快完成了。過了幾天同事A又來找他,說現在需要提升該函數的性能,建議改爲在float類型上,用一些SIMD指令。且同事A表示不是很願意修改接口。於是小D在考慮以下兩點後決定用一個宏把原來double的實現和float的實現分開來。

  1. 上層需求變動性比較大,說不定哪天又要用double了。所以還是保留double類型的實現
  2. 用宏把兩份代碼隔開來,互相不影響比較省事

於是代碼就變成了這樣:

double computeSomeThing(double paramA,double paramB,double paramC)
{
	#ifdef _USE_DOUBLE_
	// do something in double
	#else
	// convert dobule to float 
	// do something in float
	// convert float to double 
	#endif
}

同事A很滿意,因爲他只要替換一下.so或.a即可,代碼層不需要改動。於是和小D合作開發了很多這樣的函數,並且都有float和double兩種實現。在對性能要求高的時候要求小D提供float版本;性能要求低,精度要求的時候要求小D提供double版本。

此時小D會在出庫的時候感到一絲不方便。第一,版本號中需要區分float和double版本。
第二,因爲用宏隔開,切換兩個版本的時候需要重新編譯,而代碼量很多所以編譯時間很長,但這些都是能克服的。
直到有一天同事小B的模塊也需要這個庫,並且小A和小B的模塊要組合起來給小C用,最要命的是小A和小B的模塊分別要用float版本和double版本。所以此時應該提供float版本so還是提供double版本so呢。

問題分析

在上面的場景中,小D作爲一個基礎庫的提供者不應該因爲同事不願意修改接口或者圖方便用宏去隔離功能,使得一個接口有了二義性。比較合適的一種做法是,再提供一個控制選擇變量,來選擇用哪種實現,即允許運行時決定用float還是double版本。

double computeSomeThing(double paramA,double paramB,double paramC,bool isFast);

或者小A就是不願意改接口(考慮實際項目中,接口參數複雜且調用分散在各處),那麼也可以通過增加接口實現。

double computeSomeThing(double paramA,double paramB,double paramC);
void setFast(bool isFast);

下面的情況用宏做隔離就是比較合理的選擇。
比如一套代碼要分別運行在linux和windows上,依賴的頭文件、部分基礎函數接口都是有區別的。此時用宏去隔離就比較合理。因爲這兩個版本在運行時永遠不會同時出現。除了平臺差異性外,版本管理也可以用宏來做隔離。比如opencl 1.2和opencl 2.0版本相比較的話,2.0版本中新增了SVM相關的接口。當一個opencl程序未來可能運行在1.2版本的設備和2.0版本的設備上時。可以用宏來選擇是否屏蔽掉SVM接口。因爲2.0的接口運行在1.2的設備上時,無法從環境中獲取2.0新增的接口實現導致程序跑不起來(1.2的相關so中沒有SVM函數實現)。不過這個問題用宏來處理也不是最優的,使用dlopen可以有更靈活的實現。

總結

對於做基礎庫提供給很多人使用的同學,當用宏隔開的代碼有可能會同時運行在一個環境時建議改爲運行時選擇走哪條分支。但肯定互相不兼容的時候就放心的用宏吧,比如跨操作系統。另外提一下,對於有很多代碼的大項目用宏的時候也要慎重考慮一下,不要動不動就用宏去做一些功能開關,因爲編譯時間太長是很影響效率的。
比如有以下宏定義:

#define _OPEN_LOG_
#ifdef _OPEN_LOG_
	#define LOG_PRINT(...) printf(...)
#else
	#define LOG_PRINT(...) 
#endif

開發階段代碼中到處插着LOG_PRINT的使用,發佈時關閉打印又是一波整個項目重新編譯。再多來幾個這種功能,每次切換又是整個項目重新編譯,非常煩人。可以用函數指針代替:

typedef void (*LogPrint)(const char * pstrMsg);
LogPrint g_LogPrint;

void LogPrint_Imp(const char *pstrMsg)
{
	printf("%s\n",pstrMsg);
	return;
}

void LogPrint_Empty(const char *pstrMsg)
{
	return;
}

int main(int argc,char **argv)
{
	// 此處對日誌功能進行開關
	g_LogPrint = LogPrint_Imp ; 
	//g_LogPrint = LogPrint_Empty ; 
	// .....
}

void someFun()
{
	g_LogPrint("in someFun"); //到底打印還是不打印,運行時決定
}

在這個例子中,關閉日誌時編譯器只會對main函數所在的文件進行重新編譯,就不用費時費力的重新編譯整個項目了。而且還可以把g_LogPrint的賦值的行爲通過接口開放到上層,由調用者決定是否需要打開log。

再舉個例子,有些人喜歡項目中各個代碼模塊中用到的參數提到一個頭文件中,然後各個.c都包含這個頭文件。就像這樣:

// GobalParam.h
#ifndef XX_XX
#define XX_XX

#define DETECTION_MAX 100
#define INPUT_WIDTH_MAX 4096
#define INPUT_HEIGHT_MAX 4096
// 諸如此類很多宏

#endif

我個人覺得下面這種實現更好

// GobalParam.h
#ifndef XX_XX
#define XX_XX

extern const int  DETECTION_MAX;
extern const int  INPUT_WIDTH_MAX ;
extern const int  INPUT_HEIGHT_MAX ;
// 在某個.c或.cpp中賦值 :const int  INPUT_HEIGHT_MAX = 100;

#endif

這樣你對某個參數修改的時候,就不用眼巴巴的等着所有包含此頭文件的編譯模塊重新編譯了。

以上爲個人經驗,如有錯誤或未考慮完全的地方歡迎留言討論,望不吝賜教。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章