C语言——预处理和指针

发布于:2024-08-08 ⋅ 阅读:(122) ⋅ 点赞:(0)

预处理

编程的流程分为:编辑、编译、运行、调试四个阶段;
预处理属于编译阶段,编译过程又可以分为:预处理、编译、汇编、链接;

预处理
预处理是将代码中相关的预处理命令执行最终生成只包含c语言代码的文件,详细来说预处理过程实质上是处理“#”,将#include包含的头文件直接拷贝到.c当中;将#define定义的宏进行替换;将#if #else #endif定义的无用代码过滤掉,同时将代码中没用的注释部分删除等。
预处理所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。

编译,编译是对语法进行检查将源代码生成汇编代码。

汇编,汇编是将汇编代码生成机器代码。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。
目标文件由段组成。通常一个目标文件中至少有两个段:
1、代码段:该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。
2、数据段:主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。

链接,链接是将使用的其他代码链接到一起生成可执行文件。详细来说链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。

下面详细说明预处理的过程,预处理指令有宏定义、文件包含、条件编译;

宏定义

宏定义的语法形式:

#define 标识符 字符串
或者是#define 宏名 宏值
预处理命令都是以#开头的

例如:#define n 10
注意在定义宏时在宏值后面是不可以加分号的如果加了分号在预处理进行文本替换的时候会一并把分号一起替换了,比如说我在定义#define N 10的后面加上分号可以看到在进行预处理时N会被替换成10;
在这里插入图片描述

宏名的命名遵循标识符的命名规则,在定义宏名时为了区分宏名和普通变量名通常把宏名写成大写,比如#define N 10,这个宏定义的含义是将来代码中出现的的N都代表10,在编写代码是可以用N来表示10。这个本质就是在预处理的时候会进行文本替换也就是把宏名替换成宏值。通过预处理指令可以看到上述效果:

在这里插入图片描述
在这里插入图片描述
通过预处理指令gcc -E testH.c -o testH.i把testH.c文件只做预处理操作得到的目标文件testH.i打开testH.I可以看出宏名N被替换成了10。

宏的作用域

宏的作用域是从定义位置开始往下发挥作用

#include <stdio.h>



int main(void)
{
	printf("N = %d\n", N);

	return 0;
}

#define N 10

void test(void)
{
	printf("N = %d\n", N);
}

在这里插入图片描述
预处理后的代码为:
显示

编译上述代码看到说main函数中的N未定义的错误,然后通过预处理指令对testH.c只做预处理操作可以看到main函数中的N并没有报错,进一步说明宏的作用域是从定义位置开始往下发挥作用。如果我们想限制宏的作用域应该怎么做呢?我可以通过**#define 宏名 宏值 #undef 宏名**来限制宏的作用域,通过下面的例子来详细说明:
在这里插入图片描述

在这里插入图片描述
上述代码中我把#define N 10宏定义限制在main函数的范围内,然后对testH.c文件只做预处理操作可以看出在进行预处理时只有main函数的N替换成了宏值10而test()函数中的N并没有替换成宏值10。所以**#define 宏名 宏值 #undef 宏名**具有限制宏的作用域的作用。

带参的宏

语法:
#define 宏名(参数) 宏值
比如说#define ADD(a, b) a+b这个宏在预处理时会把代码中的ADD(a, b)都替换成a+b从而实现两个数相加的效果,在形式上看着带参的宏有点像函数实际上带参的宏和函数是有本质上的区别的:
1、带参宏和函数的处理阶段不一样,宏是在预处理阶段而函数是在编译阶段;
2、二者的使用阶段也不一样,宏是在预处理阶段就使用结束了而函数是在调用的时候才会进行使用,宏的本质是进行文本的原样替换而函数的使用本质上是函数代码的调用,,宏的参数只是进行文本替换用的不会进行语法检查,而函数的参数是有类型的在编译阶段会进行类型的检查。

宏的副作用

使用宏是可能会是运算优先级发生改变下面以一个具体的例子说明吧;
在这里插入图片描述
在这里插入图片描述
在调用宏时理想的结果是先让1+2和3+4求和然后再将二者的和相乘可以在进行文本的原样替换时把MUL(1 + 2, 3 + 4)替换成了1 + 2*3+4所以计算结果是11;为了避免发生这样的结果通常在进行宏定义时该加括号的加括号。

文件包含

文件包含分为
1、#include <>
2、#include “”
二者的区别是:查找头文件的方式不一样,<>是到系统默认的路径去找头文件而""实现到当前目录下寻找如果没有再到系统默认的目录下寻找。

条件编译

条件编译总共有三种形式:
1、
# ifdef 标识符
程序段 1
#else
程序段 2
#endif

它的作用是若所指定的标识符已经被# define 命令定义过,则 在程序编译阶段编译程序段 1; 否则编译程序段 。其中# else 部分可以没有。
2、
#ifndef 标识符
程序段 1
#else
程序段 2
#endif

上述形式只是第一行与第一种形式不同:将 “ifdef” 改为 “ifndef” 。它的作用是若标识符未被定义过则编译程序段 1; 否则编译程序段 2。这种形式与第一种形式的作用相反。
3、
#if 表达式
程序段1
#else
程序段2
#endif

它的作用是当指定的表达式值为真(非零)时就编译程序段 1; 否则编译程序段 。可以事先给定条件,使程序在不同的条件下执行不同的功能。

指针

指针的概念

指针就是地址而地址就是内存单元的编号。指针也是一种数据类型是专门用来处理地址这种数据的类型。

指针的定义

数据类型 变量名
语法:
基类型 * 变量名

其中基类型包含整型、浮点型、字符型、数组类型、指针类型、结构体类型 、函数类型等等;
该类型表示指针类型指向的内存空间所存放的数据是什么样的类型;
**“*”**表示表示此时定义的是一个 指针类型 的变量;
变量名符合标识符的命名规则;

举个例子说明吧:
int a = 10; //表示a的内存空间中存放的是整型类型的数据;
float b = 10;//表示b的内存空间中存放的是浮点型类型的数据;
int p = &a;
int p = &b;
其中&a表示a所在内存空间的首地址,表示获得了一块 可以存在int型数据的内存空间的地址。
int
p;
int
含义 首先表示是一个 指针类型,表示指向int型数据的指针类型 。

指针变量的引用
int a = 10;
int *p = &a; 这里p指向a,因为p中保存了a的地址;

“*”是指针运算符,它是一种单目运算符,且运算的对象只能是地址;
*p:表示访问p所指向的基类型的内存空间这种访问是间接访问可以通过a直接访问;
*p的访问完整流程是:
1、首先拿出p中地址,到内存中定位
2、偏移出sizeof(基类型)大小的一块空间
3、将偏移出的这块空间,当做一个基类型变量来看
p最后的运算效果相当于就是一个基类型的变量,也就是p等价于a;

指针变量初始化

如果指针变量没有初始化此时就是随机值,该指针叫野指针。野指针对程序的执行是有风险的所以在初始化的时候必须让指针有明确的指向例如:
int a = 1;
int *p = &a;
int *p = NULL;此时p表示的是一个空指针,p的地址编号是0;

指针的赋值:
int *p;
p = NULL;

定义多个指针变量:
int*p,*q;
*是用来修饰变量的表示此时定义的是一个指针类型的变量;而不能写成int *p,q;如果这样写p代表的是一个指针变量而q是一个int类型的变量。

指针的作为函数的参数一个重要功能就是实现被调修改主调那么如何实现被调修改主调呢?
其实指针作为函数的参数通过把背调的地址传过来然后就能通过这个地址找到其在内存中所存在的位置,从而访问内存空间中存放的数据来实现被调修改主调的效果。

指针作为函数参数:
形参是一个指针类型的变量,用来接受实参而实参是要操作内存空间的地址;
实参是要修改谁就传谁的地址,且被调函数中一定要有*p运算;

下面以一个例子来说明值传递和址传递要注意的问题:

#include <stdio.h>

void minMax(int a, int b, int *max, int *min)
{
	*max = a > b ? a : b;
	*min = a < b ? a : b;
}

int main(void)
{
	int a = 0, b = 0;
	int max = 0, min = 0;
	scanf("%d %d", &a, &b);

	minMax(a, b, &max, &min);

	printf("max = %d min = %d\n", max, min);

	return 0;
}

在这里插入图片描述
通过上述代码我们可以看到在进行函数的传参时既有值传递也有址传递那什么时候用值传递什么时候用址传递呢?如果你想要通过形参去改变实参那么就要用址传递的形式。就比如说上述代码中我要从函数中带出一个最大值和一个最小值但是不能有返回值,那么就要实现形参改变实参的效果通过值传递是实现不了这个效果的所以max和min采用了址传递的方式,我们想改变的是max和min而a和b这两个数是不需要改变的所以采用值传递就可以了。

指针+一维整型数组

如果要定义一个一维数组指针那我们要定义一个什么类型的指针呢?谁又能代表数组首元素的地址呢?首先我们得理解数组名的含义,1、数组名代表数组的类型;2、数组名代表数组首元素的地址;由数组名的含义我们可以知道数组名的首元素可以代表数组首元素的地址,数组首元素也就是a[0]而a[0]对应的数据类型是int型代表a取了一块int类型数据的地址也就是int类型,所以a的类型是int型,这样我们在定义一个一维数组指针是要定义一个int*类型的指针例如:int a[50];int p = a;指的是创建一个int类型的变量也就是创建了一个指针p,p指向的是a数组所在的内存空间的首地址,p所指向内存空间里存放的数据的数据类型是int型。

指针的访问方式;
下面以一个具体的例子来说明指针的访问方式:

#include <stdio.h>

void printArray(int *a, int len)
{
	int i = 0;

	for(i = 0; i < len; ++i)
	{
		printf("%d\n", *(a + i));
	}
}

int main(void)
{
	int a[] = { 1, 2, 3, 4, 5};

	printArray(a, 5);

	return 0;
}

上述程序实现了一个数组元素打印的过程,通过把数组首元素的地址传给函数在进行数组遍历的时候就能通过数组首元素的地址找到该数组所在的内存空间,在遍历数组元素时通过指针运算来控制指针的偏移使指针指向数组每一个元素所在空间的地址然后通过指针运算符来实现对数组元素的访问,从而实现数组元素的打印功能。其中(a + i)等价于a[i](a[i]还可以写成i[a]因为*(a + i)等价于*(i + a))。
总的来说数组作为函数参数 :
第一,形参要是数组形式,其本质上是一个指针类型变量例如int *a;除了传进一个指针还要传进去数组长度方便对数组的遍历;
第二,实参是数组名和数组长度,数组名代表的是数组首地址。

const关键字

const关键字根据其所处的位置不同起作用也不同,const 所处的位置总共有三种:
1、int a = 10;
const int p = &a;
此处的const限制的是int也就是基类型表示的意思是不能通过
p的方式去修改基类型的数据,*p只能起到读的作用。
在这种情况下,int *q = p;这样的初始化也是不允许的因为int q可以通过q的方式去改变基类型的数据,int q可读可写而const int p只能读,这样子写把p的权限放大了,权限不能放大但是能缩小。
2、int a = 10;
int const p = &a;
const和中间隔着
p所以const离int比较近此处的const其实限定的也是基类型int,其表示的意思也是不能通过
p的方式去修改基类型的数据,其他的注意事项同第一种情况。
3、int a = 10;
int * const p = &a;
const距离p比较近此时const限定的是指针p其表示的含义是p是只读的且p不能被修改,也就是p所指向的空间地址只能是a所在内存空间的地址,而不能通过p = &b;类似的操作去改变p的指向。

这么多种情况我们在使用的时候该怎么去选择呢?
如果不想通过*p方式去改变基类型对应的数据则采用第一或者第二种方式也就是const int *p = &a;
int const *p = &a; 如果指针变量p定义好后不想再去指向别的变量可以采用 方式三
int * const p = &a。所以可以根据实际需求的不同去选择const限定谁。

const还可以用在函数的形参设置当中,比如说:
下面的strlen()函数的模拟实现,myStrlen()函数的形参设置成了const char s这样做可以防止通过s的方式去修改s对应基类型的数据即防止函数当中出现误操作,一旦有这种误操作在程序编译时就能发现问题及时去修正,把运行时问题提前到了编译这个过程中。

#include <stdio.h>

size_t myStrlen(const char *s)
{
	size_t len = 0;

	while (*s != '\0')
	{
		++s;
		++len;
	}

	return len;
}

int main(void)
{
	char *s = "hello";

	printf("%ld\n", myStrlen(s));
	
	return 0;
}

相比于用字符数组的方式char s[] = "hello"去存放字符串其实还可以写成下面这种方式:
char *s = “hello”;
char s[] = “hello”; 表示在栈上开辟一块空间,用字符串常量中的 "hello"进行初始化,也就是把存放在常量区的字符串"hello"拷贝到了s所指向的内存空间;
const char p = “hello”; 表示 p指向了字符串常量区中的 “hello”,字符串常量区的数据只能读而不能写所以p只能做读取的操作而不能修改。

void*类型的指针

void * 的意思为NULL 空指针,空类型的指针也是一种万能指针为什么说它万能呢因为它可以接收任意类型的指针,注意,如果用空类型指针进行间接运算必须转换成有明确类型的指针,下面以一个具体的例子说明吧:

#include <stdio.h>

void * myMemcpy(void *dest, const void *src, size_t n)
{
	char *ret = (char*)dest;

	while(n--)
	{
		*((char*)dest++) = *((char*)src++);
	}

	return ret;
}

int main(void)
{
	long a[] = { 1, 2, 3, 4, 5 };
	long b[5];
	int i = 0;

	myMemcpy(b, a, 5 * sizeof(a[0]));

	for(i = 0; i < 5; ++i)
	{
		printf("%ld ", b[i]);
	}
	printf("\n");

	return 0;
}

这里模拟实现了一个memcpy()内存拷贝的一个函数,把形参设置成void类型的用意是让使用者根据实际的需求去决定dest和src的具体的基类型这样写提高了代码的健壮性,memcpy()函数通过字节拷贝的方式实现了不论形参是什么基类型的数据都能实现拷贝的功能,在使用空类型指针进行间接运算的时候把void强制转换成了转换成了char*类型的指针也就是:
((char)dest++) = ((char)src++);

指针+二维数组

int a[2][3];
二维数组在c语言中并不存在真正的二维数组,二维数组本质 是一维数组的一维数组,所以二维数组也符合数组的特点:具有连续性、有序性、单一性。
其实只要了解二维数组的本质就能确定对于二维数组需要定义什么类型的指针;
二维数组的写法是:int a[2][3];为了方便理解可以写成int[3] a[2];这种方式把二维数组看做一个大的一维数组每一个元素由一个一维数组组成也就是一维数组的一维数组,参照一维数组的定义int a[3];此时int[3] 就是二维数组中每一个元素的类型a[2]就是变量名,所以我在定义二维数组的指针时只要在元素类型的后面加上就可以了即int[3] a的方式但是这种写法不允许所以应该写成int (a)[3]的形式。
int(p)[3] = a; 表示的含义是:p指向二维数组a,p的基类型 int[3]。
p <=> a[0]相当于是内部这个一维数组的数组名;
二维数组的访问:
((p+1)+1),
(p+1)表示的是一维数组首元素的地址后面+1表示从首地址开始往后偏移一个int大小的空间去访问该行下一个元素,
(
(p+1)+1)等价于a[1][1]。同理
(
(p+i)+j)等价于a[i][j]。


网站公告

今日签到

点亮在社区的每一天
去签到