宏是C/C++所支持的一种语言特性,我对它最初的印象就是它可以替换代码中的符号,最常见的例子便是定义一个圆周率PI
,之后在代码中使用PI
来代替具体圆周率的值。
确实如此,宏提供了一种机制,能够使你在编译期替换代码中的符号或者语句。当你的代码中存在大量相似的、重复的代码时,使用宏可以极大的减少代码量,便于书写。
在很多书上以及网文上,宏都是不被推荐使用的,因为它会带来一些隐晦的坑,让你不经意间便受其所困。但是,正如世间的万千事物,没有什么是完全有害的,也没有什么是完全有益的,只在于如何看待它和使用它。
宏的语法
定义
宏使用#define
定义,一种简单的定义如下
// 定义圆周率
#define PI 3.14159265
// 定义一个空指针
#define NULL ((void*)0)
// 定义一个宏的名字为 SYSTEM_API,但是没有值
#define SYSTEM_API
上述例子中定义了一个圆周率PI
,那么代码中需要用到圆周率的地方就可以使用PI
来代替,比如语句
double perimeter = diameter * 3.14159265;
就可以写成
double perimeter = diameter * PI;
而该代码在编译时,编译器又会把PI
替换为它所定义的值(3.14159265)进行编译,因此,这两条语句是等价的。
C语言中的NULL
就是一个语言已经预定义的宏。预定义指的是你不必亲自定义,编译器在编译时,已经提前定义好了。
SYSTEM_API
这个宏没有定义任何值,替换后等价于什么都没写,比如像下面两条语句就是等价的。
class SYSTEM_API CSystem;
和
class CSystem;
是等价的。做过Windows模块开发的同学,可能已经意识到,上述的宏经常和预处理指令#ifdef
配合来控制模块的导出导入符号。
参数
宏还可以向函数一样携带参数,像下面这样
#define MUL(x, y) x * y
int ret = MUL(2, 3); ==> int ret = 2 * 3;
这使它看起来特别像函数,它跟函数的区别有以下几点
- 宏是简单的符号替换,不会检查参数类型,而函数会严格检查输入的参数类型
- 因为宏是在编译期进行的符号替换,所以在运行时,不会带来额外的时间和空间开销,而函数会在运行时执行压栈出栈的操作,存在函数调用的开销
- 宏是不可以调试的,而函数可以进行单步调试
- 宏不支持递归,函数支持递归
在上例中,MUL
携带有两个参数x
和y
,当使用此宏时,只需将传入宏的两个参数直接的相乘即可。
那宏的参数是否支持表达式呢,答案是支持的,但由于宏只是简单的展开替换,因此我们就遇到了宏第一个容易出错的点
int ret = MUL(2 + 3, 4);
我们的本意是先计算出2加3的和,然后与4相乘,结果为20。但实际上该宏展开后的代码为
int ret = 2 + 3 * 4;
看到了吗,宏就是非常直接的把x
换成2 + 3
,把y
换成4
,由于运算符优先级的缘故,最终算的结果为14,一个非预期的结果。
如何修正这个问题呢,就是在定义时把参数都加上括号
#define MUL(x, y) ((x) * (y))
这样的话,上述例子就被展开成为
int ret = ((2 + 3) * (4));
从而保证了运算的顺序与期望的顺序一致。
符号#
和##
#
符号把一个宏参数直接转换为字符串,例如
#define STRING(x) #x
const char * str = STRING(test);
// str的内容就是"test"
##
符号会连接两边的值,像一个粘合剂一样,将前后两部分粘合起来,从而产生一个新的值,例如
#define VAR(index) INT_##index
int VAR(1);
// 宏被展开后将成为 int INT_1;
可变参数
宏也可以支持可变长参数,这个特性可以用来对类似printf
这样的函数进行封装,使用时,使用__VA_ARGS__
这个系统预定义宏来代替printf
的参数,例如
#define trace(fmt, ...) printf(fmt, ##__VA_ARGS__)
// 这样我们就可以使用我们自己定义的宏 trace 来打印日志了
trace("got a number %d", 34);
至于为什么要在__VA_ARGS__
之前添加##
符号,主要是因为,如果不添加的话,当只有fmt参数,__VA_ARGS__
为空时,之前的逗号不会删除
trace("got a number"); ==> trace("got a number",);
从而导致编译错误,而加上##
符号的话,将使预处理器去除掉它前面的那个逗号。
多行的宏
如果宏的内容很长,很多,那么可以写成多行,每行的末尾添加\
,以表明后面的一行依然是宏的内容。比如
#define ADD(x, y) do { int sum = (x) + (y); return sum; } while (0)
// 宏的内容比较长,也没有缩进,易读性较差,因此转为多行
#define ADD(x, y) \
do \
{\
int sum = (x) + (y);\
return sum;\
} while (0)
取消宏定义
如果想要取消对一个宏的定义,可以使用#undef
预处理指令,比如要取消之前定义的ADD
宏,只要像下面即可
#undef ADD
编译器参数定义以及预定义宏
除了使用#define
预处理器来定义宏之外,也可以通过编译器参数来定义宏,具体可参考各平台的编译器参数。编译器也会在编译某文件时预定义一些宏供使用,常见的有以下几个:
宏 | 类型 | 含义 |
---|---|---|
_FILE_ | const char * | 当前所编译的文件名称(绝对路径) |
_LINE_ | int | 当前所在的行号 |
_FUNCTION_ | const char * | 当前所在的函数名称 |
_DATE_ | const char * | 当前的日期 |
_TIME_ | const char * | 当前的时间 |
宏的调试
宏不支持在运行时调试,但如果宏太过于复杂的话,出错也是难免的,因此,可以利用宏自身的特性把宏展开后的内容打印出来,来方便我们查错。
这里有一个技术前提,如果想要在编译时打印一些信息,可以使用如下预处理指令:
#pragma message ("will print this message")
但是,如果想要打印某个宏的内容,会发现编译器会报错。比如我们想要打印宏SOMEMACRO
的内容。直接使用#pragam message (SOMEMACRO)
是不行的,原因是该指令必须接收一个字符串,可使用如下代码协助输出SOMEMACRO
的内容。
#define SOMEMACRO 123456
#define MACROTOSTR2(x) #x
#define PRINTMACRO(x) #x " = " MACROTOSTR2(x)
#pragma message(PRINTMACRO(SOMEMACRO))
编译上述代码便会在输出窗口打印SOMEMACRO = 123456
的内容。
对于带参数的宏也是适用的:
#define SOMEMACRO 123456
#define MACROPARAM(x) new int(x);
#define MACROTOSTR2(x) #x
#define PRINTMACRO(x) #x " = " MACROTOSTR2(x)
#pragma message(PRINTMACRO(MACROPARAM(SOMEMACRO)))
上述代码块在编译时会打印出MACROPARAM(SOMEMACRO) = new int(123456);
,也就是宏展开后的内容。
因此,当宏出现问题时,可以使用该方法打印出宏展开后的内容,然后调试展开后的内容,找到错误原因,接着同步修正宏本身的错误。
常见的使用场景
和#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) 把它包裹起来,成为一个独立的语法单元,从而不会与上下文发生混淆。
同时因为绝大多数的编译器都能够识别do{…}while(0)这种无用的循环并进行优化,所以使用这种方法也不会导致程序的性能降低。
总结
宏有时能够方便我们编程,有时又能使我们陷入无底深渊。有时我们赞赏宏的优点从而责怪某些语言里为什么没有宏,有时又唾沫横飞大骂宏的缺点。真是又爱又恨。有句话叫做存在即合理
,我想宏也适用,宏是否有益,一是取决于本身,更多的是取决于使用它的人吧!