✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:C语言学习
贝蒂的主页:Betty‘s blog
1. 预处理符号
在C语言中,我们可以通过一些预定义符号查看文件的相关信息。
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSIC,其值为1,否则未定义
我们首先通过下面这段代码在VS2022
环境实验一下:
void Test()
{
printf("源文件为:%s\n", __FILE__); //进行编译的源文件
printf("行号为:%d\n", __LINE__); //文件当前的行号
printf("日期为:%s\n", __DATE__); //被编译的日期
printf("时间为:%s\n", __TIME__); //被编译的时间
printf("%d\n", __STDC__); //VS2022没有遵循ANSIC
}
而在Linux
的gcc
环境下是支持ANSIC
的。
2. #define的使用
2.1#define定义标识符
有些常量数据可能需要更改,并且在程序中被大量使用,为了方便起见,我们可以使用 #define
来定义常量,其语法如下:
#define name stuff
下面是一些实例:
#define PI 3.14//将Pi定义成常量3.14
#define reg register//为 register这个关键字,创建⼀个简短的名字
#define do_forever for(;;)//⽤更形象的符号来替换死循环
// 如果定义的 stuff过⻓,可以分成⼏⾏写,除了最后⼀⾏外,每⾏的后⾯都加⼀个反斜杠(续⾏符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
例如如果我们在程序中使用 PI
时,就相当于使用了 3.14
这个值。这样,当需要修改常量值时,只需在 #define
处修改即可,无需在程序中逐个查找替换,大大提升我们的效率。
其中需要特别注意的是#define
定义标识符时末尾不需要加;
。因为我们知道#define
的标识符在预处理阶段就换完成替换,根本不需要;
。
2.2#define定义宏
#define
机制包含了一个规定,允许将参数替换到文本中,这种实现通常被称为宏(macro)或定义宏(define macro),这一点和函数极为相似。
其语法如下:
#define name( parament-list ) stuff
其中的
parament-list
是⼀个由逗号隔开的符号表,它们可能出现在stuff中。注意:参数列表的左括号必须与name紧邻,如果两者之间有任何空⽩存在,参数列表就会被解释为stuff的⼀部分。
下面是宏使用的具体实例:
#define ADD(x) x + x
int main()
{
int ret = ADD(2);
printf("%d\n", ret);
return 0;
}
2.3 宏的副作用
(1) 宏替换
事实上定义宏有一个特别致命的问题,如下面这段代码:
#define SQUARE( x ) x * x
int main()
{
int a = 5;
printf("%d\n", SQUARE(a + 1));
return 0;
}//输出什么?
输出结果:11
我们预计会输出36,但是为什么变成11了呢?要知道这一点,我们还是得强调宏只是在预处理阶段完成替换。
在预处理时替换文本时,参数x被替换成a+1,所以这条语句实际上变成了:
printf ("%d\n",a + 1 * a + 1 );
这时我们就明白为什么出现错误的原因了,程序根本就没有按照我们预计的顺序走,解决这个问题也十分简单在宏定义上加上两个括号,这个问题便轻松的解决了。
#define SQUARE(x) (x) * (x)
但是这里还有个宏,按照以上方法处理,问题仍然未被解决:
#define DOUBLE(x) (x) + (x)
int main()
{
int a = 5;
printf("%d\n", 10 * DOUBLE(a));
return 0;
}
输出结果:55
加上括号后按照我们的预计应该输出100,但是事实上结果输出55,这明显又出现问题了。
替换⽂本时,参数x被替换成a,所以这条语句实际上变成了:
printf ("%d\n",10 * (5) + (5));
因为乘法优先级明显比加法高,所以并没有得到我们想要的结果,为了避免这种情况我们最后在最外层再加一层括号。
#define DOUBLE( x) ( ( x ) + ( x ) )
- 注意:所有用于对数值表达式进行求值的宏定义都应当以这种方式加上括号,以防在使用宏时,由于参数中的操作符或与其邻近操作符之间出现不可预料的相互作用。
(2) 宏参数
宏参数在宏的定义中出现不止一次的情况下,如果参数存在副作用,那么在使用该宏时就可能存在风险,致使出现不可预测的后果。所谓副作用,指的是表达式求值过程中产生的永久性效果。
x+1; //不带副作⽤
x++; //带有副作⽤
我们可以以下面这个例子来说明这个问题:
#include<stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main()
{
int a = 3;
int b = 4;
int max = 0;
max = MAX(a++, b++);
printf("max = %d, a = %d, b = %d\n", max, a, b);
return 0;
}
这段程序到底输出什么呢?是【5 4 5】【5 5 4】【6 5 4】【6 4 6】中到底是哪个呢?
我们知道预处理之后程序会变成:
z = ( (a++) > (b++) ? (a++) : (b++));
而我们前面也学过前置++与后置++的区别
- 首先a,b先使用a,b比较,a<b。a,b再自增变为4,5。
- 在执行b++,先赋值,max就等于5,然自增成6。所以输出结果是:
从上面示例我们可以看出,像自增++,自减–传进宏定义中可能会发生我们意料之外的结果,所以我们平时在使用宏的时候一定要小心谨慎。
2.4 #和##
(1) #操作符
首先我们得知道在打印字符串时候,字符串本身是具有拼接能力的,即不同的字符串最终会被合并成一个字符串。
比如下面这段代码:
int main()
{
printf("hello betty!");
printf("hello" "betty!");//两个字符串最终会合并成一个字符串
return 0;
}
#
运算符能够将宏的一个参数转换为字符串字面量。它只允许在带参数的宏的替换列表中出现。#
运算符所执行的操作可以理解为“字符串化”。
举个例子:当我们有一个变量 int a = 10
的时候,若想通过传参的方式打印出:the value of a is 10
,就可以这样写。
#define PRINT(n) printf("the value of "#n " is %d", n);
而如果没有#
,n
就会被解析成整数而发生报错。
当我们按照下⾯的⽅式调⽤的时候:
PRINT(a)
代码就会将#n
处理为字符串"a"
,然后与原字符串发生拼接。
printf("the value of ""a" " is %d", a);
通过这一特性我们可以通过宏实现一些函数无法实现的场景,比如:
//根据不同参数打印不同的字符串
#define PRINT(value, format) printf("the value of "#value" is " format "\n", value);
int main()
{
int a = 1;
char b = 'a';
double c = 3.14;
PRINT(a,"%d");
PRINT(b, "%c");
PRINT(c, "%lf");
return 0;
}
(2) ##操作符
在C语言中,我们可以通过##
实现记忆粘合,记号粘合是一种特殊的操作机制,它能够把位于其两侧的符号组合成一个新的符号。比如说,在宏定义中通过“记号粘合”,可以将原本分离的文本片段连接起来创建一个新的标识符。
以下是一个在 C 语言中关于“记号粘合”的简单示例:
#define CONCAT(X, Y) X##Y//记忆粘合
int main()
{
int num1 = 10, num2 = 20;
int combined = CONCAT(num, 1); // 这里相当于 num1
printf("%d\n", combined);
return 0;
}
在上述示例中,CONCAT(num, 1)
中的 ##
执行了记号粘合操作,将 num
和 1
粘合在一起,形成了 num1
这个标识符。
但是需要注意的是记忆粘合后的标识符必须满足C语言对于标识符的定义,否则结果就是未定义的。在 C 语言中,标识符的规定如下:
- 由字母(A - Z,a - z)、数字(0 - 9)和下划线(_)组成。
- 首字符须为字母或下划线,不可为数字。
- 区分大小写,如
myVar
和MyVar
不同。- 长度无严格限制,不同编译器可能有限制,通常前 31 个字符有意义。
- 不可用 C 语言关键字(如
int
、if
、while
等)作标识符。合法示例:
my_variable
、_underscore_start
、num123
。不合法示例:
123num
(以数字开头)、if
(是关键字) 。
并且利用记号粘合还可以实现C语言实现比较繁琐的情况。在此,我们思考这样一个情形:当编写一个用于获取两个数中较大值的函数时,由于数据类型的不同,我们不得不为每种数据类型分别编写不同的函数。例如:
int int_max(int x, int y)
{
return x>y?x:y;
}
float float_max(float x, float y)
{
return x>y?x: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;
}
2.5 #undef
#undef
这条指令⽤于移除⼀个宏定义,宏定义在被移除之后就不能再被使用。
#undef NAME
//如果现存的⼀个名字需要被重新定义,那么它的旧名字⾸先要被移除
#include<stdio.h>
//宏定义
#define PI 3.14
int main()
{
printf("取消之前:%lf", PI);
#undef PI
printf("取消之后:%lf", PI);
return 0;
}
3. 宏的替换规则
最后让我们总结一下在程序中扩展#define
定义的符号和宏,需历经以下步骤:
- 调用宏时,先检查参数是否有
#define
定义的符号,若有则先替换。- 接着,将替换文本插入原位置,对于宏,用参数值替换参数名。
- 最后,再次扫描结果文件,若含
#define
定义的符号,重复上述处理。
并且需要注意的是:
- 在宏参数和
#define
定义中,能够出现其他#define
定义的符号。不过,宏不允许出现递归。- 当预处理器对
#define
定义的符号进行搜索时,不会搜索字符串常量的内容。
4. 宏与函数的对比
宏通常被用于执行简单的运算。然而,我们发现同样的问题其实也能够通过函数来解决,那么为何有时我们会选择宏而非函数呢?原因有如下三点:
- 宏通常用于执行简单的运算。用于调用函数和从函数返回的代码所需时间,可能比实际执行小型计算工作所需的时间更多。因此,宏在程序的规模和速度上优于函数。
- 函数的参数必须声明为特定的类型,只能在类型合适的表达式上使用。而宏可以适用于整型、长整型、浮点型等,宏的参数是类型无关的。
- 宏有时候可以做到函数做不到的事情,比如宏的参数可以出现类型,而函数无法实现。如下面动态开辟内存:
#define MALLOC(num, type) (type*)malloc(num*sizeof(type))
宏虽然常被用于执行简单运算,但也存在一些显著的缺陷。
- 每次使用宏时,宏定义的代码会插入到程序中,若宏的定义较长,可能会大幅增加程序的长度。
- 宏无法进行调试,这给排查问题带来了困难。
- 宏因与类型无关,显得不够严谨。
- 宏还可能引发运算符优先级的问题,致使程序容易出错。
这些不足使得在使用宏时需要谨慎权衡其利弊。
以下是宏与函数的对比:
属性 | #define 定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时宏代码插入程序,除非很小否则程序长度大幅增长 | 代码只出现一处,每次调用同一份,通过函数栈帧执行 |
执行速度 | 更快 | 存在调用和返回的额外开销 |
操作符优先级 | 宏参数求值在周围表达式上下文,除非加括号否则可能有意外,建议多写括号 | 函数参数调用时求值一次,结果更易预测 |
带有副作用的参数 | 参数可能在宏体多处替换,求值结果难料 | 参数传参时求值一次,结果更易控制 |
参数类型 | 参数与类型无关,操作合法即可用于任何类型 | 参数与类型有关,不同类型需不同函数 |
调试 | 不方便 | 方便 |
递归 | 不能 | 能 |
5. 命令行定义
许多 C 编译器具备一种能力,允许在命令行中定义符号,以此来启动编译过程。 例如,当依据同一个源文件编译一个程序的不同版本时,这个特性颇有用处。假定在某个程序中声明了一个特定长度的数组,如果机器内存有限,就需要一个很小的数组;而在另一个内存较大的机器上,就需要一个更大的数组。
我们将在Linux
的gcc
编辑器下实验如下代码,然后输入以下指令:
gcc -D ARRAY_SIZE=10 test.c
这句指令的含义就是:使用gcc
编译器对test.c
文件进行编译,并在编译前定义宏ARRAY_SIZE
的值为 10。
#include <stdio.h>
int main()
{
int array[ARRAY_SIZE];//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;
}
6. 条件编译
条件编译是一种在编译阶段根据特定条件决定是否将一段代码包含进最终生成的可执行文件或目标代码的技术。简单来说,就是通过预定义的条件,例如指定的宏、操作系统类型、编译器选项等,来控制哪些代码被实际编译,哪些代码被忽略。
接下来我们将介绍四种常见的条件编译的形式:
#if 常量表达式
指令: 这是最基本的条件编译指令。预处理器会对常量表达式进行求值,如果结果为非零(即真),则会编译#if
到#endif
之间的代码;否则,会忽略这段代码。 示例:
#define DEBUG_LEVEL 1
#if DEBUG_LEVEL > 0
printf("Debugging is enabled.\n");
#endif
- 多个分支的条件编译: 这种结构允许根据不同的条件来选择编译不同的代码段。
#if
后面的表达式如果为真,就编译其对应的代码段;如果为假,就继续检查#elif
后面的表达式,依此类推。如果所有的条件都不满足,就编译#else
后面的代码段。 示例:
#define OS_TYPE 2
#if OS_TYPE == 1
printf("This is OS Type 1.\n");
#elif OS_TYPE == 2
printf("This is OS Type 2.\n");
#else
printf("Unknown OS Type.\n");
#endif
- 判断是否被定义:
#if defined(symbol)
和#ifdef symbol
用于检查指定的符号(通常是宏)是否已被定义,如果已定义则编译相应的代码。#if!defined(symbol)
和#ifndef symbol
则用于检查指定的符号是否未被定义,如果未定义则编译相应的代码。 示例:
#define FLAG
#ifdef FLAG
printf("FLAG is defined.\n");
#endif
#ifndef OTHER_FLAG
printf("OTHER_FLAG is not defined.\n");
#endif
- 嵌套指令: 条件编译指令可以嵌套使用,以实现更复杂的条件判断逻辑。 示例:
#define OS_UNIX
#define OPTION1
#if defined(OS_UNIX)
#ifdef OPTION1
printf("This is Unix with Option 1.\n");
#endif
#ifdef OPTION2
printf("This is Unix with Option 2.\n");
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
printf("This is MS-DOS with Option 2.\n");
#endif
#endif
条件编译和选择语句(如 if-else
语句)在控制程序的执行流程方面有一些相似之处,但也存在明显的区别:
相同点:
两者都是基于某种条件来决定程序的执行路径或代码的处理方式。
不同点:
- 执行时机:
- 条件编译发生在编译阶段,由预处理器根据定义的条件决定是否将某段代码包含在最终的编译结果中。
- 选择语句则是在程序运行时,根据条件的真假来决定执行哪部分代码。
- 作用范围:
- 条件编译影响的是代码的编译,可能会导致某些代码段完全不参与编译。
- 选择语句作用于程序运行时的执行流程,所有代码都会被编译,但根据条件决定是否执行。
- 灵活性:
- 条件编译通常用于处理与编译环境、平台等相关的差异,灵活性相对较低。
- 选择语句可以处理更复杂和动态的条件,在运行时根据各种变化的条件进行决策,灵活性更高。
总之,条件编译主要用于在编译时根据预定义的条件控制代码的包含与否,而选择语句用于在程序运行时根据动态的条件控制执行路径。根据具体的需求和场景,选择合适的方式来实现所需的逻辑控制。
7. 头文件的包含
在预处理阶段,当编译器遇到 #include
指令时,它会根据指定的方式(例如 <>
用于系统头文件, ""
用于用户自定义头文件)去查找对应的头文件。
找到头文件后,会将头文件的全部内容直接插入到 #include
指令所在的位置,就好像头文件的内容原本就在那里一样。
7.1 本地头文件
在编写程序的过程中,我们能够创建属于自己的头文件,并且可以在源文件中对其进行包含。通常,采用双引号的包含方式,形如:
#include "text.h"
当以双引号来包含头文件时,编译器首先会在源文件所在的目录下进行查找。倘若在该目录中未能找到这个头文件,编译器就会如同查找库函数头文件那样,在标准路径位置继续查找。要是最终还是找不到,就会提示编译错误。
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径
7.2 库文件
除了能够创建并包含本地头文件之外,我们还能够包含库文件,例如:<stdio.h>
、 <string.h>
。这类库文件常常采用尖括号包含的方式。
在查找这类以尖括号包含的头文件时,编译器会直接前往标准路径进行查找,如果未能找到,就会提示编译错误。
那么,对于库文件,是否也可以使用双引号“ ”
的形式来包含呢?
答案是肯定的,可以这样做。然而,如此操作会导致查找的效率降低,并且也不太容易区分所包含的文件究竟是库文件还是本地文件。
6.2 嵌套头文件
我们知道,在预处理阶段,#include
指令会展开所包含头文件的内容。如果头文件被多次包含,那么展开的重复信息就会增多,这会给编译带来较大的压力。这种问题在直接编写程序时是可以避免的,但前提是自己独立编写代码。然而,如果是在公司开发的环境中,常常需要与同事共同编写代码,这时就有可能出现你和同事包含相同头文件的情况。 比如说以下情况:
同事一编写test4.c
需要借助test1.h
与test2.h
两个头文件,而同事二编写test5.c
需要借助test2.h
与test3.h
两个头文件。最后两个同事的文件需要合并成test6.c
。结果就会造成test2.h
的头文件被重复包含。
那如何解决头⽂件被重复引⼊的问题?答案很简单使用条件编译,即每个头⽂件都包含以下内容
#ifndef __TEST_H__
#define __TEST_H__
//头⽂件的内容
#endif //__TEST_H__
比如说我们定义一个Add.h
的头文件,里面包含一个加法函数的声明。
#ifndef __TEST_H__
#define __TEST_H__
int Add(int x, int y);
#endif //__TEST_H__
这样第一次使用这个头文件时,并没有定义_TEST_H_
,ifndef
条件为真,执行#define_TEST_H_
以及头文件包含的内容。然后第二次,第三次…重复包含这个头文件时_TEST_H_
已定义,ifndef
条件为假,后续内容就不再执行。
当然还有一个办法就是直接在头文件中加:
#pragma once
#pragma once
int Add(int x, int y);
当然预处理指令还不止这些,#error
,#line
等预处理指令大家可以自主学习。