C语言 | 预处理知识详解 #预处理指令有哪些?他们如何使用?宏和函数有哪些区别?...#

简介: C语言 | 预处理知识详解 #预处理指令有哪些?他们如何使用?宏和函数有哪些区别?...#

前言


上篇文章介绍了一个程序运行的 编译与链接 ,其中编译阶段有个预处理,他会对一些预处理指令进行处理,本章就对这些预处理相关的指令,操作符等等进行探讨。


预定义符号介绍

这里介绍一些可能会常用到的符号:

__FILE__      //进行编译的源文件
__LINE__     //文件当前的行号
__DATE__    //文件被编译的日期
__TIME__    //文件被编译的时间
__STDC__    //如果编译器遵循ANSI C,其值为1,否则未定义


这些预定义符号都是语言内置的,都已经为其设定了特有的值,下面来看看个别的值是啥呢:

#include <stdio.h>
int main()
{
  printf("%s\n", __FILE__);
  printf("%d\n", __LINE__);
  printf("%s\n", __DATE__);
  printf("%s\n", __TIME__);
  // 下面的_STDC_在vs上是未定义的,编译就会报错
  //printf("%d\n", __STDC__); 
  return 0;
}


d4846f92e017425280ec900bb6ac46d4.png


有了这些预定义符号,我们可以随时随地的知道此时的时间和文件所在位置啦。

预处理指令#define


在C或C++语言源程序中允许用一个标识符来表示一个字符串,称为“宏”。被定义为“宏”的标识符称为“宏名”。在编译预处理时,对程序中所有出现的“宏名”,都用宏定义中的字符串去代换,这称为“宏代换”或“宏展开”。宏定义是由源程序中的宏定义命令完成的。宏代换是由预处理程序自动完成的。


#define 和 #include 一样,也是以“#”开头的。凡是以“#”开头的均为预处理指令,#define也不例外。


#define又称宏定义,标识符为所定义的宏名,简称宏。标识符的命名规则与前面讲的变量的命名规则是一样的。#define 的功能是将标识符定义为其后的常量。一经定义,程序中就可以直接用标识符来表示这个常量。是不是与定义变量类似?但是要区分开!变量名表示的是一个变量,但宏名表示的是一个常量。可以给变量赋值,但绝不能给常量赋值。


宏定义 #define 一般都写在函数外面,与 #include 写在一起。当然,写在函数里面也没有语法错误,但通常不那么写。#define 的作用域为自 #define 那一行起到源程序结束。如果要终止其作用域可以使用 #undef 命令,下面会介绍。


还需详细了解 #define, 可以点此链接观摩大佬解析。


语法:

#define name stuff


举些个栗子:

#define MAX 100
#define FOREVER for(;;)
#define reg register


可以看到宏的命名习惯都是大写,这样更能区别。


标识符的定义与常量是以空格隔开的。


第一个定义了一个标识符 MAX ,它是常量 100,当我们在用这个标识符时,在预处理阶段,MAX 将会被替换成100。


第二个是用更形象的符号来替换一种实现, for( ; ; ) 相当于死循环,这里用 FOREVER 来形象的表示它。


第三个是为 register这个关键字,创建一个简短的名字。


那么我们在define 定义标识符的时候,要不要在最后加上 ;

比如:

#define MAX 100;


建议不要加上,因为当我们写C语言程序时,都会习惯在后面加上分号,如果是一个变量等于这个MAXint max = MAX;),这时预处理阶段,会把这个 MAX 替换,变成 int max = 100;; ,此时有两个分号,这就出现了语法问题。


#define 定义宏:#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。


下面是宏的申明方式:

#define name( parament-list ) stuff


其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。

注意:

  1. 参数列表的左括号必须与name紧邻。
  2. 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。

如:

#define SQUARE(x)  x * x


这个宏接收一个参数 x ,如果在上述声明后,有以下写法:

SQUARE(5);
SQUARE(2 + 3);


第一种预处理阶段被替换后表达式变为:5 * 5;(计算结果为25

第二种预处理阶段被替换后表达式变为:2 + 3 * 2 + 3;(计算结果为11

可以看出,第二种并不是我们想要的结果,所以这种定义宏的方式有问题


那如果这样子使用呢:

SQUARE((2 + 3));

替换后表达式变为:(2 + 3) * (2 + 3)这样子是可以的,但是这样治标不治本,而且写的代码还不好看,所以我们直接在定义宏处加上括号,这样就更好了。

更新之后:

#define SQUARE(x)  (x) * (x)


那么这样子是否还会有问题呢?实际上还是有的。

如果是一下定义的宏:

#define SQUARE(x)  (x) + (x)


有了上面的声明后,进行一下操作:

int a = 5 * SQUARE(5);


替换后表达式为:

int a = 5 * 5 + 5;


计算结果为:30,这也与我们想要的值不符。

所以我们在定义时要给整体也加上一个括号,这样才不会出错

正确规范定义:

#define SQUARE(x)  ((x) + (x))


所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间产生的不可预料的相互作用。


#define替换规则


在程序中扩展 #define 定义符号和宏时,需要涉及几个步骤。


在调用宏时,首先对参数进行检查,看看是否包含任何由 #define定义的符号。如果是,它们首先

被替换。

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

最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,就重复上

述处理过程。


注意:


4. 宏参数和 #define 定义中可以出现其他 #define 定义的符号。但是对于宏,不能出现递归。

5. 当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不被搜索。


预处理指令 #undef


这条指令用于移除一个宏定义。

在一个程序块中用完宏定义后,为防止后面标识符冲突需要取消其宏定义。

例如:

#define MAX 100
int a = 100;
#undef MAX


这里第三行就取消了MAX的红定义,在下面还可以继续定义以MAX为标识符的宏。


宏和函数的对比


宏通常被应用于执行简单的运算,比如在两个数中找出较大的一个:

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


那为什么不用函数来完成这个任务呢?

原因有两点:


用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多(也就是函数调用和函数返回的栈帧的创建和销毁可能比实际的代码功能运行时间还要长)。所以宏比函数在程序的规模和速度方面更胜一筹。

更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整形、长整型、浮点型等可以用>来比较的类型。宏是类型无关的。


当然宏跟函数比较,也有其缺点:


每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。

宏是没法调试的(不可以调试可能程序怎么出错的都不知道)。

宏由于类型无关,也就不够严谨。

宏可能会带来运算符优先级的问题,导致程容易出现错。

根据宏的优点举个栗子(宏的参数可以出现类型,但是函数做不到):

#define MALLOC(num, type) (type *)malloc(num * sizeof(type))
...
//使用
MALLOC(10, int);  //类型作为参数
//预处理器替换之后:
(int *)malloc(10 * sizeof(int));


宏和函数的对比图

c8976167e14b4f23bb03e5745ce3fde1.png


命名约定


一般来讲函数和宏的语法很相似。所以语法本身没法帮我们区分二者。

那我们平时的一个习惯是:

  • 把宏名全部大写
  • 函数名不要全部大写


命令行定义

许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。

例如:


  • 当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些)。

如有下面的代码(在linux中):


#include <stdio.h>
int main()
{
    int array [ARRAY_SIZE];
    int i = 0;
    for(i = 0; i < ARRAY_SIZE; i++)
    {
        array[i] = i;
    }
    for(i = 0; i < ARRAY_SIZE; i++)
    {
        printf("%d " ,array[i]);
    }
    printf("\n" );
    return 0;
}


可以看到,上面的 ARRAY_SIZE是未定义的,但是我们可以通过以下指令对其赋值:

linux 环境演示

指令: gcc -D ARRAY_SIZE=10 programe.c

这样也可以灵活的控制数组大小啦。


条件编译


在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。


比如说:

调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

#include <stdio.h>
#define __DEBUG__
int main()
{
   int i = 0;
   int arr[10] = {0};
   for(i=0; i<10; i++)
   {
       arr[i] = i;
       #ifdef __DEBUG__
       printf("%d\n", arr[i]);    //为了观察数组是否赋值成功。 
       #endif     //__DEBUG__
     }
 return 0;
}


#ifdef 是如果定义了就干嘛干嘛,#endif 是截断 #ifdef的作用继续往下延伸。

可以看到,前面定义了_DEBUG_,所以后面的 printf("%d\n", arr[i]); 这条语句将会被编译执行。

  • 常见的条件编译指令:
1.
#if 常量表达式
 //...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
 //..
#endif
2.多个分支的条件编译
#if 常量表达式
 //...
#elif 常量表达式
 //...
#else
 //...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
4.嵌套指令
#if defined(OS_UNIX)
   #ifdef OPTION1
      unix_version_option1();
   #endif
   #ifdef OPTION2
      unix_version_option2();
   #endif
#elif defined(OS_MSDOS)
   #ifdef OPTION2
      msdos_version_option2();
   #endif
#endif


预处理指令 #include


#include 可以说是再熟悉不过了,我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。


这种替换的方式很简单:

  1. 预处理器先删除这条指令,并用包含文件的内容替换。
  2. 这样一个源文件被包含10次,那就实际被编译10次。


而头文件的包含方式有两种:

  • 一种是本地文件包含:
#include "filename"


查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。


Linux环境的标准头文件的路径:

/usr/include


VS环境的标准头文件的路径:

注意: 不同编译器可能放在不同地方,要按照自己的安装路径去找。

C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include


  • 一种是库文件包含
#include <filename.h>


查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。这样是不是可以说,对于库文件也可以使用 “ ” 的形式包含?答案是肯定的,可以。 但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。


嵌套文件包含


如果出现这样的场景:


6d8e63b06de245aba8e7342e80c8aed7.png

comm.h和comm.c是公共模块。
test1.h和test1.c使用了公共模块。
test2.h和test2.c使用了公共模块。
test.h和test.c使用了test1模块和test2模块。
这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复。

那么如何解决这样的问题呢? 答案是:条件编译

每个头文件的开头写:

#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif  // __TEST_H__


或者:

#pragma once


就可以避免头文件的重复引入。

注:

推荐 《高质量C/C++编程指南》 中附录的考试试卷(很重要)。

笔试题:

1. 头文件中的 ifndef/define/endif是干什么用的?
2. #include <filename.h> 和 #include "filename.h"有什么区别?


其他预处理指令

#error
#pragma
#line
...


这里就不一一做介绍,可以自己去了解。 #pragma pack()结构体一章 介绍了,可以去看噢。


写在最后


C语言阶段的知识学到这里,差不多就结束了呢,一路过来还是学到了非常多的知识,这也让我更加认清了自己的路还长着呢,接下来我会继续更新 基本数据结构 阶段的相关知识。

感谢阅读本小白的博客,错误的地方请严厉指出噢!


相关文章
|
4天前
|
编译器 C语言
C语言:预处理
C语言:预处理
7 1
C语言:预处理
|
4天前
|
存储 编译器 C语言
C语言:字符函数 & 字符串函数 & 内存函数
C语言:字符函数 & 字符串函数 & 内存函数
11 2
|
8天前
|
程序员 编译器 C语言
【C 言专栏】C 语言中的预处理器指令
【5月更文挑战第6天】C 语言的预处理器指令在编译前起作用,提供代码灵活性。常见指令包括:`#define`(定义常量和宏)、`#include`(包含文件)、`#if` 等条件编译指令,以及`#pragma`(编译器特定指示)。合理使用能提升代码可读性和可维护性,但过度使用可能导致复杂性增加。注意其文本替换性质及顺序处理,避免头文件重复包含。预处理器在实际应用中用于实现不同功能和配置,是成为优秀 C 语言程序员的关键技能之一。
【C 言专栏】C 语言中的预处理器指令
|
13天前
|
缓存 安全 编译器
【C 言专栏】C 语言函数的高效编程技巧
【5月更文挑战第1天】本文探讨了C语言中函数的高效编程技巧,包括函数的定义与作用(如代码复用和提高可读性)、设计原则(单一职责和接口简洁)、参数传递方式(值传递、指针传递和引用传递)、返回值管理、调用约定、嵌套与递归调用,以及函数优化技巧和常见错误避免。掌握这些技巧能提升C语言代码的质量和效率。
【C 言专栏】C 语言函数的高效编程技巧
|
13天前
|
存储 Linux C语言
C语言进阶第十一节 --------程序环境和预处理(包含宏的解释)-2
C语言进阶第十一节 --------程序环境和预处理(包含宏的解释)
|
13天前
|
自然语言处理 Linux 编译器
C语言进阶第十一节 --------程序环境和预处理(包含宏的解释)-1
C语言进阶第十一节 --------程序环境和预处理(包含宏的解释)
|
15天前
|
C语言
pta浙大版《C语言程序设计(第3版)》 习题6-4 使用函数输出指定范围内的Fibonacci数 (20分)
pta浙大版《C语言程序设计(第3版)》 习题6-4 使用函数输出指定范围内的Fibonacci数 (20分)
|
C语言 关系型数据库 PostgreSQL
|
19天前
|
C语言
C语言:内存函数(memcpy memmove memset memcmp使用)
C语言:内存函数(memcpy memmove memset memcmp使用)
|
15天前
|
C语言
pta 浙大版《C语言程序设计(第3版)》题目集 习题6-6 使用函数输出一个整数的逆序数 (20分)
pta 浙大版《C语言程序设计(第3版)》题目集 习题6-6 使用函数输出一个整数的逆序数 (20分)