掌握如何使用C++中的宏
学会在C++中使用宏
宏是C/C++所支持的一种语言特性,属于预处理指令的一种。
宏的语法规范
宏的简单定义如下
// 定义圆周率
#define PI 3.14159265
// 定义一个空指针
#define NULL ((void*)0)
// 也可以定义一个没有任何值的宏
#define SYSTEM_API
C/C++中已经存在了很多预定义的基本宏。NULL就是一个语言已经预定义的宏。SYSTEM_API这个宏没有定义任何值,替换后等价于什么都没写,比如像下面两条语句就是等价的。
宏还可以向函数一样携带参数:
#define INCRE(x) x+1
但是宏是直接采取符号替换的方式进行的,即使对表达式也是如此!因此会出现有趣的一幕:
int x = 66;
int y = INCRE(x+1);
// y = x + 1 = 67 ? 错!
// y = (x + 1) + 1 = 68 才对!
符号#
和##
在带有参数的宏中,可能会使用#
和##
这两种特殊符号
#
符号称为构串操作符,将传入宏的参数转换为字符串常量(也就是带引号那种)
#define TOSTRING(s) #s
int a = 114514;
printf("%d", sizeof(TOSTRING(114514)));
// 输出7,说明TOSTRING返回的确实已经是一个字符串常量了
##
符号成为合并操作符,像python中的eval一样,根据传入的值组合生成标识符。而且神奇的是,输入的参数并不是字符串常量。这也可以理解,毕竟是在预处理阶段,
整体上和python的eval性质不一样。
#define TEMP_VAR(name) temp_##name
int temp_yalimasinei = 114514;
printf("%d", TEMP_VAR(yalimasinei));
可变长参数列表
宏也可以支持可变长参数。这个特性可以用来对类似printf这样的函数进行封装,使用时,使用__VA_ARGS__
这个系统预定义宏来代替printf的参数,例如
#define trace(fmt, ...) printf(fmt, __VA_ARGS__)
trace("got a number %d", 34);
多行的宏
如果宏的内容很长,那么可以写成多行,每行的末尾添加\
符号。
需要注意的是,每行的\
符号后不能有任何其他符号,甚至不能包括注释。
取消当前对某个宏的定义行为
如果想要取消对一个宏的定义,可以使用#undef预处理指令,比如要取消之前定义的PI宏,只要像下面即可
#undef PI
#undef trace // 也能取消带参数的宏
与宏搭配使用其他预处理指令
和#ifdef和#if等预处理指令配合
通过和预处理指令配合,达到一定的代码开关控制,常见的比如在跨平台开发时,对不同的操作系统启用不同的代码。
#ifdef _WIN32 // 查看是否定义了该宏,Windows默认会定义该宏
// 如果是Windows系统则会编译此段代码
OutputDebugString("this is a Windows log");
#else
// 如果是mac,则会编译此段代码
NSLog(@"this is a mac log");
#endif
如果要查看多个宏是否定义过,可使用下面的预处理指令
#if defined(_WIN32) || defined(WIN32)
// 如果是Windows系统则会编译此段代码
OutputDebugString("this is a Windows log");
#endif
#ifdef之后的宏只要定义过就会满足条件,而#if则会看后面的宏的内容是否为真了。
#define ENABLE_LOG 1
#if ENABLE_LOG
trace("when enabled then print this log")
#endif
如果把宏的定义改成#define ENABLE_LOG 0,那么就不会满足条件了,也就不会打印日志了。在使用#if时,后面的宏ENABLE_LOG必须定义为整数才行,定义为其他的会报编译错误。
防止重复包含头文件
在C、C++中如果重复包含了同一个头文件,有可能会带来编译错误,所以我们应当避免这种事情发生,利用预处理指令和宏可以有效防止此类错误发生。具体措施为,在每一个头文件的开始和结束,加上如下的语句
#ifndef __SYSTEM_API_H__
#define __SYSTEM_API_H__
// 头文件的内容
...
#endif
第一次包含此文件时,__SYSTEM_API_H__还没有被定义,因此,头文件的内容被顺利的包含进来,同时,定义了该宏,如果此头文件被重复包含了,那么文件第一行的预处理指令将不会满足,因此文件也就不会被重复包含了。
打印错误信息
在输出日志时,除了输出错误信息外,如果能够把当前的文件名和行号一并打印出来,那就好了,这样的话就可以更快的定位问题了,之前说过,编译器已经为我们预定义了当前文件名和当前行号的宏,我们只要在输出日志时输出这些信息即可。比如
printf("%s %d printf message %s\n", __FILE__, __LINE__, "some reason");
这样有一个问题,如果每次输出信息都这么写,太繁琐了,而且,大部分都一样,因此,我们可以用宏来封装一下
#define trace(fmt, ...) printf("%s %d "fmt, __FILE__, __LINE__, ##__VA_ARGS__)
// 这样在使用时可以这么写,同样可以输出当前行号和文件名
trace("printf message %s\n", "some reason");
如此,就可以把注意力集中在要输出的信息上,而不被__FILE__,__LINE__干扰了,同时也少写了一些繁琐的代码。
减少重复代码
如果有一个类,它携带有很多的属性,而每一个属性都必须进行实现set和get函数,那么就可以使用宏来减少代码的输入。
// 类Widget拥有非常多的属性,但每一个属性的相应函数实现是类似的
class Widget
{
public:
// Width属性
int getWidth() const
{
return _Width;
}
void setWidth(int Width)
{
// 当设置新值时,打印一条日志,方便调试
printf("setWidth %d\n", Width);
_Width = Width;
}
// Height属性
int getHeight() const
{
return _Height;
}
void setHeight(int Height)
{
// 当设置新值时,打印一条日志,方便调试
printf("setHeight %d\n", Height);
_Height = Height;
}
// 之后还有其他的属性定义......
};
可以发现,虽然属性很多,但是属性的处理基本是一致的,因此可以使用宏封装一下
// 定义一个PROPERTY宏来生成相应的函数实现
#define PROPERTY(Name)\
int get##Name() const\
{\
return _##Name;\
}\
void set##Name()\
{\
printf("set"#Name" %d\n", Name);\
_##Name = Name;\
}
// 接下来就可以重新定义Widget类了
class Widget
{
public:
// Width属性
PROPERTY(Width)
// Height属性
PROPERTY(Height)
// 其他的属性
PROPERTY(Color)
PROPERTY(BackgroundColor)
// ......
};
这样是不是简单多了,需要注意的是,上述例子的属性类型固定为了int,实际中可以扩展PROPERTY宏来支持不同的参数类型。而由于宏不支持调试,因此,使用宏生成的函数将不能在IDE中单步调试,因此,如果函数实现复杂的话,还是少用为妙。
不能武断说用宏好还是用宏不好,应该依据实际情况而定。
使用宏的时候容易出问题的地方
优先级的改变
由于宏只是简单的替换,所以在某些情况下会不知不觉的改变运算的优先级。比如,如果定义了下面这样的宏
#define ADD(x, y) x + y
int value = ADD(2, 3) * ADD(4, 5);
我们期望先分别计算2和3,4和5的和,然后相乘得出45。但实际宏展开后的代码为
int value = 2 + 3 * 4 + 5;
由于乘号的优先级大于加号,所以是先计算了3和4的积,然后再与2和5相加,得出了不期望的结果19。导致错误,这种问题的修改策略就是在宏定义时加上括号,包括参数都加上括号。即
#define ADD(x, y) ((x) + (y))
宏名称的冲突
如果定义的宏名称不小心和其他源码中的名称冲突的话,也会造成编译错误,比如定义了一个宏time,那么就有可能会和标准库函数中的time函数冲突。
宏参数中含有逗号
宏可以携带参数,而参数并没有什么要求,宏只是拿到参数的值去替换之后的内容,但如果宏参数中含有逗号,那么就会带来歧义了。比如
// 该宏本身没什么实际使用意义,只是为了说明问题
#define segment(seg) seg
// 没有问题
segment(int x = 1; int y = 3);
// 编译错误,因为宏展开时把","视为参数间的分隔符
segment(int x = 1, y = 3);
// 解决办法就是给宏参数加上括号,使其为一体
segment((int x = 1, y = 3));
小技巧:学会使用do{ }while(0)
在阅读第三方源码时,经常见到宏定义中有一个do{ }while(0)语句,这是为什么呢?比如我们定义一个交换两个值的宏
#define swapint(x, y) int tmp = x; x = y; y = tmp;
在大部分情况下可以工作,但是如果之前已经定义了tmp这个变量,则就会出错了,那我们可以把tmp换成平时不常用的名字,就大大降低了重名的概率了,这确实是一个办法,但不完美,因为即使这样,依然无法用在switch语句中
int x = 1, y = 2;
switch (value)
{
case 1:
// 编译出错,因为case语句中不允许声明变量
swapint(x, y);
break;
}
那我们想,是否可以定义宏的时候,加上一层大括号,嗯,确实可以。
#define swapint(x, y) {int tmp = x; x = y; y = tmp;}
这样便可以用在switch语句中了。是否就完美了呢,依然不行,因为还可能会影响if语句的执行,看下面的例子
int x = 1, y = 2;
if (x < y)
swapint(x, y);
else
someaction();
// 上面的代码展开会得到下面的情况
if (x < y)
{int tmp = x; x = y; y = tmp;};
else
someaction();
// 编译出错,因为在else之前多了一个分号,导致语法错误
那么能不能不加分号?
可以,但是C++程序员一般都习惯在末尾添加分号,而且不过不加分号,也会影响IDE的自动代码格式化
使用do{….}while(0)
把它包裹起来,成为一个独立的语法单元,从而不会与上下文发生混淆。
同时因为绝大多数的编译器都能够识别do{…}while(0)
这种无用的循环并进行优化,所以使用这种方法也不会导致程序的性能降低。