目录
注:
本笔记参考B站up:鹏哥C语言的视频。
预定义详解
预处理符号
__FILE__ | 进行编译的源文件 |
__LINE__ | 文件当前的行号 |
__DATE__ | 文件被编译的日期 |
__TIME__ | 文件被编译的时间 |
__FUNCTION_ | 文件当前所在的函数名 |
__STDC__ | 如果编译器遵循ANSI C,其值为1,否则未定义 |
这些预处理符号都是语言内置的,通过它们,可以记录一些日志。
举例
printf("%s\n", __FILE__);
打印结果为:
d:\code\exp\lab.c即代码源文件所在路径及源文件的名字。
printf("%d\n", __LINE__);
打印结果为:
6
即该行代码所在的行号。
printf("%s\n", __DATE__); //打印日期 printf("%s\n", __TIME__); //打印时间
打印结果为:
Jun 29 2022
17:26:06刚刚好是程序执行时对应的时间。
printf("%s\n", __FUNCTION__); //对应__FUNCTION_所在的函数名
打印结果为:
main因为该函数的打印是在main函数上进行的。
printf("%d\n", __STDC__);
注:VS编译器应该是不支持该符号的,同时也不支持ANSI C标准。所以这里使用的是VS Code。
打印结果为:
1
使用例
#include<stdio.h>
int main()
{
int i = 0;
FILE* pf = fopen("log.txt", "a+");
if (pf == NULL)
{
perror("fopen\n");
return 1;
}
for ( i = 0; i < 10; i++)
{
fprintf(pf, "%s %d %s %s %d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
}
fclose(pf);
pf = NULL;
return 0;
}
执行程序,在源文件所在文件夹下就生成了一个 log.txt ,打开文件:
这就生成了一个日志。因为使用的读写形式的 "a+" ,是追加的方式,所以每运行一次,就会追加一段日志内容。
#define
#define 定义标识符
语法
#define name stuff
#define的作用:
- 定义符号
举例
#define MAX 1000 | |
#define reg register | 为 register 这个关键字创建一个更简短的名字。 |
#define do_forever for(;;) | 用更形象的符号替换一种实现。 |
#define CASE break;case | 在写 case 语句时,自动写上 break 。 |
#define DEBUG_PRINT printf("file:%s\tline:%d\t \ date:%s\ttime:%s\n" ,\ __FILE__ ,__LINE__ , __DATE__ ,__TIME__ ) | 如果定义的 stuff 过长,可以分成几行写。 最后一行除外,每行的最后都要加上一个反斜杠(续行符)。 |
提问:在使用 #define 时,后面需不需要加上 ; (分号)?即:
① #define M 1000;
② #define M 1000
这两者之间的区别。
答:这两种写法在语法上都是行得通的。但是存在一个小小的区别。
①会在替换时把1000后面的分号也给替换过来,也就是
而②在替换时不会存在这种分号
只是从实际上讲,通常不会多加一个分号。
但是还是会有无法多加一个分号的情况,如:
#include<stdio.h>
#define M 1000;
int main()
{
int a = 10;
int b = 0;
if (a > 10)
b = M;
else
b = -M;
return 0;
}
这种情况是会报错的,如果我们把 M 替换成 1000; 就会发现变成
if (a > 10)
b = 1000;
;
else
b = -M;
if语句后面跟了两条语句,这时候 else 匹配 if 就会发生混乱。因为存在两条语句,else 就会不知道到底匹配那条语句。
所以,一般情况下建议不要在使用 #define 定义标识符时在末尾加上一个分号,除非是必要情况。
#define定义宏 - 括号很重要
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常被称为宏(macro)或定义宏(define macro)。
宏的申明方式:
#define name( parament-list ) stuff
其中parament-list是一个逗号隔开的符号表,它们可能出现在stuff中。
注意:
- 参数列表的左括号必须与name紧邻,
- 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
举个例子:
定义一个宏 #define SQUARE(X) X*X
#include<stdio.h>
#define SQUARE(X) X*X
int main()
{
printf("%d\n", SQUARE(3));
return 0;
}
预处理器会把 SQUARE(3) 替换为 SQUARE(3*3),最终的打印结果就是 9 。
举一反三:如果写入 SQUARE(3+1),那么打印结果会是多少?
打印结果:
这里的打印结果是 7,而不是 16 。
这里就涉及一个重要的知识点:宏是先完成替换,再完成计算的。上面的SQUARE(3+1)到底是怎么进行工作的呢?
首先,SQUARE(3+1)被替换成 3 + 1*3 + 1,然后再计算,等于 7。
修改:如果我们需要上述的宏可以实现表达式的运算,最好应该怎么做?
定义宏:#define SQUARE(X) ( (X) * (X) )
(X)使得宏会优先计算括号内的内容,而为了计算的严谨,通常会把宏的计算的整体结果也用括号括起来,变成( (X) * (X) )。
但是,即使加上括号,如果括号不到位,宏仍然可能存在问题。
如:
#include<stdio.h>
#define DOUBLE(X) (X) + (X)
int main()
{
printf("%d\n", 10 * DOUBLE(4));
return 0;
}
按照设想,计算应该是 10 * ( 4 + 4 ) = 80,那么实际结果究竟是什么呢?
为什么是44呢?这就是替换不到位了,实际上 10 * DOUBLE(4) 在预处理器之后,变成的是 10 * 4 + 4,因为最外面少了一个括号,所以应该把宏定义为 DOUBLE(X) ( (X) + (X) )。
#define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果包含,这些符号会首先被替换。
- 替换文本随后被插入到程序中原本文本的位置。如果是宏,参数名被它们的值替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号,如果仍有包含,则重复上述处理过程。
比如:
#define M 100
#define MAX(X, Y) ( (X)>(Y)?(X):(Y) )
int main()
{
int max = MAX(101, M);
return 0;
}
在上述的#define替换中:
首先被替换的就是 int max = MAX(101, M); 中的 M ,替换为:int max = MAX(101, 100);
然后对于宏,替换参数名,即 MAX(101, 100) 变为 ( (101)>(100)?(101):(100) )
注意
- 宏参数和#define定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不会被搜索。
# 和 ##
#的使用运行我们把参数输入字符串中。(只能在宏内使用)
- # 的作用就是把#X替换成 X的内容所对应的字符串。
- ## 可以进行符号的组合。
提问:下面这串代码执行结果是什么?
#include<stdio.h>
int main()
{
printf("Hello world\n");
printf("Hello " "world\n");
return 0;
}
打印结果:
此处,第二个printf函数内存在 两个字符串,但是两个printf函数最终的处理结果是相同的,也就是说,第二个printf函数内的两个字符串是天然连接到一起的。
那么假设:
int a = 10; ||| 要求打印 the value of a is 10
int b = 20; ||| 要求打印 the value of b is 20
int c = 30; ||| 要求打印 the value of c is 30
可以看出,上面的三个要求十分相似,在这种情况下如果使用三条 printf语句 打印,就较为冗余了,那怎么办呢?
我们可以写成一个功能函数:
#include<stdio.h>
void print(int x)
{
printf("the value of ? is %d\n", x);
}
int main()
{
int a = 10;
print(a);
int b = 20;
print(b);
int c = 30;
print(c);
}
但是我们要怎么打印 ?的内容呢?说到底,x 和谁配对函数怎么知道呢?无论字符串内写成什么内容,都无法完美完成要求。
虽然函数不行,但是我们还有宏可以使用。
先尝试一种错误的写法:
#include<stdio.h>
#define PRINT(X) printf("the value of " X " is %d\n", X);
int main()
{
int a = 10;
PRINT(a);
int b = 20;
PRINT(b);
int c = 30;
PRINT(c);
return 0;
}
发现报错了:
报错是发生在链接之后,替换已经完成,说明发生的问题存在于 #define 中。说明上面的 #define 写法是有问题的。
那么什么才是正确的写法呢?
#define PRINT(X) printf("the value of " #X " is %d\n", X);
这里就体现了 # 的作用了,进行打印,发现可行:
# 的作用就是把#X替换成 X的内容所对应的字符串。
基于这种功能,如果我们需要这个宏不仅可以打印整型数据,并且可以打印浮点型的数据,譬如:
float f = 5.5f;
PRINT(f);
此时原本的宏内使用的 %d 就无法满足我们的需求了。要怎么办呢?
可以让宏多出来一个参数,用来处理类型的问题,即:
#define PRINT(X, FORMAT) printf("the value of " #X " is "FORMAT"\n", X);
#include<stdio.h>
#define PRINT(X, FORMAT) printf("the value of " #X " is "FORMAT"\n", X);
int main()
{
int a = 10;
PRINT(a, "%d");
int b = 20;
PRINT(b, "%d");
int c = 30;
PRINT(c, "%d");
float f = 5.5f;
PRINT(f, "%f");
return 0;
}
打印结果:
为什么可以完成这种操作呢?最主要的,是考虑这里的FORMAT到底被替换成了什么?
就拿PRINT(f, "%f")举例,这里的函数名被替换成了:
printf("the value of " "f" " is ""%f""\n", X); 这时候就是一段完整的代码了。
再来看看 ## :
#include<stdio.h>
#define CAT(X, Y) X##Y
int main()
{
int class101 = 100;
printf("%d\n", CAT(class, 101));
return 0;
}
打印结果为:
为什么这里是100?这就是 ## 的作用了。
## 可以把位于它两端的符号合成一个符号,允许宏定义从分离的文本片段创建标识符。
注:链接产生的标识符必须合法。
而这里,就是把 class 和 101 组合成了 class101 这个变量了。
printf("%d\n", CAT(class, 101));
printf("%d\n", class101);
//这两条语句是等价的。
带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么这个宏在被使用的过程中可能出现危险,导致不可预测的结果。
副作用就是表达式求值时出现的永久性效果。
int main()
{
int a = 1;
int b = a + 1; //程序执行后,b = 2, a = 1
int b = a++; //程序执行后,b = 2, a = 2 —— 存在副作用
return 0;
}
在上面的代码中,a++ 在 b 被赋值的同时,也改变了 a 本身的大小,这就是存在副作用的。
例子
#include<stdio.h>
#define MAX(X,Y) ( (X)>(Y)?(X):(Y))
int main()
{
int a = 5;
int b = 8;
int m = MAX(a++, b++);
printf("m = %d\n", m);
return 0;
}
打印结果:
不是 8,而是 9 。
解析:
先看看MAX到底替换了什么?
int m = MAX(a++, b++) 变为了:int m = ( (a++) > (b++) ? (a++) : (b++) )。此处进行的替换是三目操作符,从左向右依次执行:
- 在判断部分(也就是(a++) > (b++)),由于后置++,故取 5 > 8,是假命题,接下来要执行 (b++)。
- 但是,在执行 ? (a++) : (b++) 中的 (b++) 之前,(a++) > (b++) 中的 a++ 和 b++ 会优先执行,此时a = 5 + 1 = 6,b = 8 + 1 = 9。
- 接下来执行? (a++) : (b++) 中的 (b++) ,也就是先返回 b 当前的值,然后进行 b++ 操作,故 m 被赋值为 9,接下来 b++ 使 b 的值变为 10。
验证:通过观察最终 a 和 b 的值判断解析是否出错。
#include<stdio.h>
#define MAX(X,Y) ( (X)>(Y)?(X):(Y))
int main()
{
int a = 5;
int b = 8;
int m = MAX(a++, b++);
printf("a = %d, b = %d\n", a, b);
return 0;
}
打印结果:
a = 6, b = 10,符合解析。
宏和函数的对比
宏通常被应用于执行简单的运算,比如求较大值的宏。
#define MAX(X,Y) ((X)>(Y)?(X):(Y))//宏
int Max(int x, int y)//函数
{
return x > y ? x : y;
}
为什么不使用函数来完成该任务呢?
宏相比于函数的优势:
- 相比于实际执行这个小型计算工作,用于调用函数和从函数返回的代码 可能花费比实际执行更多的时间。所以,比起函数,宏在程序的规模和速度方面更胜一筹。
- 更为重要的,函数的参数必须声明为特定的类型,所以函数只能在类型适合的表达式上使用。宏则可以适用于整型、长整型和浮点型等可以用 > 来比较的类型。宏是类型无关的。
举例来证明优势1,现在需要比较 a 和 b 的较大值:
#define MAX(X,Y) ( (X)>(Y)?(X):(Y) )//宏
int Max(int x, int y)//函数
{
return x > y ? x : y;
}
int main()
{
int a = 5;
int b = 8;
int m = MAX(a, b);//使用宏
m = Max(a, b);//使用函数
return 0;
}
使用宏 [ MAX(a, b) ]的那一行代码在预编译阶段就已经被替换成:int m = ( (a)>(b)?(a):(b) )了,在这种情况下开始调试,打开反汇编,查看汇编代码:
这是执行一个宏所需要的汇编代码:
这是执行一个函数所需要的汇编代码:
1. 首先进入代码
2. 然后呼叫函数
3. 接着正式进入函数
4. 开始执行函数内部内容
5. 接着要把函数执行的结果带回主函数
最后结果被放到[m]中。
可以看到,为了调用一个函数,需要做大量的准备工作。同时,函数的返回也需要进行额外的操作。最后就会发现,调用一个函数需要大量的时间开销,而使用对等的时间,程序甚至可以进行好几个简单运算。
当然,宏相比于函数,也是存在劣势的:
- 每次使用宏的时候,一份宏定义的代码将被插入到程序中。所以,除非宏比较短,否则宏的使用可能会大幅度增加程序的长度。
- 宏是没法调试的。(宏的替换会在预处理阶段被完成,但是调试是在可执行程序的基础上进行的)
- 宏由于类型无关,也就会存在不够严谨的情况。
- 宏可能会带来运算符优先级的问题,导致程序容易出现错误。(譬如:如果传入宏内部的是一个表达式,而这个宏本身也是由各种表达式组成的,当操作符交集在一起时,就有可能因为优先级问题而出现错误)
额外的例子(宏可以完成,而函数无法完成的任务)
通过更加简便的方式开辟一块空间:
#include<stdio.h> #include<stdlib.h> #define MALLOC(num, type) (type*)malloc(num * sizeof(type)) int main() { int *p = MALLOC(10, int); return 0; }
总结
属性 | #define定义宏 | 函数 |
代码长度 | 每次使用时,宏代码都会被插入到程序中。 使用的宏,除了非常小的宏,都会导致程序的长度大幅度增长。 | 函数代码只会出现在一个地方。 每次使用这个函数时,都是从同一个地方调用的代码。 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以会相对慢一些。 |
操作符优先级 | 宏参数的求值是在周围所有表达式的上下文环境内进行的。 除非加上括号,否则临近操作符的优先级问题可能会导致不可预料的后果,所以建议宏在书写的时候多加一些括号。 | 函数参数只在函数调用的时候进行一次求值,它的结果值将被传递给某一个函数。 表达式的求值结果更容易被预测。 |
带有副作用 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候被进行一次求值,更方便控制结果。 |
参数类型 | 宏的参数是于类型无关的。 只要对参数的操作是合法的,宏就可以使用任何参数类型。 | 函数的参数是于类型有关的。 如果参数的类型不同,就需要不同的函数,即使它们执行的任务是不同的。 |
调试 | 宏是不方便调试的。 | 函数是可以逐语句调试的。 |
递归 | 宏是不能递归的。 | 函数是可以递归的。 |
如果一个运算的功能足够简单,可以考虑使用宏来完成该功能。反之,复杂的实现就交给函数来完成。
可扩展(内敛函数)
关键字:inline — 结合了宏与函数的优点。
命名约定
一般来讲,函数和宏的使用语法是很相似的,语言本身没办法帮助我们区别二者。所以作为一个约定,我们习惯:
||| 宏的名字全部大写
||| 函数的名字不要全部大写
#undef
这条指令用于移除一个宏定义。
如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
命名行定义
许多 C的编译器 提供了这样一种能力:允许在命令行中定义符号,用于启动编译过程。
例如:当我们根据一个源文件要编译出不同的程序的不同版本的时候,这个特性就会有点用处。
举例
#include<stdio.h> int main() { int arr[M] = { 0 }; int i = 0; for(i = 0; i < M; i++) { arr[i] = i; } for(i = 0; i < M; i++) { printf("%d ", i); } }
如果只是直接编译,那么在Linux环境下,会出现如下的报错
那么有没有什么办法,能够在不改变代码的前提下,使得程序正常运行呢?
接下来我们输入:
gcc test.c -D M=10
通过这种形式的输入,我们把 M 定义为了 10 这个数字。
可以发现,程序确实被成功编译。同理,我们可以把这个 M 定义为 100、1000、10000……而唯一需要改变的,就是 M 所对应的值。
这就是把 M 的值设置成100时执行程序的结果。
ls -a ls -l ls -al 在Linux环境下,形如 -a、-l和 -al 这种形式的参数,被统称为命令行参数。
而上述的 M 是在命令行中被定义的,这就是命令行定义。
条件编译
在编译一个程序的时候,我们如果要将一条语句(或者一组语句)编译或者放弃是很方便的,只要使用条件编译指令即可。
比如说:调试性的代码,删除的话会很可惜,保留的话又会碍事,此时我们就可以进行选择性的编译。
举个例子
上述的代码中的 printf语句 ,只有当 PRINT 被定义的时候才会被执行。使用 printf语句 才会是灰色的。所以如果是下面这种情况,程序就会正常执行:
执行程序
常见的条件编译命令
1.
#if 常量表达式
//...
#endif
//常量表达式有预处理器求值
如:
#define __DEBUG__ 1
#if __DEBUG__
//...
#endif
比如
此时 0 为假,不会执行 printf语句 。
2. 多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
举例
只有当判断条件结果为真时,才会执行 printf语句 ,所以程序只执行第一条 printf语句 ,打印结果:
3. 判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defned(symbol)
#ifndef symbol
举例1(定义的情况)
或者
打印结果:
-----
举例2(不定义的情况)
或者
打印结果
4. 嵌合命令
#if defined(OS_UNIX) //如果是UNIX操作系统,则执行下列语句
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#endif defined(OS_MSDOS) //如果是WINDOWS操作系统,则执行下列语句
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
文件包含
我们已经知道,#include 指令可以使另外一个文件被编译,就像这个被编译的文件实际出现在了原本 #include 指令的地方一样。
#include 指令所使用的替换的方式很简单:
- 首先,预处理器会删除这条 #include 指令。
- 然后,使用包含文件的内容进行替换。
这样一个源文件被包含10次,实际上就会被编译10次。
头文件被包含的方式
- 本地文件包含
#include "filename" //一般后缀的 .h 是要带上的。
查找策略:
- 先在源文件所在的目录下进行查找。
- 如果没有找到指定头文件,编译器就会像查找库函数的头文件那样,在标准位置查找头文件。
如果没有找到指定文件,就会提示程序错误。
Linux环境下的标准头文件的路径:
/usr/include
VS环境下的标准头文件的路径(仅供参考):
C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\ucrt
D:\mingw64\x86_64-w64-mingw32\include //这个是个人安装的。
查找时需要注意自己的安装路径。
- 库文件包含
#include <filename.h>
(库文件:C语言库中提供函数的头文件。)
查找策略:
- 查找库函数所在头文件时,编译器会直接到标准路径下去查找,如果找不到就会提示编译错误。
由上述可知,对于库文件的包含也可以使用 " " 的形式。如:
#include "stdio.h"
但是这样查找有两个缺点:
1. 降低效率。
2. 不容易区分库文件和本地文件。
< > 和 " " 的区别本质上就是查找策略的区别。
嵌套文件包含
一般而言,我们会在头文件内会写入:
- 类型的定义
- 函数的声明
而我们在写程序时,可能会出现同一个头文件被重复、多次包含的情况。
相当于 act模块 将公共模块的内容包含了两次,即一个头文件被包含了两次,而如果包含的次数过多,就会使得这个程序过于冗余。那么如何避免这种头文件被重复包含的情况呢?
举例(本次使用Ubantu系统):
首先,创建一个头文件 test.h
int Add(int x, int y);
struct S
{
char c;
int i;
};
然后,创建一个源文件 test.c,先包含两次头文件 test.h
#include "test.h"
#include "test.h"
int main()
{
int a = 10;
return 0;
}
接下来使用命令:gcc test.c -E > test.i ,只预处理 test.c ,并把预处理完的内容重定向到 test.i ,打开 test.i :
通过两次出现的 结构体类型S ,可以断定:头文件test.h 的内容被包含了两份(如果被定义的是变量,就会发生重复定义变量的情况。所以一般不会在头文件内定义变量)。要避免这种情况的发生,最简单的一种方法就是头文件内写上:
#pragma once
此时即使多次包含该头文件,在预处理时该头文件也只会被写入一次。
或者
在 test.h 中:
#ifndef __TEST_H__ //如果没有定义 __TEST_H__ ,那么接下来的内容正常执行,头文件被包含。
#define __TEST_H__ //定义 __TEST_H__ 。
int Add(int x, int y);
struct S
{
char c;
int i;
};
#endif
当第一次包含该头文件时,__TEST_H__ (名字随便取)被定义,当第二次想要包含该头文件时,由于 __TEST_H__ 已经被定义,所以包含不会进行。
其他预处理指令
参考《C语言深度解剖》
预处理名称 | 意 义 |
#line | 改变当前行数和文件名称,它们是在编译程序中预先定义的标识符。 命令的基本形式如下: #line number["filename"] |
#error | 编译程序时,只要遇到 #error 就会生成一个编译错误提示信息,并停止编译。 |
#pragma | 为实现时定义的命令,它允许向编译程序传送各种指令,例如: 编译程序可能有一种选择,它支持对程序执行的跟踪。可用 #pragma语句 指定一个跟踪选择。 ------ 再比如,可用通过 #pragma pack(number) 这样的一对指令设置和取消默认对齐数。(其中 number 是需要设置的默认对齐数的大小) |