C语言指针深度解剖

指针是C语言的灵魂,深入理解指针,是学好学会C语言的重要前提。因此,本文将重点讲解C语言指针的深度内容。

先来简单回顾一下最基础的关于指针的概念。

简单来说,指针就是地址。对于指针,有以下概念:

1.指针的大小,几乎是固定的,在32位下是4字节,在64位下是8字节。

2.指针的加减运算,每加1,就是跳过指针变量的类型的字节个数。比如有指针变量为:int* a。那么对于a+1,就是跳过4个字节;char* b,b+1就是跳过1个字节。

3.野指针问题:野指针就是指针指向了未知的地方。野指针成因:①指针未初始化。②越界访问。③指针指向的空间被释放后,没有置空。

有了这些基础概念后,接下来便是对指针的深入理解。

一.字符指针

我们都知道,对于char* parr来说,这个是一个字符指针。一般有以下的使用方法:

	//字符指针
	char ch = 'w';
	char* pc = &ch;//char* 是pc的类型,*代表着pc是指针,char代表pc指向的类型是char
	*pc = 'b';
	printf("%c\n", ch);//b

但其实对于字符指针,还有以下用法:

	//字符串
	const char* str = "abcdef";
	//char* str = "abcdef";
	printf("%s\n", str);//abcdef

第一种用法,我们很容易想到,pc拿到的是ch的地址,地址里面存的内容就是字符w。但是str指向的地址空间呢?指向的是字符串"abcdef"6个字符的地址吗?其实不然,str的类型是char*,只能指向一个字节大小的地址,不可能指向6个字符一共6个字节大小的地址。

因此,对于str来说,它指向的是字符串首字符的地址,也就是字符a的地址。然后我们在使用%s来打印的时候,会从a的地址开始找,一直打印整个字符串,直到遇到'\0'就停止。

并且,对于字符串"abcdef",它属于常量,不能被修改。因此最好加上const修饰。而对于char arr[] = "abcdef";这种就是将整个字符串放在数组里面,因为数组开辟了足够的空间用来存放它。

下面来看看一道问题,来对上面所说的进行一个加深理解:

有下面代码,我们来分析一下打印的结果是什么:

int main()
{
	const char* p1 = "abcdef";
	const char* p2 = "abcdef";

	char arr1[] = "abcdef";
	char arr2[] = "abcdef";

	if (p1 == p2)
		printf("p1==p2\n");//√
	else
		printf("p1!=p2\n");

	if (arr1 == arr2)
		printf("arr1==arr2\n");
	else
		printf("arr1!=arr2\n");//√


	return 0;
}

解释:因为abcdef是常量字符串,存放在常量区,而且不能修改,所以只存一份,所以p1和p2指向的都是同一份abcdef,地址一样。但是在数组中,是两个独立的数组,开辟了空间用来存放"abcdef",所以不同空间,地址自然不同。答案便是上面代码中打勾的部分。

二.指针数组

所谓指针数组,本质就是一个数组,用来存放指针的数组

用int* arr[5];来解析一下:首先,*号是与int优先结合,而arr则与[]结合,表示了它是一个数组,那么,数组里面的5个元素的类型便是int*,也就是存放了5个类型为整型指针的元素。

看下面代码:

int main()
{
	//int arr[10];//整型数组
	//char ch[5];//字符数组

	//int* arr2[6];//指针数组,存放整型指针的数组  
	//char* arr3[5];//存放字符指针的数组
    
    //用法举例:

	int arr1[] = { 1,2,3,4,5 };
	int arr2[] = { 6,7,8,9,10 };
	int arr3[] = { 11,12,13,14,15 };

	//数组名就是首元素的地址,所以arr1是1的地址,arr2是6的地址,arr3是11的地址
	int* parr[] = { arr1,arr2,arr3 };//指针数组,整型指针的数组
	//for (int i = 0; i < 3; i++)
	//{
	//	printf("%d ", *parr[i]);       //1  6  11
	//}

	for (int i = 0; i < 3; i++)
	{
		for (int j = 0; j < 5; j++)
		{
			printf("%d ", *(parr[i]+j));
			//printf("%d ", parr[i][j]);

			//*(p+i) == p[i]
		}
		printf("\n");
		
	}
    //打印的结果就是跟二维数组打印的结果一样,三行五列。
	

	return 0;
}

三.数组指针

数组指针,本质就是指针,指向数组的指针

我们熟悉的整型指针就是指向整型的指针,字符指针就是指向字符的指针,那么数组指针,就是指向数组的指针。

那么数组指针的格式,或者说如何表示?

我们先回想指针数组是怎么写的?

int* arr[5];//指针数组

既然*号与int结合后,arr只能跟[]结合了,表明了arr是一个数组。那么我们将*号与arr结合,那么,arr便是一个指针。即int (*arr)~  。接着,既然数组指针指向的是数组,那么,它就应该具有数组的某些属性,那就是包含方括号,用于存放它指向的对象数组。所以,最终格式:

int(*p)[10];//p2是跟*结合的,是数组指针,p2是数组,数组的每个元素是int,10个

了解了什么是指针数组后,我们接下来看,当我们创建了一个数组指针,就需要对它初始化,避免它是个野指针。那么便有下面代码:

int arr[10] = { 0 };
int(*p)[10] = &arr;
//数组指针,存放数组指针,*不是解引用,是告诉我们p2是指针,p2是类型是数组指针的类型

那么问题来了,对于&arr,它到底是谁的地址?是arr数组中首元素的地址还是整个数组的地址?

所以,看下面对数组名的解释:

数组名的使用介绍:

看下面代码,打印的结果:

int arr[10] = { 0 };
printf("%p\n", arr);
printf("%p\n", &arr[0]);//一样的

其实他们打印的结果是一样的,都是指向了数组中的首元素的地址。证明它们都是指向数组首元素的地址,那就查看调试结果:

我们看到,arr和&arr[0]的地址是相同的。所以证明了这一点。也就是说,单独求数组名所对应的地址,跟取首元素地址,所得的结果是一样的。

很好,接下来继续看下面这段代码:

int sz = sizeof(arr);
printf("%d\n", sz);//40

我们得出来的结果,是40.既然arr是首元素的地址,算出来应该是4或8,为什么是40?所以这里不能把数组名理解为首元素地址。这里其实是整个数组的地址。

再来看看下面这个代码:

printf("%p\n", &arr);

这个代码打印的结果,它的地址是跟首元素的地址是相同的,但是这只是起始地址,因为总得有个起始位置吧。

但是使用下面代码来算出来的结果后,我们就可以发现其中的秘密了:

结合上面的情况,我们都将其进行运算,都加1,看看地址如何改变

	printf("%p\n", arr);
	printf("%p\n", arr+1);//跳过四个字节

	printf("%p\n", &arr[0]);
	printf("%p\n", &arr[0]+1);//跳过四个字节

	printf("%p\n", &arr);
	printf("%p\n", &arr+1);//跳过40个字节

C语言指针深度解剖_第1张图片

很明显,前两个,地址都是加了4个字节,对于了其int类型,而对于&arr,则是加了40个字节。

所以我们得出结论:

1.数组名通常表示都是首元素地址

2.但是两个例外:①sizeof(数组名),这个数组名是单独放的,这里的数组名表示的是整个数组,计算的是整个数组的大小,单位是字节。②&数组名。

回到我们的问题:”那么问题来了,对于&arr,它到底是谁的地址?是arr数组中首元素的地址还是整个数组的地址?“。所以我们也知道了,为什么数组指针也要加个方括号,并且给出的个数是要跟所指向的对象数组是一样的呢(这个必须写,不能空),原因就在这,它存放的是对象数组整个数组的地址。

下面是代码的注意事项和举例:

	int arr[10] = { 0 };
	int* p = arr;//整型指针
	int(*p2)[10] = &arr;
    //数组指针,存放数组指针,*不是解引用,是告诉我们p2是指针,p2是类型是数组指针的类型

	char* ptr[5] = { 0 };
	char* (*pc)[5] = &ptr;//*pc代表着pc是指针,一共5个,每个是类型是char*

	//方括号里面写元素个数
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	int(*p)[10] = &arr;
	//因为arr有10个元素,但是在运行后会报错,认为p只有p[0],而arr有arr[10]。所以int(*p)[10] = &arr;要加个数
	return 0;
}

下面是数组指针的用法:

void Print(int(*p)[5], int r, int c)
{
	int i = 0;
	for (i = 0; i < r; i++)
	{
		int j = 0;
		for (j = 0; j < c; j++)
		{
			printf("%d ", *(*(p + i) + j));
			//p+i指向的是二维数组第i行的整个地址
			//进行解引用后*(p+i)拿到的是第i行首元素的地址
			//*(p + i) + j,拿到第i行第j列元素的地址
			//*(*(p + i) + j)拿到这个数据

			//printf("%d ", p[i][j]);//这个也一样
		}
		printf("\n");
	}
}
int main()
{
	int arr[3][5] = { 1,2,3,4,5,2,3,4,5,6,3,4,5,6,7 };
	Print(arr, 3, 5);//arr是数组名,表示的是二维数组的首元素的地址
	//二维数组的首元素的地址是第一行的地址
	//数组的地址,就放在数组指针当中
	//对于第一行这个一维数组的地址,那么每一个元素的int类型,共五个,那么就是
	//int (*p)[5]

	return 0;
}

对于上面代码,要求对二维数组、数组指针还有如何传参的理解。

在二维数组arr[row][col]中,首元素就是第一行的一维数组arr[0]。那么其首元素的地址,便是arr[0]的整个一维数组的地址。

在传参的时候,将arr当作实参参数传入的时候,由于arr是首元素的地址,形参就接受这个地址,便用指针来接受,所以就是数组指针int(*p)[5]。

当然,如果是一维数组做实参,则形参不需要指针数组,因为传进去的不是整个数组的地址,而是一个元素的地址,那么就是int* a或者是char* a等等。这点要分清楚。

C语言指针深度解剖_第2张图片

四、数组参数、指针参数

既然我们有时会使用到各种指针,各种数组指针,就避免不了要使用到函数来调用它们。

来分析下面代码,就能知道如何传参。

1.一维数组传参

#include 
void test(int arr[])//ok?
{}
void test(int arr[10])//ok?
{}
void test(int* arr)//ok?
{}
void test2(int* arr[20])//ok?
{}
void test2(int** arr)//ok?
{}
int main()
{
	int arr[10] = { 0 };
	int* arr2[20] = { 0 };
	test(arr);
	test2(arr2);
}

调用test函数传入的实参,是一个一维数组arr,此时的数组名代表着首元素的地址。那么第一个和第二个test函数是没问题的,直接按照一维数组的形式来写形参,而且一维数组的方括号可以不写个数。第三个test函数也是没问题,地址用指针来接收,并且arr是一维数组,存放的元素类型是int类型,其首元素地址就是int*类型的,使用int* arr来接收,没问题。

调用test2函数的是arr2,它是一个指针数组,存放的是int*类型的元素。第一个test2,一眼看到就是没问题的了。第二个test2,由于arr2作为实参传进去,是首元素地址的指针,其首元素的类型是int*,相当于是拿到了这个地址的地址,因此,使用二级指针来接收,没问题。

2.二维数组传参

#include 
void test(int arr[3][5])//ok?
{}
void test(int arr[][])//ok?
{}
void test(int arr[][5])//ok?
{}
void test(int* arr)//ok?
{}
void test(int* arr[5])//ok?
{}
void test(int(*arr)[5])//ok?
{}
void test(int** arr)//ok?
{}
int main()
{
	int arr[3][5] = { 0 };
	test(arr);
}

第一个test,没问题。第二个test,这个不行,对于维数组传参,函数形参的设计只能省略第一个[]的数字,因为对一个二维数组,可以不知道有多少行,但是必须知道一行多少元素。这样才方便运算。第三个test,根据上面那个说法,判断可以。第四个test,因为二维数组的首元素地址是可以看成是一维数组的整个数组的地址,因此使用整型指针是接收不了的,不可以。第五个test,这个函数的形参是指针数组,用来存放指针的,元素类型是int*,但是实参的类型并不是int*,跟第四个的道理一样,所以不行。第六个test,形参是数组指针,用来指向数组的地址,ok没问题。第七个test,形参是二级指针,二级指针是用来指向一级指针的地址,但是二维数组的首元素地址是一个一级指针,所以不可以。

3.一级指针传参

#include 
void print(int* p, int sz)
{
	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d\n", *(p + i));
	}
}
int main()
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9 };
	int* p = arr;
	int sz = sizeof(arr) / sizeof(arr[0]);
	//一级指针p,传给函数
	print(p, sz);
	return 0;
}

反过来,从形参的角度来看,我们如何取选择实参。

对于一级指针做形参,我们可以选择的实参有:一维数组的数组名,普通变量的地址或取地址

int arr[10] = { 1,2,3,4,5,6,7,8,9 };

print(arr);

int a = 10;

int* p = &a;

print(&a);

print(p);

4.二级指针传参

#include 
void test(int** ptr)
{
	printf("num = %d\n", **ptr);
}
int main()
{
	int n = 10;
	int* p = &n;
	int** pp = &p;
	test(pp);
	test(&p);
	return 0;
}

对于二级指针做形参,我们可以选择的实参有:一级指针的地址变量。

五.函数指针

函数也有自己对应的指针,只不过,对于函数指针来说,不管怎么样,取的地址都是一样的。看下面代码:

#include 
void test()
{
	printf("hehe\n");
}
int main()
{
	printf("%p\n", test);
	printf("%p\n", &test);
	return 0;
}

输出的是两个地址,这两个地址是 test 函数的地址,因此对于函数来说,&函数名和函数名都是函数的地址。那么函数指针如何定义?

int (*pf)(int, int) = Add;//pf是函数指针

它与数组指很像,就是将[]变成(),写的内容也不一样。*号与pf结合,说明pf是指针,()说明它是一个函数指针,里面的是参数的类型,返回的类型是int。

有了函数指针,即函数的地址,我们怎么保存?

看下面的函数指针数组。

六.函数指针数组

指针数组:int* arr[5]用来存放整型指针的数组;char* str[6];用来存放字符指针的数组。同理:函数指针数组,用来存放函数指针的数组。那么,该如何定义函数指针数组呢?看下面代码:

#include
int Add(int x, int y)
{
	return x + y;
}
int Sub(int x, int y)
{
	return x - y;
}
int Mul(int x, int y)
{
	return x * y;
}
int Div(int x, int y)
{
	return x / y;
}
int main()
{
	int (*pf)(int, int) = Add;//pf是函数指针,所以
	int (*arr[4])(int,int) = {Add,Sub,Mul,Div};//arr是函数指针数组
	int i = 0;
	for (int i = 0; i < 4; i++)
	{
		int ret = arr[i](8, 8);
		printf("%d ", ret);
	}
	return 0;
}

int (*arr[4])(int,int) = {Add,Sub,Mul,Div}。我们去除[4],这一看int (*arr   )(int,int),这不就是函数指针吗,然后让它变成数组,由于[]的优先级高于*号,加一个[],就变成了int (*arr[4])(int,int)。接着分析一下:arr与[]结合了,代表着它是一个数组,里面有4个元素,那么剩下的int (*arr   )(int,int)便是数组元素的类型,是函数指针类型。这就是函数指针数组,用于存放函数指针。

七.指向函数指针数组的指针

函数指针数组指针,就是一个指针,指向了函数指针数组的指针。

看下面代码:

int main()
{
	//函数指针数组
	int (*arr[4])(int, int) = { Add,Sub,Mul,Div };
	//指向 函数指针数组  的指针
	int (*(*parr)[4])(int, int) = &arr;
	//*parr说明parr是指针,指针指向数组,指向的是int (*)(int, int)这种类型
	return 0;
}

int (*(*parr)[4])(int, int) = &arr;分析:parr跟*号结合,代表着parr是指针,我们拿开*parr,int (*[4])(int, int) 发现parr指向的对象的类型,就是这个,是函数指针数组的地址。

其定义的格式就是在函数指针数组的基础上,将变量名与*号结合。

八.回调函数

回调函数的定义:回调函数就是一个通过函数指针调用的函数。

如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。

意思就是我们创建的这个函数,不是我们直接取调用的,比如我们创建了一个Add函数,我们直接拿来做加法运算,这就不是回调函数了。而是我们用在另外的函数或特点的事件上,由它们来进行特点调用。

接下来,我们来用qsort函数来解释。

qsort是C语言的一个排序函数,其排序方法就是使用了快排的思想,可以满足不同使用场景下的排序。

先来看看qsort的函数声明:

 可以看到,qsort有四个形参。

void qsort(void* base,//你要排序的数据的起始位置
	       size_t num,//待排序的数据元素的个数
	       size_t width,//待排序的数据元素的大小(单位是字节)
	       int(* cmp)(const void* e1, const void* e2)//函数指针-比较函数
          );

base是数据的起始位置,比如数组的首元素地址。num就是个数。size,也就是width,要进行排序的数据的大小,单位是字节。cmp,是一个函数指针,是一个比较函数,用来比较两个数据的大小。当e1大于e2的时候,返回1,等于返回0,小于返回-1(看成)。

 使用例子:

int cmp_int(const void* e1, const void* e2)
{
	return (*(int*)e1 - *(int*)e2);
}

void test1()
{
	int arr[] = { 9,8,7,6,5,4,3,2,1,0 };
	//把数组排成升序
	int sz = sizeof(arr) / sizeof(arr[0]);

	qsort(arr, sz, sizeof(arr[0]), cmp_int);

	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d ", arr[i]);
	}
}

首先,传入arr,表示起始位置,然后计算数据个数,sz传入,然后是元素的大小,传入。最后是cmp,由于qsort要适用于不同场景下的数据的排序,可能是int,可能是char,可能是double,也可能是结构体。所以,cmp的形参类型是void*。

如果一个变量的类型是void*,那么表示着这个变量不能被解引用,不能进行+-运算。因为无法知道它的具体类型,到底是要几个字节。

这就需要我们在比较的时候,需要进行强制类型转换。

最后,得出的结果自然就是0 1 2 3 4 5 6 7 8 9,升序排序。如果需要降序,将e1和e2调换过来就行了。

再来看看结构体数据排序的例子:根据姓名来排序:

struct Stu
{
	char name[20];
	int age;
};
//根据名字来排序
int cmp_stu_by_name(const void* e1, const void* e2)
{
	//strcmp --> >0 ==0 <0
	return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
}

void test2()
{
	//测试使用qsort来排序结构数据
	struct Stu s[] = { {"zhangsan", 15}, {"lisi", 30}, {"wangwu", 25} };
	int sz = sizeof(s) / sizeof(s[0]);
	qsort(s, sz, sizeof(s[0]), cmp_stu_by_name);
}

排序前:

C语言指针深度解剖_第3张图片

 排序后:

在了解了qsort的使用方法,我们同时也了解了什么是回调函数了吧。

来一个比较有意思的游戏,就是使用冒泡排序,来模拟实现一下qsort的功能,也就是可以在不同场景下进行排序,因为正经的冒泡排序只能用于整型。

void Swap(char* buf1, char* buf2, int width)//一个字节一个字节的交换,交换width次,就交换了width字节
{
	int i = 0;
	for (i = 0; i < width; i++)
	{
		char tmp = *buf1;
		*buf1 = *buf2;
		*buf2 = tmp;
		buf1++;
		buf2++;
	}
}

void bubble_sort(void* base, int sz, int width, int(*cmp)(const void* e1, const void* e2))
{
	int i = 0;
	//趟数
	for (i = 0; i < sz - 1; i++)
	{
		int flag = 1;//假设数组是排好序
		//一趟冒泡排序的过程
		int j = 0;
		for (j = 0; j < sz - 1 - i; j++)
		{
			if (cmp((char*)base + j * width, (char*)base + (j + 1) * width) > 0)
			{
				//交换
				Swap((char*)base + j * width, (char*)base + (j + 1) * width, width);
				flag = 0;
			}
		}
		if (flag == 1)
		{
			break;
		}
	}
}

主要分析一下交换数据这段代码:

因为我们要适合各种场景下的数据的交换,因此形参是void*,那么问题来了,强制类型转换如何转换?转换成int*还是char*,还是结构体类型的?我们不知道。但我们知道的是,char*是一个字节一个字节的,所以我们就细化字节大小,不管要交换的数据是4字节,还是8字节还是多少,我们一个字节一个字节地去交换,那么就肯定能够适合n个字节的数据。

这样,就能解决swap函数的问题,传入要交换的两个数据,再传入字节大小,便可实现交换函数。

当然,传入的实参,也要好好想想。如何控制对应的字节大小?

cmp((char*)base + j * width, (char*)base + (j + 1) * width)

base是起始位置,当j = 0,而width = 4时,那么相当于是跳过了4个字节,来到了下一个位置。

C语言指针深度解剖_第4张图片

 以此类推,便解决了这个问题。

本文到此结束~如果喜欢的话,可以点赞收藏。有什么问题可以在评论区留言!

你可能感兴趣的:(C语言,c语言,c++,开发语言)