C++宏的使用方法总结

宏是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携带有两个参数xy,当使用此宏时,只需将传入宏的两个参数直接的相乘即可。
那宏的参数是否支持表达式呢,答案是支持的,但由于宏只是简单的展开替换,因此我们就遇到了宏第一个容易出错的点

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__干扰了,同时也少写了一些繁琐的代码。

减少重复代码

如果有一个类,它携带有很多的属性,而每一个属性都必须进行实现setget函数,那么就可以使用宏来减少代码的输入。

// 类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)这种无用的循环并进行优化,所以使用这种方法也不会导致程序的性能降低。

总结

宏有时能够方便我们编程,有时又能使我们陷入无底深渊。有时我们赞赏宏的优点从而责怪某些语言里为什么没有宏,有时又唾沫横飞大骂宏的缺点。真是又爱又恨。有句话叫做存在即合理,我想宏也适用,宏是否有益,一是取决于本身,更多的是取决于使用它的人吧!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容