我深度使用宏的机会并不是很多,闲来无事,以打发时间的心态,了解了一些相关知识,记录于本文。主要包括如下几个方面的内容:

  • 宏的基本介绍
  • 常见的坑
  • 宏的实际应用
  • 宏的命名

基础

这一部分内容基本上参考自:https://gcc.gnu.org/onlinedocs/cpp/Macros.html。

Object-like-Macro && Function-like Macro(对象宏和函数宏)

C中的宏分为两类,对象宏(object-like macro)和函数宏(function-like macro):

  • 对象宏常用于定义常量,譬如#define PI 3.14
  • 函数宏的行为类似于函数,譬如#define pf() printf()

Stringification(字符串化) – #操作符

有时候,我们希望将某个宏参数(针对函数宏)转化为字符串常量,此时需要用到#操作符,譬如:

#define WARN_IF(EXP) \
do { if (EXP) \
fprintf (stderr, "Warning: " #EXP "\n"); } \
while (0)
WARN_IF (x == 0);
// ==> do { if (x == 0) fprintf (stderr, "Warning: " "x == 0" "\n"); } while (0);

P.S: 宏处理器会对连续的字符串进行拼接?譬如"1""2""3"->"123"

Concatenation(级联) – ##操作符

##操作符能够帮助把两个标识符给连起来,譬如有如下一个结构体:

struct command
{
char *name;
void (*function) (void);
};

现在我们需要构建这么一个数组:

struct command commands[] =
{
{ "quit", quit_command },
{ "help", help_command },
// ...
};

除了直接定义之外,我们还可以使用##构成的宏进行处理:

#define COMMAND(name) { #name, name ## _command }
struct command commands[] =
{
COMMAND(quit),
COMMAND(help),
// ...
};

Variadic Macros(可变参数的宏)

可以声明参数可变的函数宏,譬如:

#define eprintf(...) fprintf (stderr, __VA_ARGS__)

When the macro is invoked, all the tokens in its argument list after the last named argument (this macro has none), including any commas, become the variable argument. This sequence of tokens replaces the identifier __VA_ARGS__ in the macro body wherever it appears.

除了上面这种,还可以使用其他姿势定义参数可变的函数宏:

#define eprintf(args...) fprintf (stderr, args)
#define eprintf(format, ...) fprintf (stderr, format, __VA_ARGS__)
#define eprintf(format, ...) fprintf (stderr, format, ##__VA_ARGS__)
#define eprintf(format, args...) fprintf (stderr, format , ##args)

Predefined Macros(预定义宏)

编译器提供了一些预定义宏,可以分为3种:

Undefining and Redefining Macros(取消和重置宏定义)

定义宏使用#define关键字,取消宏定义使用#undef,测试时常会用到。

一些坑

宏的最大诟病在于它存在很多坑,并且不太容易发现。

操作符优先级问题

这个坑是入门级别的坑,比较常见,不多说了,详见Operator Precedence Problems

分号吞噬

经常会为一段逻辑定义一个宏,这段逻辑往往包含多条语句,譬如:

#define DLog(format, ...) { \
fprintf(stderr, "[%s-%d: %s]", __FILE__, __LINE__, __func__); \
fprintf(stderr, format"\n", ##__VA_ARGS__); \
}

正常情况下,执行这段代码是没有问题的,譬如:

int main(void)
{
DLog("他说:我和%s谈笑风声", "美国的华莱士"); // mark
return 0;
}

这段代码有两个问题:其一是把mark处的分号去掉,其实也是没问题的,但是我们希望能从编译合法性的层面强制用户加上分号;其二是在如下场景使用会存在问题:

int main(void)
{
int b = 1;
if (b)
DLog("他说:我和%s谈笑风声", "美国的华莱士");
else
DLog("他说:你比%s跑得还快", "西方记者");
return 0;
}

这段代码无法通过编译,因为展开是这样的:

int main(void)
{
int b = 1;
if (b) {
fprintf(__stderrp, "[%s-%d: %s]", "print.c", 12, __func__);
fprintf(__stderrp, "他说:我和%s谈笑风声""\n", "美国的华莱士");
};
else {
fprintf(__stderrp, "[%s-%d: %s]", "print.c", 14, __func__);
fprintf(__stderrp, "他说:你比%s跑得还快""\n", "西方记者");
};
return 0;
}

do-while语句可以解决这两个问题:

#define DLog(format, args...) \
do { \
fprintf(stderr, "[%s-%d: %s]", __FILE__, __LINE__, __func__); \
fprintf(stderr, format"\n", ##args); \
} while (0)

P.S: 我认为在花括号外面加上()也可以解决这两个问题,这有什么其他的弊端吗?

单纯从吞分号的作用来看,({ /* do stuff */ })do { /* do stuff */ } while(0)效果相同,但是后者还有一个好处:可以处理break语句。函数宏中的break类似于函数中的return。如下:

#define DEBUG 0
#define DLog(format, ...) \
do { \
if (!DEBUG) { \
break; \
} \
fprintf(stderr, "[%s-%d: %s]", __FILE__, __LINE__, __func__); \
fprintf(stderr, format"\n", ##__VA_ARGS__); \
} while(0)

副作用重复问题

定义一个宏,作用是返回两个参数中的较小者。

#define MIN(X, Y) (((X) > (Y)) ? (Y) : (X))

似乎没什么问题,该保护的都保护了,但当我们这样使用时就有问题:

int main(void){
int a = 3, b = 5;
printf("%d\n", MIN(++a, --b));
return 0;
}

不难分析毛病所在,问题是:如何避免呢?即如何确保MIN(X, Y)中的XY为表达式时,该表达式只被调用一次呢?即副作用只产生一次呢?
解决方法是使用typeof操作符:

#define MIN(X, Y) \
({ \
typeof(X) _x = (X); \
typeof(Y) _y = (Y); \
_x > _y ? _y : _x; \
})

P.S: 关于typeof的副作用说明,详见本文末尾的的补充说明。

宏的实际应用

iOS平台下,与宏相关的第三方资源,最著名莫过于libextobjc(其作者也是ReactiveCocoa的作者之一),是一个非常好的学习资料。下面介绍一些频繁出现的并且有代表性的宏应用场景。

装X符 – @

经常在代码中看到一些以@为前缀的宏的使用。譬如:@within_main_thread(someBlock)@strongify(self)@weakify(self)within_main_thread是我在开发过程中,经常会用到的宏,目的是确保someBlock在主线程执行;后两个是ReactiveCocoa中引入的宏。

这些宏要求在使用时必须使用@为前缀进行修饰,据说这样做的主要目的是为了显眼(装X)。我更关心它们是如何做到的,根据代码,可以看到有两种常见的实现:

  • 在宏的开始插入空的try语句,即try {} @catch (...) {}(有时候是try {} @catch (...) {}
  • 在宏的开始插入空的autorelease语句,即autoreleasepool {}

try和autoreleasepool都要求补上@前缀,装X效果就达到了。问题来了,什么时候使用try的,什么时候使用autoreleasepool呢?

ReactiveCocoa(v2.5)的RACEXTScope.h的处理如下:

#if DEBUG
#define rac_keywordify autoreleasepool {}
#else
#define rac_keywordify try {} @catch (...) {}
#endif

即在debug状态使用autoreleasepool,release状态使用try。为什么是这样呢?

在release状态使用try而非autoreleasepool的原因是,编译器对空的try语句有优化处理,不会影响到性能;而对空的autoreleasepool语句没有优化,会有性能损耗,为了装X折损性能有些得不偿失。

P.S: 这个说法参考了这里

当然,try语句也有副作用,如下有一段代码:

{
// 定义一个返回值为BOOL类型的block
BOOL (^someBlock)(void) = ^BOOL {
NSLog(@"do nothing");
};
}

这段代码无法通过编译,因为someBlock没有提供匹配类型的返回值。

现在在这个block中插入一段空的try语句:

{
// 定义一个返回值为BOOL类型的block
BOOL (^someBlock)(void) = ^BOOL {
@try {} @catch(...) {} // 或者`@try {} @finally {}`
NSLog(@"do nothing");
};
}

现在可以通过编译了。try语句的缺陷很明了:它会将返回值错误给掩盖掉。

因此,ReactiveCocoa采取了折中方案,在debug时使用autorelease,在release状态使用try。

宏的命名

宏在头文件里定义,参考imeituan,宏的标识符都是大写,单词之间用_隔开,譬如FOO_BAR,有的宏是内部使用的,需要在首尾加上双下划线,譬如__FOO_BAR__

内联函数与宏有些相似,也经常在头文件中定义,只是函数名的习惯写法是小写,单词之间也使用_隔开,譬如foo_bar(...),对于内部内联函数,也在首尾加上双下划线,譬如__foo_bar__(...)

P.S: 关于内联函数和函数宏,需要进行更多的分析和对比,以后补充吧!

补充:typeof

typeof是C语言的基本操作符,typeof(x)返回x所对应的类型。x可以是一个类型名,也可以是一个表达式。
这里关心的是,当typeof的参数是一个表达式时,typeof(expression)会有副作用吗?即expression会被执行吗?譬如如下两行代码:

int i = 3;
typeof(++i) j = 4;
// i此时的值为多少?

答案是3。似乎typeof没有副作用(side effects),果真如此吗?看如下几行代码:

int i = 3;
typeof(int[++i]) j; // 声明一个长度为++i的数组
// i此时的值为多少?

答案是4,即j是一个长度为4的int型一维数组。此时typeof的副作用就体现出来了。这就蛋疼了,为什么有时候有副作用,有时没有副作用呢?

GCC-Typeof是这么说的:

The operand of typeof is evaluated for its side effects if and only if it is an expression of variably modified type or the name of such a type.

这句话不是很好懂,我参考了Side Effects Within A typeof Expression。大概意思是,当typeof(x)中的x是一个variably modified type时,x这个表达式会被执行。typeof(int[++i])就属于这种情况。

P.S: 对「variably modified type」的理解还不够,除了数组类型,还有其他的吗?

本文参考