计算机初级选手的成长历程——指针(1)

初阶指针

导言

大家好,很高兴又和大家见面了!!!今天我们终于开始了指针内容的学习了。在开始介绍指针之前我们先回顾一下前面的知识点。 在前面的学习中,我们了解了内存以及地址的相关知识点:

  • 计算机硬件中的存储器分为主存储器和辅助存储器,主存储器就是我们所说的内存
  • 在主存储器中,主存储器被划分成了一个个小的存储单元,这就是内存单元
  • 内存单元的大小为1个字节
  • 每个内存单元都有自己的编号,这些编号就是内存单元的地址
  • 内存的工作方式是通过存储单元的地址进行存取的,这种存取方式被称为按地址存取
  • 地址是由电信号的低电位(0)与高电位(1)组成的,我们通过比特位来存放不同的电位
  • 一个比特位只能存放一个‘0’或‘1’;
  • 32位操作系统中,地址总共有32个比特位,在64位操作系统中,地址总共有64个比特位
  • 计算机的单位中除了bit、byte之间的转化为8外,其它单位之间的转化都是1024
  • 程序猿可以通过取地址操作符&将操作对象的地址取出来;
  • 程序猿可以通过解引用操作符*将地址中存放的值取出来;

PS:上述知识点在数组、函数栈帧的创建与销毁以及操作符篇章中都有详细介绍;

  • 【数组篇章】详细介绍了内存以及地址的表现形式
  • 【函数栈帧的创建与销毁篇章】详细介绍了存储器
  • 【操作符篇章】详细介绍了:计算机中的单位、地址的产生与地址的作用;

对这些内容感兴趣的朋友可以点击链接来了解一下相关知识点。

在回顾完这些知识点后,我们再来看看什么是指针;

一、指针与指针变量

在计算机科学中,指针(pointer)是编程语言中的一个对象,利用地址,它的值直接指向(points to)存在电脑存储器中另一个地方的值。由于通过地址能找到所需的变量单元,可以说地址指向该变量单元。因此将地址形象化的称为“指针”。意思就是通过它能找到以它为地址的内存单元。

我们知道字符变量是用来存放字符的、整型变量是用来存放整数的、浮点型变量是用来存放浮点数的,指针变量也是同样的作用,它是用来存放指针的,因为指针就是地址,我们也可以说指针变量是用来存放地址的

注意:我们在口语中说的指针一般指的是指针变量。

二、指针变量的创建和指针类型

我们知道对于变量的创建是通过数据类型+变量名这个格式来实现的,变量的初始化会根据变量数据类型的不同给变量赋予一个同类型的初始值,如:

代码语言:javascript
复制
//变量的创建及初始化
char ch = 'a';
short sh = 1;
int i = 2;
long l = 2;
long long ll = 4;
float f = 1.0f;
double lf = 2.0f;

现在我们知道指针变量存储的是指针,也就是存储的地址,我们可以通过取地址操作符&将操作对象的地址取出来赋值给指针变量来完成指针变量的初始化:

代码语言:javascript
复制
//指针变量的创建
int a = 4;
p = &a;

对于指针变量来说,它的数据类型与我们常见的数据类型区别,指针的数据类型是在数据类型的基础上加上一个*,如下所示:

代码语言:javascript
复制
//指针的数据类型
char*——字符型指针类型
short* ——短整型型指针类型
int* ——整型指针类型
long* ——长整型指针类型
long long* ——更长的整型指针类型
float* ——单精度浮点型指针类型
double* ——双精度浮点型指针类型
……

只要是数据类型再加上*,此时的数据类型就会变成指针的数据类型;

对于这颗_的理解,我是理解成钥匙孔,在介绍&和_这两个操作符时,我有提到过,取地址操作符就相当于是取出门牌号,而解引用操作符就是门的钥匙,那现在我们从指针的数据类型就可以知道了为什么是*而不是#甚至是其它的符号,因为钥匙的形状要和钥匙孔对的上才行。

那现在问题来了这些类型与普通的数据类型有什么区别呢?这里我们通过sizeof来测试一下:

从测试结果中我们可以看到,在32位操作系统下,不管是哪种类型的指针,此时所占空间大小都是4个字节,也就是32个比特位,下面我们来看一下在64位操作系统下又是什么情况:

可以看到,此时的大小为8个字节,也就是64个比特位,这个数值有没有感觉很熟悉? 没错这个和地址在这两个操作系统下的大小是一致的,这一点可以直接证明指针就是地址

那既然不管什么类型的指针所占空间大小都是一样的,那是不是说我随意定义一个类型的指针就可以了呢?不同类型的指针有什么区别呢?

三、指针类型的意义

对于前面定义的整型变量a以及还未确定类型的指针p,为了探究不同类型指针的意义,我们分别用char类型、short类型、int类型以及long long类型的指针来接收变量a的地址,如下所示:

代码语言:javascript
复制
//指针类型的意义
int main()
{
	int a = 4;
	//通过取地址操作符将变量a的地址取出来存放在指针变量中
	char* p1 = &a;
	short* p2 = &a;
	int* p3 = &a;
	long long* p4 = &a;
	return 0;
}

此时我们已经完成了指针变量的创建,接下来我们分别通过对指针进行整数加减以及通过解引用来完成对变量a存储内容的修改,我们来看看不同类型的指针都会有哪些差异;

3.1 指针 '+'/'-' 整数

因为指针存储的是地址,所以指针加减整数实质上就是地址进行整数的加减,为了更加直观的看到其变化,我们通过打印格式%p——以地址的形式打印,我们现在对这四种指针类型分别进行+1和-1的操作,测试结果如下所示:

从测试结果中我们可以看到:

对于char*类型的指针p1来说,它加减1的值刚好是1个字节的大小; 对于short*类型的指针p2来说,它加减1的值刚好是2个字节的大小; 对于int*类型的指针p3来说,它加减1的值刚好是4个字节的大小; 对于long long*类型的指针p4来说,它加减1的值刚好是8个字节的大小;

大家应该对char、short、int、long long这些数据类型所占空间大小应该还有印象吧,没印象也没关系,如下图所示:

现在大家有什么发现吗?

没错,不同类型的指针在进行加1和减1操作后,指针变化的字节大小与对应的数据类型所占空间大小相同

那如果是+2,就相当于是+1之后再+1,那指针变化的字节大小应该是对应数据类型所占空间大小的2倍;

同理,+3就是3倍,+4就是4倍……+n就是n倍;那具体是不是这样呢?我们继续测试:

从测试结果中可以看到,不管是+2/-2也好还是+10/-10也好,指针变化的大小确实是对应数据类型的整数倍。因此我们可以得到结论

3.2 指针解引用

接下来我们来看一下对于不同类型的指针进行解引用,又会是什么结果;

代码语言:javascript
复制
//指针解引用
int main()
{
	int a1 = 0x11223344;
	int a2 = 0x11223344;
	int a3 = 0x11223344;
	int a4 = 0x11223344;
	//通过取地址操作符将变量a的地址取出来存放在指针变量中
	//指针类型 = 数据类型*
	char* p1 = (char*)&a1;
	short* p2 =(short*)&a2;
	int* p3 = &a3;
	long long* p4 =(long long*)&a4;
	//通过解引用操作符对指针中存放的内容进行修改
	*p1 = 0;
	*p2 = 0;
	*p3 = 0;
	*p4 = 0;
	return 0;
}

这里我们通过四个变量来进行解引用,为了方便观察,我们通过调试内存窗口来观察不同类型的指针解引用的变化:

从内存窗口我们可以看到:

对于char*类型的指针p1,在通过解引用将地址中存储的值改为0时,p1改变了1个字节的内容;对于short*类型的指针p2,在通过解引用将地址中存储的值改为0时,p2改变了2个字节的内容;对于int*类型的指针p3,在通过解引用将地址中存储的值改为0时,p3改变了4个字节的内容;对于long long*类型的指针p4,在通过解引用将地址中存储的值改为0时,p4改变了8个字节的内容;

可以看到这个改变内容的字节大小与指针对应的数据类型所占空间大小也是相同的,也就是说,不同类型的指针在进行解引用操作是可以操作的字节大小与对应类型所占空间大小相同。

经过这两次测试的结果,对于不同类型指针的意义,现在我们可以得到的结论:

  • 不同类型的指针在进行+/-整数时,指针变化的值为对应类型所占空间大小与整数的乘积;
  • 不同类型的指针在解引用时,对值修改可操作的字节大小为对应类型所占空间大小;

现在我们已经知道了什么是指针,也知道了指针类型的意义,现在我们来看一个新的概念——野指针;

四、野指针

我看到野指针的这个野时,联想到的是野猫、野狗、野猪……现在问题来了,它们为什么被称为野猫、野狗和野猪呢?

是因为它们和家养的小动物的区别是家养的小动物是有明确的主人喂养的,而这些野生的小动物都是流浪在野外的。

对于家养的小动物来说,我们只需要通过它们主人的住址就能找到它们,但是野生的小动物你即使知道它的活动区域也不一定能找到它,因为它们的位置是不可知的。

野指针也是一样,下面我们来看一下野指针的定义;

4.1 定义

野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的);

4.2 野指针的成因

既然这些指针指向的位置是不可知的,那它们是怎么出现的呢? 对于野指针的出现主要是三个原因:

  • 指针未初始化;
  • 指针越界访问;
  • 指针指向的空间被释放了;

下面我们对这些原因进行一一的说明;

4.3 指针未初始化

指针变量实质上也是一个变量,只不过它存放的是地址而已,既然是变量,那我在创建时,如果未给变量进行初始化,那么就会导致此时的指针变量指向的是一个随机的地址,那如果我要对这个随机的地址进行解引用并对地址中的内容进行修改,那会出现什么情况呢?

此时我们可以看到,在VS2019编译器下直接对这个错误进行了报错,报错的原因就是未初始化的局部变量p,也就是说此时都不需要你去思考如何操作了,编译器直接不给你过。

但是,在其它的编译器下可能会正常运行,但是此时*p对内存的访问其实是非法的。

我们在函数栈帧篇章有介绍过,局部变量的创建是在main函数的栈帧中实现的,即使是进行函数调用,也是得先将调用的函数的函数栈帧创建好了才能进行后续的操作,此时的*p 却是在内存中随意找寻的一块地址,那就会出现以下的情况:

  • 当指针指向main函数的栈帧中的空间时,你并不能确定它指向的是哪一块空间,也就是说,指针p此时可能指向已经被使用的地址,那此时对这个空间的值进行修改,是不是有可能导致我写的代码不能正常的运行呢?
  • 当指针指向main函数的栈帧外的空间时,你就更不能确定它指向的是哪一块空间了,也就是说,指针p此时可能指向的是调用main函数的函数栈帧中的一块地址,那此时对这个空间的值进行修改,是不是有可能因为修改的这个值导致main函数的调用出现错误呢?

如果不好理解的话,那我们换一个角度来理解:

此时的指针p就好比一个旅行者张三,他跑到一个陌生的城市旅游,此时他是需要给自己找一个住处的。 他寻找住处的方式就是通过酒店的房间地址来明确居住的房间。 给指针初始化的过程就是张三在酒店前台登记入住的过程,登记好了才能正常在酒店入住; 不给指针初始化就好比这个张三随意跑到一个房间居住,这个房间可能是酒店的房间,也可能是别人的住宅,不管是哪种情况,这种行为都是违法的。 所以,不给指针初始化,指针就会进行非法访问

4.4 指针越界访问

当我们正常的给指针初始化后,也可能出现野指针的情况,如下所示:

在这个代码中,对于数组arr来说,它的空间内只有3个元素,我们通过数组名将数组的首元素地址赋值给变量p后,变量p在进行对地址内容修改时,修改了5个地址,此时系统就报错了,报错内容为变量arr周围的栈区被损坏,此时就是指针进行越界访问了。

这种情况就好比:

还是这个张三,他此时开了三间房,并且在酒店前台登记了,结果他在入住时,不仅将开好的三间房中放置了自己的行李,他还将自己的行李放在了另外两间房间内; 这种情况下,对于酒店来说,张三对未登记的两间房间进行了越界访问,这种行为也是不可取的; 所以,指针也是不能进行越界访问的

4.5 指针指向的空间被释放

当指针已经初始化了,也没有进行越界访问,还有可能会出现野指针的情况,如下所示:

在这个代码中,我们定义了一个整型指针类型的函数test并在函数内部创建了一个变量a,a的空间内部存放的值为1,此时我们将a的地址返回给函数,在主函数中整型指针p接收了这个返回值,并将地址中的值打印出来了。

可以看到,此时的程序是能正常运行的,但是系统会报警告,警告的内容为返回了局部变量或零时变量的地址a

通过函数栈帧的角度来分析的话,变量a的地址是创建在test函数栈帧内的,当调用结束时,test的函数栈帧就被销毁了,此时指针p指向的是main函数栈帧外的空间地址,虽然是一个明确的地址,但是此时的指针p是一个野指针,如下图所示:

此时的指针p并不在函数栈帧内部,所以此时的p也是属于非法访问内存空间的。如果此时我在打印前再调用一个函数,我们来看一下会是什么结果;

从这一次的测试结果中我们可以看到,此时不管是变量a也好、变量b也好还是指针p指向的地址也好,它们的地址都是同一个,所以此时我们想将a的值通过指针p来打印是做不到的,因为此时指针p指向的地址在test2中被使用了,所以打印出来的值是被使用后的值,也就是现在的100;

我们还是通过张三的例子来理解:

test函数中的变量a就是李四,李四现在在一家酒店登记了,并在自己登记的房间里睡了一觉,在李四离开酒店的时候,他将自己休息的房间号告诉了张三,并跟张三说,我在房间内给你留了一份礼物,于是张三就跑过去拿礼物了; 在第一个例子中,这时的房间还没有人使用,所以张三成功的取到了李四留下的礼物; 在第二个例子中,在张三过去前,房间被王五使用过了,结果王五在离开的时候将自己的行李落在了酒店,等张三到达酒店时,拿到的就是王五的行李; 以上这两种行为,对于酒店来说,都是不合法的,所以酒店会提出警告;

4.6 如何规避野指针

既然野指针会产生这些问题,那我们应该怎么做才能规避野指针呢?方法很简单,只要将上述问题反着来就好了:

  1. 给指针进行初始化;
  2. 避免指针越界访问;
  3. 不要返回局部变量或者临时变量的地址;
  4. 当指针指向的地址不再使用时,将指针置为空(NULL);
  5. 在使用指针前,检查指针的有效性;

对于前面三点,我相信大家现在都是能理解的,但是对于第4点,可能就有朋友有疑问了,为什么当地址不再使用时要将指针置为空呢?

这是因为一个约定俗成的规则:当指针为空指针时,就不会对指针进行访问

所以就有了第5点,在使用指针前需要检查指针是否为空指针

结语

今天的内容到这里就全部结束了,在今天的篇章中,我们介绍了指针以及野指针的相关内容,相信大家此时对指针有了一个初步的认识,在接下来的篇章中,我会继续分享指针的相关知识点,最后感谢大家的翻阅,咱们下一篇再见。