[C 語言] 程式設計教學:如何使用巨集 (macro) 或前置處理器 (Preprocessor)

【分享文章】
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

    前言

    前置處理器是在 C 或 C++ 中所使用的巨集 (macro) 語言。嚴格說來,前置處理器的語法不是 C 語言,而是一個和 C 語言共生的小型語言。在本文中,我們介紹數種常見的前置處理器用法。

    閱讀經前置處理器處理過的 C 程式碼

    在 C 編譯器中,前置處理器和實質的 C 編譯器是分開的。C 程式碼會經過前置處理器預處理 (preprocessing) 過後,再轉給真正的 C 編譯器,進行編譯的動作。

    預處理在本質上是一種字串代換的過程。前置處理器會將 C 程式碼中巨集宣告的部分,代換成不含巨集的 C 程式碼。之後再將處理過的 C 程式碼導給 C 編譯器,進行真正的編譯。

    所幸,預處理在一些 C 編譯器中是可獨立執行的步驟。以 GCC 為例,我們可以把前處理這一步獨立出來,觀察預處理後的程式碼。下列的範例指令將程式碼前處理後,用 indent 程式以 K&R 風格重新排版:

    $ gcc -E -o file.i file.c
    $ indent -kr file.i
    

    藉由閱讀整理過的 file.i 文字檔,我們可以了解前置處理器做了什麼事情,有利於除錯。

    #include 引入函式庫

    #include 敘述用來引入外部函式庫,這算是單純的敘述。在引入函式庫時,有兩種語法可用,如下例:

    // Include some standard or third-party library.
    #include <stdlib.h>
    
    // Include some internal library.
    #include "something.h"
    

    有些程式會將外部函式庫以一對角括號 <> 將標頭檔名稱括起來,專案內部的模組用則成對雙引號 "",在視覺上可簡單地區分。這只是撰碼風格,非強制規範。

    #define 宣告巨集

    #define 敘述用來宣告巨集。這應該是前置處理器中最具可玩性的部分。有些程式人會用巨集寫擬函式,甚至會用巨集創造語法。基本上,用巨集創造語法算是走火入魔了,我們不鼓勵讀者這麼做,知道有這件事即可。

    最簡單的巨集是宣告定值:

    #define SIZE 10
    

    實際上,在轉換後的 C 程式中,並沒有 SIZE 這個變數。每個 SIZE 所在的位置會經前置處理器代換為 10

    承上,我們來看一個相關的範例:

    #include <stdio.h>                    /*  1 */
    
    #define SIZE 5                        /*  2 */
    
    int main(void)                        /*  3 */
    {                                     /*  4 */
        int arr[SIZE];                    /*  5 */
    
        for (int i = 0; i < SIZE; i++) {  /*  6 */
            arr[i] = i + 3;               /*  7 */
        }                                 /*  8 */
    
        for (int i = 0; i < SIZE; i++) {  /*  9 */
            printf("%d\n", arr[i]);       /* 10 */
        }                                 /* 11 */
    
        return 0;                         /* 12 */
    }                                     /* 13 */
    

    我們知道陣列不能用變數初始化,但第 2 行所宣告的巨集變數 SIZE 會在前處理時將程式碼中 SIZE 出現的位置轉換成定值 5。實數編譯時陣列的長度是定值 5 而非巨集變數 SIZE,所以程式可正確編譯和運行。

    稍微進階一點的用法是用巨集寫簡單的擬函式。像是以下的 MAX 巨集:

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

    實際上,該巨集會代換為三元運算子,所以可以像函式般回傳值。

    但巨集是很不牢靠的,像是以下的誤用例:

    m = MAX(a++, b++);
    

    該巨集會展開成以下 C 程式碼:

    m = ((a++) > (b++) ? (a++) : (b++));
    

    由於遞增運算子會隱微地改變程式的狀態,這行程式會產生預期外的結果。

    巨集也可以用來跨越多行,這時候就更像函式了。我們來看一個反例,待會兒會改善該實例:

    #include <assert.h>
    #include <stdbool.h>
    
    // DON'T DO THIS IN PRODUCTION CODE!
    #define compare(a, b) \
        bool cmp = 0; \
        if ((a) > (b)) { \
            cmp = 1; \
        } else if ((a) < (b)) { \
            cmp = -1; \
        } else { \
            cmp = 0; \
        }
    
    int main(void)
    {
        compare(5, 3);
        assert(cmp > 0);
    
        return 0;
    }
    

    在這個範例中,巨集 COMPARE 竟然強制引入了一個新的變數 cmp,而且無法修改。這樣的巨集汙染了命名空間。此外,這樣的程式會報錯:

    assert(COMPARE(5, 3));
    

    因為 COMPARE 本身非表達式,而是由多行敘述組成,無法放入 assert 中。

    理想的巨集應該是安全的,不會隨意引入新的變數。透過 GCC extension 中的 statement expression 可以很安全地將變數封裝在巨集內:

    #include <assert.h>
    #include <stdbool.h>
    
    // The GCC way.
    #define COMPARE(a, b) ({ \
            int flag = 0; \
            if (a > b) { \
                flag = 1; \
            } else if (a < b) { \
                flag = -1; \
            } else { \
                flag = 0; \
            } \
            flag; \
        })
    
    int main(void)
    {
        assert(COMPARE(5, 3) > 0);
        
        return 0;
    }
    

    雖然這個版本的巨集 COMPARE 很漂亮地將變數封裝起來,但這是 GCC 特有的延伸語法,非標準 C 的一部分。除非很確定專案只會用 GCC 來編譯,否則應避開這樣的特異功能。

    當使用標準 C 時,我們退而求其次:

    #include <assert.h>
    #include <stdbool.h>
    
    // The portable way.
    #define COMPARE(a, b, out) { \
            if ((a) > (b)) { \
                out = 1; \
            } else if ((a) < (b)) { \
                out = -1; \
            } else { \
                out = 0; \
            } \
        }
    
    int main(void)
    {
        int out;
    
        COMPARE(5, 3, out);
        assert(out > 0);
        
        return 0;
    }
    

    雖然這個版本的巨集 COMPARE 仍會引入新的變數,但這個變數可由巨集使用者決定,故比原本的版本好一些。

    為什麼我們要用巨集寫擬函式呢?因為巨集本質上是字串代換,不受到型別限制,可用來寫擬泛型程式。但用巨集模擬泛型程式並沒有成為主流,因為巨集經過多一次轉換,難以追蹤錯誤真正發生的位置。此外,不當地使用巨集,易產生難以發覺的 bug。所以,我們儘量只用巨集處理簡單的功能。

    (無用) 用巨集創造語法

    承上節,程式設計者可以利用 #define 為 C 語言創造新語法。這種用法算是經典的反模式 (anti-pattern),所以看看就好,不要深入學習。

    例如,我們用巨集將中序運算子「轉換」為前序運算子,就可以在 C 程式碼中「寫」Lisp:

    /* DON'T DO THIS IN PRODUCTION CODE. */
    #include <assert.h>
    #include <stdio.h>
    
    typedef unsigned int uint;
    
    /* Arithmetic operators. */
    #define ADD(a, b) ((a) + (b))
    #define SUB(a, b) ((a) - (b))
    
    /* Relational operators. */
    #define GT(a, b) ((a) > (b))
    #define LE(a, b) ((a) <= (b))
    #define EQUAL(a, b) ((a) == (b))
    
    /* Assignment operator. */
    #define SETQ(a, b) ((a) = (b))
    
    /* Function implementation. */
    #define DEFUN(fn, t, params, body) \
      t fn params { body }
    
    DEFUN(fib, uint, (uint n),
      assert(
        GT(n, 0));
      if (EQUAL(n, 1))
        return 0;
      else if (EQUAL(n, 2))
        return 1;
      else
        return
          ADD(fib(SUB(n, 1)),
              fib(SUB(n, 2)));)
    
    DEFUN(main, int, (void),
      uint i;
      for (SETQ(i, 1); LE(i, 20); ++i)
        printf(
          "%u\n", fib(i));
      return 0;)
    

    泛型型別巨集 (C11)

    泛型型別巨集是 C11 所引入的新特性,用於模擬泛型程式。為什麼我們說是模擬泛型呢?我們來看以下的泛型 log10 函式:

    #define log10(x) _Generic((x), \
        log double: log10l, \
        float: log10f, \
        default: log10)(x)
    

    在此範例程式中,巨集 log10 是一個利用泛型型別巨集宣告的擬函式。在我們帶入不同型別的參數時,該巨集會將參數導向適合該參數的型別的函式。藉由這種方式,達成泛型的效果。

    也就是說,我們雖然有泛型程式的型別安全,但卻無法真正節省實作的時間。因為我們仍然要針對不同型別重覆實作相同演算法的函式。為什麼 C 標準要引入這樣的特性呢?應該是為了兼具相容性和現代語法所做的妥協吧。

    如果想要看更多泛型型別巨集的範例,可參考這裡

    用巨集進行條件編譯

    利用巨集中有關條件編譯的語法,我們可以根據不同情境改變巨集的輸出。也就是說,我們可以利用這項特性保留所的需程式碼,去除不需要的程式碼。

    一個經典的實例是標頭檔的 #include guard:

    #ifndef SOMETHING_H
    #define SOMETHING_H
    
    /* Declare some data types and public functions. */
    
    #endif /* SOMETHING_H */
    

    當前置處理器第一次讀到此標頭檔時,SHOMETHING_H 是未定義的,這時候前置處理器會繼續執行下一行敘述。在下一行我們定義了 SOMETHING_H

    反之,當前置處理器第二次讀到此標頭檔時,由於 SOMETHING_H 己定義了,前置處理器不會繼續執行後續的內容,巧妙地避開了重覆引入的議題。

    使用 #include guard 時,標頭檔最尾端的 #endif 是要和開頭的 #ifndef 成對出現的固定語法。初學者有時會忘了加上去。

    在使用 #include guard 時,有微小的機會會發生巨集名稱衝突。像是以下的例子:

    #ifndef UTILS_H
    #define UTILS_H
    
    /* Declare some data types and public functions. */
    
    #endif  /* UTILS_H */
    

    C 專案或多或少會有一些無法歸類的工具函式,一個常見的方式是將這些工具函式放在同一個模組中集中管理。而 UTILS_H 又是很普遍的名稱,就有可能引發巨集名稱衝突。

    替代的方式是在標頭檔第一行加入以下敘述:

    #pragma once
    

    #pragma once 在效果上等同於 #include guard,但不會引發巨集名稱衝突。雖然 #pragma once 非標準 C 語法,許多 C 編譯器都有實作這項功能。除非手上的 C 專案要支援一些冷門的 C 編譯器,可以考慮使用這個語法替代 #include guard。

    另外一個經典的例子是 extern "C" 敘述:

    #ifdef __cplusplus
    extern "C" {
    #endif
    
    /* Some declarations. */
    
    #ifdef __cplusplus
    }
    #endif
    

    extern "C" 敘述用於混合 C 和 C++ 的專案。C++ 為了處理命名空間 (namespace)、函式重載 (function overloading) 等語法特性,會將函式名稱 mangling。但我們不希望 C++ 編譯器將 C 函式庫的標頭檔內的函式宣告也 mangling,所以我們用 extern "C" 敘述告知 C++ 編譯器不要對該區塊內的函式名稱 mangling。

    由於 extern "C" 是一個選擇性的區塊,所以我們用兩段巨集宣告把函式宣告包起來。這已經算是固定的手法了。

    條件編譯也常用來偵測編譯程式時的系統環境。如以下的例子:

    #if defined(_WIN32)
        #define PLATFORM_NAME "Windows"
    #elif defined(__CYGWIN__) && !defined(_WIN32)
        #define PLATFORM_NAME "Cygwin"
    #elif defined(__linux__)
        #define PLATFORM_NAME "GNU/Linux"
    #elif defined(__APPLE__)
        #define PLATFORM_NAME "Mac"
    #elif defined(__unix__)
        #define PLATFORM_NAME "Unix"
    #else
        #define PLATFORM_NAME "Other OS"
    #endif
    

    在這個例子中,我們僅僅用條件編譯來決定 PLATFORM_NAME 的值,但我們可以進一步用條件編譯篩選不同系統下的程式碼,撰寫跨平台程式碼。

    條件編譯對於跨平台程式碼相當重要。因為不同系統的 C API 等內容不會相等。我們可以用條件編譯的方式,針對不同平台撰寫不同的程式碼,滿足跨平台的需求。

    我們的例子中已經列出幾個常見的系統,這裡則列出更多系統名稱,有興趣的讀者可以參考一下。

    我們還可以用條件編譯來註解掉一段程式碼。如下例:

    #if 0
        printf("It won't print\n");
    #endif
    

    由於 #if 0 為偽,從 #if 0#endif 之間的 C 程式碼會被前置處理器自動忽略掉,達到註解的效果。

    條件編譯常用來輔助除錯。參考以下例子:

    #ifdef DEBUG
        fprintf(stderr, "Some message\n");
    #endif
    

    當巨集 DEBUG 為真時,會印出錯誤訊息。反之,則不會印出來。

    我們在編譯 C 程式碼時,可以在參數中開啟 DEBUG 宣告:

    $ gcc -DDEBUG -o file file.c
    

    這時候巨集 DEBUG 視為真,印出錯誤訊息。最後程式要發佈前,關掉這個參數再編譯一次即可。

    在編譯時期引發錯誤

    我們可以在巨集中引發錯誤,中止程式編譯。參考以下實例:

    #if __unix__
        #error "Unsupported OS"
    #endif
    

    在這個例子中,假定我們的專案不支援類 Unix 系統,試圖在類 Unix 系統下編譯時會引發錯誤訊息。

    引入各編譯器特有的特性

    #pragma 敘述開放給各個 C 編譯器,用來自訂新的巨集功能。像是我們先前提到的 #pragma once 就是一個例子。#pragma 敘述的用法在 C 編譯器不統一,得查閱各個 C 編譯器的使用手冊,這裡不多做說明。

    預先定義的巨集

    在 C 語言中,預先定義好數個巨集,這些訊息和程式本身的資訊相關,可用於除錯等。包括以下巨集:

    • __LINE__:程式所在的行數
    • __FILE__:檔案名稱
    • __DATE__:前置處理器執行的日期
    • __TIME__:前置處理器執行的時間
    • __STDC__:確認某個編譯器是否有遵守 C 標準
    • __func__:函式名稱 (C99)

    我們利用這些巨集寫了一個印出錯誤訊息的巨集:

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

    在這個巨集中,第一個參數當成格式化輸出的模板,第二個以後的參數則是輸入的字串。我們利用兩個內定的巨集 __FILE____LINE__ 分別印出錯誤訊息所在的檔案名稱和行數,有利於除錯。

    結語

    在 C 語言中,前置處理器是實用卻易被忽略的特性,許多入門教材不會深入前置處理器的使用方式。可能的原因是巨集難寫、難除錯。然而,只要不過度使用魔術語法,巨集也能成為我們撰寫 C 程式的助力。

    電子書籍

    如果讀者想要離線使用 C 語言程式設計相關的內容,可以參考以下書籍:

    現代 C 語言程式設計

    【分享文章】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【追蹤網站】
    Facebook Facebook Twitter Plurk
    【支持本站】
    Buy me a coffeeBuy me a coffee