C 语言预处理

什么是预处理

C 语言通过预处理器提供了一些语言功能。从概念上讲,预处理器是编译过程中单独执行的第一个步骤。两个最常用的预处理器指令是:#include 指令 (用于在编译期间把指定文件的内容包含进当前文件中) 和 #define 指令 (用任意字符序列替代一个标记)。

为啥要进行预先处理呢?如果要深入的了解的话可以参考《程序员的自我修养:链接、装载与库》这本书。这里举一个非常常见的例子,假如我们编写跨平台的程序时,我们就需要考虑不同平台的系统库是不同的,如果只包含了一个平台下的库文件,换个平台编译就可能出错。这时候就需要在编译前进行预处理。

有重要的预处理器指令:
| 指令 | 描述|
|:———-:|:—–:|
| #define | 定义宏|
| #include | 包含一个源代码文件|
| #undef | 取消已定义的宏|
| #ifdef | 如果宏已经定义,则返回真|
| #ifndef | 如果宏没有定义,则返回真|
| #if | 如果给定条件为真,则编译下面代码|
| #else | #if 的替代方案|
| #elif | 如果前面的 #if 给定条件不为真,当前条件为真,则编译下面代码 |
| #endif | 结束一个 #if……#else 条件编译块|
| #error | 当遇到标准错误时,输出错误消息|
| #pragma | 使用标准化方法,向编译器发布特殊的命令到编译器中|

条件编译

#if

#if 整型常量表达式1
    程序段1
#elif 整型常量表达式2
    程序段2
#elif 整型常量表达式3
    程序段3
#else
    程序段4
#endif

它的意思是:如常“表达式 1”的值为真(非 0),就对“程序段 1”进行编译,否则就计算“表达式 2”,结果为真的话就对“程序段 2”进行编译,为假的话就继续往下匹配,直到遇到值为真的表达式,或者遇到 #else 。这一点和 if else 非常类似。

需要注意的是, #if 命令要求判断条件为整型常量表达式,也就是说,表达式中不能包含变量,而且结果必须是整数;而 if 后面的表达式没有限制,只要符合语法就行。这是 #ifif 的一个重要区别。

#include <stdio.h>
//不同的平台下引入不同的头文件
#if _WIN32  //识别 Windows 平台
#include <windows.h>
#elif __linux__  //识别 Linux 平台
#include <unistd.h>
#endif
int main() {
    //不同的平台下调用不同的函数
    #if _WIN32  //识别 Windows 平台
    Sleep(5000);
    #elif __linux__  //识别 Linux 平台
    sleep(5);
    #endif
    puts("http://c.biancheng.net/");
    return 0;
}

#ifedf

#ifdef  宏名
    程序段1
#else
    程序段2
#endif

#ifndef

#ifndef 宏名
    程序段1 
#else 
    程序段2 
#endif

#ifdef相比,仅仅是将 #ifdef 改为了 #ifndef。它的意思是,如果当前的宏未被定义,则对“程序段 1”进行编译,否则对“程序段 2”进行编译,这与#ifdef 的功能正好相反。

文件包含#include

#include 叫做文件包含命令,用来引入对应的头文件(.h 文件)。 #include 的处理过程很简单,就是将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。

#include 的用法有两种,如下所示:

#include <stdHeader.h>
#include "myHeader.h"

使用尖括号 < > 和双引号 " " 的区别在于头文件的搜索路径不同:

  • 使用尖括号< >,编译器会到环境变量下查找头文件;
  • 使用双引号" ",编译器首先在当前目录下查找头文件,如果没有找到,再到环境变量下查找。

注意事项:

  • 在头文件中尽量不要进行函数的定义,只对其进行声明。否则如果有多个源文件链接时会报错
  • 某一个头文件的内容发生变化,所有包含该文件的源文件都需要重新编译
  • 一个#include命令指定一个头文件,多个头文件需要多个#include
  • 包含可以嵌套
  • 文件 1 包含文件 2,文件 2 用到文件 3,则文件 3 的包含命令#include 应放在文件 1 的头部第一行;
  • 被包含文件中的静态全局变量不用在包含文件中声明

宏定义

what

#define 叫做宏定义命令,它也是 C 语言预处理命令的一种。所谓宏定义,就是用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串


#define  宏名  字符串    //基本格式
#define N 100           //将所有N都替换成整数100
#define forever for (;;) //该语句为无限循环定义了一个新名字forever
#define max(A, B) ((A)> (B) ? (A) : (B) )

why

对于函数,其调用必须要将程序执行的顺序跳转到函数所在内存的某个地址,在将函数程序执行完成后,再跳转回去执行函数调用前的地方。这种跳转操作要求在函数执行前保存现场并记录当前执行地址,函数调用返回后要恢复现场,并按原来保存地址继续执行。因此,函数调用会有一定的时间和空间方面的开销,必将影响程序的运行效率。

对于宏,它只是在预处理的地方把代码展开,而不需要额外的空间和时间方面的开销,因此调用宏比调用函数更有效率。

但是,宏也有很多的问题和缺陷:

  • 在 C 语言中,宏容易出现一些边界性的问题,容易产生歧义。(优先级的问题,能加括号都加括号)
  • 在 C++语言中,宏不可以调用 C++类中的私有或受保护的成员。

Tips

  • 能用括号的地方都用括号,不要偷懒省略,以免歧义,特别是于带参宏定义不仅要在参数两侧加括号,还应该在整个字符串外加括号
  • 宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换
  • 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用#undef命令
  • 代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替
  • 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开时由预处理程序层层代换
  • 习惯上宏名用大写字母表示,以便于与变量区别
  • 可用宏定义表示数据类型,使书写方便
  • 带参数的宏和函数很相似,但有本质上的区别:宏展开仅仅是字符串的替换,不会对表达式进行计算;宏在编译之前就被处理掉了,它没有机会参与编译,也不会占用内存。而函数是一段可以重复使用的代码,会被编译,会给它分配内存,每次调用函数,就是执行这块内存中的代码。

内联函数

从上文可知,可以看到宏有一些难以避免的问题,对于不能访问 C++类中私有或者受保护的成员,我们应该如何解决呢?

what

关键字 inline 告诉编译器,任何地方只要调用内联函数,就直接把该函数的机器码插入到调用它的地方。这样程序执行更有效率,就好像将内联函数中的语句直接插入到了源代码文件中需要调用该函数的地方一样。

why

内联函数是代码被插入到调用者代码处的函数。如同 #define 宏,内联函数通过避免被调用的开销来提高执行效率,尤其是它能够通过调用(过程化集成)被编译器优化。

内联函数和宏很类似,而区别在于,宏是由预处理器对宏进行替代,而内联函数是通过编译器控制来实现的。而且内联函数是真正的函数,只是在需要用到的时候,内联函数像宏一样的展开,所以取消了函数的参数压栈,减少了调用的开销。你可以像调用函数一样来调用内联函数,而不必担心会产生于处理宏的一些问题。

How

对于内联函数,其工作原理是:

对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。

这个过程与预处理有显著的不同,因为预处理器不能进行类型安全检查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。

Tips

  • 当你定义一个内联函数时,在函数定义前加上 inline 关键字,并且将定义放入头文件
  • 内联函数必须是和函数体声明在一起才有效
  • 内联函数不宜过大,比如循环体,递归体就不适合内联。如果过大,编译器会放弃内联,采用普通方式调用函数。

相关参考

C 预处理器
C 语言预处理命令是什么?
C 语言中宏与内联函数解析
C 语言内联函数
内联函数