预处理器进阶技巧【译】

预处理器进阶技巧【译】

在本文中,我们将介绍一些预处理器进阶话题。首先,我们将深入探讨类函数宏,重点讨论如何避免一些常见的陷阱。其次我们介绍 ### 预处理器运算符,并阐述在定义宏时如何使用它们。再次介绍 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 使用的符号对应的符号为 truefalse,可以分别由整数 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

最终的程序,所有配置变量以及的默认设置都集中在统一的地方。这也正是是你一直计划重构但是未付诸实践的绝佳机会。

原文链接

Advanced preprocessor tips and tricks

posted @ 2020-05-29 00:49  suanite  阅读(182)  评论(0编辑  收藏  举报