17.3 有条件编译
条件编译用于在预处理阶段按条件选择代码片段,常见场景是标准版本差异、实现能力探测和特性开关。
1. defined 运算符
defined 标识符 或 defined(标识符) 会在预处理表达式中产生 1 或 0,用于判断宏是否已定义。
#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 202311L)
#define USE_C23 1
#else
#define USE_C23 0
#endif2
3
4
5
2. 条件指令族
#if、#elif、#else、#endif 构成通用分支;#ifdef 与 #ifndef 是“只检查是否定义”的简写;C23 增加了 #elifdef 与 #elifndef,可让多分支条件更紧凑。若目标仍是 C11/C17,可写成 #elif defined(...) 以保持兼容。
#ifdef _WIN32
#define PATH_SEP "\\"
#elifdef __unix__
#define PATH_SEP "/"
#else
#define PATH_SEP "/"
#endif2
3
4
5
6
7
3. 预处理条件与运行期条件
#if 里的表达式发生在预处理阶段,只能基于宏展开后的常量表达式计算,不能直接读取运行期对象值。把预处理条件和运行期 if 区分清楚,能避免“为什么这段分支没有生效”的常见误判。
#define FEATURE_FLAG 1
#if FEATURE_FLAG
/* 编译期保留 */
#endif2
3
4
5
当条件依赖外部构建参数时,建议集中在少量配置头中定义,再由业务代码读取统一宏,避免条件分支散落到各处。
4. 习题
下面的程序把五种条件写法排在一起,其中三处的输出和直觉不一致。先写出输出,再编译核对。
#include <stdio.h>
#define LEVEL 0
const int enabled = 1;
int main(void) {
#ifdef LEVEL
puts("LEVEL defined");
#endif
#if LEVEL
puts("LEVEL true");
#endif
#if enabled
puts("enabled true");
#else
puts("enabled false");
#endif
#if -1 < 0u
puts("negative less than zero");
#else
puts("negative not less");
#endif
#ifdef NDEBUG
puts("NDEBUG");
#endif
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- 写出输出。为什么
LEVEL被定义了却没有输出LEVEL true? const int enabled = 1;在#if enabled里被当成了什么?把enabled改成ENABLED(全大写)会改变结果吗?说明#if能看见的名字有哪些。-1 < 0u为什么是假?预处理器里的整数常量是什么类型、按什么规则比较?GCC 13.2 在-Wall下会对这一行给出警告,而单独开-Wextra或-Wsign-compare都不给。把三种开法各试一次,记下结果;然后说明这条警告想提醒你什么,以及它为什么不能替代自己判断常量类型。- 加
-Wundef重新编译,抄下新的警告。这条警告针对的是哪个名字?它为什么值得在工程里默认打开? - 把
const int enabled整条删掉,改用宏:文件开头写#define ENABLE_FEATURE 1,原来那组#if enabled改成#if ENABLE_FEATURE,两个分支分别打印feature on与feature off。给出改后的完整程序,要求-Wall -Wextra -Wundef下零警告——这意味着第 3 问里那条-1 < 0u也要一并改掉,想想改成什么写法既保留「无符号比较」的意思又不再触发警告。最后说明为什么改后不会再有「名字来自普通对象」的误判。
写一个配置头 platform.h,只靠条件编译,让同一个 main.c 在四种构建下给出不同结果。
要求:
BUILD_LEVEL由命令行给出,允许 1、2、3。未定义时按 1 处理,且这一默认值不能覆盖命令行传入的值(-DBUILD_LEVEL=3必须生效)。- 按级别定义
LOG_PREFIX与BUFFER_SIZE:3 为"[trace] "与4096,2 为"[info] "与1024,其余为"[warn] "与256。 - 按平台定义
PATH_SEP:_WIN32下为"\\",其余为"/"。用 C23 的#elifdef写;如果目标工具链只支持 C17,要能在不改代码的前提下换成#elif defined(...)(把那一段给出两份)。 main.c打印一行:前缀 + 缓冲区大小 + 路径分隔符。
验收:
$ gcc -std=c2x -Wall -Wextra -Wundef -pedantic -DBUILD_LEVEL=2 main.c && ./a.out
[info] 1024 [\]2
(Windows 下输出 [\],Linux 下为 [/]。)另外三种构建 BUILD_LEVEL=1、BUILD_LEVEL=3、不传 BUILD_LEVEL 都要编过,且不传时的输出与 BUILD_LEVEL=1 相同。最后回答:
第 1 条里的默认值为什么必须写成
#if !defined(BUILD_LEVEL)而不是直接#define BUILD_LEVEL 1?后者在-DBUILD_LEVEL=3时会给出什么诊断,最终用的是哪个值?把
#elifdef那一版用-std=c17 -pedantic-errors编译,得到什么?换成-std=gnu17再编译一次(不加-pedantic),诊断有什么不同?这说明#elifdef的支持程度由标准模式还是由编译器决定?-std=c17下,报不报「invalid preprocessing directive」取决于条件链走到哪一步。用下面三份文件各编译一次,把结果和标准 C17 的要求对照:c/* d1.c:前面的分支成立 */ #ifdef _WIN32 ... #elifdef __unix__ ... #endif1
2
3
4
5
6c/* d2.c:前面的分支不成立 */ #ifdef _NOT_DEFINED_ANYWHERE ... #elifdef __unix__ ... #endif1
2
3
4
5
6c/* d3.c:整条链在 #if 0 里 */ #if 0 #ifdef A ... #elifdef B ... #endif #endif1
2
3
4
5
6
7
8哪一份报错、哪一份不报?把
#elifdef全部换成#elif defined(...)再试一次,说明「没报错」为什么不等于「支持」。这一条在本机 GCC 13.2 上实测,值得写进报告。特别记录d2.c的输出:_NOT_DEFINED_ANYWHERE不成立时,__unix__在 MinGW 上也没有定义,程序走的哪一支?这和标准 C17 换用#elif defined(__unix__)的结果一致吗?
用 #if 检查构建参数,把「必须恰好选一个」这类约束写进头文件。
要求:写一个 options.h,让下面四种构建只有两种成功,另外两种在编译期就失败。
-DOPT_FAST或-DOPT_SMALL,恰好一个。- 两个都不给、或者两个都给,都要用
#error中止,消息写明「define exactly one」。 main.c在成功时打印fast或small。
先给出实现,再回答:
为什么不能只用
#ifdef OPT_FAST ... #else ... #endif来表达「恰好一个」?这种两分支写法在「两个都定义」时会怎么走?用
(defined(OPT_FAST) + defined(OPT_SMALL)) != 1时,-DOPT_FAST=0算不算「定义了」?实测这条命令的结果,并说明defined与「值为真」的区别。用
#if (OPT_FAST + OPT_SMALL) != 1的写法要额外做什么?为什么必须先把两个宏都补成 0?把这一版跑一遍下面四种构建,把结果和上一问的defined版对照,列出两版结论不同的那些输入:text(不给宏) -DOPT_FAST -DOPT_FAST=0 -DOPT_FAST=0 -DOPT_SMALL=11
2
3
4然后说明:
defined版检查的是「有没有定义」,加法版检查的是「值加起来是不是 1」。哪一版会把-DOPT_FAST=0判成错配,哪一版不会?哪一版在用户写-DOPT_FAST=2时会拦住他?两种写法各自适合什么样的构建接口。把
#error换成#warning会怎样?给出「两个都不给」「只给一个」「两个都给」三种构建在加与不加-Werror时的退出码,并说明为什么这类「配置检查」通常该用#error而不是#warning。提示:不加-Werror时#warning版本连错配的构建都会成功,-Werror版本才拦得住;而#error不依赖任何警告选项。
验收:四种构建的退出码依次为 0、0、1、1,成功后两行输出分别为 fast 与 small;-Wall -Wextra -Wundef 下成功构建零警告。换成 #warning 后,三种错配/漏配构建在无 -Werror 时退出码都是 0,加上 -Werror 才变成 1。
#if 0 与运行期 if (0) 看起来都是「这段不执行」,对生成代码的影响却不同。用一张 4096 个元素的常量表做实验。
写三个程序,都含
static const int table[4096] = {1, 2, 3};,都打印table[argc % 4096],但包的层不同:pp.c:把table的定义和打印一起放进#if 0 ... #endif;if0.c:用if (0) { ... }包住打印;rt.c:用if (argc > 99) { ... }包住打印。
三个都用
-std=c17 -c编成.o,用size和objdump -h量大小,用nm查有没有table符号,填一张 3 行 1 列的表。注意size的 text 列把.rdata一起算了进去,只看它会分不清代码和数据,所以还要用objdump -h单独读.rdata的字节数。if0.o与pp.o的 text 列差多少?差额里.rdata占多少?在你的编译器上if0.o里的table还会不会保留?标准有没有要求编译器必须把这段永不执行的代码删掉?#if 0里的table定义连符号表都进不去。它在阶段 3 已经被切成预处理记号,到阶段 4 执行#if时整组被跳过,因此从未进入翻译单元。对照 17.1 的八个阶段说明:为什么在阶段 4 之前不能提前扔掉这组记号,又为什么阶段 4 一过它就不存在了。用
nm看table在if0.o里的绑定:是小写的r还是大写的R?这个区别对应内部链接还是外部链接?static const保留下来却不会和其他文件冲突,原因就在这一点。结论写成两句:什么样的开关必须写成预处理条件(提示:换掉类型、换掉函数名、让整段依赖根本不进翻译单元),什么样的开关用运行期
if更合适(提示:可以热插拔、可以来自配置文件、需要进日志)。
验收:pp.o 的 text 列只有一百多字节、.rdata 为空或极小;if0.o 的 text 列比 pp.o 大出整张表的量级(本机约一万六千字节,.rdata 为 16 KiB);table 符号只出现在 if0.o 与 rt.o 里,且是小写 r。