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

C 語言巨集
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 程式的助力。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。