一、前言

一直以来,我都有这样一种感觉:当我学习一个新领域的知识时,如果其中的某个知识点在刚开始接触时,我感觉比较难懂、不好理解,那么以后不论我花多长时间去研究这个知识点,心里会一直认为该知识点比较难,也就是说第一印象特别的重要。

就比如 C 语言中的宏定义,好像跟我犯冲一样,我一直觉得宏定义是 C 语言中最难的部分,就好比有有些小伙伴一直觉得指针是 C 语言中最难的部分一样。

宏的本质就是代码生成器,在预处理器的支持下实现代码的动态生成,具体的操作通过条件编译和宏扩展来实现。我们先在心中建立这么一个基本的概念,然后通过实际的描述和代码来深入的体会:如何驾驭宏定义。

所以,今天我们就来把宏定义所有的知识点进行汇总、深挖,希望经过这篇文章,我能够摆脱心理的这个魔障。看完这篇总结文章后,我相信你也一定能够对宏定义有一个总体、全局的把握。

二、预处理器的操作

1. 宏的生效环节:预处理

一个 C 程序在编译的时候,从源文件开始到最后生成二进制可执行文件,一共经历 4 个阶段:

我们今天讨论的内容就是在第一个环节:预处理,由预处理器来完成这个阶段的工作,包括下面这 4 项工作:

  1. 文件引入(#include);

  2. 条件编译(#if..#elif..#endif);

  3. 宏扩展(macro expansions);

  4. 行控制(line control)。

2. 条件编译

一般情况下,C 语言文件中的每一行代码都是要被编译的,但是有时候出于对程序代码优化的考虑,希望只对其中的一部分代码进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。

简单的说:就是预处理器根据我们设置的条件,对代码进行动态的处理,把有效的代码输出到一个中间文件,然后送给编译器进行编译。

条件编译基本上在所有的项目代码中都被使用到,例如:当你需要考虑下面的几种情况时,就一定会使用条件编译:

  1. 需要把程序编译成不同平台下的可执行程序;

  2. 同一套代码需要运行在同一平台上的不同功能产品上;

  3. 在程序中存在着一些测试目的的代码,不想污染产品级的代码,需要屏蔽掉。

这里举 3 个例子,在代码中经常看到的关于条件编译:

示例1:用来区分 C 和 C++ 代码

#ifdef __cplusplus extern "C" { #endif
void hello();
#ifdef __cplusplus } #endif 

这样的代码几乎在每个开源库中都可能见到,主要的目的就是 C 和 C++ 混合编程,具体来说就是:

  1. 如果使用 gcc 来编译,那么宏 __cplusplus 将不存在,其中的 extern "C" 将会被忽略;

  2. 如果使用 g++ 来编译,那么宏 __cplusplus 就存在,其中的 extern "C" 就发生作用,编译出来的函数名 hello 就不会被 g++ 编译器改写,因此就可以被 C 代码来调用;

示例2:用来区分不同的平台

#if defined(linux) || defined(__linux) || defined(__linux__)    sleep(1000 * 1000); // 调用 Linux 平台下的库函数#elif defined(WIN32) || defined(_WIN32)    Sleep(1000 * 1000); // 调用 Windows 平台下的库函数(第一个字母是大写)#endif

那么,这些 linux, __linux, __linux__, WIN32, _WIN32 是从哪里来的呢?我们可以认为是编译目标平台(操作系统)为我们预先准备好的。

示例3:在编写 Windows 平台下的动态库时,声明导出和导入函数

#if defined(linux) || defined(__linux) || defined(__linux__)    #define LIBA_API #else  #ifdef LIBA_STATIC    #define LIBA_API  #else      #ifdef LIBA_API_EXPORTS          #define LIBA_API __declspec(dllexport)      #else          #define LIBA_API __declspec(dllimport)      #endif  #endif#endif
// 函数声明LIBA_API void hello();

这段代码是直接从我之前在 B 站录制的一个小视频里的示例拿过来的,当时主要是演示如何如何在 Linux 平台下使用 make 和 cmake 构建工具来编译,后来又小伙伴让我在 Windows 平台下也用 make 和 cmake 来构建,所以就写了上面这段宏定义。

  1. 在使用 MSVC 编译动态库时,需要在编译选项(Makefle 或者 CMakeLists.txt)中定义宏 LIBA_API_EXPORTS,那么导出函数 hello 的最前面的宏 LIBA_API 就会被替换成:__declspec(dllexport),表示导出操作;

  2. 在编译应用程序的时候,使用动态库,需要 include 动态库的头文件,此时在编译选项中不需要定义宏 LIBA_API_EXPORTS,那么 hello 函数最前面的 LIBA_API 就会被替换成 __declspec(dllimport),表示导入操作;

  3. 补充一点:如果使用静态库,编译选项中不需要任何宏定义,那么宏 LIBA_API 就为空。

3. 平台预定义的宏

上面已经看到了,目标平台会为我们预先定义好一些宏,方便我们在程序中使用。除了上面的操作系统相关宏,还有另一类宏定义,在日志系统中被广泛的使用:

FILE:当前源代码文件名;
LINE:当前源代码的行号;
FUNCTION:当前执行的函数名;
DATE:编译日期;
TIME:编译时间;

例如:

printf("file name: %s, function name = %s, current line:%d \n", __FILE__, __FUNCTION__, __LINE__);

三、宏扩展

所谓的宏扩展就是代码替换,这部分内容也是我想表达的主要内容。宏扩展最大的好处有如下几点:

  1. 减少重复的代码;

  2. 完成一些通过 C 语法无法实现的功能(字符串拼接);

  3. 动态定义数据类型,实现类似 C++ 中模板的功能;

  4. 程序更容易理解、修改(例如:数字、字符串常亮);

我们在写代码的时候,所有使用宏名称的地方,都可以理解为一个占位符。在编译程序的预处理环节,这些宏名将会被替换成宏定义中的那些代码段,注意:仅仅是单纯的文本替换。

1. 最常见的宏

为了方便后面的描述,先来看几个常见的宏定义:

(1) 数据类型的定义

#ifndef BOOL    typedef char BOOL;#endif
#ifndef TRUE    #define TRUE#endif
#ifndef FALSE    #define FALSE#endif

在数据类型定义中,需要注意的一点是:如果你的程序需要用不同平台下的编译器来编译,那么你要去查一下所使用的编译器对这些宏定义控制的数据类型是否已经定义了。例如:在 gcc 中没有 BOOL 类型,但是在 MSVC 中,把 BOOL 类型定义为 int 型。

(2) 获取最大、最小值

#define MAX(a, b)    (((a) > (b)) ? (a) : (b))#define MIN(a, b)    (((a) < (b)) ? (a) : (b))

(3) 计算数组中的元素个数

#define ARRAY_SIZE(x)    (sizeof(x) / sizeof((x)[0]))

(4) 位操作

#define BIT_MASK(x)         (1 << (x))#define BIT_GET(x, y)       (((x) >> (y)) & 0x01u)#define BIT_SET(x, y)       ((x) | (1 << (y)))#define BIT_CLR(x, y)       ((x) & (~(1 << (y))))#define BIT_INVERT(x, y)    ((x) ^ (1 << (y)))

2. 与函数的区别

从上面这几个宏来看,所有的这些操作都可以通过函数来实现,那么他们各有什么优缺点呢?

通过函数来实现:

  1. 形参的类型需要确定,调用时对参数进行检查;

  2. 调用函数时需要额外的开销:操作函数栈中的形参、返回值等;

通过宏来实现:

  1. 不需要检查参数,更灵活的传参;

  2. 直接对宏进行代码扩展,执行时不需要函数调用;

  3. 如果同一个宏在多处调用,会增加代码体积;

还是举一个例子来说明比较好,就拿上面的比较大小来说吧:

(1) 使用宏来实现

#define MAX(a, b)    (((a) > (b)) ? (a) : (b))
int main(){    printf("max: %d \n", MAX(1, 2));}

(2) 使用函数来实现

int max(int a, int b){    if (a > b)        return a;    return b;}
int main(){    printf("max: %d \n", max(1, 2));}

除了函数调用的开销,其它看起来没有差别。这里比较的是 2 个整型数据,那么如果还需要比较 2 个浮点型数据呢?

  1. 使用宏来调用:MAX(1.1, 2.2);一切 OK;

  2. 使用函数调用:max(1.1, 2.2); 编译报错:类型不匹配。

此时,使用宏来实现的优势就体现出来了:因为宏中没有类型的概念,调用者传入任何数据类型都可以,然后在后面的比较操作中,大于或小于操作都是利用了 C 语言本身的语法来执行。

如果使用函数来实现,那么就必须再定义一个用来操作浮点型的函数,以后还有可能比较:char 型、long 型数据等等。

在 C++ 中,这样的操作可以通过参数模板来实现,所谓的模板也是一种代码动态生成机制。当定义了一个函数模板后,根据调用者的实参,来动态产生多个函数。例如定义下面这个函数模板:

template<typename T> T max(T a, T b){    if (a > b)         return a;    return b;}
max(1, 2);     // 实参是整型max(1.1, 2,2); // 实参是浮点型

当编译器看到 max(1, 2) 时,就会动态生成一个函数 int max(int a, int b) { ... }

当编译器看到 max(1.1, 2.2) 时,又会动态生成另一个函数 float max(float a, float b) { ... }

所以,从代码的动态生成角度看,宏定义和 C++ 中的模板参数有点神似,只不过宏定义仅仅是代码扩展而已。

下面这个例子也比较不错,利用宏的类型无关,来动态生成结构体:

#define VEC(T)          \    struct vector_##T { \        T *data;       \        size_t size;    \    };
int main(){    VEC(int)   vec_1 = { .data = NULL, .size = 0 };    VEC(float) vec_2 = { .data = NULL, .size = 0 };}

这个例子中用到了 ##,下面会解释这个知识点。在前面的例子中,宏的参数传递的都是一些变量,而这里传递的宏参数是数据类型,通过宏的类型无关性,达到了“动态”创建结构体的目的:

struct vector_int {    int *data;    size_t size;}
struct vector_float {    float *data;    size_t size;}

这里有一个陷阱需要注意:传递的数据类型中不能有空格,如果这样使用:VEC(long long),那替换之后得到:

struct vector_long long {  // 语法错误    long long *data;    size_t size;}

四、符号:# 与 ##

这两个符号在编程中的作用也是非常巧妙,夸张的说一句:在任何框架性代码中,都能见到它们的身影!作用如下:

  1. #:把参数转换成字符串;

  2. ##:连接参数。

1. #: 字符串化

直接看最简单的例子:

#define STR(x) #xprintf("string of 123: %s \n", STR(123));

传入的是一个数字 123,输出的结果是字符串 “123”,这就是字符串化。

2. ##:参数连接

把宏中的参数按照字符进行拼接,从而得到一个新的标识符,例如:

#define MAKE_VAR(name, no) name##no
int main(void){    int MAKE_VAR(a, 1) = 1;     int MAKE_VAR(b, 2) = 2; printf("a1 = %d \n", a1);    printf("b2 = %d \n", b2);    return 0;}

当调用宏 MAKE_VAR(a, 1) 后,符号 ## 把两侧的 name 和 no 首先替换为 a 和 1,然后连接得到 a1。然后在调用语句中前面的 int 数据类型就说明了 a1 是一个整型数据,最后初始化为 1。

五、可变参数的处理

1. 参数名的定义和使用

宏定义的参数个数可以是不确定的,就像调用 printf 打印函数一样,在定义的时候,可以使用三个点(...)来表示可变参数,也可以在三个点的前面加上可变参数的名称。

如果使用三个点(...)来接收可变参数,那么在使用的时候就需要使用 __VA_ARGS__ 来表示可变参数,如下:

#define debug1(...)      printf(__VA_ARGS__)
debug1("this is debug1: %d \n", 1);

如果在三个点(...)的前面加上了一个参数名,那么在使用时就一定要使用这个参数名,而不能使用 __VA_ARGS__ 来表示可变参数,如下:

#define debug2(args...)  printf(args)
debug1("this is debug2: %d \n", 2);

2. 可变参数个数为零的处理

看一下这个宏:

#define debug3(format, ...)      printf(format, __VA_ARGS__)
debug3("this is debug4: %d \n", 4);

编译、执行都没有问题。但是如果这样来使用宏:

debug3("hello \n");

编译的时候,会出现错误: error: expected expression before ‘)’ token。为什么呢?

看一下宏扩展之后的代码(__VA_ARGS__为空):

printf("hello \n",);

看出问题了吧?在格式化字符串的后面多了一个逗号!为了解决问题,预处理器给我们提供了一个方法:通过 ## 符号把这个多余的逗号给自动删掉。于是宏定义改成下面这样就没有问题了。

#define debug3(format, ...)     printf(format, ##__VA_ARGS__)

类似的,如果自己定义了可变参数的名字,也在前面加上 ##,如下:

#define debug4(format, args...)  printf(format, ##args)

六、奇思妙想的宏

宏扩展的本质就是文本替换,但是一旦加上可变参数(__VA_ARGS__)和 ## 的连接功能,就能够变化出无穷的想象力。

我一直坚信,模仿是成为高手的第一步,只有见多识广、多看、多学习别人是怎么来使用宏的,然后拿来为己所用,按照“先僵化-再优化-最后固化”这个步骤来训练,总有一天你也能成为高手。

这里我们就来看几个利用宏定义的巧妙实现。

1. 日志功能

在代码中添加日志功能,几乎是每个产品的标配了,一般见到最普遍的是下面这样的用法:

#ifdef DEBUG    #define LOG(...) printf(__VA_ARGS__)#else    #define LOG(...) #endif
int main(){    LOG("name = %s, age = %d \n", "zhangsan", 20);    return 0;}

在编译的时候,如果需要输出日志功能就传入宏定义 DEBUG,这样就能打印输出调试信息,当然实际的产品中需要写入到文件中。如果不需要打印语句,通过把打印日志信息那条语句定义为空语句来达到目的。

换个思路,我们还可以通过条件判断语句来控制打印信息,如下:

#ifdef DEBUG    #define debug if(1)#else     #define debug if(0)#endif
int main(){    debug {        printf("name = %s, age = %d \n", "zhangsan", 20);    }    return 0;}

这样控制日志信息的看到的不多,但是也能达到目的,放在这里只是给大家开阔一下思路。

2. 利用宏来迭代每个参数

#define first(x, ...) #x#define rest(x, ...)  #__VA_ARGS__
#define destructive(...)                              \    do {                                              \        printf("first is: %s\n", first(__VA_ARGS__)); \        printf("rest are: %s\n", rest(__VA_ARGS__));  \    } while (0)
int main(void){    destructive(1, 2, 3);    return 0;}

主要的思想就是:每次把可变参数 VA_ARGS 中的第一个参数给分离出来,然后把后面的参数再递归处理,这样就可以分离出每一个参数了。我记得侯杰老师在 C++ 的视屏中,利用可变参数模板这个语法,也实现了类似的功能。

刚才在有道笔记中居然找到了侯杰老师演示的代码,熟悉 C++ 的小伙伴可以研究下下面这段代码:

// 递归的最后一次调用void myprint(){}
template <typename T, typename... Types>void myprint(const T &first, const Types&... args){    std::cout << first << std::endl;    std::cout << "remain args size = " << sizeof...(args) << std::endl;// 把其他参数递归调用  myprint(args...);}
int main(){    myprint("aaa", 7.5, 100);    return 0;}

3. 动态的调用不同的函数

// 普通的枚举类型enum {  ERR_One,  ERR_Two,  ERR_Three};
// 利用 ## 的拼接功能,动态产生 case 中的比较值,以及函数名。#define TEST(no) \    case ERR_##no: \      Func_##no(); \      break;
void Func_One(){    printf("this is Func_One \n");}
void Func_Two(){    printf("this is Func_Two \n");}
void Func_Three(){    printf("this is Func_Three \n");}
int main(){    int c = ERR_Two;    switch (c) {        TEST(One);        TEST(Two);        TEST(Three);    };return 0;}

在这个例子中,核心在于 TEST 宏定义,通过 ## 拼接功能,构造出 case 分支的比较目标,然后动态拼接得到对应的函数,最后调用这个函数。

4. 动态创建错误编码与对应的错误字符串

这也是一个非常巧妙的例子,利用了 #(字符串化) 和 ##(拼接) 这 2 个功能来动态生成错误编码码和相应的错误字符串:

#define MY_ERRORS     \    E(TOO_SMALL)      \    E(TOO_BIG)        \    E(INVALID_VARS)
#define E(e) Error_## e,typedef enum {    MY_ERRORS} MyEnums;#undef E
#define E(e) #e,const char *ErrorStrings[] = {    MY_ERRORS};#undef E
int main(){    printf("%d - %s \n", Error_TOO_SMALL, ErrorStrings[0]);    printf("%d - %s \n", Error_TOO_BIG, ErrorStrings[1]);    printf("%d - %s \n", Error_INVALID_VARS, ErrorStrings[2]);return 0;}

我们把宏展开之后,得到一个枚举类型和一个字符串常量数组:

typedef enum {    Error_TOO_SMALL,    Error_TOO_BIG,    Error_INVALID_VARS,} MyEnums;
const char *ErrorStrings[] = {    "TOO_SMALL",    "TOO_BIG",    "INVALID_VARS",};

宏扩展之后的代码是不是很简单啊。编译、执行结果如下:

0 - TOO_SMALL 1 - TOO_BIG 2 - INVALID_VARS

七、总结

有些人对宏爱之要死,多到滥用的程度;而有些人对宏恨之入骨,甚至用上了邪恶(evil)这个词!其实宏对于 C 来说,就像菜刀对于厨师和歹徒一样:用的好,可以让代码结构简洁、后期维护特别方便;用的不好,就会引入晦涩的语法、难以调试的 Bug。

对于我们开发人员来说,只要在程序的执行效率、代码的可维护性上做好平衡就可以了。

【推荐阅读】

C语言指针-从底层原理到花式技巧,用图文和代码帮你讲解透彻

嵌入式工程师常用的宏定义

C语言实现面向对象的原理

C语言中的结构体和联合体

万字整理,C语言最全入门笔记!

几个值得学习的C编程习惯

内联汇编很可怕吗?看完这篇文章,终结它!

5T技术资源大放送!包括但不限于:C/C++,Arm, Linux,Android,人工智能,单片机,树莓派,等等。在公众号内回复「peter」,即可免费获取!!

 记得点击分享在看,给我

提高代码逼格的利器:宏定义的使用相关推荐

  1. 提高代码逼格的利器:宏定义-从入门到放弃

    道哥的第 019 篇原创 文章目录 一.前言 二.预处理器的操作 1. 宏的生效环节:预处理 2. 条件编译 3. 平台预定义的宏 三.宏扩展 1. 最常见的宏 2. 与函数的区别 四.符号:\# 与 ...

  2. C中的预编译宏定义-转

    C中的预编译宏定义 2009-02-10 作者: infobillows 来源:网络 在将一个C源程序转换为可执行程序的过程中, 编译预处理是最初的步骤. 这一步骤是由预处理器(preprocesso ...

  3. 函数宏定义 与函数自定义

    为什么在C语言中要有函数宏定义这种形式呢?宏定义可以帮助我们防止出错,提高代码的可移植性和可读性等. 在软件开发过程中,经常有一些常用或者通用的功能或者代码段,这些功能既可以写成函数,也可以封装成为宏 ...

  4. c语言里宏定义算变量嘛,C语言宏定义的一些总结

    较大的C语言项目都会用大量的宏定义来组织代码,比较经典的代码就是Linux Kernel的头文件中用的宏定义.看起来宏展开就是做个替换而已,其实里面有比较复杂的规则,有关宏展开的语法规则此小结力图整理 ...

  5. c语言如何增加编译宏,C中的预编译宏定义

    在将一个C源程序转换为可执行程序的过程中, 编译预处理是最初的步骤. 这一步骤是由预处理器(preprocessor)来完成的. 在源流程序被编译器处理之前, 预处理器首先对源程序中的"宏( ...

  6. 【转自猫大】宏定义的黑魔法 - 宏菜鸟起飞手册

    宏定义在C系开发中可以说占有举足轻重的作用.底层框架自不必说,为了编译优化和方便,以及跨平台能力,宏被大量使用,可以说底层开发离开define将寸步难行.而在更高层级进行开发时,我们会将更多的重心放在 ...

  7. 函数式宏定义与普通函数的区别?C语言宏定义和宏定义函数 ?

    http://www.jb51.net/article/41869.htm 尽管函数式宏定义和普通函数相比有很多缺点,但只要小心使用还是会显著提高代码的执行效率,毕竟省去了分配和释放栈帧.传参.传返回 ...

  8. 宏定义#define整理

    一.宏定义#define 优点:一方面可以节省程序的空间上的篇幅,另外,恰当地使用宏定义可提高程序的时间效率.代码可以写的通俗易懂.可以提高程序的清晰性.可读性,使于修改移植等. 缺点:宏定义的使用实 ...

  9. 宏定义中的 ## ... __VA_ARGS__

    宏定义中的##操作符和... and _ _VA_ARGS_ _ 1.Preprocessor Glue: The ## Operator 预处理连接符:##操作符 Like the # operat ...

最新文章

  1. ros 配置udev
  2. 全球及中国磁性分离滑轮行业竞争战略及未来产销需求预测报告2022版
  3. [图神经网络] 图节点Node表示(DeepWalk与Node2Vec)
  4. 机器学习基础—Kaggle泰坦尼克预测(完整分析)
  5. 理财产品利息可每天提取吗?
  6. 【postMan】发送post请求,返回错误码415
  7. 怎么阻止acrobat自动更新升级?
  8. vb rs.recordCount=-1的解决办法
  9. android捕获全局异常lin,全局获取 (Activity)Context,实现全局弹出 Dialog
  10. 《东周列国志》第三十六回 晋吕郤夜焚公宫 秦穆公再平晋乱
  11. bilibili弹幕爬虫, 2019-1-10
  12. 五线谱上的Linux旋律
  13. 园林景观cad_5+ 园林景观设计PSD树木建筑人物鸟瞰图效果图ps后期分层素材库配景A...
  14. 腾讯云配置密钥使用putty登录 PuTTY实现Windows向Linux上传文件
  15. Win11资源管理器(文件夹)出现的工具栏怎么隐藏?
  16. 国徽FLASH SM25QH256M烧录问题总结
  17. 安卓webview的一些坑
  18. picker-view——实现picker自定义样式
  19. 自动关闭MessageBox
  20. 数据库binlog(二进制日志binary log)

热门文章

  1. 基于Python 的京东秒杀 火车票脚本
  2. 戴尔服务器总是自动重启,Dell R710服务器重启问题
  3. Echarts之气泡图图例
  4. Java局部变量和成员变量的区别
  5. Qt终端输入qtcreator打不开IDE,点击图标是可以打开的
  6. 无法进入bios,bios黑屏,无法进入pe如何解决
  7. 强化学习-reward-shaping-好奇心驱动-调研
  8. 数学建模(二)—— 蔬菜供应方案设计
  9. UI设计初学者如何避免走弯路?
  10. Node.js 的 perf_hooks