【在主画面加入捷径】
       
【选择语系】
繁中 简中

[C 语言] 程序设计教学:如何使用宏 (macro) 或前置处理器 (Preprocessor)

【赞助商连结】

    在先前的范例程序中,我们没有特别说明前置处理器的用法,大部分都只是用 #include 来引入某些函式库;但前置处理器本身其实是一个小型的宏语言。我们先前提过 C 语言的编译步骤,第一步是预处理,在这一步中,所有 C 程序代码中有关前置处理器的语法都会经过一连串的字串代换替换成等效的 C 语言程序代码,之后才是真正的编译。

    由于前置处理器部分的程序代码是宏而非真正的 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++ 程序中的兼容性。

    不过,虽然 #include guard 是比较正统的方法,仍然有微小的机会会造成命名冲突,这时候可以使用编译器的 #pragma once 语法来代替。虽然 #pragma once 语法不是标准的前置处理器语法,但大部分的 C 编译器皆支援这个语法 (参考这里),除非项目要支援冷门的 C 编译器,要不然都可以放心地使用 #pragma once 语法。

    另一个例子是在编译期确认编译时所在的系统:

    #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)); \
        }
    
    【赞助商连结】