C语言中的预处理器

预处理器

作者注:

最近在阅读U-Boot的源码时,发现在头文件中使用了较多的宏定义语句,顿时觉得这个宏在实现C语言程序可移植性特性起着重要作用,故本次简单地复习一下预处理器…

编译一个C程序会经过四个阶段:预处理、编译、汇编、链接。而第一阶段的预处理阶段到底时做什么呢?答案就是:在源代码编译之前对其进行一些文本性质的操作。那么,什么是文本性质的操作呢? 就个人理解来说,应该是想表达一种使用某种文本去替换另一种文本的操作

它的主要任务有:

  • 删除注释
  • 插入被#include指令包含的文件的内容。
  • 定义和替换#define指令定义的符号。
  • 确定代码的部分内容是否应该根据一些条件编译指令进行编译(这个在一些源码中蛮常见的,下面会简单说一下)。

一、预定义符号

预处理定义的符号可看如下表的内容

符号样例值含义
__FILE__“name.c”进行编译的源文件名
__LINE__25文件当前行的行号
__DATE__”Jan 31 1997“文件被编译的日期
__TIME__”18:04:30“文件被编译的时间
__STDC__1如果编译器遵循ANSI C,其值就为1,否则未定义。

示例:

#include<stdio.h>
int main()
{
    printf("__FILE__: %s\n__LINE__: %d\n__DATE__: %s\n__TIME__: %s\n__STDC__: %d\n",__FILE__,__LINE__,__DATE__,__TIME__,__STDC__);
    return 0;
}
/**
在我本地运行
结果是:
__FILE__: D:\C_Code\test20\main.c 
__LINE__: 4
__DATE__: Nov 25 2021
__TIME__: 20:59:37
__STDC__: 1
*/

这些预定义符号,其实是不怎么常用的,简单了解即可。

二、#define

2.1 命名符号

#define常用的用法就是使用其为一个数值命名符号,它的正式描述及一些示例:

/**
* 每当有符号name出现在这条指令的后面时,预处理器就会把它替换为stuff
* 这个name不仅仅可以使用数值字面值常量,它可以是任何文本
*/
#define name stuff

//示例1. 为关键字register创建一个简短的别名
#define reg register
//示例2. 用一个更具描述性的符号表示实现无限循环的for语句
#define do_forever for(;;)
//示例3. 用CASE 替换常规的 case关键字,自动地在每个case前增加了break,避免了使用switch时忘记添加break。
#define CASE break;case

当然,stuff也可以定义多行,如果你的stuff很长的话,它的示例:

/**
* 这里C语言中有一个特性: 相邻字符串常量可以被自动连接为一个字符串。
* 通过使用反斜杠 \ stuff 可以定义多行。
*/
#define PRINT_TEST printf("__FILE__: %s\n__LINE__: %d\n"\
        "__DATE__: %s\n__TIME__: %s\n__STDC__: %d\n", \
        __FILE__,__LINE__,__DATE__,__TIME__,__STDC__);

🚩注意:建议不要在宏定义的尾部加上分号,如果你加了分号,就会在下文的没有宏的语句加分号与有宏的语句中不加分号之间产生混淆。

2.2 宏的使用

#define允许把参数替换到文本中,该实现通常被称为定义宏,它的声明方式为:

/**
* parameter-list 参数列表是一个由逗号分隔的符号列表,它们可能出现在stuff中。
* 参数列表的左括号必须与name紧邻,若两者之间存在空白,参数列表就会被解释为stuff的一部分
*/
#define name(parameter-list) stuff

当宏被使用时,参数列表中的参数的实际调用值江湖被替换到stuff中,下面是一些示例。

示例

#define SQUARE(x) x*x

如果你在程序中使用该宏,并提供一个参数10,即使用SQUARE(10),那么预处理器将会使用表达式10 * 10替换。

易被忽视的坑

假设在程序有如下的这个程序片段:

a = 5;
printf("%d\n",SQUARE(a+1));
//也许你会想,这结果应该是 36 可惜的是:它不是,而是 11

它这个a+1并不会先进行计算,预处理器只是将其当作一个文本替换掉stuff部分的x*x,也就是变成了5+1*5+1,显而易见,结果是11。如果你想结果为36,那么我们只需为其添加一对括号就行。

2.3 替换

替换三步骤

  1. 在调用宏的时候,首先对参数进行检查,看看是否包含了任何由#define定义的符号。如果是,则它们首先会被替换。

  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,其参数名会被值所替代。

  3. 最后,再次对结果文本进行文本扫描,如果还包含#define定义的符号,重复上诉步骤

宏参数和#define定义可以包含其他#define定义的符号,但宏不可以出现递归。

将宏参数插入字符串常量

  • 使用邻近字符串自动连接的特性将字符串分成几段,每一段都是一个宏参数。(这个技巧显然只能针对参数为字符串常量的)

    #define PRINT(FORMAT,VALUE) \
            printf("The Value is "FORMAT"\n",VALUE)
    ...
    //在程序中的使用方式
    PRINT("%d",10);
    
    
  • 将一个宏参数转换为一个字符串。

    • 使用#argument,它会被翻译为argument,不涉及任何其他动作,字面替换。

      #define PRINT(VALUE) \
              printf("The expression is " \
              #VALUE "\n",VALUE)
      ...
      //在程序中的使用方式
      PRINT(10+10);
      // 输出为: The expression is 10+10
      
    • 使用##结构,它会将位于其两边的符号连接成一个符号。

      #include<stdio.h>
      #define ADD_TO_SUM(sum_number,value) \
                      sum ## sum_number += value
      int main()
      {
          int sum5=20;
          ADD_TO_SUM(5,10);
          printf("%d",sum5);
          return 0;
      }
      // 相当于 sum 与 5 进行连接,即 stuff 实际变成了 sum5 += 10;
      
2.4 宏与函数

如果我们想要执行一些简单的计算,如求两数最大值,可以尝试着使用宏。

/**
* 这里使用括号是避免使用参数时出现之前提过的坑。
*/
#define MAX(a,b) ((a)>(b)?(a):(b))

当然,实现这个也可以使用函数,但是你需要考虑一些东西。

  • 程序规模会比宏大
  • 函数是需要栈参与到其中的,可能会带来一些额外的时间花销。
  • 函数的参数必须声明为一种特定的类型。

宏的不利之处: 其定义代码的拷贝将插入程序中,如果太长的话,将会大幅度增加程序的长度。

当然,有一些任务函数是无法做到的,如:

//type 是一个类型,无法作为函数参数传递。
#define MALLOC(n,type) \
((type *)malloc ((n)*sizeof(type)))

如果你在使用宏时,宏参数在定义中出现的次数超过一次,若参数具备“副作用”,那么当你使用这个宏时将会出现危险,导致不可预料的结果。副作用: 在表达式求值时出现的永久性结果。

示例

int x = 5, y = 8;
int z = MAX(x++,y++);
//结果是 x = 6,y = 10,z = 9。 好好思考一下这是为什么呢?

因为#define宏的行为和真正的函数相比存在一些不同的地方,区别如下:

属性#define函数
代码长度每次使用时,宏代码都被插入程序中,除了非常小的宏之外,程序的长度将会大幅度增长函数代码只出现于一个地方,每次使用这个函数时,都调用哪个地方的同一份代码。
执行速度更快存在函数调用/返回的额外开销。
操作符优先级宏参数的求值是在所有周围表达式的上下文环境里,除非它们加上括号,否则邻近操作符的优先级可能会产生不可预料的结果。函数参数只在函数调用时求值一次,它的结果值传递给函数,表达式的求值结果更容易预测。
参数求值参数每次用于宏定义时,它们都重新求值。由于多次求值,具有副作用的参数可能会产生不可预料的结果。参数在函数被调用前只求值一次。在函数中多次使用参数并不会导致多种求值过程。参数的副作用并不会造成任何特殊的问题。
参数类型宏与类型无关。与类型有关。
2.5 其他

#undef

该指令用于移除一个宏定义。用法;

#undef name

命令行定义

有时,你可以在一些编译器中看到-Dxxx啥的,这就是表示你在定义一些符号。

示例

-Dname
-Dname=stuff

三、条件编译

在编译一个程序时,若我们可以选择某条语句或某组语句进行翻译或者被忽略,常常会显得很方便。使用条件编译你将会很容易达到这个目的。

它的简单语法形式:

/**
* constant-expression 是一个常量表达式,由预处理器进行求值。非零值表 真。
* 如 constant-expression 为真 statements部分就会被编译。
* constant-expression 可以是 字面值常量 也可以是由 #define定 义的符号
*/

#if constant-expression
statements
#endif 

示例

//想编译就将DEBUG置为非0值,不然就置为0
#define DEBUG 1
#if DEBUG
    printf("x=%d,y=%d\n",x,y);
#endif

它的完整语法:

#if constant-expression
statements
#elif constant-expression
    others statements
#else constant-expression
others statements
#endif 

#elif可以出现多次,这个结构与我们程序中的if else类似。

预处理器提供了几种可以测试一个符号是否已被定义的方式:

  • #if defined(symbol)

  • #ifdef symbol

  • #if !defined(symbol)

  • #ifndef symbol

每对定义的两条语句是等价的。

如果你在#if使用到嵌套指令,记住请在#endif添加一些注释,这样读者能够记住你这个嵌套指令对应哪一层。

四、文件包含

入门C语言的时候,也许我们就已基本了解#include指令是使另一个文件的内容被编译。这种替换的执行方式: 预处理器删除这条指令,并用包含文件的内容取而代之。这样的话,它被包含几次,它实际上就被编译几次。所以它会产生一些小小的开销,所以你需要注意一下:

  • 头文件应该只包含一组函数或数据的声明。
4.1 函数库文件包含

函数库文件包含使用下面的语法:

#include <filename>

这个是由编译器去观察由编译器定义的“一系列标准位置”查找函数库头文件。

4.2 本地文件包含

本地文件包含使用下面的语法:

#include "filenam"

这个也是由编译器自行决定是否把本地形式的#include和函数库形式的#include区别对待。常见策略是:在源文件所在的当前目录进行查找。

4.3 嵌套文件包含

在涉及嵌套文件包含的过程中,就能遇到一个头文件会被多次被包含。考虑一个例子:

//看下面这个好像没有什么问题。
#include "a.h"
#include "b.h"
//但是如果 a.h 和 b.h都包含一个x.h的话,x.h就被多重包含了。

为了解决这个问题,我们可以使用条件编译,如下:

#ifndef _HEADERNAME_h 
#define _HEADERNAME_h 1
/**
* 这里包含一些声明
*/
#endif

参考文献

[1] Kenneth A. Reek. C和指针[M]. 人民邮电出版社, 2008.

码字不易,如果你觉得有用的话,记得点赞哈
欢迎到访我的个人博客主页: https://blog.dreamforme.top/

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值