C 語言程式設計教學:前置處理器 (Preprocessor)

PUBLISHED ON SEP 3, 2018 — PROGRAMMING

    在先前的範例程式中,我們沒有特別說明前置處理器的用法,大部分都只是用 #include 來引入某些函式庫;但前置處理器本身其實是一個小型語言 (mini language)。我們先前提過 C 語言的編譯步驟,第一步是前處理,在這一步中,所有 C 程式碼中有關前置處理器的語法都會經過一連串的字串代換替換成等效的 C 語言程式碼,之後才是真正的編譯。

    甚至在某些非主流的手法中,將前置處理器用在 C (或 C++) 程式碼以外的用途,讀者可自行上網搜尋一些相關的線上文章即可略知一二。但前置處理器本身並不是一個通用的程式語言,用在這些非主流用途其實不是很好用,如果真的很喜歡玩巨集的讀者,可以試試 m4

    經前處理的 C 程式碼

    前置處理器難以除錯的原因在於程式碼經過兩層轉換,我們只能透過編譯器的錯誤訊息間接推測可能寫錯的地方。筆者建議自行閱讀前置處理器處理過的 C 程式碼,比較能夠知道實際轉出來的程式碼是否有問題。幸好前處理可以分開操作,要閱讀前處理過的程式碼不會太難。

    參考以下的類 Unix 系統指令:

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

    透過閱讀 file.i 就可以知道我們寫的巨集是否有問題。

    #include

    #include 用於引入函式庫,算是前置處理器最單純的語法。

    #include 有兩種語法,參考範例如下:

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

    <> (角括號) 通常用於標準函式庫和外部函式庫,而 "" (雙引號) 則用於自製函式庫,但這只是一個習慣用法,不是硬性規定。

    #define

    #define 是前置處理器最好玩的部分,因為寫巨集就是用這個保留字。

    最簡單的巨集就是定義編譯器的常數:

    #define SIZE 10
    

    因為巨集處理的對象是字串,故巨集是無型別的程式。利用這個特性,可以用巨集來模擬泛型程式,如以下常見的泛型 max 「函式」:

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

    但這個巨集也相當危險,如果有程式設計者用不良的方式使用此巨集就會出問題,如下例:

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

    前置處理器基本上是一種字串代換程式,沒有 C 語言的知識,所以巨集本身不會檢查程式碼是否安全,使用巨集需要程式設計者本身的自律。

    巨集也可以跨越多行,我們會用一個例子來說明。

    我們故意寫一個很 naive 的巨集,以突顯巨集的問題:

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

    C 的巨集沒有什麼安全措施,在這個例子中,甚至直接引入一個新的變數 cmp,實際上當然不會用這樣的巨集來寫程式。

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

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

    雖然 GCC 是很普遍的 C 編譯器,但 GCC extension 畢竟不是 C 標準,如果希望能在 C 標準下又維持巨集的安全性可參考以下範例:

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

    在這個巨集中,我們利用區塊 (block) 避免額外引入新的變數。雖然我們仍會多出一個新的變數,但變數名稱在我們的控制之內,不會造成什麼大問題。

    #if 相關的語法

    前置處理器有數個 #if 相關的語法,主要是用於條件編譯。由於各個作業系統的 API 各有不同,利用條件編譯處理平台的差異是 C (或 C++) 中常見的手法,一些跨平台的 C (或 C++) 函式庫內部會使用許多條件編譯來封裝平台間的差異性。

    一個常見的例子是用來寫標頭檔 (header),如以下 C 虛擬碼:

    #ifndef SOMETHING_H
    #define SOMETHING_H
    
    // Include some libaries
    
    #ifdef __cplusplus
    extern "C" {
    #endif
    
    // Declare some data types and public functions.
    
    #ifdef __cplusplus
    }
    #endif
    
    #endif // SOMETHING_H
    

    在這個標頭檔中,有兩種常見的手法,一個是避免重覆引入標頭檔,一個是維持該函式庫在 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
    

    這裡維護一份辨識作業系統的巨集名稱,需要時可參考。

    我們可以用前置處理器註解掉一段程式碼:

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

    我們也可以在開發過程中,選擇性地插入除錯訊息:

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

    在編譯時,加入 -DDEBUG 參數就會開啟相關的除錯訊息:

    $ gcc -DDEBUG -o file file.c
    

    最後要發布程式時再關掉此參數即可隱藏除錯訊息。

    #error

    #error 是在編譯期噴出的錯誤事件,可直接中止編譯,如下例:

    #if defined(__unix__)
        #error "Unsupported OS"
    #endif
    

    #pragma

    #pragma 通常是各個 C 編譯器特有的功能,不同的編譯器能用的 #pragma 往往不相通,需自行查閱該編譯器的手冊才知可用的 #pragma 語法。

    預先定義的巨集

    在前置處理器中已經預先定義好一些巨集,可直接套用:

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

    我們用上述巨集撰寫一個可以印出除錯訊息的巨集,該巨集會顯示錯誤訊息所在的檔案名稱和行數:

    #define info(msg)  { \
            fprintf(stderr, "%s at %d: %s\n", __FILE__, __LINE__, (msg)); \
        }
    
    comments powered by Disqus