为什么宏中要使用看似毫无意义的do-while和if-else语句?

934

在许多C/C++宏中,我看到宏代码被包裹在一个看起来像是无意义的do while循环中。以下是示例。

#define FOO(X) do { f(X); g(X); } while (0)
#define FOO(X) if (1) { f(X); g(X); } else

我看不懂 do while 在做什么。为什么不直接写成没有它的样子呢?

#define FOO(X) f(X); g(X)

3
关于带有 else 的例子,我会在结尾处添加一个 void 类型的表达式,例如 **((void)0)**。 - Phil1970
3
提醒一下,do while 结构与返回语句不兼容,因此在标准 C 中,if (1) { ... } else ((void)0) 结构具有更兼容的用法。而在 GNU C 中,您会更喜欢我回答中描述的结构。 - Cœur
9个回答

980

do ... whileif ... else 的作用是确保宏定义中分号的意义总是相同的。假设你有类似于第二个宏定义的东西。

#define BAR(X) f(x); g(x)

如果你在一个if ... else语句中使用BAR(X);,而if语句的主体没有用花括号包含,那么你将会得到一个不好的惊喜。

if (corge)
  BAR(corge);
else
  gralt();

以上代码会展开为:

if (corge)
  f(corge); g(corge);
else
  gralt();

代码存在语法错误,因为else不再与if关联。在宏中用花括号包裹内容并不能解决问题,因为在花括号后添加分号是语法错误的。

if (corge)
  {f(corge); g(corge);};
else
  gralt();

解决这个问题有两种方法。第一种是在宏中使用逗号来排序语句,而不会剥夺它作为表达式的能力。

#define BAR(X) f(X), g(X)

上面的 BAR 版本将上述代码扩展为以下语法正确的代码。

if (corge)
  f(corge), g(corge);
else
  gralt();

如果你需要放置一个更复杂的代码块而不是f(X),那么这种方法将无法工作。例如,为了声明局部变量,需要将代码块放在自己的区块中。在最一般的情况下,解决方案是使用类似于do ... while的语法,使得宏成为一个单独的语句,可以在没有歧义的情况下加上分号。

#define BAR(X) do { \
  int i = f(X); \
  if (i > 4) g(i); \
} while (0)

你不必使用 do ... while,也可以通过 if ... else 编写一些内容,尽管当 if ... elseif ... else 中扩展时,它会导致 "悬挂 else" 的问题,这可能会使现有的悬挂 else 问题更难以找到,就像以下代码中所示。

if (corge)
  if (1) { f(corge); g(corge); } else;
else
  gralt();

关键在于在禁止出现悬挂分号的情况下使用分号。 当然,此时可能会(也应该)争辩说最好将BAR声明为实际函数而不是宏。

总之,do ... while 的存在是为了解决C预处理器的缺陷。当那些C风格指南告诉你要避免使用C预处理器时,他们担心的正是这种情况。


36
总结: 建议在if, while和for语句中始终使用花括号,这可以解决潜在的问题并符合MISRA-C的要求。建议总是在if、while和for语句中使用花括号,这是一种强有力的论据吗?如果您一直这样做(例如,遵循MISRA-C的要求),则可以消除上述描述的问题。 - Steve Melnikoff
25
逗号的例子应该是: #define BAR(X) (f(X), g(X)) 否则,运算符优先级可能会混淆语义。 - Stewart
25
虽然你和我四年半前的自己都有很好的观点,但我们必须生活在现实世界中。除非我们可以保证代码中所有的“if”语句等都使用大括号,否则像这样包装宏是避免问题的简单方法。 - Steve Melnikoff
21
请注意:相对于使用do {...} while(0)形式的宏来说,使用if(1) {...} else void(0)形式的宏更加安全,因为它不会改变break或continue关键字的行为。例如,当MYMACRO定义为#define MYMACRO(X, CODE) do { if (X) { cout << #X << endl; {CODE}; } } while (0)时,以下代码for (int i = 0; i < max; ++i) { MYMACRO( SomeFunc(i)==true, {break;} ) }会导致意外的行为,因为break语句影响的是宏的while循环而不是宏调用点处的for循环。 - Chris Kline
10
@ace 中的 void(0) 是一个笔误,我想表达的是 (void)0。我认为这确实解决了“悬挂else”问题:请注意,在 (void)0 后面没有分号。在那种情况下出现的悬挂 else(例如 if (cond) if (1) foo() else (void)0 else { /* dangling else body */ })会触发编译错误。这里有一个演示例子 - Chris Kline
显示剩余8条评论

185

宏是文本复制/粘贴的代码片段,预处理器会将其放入真实代码中;宏的作者希望替换后能产生有效的代码。

有三个好的“技巧”可以成功实现这一点:

帮助宏表现得像真实代码

通常,正常的代码以分号结尾。如果用户查看的是不需要分号的代码......

doSomething(1) ;
DO_SOMETHING_ELSE(2)  // <== Hey? What's this?
doSomethingElseAgain(3) ;

这意味着用户希望编译器在分号缺失时产生错误。

但是真正的好理由是,在某些情况下,宏的作者可能需要用真正的函数(可能是内联函数)替换宏。因此,宏应该像一个真正的函数一样进行处理。

因此,我们应该使用需要分号的宏。

生成有效的代码

如jfm3的回答所示,有时宏包含多个指令。如果宏在if语句中使用,这将是有问题的:

if(bIsOk)
   MY_MACRO(42) ;

这个宏可以展开为:

#define MY_MACRO(x) f(x) ; g(x)

if(bIsOk)
   f(42) ; g(42) ; // was MY_MACRO(42) ;

g函数将被执行,无论bIsOk的值如何。

这意味着我们必须向宏添加一个范围:

#define MY_MACRO(x) { f(x) ; g(x) ; }

if(bIsOk)
   { f(42) ; g(42) ; } ; // was MY_MACRO(42) ;

生成有效的代码 2

如果宏类似于:

#define MY_MACRO(x) int i = x + 1 ; f(i) ;

以下代码可能存在另一个问题:

void doSomething()
{
    int i = 25 ;
    MY_MACRO(32) ;
}

因为它将扩展为:

void doSomething()
{
    int i = 25 ;
    int i = 32 + 1 ; f(i) ; ; // was MY_MACRO(32) ;
}

当然,这段代码无法编译。因此,解决方案是使用作用域:

#define MY_MACRO(x) { int i = x + 1 ; f(i) ; }

void doSomething()
{
    int i = 25 ;
    { int i = 32 + 1 ; f(i) ; } ; // was MY_MACRO(32) ;
}

代码再次正常工作。

结合分号+作用域产生的影响?

有一种C/C++惯用语会产生这种效果:do/while循环:

do
{
    // code
}
while(false) ;

do {

     //macro code

} while(0);

do/while循环可以创建作用域,从而封装宏的代码,并且需要一个分号结束,因此会扩展成需要一个分号的代码。

额外的好处是?

C++编译器将优化掉do/while循环,因为其后置条件在编译时已知为假。这意味着像下面这样的宏:

do {

     //宏定义的代码

} while(0);

#define MY_MACRO(x)                                  \
do                                                   \
{                                                    \
    const int i = x + 1 ;                            \
    f(i) ; g(i) ;                                    \
}                                                    \
while(false)

void doSomething(bool bIsOk)
{
   int i = 25 ;

   if(bIsOk)
      MY_MACRO(42) ;

   // Etc.
}

将正确地展开

void doSomething(bool bIsOk)
{
   int i = 25 ;

   if(bIsOk)
      do
      {
         const int i = 42 + 1 ; // was MY_MACRO(42) ;
         f(i) ; g(i) ;
      }
      while(false) ;

   // Etc.
}

然后被编译并优化掉了。

void doSomething(bool bIsOk)
{
   int i = 25 ;

   if(bIsOk)
   {
      f(43) ; g(43) ;
   }

   // Etc.
}

7
请注意,将宏更改为内联函数会更改一些标准预定义宏,例如以下代码显示了 FUNCTIONLINE 的更改:#include <stdio.h>#define Fmacro() printf("%s %d\n", FUNCTION, LINE) inline void Finline() { printf("%s %d\n", FUNCTION, LINE); }int main() { Fmacro(); Finline(); return 0; }(粗体术语应由双下划线括起来 - 格式不佳!) - Gnubie
8
这个答案有一些小问题,但并不完全无关紧要。例如:void doSomething() { int i = 25 ; { int i = x + 1 ; f(i) ; } ; // was MY_MACRO(32) ; }的展开不正确;展开中的 x 应该是32。更复杂的问题是 MY_MACRO(i+7) 的展开结果是什么。另一个问题是 MY_MACRO(0x07 << 6) 的展开结果是什么。虽然大部分是正确的,但还有些需要修改。 - Jonathan Leffler
@Gnubie:如果你还在这里,而且到现在还没有弄清楚这个问题:你可以用反斜杠来转义注释中的星号和下划线,因此,如果你输入\_\_LINE\_\_,它会被渲染为__LINE__。我的看法是,最好对代码使用代码格式;例如,__LINE__(不需要任何特殊处理)。P.S. 我不知道这个在2012年是否正确;他们从那时起对引擎进行了很多改进。 - Scott - Слава Україні
1
虽然我的评论晚了六年,但是大多数C编译器实际上并没有内联“inline”函数(正如标准所允许的那样)。 - Andrew
谢谢!最受赞同的答案提到了在无作用域的if语句中,你需要一个while(0)才能正确运行。但是他们没有涵盖(而你做到了)为什么将宏体放在作用域中,例如#define FOO(x) { bar(x); }并不足够简单。看起来你需要while(0)的真正原因是要求在语句之后加上分号。 - Snackoverflow

57

@jfm3 - 您对此问题的回答很好。您可能还希望补充一下宏习惯用语可以防止简单的'if'语句中可能更危险(因为没有错误)的意外行为。

#define FOO(x)  f(x); g(x)

if (test) FOO( baz);

扩展为:

if (test) f(baz); g(baz);

虽然语法无误,因此不会出现编译错误,但可能会产生意外后果,导致 g() 总是被调用。


25
上面的答案解释了这些结构的含义,但是它们之间存在一个重要的区别,没有被提到。实际上,有理由更喜欢使用do ... while而不是if ... else结构。 if ... else结构的问题在于它不会“强制”你加上分号。就像在这段代码中:
FOO(1)
printf("abc");

尽管我们不小心漏掉了分号,但代码仍将扩展为

if (1) { f(X); g(X); } else
printf("abc");

如果使用这个代码块,编译器通常会默默地编译通过(尽管某些编译器可能会对无法访问的代码发出警告)。但是printf语句将永远不会执行。

do... while结构没有这样的问题,因为在while(0)之后唯一有效的标记是分号。


这就是为什么你应该使用 #define FOO(X) if (1) { f(X); g(X); } else (void)0 - Richard Hansen
3
@RichardHansen:还不够好,因为从宏调用的外观来看,你无法确定它是展开为语句还是表达式。如果有人假设后者,她可能会写FOO(1),x++;,这将再次给我们带来误报。只需使用do ... while就可以了。 - Yakov Galka
1
记录宏以避免误解应该足够了。我确实同意do ... while (0)更可取,但它有一个缺点:breakcontinue将控制do ... while (0)循环,而不是包含宏调用的循环。因此,if技巧仍然有价值。 - Richard Hansen
3
我看不到你能在宏的伪循环do {...} while(0)中放置breakcontinue语句,即使在宏参数中这样做也会导致语法错误。 - Patrick Schlüter
7
使用 do {...} while(0) 而不是 if 结构的另一个原因是它的惯用性。许多程序员广泛地使用 do {...} while(0) 结构,这个结构很有名并被广泛使用。它的原理和文档也很容易获得。相比之下,if 结构就不是那么容易理解了。因此,在进行代码审查时,使用 do {...} while(0) 会更加简单。 - Patrick Schlüter
2
@tristopia: 我曾经看到人们编写宏,将代码块作为参数传递(我不一定推荐这样做)。例如:#define CHECK(call, onerr) if (0 != (call)) { onerr } else (void)0。可以像这样使用:CHECK(system("foo"), break;);,其中 break; 旨在引用包含 CHECK() 调用的循环。 - Richard Hansen

21

解释

do {} while (0)if (1) {} else的作用是确保宏展开为仅有一条指令。否则:

if (something)
  FOO(X); 

将会扩展为:

if (something)
  f(X); g(X); 

使用 ({}) 可以比 do {} while (0)if (1) {} else 更好地解决此问题。 这是通过使用GNU的 语句表达式(不是标准C的一部分)来实现的:

#define FOO(X) ({f(X); g(X);})

这种语法与返回值兼容(请注意,do {} while (0)不兼容),例如:

return FOO("X");

5
在宏中使用大括号 {} 来夹住代码块,可以确保宏的所有代码都在同一个 if 条件路径下执行。而使用 do-while 则是为了在宏被调用时强制加上分号。这样就能使宏更像函数的行为方式。同时,在宏使用时需要注意添加结尾的分号。 - Alexander Stohr

17

虽然预期编译器会优化掉 do { ... } while(false); 循环,但还有另一种解决方案不需要使用该结构。解决方案是使用逗号运算符:

#define FOO(X) (f(X),g(X))

甚至更加奇特的是:

#define FOO(X) g((f(X),(X)))

虽然这对于单独的指令有效,但在变量构建并作为#define的一部分使用的情况下将不起作用:

#define FOO(X) (int s=5,f((X)+s),g((X)+s))

使用这种方式将强制使用do/while结构。


谢谢,由于逗号运算符不保证执行顺序,这种嵌套是一种强制执行的方式。 - Marius
17
@Marius:False;逗号运算符是一个序列点,因此确保执行顺序。我怀疑你把它和函数参数列表中的逗号混淆了。 - R.. GitHub STOP HELPING ICE
只是想补充一下,编译器被迫保留程序的可观察行为,因此优化掉 do/while 循环并不是什么大问题(假设编译器优化正确)。 - Marco A.
@MarcoA. 虽然你是正确的,但我过去发现编译器优化虽然完全保留了代码的功能,但通过改变在单一上下文中似乎无关紧要的行来打破多线程算法。例如Peterson算法。 - Marius
这种方法并不适用于所有类型的结构,尽管C语言具有三元运算符和此功能,但它相当表达。 - mirabilos

12

Jens Gustedt的P99预处理器库(是的,这件事的存在也让我感到震惊!)通过定义以下内容,在一个小但重要的方面上改进了if(1) { ... } else结构:

#define P99_NOP ((void)0)
#define P99_PREFER(...) if (1) { __VA_ARGS__ } else
#define P99_BLOCK(...) P99_PREFER(__VA_ARGS__) P99_NOP
这样做的原因是,与do {...} while(0)结构不同,breakcontinue仍然可以在给定的块内工作,但是((void)0)如果在宏调用后省略分号将会创建一个语法错误,否则会跳过下一块。 (实际上这里并没有“悬挂else”问题,因为else绑定到最近的if,即宏中的那个。)

如果您对可以在C预处理器中相对安全地完成的事情感兴趣,请查看该库。


虽然非常聪明,但这会导致编译器警告潜在的悬挂else问题。 - Segmented
2
通常使用宏来创建一个封闭的环境,也就是说,你永远不会在宏内部使用 break(或 continue)来控制在外部开始/结束的循环,这只是一种糟糕的风格,并且隐藏了潜在的退出点。 - mirabilos
Boost库中还有一个预处理器库。它的惊人之处是什么? - Rene
else ((void)0) 的风险在于,有人可能会写 YOUR_MACRO(), f();,这是语法上有效的,但永远不会调用 f()。而使用 do while 则会导致语法错误。 - melpomene
@melpomene 那么 else do; while (0) 怎么样? - Carl Lei

9

由于某些原因,我无法在第一个答案上进行评论...

有些人展示了带有局部变量的宏,但没有人提到您不能在宏中使用任何名称!这会在某一天咬住用户!为什么?因为输入参数将替换为您的宏模板。而在您的宏示例中,您使用了可能是最常用的变量名i

例如,当下面的宏被调用:

#define FOO(X) do { int i; for (i = 0; i < (X); ++i) do_something(i); } while (0)

用于以下函数

void some_func(void) {
    int i;
    for (i = 0; i < 10; ++i)
        FOO(i);
}

宏将不使用在some_func开头声明的意图变量i,而是使用在宏的do ... while循环中声明的本地变量。

因此,在宏中永远不要使用常见的变量名!


通常的模式是在宏中的变量名中添加下划线 - 例如 int __i; - Blaisorblade
8
@Blaisorblade:实际上这是不正确和非法的C语言写法;前导下划线被保留供实现使用。你之所以看到这样的“常见模式”,是因为读取了系统头文件(“实现”),它们必须限制自己使用这个保留命名空间。对于应用程序/库,你应该选择自己的不带下划线的不太可能冲突名称,例如mylib_internal___i或类似名称。 - R.. GitHub STOP HELPING ICE
2
@R.. 你说得对 - 我确实在一个“应用程序”中读到过这个,Linux内核,但它是一个例外,因为它不使用标准库(从技术上讲,它是一个“自由站立”的C实现,而不是一个“托管”的实现)。 - Blaisorblade
4
@R.. 这不太正确:以一个大写字母或第二个下划线开头的前导下划线在所有情况下都为实现保留。以其他内容开头的前导下划线在本地范围内未被保留。 - Alex Celeste
1
@Leushenko:是的,但这种区别足够微妙,以至于我认为最好告诉人们完全不要使用这样的名称。那些理解微妙之处的人可能已经知道我正在忽略细节。 :-) - R.. GitHub STOP HELPING ICE
虽然正确,但这并不是问题的答案。根据网站规则,请将其转换为评论;如果您无法这样做,请先从好的答案中获取声望(这些答案实际上是答案);是的,第一步可能很难,但在SO上有这么多可以做的事情。 - mirabilos

2

我认为这个问题没有被提及,所以请考虑一下:

while(i<100)
  FOO(i++);

将被翻译成

while(i<100)
  do { f(i++); g(i++); } while (0)

注意宏定义中i++被评估了两次。这可能会导致一些有趣的错误。

18
这与 do ... while(0) 结构无关。 - Trent
2
真的。但与宏和函数的主题相关,以及如何编写行为类似函数的宏... - John Nilsson
3
类似于上面的评论,这不是一个答案而是一个评论。关于主题:这就是为什么你只使用一次的东西:do { int macroname_i = (i); f(macroname_i); g(macroname_i); } while (/* CONSTCOND */ 0) - mirabilos

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接