C语言从入门到实战——预处理详解

本文涉及的产品
应用实时监控服务-用户体验监控,每月100OCU免费额度
应用实时监控服务-可观测链路OpenTelemetry版,每月50GB免费额度
应用实时监控服务-应用监控,每月50GB免费额度
简介: C语言预处理是C语言编译过程的一个阶段,它在编译之前对源代码进行一系列的处理操作,包括宏替换、文件包含、条件编译等,最终生成经过预处理的代码,然后再进行编译。

预处理详解


前言

C语言预处理是C语言编译过程的一个阶段,它在编译之前对源代码进行一系列的处理操作,包括宏替换、文件包含、条件编译等,最终生成经过预处理的代码,然后再进行编译。

C语言预处理的主要功能有:

  1. 宏替换:通过使用#define定义宏,可以将一段代码或表达式抽象成一个标识符,在编译时将标识符替换成对应的代码或表达式。
  2. 文件包含:通过使用#include指令,可以将其他文件的内容包含到当前文件中,方便代码的组织和复用。
  3. 条件编译:通过使用#ifdef#ifndef#endif#if#elif#else等指令,可以根据条件编译开关的设置决定是否编译某段代码,从而实现不同平台或配置下的代码选择。
  4. 编译器指令:通过使用#pragma指令,可以向编译器发出一些特殊的命令,控制编译过程的行为。

C语言预处理的工作原理如下:

  1. 预处理器扫描源文件,遇到以#开头的指令时,按照指令的要求进行处理。
  2. 对于宏替换指令,预处理器将标识符替换成对应的代码或表达式。
  3. 对于文件包含指令,预处理器将被包含文件的内容复制到当前文件中。
  4. 对于条件编译指令,预处理器根据条件编译开关的设置决定是否编译某段代码。
  5. 对于编译器指令,预处理器将其直接传递给编译器。
  6. 预处理完成后,生成经过预处理的代码,进入下一阶段的编译。

需要注意的是,预处理器只是对源代码进行替换、复制等简单的文本处理操作,并不进行语法检查和语义分析。因此,在使用预处理器时需要谨慎,避免产生预期之外的结果。


一、预定义符号

C语言设置了一些预定义符号,可以直接使用,预定义符号也是在预处理期间处理的。

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

举个例子:

printf("file:%s line:%d\n", __FILE__, __LINE__);

1.1 __FILE__

FILE 是一个特殊的预定义常量,在 PHP 中表示当前文件的路径和文件名。当需要获取当前文件的路径和文件名时,可以使用这个常量。

在进行编译的源文件中,使用 FILE 会返回当前源文件的路径和文件名。例如,如果源文件的路径和文件名为/var/www/html/index.php,那么 FILE 将返回字符串'/var/www/html/index.php'

1.2__LINE__

在 C 语言中,__LINE__ 是一个特殊的预处理器宏,用于获取当前代码行的行号。

__LINE__ 宏可以在程序中使用,它会在编译时被替换为当前代码行的行号。这个宏在调试和错误排查中非常有用,可以帮助开发人员快速定位代码中的问题。

示例代码:

#include <stdio.h>
int main() {
    printf("当前行号:%d\n", __LINE__);
    return 0;
}

在上面的例子中,__LINE__ 宏被用于输出当前行号到控制台。编译并运行该程序,输出结果为当前行号的值。

1.3 __DATE__

在C语言中,__DATE__ 是一个特殊的预处理器宏,用于获取当前编译的日期字符串。

__DATE__ 宏可以在程序中使用,它会在编译时被替换为一个字符串,表示编译源文件时的日期。这个宏的字符串格式是 “MMM DD YYYY”,其中 MMM 表示月份的缩写(例如 Jan、Feb、Mar 等),DD 表示日期,YYYY 表示年份。

示例代码:

#include <stdio.h>
int main() {
    printf("编译日期: %s\n", __DATE__);
    return 0;
}

以上代码会输出当前编译的日期字符串,例如 “Jan 01 2022”。注意,这个日期是编译时的日期,而不是运行时的日期。每次编译程序时,__DATE__ 宏的值会自动更新为当前的日期。

1.4__TIME__

在C语言中,__TIME__ 是一个特殊的预处理器宏,用于获取当前编译的时间字符串。

__TIME__ 宏可以在程序中使用,它会在编译时被替换为一个字符串,表示编译源文件时的时间。这个宏的字符串格式是 “HH:MM:SS”,其中 HH 表示小时(24小时制),MM 表示分钟,SS 表示秒。

示例代码:

#include <stdio.h>
int main() {
    printf("编译时间: %s\n", __TIME__);
    return 0;
}

以上代码会输出当前编译的时间字符串,例如 “12:34:56”。注意,这个时间是编译时的时间,而不是运行时的时间。每次编译程序时,__TIME__ 宏的值会自动更新为当前的时间。

1.5__STDC__

STDC 是 C 语言中的一个预定义宏,用于表示当前编译器是否符合 ANSI C 标准。当编译器符合 ANSI C 标准时,它会定义 STDC 宏,其值常为 1。编写 C 代码时,可以使用该宏来判断编译器是否符合 ANSI C 标准,从而使用标准的 C 语言特性。

例如,有些编译器可能支持非标准的 C 语言扩展,当编写需要兼容 ANSI C 的代码时,可以使用条件编译来控制是否使用这些扩展。下面是一个示例:

#include <stdio.h>
int main() {
#ifdef __STDC__
    printf("This compiler follows the ANSI C standard.\n");
#else
    printf("This compiler does not follow the ANSI C standard.\n");
#endif
    return 0;
}

在这个示例中,如果编译器符合 ANSI C 标准,则会输出"This compiler follows the ANSI C standard.";如果编译器不符合 ANSI C 标准,则会输出"This compiler does not follow the ANSI C standard."

二、 #define定义常量

基本语法:

#define name stuff

举个例子:

#define MAX 1000
#define reg register //为 register这个关键字,创建一个简短的名字 
#define do_forever for(;;) //用更形象的符号来替换一种实现 
#define CASE break;case //在写case语句的时候自动把 break写上。 
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
 date:%s\ttime:%s\n" ,\
 __FILE__,__LINE__ , \
 __DATE__,__TIME__ )

思考:在define定义标识符的时候,要不要在最后加上 ; ?

比如:

#define MAX 1000;
#define MAX 1000

建议不要加上 ; ,加上的话更容易导致问题。

比如下面的场景:

if(condition)
 max = MAX;
else
 max = 0;

如果是加了分号的情况,等替换后,ifelse之间就是2条语句,而没有大括号的时候,if后边只能有一条语句。这里会出现语法错误。

三、 #define定义宏

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

下面是宏的申明方式:

#define name( parament-list ) stuff

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

注意

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

举例

#define SQUARE( x ) x * x

这个宏接收一个参数 x .如果在上述声明之后,你把 SQUARE( 5 ); 置于程序中,预处理器就会用下面这个表达式替换上面的表达式: 5 * 5

警告:

这个宏存在一个问题:

观察下面的代码段:

int a = 5;
printf("%d\n" ,SQUARE( a + 1) );

乍一看,你可能觉得这段代码将打印36,事实上它将打印11,为什么呢?

替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:

printf ("%d\n",a + 1 * a + 1 );

这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。

在宏定义上加上两个括号,这个问题便轻松的解决了

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

这样预处理之后就产生了预期的效果:

printf ("%d\n",(a + 1) * (a + 1) );

这里还有一个宏定义:

#define DOUBLE(x) (x) + (x)

定义中我们使用了括号,想避免之前的问题,但是这个宏可能会出现新的错误。

int a = 5;
printf("%d\n" ,10 * DOUBLE(a));

这将打印什么值呢?看上去,好像打印100,但事实上打印的是55.

我们发现替换之后:

printf ("%d\n",10 * (5) + (5));

乘法运算先于宏定义的加法,所以出现了 55

这个问题,的解决办法是在宏定义表达式两边加上一对括号就可以了。

#define DOUBLE( x) ( ( x ) + ( x ) )

提示

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

四、 带有副作用的宏参数

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。

例如:

x+1;//不带副作用
x++;//带有副作用

MAX宏可以证明具有副作用的参数所引起的问题。

#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
...
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么?

这里我们得知道预处理器处理之后的结果是什么:

z = ( (x++) > (y++) ? (x++) : (y++));

所以输出的结果是:x=6 y=10 z=9

五、 宏替换的规则

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

  1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
  3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。

注意:

  1. 宏参数和#define定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
  2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

六、宏函数的对比

宏通常被应用于执行简单的运算。

比如在两个数中找出较大的一个时,写成下面的宏,更有优势一些。

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

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

原因有二:

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
  2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于 > 来比较的类型。宏是类型无关的。

和函数相比宏的劣势:

  1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
  2. 宏是没法调试的。
  3. 宏由于类型无关,也就不够严谨。
  4. 宏可能会带来运算符优先级的问题,导致程容易出现错。

宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。

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

宏和函数的一个对比

属性 #define定义宏 函数
代码长度 每次使用时,宏代码都会被插入程序中。除了非常小的宏之外,程序的长度会大幅度增长 函数代码只出现于一个地方;每次使用函数的时候,都会调用那个地方的同一个代码
执行速度 更快 存在函数的调用和返回的额外开销,所以会相对慢一些
操作符优先级 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则临近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号。 函数参数只在函数调用的时候才开始求值,并将结果值传递给函数。表达式的结果更容易预测
带有副作用的参数 参数可能被替换到宏体中的多个位置,如果宏的参数被多次计算,带有副作用的参数求值可能会产生不可预料的结果。 函数参数只在传参的时候求值一次,结果更容易控制
参数类型 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 函数的参数是与类型有关的,如果类型不同,就需要不同的函数,即使他们执行的任务是不同的
调试 宏是不方便调试的 函数是可以逐语句调试的
递归 宏是不能递归的 函数是可以递归的

七、 ###

7.1 #运算符

#运算符将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。

#运算符所执行的操作可以理解为”字符串化“。

当我们有一个变量 int a = 10; 的时候,我们想打印出: the value of a is 10 .

就可以写:

#define PRINT(n) printf("the value of "#n " is %d", n);

当我们按照下面的方式调用的时候:

PRINT(a);//当我们把a替换到宏的体内时,就出现了#a,而#a就是转换为'a'

代码就会被处理成

printf("the value of ""a" " is %d", a);

运行代码就能在屏幕上打印:

the value of a is 10

7.2 ##运算符

## 可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符。

## 被称为记号粘合

这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。

这里我们想想,写一个函数求2个数的较大值的时候,不同的数据类型就得写不同的函数。

比如:

int int_max(int x, int y)
{
 return x>y?x:y;
}
float float_max(float x, float y)
{
 return x>yx:y;
}

但是这样写起来太繁琐了,现在我们这样写代码试试:

//宏定义 
#define GENERIC_MAX(type) \
type type##_max(type x, type y)\
{ \
 return (x>y?x:y); \
}

使用宏,定义不同函数

GENERIC_MAX(int) //替换到宏体内后int##_max 生成了新的符号 int_max做函数名 
GENERIC_MAX(float) //替换到宏体内后float##_max 生成了新的符号 float_max做函数名 
int main()
{
 //调用函数 
   int m = int_max(2, 3);
   printf("%d\n", m);
   float fm = float_max(3.5f, 4.5f);
   printf("%f\n", fm);
   return 0;
}

输出:

3
4.500000

在实际开发过程中##使用的很少,很难取出非常贴切的例子。

八、 命名约定

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

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

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

九、 #undef

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

#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。

十、命令行定义

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

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

#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;
}

编译指令:

//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;
}

常见的条件编译指令:

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

十二、头文件的包含

12.1 头文件被包含的方式:

12.1.1 本地文件包含

#include "filename"

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

如果找不到就提示编译错误。

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

/usr/include

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

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

注意按照自己的安装路径去找

12.1.2 库文件包含

#include <filename.h>

查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。

这样是不是可以说,对于库文件也可以使用 "" 的形式包含?

答案是肯定的,可以,但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。

12.2 嵌套文件包含

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

这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。

一个头文件被包含10次,那就实际被编译10次,如果重复包含,对编译的压力就比较大。

test.c

#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
int main()
{
 
 return 0;
}

test.h

void test();
struct Stu
{
 int id;
 char name[20];
};

如果直接这样写,test.c文件中将test.h包含5次,那么test.h文件的内容将会被拷贝5份在test.c中。

如果test.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()在结构体部分介绍。

#error

在 C 语言中,#error 是一个预处理指令,用于在编译时产生一个错误消息并停止编译。

#error 指令后面可以跟上一个字符串常量作为错误消息,当编译器遇到 #error 指令时,会输出该错误消息并停止编译。

下面是一个示例:

#include <stdio.h>
#ifndef __STDC__
#error "This code requires an ANSI C compiler."
#endif
int main() {
    printf("Hello, World!\n");
    return 0;
}

在这个示例中,#ifndef __STDC__ 检查编译器是否符合 ANSI C 标准,如果不符合,就通过 #error 输出错误消息并停止编译。

编译器在遇到 #error 指令时会显示以下错误消息并停止编译:

main.c: In function 'main':
main.c:5:2: error: #error "This code requires an ANSI C compiler."
    5 | #error "This code requires an ANSI C compiler."
      |  ^~~~~

通过使用 #error ,可以在编译时做一些约束条件的检查,确保代码在符合特定条件下才能编译。

#pragma

在 C 语言中,#pragma 是一个预处理指令,用于向编译器发送特定的指示或控制命令。

#pragma 指令用于提供与编译器或特定环境相关的一些指令,它通常是与特定的编译器或操作系统相关的,不同的编译器可能会支持不同的 #pragma 指令。

下面是一些常见的 #pragma 指令用法:

  1. #pragma once:用于确保头文件只被包含一次,可以在头文件的开头使用该指令。
#pragma once
  1. #pragma pack:用于指定结构体的内存对齐方式,可以指定对齐字节数。
#pragma pack(4)
struct MyStruct {
    int a;
    char b;
};
#pragma pack()
  1. #pragma message:用于在编译时输出一条自定义的消息。
#pragma message("This is a custom message")
  1. #pragma warning:用于控制编译器的警告信息输出。
#pragma warning(disable: 1234) // 禁用特定警告
#pragma warning(push) // 保存当前警告状态
#pragma warning(disable: 5678) // 禁用另一个特定警告
// 一些需要禁用警告的代码
#pragma warning(pop) // 恢复之前的警告状态

注意,#pragma 指令的具体用法和支持的指令会因编译器和操作系统而异。在使用 #pragma 指令时,应查阅对应编译器的文档以了解具体用法和支持情况。

#line

#line 是 C 语言中的一个预处理指令,用于修改编译器生成的行号和文件名。

#line 指令的一般语法是:

#line [行号] ["文件名"]

其中,行号和文件名都是可选的参数。如果只指定行号,则编译器将设置当前行号为指定的行号。如果只指定文件名,则编译器将设置当前文件名为指定的文件名。如果同时指定行号和文件名,则编译器将设置当前行号和文件名为指定的值。

下面是一个示例:

#line 10 "myfile.c"

上述示例中,编译器将当前行号设置为 10,当前文件名设置为 “myfile.c”。

#line 指令通常用于调试和错误报告中,可以帮助开发人员更好地跟踪代码的执行过程或报告错误时准确指示错误位置。

需要注意的是,#line 指令的有效范围通常只在它出现之后的部分。也就是说,如果在某处使用了 #line 指令修改了行号或文件名,那么该指令之前的代码将不受影响。

相关文章
|
2月前
|
编译器 C语言
C语言--预处理详解(1)
【10月更文挑战第3天】
|
2月前
|
编译器 Linux C语言
C语言--预处理详解(3)
【10月更文挑战第3天】
|
2月前
|
安全 编译器 C语言
C++入门1——从C语言到C++的过渡
C++入门1——从C语言到C++的过渡
65 2
|
2月前
|
自然语言处理 编译器 Linux
【C语言篇】编译和链接以及预处理介绍(上篇)1
【C语言篇】编译和链接以及预处理介绍(上篇)
42 1
|
1月前
|
C语言
【c语言】你绝对没见过的预处理技巧
本文介绍了C语言中预处理(预编译)的相关知识和指令,包括预定义符号、`#define`定义常量和宏、宏与函数的对比、`#`和`##`操作符、`#undef`撤销宏定义、条件编译以及头文件的包含方式。通过具体示例详细解释了各指令的使用方法和注意事项,帮助读者更好地理解和应用预处理技术。
25 2
|
2月前
|
编译器 Linux C语言
【C语言篇】编译和链接以及预处理介绍(下篇)
【C语言篇】编译和链接以及预处理介绍(下篇)
33 1
【C语言篇】编译和链接以及预处理介绍(下篇)
|
2月前
|
C语言
C语言--预处理详解(2)
【10月更文挑战第3天】
|
2月前
|
存储 Java 编译器
初识C语言1——C语言入门介绍
初识C语言1——C语言入门介绍
31 1
|
2月前
|
C语言
回溯入门题,数据所有排列方式(c语言)
回溯入门题,数据所有排列方式(c语言)
|
3月前
|
存储 人工智能 C语言
数据结构基础详解(C语言): 栈的括号匹配(实战)与栈的表达式求值&&特殊矩阵的压缩存储
本文首先介绍了栈的应用之一——括号匹配,利用栈的特性实现左右括号的匹配检测。接着详细描述了南京理工大学的一道编程题,要求判断输入字符串中的括号是否正确匹配,并给出了完整的代码示例。此外,还探讨了栈在表达式求值中的应用,包括中缀、后缀和前缀表达式的转换与计算方法。最后,文章介绍了矩阵的压缩存储技术,涵盖对称矩阵、三角矩阵及稀疏矩阵的不同压缩存储策略,提高存储效率。
458 8