之后真不会执行吗?编译优化与控制流真相)
在C语言群和论坛里每隔一段时间就会冒出这个问题if(0)后面的语句真的不会执行吗问这个问题的可能是刚学到if语句的新生也可能是在看某段宏定义时一头雾水的初级工程师。多数人第一反应是条件为假怎么可能会执行但这个问题之所以能成为一个值得写一篇长文的题目是因为之后这个词本身就有两种读法而执行又分了好几个层面。1. 拆解问题if(0)之后到底是哪里先别急着回答会还是不会我们要先搞清楚题目问的之后是指哪个位置。这一个词没对齐答案可以完全相反。1.1 读法一if(0)语句后面、花括号外面的普通代码照常执行先上最简单的例子#include stdio.h int main(void) { if (0) { printf(A在 if(0) 的块里面。\n); } printf(B在 if(0) 之后的花括号外面。\n); return 0; }运行结果只有一行B在 if(0) 之后的花括号外面。这是很多初学者最容易混淆的点。if(0)的作用仅仅是让紧跟在它后面的那个受控语句这里是一个复合语句块不执行它不会影响这个if语句之外、以及整个if构造结束之后的其他代码。只要前面的代码没有用return、goto、longjmp或exit之类的手段把流程强制带走if(0)之后的那条普通语句该执行还是会执行。这一点在计算机二级这类考试里尤其常见题目如果问if(0)后面的printf会不会执行首先要看清楚printf写在大括号里面还是大括号外面。写在外面一定会执行写在里面正常流程中不会执行。题目本身不绕绕的是出题人有没有在这个词上做文章。1.2 读法二if(0)块内部的代码正常流程中确实不会执行如果问题指的是被if(0)包起来的那个花括号里的代码结论在正常流程下就是不会执行。if (0) { // 这一整块正常流程中永远不会被执行 do_something(); }0在C语言里是整型常量而整型常量在if条件里的作用就是假。C语言的条件语句只认真/假两种结果条件为假跳过受控语句执行if后面的内容。这个结论本身没有任何问题。但要提醒的是正常流程中不会执行不等于永远不会执行。编译器怎么看待这一块代码、运行时是否能通过某些非常规的跳转进入这一块是两个完全不同的故事后面第3节会专门展开。1.3 如果把问题里的0换成变量结论就立刻松动很多人之所以在这个问题上犯迷糊是因为把字面量0和值为0的变量混为一谈。int x 0; if (x 0) { // 当前x确实是0这个块不会执行 }看起来结果一样但语义完全不一样字面量if(0)在编译期就能确定条件恒为假而x 0必须等到运行时取出x的值才能判断。如果x在某个信号处理函数、另一个线程或者硬件中断里被改成非0这个分支就成立了。同样是0一个是写在源代码里的常量事实一个是运行时才揭晓的变量状态后面第5节会专门对比这些写法的差异。2. 编译器和CPU眼中的if(0)不执行不等于不编译很多人在写代码时把if(0)当成临时注释来用觉得里面的代码无所谓。这种想法在绝大多数时候能跑但藏着一些隐患因为编译器根本不认为这块代码不存在。2.1 死代码的另一面它依然要过语法和语义检查if(0)块里的代码和普通代码一样要经过词法分析、语法分析、语义分析。它只是不可能在控制流中到达而不是预处理阶段就被删除。int test(void) { if (0) { undeclared_function(); // 这里照样会编译报错 } return 0; }上面这段代码虽然undeclared_function()永远不会执行但编译器在编译阶段就会发现这个函数没有声明然后直接报错。同理块内变量的类型不匹配、函数调用的参数个数不对、宏没定义等等问题都会在编译期被揪出来。这一点和#if 0形成鲜明对比2.3节会详细对比。所以不会执行和不影响编译是两码事。如果你在if(0)块里放了一段很久以前写的老代码引用了某个已经被删掉的函数或常量那么程序可能在编译阶段就因为这段永远不会执行的代码而编译失败。我在实际维护中就遇到过这种情况一会儿放到4.1节讲。2.2 用nm和objdump验证优化级别改变了if(0)的存在感既然块内代码会被编译那它会不会真的变成机器码放在可执行文件里答案取决于编译器的优化级别。写一个最简单的测试文件#include stdio.h void test(void) { if (0) { printf(dead code\n); } }分别用-O0和-O2编译再用nm看目标文件的未定义符号gcc -O0 -c t.c -o t0.o gcc -O2 -c t.c -o t2.o nm t0.o | grep printf # -O0下printf这个外部符号通常还挂在符号表里 nm t2.o | grep printf # -O2下通常已经没有printf了我手头x86-64环境下的gcc 13实测不开优化时目标文件里还能看到对printf的引用开了-O2之后优化器发现if(0)的条件是编译期常量0整块代码属于不可达的死代码直接把整个块剔除掉了连printf的调用都不留。用objdump -d反汇编也能看到类似结论-O2下test函数经常只剩一条ret或等价指令。这个实验结果说明一个核心事实在C语言标准层面if(0)块内代码是否会变成最终二进制并不是标准规定死的而是编译器优化行为的一部分。但无论它变没变成机器码运行时控制流都到不了那里——除非用第3节那些跳转手段强行闯入。2.3 if(0)和#if 0只差一个井号本质差一个编译阶段#if 0 ... #endif和if(0) { ... }是两种经常被混用、但本质完全不同的写法。我见过不少初级程序员把if(0)当#if 0用结果某天被块里过期的代码坑了一次。对比项if(0)#if 0处理阶段编译阶段已过预处理预处理阶段编译器看到之前已被删除块内代码是否需要语法正确必须正确否则编译报错不需要预处理阶段不会送到编译器对调试开关的用途适合短期临时关闭保留语法检查适合长期屏蔽代码过期也不会报错是否进入目标文件看优化级别可能残留死代码完全不会#if 0的本质是预处理指令它让夹在#if 0和#endif之间的文本在预处理阶段就被吞掉编译器根本看不见。所以#if 0里面可以放心地写语法已经坏掉的代码也不会影响编译而if(0)做不到这一点。这里给新手一个直觉类比#if 0相当于在寄包裹之前就把包裹扔掉了快递员压根不知道有过这个包裹if(0)则是东西打包好了也上车了只是在运输终点被贴在箱子上的标签拦住了永远不会拆开它。前者技术上是不存在后者是存在但不会被使用。2.4 为什么说用if(0)代替注释不是一个好习惯网上有人喜欢用if(0)临时关掉大段逻辑理由是比注释更快、比#if 0看起来更像C。但从工程角度看if(0)不是注释的替代品。原因很简单注释和#if 0都不会参与编译而if(0)会。if(0)块里的代码必须永远保持能通过编译的状态。一旦代码库里有人删除了某个常量、改了某个函数签名这份永远不会执行的代码就成了编译炸弹。它藏在看起来无害的死代码块里排查起来特别费劲。我的建议是如果是当天调试用的临时关闭手段if(0)可以接受但务必加注释说明如果是准备提交到仓库、可能存活很久的屏蔽代码直接改成#if 0或者干脆删除让版本管理里的历史记录来承担回忆功能。3. 四条旁门左道让if(0)块里的代码真的跑起来看到这里细心读者应该意识到了前文说的正常流程中不会执行其实留了一条活口。正常流程不执行不代表任何流程都不执行。C语言的控制流里确实隐藏着几条能绕过条件、直接闯入if(0)块内的通道。3.1 goto跳进if(0)合法控制流里的闯空门C语言的goto可以跳转到函数内任意一个带标签的语句这个标签如果恰好写在if(0)的块内部那跳转就会绕过if条件的判断直接落在块内。#include stdio.h int main(void) { goto enter; if (0) { enter: printf(我进入了 if(0) 的块内部\n); } printf(继续执行后面的代码\n); return 0; }这段代码能被编译执行并且会输出一行我进入了 if(0) 的块内部然后继续走正常流程。也就是说if(0)块内语句确实执行了。这里要说明这种写法在C语言里是合法的但有一些限制C标准规定goto不能从某个具有可变修改类型VLA标识符的作用域外跳转到该标识符的作用域内。也就是说在goto和标签之间不能跨过一个变长数组的声明。对于普通自动变量C标准没有像C那样禁止跳转绕过初始化——那是C的规矩C语言相对更宽松。不过放宽不等于推荐正常业务代码里这么写review时大概率会被要求改掉。3.2 switch的case标签藏在if(0)里Duffs device的变体如果你觉得goto已经够野了那再来看一个更经典的case标签其实可以写在if(0)块里面。#include stdio.h int main(void) { int n 2; switch (n) { case 0: printf(case 0\n); if (0) { case 2: printf(case 2我从 if(0) 块里被拉出来了\n); break; } printf(case 0 的后续代码\n); break; default: printf(default\n); } return 0; }当n为2时switch会直接跳到case 2:标签处执行而这个标签的位置在if(0)的花括号里面。执行流进入后if(0)的条件判断根本没有机会被求值——因为人已经到了块里面还能判断什么呢结果就是printf正常执行。这个技巧并不是什么冷门魔法编程史上大名鼎鼎的Duffs device达夫设备就是同一套原理。Duffs device把switch的case标签写进一个do...while循环体里面让处理器根据余数直接跳转到循环体中部实现循环展开。这里把case标签写进if(0)块和Duffs device一样都在利用标签位置不需要和块结构对齐这个C语言特性。switch (count % 8) { case 0: do { *to *from; case 7: *to *from; case 6: *to *from; case 5: *to *from; case 4: *to *from; case 3: *to *from; case 2: *to *from; case 1: *to *from; } while (--n 0); }不少状态机、协程、protothread的实现就是在Duffs device这种switch和条件块错位嵌套的思路之上发展出来的。以后你在协议栈代码里看到case标签出现在一个看起来不能到达的块里不要惊讶那很可能就是作者在利用C语言的标签跳转能力做线程调度模拟。3.3 宏里的if(0)编译期检查的马甲上面两个是运行时硬闯下面这个更隐蔽也更好用if(0)块虽然运行时不执行但编译期它照样参与类型检查和格式串检查。于是有人干脆利用这一点把if(0)当成编译期校验用的马甲。最常见的场景是日志宏。假设你有一个没有声明printf属性的自定义日志函数void log_message(const char *str); #define LOG(fmt, ...) \ do { \ if (0) { \ printf(fmt, __VA_ARGS__); /* 借用printf做格式串检查 */ \ } else { \ char buf[256]; \ snprintf(buf, sizeof(buf), fmt, __VA_ARGS__); \ log_message(buf); \ } \ } while (0)if(0)里的printf永远不会在运行时被调用但由于它仍然会被编译编译器会按照printf的格式化规则检查fmt和后面的参数是否匹配。如果你传了一个%d格式却给了字符串指针编译期就会弹出-Wformat警告。这就是永远不执行但仍然帮你检查代码的典型用法。类似的if(0)还可以用来做函数指针的类型校验这个放到4.3节再展开。总而言之if(0)块内代码对编译器来说是可见的这个特性用好了是生产环境里的利器。3.4 编译器扩展computed goto到状态机实现上面三种都是标准C能解释的。如果在GCC、Clang这类允许扩展的编译器里还有更花哨的玩法label取标签地址配合goto *ptr做计算跳转。#include stdio.h int main(void) { void *target inside; goto *target; if (0) { inside: printf(computed goto 也跳进了 if(0)\n); } return 0; }这种computed goto是GNU扩展不是ISO C标准的一部分。它在某些解释器、状态机和协程库中被当作轻量级线程切换的手段因为取出标签地址、存入变量、在合适的时候跳回去几乎就等价于保存/恢复一个执行点。if(0)在这里的角色依然是一个标签容器——它让标签可以安放在一个不影响正常代码布局的地方。需要提醒的是这类代码移植性差MSVC肯定不支持遇到信号处理、栈展开之类的场景也要格外小心。看懂思路没问题工程上大面积使用要慎重。4. 生产代码里if(0)的真实存在方式讲完旁门左道再回到日常。if(0)在真实项目里其实是被广泛使用的只不过用法通常比临时注释高级得多。4.1 临时禁用代码我为什么更喜欢#if 0前文说过我不建议拿if(0)长期屏蔽代码。这里讲一个我自己踩过的坑。早几年维护一个嵌入式模块同事为了调试方便用if(0)包住了一段旧的协议解析逻辑。那段代码里引用了一个枚举值后来重构时大家觉得这个枚举没用顺手删了。结果第二天编译就炸了——炸的位置指向那段if(0)块。因为if(0)块仍然参与编译引用已删除枚举的那一行直接报未声明标识符。最后大家翻遍了代码才找到这个藏在不会执行代码里的坑。从那以后凡是不知道什么时候会解封的屏蔽代码我一律用#if 0。它把整段代码从预处理阶段就拿掉引用什么符号都不影响编译哪怕你想临时关掉一大段语法都坏掉的代码也完全可行。代价是#if 0不会像if(0)那样保留语法高亮和括号匹配所以代码编辑器里的观感差一些。两者取舍就看场景当天能解决的调试用if(0)会过夜、会提交、可能要活很久的用#if 0。4.2 do-while(0)与if(0)-else两种宏包装的取舍宏设计里有个老生常谈的问题怎么让一段多语句宏变成一个单语句从而不会破坏外层的if-else结构。最常见的错误写法是#define SAY_HI() if (1) printf(hi\n) int main(void) { int x 0; if (x) SAY_HI(); else printf(outer else\n); return 0; }宏展开之后变成if (x) if (1) printf(hi\n); else printf(outer else\n);C语言的悬挂else规则会把else绑到最近的未匹配if上也就是内层那个if(1)。于是当x为0时外层if的真分支没执行内层if根本没被求值它后面的else自然也不会执行——程序什么都不输出outer else被吞掉了。这就是经典的dangling-else陷阱。业界最主流的解决方案是do { ... } while (0)#define SAY_HI() do { printf(hi\n); } while (0)这样宏整体被包成一个复合语句配合末尾分号使用起来完全像普通函数调用。但也有人用if (0) {} else { ... }的写法#define SAY_HI() if (0) {} else { printf(hi\n); }展开后if (x) if (0) {} else { printf(hi\n); } else printf(outer else\n);内层if(0){}else{}把else消耗掉外层的else就正确绑定到外层if上。这个写法在逻辑上和do-while(0)等价但在一个细节上不同如果宏内部需要break出外层循环用do { break; } while(0)的话break只会跳出宏自己的do-while并不会跳出外层while而if(0){}else{ break; }里的break直接作用于外层循环。某些强调状态机的代码会因为这个原因选择if(0)-else写法。当然能用do-while(0)的地方尽量用do-while(0)它是可读性最好的主流方案。4.3 用if(0)做编译期类型/格式串校验除了日志宏另一个实用场景是函数指针类型校验。假设项目里有一个注册回调函数的接口签名必须是void (*)(int)。为了防止有人传错函数类型可以在宏里放一个if(0)的马甲void real_register(void (*fn)(int)); #define REGISTER(fn) \ do { \ if (0) { \ void (*check)(int) (fn); /* 类型不匹配就编译诊断 */ \ (void)check; \ } \ real_register(fn); \ } while (0) void callback_a(int code) { (void)code; } void callback_b(void) {} int main(void) { REGISTER(callback_a); // 正常 // REGISTER(callback_b); // 与 void(*)(int) 不兼容编译阶段报错或告警 return 0; }当传入callback_b这种签名不对的函数指针时if(0)块内的赋值语句会触发编译器的类型不兼容诊断从而把运行期的潜在崩溃提前到编译期暴露。这类永不执行但参与编译的用法本质上就是用编译器当静态检查器。前提是你要让维护者明白if(0)块是有意为之的不是忘删的代码所以注释要写清楚意图。5. 从if(0)延伸到if(ZERO)、if(volatile)的判定差异最后一个部分我们把0这个条件从字面量扩展到各种看起来是0的写法看看编译器和运行时分别是什么态度。5.1 const变量的0优化器敢不敢折叠const int ZERO 0; int func_const(void) { if (ZERO) { return 1; } return 0; }ZERO是一个const限定的int变量初始值为0。在主流通用编译器上只要程序没有通过指针等非法手段修改它优化器大概率会把它当作常量0来折叠func_const在-O2下等效于直接返回0。但要分清编译器可以优化和标准保证它是常量的区别。在ISO C里const int ZERO 0;并不是integer constant expression不能用来定义数组大小或者做case标签。编译器之所以敢折叠是因为按as-if规则只要结果不违反可观测行为它就可以随意优化。如果代码里用ZERO拿地址再在某些文件里通过memcpy之类的手段真把内存改成非0那是未定义行为优化器不背锅。5.2 volatile变量的0编译器必须读内存volatile int VZERO 0; int func_volatile(void) { if (VZERO) { return 1; } return 0; }这段代码和if(0)有本质区别VZERO被声明为volatile编译器在标准层面就必须每次使用时都去内存读取它的值。哪怕所有人都知道它初始化是0也不允许省掉这次读取因为volatile的语义就是告诉编译器这个变量的值可能在程序外部被改变。所以如果VZERO是一个内存映射寄存器或者会被信号处理器/中断修改那么这个if条件在运行时完全可能为真if(VZERO)的块照样执行。这也是为什么硬件驱动里几乎不会写if(0)而会写if(volatile_register FLAG)。5.3 运行时才知真假的0函数参数与全局变量再看两种最普通的0int func_arg(int x) { if (x 0) { // 取决于传进来的x } return 0; }x只有在函数被调用时才知道值。调用方传0就进不了块传非0就进块。这种判断必须在运行时完成和if(0)这种编译期常量判断完全是两条路线。全局变量也一样int counter 0; void poll_counter(void) { if (counter 0) { // 可能在某一时刻为0下一秒就非0 } }如果counter是普通全局变量代码在另一个地方把它改成非0那么这个分支的判定结果就会变化。编译器虽然在同一个编译单元里做常量传播时可能知道它当前为0但只要程序有外部修改路径它就不能在优化时把这个条件写成恒假。5.4 一张表记住不同0的语义条件写法编译期能否判定恒假运行时是否读取内存常规执行流中块内代码是否可能执行字面量if(0)可以不读不会除非goto/switch跳入const int ZERO 0优化器通常折叠通常不读不会行为同字面量volatile int VZERO 0不能每次读取可能外部可改值普通全局变量为0一般不能读取可能函数参数传入0一般不能读取参数值取决于每次调用这张表可以当个速查手册使用。写代码时看到一个值为0的变量不要下意识认为它就是if(0)先想清楚它在编译期到底能不能被确定是不是会在某个看不见的地方被修改。最后说一点个人经验。我在做code review和带新人时看到if(0)从来不会默认它是不会执行的死代码一定会追问一句你想表达什么如果是临时关掉一段代码我一般建议直接改成#if 0这样代码就算过期了也不会参与编译不会半夜给你报一个莫名其妙的错如果是要借用编译期检查比如格式串校验、函数指针类型校验那if(0)里那段永远不会跑的代码其实是你的盟友但记得上一句注释说明意图。至于goto、switch-case跳进if(0)这种玩法你在Duffs device、协程宏、协议状态机里见到能读懂就行日常业务代码里能不炫就不炫可读性永远比机灵重要。