掌握如何使用C++中的宏

学会在C++中使用宏

内容参考自C++中宏定义C++中#与##Microsoft 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)这种无用的循环并进行优化,所以使用这种方法也不会导致程序的性能降低。

posted @ 2022-07-21 11:15  neumy  阅读(312)  评论(0编辑  收藏  举报