目录
1、程序环境
1.1 ANSI C 标准
1.2程序的翻译环境和执行环境
1.3运行环境
2、预处理详解
2.1、预定义符号
2.2、#define
2.2.1#define定义表示符
2.2.2#define定义宏
2.2.3#define替换规则
2.4#和##
2.2.5带副作用的宏参数
2.2.6宏和函数对比
3、#undef
4、命令行定义
5、条件编译
6、文件包含
1、程序环境
1.1 ANSI C 标准
C语言发展之初,并没有所谓的C标准。1978年,布莱恩·柯林汉和丹尼斯·里奇合著的The C Programming Language(《C语言程序设计》)第一版是公认的C标准,通常称之为K&R C或经典C。随着C的不断发展,越来越广泛地应用于更多系统中,C社区意识到需要一个更全面、更新颖、更严格的标准。美国国家标准协会(ANSI)于1983年组建了一个委员会,开发了一套新标准,并于1989年正式公布。该标准(ANSI C)定义了C语言和C标准库。国际标准化组织于990年采用了这套C标准(ISO C)。ISO C和ANSI C是完全相同的标准。ANSI/ISO标准的最终版本通常叫做C89(C90)。另外,由于ANSI先公布C标准,因此业界认识通常使用ANSI C。1994年,ANSI/ISO联合委员会开始修改C标准,最终发布了C99标准。该委员会遵循了最初C90标准的原则,包括保持语言的精炼简单。委员会的用意不是在C语言中添加新特性,而是为了达到新的目标。
ANSI C是由美国国家标准协会(ANSI)及国际化标准组织(ISO)推出的关于C语言的标准。
ANSI C 主要标准化了现存的实现, 同时增加了一些来自 C++ 的内容 (主要是函数原型)
并支持多国字符集 (包括备受争议的三字符序列)。
1.2程序的翻译环境和执行环境
ANSI C 的任何一种实现中,存在两种不同的环境:
翻译环境:在该环境中,源代码被转换为可执行的机器指令
执行环境:用于实际执行代码
add.c
#include "add.h"
int add(int a, int b)
{
return a + b;
}
test.c
#includde "add.h"
int main(void)
{
int ret = add(3,4);
printf("a + b = %d\n",ret);
return 0;
}
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
连接器同时也会引入标准C库函数中任何被该程序所用到的函数,且可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
举个例子:test.c、add.c、minu.c
1.3运行环境
程序执行过程:
- 程序必须载入内存中。在有操作系统的环境中:程序的载入一般由操作系统完成。在独立环境中:程序的载入必须手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用 main 函数。
- 开始执行程序代码,这个时候程序将使用一个运行时堆栈(stack),内存函数的局部变量和返回地址。程序同时也可以使用静态(staic)内存,存储与静态内存中的变量在整个执行过程中一直保留他们的值。
- 终止程序。正常终止 main 函数(也有可能是意外终止)。
2、预处理详解
2.1、预定义符号
__FILE__ //进行编译的源文件,表示当前源代码文件名的字符串字面量
__LINE__ //文件当前的行号,表示当前源代码文件中的行号的整型常量
__DATE__ //文件被编译的日期,预处理的日期
__TIME__ //文件被编译的时间,翻译代码的时间
__FUNCTION__ //文件被编译的函数名
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
__STDC_HOSTED__ //本机环境设置为1;否则设置为0
__STDC_VERSION__ //支持C99标准,设置为199901L:支持C11标准,设置201112L
void print(void)
{
printf("文件被编译的函数名:%s\n", __FUNCTION__);
}
int main(void)
{
printf("进行编译的源文件:%s\n", __FILE__);
printf("文件当前的行号:%d\n", __LINE__);
printf("文件被编译的日期:%s\n", __DATE__);
printf("文件被编译的时间:%s\n", __TIME__);
printf("文件被编译的函数名:%s\n", __FUNCTION__);
//printf("如果编译器遵循ANSI C,其值为1,否则未定义:%d\n", __STDC__);
printf("---------------------\n");
print();
return 0;
}
我使用的的编译器为vs2022版本,对于__STDC__这个符号没有定义,这些预定义符号都是语言内置的。
2.2、#define
(#define定义的标识符和宏和枚举一样,习惯用大写)(程序员的约定俗成)
2.2.1#define定义表示符
语法:#define name stuff
name:替换的名字
stuff:被替换之后的内容
根据上面的语法我们就可以写出下面的例子:
#define MAX 1000
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ ,\
__DATE__,__TIME__ )
stuff是可以分开几行写,只是需添加反斜杠(续航符)
这里有一个很特殊的问题了,之前语句篇(C语言入门篇——语句篇_sakura0908的博客-CSDN博客)中说语句都是用分号结尾,那这里的最后要不要添加分号呢?在一些场景中会容易导致问题(语法错误),建议不要加上分号。
2.2.2#define定义宏
#define name(parament-list) stuff
name:替换的名字
parament-list:由逗号隔开的符号表(参数列表)
stuff:被替换之后的内容
介绍:#define 机制允许把参数替换到文本中,这种实现通常被称为宏(macro)或定义宏(define macro),parament-list 是一个由逗号隔开的符号表,他们可能出现在 stuff 中。
注意事项:
- 参数列表的左括号必须与 name 紧邻。
- 如果两者之间由任何空白存在,参数列表就会将其解释为 stuff 的一部分。
测试案例代码如下:
#define Multiply(X) X*X
int main(void)
{
printf("X * X = %d\n", Multiply(3));
return 0;
}
那么 Multiply(3+1) 的结果是什么?一些初学者可能认为是16,但当运行之后答案却不一样,这是什么原因呢?
怎么去理解这答案呢?要把宏定义中的参数列表作为一个整体,完全替换。宏的参数是完成替换的,他不会提前完成计算,而是替换进去后再计算。替换是在预处理阶段时替换,表达式真正计算出结果是在运行时计算。如果想获得 3+1 相乘(也就是得到 4×4 = 16) 的结果,我们需要给他们添加括号:
#define Multiply(X) X*X
#define Multiply2(X) (X)*(X)
int main(void)
{
printf("X * X = %d\n", Multiply(3 + 1));
printf("X * X = %d\n", Multiply2(3 + 1));
return 0;
}
另外,整体再套一个括号!让代码更加严谨,防止产生不必要的错误。
#define Multiply3(X) ((X)*(X))
结论:所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,可以有效避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料地相互作用。
2.2.3#define替换规则
在程序中扩展 #define 定义符号或宏时,需要涉及的步骤如下:
- 检查:在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果包含,它们首先被替换。 替换:替换文本随后被插入到程序中原来的文本位置。
- 对于宏,函数名被它们的值替换。
- 再次扫描:最后,再次对结果文件进行扫描,看看是否包含任何由 #define 定义的符号。
如果包含,就重复上述处理过程。
注意:宏参数和#define定义中可以出现#define定义的变量,但是对于宏绝对不能出现递归;当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索
2.4#和##
2.4.1#的作用
这里所说的#并不是#define和#include中的#,这里所说的#的作用是:把一个宏参数变成对应的字符串。
这个#到底有什么实际的作用呢?在介绍#的作用的之前,我先向大家说明一下:字符串是有自动连接的特点的。
char arr[] = "hello ""world!";
//等价于char arr[] = "hello world!";
printf("helll ""world!\n");
//等价于printf("helll world!\n");
int main(void)
{
int age = 22;
printf("The value of age is %d\n", age);
double pi = 3.1415;
printf("The value of pi is %f\n", pi);
int* p = &age;
printf("The value of p is %p\n", p);
return 0;
}
printf要打印的内容大部分是一样的,那么,为了避免代码冗余,我们可不可以将其封装成一个函数或是宏呢?这时就需要用到这个#了
#define print(data,format) printf("The value of "#data" is "format"\n",data)
int main()
{
int age = 22;
print(age, "%d");
double pi = 3.1415;
print(pi, "%f");
int* p = &age;
print(p, "%p");
return 0;
}
2.4.2##的作用
##可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符
例如,下面定义的宏可以将传入的两个符号合成一个符号,测试案例代码如下:
#define STRCAT(x,y) x##y
int main(void)
{
int xy = 100;
printf("%d\n", STRCAT(x, y));//打印什么?
return 0;
}
2.2.5带副作用的宏参数
在介绍带副作用的宏参数之前,我们先看看带有副作用是什么意思。
int a = 10;
int b = a + 1;//无副作用
int c = a++;//有副作用
代码中,b和c都想得到a+1的值,但不改变a的值。b得到a+1的值后,a的值并没有发生改变,所以无副作用;但是c得到a+1的值后,a的值也变化了,也就是有副作用。简单来说,代码执行后,除了达到我们想要的结果之外,还导致了其他问题的发生,我们就说该条语句带有副作用。
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如,我们要比较a和b的大小,并将其较大值赋值给c,之后再将a和b同时加1。
#define MAX(x,y) ((x)>(y)?(x):(y))
int main(void)
{
int a = 10;
int b = 20;
int c = MAX(a++, b++);
printf("%d\n", c);
return 0;
}
这段代码看似没有问题,但是结果却是不正确的,因为该宏经过替换后,等价于以下代码:
int main(void)
{
int a = 10;
int b = 20;
int c = ((a++)>(b++)?(a++):(b++));
printf("%d\n", c);
return 0;
}
经过替换后,我们一分析便可得出答案,c的最后的结果是21,并且代码执行后,a和b的值并不是同时加1,a的值变为了11,而b的值却变为了22。
所以,当我们使用宏的时候,应该避免传入带有副作用的宏参数。
2.2.6宏和函数对比
属性 | #define定义宏 | 函数 |
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常 小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开 销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括 号 | 函数参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果 | 函数参数只在传参的时候求值一 次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型 | 函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 不同的 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
3、#undef
#undef NAME 用于移除一个宏定义。(也不用在后面加分号)
#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除
测试案例代码如下:
#define TEST 1
int main(void)
{
int a = TEST;
printf("%d\n", TEST);
#undef TEST// 移除宏定义
return 0;
}
4、命令行定义
许多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 programe.c ARRAY_SIZE = 10
经过该编译指令后,便可以打印出0到9的数字。
5、条件编译
条件编译,即满足条件就参与编译,不满足条件就不参与编译。
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
常见的条件编译指令有以下几种:
1.单分支的条件编译
#if 表达式
//待定代码
#endif
如果#if后面的表达式为真,则“待定代码”的内容将参与编译,否则“待定代码”的内容不参与编译。
2.多分支的条件编译
#if 表达式
//待定代码1
#elif 表达式
//待定代码2
#elif 表达式
//待定代码3
#else 表达式
//待定代码4
#endif
多分支的条件编译类似于if-else语句,“待定代码1,2,3,4”之中只会有一段代码参与编译。
3.判断是否被定义
//第一种的正面
#if defined(表达式)
//待定代码
#endif
//第一种的反面
#if !defined(表达式)
//待定代码
#endif
如果“表达式”被#define定义过,则“第一种的正面”的“待定代码”将参与编译,否则不参与编译。“第一种的反面”的执行机制与“第一种的正面”恰好相反。
//第二种的正面
#ifdef 表达式
//待定代码
#endif
//第二种的反面
#ifndef 表达式
//待定代码
#endif
如果“表达式”被#define定义过,则“第二种的正面”的“待定代码”将参与编译,否则不参与编译。“第二种的反面”的执行机制与“第二种的正面”恰好相反。
4.嵌套指令
#include <stdio.h>
#define MIN 10
int main()
{
#if !defined(MAX)
#ifdef MIN
printf("hello\n");
#else
printf("world\n");
#endif
#endif
return 0;
}
这里条件编译指令的嵌套类似于if-else语句的嵌套,详情可阅读此篇博客(C语言入门篇——语句篇_sakura0908的博客-CSDN博客),博友们可以类比理解。
注意:未满足条件编译指令的代码,在预处理阶段将被编译器自动删除,不参与后面的代码编译过程。
例如,以下代码:
#include <stdio.h>
int main()
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d\n", i);
#if 0
printf("hello world!\n");
#endif
}
return 0;
}
因为#if后面的表达式为假,语句 #if 0 和 #endif 之间的代码将不参与编译,所以在预处理阶段过后,编译器编译的代码是:
//#include <stdio.h>
//预处理阶段头文件也被包含了
int main()
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d\n", i);
}
return 0;
}
所以,代码运行后只会打印0到9的数字。
6、文件包含
我们知道,#include指令可以使被包含的文件参与编译,在预处理阶段,就会进行文件的包含。
例如:
#include <stdio.h>
在预处理阶段,编译器会先删除该指令,并用stdio.h文件中的内容进行替换。
但是,文件的包含有两种:
#include <stdio.h>
#include "stdio.h"
一种是用尖括号将要包含的文件括起来,另一种是用双引号将要包含的文件引起来。这两种方法,在某些情况下似乎都可行,那么这两种方法到底有什么区别呢?
< >:如果使用尖括号的方式对头文件进行包含,那么当代码运行到预处理阶段,将对头文件进行包含时,编译器会自动去自己的安装路径下查找库目录,若库目录中含有该头文件,则将其进行包含,若库目录下不存在该头文件,则提示编译错误。
" ":如果使用双引号的方式对头文件进行包含,那么当代码运行到预处理阶段,将对头文件进行包含时,编译器会首先去正在编译的源文件目录下进行查找,若没有找到目标头文件,则再去库目录下进行查找,若两处都没有找到目标头文件,则提示编译错误。
这样看来,当我们要包含的头文件是库函数的头文件的时候,我们使用尖括号或者双引号都可以,但是当我们要包含的头文件是自定义的头文件时,我们只能用双引号进行头文件的包含。
但是如果我们明明知道自己要包含的头文件是库函数的头文件,那我们就没有必要使用双引号去包含,因为那样会降低代码的效率。所以说,为了提高代码执行效率:
< >:一般用于包含C语言提供的库函数的头文件。
" ":一般用于包含自定义的头文件。
关于头文件,还有一点值得注意的是,当我们使用#include来包含头文件时,如果我们重复包含同一个头文件,那么在预处理阶段就会重复包含该头文件的内容,会大大加长代码量,导致代码冗余。
避免该问题的发生,有以下两种方法(以add.h为例):
方法一:
#ifndef __ADD_H__
#define __ADD_H__
//头文件内容
#endif
当第一次包含该头文件时,会用#define定义符号__ADD_H__,当第二次重复包含该头文件时,因为__ADD_H__已经被定义过,就无法再次包含该头文件的内容了。
方法二:
#pragma once
//头文件内容
只需在头文件开头加上这句代码,那么该头文件就只会被包含一次。