Menu

C 语言条件编译:#ifdef、#ifndef、#if 和 -D

用 #ifdef、#ifndef、#if、#elif 和 #else 为不同的构建编译不同的代码:调试开关、平台分支、用 -D 在命令行中定义宏,以及用 #if 0 禁用代码。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

条件编译能让一份源文件变成好几个不同的程序。预处理器在编译器运行之前就决定哪些文本块保留下来,所以条件不成立的分支里的代码并不只是在运行时被跳过,而是被删除了。它从不被解析,因此对可执行文件没有任何贡献,里面甚至可以包含当前编译器会拒绝的写法。

这就是它和普通 if 的区别。两者都在路径之间做选择,但只有一个是在程序存在之前就做出选择的。

#ifdef 和 #ifndef

最简单的问题是:某个宏到底有没有被定义:

删掉 #define DEBUG 这一行,所有调试打印就会从构建中消失:不是被禁用,而是不存在了。先按原样运行一次,然后删掉那一行再运行一次。

#ifndef 是它的反面:只有当宏没有被定义时才保留代码块。它最常见的用途是头文件中讲到的头文件保护(include guard),它也可以用来提供一个调用者可以覆盖的默认值:

每个 #ifdef 和 #ifndef 都必须用 #endif 结束。在长文件中给结束符加上注释(#endif /* DEBUG */),以后能省下不少时间。

#if、#elif、#else

#if 接收一个整数常量表达式,当它非零时保留代码块。这让比较版本号和级别成为可能:

把 LOG_LEVEL 改成 0 或 3 再运行,看看程序本身是怎样改变形态的。

这个表达式由预处理器计算,这意味着它只能使用整数常量、算术和比较运算符,以及展开后是这些内容的宏。它看不到 sizeof、enum 的值、变量,以及任何需要编译器才能理解的东西。#if 中未定义的宏会被当作 0,而不是报错,这很方便,偶尔也会让人意外:

#if FEATURE_X        /* FEATURE_X is never defined anywhere -> 0 -> block dropped */

defined(NAME) 把 #ifdef 的判断变成可以在 #if 中使用的形式,这样就能组合多个条件:

#if defined(DEBUG) && !defined(NDEBUG)
    /* debug build, and assertions are on */
#endif

#if defined(LINUX) || defined(BSD)
    /* either unix-like system */
#endif

#if defined(X) 和 #ifdef X 的意思相同;需要 &&、|| 或 ! 时就用前者。

在命令行中定义宏

这些开关真正强大的地方在于,源代码完全不需要改动。gcc -D 为整个编译过程定义一个宏:

gcc -DDEBUG program.c -o program        # DEBUG defined as 1
gcc -DLOG_LEVEL=3 program.c -o program  # specific value
gcc -DDEBUG -DBUFFER_SIZE=512 a.c b.c -o app

单独的 -DNAME 等价于 #define NAME 1。-U NAME 用来取消定义,当某个头文件定义了你想关闭的东西时,这就很重要。

所以通常的安排是:源代码中包含用 #ifndef 保护的默认值,由构建命令来选择配置。

/* config.h */
#ifndef LOG_LEVEL
#define LOG_LEVEL 1        /* quiet by default */
#endif

#ifndef MAX_CONNECTIONS
#define MAX_CONNECTIONS 64
#endif
# development build
gcc -DDEBUG -DLOG_LEVEL=3 -Wall -Wextra -g src/*.c -o app-dev

# release build
gcc -DNDEBUG -O2 src/*.c -o app

NDEBUG 是标准规定的:定义它会禁用程序中所有的 assert(),因为 <assert.h> 本身就是用条件编译写成的。这正是这种模式的缩影:一个头文件,根据构建时定义了什么,编译出不同的内容。

平台开关

编译器会预定义一些标识目标系统的宏,这样一份源文件就能在每个平台上调用正确的 API:

#if defined(_WIN32)
    #include <windows.h>
    #define CLEAR_SCREEN "cls"
#elif defined(__APPLE__)
    #include <unistd.h>
    #define CLEAR_SCREEN "clear"
#elif defined(__linux__)
    #include <unistd.h>
    #define CLEAR_SCREEN "clear"
#else
    #error "Unsupported platform"
#endif

常见的有:_WIN32(32 位和 64 位 Windows 上都有定义)、_WIN64、__linux__、__APPLE__、__unix__、__ANDROID__。标识编译器的宏有另外一组(__GNUC__、__clang__、_MSC_VER),架构也有(__x86_64__、__aarch64__)。

下面是一个可以运行的版本,它会报告自己是在什么环境下构建的:

#error 本身也值得了解:它会带着你写的信息中止编译。在平台判断链的末尾写上 #else / #error "Unsupported platform",就能把悄无声息的错误构建变成构建日志顶部一条清晰的失败信息。

要列出编译器预定义的所有宏:

gcc -dM -E - < /dev/null

这个输出就是“在这台机器上我能判断哪些宏”这个问题的权威答案。

用 #if 0 禁用代码

用 /* ... */ 注释掉一段代码,一旦这段代码里自己也有注释就会失败,因为 C 语言的注释不能嵌套:里面的第一个 */ 会结束外层注释,之后的所有内容都成了多余的代码。#if 0 没有这个问题:

#if 0
    /* This whole region is removed, comments and all. */
    legacy_init();
    int n = old_calculation(42);  /* even this comment is fine */
    report(n);
#endif

把它改成 #if 1 就能恢复代码。编辑器会继续把它当作 C 代码高亮显示,你还可以把一个 #if 0 嵌套在另一个 #if 里面。

它是调试工具,而不是存储代码的地方。放在 #if 0 里的代码从不被编译,所以会悄悄地腐化:等到有人打开开关时,它已经编译不过了。在排查问题时用它,然后删掉这段代码,让版本控制替你记住它。

条件编译容易出问题的地方

大部分麻烦来自三种情况。

从不被编译的代码也从不被检查。 未启用的 #ifdef 分支里的拼写错误是看不见的,直到有人构建那个配置才会暴露,可能是几个月之后,也可能是在 CI 中某个你没有的平台上。如果一个项目有重要的平台分支,就要定期构建所有分支。

把 #ifdef 和控制流交织在一起,很快就会变得难以阅读。 下面这种代码很难推理:

if (ready) {
#ifdef FAST_PATH
    fast_send(buf);
} else {
#endif
    slow_send(buf);
}

在一个分支中打开、在另一个分支中关闭的花括号是合法的,但非常糟糕。更好的做法是让条件编译包住整个函数,然后在它们之间选择:

#ifdef FAST_PATH
static void send_data(const char *buf) { fast_send(buf); }
#else
static void send_data(const char *buf) { slow_send(buf); }
#endif

运行时的 if 往往更好。 如果两个分支在所有平台上都能编译,一个普通的 if (debug_enabled) 能让两条路径都经过类型检查、都可以测试,而且不需要重新构建就能切换。把预处理器留给只有它能做的事:在另一个平台上确实无法编译的代码。

关于把这些结合起来的模式(一个存放默认值的头文件、每次构建的 -D,以及包在头文件保护里的平台分支),请参阅头文件;关于 #define 本身,请参阅宏。

常见问题

C 语言中的条件编译是什么?

在编译器运行之前,用预处理指令保留或丢弃源代码块。条件不成立的 #ifdef 里的代码会从文本中被彻底删除:它从不被解析、从不被编译,也不会出现在可执行文件中。一份源文件正是靠它来支持多个平台或多种构建类型的。

#ifdef 和 #if 有什么区别?

#ifdef NAME 只判断这个宏是否存在,不管它的值是什么。#if expression 计算一个整数常量表达式,所以可以比较值:#if VERSION >= 3。只关心有没有定义的开关用 #ifdef,值有意义时用 #if。#if defined(NAME) 结合了两者,还可以用 && 和 || 组合。

怎么用 gcc 在命令行中定义宏?

使用 -D:gcc -DDEBUG program.c 定义了 DEBUG,效果就像文件开头写了 #define DEBUG 1;-DMAX=50 则给它指定一个值。构建系统就是这样在不修改源代码的情况下打开功能的,-U NAME 用来取消定义。

为什么用 #if 0 而不是把代码注释掉?

因为 /* ... */ 注释不能嵌套:区域内的第一个 */ 会提前结束注释,剩下的部分就成了错误的代码。#if 0 ... #endif 可以删除任意多的代码,里面有多少注释都没关系,语法高亮也照常工作,而且改成 #if 1 就能轻松恢复。

Coddy 编程语言插图

和 Coddy 一起学编程

开始