C語言雜談01—如何理解條件編譯


前言

由於地區翻譯關係,有些書籍將macro翻譯成"巨集",有些翻譯成"宏",為了避免混淆(我自己),所以文章內容會以英文名macro來代替中文譯名

甚麼是條件編譯

條件編譯就是根據已經定義的macro進行選擇性判斷的語句,它會在compiler進行編譯前完成,主要由預處理器負責

預處理器會將條件編譯指令的結果告訴compiler,讓他去編譯指定區段的程式碼。條件編譯指令可能會出現在程式的任何一個位置,端看使用方法,例如下方這個簡單的程式範例就含有條件編譯:

#include <stdio.h>

/*若a沒有被定義就定義它*/
#ifndef a
#define a 1
#endif

int main(){
    #if (a == 1)
        printf("a == 1\n");
    #else
        printf("a != 1\n");
    #endif

    return 0;
}

和一般的條件語句不同的是,條件編譯在compile之前就已經決定,相反的,正常的條件語句(if, else if, else…)需要我們在執行時(run time)才能進行判斷

也就是說條件編譯語句可以讓compiler知道那些程式碼區段需要編譯,那些可以直接捨去;而正常的條件語句因為需要在執行時依照變數值去判斷執行區塊,所以無論如何整個邏輯區塊都會被全部編譯

下圖我們看到一個.c檔被編譯成可執行檔的過程,綠色區塊就是條件編譯主要涉及部分。條件編譯有點超前部署的味道,它會決定誰會被包含、編譯、忽略,它不被編譯器編譯,想當然也不屬於C/C++範疇

條件編譯種類

#if, #elif, #defined

#if, #elif利用後方的常數表達式(constant-expression)來判斷程式碼區段是否需要被包含

例如下面簡單的程式碼片段,因為test被定義成1,這個條件恰好吻合第一個區段,所以會編譯並執行#if#else之間區段

#include <stdio.h>

#define test 1

int main(){
   #if (test == 1)
       printf("Macro test exist...");
   #else
        printf("Macro test is not defined...");
   #endif
}

輸出結果:

Macro test exist...

#if後的常數表達式可以使用一元運算子進行判斷,也可以使用邏輯運算子結合多個判斷式。當判斷條件超過兩組時可以使用#elif, #else,和一般的if-else if-else語句沒什麼分別

#include <stdio.h>

#define test1 10
#define test2 1

int main(){
   #if (test1 > 8) && (test1 < 15) && (test2 > 0)
       printf("Macro test meet the requirement");
   #elif(test1 > 15)
       printf("Macro test meet the requirement, but way too big");
   #else
       printf("Macro test doesn't meet the requirement");
   #endif
}

輸出結果:

Macro test meet the requirement

切記#if後方的判斷式要加上小括號()

#if還可以加上條件編譯語句defined(),它用來判斷一個macro是否被定義。例如我們把上面的程式碼稍微改寫一下:

#include <stdio.h>

#define test1 10
// #define test2 1

int main(){
   #if (test1 > 8) && (test1 < 15) && defined(test2)
       printf("Macro test meet the requirement");
   #elif(test1 > 15)
       printf("Macro test meet the requirement, but way too big");
   #else
       printf("Macro test doesn't meet the requirement");
   #endif
}

輸出結果:

Macro test doesn't meet the requirement

由於test2被我們註解掉,所以實際上它沒有被定義,所以最後輸出結果沒能滿足#if#elif條件

一些常見問題
使用#if#defined的時機其實有點不同,前者單獨使用必須搭配表達式,對macro的進行判斷;後者僅用來判斷macro是否被定義

假設我們想用#if來代替#defined判斷一個test2是否被定義:

#include <stdio.h>

#define test1 1
#define test2 2

int main(){
    #if defined(test1) && (test2)
        printf("success\n");
    #else
        printf("fail\n");
    #endif

    return 0;
}

輸出結果為:

success

test1, test2均判斷成功。但我們修改一下test2的定義值,結果會大為不同:

#include <stdio.h>

#define test1 1
#define test2 0

int main(){
    #if defined(test1) && test2
        printf("success\n");
    #else
        printf("fail\n");
    #endif

    return 0;
}

這時的輸出結果變成:

fail

與我們期望的判斷功能大相逕庭,但至少還能打印輸出。再次對test2的定義值進行修改:

#include <stdio.h>

#define test1 1
#define test2

int main(){
    #if defined(test1) && (test2)
        printf("success\n");
    #else
        printf("fail\n");
    #endif

    return 0;
}

執行時得到compiler的報錯missing expression between '(' and ')',因為test2若沒有填入參數,會被解讀為空字串,這個空字串不能用表達式進行判斷,所以盡管為上述程式碼加上判斷(test2 > 10)也是會發生錯誤error: operator '>' has no left operand

如果單純沒有定義macro,在#if判斷式中會傳入0,這點有點不同

#include <stdio.h>

// #define test 1

int main(){
    #if (test == 0)
        printf("test equal to 0...");         
    #elif (test > 10)
        printf("test greater than 10...");
    #elif (test <= 10)
        printf("test lesser than or equal to 10...");
    #else
        printf("test is not defined");  
    #endif

    return 0;
}

輸出結果:

test equal to 0...

我們明明沒有定義test,輸出結果確判斷它等於0,這是因為預處理器將未定義macro替換成0的關係

從上面一連串的案例可以發現,若是要判斷一個macro是否被定義,一定要在#if後面加上#defined()指令。另外使用表達式判斷前應先判斷macro是否存在

用來條件編譯的macro避免定義成小數點

#ifdef, #ifndef

其實#ifdef就是#if defined()#ifndef就是#if !defined(),使用目的當然也是用來判斷macro是否被定義,它的使用邏輯如下:

  • 若macro有定義:
    • #ifdef()會判斷為true
    • #ifdef()會判斷為false
  • 若macro沒有定義
    • #ifdef()會判斷為false
    • #ifndef()會判斷為true

舉例來說:

#include <stdio.h>

#define test1 1
#define test2 0
int main(){
    #ifndef test1 // #if !defined(test1)
        printf("test1 is not defined...\n");         
    #else
        printf("test1 is defined...\n");   
    #endif

    #ifdef test2 // #if defined(test2)
        printf("test2 is defined...\n");         
    #else
        printf("test2 is not defined...\n");   
    #endif

    return 0;
}

輸出結果:

test1 is defined...
test2 is defined...

#else

#else語句是條件編譯判斷的擴充。當#if, #elif的判斷均為否,則會執行#else#endif之間的程式碼區段:

#include <stdio.h>

#define test 100

#if (test > 500)
    #define MAX 75
#elif (test > 300)
    #define MAX 50
#elif (test > 150)
    #define MAX 35
#else
    #define MAX 10
#endif

使用在#ifndef, #ifdef則相對簡單,因為它們只有存在與不存在兩個狀態:

#include <stdio.h>

#ifdef test
    #define MAX 75
#else
    #define MAX 50
#endif

#endif

#endif用來結束條件編譯區段,每完成一個條件判斷結構就需要使用一個#endif語句,以下為偽代碼範例,每一個完整的條件編譯語句都需要#endif來收尾:

/*條件編譯*/
#if (...)
    #if (...)
        // do-something
    #else
        // do-something
    #endif
#endif

巢狀結構

條件編譯和一般的條件語句一樣可以巢狀嵌套。

我們假定該程式碼會依照定義來決定該執行哪種作業系統平台的執行緒初始化。使用巢狀結構有助於我們細分目標,你可以看看它的結構,其實跟普通的條件語句根本是同一個媽生的:

#if defined(Linux)
    #ifdef ubuntu
        ubuntu_thread_init();
    #endif /*ubuntu*/
    #ifdef centos
        centos_thread_init();
    #endif /*centos*/
#elif defined(MS)
    #ifdef WIN10
        windows_10_thread_init();
    #endif /*WIN10*/
    #ifdef WIN7
        windows_7_thread_init();
    #endif /*WIN7*/
#endif

空定義

空定義顧名思義就是沒有為macro定義任何數值:

#define test 

空定義是一個甚麼都沒有的macro,預處理器不會將任何參數替換給使用它的程式碼,它代表一個空字串:

#include <stdio.h>
#define test

int main(){
    test test test test test test test
    test printf("empty macro!\n"); test
    test test test test test test test

    return 0;
}

輸出結果

empty macro!

但你以為它沒甚麼用處嗎?空定義雖然不代表任何值,但它可以被#if defined()#ifdef等條件編譯捕捉

換句話說有一些根本不需要替換定義值的場景,使用空定義還是非常有用的,例如接下來將要介紹的標頭守衛功能

標頭守衛

首先科普一下#include這條語句的功能,預處理器會將包含的標頭檔內容全部複製過來,然後把#include這條語句刪除

不過這中間產生了一個問題,若是主程式重複#include同一個標頭檔會發生甚麼事?,例如下面這個程式:

/*test1.h*/
#define SerialName      "my_test_0001\n"
#define SW_version          "V.1.3.0\n"
#define FW_version          "V.1.3.0\n"

typedef enum
{
    socket_init = 0,
    socket_connecting,
    socket_connected,
    socket_close
}socket_process;

// ...
/*test2.h*/
#include "test1.h"
#include <stdint.h>

#define MAX_SOCKET_NUMBER 4

typedef struct{
    uint8_t family;
    uint8_t port;
    uint8_t* addr;
    socket_process socket_information;
}socket_info[MAX_SOCKET_NUMBER];

// ...
/**
 * main.c
 */
#include "test1.h"
#include "test2.h"

int main(){
    // do-something
    return 0;
}

上述這個程式的問題在於,test1.h在main.c中被包含,同時在包含test2.h的時候又被嵌套包含,相同標頭檔如果被重複包含2次,實際上它的內容會被編譯2次,不僅浪費資源,又可能會發生錯誤

例如編譯器會提醒你"xxx" has already been declared in the xxx file或類似的訊息,就是發生重複編譯

這就是標頭守衛(header guards)該挺身而出的時候,它的目的就是防止標頭檔內容被重複編譯,例如各種類型的數據、結構體數據、靜態變數等等

回到原先的程式範例,我們來改寫它:

/*test1.h*/
#ifndef __TEST1_H
#define __TEST1_H

#define SerialName      "my_test_0001\n"
#define SW_version          "V.1.3.0\n"
#define FW_version          "V.1.3.0\n"

typedef enum
{
    socket_init = 0,
    socket_connecting,
    socket_connected,
    socket_close
}socket_process;

// ...

#endif /*__TEST1_H*/
/*test2.h*/
#ifndef __TEST2_H
#define __TEST2_H

#include "test1.h"
#include <stdint.h>

#define MAX_SOCKET_NUMBER 4

typedef struct{
    uint8_t family;
    uint8_t port;
    uint8_t* addr;
    socket_process socket_information;
}socket_info[MAX_SOCKET_NUMBER];

// ...

#endif /*__TEST2_H*/
/**
 * main.c
 */
#include "test1.h"
#include "test2.h"

int main(){
    // do-something
    return 0;
}

__TEST1_H稱為前置處理變數,通常以__作為開頭,英文字母均以大寫表示,這種特殊寫法目的是避免使用者
也定義了相同名稱的macro因而造成錯誤

整個流程如下圖所示,第一次包含test1.h時由於沒有定義過__TEST1_H,會成功進入ifndef條件編譯區段,並複製內容

第二次重複包含test1.h發生在包含test2.h的時候,由於test1.h__TEST1_H已經在上一次定義過了,因此ifndef條件編譯區塊會被忽略,成功防止重複包含

因為標頭首位中間撰寫的程式碼有可能會很長,其中也不乏會出現其他條件編譯程式碼,因此最好在#endif後方加上註解__TEST1_H來體醒開發者這個#endif屬於標頭守衛區段

切割特性

切割特性簡單來說就是只執行某個特定程式碼區段,但又不想直接刪除程式碼。通常條件編譯的切割特性用於debug測試,或是執行指定版本程式碼。例如下方偽代碼就是一個例子:

debug測試

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

#define THREAD1_TEST_MODE

int main(void){
    thread1_init();

    #ifndef THREAD1_TEST_MODE
    thread2_init();
    #endif

    while(1){
        thread1();

        #ifndef THREAD1_TEST_MODE
        thread2();
        #endif
    }
}

當我們需要測試thread1功能時,就定義THREAD1_TEST_MODE,如此一來thread2程式碼就自動被忽略了,因為thread2部分不會被編譯器編譯,所以就某種程度上來說,切割特性可以節省code size,這個特性在下一個案例上更加明顯

指定版本程式碼
例如有一個軟體擁有四種不同的方案,我們只需要依照條件編譯的需求,將SOFTWARE_VERSION定義成指定參數值就可以明確編譯並執行指定版本的程式碼:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

#define SOFTWARE_VERSION 0

int main(void){
    while(1){
        #if (SOFTWARE_VERSION == 0)
            personal_version_thread(); // 個人版本
        #elif (SOFTWARE_VERSION == 1)
            family_version_thread(); // 家庭版本
        #elif(SOFTWARE_VERSION == 2)
            enterprise_version_thread(); // 企業版本
        #else
            pro_version_thread(); // 專業版本
        #endif
    }
}

移植性問題

使用macro選擇預處理區段
使用條件編譯還有利於程式的移植性,我自己習慣創建一個負責設定參數的標頭檔,還有多個根據參數定義來切割的功能性標頭檔

舉以下簡單程式案來說,我透過在header1.h定義程式需要用到的macro,以及用來選擇功能區段的條件macro

也就是說我可以透過SPECIALTY來選擇預處理的區段(見header2.h),由於macro的定義名均相同,所以從程式邏輯來看,每次移植程式我只需要更改header1.h的定義值,就可以相容code base相同的程式

當然啦這個程式沒有任何邏輯可言,僅僅是做為一個範例,但是核心概念不便,依然是利用條件編譯提升移植性

/*header1.h*/
#ifndef __HEADER1_H
#define __HEADER1_H

#define NAME        "HAU-WEI"
#define GENDER      "male"
#define AGE         25

#define PROGRAMMER  0
#define MANAGER     1
#define ATHLETE     2

#define SPECIALTY PROGRAMMER
#include "header2.h"

#endif /*__HEADER1_H*/
/*header2.h*/
#ifndef __HEADER2_H
#define __HEADER2_H

#if (SPECIALTY == PROGRAMMER)
    #define Intro(x) printf("Hi, my name is %s, I'm a programmer\n[Gender][%s]\n[Age][%d]\n", NAME, GENDER, AGE)
    #define SKILL1      "JAVA"
    #define SKILL2      "C++"
    #define SKILL3      "SQL"
    #define SKILL4      "linux"
#elif (SPECIALTY == MANAGER)
    #define Intro(x) printf("Hi, my name is %s, I'm a manager\n[Gender][%s]\n[Age][%d]\n", NAME, GENDER, AGE)
    #define SKILL1      "Communication"
    #define SKILL2      "Management"
    #define SKILL3      "Negotiation"
    #define SKILL4      "English"
#elif (SPECIALTY == ATHLETE)
    #define Intro(x) printf("Hi, my name is %s, I'm a athlete\n[Gender][%s]\n[Age][%d]\n", NAME, GENDER, AGE)
    #define SKILL1      "Basketball"
    #define SKILL2      "Soccer"
    #define SKILL3      "Swimming"
    #define SKILL4      "Tenis"    

#endif /*SPECIALTY*/

#endif /*__HEADER2_H*/
#include <stdio.h>
#include "header1.h"
/**
 * main.c
 */
int main(){
    Intro(NAME);

    if(SKILL1 == "JAVA"){
        printf("I'm capable for JAVA!\n");
    }
    else{
        printf("I'm not capable for JAVA!\n");
    }


    return 0;
}

輸出結果:

Hi, my name is HAU CHEN, I'm a programmer
[Gender][male]
[Age][25]
I'm capable for JAVA!

我們試著將header1.h中的SPECIALTY改為MANAGER看看輸出會發生什麼變化:

Hi, my name is HAU-WEI, I'm a manager
[Gender][male]
[Age][25]
I'm not capable for JAVA!

輸出結果確實根據定義參數類型而改變!

使用不同標頭檔
如果想在大型程式上使用移植特性進行開發,可以利用條件編譯來決定#include哪一個標頭檔,這些標頭檔所包含的函式、macro等名稱均相同

我們只需要改變macro定義的值就可以依照需求切換功能,這種方式適用於主程式架構邏輯不變,想額外改寫一些特殊功能時使用,通常都是類似但有一些小差異產品

舉例來說,下面這個程式範例會依照FUNC的定義值#include不同的標頭檔。且由於每個標頭檔中都有一個名為print_result的函式,所以若以後想要移植檔案,只要將含有print_result函式的檔案移植即可

通常會移植成對的source與header files,範例為了方便起見把程式碼都伈在寫在header中,不過邏輯不變

/*header1*/
#ifndef __HEADER1_H
#define __HEADER1_H

int operation(int a, int b){
   return a + b; 
}


int print_result(int a, int b){
    printf("%d + %d = %d\n", a, b, operation(a, b));
}

#endif /*__HEADER1_H*/
/*header2*/
#ifndef __HEADER2_H
#define __HEADER2_H

int operation(int a, int b){
   return a - b; 
}


int print_result(int a, int b){
    printf("%d - %d = %d\n", a, b, operation(a, b));
}

#endif /*__HEADER2_H*/
/*header3*/
#ifndef __HEADER3_H
#define __HEADER3_H

int operation(int a, int b){
   return a * b; 
}


int print_result(int a, int b){
    printf("%d * %d = %d\n", a, b, operation(a, b));
}

#endif /*__HEADER3_H*/
/**
 * main.c
 */
#include <stdio.h>

#define FUNC 3 

#if (FUNC == 1)
    #include "header1.h"
#elif (FUNC == 2)
    #include "header2.h"
#elif (FUNC == 3)
    #include "header3.h"
#endif

int main(){
    #if defined(FUNC)
    int a=4, b=3;
    print_result(a, b);
    #endif

    return 0;
}

輸出結果:

4 * 3 = 12

試著把FUNC改成2,查看輸出結果:

4 - 3 = 1

好了條件編譯的介紹大致就到這裡,希望對未來進行大型程式開發的各位有幫助😎

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com 標誌

您的留言將使用 WordPress.com 帳號。 登出 /  變更 )

Twitter picture

您的留言將使用 Twitter 帳號。 登出 /  變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 /  變更 )

連結到 %s

%d 位部落客按了讚: