预处理器进阶技巧【译】
预处理器进阶技巧【译】
在本文中,我们将介绍一些预处理器进阶话题。首先,我们将深入探讨类函数宏,重点讨论如何避免一些常见的陷阱。其次我们介绍 #
和 ##
预处理器运算符,并阐述在定义宏时如何使用它们。再次介绍 do {...} while (0)
的经典用法。本文最后讨论 #if
还是 #ifdef
更适合条件编译。
类函数宏的问题
乍一看,类函数宏似乎是一个简单而直接的结构。然而,当你进一步研究它们,你会发现一些非常恼人的问题。我将举一些例子,详细说明每个问题,并提出处理这些问题的方法
始终在参数的两边加上括号
请看以下看似简单的宏:
#define TIMES_TWO(x) x * 2
对于简单的情况,这种写法没有问题的。例如 TIMES_TWO(4)
将展开为 4 * 2
,其计算结果为 8。另外,你希望的是 TIMES_TWO(4+5)
会得到 18,没错吧?然而结果不是 18,因为宏只是简单地用参数替换了 x
。这意味着编译器看到的是 4 + 5 * 2
,其结果为 14。
解决方法是将参数用括号包含起来,例如:
#define TIMES_TWO(x) (x) * 2
宏(表达式)两边加上括号
假设我们有以下宏:
#define PLUS1(x) (x) + 1
这里,我们在参数 x
两边加上了括号。该宏在部分情况下是正常的;例如,以下例子将输出 11,符合预期:
printf("%d\n", PLUS1(10));
然而,在其他情况下,结果可能会出人意料;以下例子将输出 21,而不是 22:
printf("%d\n", 2 * PLUS1(10));
这是怎么回事?相同的道理,因为预处理器只是将宏调用替换为一段源代码。这是编译器看到的:
printf("%d\n", 2 * (10) + 1);
显然,这不是我们想要的,因为乘法的优先级高于加法。
解决方案是将宏的整个定义放在括号内:
#define PLUS1(x) ((x) + 1)
预处理器将展开如下操作,结果将按预期输出 22。
printf ("%d\n", 2 * ((10) + 1));
未定义行为的宏和参数
请看以下宏及其使用方式:
#define SQUARE(x) ((x) * (x))
printf("%d\n", SQUARE(++i));
宏的使用者可能打算在 i
自增后输出其平方值。这扩展为:
printf("%d\n", ((++i) * (++i)));
问题是,副作用将在每次使用参数时发生。(旁注,生成的表达式甚至是未定义行为的 C 代码,因为同一表达式包含对 i
的两次修改。
此处的经验是,每个参数应尽可能只调用一次。如果不能做到,就应该记录到文档中,以便提醒潜在使用者。
因此,如果不能编写使用每个精确参数的宏,我们该怎么办?直截了当的答案是避免使用宏——现在所有 C++ 和大多数 C 编译器都支持内联函数,并且它们也能实现同样的功能,而不会出现宏的参数未定义行为问题。此外,编译器在使用函数时更容易报告错误,因为它们不像宏那样没有类型。
特殊的宏特性
使用 #
运算符创建字符串
#
运算符可用于类函数宏,将参数转换为字符串。乍看之下,这似乎非常简单,但如果你只是在宏定义时用简单的写法,其导致的结果会让你大吃一惊。
例如:
#define NAIVE_STR(x) #x
puts (NAIVE_STR(10)); /* 输出 "10". */
一个无法按预期执行输出的示例是:
#define NAME Anders
printf("%s", NAIVE_STR(NAME)); /* 输出 NAME. */
后一个示例输出 NAME
,而不是 Anders
,这不是我们想要的结果。那么我们应该怎么办?这里有一个标准的解决方案:
#define STR_HELPER(x) #x
#define STR(x) STR_HELPER(x)
这种奇怪的结构背后的逻辑是,当 STR(NAME)
展开到 STR_HELPER(NAME)
时,在 STR_HELPER
扩展之前,所有类对象宏比如 NAME
将被优先替换(只要有宏要替换)。当调用类函数宏 STR_HELPER
时,传递给它的参数是 Anders
。
使用 ##
运算符拼接操作符
预处理器宏使用 ##
运算符将一些片段合并成更大的标识符、数字、其他任何变量。
例如,假设我们有一组变量:
MinTime, MaxTime, TimeCount.
MinSpeed, MaxSpeed, SpeedCount.
我们可以用定义一个带单个参数的宏 AVERAGE
,它可以返回平均时间、速度或其他任何内容。我们的第一个,简易的方法是类似以下内容:
#define NAIVE_AVERAGE(x) (((Max##x) - (Min##x)) / (x##Count))
这适用于典型的用例:
NAIVE_AVERAGE(Time);
在这种情况下,这将展开为:
return (((MaxTime) - (MinTime)) / (TimeCount));
但是,与上面的 #
运算符一样,在以下上下文环境中使用时,这不符合预期:
#define TIME Time
NAIVE_AVERAGE(TIME)
不幸的是,展开结果是:
return (((MaxTIME) - (MinTIME)) / (TIMECount));
与上面的 STR
例子一样,解决方案非常简单。你必须分两步展开宏。典型的做法是可以定义通用宏来将任何内容粘合在一起:
#define GLUE_HELPER(x, y) x##y
#define GLUE(x, y) GLUE_HELPER(x, y)
现在,我们可以在 AVERAGE
宏中使用它:
#define AVERAGE(x) (((GLUE(Max,x)) - (GLUE(Min,x))) / (GLUE(x,Count)))
让宏执行多条语句,“do {} while (0)" 的使用技巧
编写多条语句的宏非常方便的,因为它们具有与普通 C 代码相同的形式。
第一步很简单:对常量使用类对象的宏。对带有参数的语句或随时间变化的表达式使用类函数宏。
为了保持形式一致,我们希望让用户在类函数宏之后加上分号。预处理器仅用源代码替换宏,因此我们必须确保生成的程序不会因末尾的分号而出现异常。
例如:
void test ()
{
a_function(); /* 末尾分号不是宏的一部分;宏替换 */
}
对于由一个语句组成的宏,这是显而易见的,只需定义没有尾随分号的宏。
#define DO_ONE() a_function(1,2,3)
但是,如果宏包含两个语句(例如两个函数调用)会发生什么情况?为什么以下的代码不是个好例子呢?
#define DO_TWO() first_function(); second_function()
在一般情况下,上面的例子没有问题,例如:
DO_TWO();
这将展开为:
first_function(); second_function();
但是,在期望只有一条语句的情况下会发生什么,例如:
if (... test something ...)
DO_TWO();
遗憾的是,这将展开为:
if (... test something ...)
first_function();
second_function();
只有 first_function
才会成为 if
语句的执行部分。second_function
不是 if
语句的一部分,因此它将始终被调用。
那么,将两个调用函数包含在花括号中,然后用户编写的分号作为一个空语句如何?
#define DO_TWO() \
{ first_function(); second_function(); }
不幸的是,即使我们将上下文扩展到 if else
语句,这仍然不能按预期执行。
请考虑以下示例:
if (... test something ...)
DO_TWO();
else
...
这展开如下,注意右大括号后的分号:
if (... test something ...)
{ first_function(); second_function(); };
else
...
if
的主体由两条语句组成(大括号内的复合语句和仅由分号组成的空语句)。这不是合乎规范的 C 代码,因为 if (...)
和 else
之间必须只有一条语句!
下面是一种经典的方法:
#define DO_TWO() \
do { first_function(); second_function(); } while(0)
我不记得我第一次看到它是在哪里,但是我清晰地记得在理解这种结构的巧妙之前我是多么困惑。 这种方法是使用末尾带分号的复合语句,C 中有这样一种结构,即 do ... while(...);
。
等一下,你可能会认为,这是循环!我只想执行一次,而不是重复执行它们!
嗯,在这种情况下,我们只是非常取巧。do ... while(...)
循环体至少执行一次,然后只要条件表达式为 true
就继续执行。那么,让我们确保条件表达式永远不会为 true
就行了,表达式 0
正是 false
,因此循环的主体正好只执行一次。
这种形式的宏具有一般函数的样式。在 if...else
语句中使用时,宏将展开为以下正确的 C 代码:
if (... test something ...)
do { first_function(); second_function(); } while(0);
else
...
最后,do ... while(0)
用法在创建类函数宏时非常有用,其形式与正常函数一样。缺点是宏定义可能看起来不直观,因此我建议你注明 do ... while(0)
的用途,这样,其他阅读代码的人不会像我第一次遇到这种用法时那样感到困惑。
即使你不使用这种方法,希望下次遇到这种形式的宏时,您也能理解这种用法。
为什么你应该更倾向于 #ifs 而不是 #ifdefs
大多数程序需要某种配置,用于指定部分源代码无需编译进来。 例如,你可以编写具有可供选择执行的库,程序可能包含需要特定操作系统或处理器的代码,或者程序可能包含用于在内部测试时的跟踪输出。正如我们前面介绍的,#if
和 #ifdef
都可用于排除编译源代码的某些部分。
使用 #ifdefs
程序通常不需要对配置变量进行任何特殊处理,#ifdefs
代码如下所示:
#ifdef MY_COOL_FEATURE
... included if "my cool feature" is used ...
#endif
#ifndef MY_COOL_FEATURE
... excluded if "my cool feature" is used ...
#endif
当你使用 #ifs
时,预处理器符号通常总是先定义好了。 与 #ifdef
使用的符号对应的符号为 true
或 false
,可以分别由整数 1 和 0 表示。
#if MY_COOL_FEATURE
... included if "my cool feature" is used ...
#endif
#if !MY_COOL_FEATURE
... excluded if "my cool feature" is used ...
#endif
当然,预处理器符号可能有更多的状态,例如:
#if INTERFACE_VERSION == 0
printf("Hello\n");
#elif INTERFACE_VERSION == 1
print_in_color("Hello\n", RED);
#elif INTERFACE_VERSION == 2
open_box("Hello\n");
#else
#error "Unknown INTERFACE_VERSION"
#endif
通常使用这种样式的程序要强制为所有配置变量指定默认值。 例如,这可以在文件 defaults.h
中完成。 在配置程序时,可以在命令行上指定某些符号,或者最好在特定的配置头文件 config.h
中指定符号。 如果应使用默认配置,则此配置头文件可以保留为空。
defaults.h
头文件的示例:
/* defaults.h for the application. */
#include "config.h"
/*
* MY_COOL_FEATURE -- True, if my cool feature
* should be used.
*/
#ifndef MY_COOL_FEATURE
#define MY_COOL_FEATURE 0 /* Off by default. */
#endif
#if
与 #ifdef
我们该使用哪一个?
到目前为止,这两种方法似乎是一样的。如果您查看实际程序,您就会注意到两者都是常用的。乍一看 #ifdefs
似乎更容易处理,但经验告诉我,从长远来看,#ifs
更为优越。
#ifdef
检查拼写错误的单词,#ifs
可以。#ifdef
不知道标识符是否拼写错误,因为它所能判断的只是是否定义了某个标识符。例如,以下错误将在编译中被忽视:
#ifdef MY_COOL_FUTURE /* Should be "FEATURE". */
... Do something important ...
#endif
另一方面,大多数编译器可以检测到 #if
指令中使用了未定义的符号。从 C 标准上看这应该可行,在这种情况下,符号的值应为零。(对于 IAR 编译器,将发出诊断消息 Pe193;默认情况下,这是注释,但可能会引发到警告,甚至最好还是错误。
#ifdefs
可扩展性差。试想 #ifdefs
配置程序的做法。 如果要确保将以特定方式配置属性(例如,支持颜色属性),如果将来更改默认值会发生什么情况? 遗憾的是这个功能用 #ifdefs
不能实现。另一方面,如果使用 #ifs
配置程序,则可以将配置变量设置为特定值,以确保将来更改默认值时可以具有可扩展性。
对于 #ifdefs
,默认值指示名称。如果使用 #ifdefs
配置程序,则默认配置是不指定任何其他符号。 对于其他功能,这很简单,只需定义 MY_COOL_FEATURE
。 但是,如果要删除某个功能,标识符的名称通常会变成 DONT_USE_COLORS
。双重否定表示肯定。说是这么说,这样写的一个缺点双重否定会导致代码变得更加难以维护。例如,假设要添加一些支持颜色的代码:
#ifndef DONT_USE_COLORS
... do something ...
#endif
这看起来只是一个小细节,但如果你要阅读大段的代码,你迟早会感到困惑——至少我是这样。我更喜欢以下的用法:
#if USE_COLORS
... do something ...
#endif
另一个缺点是,在编写程序时,您必须知道(或查找)默认情况下是否启用了功能。 结合你没有防止拼写错误的单词这一情况,这是一个潜在的隐患。
但是,最大的缺点是,使用 #ifdefs
时,如果不更改整个程序中的所有 #ifdefs
,程序将无法更改默认值。对于 #ifs
,更改默认值很简单。需要做的就是更新包含默认值的文件。
从 #ifdefs
迁移到 #ifs
好吧,你可能会说,这听起来不错,但既然我们已经在程序中使用 #ifdefs
,我想我们必须迁就原有的代码。不,我的回答是,你不必那样做!从 #ifdefs
迁移到 #ifs
或多或少是很简单的。此外,还可以为旧的配置变量提供向后兼容性。
首先决定命名新的配置变量。具有否定概念(例如 DONT_USE_COLORS
)的变量应重命名为肯定概念的(例如 USE_COLORS
)。表示肯定意义的变量名可以不变,也可以再重命名。如果保留配置变量的名称,并且用户将其定义为空(如 #define MY_COOL_FEATURE
),则在使用该符号的第一个 #if
处将出现编译错误。 只需告诉用户将它们定义为 1 即可。
创建一个 defaults.h
头文件(如上所述),并确保所有源文件都包含此文件。(如果没有包含此文件,您也会注意到这一点,因为源文件使用未定义的配置变量,编译器会提示错误。)在头文件开头,您可以将旧 #ifdef
名称映射到新名称,例如:
/* 旧有配置变量,确保他们重构后依旧能使用 */
#ifdef DONT_USE_COLORS
#define USE_COLORS 0
#endif
/* 设置默认值 */
#ifndef USE_COLORS
#define USE_COLORS 1
#endif
然后在所有源文件中将所有出现的 #ifdefs
重命名为 #ifs
,相应地:
From | To
- | -
#ifdef MY_COOL_FEATURE
|#if MY_COOL_FEATURE
#ifndef MY_COOL_FEATURE
|#if !MY_COOL_FEATURE
#ifdef DONT_USE_COLORS
|#if !USE_COLORS
#ifndef DONT_USE_COLORS
|#if USE_COLORS
最终的程序,所有配置变量以及的默认设置都集中在统一的地方。这也正是是你一直计划重构但是未付诸实践的绝佳机会。