“10分钟学完指针(超详细指南) - 看完不懂你找我”

前言 

  此篇从指针的基础概念出发,从内存的角度,逐步深入,不管你之前对指针的掌握程度是多少,是否是小白,看完本篇,勤加运用,你一定能够更好地理解指针及其在实际编程中的应用。

1. 内存单元和地址

1.1 内存单元

   再讲指针之前,我想先让大家理解地址和内存单元,有这样一个生活案例:在现实生活中,假设有一栋楼,现在我想去我同学家玩,但是我不知道他家的门牌号,我只能一层楼一个房间一个房间的找,这样效率是不是太慢了,如果我直接知道他的门牌号,就可以直接找到他家。计算机也是如此,对于计算机来说,电脑上内存是8GB/16GB/32GB等,那么在CPU向内存中读取数据时,怎么快速定位的呢?

  其实,对于计算器来说,也是把内存划为一个个内存单元,每个内存单元是一个字节,就像房间的门牌号一样,而这些门牌号早已设置好,就像建楼房一样,每个门牌号是建筑公司设置好的,计算机也一样,内存单元是硬件设置好的,直接可以通过内存单元找到数据。

  我们可以简单理解,32位机器有32根地址总线, 每根线只有两态,表示0,1【电脉冲有无】,那么 ⼀根线,就能表⽰2种含义,2根线就能表⽰4种含 义,依次类推。32根地址线,就能表⽰2^32种含 义,每⼀种含义都代表⼀个地址。 地址信息被下达给内存,在内存上,就可以找到 该地址对应的数据,将数据在通过数据总线传⼊ CPU内寄存器。这样计算机的效率更高。计算机中常见的单位(补充):

bit - 比特位   Byte - 字节 KB MB GB TB PB

1Byte = 8bit         

1KB = 1024Byte

1MB = 1024KB

1GB = 1024MB

1TB = 1024GB

1PB = 1024TB

1.2 地址

  每个内存单元都有自已的编号,cpu处理数据时直接通过内存单元的编号来访问内存空间。这些一个个内存单元我们称为地址,(就像房间号我们也叫做地址一样),c语言给地址起了新的名字,叫做指针。所以:

内存单元 == 地址 ==指针

2. 指针变量和&,*操作符

2.1 指针变量

  而存放地址的变量我们叫做指针变量,就像存放整数的变量我们叫做整形变量,存放字符的变量我们叫做字符变量一样:

int a = 10;//存放整形的变量
char str = 'ch';//存放字符的变量
int* pa = &a;//存放地址的变量

   指针变量也是一种变量,只不过这个变量存放的值是地址罢了,存放在指针变量里的值就是地址。

2.2 &操作符

  &是取地址操作符,在c语言中我们知道,创建变量其实就是向内存申请空间,而想要知道这块空间的地址就用取地址操作符,这样我们就知道这块数据在内存中的地址

int main()
{
	int a = 10;
	printf("%p\n", &a);
	return 0;
}

2.3 *操作符

  好,那我们知道地址了,怎么访问地址指向的对象的值呢,这时候就要用解引用操作符(*)了,通过 (*地址)我就可以获取指针指向的对象的值。拿取指向该地址对应的值了。

2.31 拆解指针类型

  对int* p = &a的理解,首先*表示p是指针变量,而int则表示p指向的对象是int类型的

2.32 指针的大小

指针就是地址,那么地址的大小是多大呢,我们知道,32位机器(x86)有32跟地址线,每根地址线对应一个bit位,那么32跟地址就对应32个bit,也就是4个字节大小,同理:对64位机器,应该一个指针对应8个字节。我们通过测试:

32位机器: 

64位机器: 

  结论:我们发现,指针(地址)的大小与类型无关,只与机器类型有关,32位机器指针大小4个字节,64位机器,指针大小8个字节。

 3. 指针变量类型的意义

  我们是否有这样一个疑惑,既然指针的大小与类型无关,那么指针变量的类型有什么意义呢?

其实,指针的类型决定了指针解引用时每次向内存空间访问时,访问几个字节。 比如: char* 的指针解引⽤就只能访问⼀个字节,而int* 的指针的解引⽤就能访问四个字节

举个例子:

  如上图,对于char*的指针时,加一个整数,地址向后跳一个字节,而对于int*类型的指针,地址向后跳4个字节 ,这也是指针类型的意义。通过指针类型的意义,选择合适的指针来访问内存空间十分重要。

结论:指针的类型决定了指针向前或者向后走一步有多大距离。

 3.1 void*指针

  void*指针是无类型的指针,当我们不知道要向后一次性访问多少个字节时,就可以使用void*类型的指针,该指针可以接受任何类型的地址,但是有一个问题就是不能做指针的运算,因为无类型,所以电脑也不知道一次该访问几个字节。在后期实现一些函数,不知道指针类型时十分管用,如qsort函数,memmove,memcpy等等,都很有用。

3.2 const修饰指针

int main()
{
	int a = 10;
	int b = 10;
	int* pb = &b;

	a = 20;//通过赋值操作修改a的值

	*pb = 20;//通过指针来修改b的值


	return 0;
}

  不知道小伙伴们对这个代码是否有这个疑惑,既然我可以直接通过赋值操作直接改变变量的值,那么我为什么还要用指针指向b的地址,然后再解引用操作,这样不麻烦吗?

  其实,这里的赋值操作就好比我们自已去找到一个房间,再把物品给他,但是用指针,就好比我给了一个房间号,麻烦我的好朋友去帮我找到这个房间,再把这件事情做了。但是这样有一定的不安全性,就是凡是知道这个房间号(地址)的人,都可以帮我做这件事,有点不够保密了。

下面我们介绍两种:一种是const修饰指针变量本身,一种是const修饰指针变量指向的那块空间的值。

/无const修饰
void test1()
{
	int a = 10;
	int* pa = &a;
	*pa = 20;//ok
}
//const修饰指针变量
void test2()
{
	int a = 10;
	int b = 0;

	int* const pa = &a;
	*pa = 20;//ok?
	pa = &b;//ok?
}
//const修饰指针变量所指向的值
void test3()
{
	int a = 10;
	int b = 0;

	const int*  pa = &a;
	*pa = 20;//ok?
	pa = &b;//ok?
}
//const在*左右两边都有
void test4()
{
	int a = 10;
	int b = 0;

	const int* const pa = &a;
	*pa = 20;//ok?
	pa = &b;//ok?
}

int main()
{
	test1();
	test2();
	test3();
	test4();
	return 0;
}

 结论:const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。 但是指针变量本身的内容可变。

   const如果放在*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。

所以在恰当时加上const可以保证代码的安全性,让代码更健壮。 

4. 指针的运算 

4.1 指针加减整数

  对于一片连续的内存空间,如数组,只要知道一个元素的地址,就可以通过指针+-整数来获取其他元素的值:

#include <stdio.h>1
void print(int* pa, int sz)
{
	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d ", *(pa + i));
	}
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	print(arr, sz);
}

pa+1指向下标为1的地址,pa+2指向下标为2的地址,依次往后遍历,解引用,即可获得一片连续空间一维数组的值。 

4.2 指针加减指针

  我们知道,指针就是地址,而对于一片连续空间的一维数组,地址是由低地址到高地址的,假设一个低地址是ox0012ff40,一个高地址是ox0012ff48,那么两个地址之间的差值就是元素个数。假设该一维数组是整形,如图:

4.3 野指针和assert断言

  所谓野指针,就是该指针指向的内容不确定,没有限制的。所以在使用指针时,我们最好初始化指针或在不知道指针指向哪里时,将指针置为空指针,指针变量不再使用时,及时置NULL,指针使用之前检查他的有效性。

#include <stdio.h>
int main()
{
 int num = 10;
 int*p1 = &num;
 int*p2 = NULL;//避免指针指向的空间未知,造成越界访问
 
 return 0;
}

  野指针就好比一条野狗,我们不轻易靠近他,要找一颗树把他栓起来,如果真的要使用,也要在用完时再把它栓起来,也就是再置为空指针NULL

  而如果有时候真的忘记了怎么办呢?其实,我们还可以使用assert断言,通过断言,如果指针为空,程序会直接崩溃。

  下面我们综合指针加减指针以及assert断言写一个strlen的实现方式:

指针-指针
size_t my_strlen(const char* str)
{
	char* start = str;
	assert(str);//确保str不是空指针

	while (*str)
	{
		str++;
	}

	return str - start;
}
 
strlen的使⽤和模拟实现
int main()
{
	char a[] = "shuiauwu";
	size_t ret = my_strlen(a);
	printf("%zd\n", ret);
}

5. 数组名的理解

  在c语言中,数组名就是首元素的地址,但是两种特殊情况除外

1.sizeof(数组名)这里数组名是整个数组的大小,而不是首元素的地址,所以计算的是整个数组的字节大小。

2.&数组名,取出的是整个数组的的地址,而不是首元素的地址,为了验证,我们可以观察下列代码的结果:

  我们发现arr与&arr[0]一样,可以再次确定 ,数组名arr就是首元素的地址。

  但是有一个问题,arr ,&arr[0] , &arr的地址也一样这是怎么回事呢?数组名就是首元素的地址,加一,跳过一个整形,也就是四个字节, 但是,&arr+1跳过40个字节(16进制28就是40),噢我们发现了,&数组名取出整个数组的地址,加一,跳过整个数组。

总结:(1)sizeof(数组名),sizeof中单独放数组名,这⾥的数组名表示整个数组,计算的是整个数组的大小, 单位是字节

(2) &数组名,这⾥的数组名表示整个数组,取出的是整个数组的地址(整个数组的地址和数组⾸元素 的地址是有区别的) 除此之外,任何地方使用数组名,数组名都表示首元素的地址。

5.1 一维数组传参的本质

  一维数组传参传递的是首元素的地址,而不是整个数组。如果传递的是整个数组,不仅实参要占内存,形参接受也需要内存,同时还会浪费时间,是时间空间的双重浪费。我们可以观察下列代码看看哪里出错了?

void print(int arr[])
{
	size_t sz = sizeof(arr) / sizeof(arr[0]);//err

	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d ", arr[i]);
	}
}
int main()
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	print(arr);
	return 0;
}

 这个代码的结果只会打印1,为啥呢,好像看着没什么错误,其实,数组名传参时,传递的是首元素的地址,那么就应该用指针接受,所以,看着arr[]好像是个数组,其实是个指针,我们又知道,32位机器,指针的大小4,所以sz计算结果为1,只会打印一次。所以要注意,数组名传参的本质是首元素的地址。

5.2 传值调用

  我们在函数传参时知道,形参是实参的一份临时拷贝,改变形参的值不会影响到实参,那么如果我想在函数内部单单求一个值,然后返回该值,可以直接接受实参。比如说求和:

int ADD(int x, int y)
{
	return x + y;
}
int main()
{
	int a = 10;
	int b = 20;
	int ret=ADD(a, b);
	return 0;
}

   不需要知道实参的地址,只是计算值并返回,这样就够了。

5.3 传址调用

  如果我们现在想在函数内部交换两个数的值,这样的代码能不能做到?

#include <stdio.h>
void swap1(int a, int b)
{
	int tmp = b;
	b = a;
	a = tmp;
}
int main()
{
	int a = 10;
	int b = 20;
	printf("交换前:a= %d,b= %d\n", a, b);
	swap1(a, b);
	printf("交换后:a= %d,b= %d\n", a, b);
	return 0;
}

  其实是不能的,原因是我们在传参时,形参是实参的一份临时拷贝,形参和实参的空间是独立的,互不干扰的,所以在swap对a,b进行交换他们的值,出了函数这个作用范围,形参a,b就销毁了,不会对实参a,b影响,自然不能发生交换的效果。

结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实 参。 所以Swap1是失败的了。

  既然我们知道了这些,只能把形参的地址传过去,然后用指针接收了: 

#include <stdio.h>
void Swap2(int* px, int* py)
{
	int tmp = 0;
	tmp = *px;
	*px = *py;
	*py = tmp;
}
int main()
{
	int a = 10;
	int b = 20;
	printf("交换前:a=%d b=%d\n", a, b);
	Swap2(&a, &b);
	printf("交换后:a=%d b=%d\n", a, b);
	return 0;
}

6. 二级指针

我们知道存放一个变量的地址用指针变量来存放,(其实这里的指针变量是一级指针),那指针变量也是一个变量啊,存放一级指针变量的地址的指针就叫做二级指针了。可以借助图和之前我们讲过的指针变量类型的拆分来理解:

能看到pa存放的是a的地址;同理,ppa存放的是pa的地址。pa的类型是int*,可以这么理解,首先pa是指针,所以有一颗(*),pa指向的对象是int类型的,所以pa的类型是int*;同理,ppa首先是指针,有一颗(*),指向的对象是一级指针,一级指针的类型是(int*),所以ppa的类型是int**。 

  我们想通过ppa拿到a里的值也就很简单了,对ppa解引用(*ppa)访问ppa里的值,也就是pa的地址,再解引用一次(*(*ppa))通过pa的地址来访问a的值。 

7. 指针数组

  是否有这样一个疑惑,指针数组到底是指针还是数组?值得肯定的是,指针数组按字解释就是存放指针的数组,所以指针数组一定是数组。我们可以类比一下,整形数组就是存放整形的数组,字符数组,就是存放字符的数组,那么指针数组就是存放指针的数组,每个元素都是指针(地址)。

在后续如果一个数组里存放的都是地址,我们就可以使用指针数组来存放。

这里简单使用指针数组来模拟二维数组的效果,但并不是真正的二维数组,因为二维数组内存是连续的。代码如下:

#include <stdio.h>
int main()
{
	int arr1[] = { 1,2,3,4,5 };
	int arr2[] = { 2,3,4,5,6 };
	int arr3[] = { 3,4,5,6,7 };
	//数组名是数组⾸元素的地址,类型是int*的,就可以存放在指针数组中 
	int* parr[3] = { arr1, arr2, arr3 };
	int i = 0;
	int j = 0;
	for (i = 0; i < 3; i++)
	{
		for (j = 0; j < 5; j++)
		{
			printf("%d ", parr[i][j]);
		}
		printf("\n");
	}
}

结果是 :         

8. 数组指针变量

8.1 数组指针变量是什么

数组指针变量是指针变量?还是数组? 答案是:指针变量。

我们可以类比:存放整形的地址叫做整形指针,指向的是整形数据的指针;存放字符的地址叫做字符指针,指向的是字符数据的指针。那么数组指针也是一个指针变量,存放的是数组的地址,指向数组地址的指针变量。

 数组指针变量

int (*p)[10];

解释一下:*p表示它是一个指针变量,[10]表示p这个指针变量指向的对象是数组,数组中有10个元素,int表示,每个元素是int类型的。那么存放这样一个数组的指针变量就叫做数组指针。

这里要注意:[]的优先级要⾼于*号的,所以必须加上()来保证p先和*结合。  

 8.2 数组指针变量如何初始化

我们之前知道,如果要想获取一个数组的地址,可以用&数组名来取出整个数组的地址。我们先创建一个数组指针变量并对其初始化:

int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int(*parr)[10] = &arr;
    return 0;
}

通过调试可以发现,parr与&arr的类型一样都是int [10]*。

8.3 二维数组传参的本质

我们可以换个视角来看二维数组:二维数组的每一行都是一个一维数组,而每一个一维数组,我们都可以把它当成二维数组的一个元素,有几行就有几个元素。那么二维数组首元素就是第一行,是不是很清楚了。

我们又知道,除两种特殊情况外,数组名就是首元素的地址,那么二维数组的数组名arr也就是第一行的地址,而第一行又是一个数组(int[]),所以,要存放数组的地址,我们要用数组指针变量,所以,接受二维数组的数组名要用数组指针变量,指向的是第一行数组的地址int*[]。

这样,我们就可以使用指针来完成二维数组的实现了,比如打印二维数组;

void print(int(*pa)[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 ", *(*(pa + 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);//二维数组的地址,可以用数组指针变量接收
	return 0;
}

1 2 3 4 5
2 3 4 5 6
3 4 5 6 7 

值得注意的是:pa是一个数组指针,指向的是二维数组首元素的地址,也就是第一行的地址&arr[0]pa+i也就是第i行的地址,即&arr[i]*(pa+i)也就是依次取出arr[i]行,(*(pa+i)+j)i行第j列的元素的地址,*(*(pa+i)+j)访问第i行第j列每个元素。

这里*pa == arr == arr[0]

*(pa+1) == arr[1]

*(pa+i)+j == arr[i]+j == &arr[i][j]

*(*(pa+i)+j) == arr[i][j] == pa[i][j]

9. 函数指针变量

9.1 什么是函数指针变量

函数指针变量顾名思义也是变量。我们前面学过整形指针,数组指针,函数指针也只不过是存放函数地址的变量。在我们未来使用希望能通过函数的地址来调用函数。

我们想获取函数的地址有两种方式:

&函数名,或者函数名就表示地址

函数名与数组名没有关联,&数组名才取出整个数组的地址,而函数名可以直接取出函数的地址 

创建函数指针变量:

int a, b;
Add(a,b);
int (*pf)(int, int)=Add;

int(*pf)(int x,int y) = &Add//带不带&都可以

//函数指针类型int(*)(int,int)

 函数指针类型解析:

通过函数指针调用函数:

int Add(int x, int y)
{
	return x + y;
}

int main()
{
	int (*pf)(int, int)=Add;
	int ret = (*pf)(3, 5);
	ret = pf(5, 6);//有无*都可
	return 0;
}

9.2 函数指针数组

我们在前面学习了指针数组:存放指针的数组。那么函数指针数组就是存放函数指针的数组,将多个函数的地址存起来,形成的数组就是函数指针数组。

可以表示成这样

int (*parr[])() 

int (*parr[])(int,int)

parr[]表示是数组,数组的内容是指向 int (*)()的函数指针

10. 转移表

在函数指针数组的基础上,我们可以完成转移表的一些实现。 

举例:简单计算器用函数指针数组的实现:

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;
}
void menu()
{
	printf("***********************\n");
	printf("******1.Add  2.Sub*****\n");
	printf("******3.Mul  4.Div*****\n");
	printf("*********0.Exit********\n");
	printf("***********************\n");

}
int main()
{
	int input;
	int a, b;
	int ret = 0;

	do
	{
		menu();
		printf("请选择:>");
		scanf("%d", &input);
		int (*parr[5])(int x, int y) = { 0,Add,Sub,Mul,Div };//转移表
		//                               0,  1  2   3   4  //与选择匹配哦
		if (input >= 1 && input <= 4)
		{
			printf("请输入两个操作数:");
			scanf("%d %d", &a, &b);
			ret = parr[input](a, b);//利用函数数组指针当跳板  //相当于函数名(int,int);
			printf("计算结果是:%d\n", ret);
		}
		else if (0 == input)
		{
			printf("退出计算器\n");
		}
		else
		{
			printf("选择错误,请重新输入\n");
		}
	} while (input);
	return 0;
}

可以这样理解,这里parr[input] == 函数名,parr[input](int,int) 相当于函数名(int,int),在这里函数指针数组就像跳板一样, 通过结合函数指针数组和转移表,我们实现了一个灵活的状态机。每个状态对应一个函数,这种做法能够有效地组织代码,减少复杂的条件判断,使得状态和行为清晰地分开并易于维护。

如果还有什么不清楚的地方,给我留言,我一定会回你。一定要多练习,多敲代码!

如果没什么问题,谢谢你的关注和赞!  ^-^

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值