提高程式碼逼格的利器:巨集定義-從入門到放棄

sewain發表於2021-02-06

道哥的第 019 篇原創

一、前言

一直以來,我都有這樣一種感覺:當我學習一個新領域的知識時,如果其中的某個知識點在剛開始接觸時,我感覺比較難懂、不好理解,那麼以後不論我花多長時間去研究這個知識點,心裡會一直認為該知識點比較難,也就是說第一印象特別的重要。

就比如 C 語言中的巨集定義,好像跟我犯衝一樣,我一直覺得巨集定義是 C 語言中最難的部分,就好比有有些小夥伴一直覺得指標是 C 語言中最難的部分一樣。

巨集的本質就是程式碼生成器,在前處理器的支援下實現程式碼的動態生成,具體的操作通過條件編譯和巨集擴充套件來實現。我們先在心中建立這麼一個基本的概念,然後通過實際的描述和程式碼來深入的體會:如何駕馭巨集定義。

所以,今天我們就來把巨集定義所有的知識點進行彙總、深挖,希望經過這篇文章,我能夠擺脫心理的這個魔障。看完這篇總結文章後,我相信你也一定能夠對巨集定義有一個總體、全域性的把握。

二、前處理器的操作

1. 巨集的生效環節:預處理

一個 C 程式在編譯的時候,從原始檔開始到最後生成二進位制可執行檔案,一共經歷 4 個階段:

我們今天討論的內容就是在第一個環節:預處理,由前處理器來完成這個階段的工作,包括下面這 4 項工作:

  1. 檔案引入(#include);
  2. 條件編譯(#if..#elif..#endif);
  3. 巨集擴充套件(macro expansions);
  4. 行控制(line control)。

2. 條件編譯

一般情況下,C 語言檔案中的每一行程式碼都是要被編譯的,但是有時候出於對程式程式碼優化的考慮,希望只對其中的一部分程式碼進行編譯,此時就需要在程式中加上條件,讓編譯器只對滿足條件的程式碼進行編譯,將不滿足條件的程式碼捨棄,這就是條件編譯

簡單的說:就是前處理器根據我們設定的條件,對程式碼進行動態的處理,把有效的程式碼輸出到一箇中間檔案,然後送給編譯器進行編譯。

條件編譯基本上在所有的專案程式碼中都被使用到,例如:當你需要考慮下面的幾種情況時,就一定會使用條件編譯

  1. 需要把程式編譯成不同平臺下的可執行程式;
  2. 同一套程式碼需要執行在同一平臺上的不同功能產品上;
  3. 在程式中存在著一些測試目的的程式碼,不想汙染產品級的程式碼,需要遮蔽掉。

這裡舉 3 個例子,在程式碼中經常看到的關於條件編譯:

示例1:用來區分 C 和 C++ 程式碼

#ifdef __cplusplus 
extern "C" { 
#endif 
 
void hello();
 
#ifdef __cplusplus 
} 
#endif 

這樣的程式碼幾乎在每個開源庫中都可能見到,主要的目的就是 C 和 C++ 混合程式設計,具體來說就是:

  1. 如果使用 gcc 來編譯,那麼巨集 __cplusplus 將不存在,其中的 extern "C" 將會被忽略;
  2. 如果使用 g++ 來編譯,那麼巨集 __cplusplus 就存在,其中的 extern "C" 就發生作用,編譯出來的函式名 hello 就不會被 g++ 編譯器改寫,因此就可以被 C 程式碼來呼叫;

示例2:用來區分不同的平臺

#if defined(linux) || defined(__linux) || defined(__linux__)
    sleep(1000 * 1000); // 呼叫 Linux 平臺下的庫函式
#elif defined(WIN32) || defined(_WIN32)
    Sleep(1000 * 1000); // 呼叫 Windows 平臺下的庫函式(第一個字母是大寫)
#endif

那麼,這些 linux, __linux, __linux__, WIN32, _WIN32 是從哪裡來的呢?我們可以認為是編譯目標平臺(作業系統)為我們預先準備好的。

示例3:在編寫 Windows 平臺下的動態庫時,宣告匯出和匯入函式

#if defined(linux) || defined(__linux) || defined(__linux__)
    #define LIBA_API 
#else
	#ifdef LIBA_STATIC
		#define LIBA_API
	#else
	    #ifdef LIBA_API_EXPORTS
	        #define LIBA_API __declspec(dllexport)
	    #else
	        #define LIBA_API __declspec(dllimport)
	    #endif
	#endif
#endif

LIBA_API void hello();

這段程式碼是直接從我之前在 B 站錄製的一個小視訊裡的示例拿過來的,當時主要是演示如何如何在 Linux 平臺下使用 make 和 cmake 構建工具來編譯,後來又小夥伴讓我在 Windows 平臺下也用 make 和 cmake 來構建,所以就寫了上面這段巨集定義。

  1. 在使用 MSVC 編譯動態庫時,需要在編譯選項(Makefle 或者 CMakeLists.txt)中定義巨集 LIBA_API_EXPORTS,那麼匯出函式 hello 的最前面的巨集 LIBA_API 就會被替換成:__declspec(dllexport),表示匯出操作;
  2. 在編譯應用程式的時候,使用動態庫,需要 include 動態庫的標頭檔案,此時在編譯選項中不需要定義巨集 LIBA_API_EXPORTS,那麼 hello 函式最前面的 LIBA_API 就會被替換成 __declspec(dllimport),表示匯入操作;
  3. 補充一點:如果使用靜態庫,編譯選項中不需要任何巨集定義,那麼巨集 LIBA_API 就為空。

3. 平臺預定義的巨集

上面已經看到了,目標平臺會為我們預先定義好一些巨集,方便我們在程式中使用。除了上面的作業系統相關巨集,還有另一類巨集定義,在日誌系統中被廣泛的使用:

FILE:當前原始碼檔名;
LINE:當前原始碼的行號;
FUNCTION:當前執行的函式名;
DATE: 編譯日期;
TIME: 編譯時間;

例如:

printf("file name: %s, function name = %s, current line:%d \n", __FILE__, __FUNCTION__, __LINE__);

三、巨集擴充套件

所謂的巨集擴充套件就是程式碼替換,這部分內容也是我想表達的主要內容。巨集擴充套件最大的好處有如下幾點:

  1. 減少重複的程式碼;
  2. 完成一些通過 C 語法無法實現的功能(字串拼接);
  3. 動態定義資料型別,實現類似 C++ 中模板的功能;
  4. 程式更容易理解、修改(例如:數字、字串常亮);

我們在寫程式碼的時候,所有使用巨集名稱的地方,都可以理解為一個佔位符。在編譯程式的預處理環節,這些巨集名將會被替換成巨集定義中的那些程式碼段,注意:僅僅是單純的文字替換

1. 最常見的巨集

為了方便後面的描述,先來看幾個常見的巨集定義:

(1) 資料型別的定義

#ifndef BOOL
    typedef char BOOL;
#endif

#ifndef TRUE
    #define TRUE
#endif

#ifndef FALSE
    #define FALSE
#endif

在資料型別定義中,需要注意的一點是:如果你的程式需要用不同平臺下的編譯器來編譯,那麼你要去查一下所使用的編譯器對這些巨集定義控制的資料型別是否已經定義了。例如:在 gcc 中沒有 BOOL 型別,但是在 MSVC 中,把 BOOL 型別定義為 int 型。

(2) 獲取最大、最小值

#define MAX(a, b)    (((a) > (b)) ? (a) : (b))
#define MIN(a, b)    (((a) < (b)) ? (a) : (b))

(3) 計算陣列中的元素個數

#define ARRAY_SIZE(x)    (sizeof(x) / sizeof((x)[0]))

(4) 位操作

#define BIT_MASK(x)         (1 << (x))
#define BIT_GET(x, y)       (((x) >> (y)) & 0x01u)
#define BIT_SET(x, y)       ((x) | (1 << (y)))
#define BIT_CLR(x, y)       ((x) & (~(1 << (y))))
#define BIT_INVERT(x, y)    ((x) ^ (1 << (y)))

2. 與函式的區別

從上面這幾個巨集來看,所有的這些操作都可以通過函式來實現,那麼他們各有什麼優缺點呢?

通過函式來實現:

  1. 形參的型別需要確定,呼叫時對引數進行檢查;
  2. 呼叫函式時需要額外的開銷:操作函式棧中的形參、返回值等;

通過巨集來實現:

  1. 不需要檢查引數,更靈活的傳參;
  2. 直接對巨集進行程式碼擴充套件,執行時不需要函式呼叫;
  3. 如果同一個巨集在多處呼叫,會增加程式碼體積;

還是舉一個例子來說明比較好,就拿上面的比較大小來說吧:

(1) 使用巨集來實現

#define MAX(a, b)    (((a) > (b)) ? (a) : (b))

int main()
{
    printf("max: %d \n", MAX(1, 2));
}

(2) 使用函式來實現

int max(int a, int b)
{
    if (a > b)
        return a;
    return b;
}

int main()
{
    printf("max: %d \n", max(1, 2));
}

除了函式呼叫的開銷,其它看起來沒有差別。這裡比較的是 2 個整型資料,那麼如果還需要比較 2 個浮點型資料呢?

  1. 使用巨集來呼叫:MAX(1.1, 2.2);一切 OK;
  2. 使用函式呼叫:max(1.1, 2.2); 編譯報錯:型別不匹配。

此時,使用巨集來實現的優勢就體現出來了:因為巨集中沒有型別的概念,呼叫者傳入任何資料型別都可以,然後在後面的比較操作中,大於或小於操作都是利用了 C 語言本身的語法來執行。

如果使用函式來實現,那麼就必須再定義一個用來操作浮點型的函式,以後還有可能比較:char 型、long 型資料等等。

C++ 中,這樣的操作可以通過引數模板來實現,所謂的模板也是一種程式碼動態生成機制。當定義了一個函式模板後,根據呼叫者的實參,來動態產生多個函式。例如定義下面這個函式模板:

template<typename T> T max(T a, T b){
    if (a > b) 
        return a;
    return b;
}

max(1, 2);     // 實參是整型
max(1.1, 2,2); // 實參是浮點型

當編譯器看到 max(1, 2) 時,就會動態生成一個函式 int max(int a, int b) { ... }

當編譯器看到 max(1.1, 2.2) 時,又會動態生成另一個函式 float max(float a, float b) { ... }

所以,從程式碼的動態生成角度看,巨集定義和 C++ 中的模板引數有點神似,只不過巨集定義僅僅是程式碼擴充套件而已。

下面這個例子也比較不錯,利用巨集的型別無關,來動態生成結構體

#define VEC(T)          \
    struct vector_##T { \
        T *data;       \
        size_t size;    \
    };

int main()
{
    VEC(int)   vec_1 = { .data = NULL, .size = 0 };
    VEC(float) vec_2 = { .data = NULL, .size = 0 };
}

這個例子中用到了 ##,下面會解釋這個知識點。在前面的例子中,巨集的引數傳遞的都是一些變數,而這裡傳遞的巨集引數是資料型別通過巨集的型別無關性,達到了“動態”建立結構體的目的:

struct vector_int {
    int *data;
    size_t size;
}

struct vector_float {
    float *data;
    size_t size;
}

這裡有一個陷阱需要注意:傳遞的資料型別中不能有空格,如果這樣使用: VEC(long long),那替換之後得到:

struct vector_long long {  // 語法錯誤
    long long *data;
    size_t size;
}

四、符號:# 與 ##

這兩個符號在程式設計中的作用也是非常巧妙,誇張的說一句:在任何框架性程式碼中,都能見到它們的身影!
作用如下:

  1. :把引數轉換成字串;

  2. :連線引數。

1. #: 字串化

直接看最簡單的例子:

#define STR(x) #x

printf("string of 123: %s \n", STR(123));

傳入的是一個數字 123,輸出的結果是字串 “123”,這就是字串化。

2. ##:引數連線

把巨集中的引數按照字元進行拼接,從而得到一個新的識別符號,例如:

#define MAKE_VAR(name, no) name##no

int main(void)
{
    int MAKE_VAR(a, 1) = 1; 
    int MAKE_VAR(b, 2) = 2; 

    printf("a1 = %d \n", a1);
    printf("b2 = %d \n", b2);
    return 0;
}

當呼叫巨集 MAKE_VAR(a, 1) 後,符號 ## 把兩側的 name 和 no 首先替換為 a 和 1,然後連線得到 a1。然後在呼叫語句中前面的 int 資料型別就說明了 a1 是一個整型資料,最後初始化為 1。

五、可變引數的處理

1. 引數名的定義和使用

巨集定義的引數個數可以是不確定的,就像呼叫 printf 列印函式一樣,在定義的時候,可以使用三個點(...)來表示可變引數,也可以在三個點的前面加上可變引數的名稱。

如果使用三個點(...)來接收可變引數,那麼在使用的時候就需要使用 VA_ARGS 來表示可變引數,如下:

#define debug1(...)      printf(__VA_ARGS__)

debug1("this is debug1: %d \n", 1);

如果在三個點(...)的前面加上了一個引數名,那麼在使用時就一定要使用這個引數名,而不能使用 VA_ARGS 來表示可變引數,如下:

#define debug2(args...)  printf(args)

debug1("this is debug2: %d \n", 2);

2. 可變引數個數為零的處理

看一下這個巨集:

#define debug3(format, ...)      printf(format, __VA_ARGS__)

debug3("this is debug4: %d \n", 4);

編譯、執行都沒有問題。但是如果這樣來使用巨集:

debug3("hello \n");

編譯的時候,會出現錯誤: error: expected expression before ‘)’ token為什麼呢?

看一下巨集擴充套件之後的程式碼(__VA_ARGS__為空):

printf("hello \n",);

看出問題了吧?在格式化字串的後面多了一個逗號!為了解決問題,前處理器給我們提供了一個方法:通過 ## 符號把這個多餘的逗號給自動刪掉。於是巨集定義改成下面這樣就沒有問題了。

#define debug3(format, ...)     printf(format, ##__VA_ARGS__)

類似的,如果自己定義了可變引數的名字,也在前面加上 ##,如下:

#define debug4(format, args...)  printf(format, ##args)

六、奇思妙想的巨集

巨集擴充套件的本質就是文字替換,但是一旦加上可變引數(__VA_ARGS__)和 ## 的連線功能,就能夠變化出無窮的想象力

我一直堅信,模仿是成為高手的第一步,只有見多識廣、多看、多學習別人是怎麼來使用巨集的,然後拿來為己所用,按照“先僵化-再優化-最後固化”這個步驟來訓練,總有一天你也能成為高手。

這裡我們就來看幾個利用巨集定義的巧妙實現。

1. 日誌功能

在程式碼中新增日誌功能,幾乎是每個產品的標配了,一般見到最普遍的是下面這樣的用法:

#ifdef DEBUG
    #define LOG(...) printf(__VA_ARGS__)
#else
    #define LOG(...) 
#endif

int main()
{
    LOG("name = %s, age = %d \n", "zhangsan", 20);
    return 0;
}

在編譯的時候,如果需要輸出日誌功能就傳入巨集定義 DEBUG,這樣就能列印輸出除錯資訊,當然實際的產品中需要寫入到檔案中。如果不需要列印語句,通過把列印日誌資訊那條語句定義為空語句來達到目的。

換個思路,我們還可以通過條件判斷語句來控制列印資訊,如下:

#ifdef DEBUG
    #define debug if(1)
#else
     #define debug if(0)
#endif

int main()
{
    debug {
        printf("name = %s, age = %d \n", "zhangsan", 20);
    }
    return 0;
}

這樣控制日誌資訊的看到的不多,但是也能達到目的,放在這裡只是給大家開闊一下思路。

2. 利用巨集來迭代每個引數

#define first(x, ...) #x
#define rest(x, ...)  #__VA_ARGS__

#define destructive(...)                              \
    do {                                              \
        printf("first is: %s\n", first(__VA_ARGS__)); \
        printf("rest are: %s\n", rest(__VA_ARGS__));  \
    } while (0)

int main(void)
{
    destructive(1, 2, 3);
    return 0;
}

主要的思想就是:每次把可變引數 VA_ARGS 中的第一個引數給分離出來,然後把後面的引數再遞迴處理,這樣就可以分離出每一個引數了。我記得侯傑老師在 C++ 的視屏中,利用可變引數模板這個語法,也實現了類似的功能。

剛才在有道筆記中居然找到了侯傑老師演示的程式碼,熟悉 C++ 的小夥伴可以研究下下面這段程式碼:

// 遞迴的最後一次呼叫
void myprint()
{
}

template <typename T, typename... Types>
void myprint(const T &first, const Types&... args)
{
    std::cout << first << std::endl;
    std::cout << "remain args size = " << sizeof...(args) << std::endl;
   
    // 把其他引數遞迴呼叫
	myprint(args...);
}

int main()
{
    myprint("aaa", 7.5, 100);
    return 0;
}

3. 動態的呼叫不同的函式

// 普通的列舉型別
enum {
  ERR_One,
  ERR_Two,
  ERR_Three
};

// 利用 ## 的拼接功能,動態產生 case 中的比較值,以及函式名。
#define TEST(no) \
    case ERR_##no: \
      Func_##no(); \
      break;

void Func_One()
{
    printf("this is Func_One \n");
}

void Func_Two()
{
    printf("this is Func_Two \n");
}

void Func_Three()
{
    printf("this is Func_Three \n");
}

int main()
{
    int c = ERR_Two;
    switch (c) {
        TEST(One);
        TEST(Two);
        TEST(Three);
    };

    return 0;
}

在這個例子中,核心在於 TEST 巨集定義,通過 ## 拼接功能,構造出 case 分支的比較目標,然後動態拼接得到對應的函式,最後呼叫這個函式。

4. 動態建立錯誤編碼與對應的錯誤字串

這也是一個非常巧妙的例子,利用了 #(字串化) 和 ##(拼接) 這 2 個功能來動態生成錯誤編碼碼和相應的錯誤字串:

#define MY_ERRORS     \
    E(TOO_SMALL)      \
    E(TOO_BIG)        \
    E(INVALID_VARS)

#define E(e) Error_## e,
typedef enum {
    MY_ERRORS
} MyEnums;
#undef E

#define E(e) #e,
const char *ErrorStrings[] = {
    MY_ERRORS
};
#undef E

int main()
{
    printf("%d - %s \n", Error_TOO_SMALL, ErrorStrings[0]);
    printf("%d - %s \n", Error_TOO_BIG, ErrorStrings[1]);
    printf("%d - %s \n", Error_INVALID_VARS, ErrorStrings[2]);

    return 0;
}

我們把巨集展開之後,得到一個列舉型別和一個字串常量陣列:

typedef enum {
    Error_TOO_SMALL,
    Error_TOO_BIG,
    Error_INVALID_VARS,
} MyEnums;

const char *ErrorStrings[] = {
    "TOO_SMALL",
    "TOO_BIG",
    "INVALID_VARS",
};

巨集擴充套件之後的程式碼是不是很簡單啊。編譯、執行結果如下:

0 - TOO_SMALL 
1 - TOO_BIG 
2 - INVALID_VARS 

七、總結

有些人對巨集愛之要死,多到濫用的程度;而有些人對巨集恨之入骨,甚至用上了邪惡(evil)這個詞!其實巨集對於 C 來說,就像菜刀對於廚師和歹徒一樣:用的好,可以讓程式碼結構簡潔、後期維護特別方便;用的不好,就會引入晦澀的語法、難以除錯的 Bug。

對於我們開發人員來說,只要在程式的執行效率、程式碼的可維護性上做好平衡就可以了。


不吹噓,不炒作,不浮誇,認真寫好每一篇文章!
歡迎轉發、分享給身邊的技術朋友,道哥在此表示衷心的感謝! 轉發的推薦語已經幫您想好了:

道哥總結的這篇總結文章,寫得很用心,對我的技術提升很有幫助。好東西,要分享!


【原創宣告】

作者:道哥(公眾號: IOT物聯網小鎮)
知乎:道哥
B站:道哥分享
掘金:道哥分享
CSDN:道哥分享

轉載:歡迎轉載,但未經作者同意,必須保留此段宣告,必須在文章中給出原文連線。

關注+星標公眾號,不錯過最新文章



推薦閱讀

利用C語言中的setjmp和longjmp,來實現異常捕獲和協程
C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
一步步分析-如何用C實現物件導向程式設計
原來gdb的底層除錯原理這麼簡單
關於加密、證書的那些事
深入LUA指令碼語言,讓你徹底明白除錯原理

相關文章