最近就做一件事,整理 C 语言(2) — 宏的用法

117 阅读9分钟

一直以来都想做一个系列的分享,想做的 unique,还想到真真切切地能给大家带来价值。看到这儿,可能您猜到我是一个位梦想家。因为其实想一想这件事,也不是什么容易的事,很多大牛都没能做到事儿,你能做到吗? all in all 有梦想还是好的,有梦想,有目标努力才有动力,不容易迷茫,正好这两天新冠收尾,有点时间看看资料,动动笔尝试从一个知识点切入来给大家写点东西。

关于宏的基本概念

  • 什么是宏
  • 如何定义和使用宏
  • 为什么需要宏

什么是宏

根据一系列预定义的规则替换一定的文本模式。解释器或编译器在遇到宏时会自动进行这一模式替换。对于编译语言,宏展开在编译时发生,进行宏展开的工具常被称为宏展开器。

简单说一句,所谓宏就是定义好字符串来替换在文件中出现宏标识位置,就是简单的原样文本替换。不过这里有一些值得注意问题以及一些小技巧,也是我们今天要说的重点之一。还有一个重点要说就是宏应用场景。

#include <stdio.h>

#define W_WIDTH 800
#define W_HEIGHT 600


void createWindow(int w,int h)
{
    printf("create (%d,%d)",w,h);
}

int main(int argc, char const *argv[]){

    createWindow(W_WIDTH,W_HEIGHT);
    
    return 0;
}

只要简单了解过宏,对上面代码应该比较熟悉,例如开发软件也好,或者开发一个游戏也好,总会创建窗口,窗口会有大小,窗口的大小通常在整个应用中是固定不变的。这些在应用中通常不会改变的量,通常我们可以使用宏来定义。

这里就是宏定义的例子,帮助大家理解什么是文本原样替换,也就是将代码中出现 createWindow(W_WIDTH,W_HEIGHT) 中的 W_WIDTHW_HEIGHT 替换为为 800 和 600

上面是一个实现了对于数值求绝对值,不难看出两个问题,第一个问题就是在宏中是没有类型检查的,使用宏时候需要调用者自觉遵守规则来

什么是预处理

使用 gcc 将 c 语言源文件编译到可执行文件,其实并不是一步到位的,主要经历以下 4 个阶段,预处理是编译过程的一个阶段,关于这部分内容不做重点介绍,大家感兴趣可以自己查一查

  • 预处理
  • 编译
  • 汇编
  • 链接

预处理过程中具体做了哪些事

  • 宏展开
  • 头文件包含
  • 条件编译

如何定义宏

在宏的定义会使用 #define 这个关键字

  • 一般形式,也就是不带参数宏的定义

#define 宏名称 字符串

  • 带参数形式 #define 宏名称 字符串 接下来我们再来举一个例子,为了是说明如何使用带参数的宏,
#include <stdio.h>

#define ABS(a) (((a) > 0)?(a):(-(a)))

int main(int argc, char const *argv[]){
    
    int a = 12;
    
    printf("%d",ABS(a));//12
    
    return 0;
}

这里举了一个带参数的宏例子,也就是求数值的绝对值,比较简单。可能大家唯一会有疑惑的就是为什么有这么多的括号。这也是大家在定义宏时候需要注意一点,因为宏是原文替换,所以在替换过程中,没有考虑运算一些先后顺序,所以需要我们通过添加一些括号来保证能够得到我们想要的效果。

#include <stdio.h>

#define ABS(a) a > 0?a:-a

int main(int argc, char const *argv[]){
    
    int a = 12;
    
    printf("%d",ABS(-3+2));//5
    
    return 0;
}

这里因为没有添加括号,我们不难看出

ABC(-3+2) 会被替换为 -3+2 > 0?-3+2:--3 + 2 所以会得到 5。

define 定义以及撤销

#include <stdio.h>

#define PI 3.1415926

int main(int argc, char const *argv[]){
    
    float r = 12.0;
    
    float area = PI * r * r;
    
    printf("%f",area);
    
    return 0;
}
  • 这里简单定义了一个宏 PI ,然后利用 PI 和半径来计算圆的面积

那么我们如何来撤销已经定义好的宏

#include <stdio.h>

#define PI 3.1415926

int main(int argc, char const *argv[]){
    
    float r = 12.0;
 #undef PI   
    float area = PI * r * r;
    
    printf("%f",area);
    
    return 0;
}

在宏中 # 和 ## 的使用

  • 单个 # 作用是将参数字符串化也就是这里 #a 应该替换为 "a" 而不再是 a
  • 两个 # 作用是将两个两个对象拼接在一起,a##b 拼接后等价于 ab 合并一起写的

对于一个 # 来表示字符串字面量

#include <stdio.h>

#define STRING(str) str

int main(int argc, char const *argv[]){
    
    int a = 2;
    
    printf("%d",STRING(a));
    
    return 0;
}

也就是将 str 转换为字符串

#include <stdio.h>

#define STRING(str) #str

int main(int argc, char const *argv[]){
    
    int a = 12;
    printf("%s\n",STRING(a));
    printf("%s\n",STRING(abc         def));
    printf("%s\n",STRING(       abcdef));
    printf("%s","hello world");
    
    return 0;
}
  • 这里 abc def 只会保留一个空格
  • 这里 abcdef 会去掉之前的空格
#include <stdio.h>

#define NAME(a,b) a##b
#define HW "helloworld"
int main(int argc, char const *argv[]){
    
    char* hello = "hey";
    
    printf("%s\n",hello);//hey
    printf("%s\n",NAME(h,ello));//hey
    printf("%s\n",NAME(he,llo));//hey
    printf("%s\n",NAME(H,W));//helloworld
    printf("%s\n",NAME(HW,));//helloworld
    
    return 0;
}
  • a##b 表示连接将 ab 连接在一起,那么上面例子就不难理解了,而且这里也支持嵌套宏

我们在哪里见过宏(宏应用场景)

  • 避免头文件重复包含
  • 宏可以简单定义常量
  • 宏提供了代码复用性
  • 我们可以通过 for 或者 while 循环来让一行或者多行代码反复执行
  • 函数提供了,让代码块来可以反复使用

避免头文件重复包含

估计对于一些初学者,避免头文件重复包含使用场景大家并不陌生。下面通过代码解释一下是如何通过宏来防止头文件重复加载。

#ifndef _ADD_H_
#define _ADD_H_
int add(int a,int b);
#endif
  • 首先 ifndef 表示 if not define 含义也就是检查是否定义了宏 _ADD_H_ 如果没定义宏就会执行条件语句内部逻辑来定义宏,然后包含我们头文件的内容 endif 解释条件语句,这种条件语句结束方式与我们熟悉方式有所不同。
  • 如果已经定义了,不满足条件语句,也就是不会再次包含头文件的内容,避免了代码重复包含。

错误信息输出

  • 使用宏要比调用一个函数的成本低得多,下面的例子我们就定义了一个宏来实现错误信息打印功能来代替原有错误信息输出,关于这部分代码中有两点想要说的,也可能是你困惑的地方,第一个就是 \ 在宏中多行结尾处都出现了。表示扩展符号,因为宏定义时需要将定义字符串写在同一行,不过对于一些复杂情景这样做不便于阅读,所以通过 \ 可以一行内容拆分多行来写
#include <stdio.h>

#define error_t(A) \
    do{\
        printf(A); \
        putchar(10); \
        return 0;\
    }while(0)

int main(int argc, char const *argv[]){
    
    int a;
    int ret = scanf("%d",&a);
    if(ret < 0)
    {
        error_t("hello world");
    }
    
    printf("%d\n",a);
    
    return 0;
}

还有就是为什么要用 do while 这样结构来包括要执行的代码呢?这个可能是大家另一个疑惑的点,其实我们注意在定义宏 while(0) 后面没有加 ;,这样做的好处就是让我们调用宏和调用函数一样都需要在结尾处添加; 只是为代码看起来更加优雅。

举几个小例子

计算某一个字段在结构体中偏移量

#include <stdio.h>

#define OFFSETOF(type,field) ((size_t)&((type*) 0)->field)

typedef struct Employee
{
    int Id;
    char name[32];
    float salaray; 
} Employee;

int main(int argc, char const *argv[]){
    
    int offset = OFFSETOF(Employee,salaray);
    printf("offset=%d\n",offset);
    
    return 0;
}
  • 这里可能有疑惑的就是 ((type*) 0 ) 这里的 0 表示一个起始地址,然后将其转换为 type 类型的指针,-> 访问其属性的地址,
  • 在 ANSI C 语言标准中,值为 0 的常量可以强制转换为任何一种类型的指针,并且转换的结果为 NULL 因此 ((type*) 0) 的结果就是一个类型为 type 指向 NULL的指针
  • 内存地址起始于 0 ,这里指向 NULL ,拿 NULL 是一个空指针,既然是一个指针也一定存放在内存中某一个位置的地址,通过下面代码不难看出 NULL 指向内存起始位置 0 的内存地址。
char *str = NULL;
printf("%d\n",str);//0
  • 接下来问题又来了,我们尝试访问一个空指针的 field 为什么没有报错呢? 在 c 语言中,我们要访问一个地址空间,通常先找到地址,然后再去访问地址所指的空间。因为这里
#define OFFSETOF(type,field) ((size_t) &(((type*) 0)->field) )

因为这里 & 表示计算只关心地址,而不关心空间值,所有编译器在优化时,直接计算地址,而不会方法地址对应的空间,所以不会报错。

计算结构体某一个字段的大小

#include <stdio.h>

#define FIELDSIZE(type,field) sizeof( ((type*) 0)->field )

typedef struct Employee
{
    int Id;
    char name[32];
    float salaray; 
} Employee;

int main(int argc, char const *argv[]){
    
    int offset = FIELDSIZE(Employee,salaray);
    printf("offset=%d\n",offset);
    
    return 0;
}

字符字母大小写转换

#define UPCASE(ch) ((ch<='Z' && ch>='A')?(ch - 0x20):ch)
#define LOWCASE(ch) ((ch<='Z' && ch>='A')?(ch + 0x20):ch)

调试输出

在开发过程中,少不了调试输出一些信息,当然如果不是做嵌入式,在有条件情况下,还是推荐使用 IDE 提供功能通过设置断点来进行调试。不过在 c 语言开发中,很多情况下,并没有条件来进行断点调试,这时就少不了 printf 不过这样做比较繁琐,而且每次在发布前还需要做一些注释 printf 语句的工作。为此我们看一看如何通过宏来解决这个痛点。

int main(int argc, char const *argv[]){
    
    int a = 0;
    for(int i = 0; i < 10;i++)
    {
        printf("a=%d ",a);
    }
    
    printf("\n");
    
    return 0;
}

最近在开发 js ,个人喜欢代码中到处都是 console.log ,在这里多说两句,一个好的项目,一定有关于这些问题解决方案或者说明,也就是错误跟踪,例如日志输出,版本管理还有就是单元测试,参与到项目几乎很少有人愿意投入精力来做这些工作。

#define PRINT(fmt,...) \
    printf("[FILE:%s][FUNC:%s][LINE:%d] " fmt, \
    __FILE__,__FUNCTION__,__LINE__,__VA_ARGS__)

int main(int argc, char const *argv[]){
    
    int a = 0;
    for(int i = 0; i < 10;i++)
    {
        // printf("a=%d ",a);
        PRINT("a=%d",a);
    }
    
    printf("\n");
    
    
    return 0;
}

关于这部分代码感兴趣,没看不懂可以给我留言。这样会打印出信息出自哪一个文件的哪一个方法。我们还是简单分析吧,这里有一个 c 语言的可变参数,用过 js 应该了解这个语法现象。

接下来加一个控制是否输出信息调试开关,通过控制 DEBUG

#define DEBUG 0

#if DEBUG
#define PRINT(fmt,...) \
    printf("[FILE:%s][FUNC:%s][LINE:%d] " fmt, \
    __FILE__,__FUNCTION__,__LINE__,__VA_ARGS__)
#else 
#define PRINT(fmt,...) 
#endif
int main(int argc, char const *argv[]){
    
    int a = 0;
    for(int i = 0; i < 10;i++)
    {
        // printf("a=%d ",a);
        PRINT("a=%d",a);
    }
    
    printf("\n");
    
    
    return 0;
}