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

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


相关文章
|
2天前
|
存储 缓存 算法
【C语言】内存管理函数详细讲解
在C语言编程中,内存管理是至关重要的。动态内存分配函数允许程序在运行时请求和释放内存,这对于处理不确定大小的数据结构至关重要。以下是C语言内存管理函数的详细讲解,包括每个函数的功能、标准格式、示例代码、代码解释及其输出。
24 6
|
19天前
|
C语言
c语言调用的函数的声明
被调用的函数的声明: 一个函数调用另一个函数需具备的条件: 首先被调用的函数必须是已经存在的函数,即头文件中存在或已经定义过; 如果使用库函数,一般应该在本文件开头用#include命令将调用有关库函数时在所需要用到的信息“包含”到本文件中。.h文件是头文件所用的后缀。 如果使用用户自己定义的函数,而且该函数与使用它的函数在同一个文件中,一般还应该在主调函数中对被调用的函数做声明。 如果被调用的函数定义出现在主调函数之前可以不必声明。 如果已在所有函数定义之前,在函数的外部已做了函数声明,则在各个主调函数中不必多所调用的函数在做声明
31 6
|
1月前
|
C语言
【c语言】你绝对没见过的预处理技巧
本文介绍了C语言中预处理(预编译)的相关知识和指令,包括预定义符号、`#define`定义常量和宏、宏与函数的对比、`#`和`##`操作符、`#undef`撤销宏定义、条件编译以及头文件的包含方式。通过具体示例详细解释了各指令的使用方法和注意事项,帮助读者更好地理解和应用预处理技术。
24 2
|
1月前
|
存储 算法 程序员
C语言:库函数
C语言的库函数是预定义的函数,用于执行常见的编程任务,如输入输出、字符串处理、数学运算等。使用库函数可以简化编程工作,提高开发效率。C标准库提供了丰富的函数,满足各种需求。
|
2月前
|
存储 C语言
【c语言】字符串函数和内存函数
本文介绍了C语言中常用的字符串函数和内存函数,包括`strlen`、`strcpy`、`strcat`、`strcmp`、`strstr`、`strncpy`、`strncat`、`strncmp`、`strtok`、`memcpy`、`memmove`和`memset`等函数的使用方法及模拟实现。文章详细讲解了每个函数的功能、参数、返回值,并提供了具体的代码示例,帮助读者更好地理解和掌握这些函数的应用。
27 0
|
2月前
|
C语言
【c语言】qsort函数及泛型冒泡排序的模拟实现
本文介绍了C语言中的`qsort`函数及其背后的回调函数概念。`qsort`函数用于对任意类型的数据进行排序,其核心在于通过函数指针调用用户自定义的比较函数。文章还详细讲解了如何实现一个泛型冒泡排序,包括比较函数、交换函数和排序函数的编写,并展示了完整的代码示例。最后,通过实际运行验证了排序的正确性,展示了泛型编程的优势。
23 0
|
2月前
|
C语言 C++
C语言 之 内存函数
C语言 之 内存函数
36 3
|
2月前
|
存储 缓存 C语言
【c语言】简单的算术操作符、输入输出函数
本文介绍了C语言中的算术操作符、赋值操作符、单目操作符以及输入输出函数 `printf` 和 `scanf` 的基本用法。算术操作符包括加、减、乘、除和求余,其中除法和求余运算有特殊规则。赋值操作符用于给变量赋值,并支持复合赋值。单目操作符包括自增自减、正负号和强制类型转换。输入输出函数 `printf` 和 `scanf` 用于格式化输入和输出,支持多种占位符和格式控制。通过示例代码详细解释了这些操作符和函数的使用方法。
43 10
|
2月前
|
机器学习/深度学习 C语言
【c语言】一篇文章搞懂函数递归
本文详细介绍了函数递归的概念、思想及其限制条件,并通过求阶乘、打印整数每一位和求斐波那契数等实例,展示了递归的应用。递归的核心在于将大问题分解为小问题,但需注意递归可能导致效率低下和栈溢出的问题。文章最后总结了递归的优缺点,提醒读者在实际编程中合理使用递归。
66 7
|
2月前
|
存储 编译器 程序员
【c语言】函数
本文介绍了C语言中函数的基本概念,包括库函数和自定义函数的定义、使用及示例。库函数如`printf`和`scanf`,通过包含相应的头文件即可使用。自定义函数需指定返回类型、函数名、形式参数等。文中还探讨了函数的调用、形参与实参的区别、return语句的用法、函数嵌套调用、链式访问以及static关键字对变量和函数的影响,强调了static如何改变变量的生命周期和作用域,以及函数的可见性。
32 4