目录
在正式学习指针之前,我们先要理解在C/C++中两个非常重要的概念------内存 和地址。正是有这两种因素的存在,才使得C/C++拥有当今不可动摇的地位。
一、内存和地址
1.内存
在讲内存和地址之前,我们举个生活中的案例:
假设有一栋宿舍楼,楼上有100个房间,你就住在这100个房间里的某一间。但是房间没有编号,你的一个朋友来找你玩,正常情况下如果想找到你就得挨个房间去找,这样效率很低,但是我们如果根据楼层和楼层的房间的情况,给每个房间编上号,比如:
一楼:101,102,103...
二楼:201,202,203....
...
有了房间号,如果你的朋友得到房间号,就可以快速地找到你。生活中,每个房间有了房间号,就能提高效率快速的找到房间。
如果把上面的例子对照到计算中又是怎么样呢?我们知道计算机CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那我们买电脑的时候,电脑上内存是8GB/16GB/32GB等,那这些内存空间如何高效的管理呢?
其实也是把内存划分为一个个的内存单元,每个内存单元的大小取1个字节。
计算机中常见的单位(一个比特位可以存储一个2进制的位------1或者0):
1byte = 8bit
1KB = 1024byte 1
MB = 1024KB
1GB = 1024MB
1TB = 1024GB
1PB = 1024TB
其中,每个内存单元相当于一个学生宿舍,一个人字节空间里面能放8个比特位,就好比同学们住的八人间,每个人是一个比特位。
每个内存单元也都有一个编号(这个编号就相当于宿舍房间的门牌号),有了这个内存单元的编号CPU就可以快速找到一个内存空间。
生活中我们把门牌号叫做地址,在计算机中我们把内存单元的编号也称为地址。C语言中给地址起 了新的名字叫:指针。
所以我们可以理解为:内存单元的编号 == 地址 == 指针
2.究竟该如何理解编址
CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,而因为内存中字节 很多,所以需要给内存进行编址(就如同宿舍很多,需要给宿舍编号一样)。
计算机中的编址,并不是把每个字节的地址记录 下来,而是通过硬件设计完成的。
钢琴、吉他上没有写上"都瑞咪发嗦啦"这样的信息,但演奏者照样能够准确找到每一个琴弦的每个位置,这是为什么呢?因为制造商已经在乐器硬件层面上设计好了,并且所有的演奏者都知道。本质是一种约定出来的共识!
硬件编址也是如此
首先必须理解的是计算机内是有很多的硬件单元的,而硬件单元是要互相协同工作的。所谓的协 同就是指至少相互之间要能够进行数据传递。
但是硬件与硬件之间是互相独立的,那么如何通信呢?答案很简单,用"线"连起来。
而CPU和内存之间也是有大量的数据交互的,所以两者必须也用线连起来。
不过我们今天关心一组线,叫做地址总线。
我们可以简单理解,32位机器有32根地址总线,每根线只有两态,分别表示0和1。(即电脉冲有无)那么一根线,就能表示2种含义,2根线就能表示4种含义,依此类推,32根地址线,就能表示2^32种含义,每一种含义都代表一个地址。
地址信息被下达给内存,在内存上就可以找到该地址对应的数据,将数据在通过数据总线传如CPU内寄存器。
二、指针变量和地址
1.取地址操作符(&)
理解了内存和地址的关系,我们再回到C语言。在C语言中创建变量其实就是向内存申请空间,比如:
cpp
#include <stdio.h>
int main()
{
int a = 10;
return 0;
}
VS2022中该如何调用内存窗口?运行程序时按F10,然后点击选项卡中的调试:
比如上述的代码就是创建了整型变量a,随后在内存中申请了4个字节用于存放整数10,其中每个字节都有地址,上图中4个字节的地址分别是:
0x006FFD70
0x006FFD71
0x006FFD72
0x006FFD73
那我们如何能得到a的地址呢?这里就得学习一个操作符------取地址操作符(&)
在前面我们使用scanf()函数时,scanf()的参数其实就是变量的地址。也就是说如果main()函数不使用return返回的值,则必须通过地址才能修改main()函数中的值。所以在C中引入了取地址操作符来方便我们通过地址来改变变量的值。
在上面我们已经知道,内存单元的编号称为地址,那么地址就是一个具体的值,那我们就对上述代码中的a进行取地址操作,然后打印出来。这里我们把变量a的值也打印出来,做个对比:
cpp
#include <stdio.h>
int main()
{
int a = 10;
&a;
printf("%d\n",a);
printf("%p\n",&a);
return 0;
}
由于在x64环境下打印出来的地址过长,这里我们切换为x86环境进行观察。结果:
如果是按上述我们在调试窗口看到的a的地址,打印出来应该是006FFD70
那么&a取出的就是a所占4个字节中地址较小的字节的地址。虽然整型变量占用4个字节,我们只要知道了第一个字节地址,顺藤摸瓜访问到4个字节的数据也是可行的。
2.解引用操作符(*)
我们通过取地址操作符(&)拿到的地址是一个数值比如:0x006FFD70。这个数值有时候也是需要 存储起来方便后期再使用的,那我们把这样的地址值存放在哪里呢?答案是:指针变量中。
我们之前提到过在C/C++中我们可以通过变量的地址来改变变量的值,那我们该如何改变呢?这时就要用到我们的**解引用操作符(*)**了。变量的地址需要存放到指针变量中,那我们就需要创建一个指针变量:
int* pa = &a;
这里pa左边写的是int*,*是在说明pa是指针变量,而前面的int是在说明pa指向的是整型(int)类型的对象,然后我们就可以把a的地址也就是&a赋给指针变量pa。
同样的,如果是其他类型的变量,比如char、float、short等,对应的指针变量就是char*、float*、short*等。
我们将地址保存起来,未来是要使用的,那怎么使用呢?
在现实生活中,我们使用地址要找到一个房间,在房间里可以拿取或者存放物品。
C语言中其实也是一样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这里必须学习一个操作符叫解引用操作符(*)。
cpp
#include <stdio.h>
int main()
{
int a = 100;
int* pa = &a;
*pa = 0;
return 0;
}
上面代码中"*pa = 0"就使用了解引用操作符,*pa的意思就是通过pa中存放的地址,找到指向的空间,*pa其实就是a变量。所以*pa = 0这个操作就是把a改成了0。
这里如果目的就是把a改成0的话,写成a=0不就完了,为啥非要使用指针呢?其实这里是把a的修改交给了指针变量pa来操作,这样对a的修改就多了一种途径,写代码就会更加灵活。后期慢慢就能理解了。
3.指针变量的大小
从前面的内容我们了解到,假设32位机器有32根地址总线,每根地址线出来的电信号转换成数字信号后就是1或者0,那我们把32根地址线产生的2进制序列当做一个地址,那么一个地址就是32个bit位,需要4个字节才能存储。如果指针变量是用来存放地址的,那么指针变量的大小就是4个字节。
同理假设64位机器有64根地址线,一个地址就是64个二进制位组成的二进制序列,存储起来就需要8个字节的空间,指针变量的大小就是8个字节。
cpp
#include <stdio.h>
//指针变量的⼤⼩取决于地址的⼤⼩
//32位平台下地址是32个bit位(即4个字节)
//64位平台下地址是64个bit位(即8个字节)
int main()
{
printf("%zd\n", sizeof(char*));
printf("%zd\n", sizeof(short*));
printf("%zd\n", sizeof(int*));
printf("%zd\n", sizeof(double*));
return 0;
}
32位环境下的输出结果:
64位环境下的输出结果:
结论:
①32位平台下地址是32个bit位,指针变量大小是4个字节
②64位平台下地址是64个bit位,指针变量大小是8个字节
③指针变量的大小和类型是无关的,只要是指针类型的变量,在相同的平台下大小都是相同的。
三、指针变量类型的意义
指针变量的大小和类型无关,只要是指针变量,在同⼀个平台下大小都是一样的,为什么还要有各 种各样的指针类型呢?
1.指针的解引用
对比下面两端代码在调试时内存的变化:
cpp
//代码1
#include <stdio.h>
int main()
{
int n = 0x11223344;
int* pi = &n;
*pi = 0;
return 0;
}
//代码2
#include <stdio.h>
int main()
{
int n = 0x11223344;
char *pc = (char *)&n;
*pc = 0;
return 0;
}
调试我们可以看到,代码1会将n的4个字节全部改为0,但是代码2只是将n的第⼀个字节改为0。
结论:
指针的类型决定了对指针解引用时有多大的权限(一次能操作几个字节)。比如:char*的指针解引用就只能访问一个字节,而int*的指针的解引用就能访问四个字节。
2.指针+-整数
先看一段代码,调试观察地址的变化。
cpp
#include <stdio.h>
int main()
{
int n = 10;
char* pc = (char*)&n;
int* pi = &n;
printf("%p\n", &n);
printf("%p\n", pc);
printf("%p\n", pc+1);
printf("%p\n", pi);
printf("%p\n", pi+1);
return 0;
}
结果:
我们可以看出char*类型的指针变量+1跳过1个字节,int*类型的指针变量+1跳过4个字节。这就是指针变量的类型差异带来的变化。
结论:
指针变量的类型决定了指针向前或者向后走一步有多大(距离)
3.void*指针
在指针类型中有一种特殊的类型------void* ,可以理解为无具体类型的指针 (或者叫泛型指针)。这种类型的指针可以用来接受任意类型地址。但是也有局限性, void*类型的指针不能直接进行指针的+-整数和解引用的运算。
cpp
#include <stdio.h>
int main()
{
int a = 10;
int* pa = &a;
char* pc = &a;
return 0;
}
在上面代码中将一个int类型的变量的地址赋值给一个char*类型的指针变量。编译器给出了一个警 告(如下图),原因是类型不兼容。如果使用void*类型的指针变量就不会有这样的问题。
使用void*类型的指针接收地址:
cpp
#include <stdio.h>
int main()
{
int a = 10;
void* pa = &a;
void* pc = &a;
*pa = 10;
*pc = 0;
return 0;
}
VS编译代码结果:
这里我们可以看到,void*类型的指针可以接收不同类型的地址,但还是无法直接进行指针运算。
那么void*类型的指针到底有什么用呢?一般void*类型的指针是在函数参数部分才使用的,用来接收不同类型数据的地址,这样的设计可以实现泛型编程的效果,使得一个函数可以来处理多种类型的数据。在C语言:指针详解(4)中我们会详细讲解。
四、const修饰指针
变量是可以修改的,如果把变量的地址交给一个指针变量,我们就可以通过指针变量修改这个变量。但是如果我们希望将一个变量加上一些限制,让这个变量不能被修改,该怎么做呢?这时就要用到C中的关键词------const
1.const修饰变量
cpp
#include <stdio.h>
int main()
{
int m = 0;
m = 20;//m是可以修改的
const int n = 0;
n = 20;//n是不能被修改的
return 0;
}
上述代码中n是不能被修改的,n本质是变量,但是被const修饰后,在语法上会有限制,如果我们在代码中对n的值进行修改,就不符合const的语法规则,就会报错:
但是如果我们不直接对n的值进行修改,而是通过n的地址去修改n的值,就可以做到改变n的值。可我们在编程时对一个变量进行const修饰,我们还是可以通过这个变量的地址来修改变量的值,这就违背了我们对这个变量进行const修饰的初心,因为我们本来就不希望让这个变量的值改变。
cpp
#include <stdio.h>
int main()
{
const int n = 0;
printf("n = %d\n", n);
int* p = &n;
*p = 20;
printf("n = %d\n", n);
return 0;
}
所以要彻底让一个值不能被修改,不仅使其不能直接被修改,还要防止通过指针来修改它的值。
2.const修饰指针变量
我们来分析下列代码:
cpp
#include <stdio.h>
void test1()
{
int n = 10;
int m = 20;
int* p = &n;
*p = 20;//ok?
p = &m; //ok?
}
void test2()
{
int n = 10;
int m = 20;
const int* p = &n;
*p = 20;//ok?
p = &m; //ok?
}
void test3()
{
int n = 10;
int m = 20;
int *const p = &n;
*p = 20; //ok?
p = &m; //ok?
}
void test4()
{
int n = 10;
int m = 20;
int const * const p = &n;
*p = 20; //ok?
p = &m; //ok?
}
int main()
{
test1();//测试无const修饰的情况
test2();//测试const放在*的左边情况
test3();//测试const放在*的右边情况
test4();//测试*的左右两边都有const
return 0;
}
我们可以发现,除了test1()函数,其余三个函数均发生了报错(红色波浪号代表错误源)。
我们来逐个对test2()、test3()和test4()函数进行分析。
首先来看test2()函数。我们将const加在了星号(*)的左边,然后再对指针变量p进行解引用操作和更改指向变量操作。我们会发现我们无法对指针变量p进行解引用,但是可以使指针变量p指向其他的变量。
再来看test3()函数。我们将const加在了星号(*)的右边,然后再对指针变量p进行解引用操作和更改指向变量操作。我们会发现我们可以对指针变量p进行解引用,但是无法使指针变量p指向其他的变量。
最后来看test3()函数。我们将const同时加在了星号(*)的左右两边,然后再对指针变量p进行解引用操作和更改指向变量操作。我们会发现我们既无法对指针变量p进行解引用,也无法使指针变量p指向其他的变量。
综合上述分析,我们可以得出const修饰指针变量的结论:
①const如果放在*的左边,即修饰的是指针指向的初始变量,则保证了指针指向的初始变量的内容不能通过指针来改变。但是指针变量本身可以指向除初始变量以外的其他变量
②const如果放在*的右边,即修饰的是指针变量本身,则保证了指针变量指向的变量不能修改。但是指针指向的变量的内容可以通过指针改变。
五、指针运算
指针的基本运算有三种,分别是:
①指针+- 整数
②指针-指针
③指针的关系运算
其中①和②统称为指针的算术运算
1.指针+-整数及利用指针遍历数组的实现
对于指针+-整数我们结合一维数组来理解~
我们知道,数组在内存中是连续存放的,只要知道第一个元素的地址,顺藤摸瓜就能找到后面的所有元素。
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
在以前我们要遍历整个数组,需要访问数组每一个元素的下标:
cpp
#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[0]);
for (int i = 0; i < sz; i++)
printf("%d ", arr[i]);
return 0;
}
现在我们学习了指针,我们是否可以通过指针来遍历数组的每一个元素呢?当然可以。
观察以前我们遍历整个数组的代码。我们计算了数组的长度,然后建立了一个for循环,从i=0开始,也就是从数组的首元素开始依次往后打印。同样的,我们用指针来遍历整个数组时,肯定也是要先从首元素开始打印。
这里我们举一个例子。我们定义一个int类型的变量n,将其赋值为10,然后定义一个int*类型的指针变量pn来存放变量n的地址。这时我们要打印变量n有两种方法:
cpp
#include <stdio.h>
int main()
{
int n = 10;
int* pn = &n;
printf("%d\n", n);
printf("%d\n", *pn);
}
第一种方法就是直接打印变量n,第二种就是通过对指针变量解引用来间接打印变量n。
哦,那我就知道了!如果我们要打印一维数组中的某个元素,一种方法就是访问这个元素的下标来进行打印,另一种方法就是取出这个元素的地址然后对其地址进行解引用来打印。
我们要遍历整个数组,不妨就先取出数组首元素的地址!
cpp
#include <stdio.h>
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int* p = &arr[0];
int sz = sizeof(arr)/sizeof(arr[0]);
return 0;
}
这里我们事先定义好了一个长度为10的整型数组,然后定义一个int*类型的指针变量p来存放首元素的地址。由于数组在内存中是连续存放的,我们可以根据数组的这一特性,通过数组首元素的地址来依次获取后续元素的地址。这里我们用图的形式来理解:
我们已经事先找到了数组首元素的地址,然后用一个指针来指向它。我们都知道整数+-整数的结果,比如1+1=2,4-1=3这样的,那么如果我们对数组的首元素地址进行+-整数的操作呢?如果我们让数组的首元素地址+1,指针就会向右移动一格:
指针向右移动一格后,我们就会得到下标为1的元素的地址,就可以通过解引用操作来打印下标为1的元素。p+1就是下标为1的元素的地址,p+2就是下标为2的元素的地址...随后以此类推,我们就可以得到数组的每个元素的地址,然后通过解引用操作后可以将所有元素打印出来。那我们就可以写出如下代码:
cpp
#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int* p = &arr[0];
int i = 0;
int sz = sizeof(arr) / sizeof(arr[0]);
for (i = 0; i < sz; i++)
printf("%d ", *(p + i));//p+i这里就是指针+整数
return 0;
}
在这里我们通过调试来进一步理解,点一下F10键,打开监视窗口,然后监视指针变量p和p+i的值(32位环境下):
当我们运行到循环入口时,我们可以发现p的值和p+i的值是相等的,此时代表的都是数组首元素的地址,而花括号内的1就是解引用地址后得到的数值,也就是数组首元素:
我们按两下F10后,就会发现指针p和p+i的值就变得不一样了,这也就充分的说明了指针确实有在向后移动:
再按几下:
我们可以很清楚地发现我们确实通过指针遍历到了数组的每一个元素,所以通过指针遍历数组这一方法的确有效!
2.指针-指针及strlen()函数的模拟实现
在介绍指针-指针运算时,我们先举一个生活中的例子。马上就要五一劳动节了,很多同学都在计划假期去哪玩,这时肯定就会考虑到距离劳动节还有几天这样的一个问题。比如说今天是2024年4月8日,我一算,距离劳动节还有23天,还有这么长时间!还要努力奋斗23天才能休息!这是日期-日期的运算。
再回到指针-指针的运算,我们确实可以算出一个具体的数值。就拿上面我们通过指针遍历数组来说,我们让下标3的元素的地址减去首元素的地址,得出来的其实是下标为3的元素和首元素之间的元素个数,也就是3。同样的,我们让数组的最后一个元素的地址减去首元素的地址,得到的就是数组的长度。
这里我们拿字符串来举一个例子。我们知道计算字符串的长度有专门的一个函数------strlen() 函数。根据我们上述的分析:一维数组的最后一个元素的地址减去首元素的地址的结果是数组的长度。我们可以将一个字符串看成一个字符数组,将第一个字符的地址和最后一个字符的地址表示出来,随后两者做差,得到的就是字符串的长度。这里一个很重要的点就是strlen()函数计算时不包括字符末尾的'\0'。
我们不妨定义一个由我们自己编写的计算字符串长度的函数------my_strlen() 函数。既然是计算字符串的长度,返回类型就是int类型。由上面通过指针遍历数组的例子我们可以得出my_strlen()函数的形参只需要字符串的第一个字符的地址即可。我们不妨将指向字符串第一个字符的指针变量的形参形式表达为s。
int my_strlen(char* s)
由于我们要计算字符串的长度,肯定是要同时取得第一个字符的地址和最后一个字符的地址。我们不妨在函数内部再创建一个和s同类型的指针变量p,用来后续表示出最后一个字符的地址。然后定义一个while循环,循环体内部是指针变量p自增,条件是只要遇到'\0'就停止。while循环停止时,得到的就是最后一个字符的地址,因此函数返回的是最后一个元素的地址和首元素地址的差值,也就是字符串的长度。由此,我们可以写出my_strlen()函数。然后我们再将C库给我们的strlen()与我们自定义的my_strlen()函数进行比较:
cpp
#include <stdio.h>
int my_strlen(char* s)
{
char* p = s;
while (*p != '\0')
p++;
return p - s;
}
int main()
{
printf("%d\n", strlen("hello bit"));
printf("%d\n", my_strlen("hello bit"));
}
结果:
返回结果都一样,说明我们自己写的my_strlen()函数没有问题~
那这时就有人要问了,既然有指针-指针的运算,那肯定有指针+指针的运算吧!答案是没有的!还是举劳动节那个例子,今天是4月8日,劳动节是5月1日,如果我突发奇想将4月8日和5月1日相加后得到的结果是多少呢?我想了又想,彻夜难眠。于是最后得到的答案是9月9日。这样算得到的结果肯定是不对的。与其就说日期+日期本身就没有意义。
同样的道理,指针+指针也就是地址+地址,虽然地址确实是实实在在的数,但它本质上还是指向的一块空间。假设有一个一维数组,现在让你将第三个元素的地址和第七个元素的地址相加,你觉得得到的结果是啥呢?难不成是第十个元素的地址吗?显然不是。所以指针+指针这个操作是没有意义的。
3.指针的关系运算
我们可以使用关系操作符对两个指针进行比较,然后所比较的两个指针进行操作。举个例子:
cpp
#include <stdio.h>
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];
int i = 0;
int sz = sizeof(arr)/sizeof(arr[0]);
while(p<arr+sz)
{
printf("%d ", *p);
p++;
}
return 0;
}
上述代码中while循环就用到了指针的关系运算,实际上与我们平常将若干个变量进行比较然后判断是否符合条件相似,只不过这里的变量换成了指向变量的指针,如果符合就运行接下来的代码;反之则不运行。
这里由于指针p指向的是首元素的地址,数组名arr的另一种含义就是数组名的地址(这点我们会在后续进行讲解,这里先做了解)。那么首元素的地址肯定是小于首元素的地址加上数组的大小的,那么这个循环就会执行sz次。直到p大于arr+sz或等于arr+sz时,这个循环就会停止。
结果:
六、野指针
野指针是指那些指向未知内存区域或者已经释放的内存区域的指针。这种指针没有指向合法的、有效的内存地址,因此对野指针的解引用操作是危险的,可能导致程序崩溃、数据损坏或者其他未定义的行为。
1.指针未初始化
假如我们定义一个int*类型的指针变量p,然后对其进行解引用,赋值为20:
cpp
#include <stdio.h>
int main()
{
int* p;
*p = 20;
return 0;
}
当我们试图在VS2022上运行时:
我们平时定义的指针变量一般都是取到某一个变量的地址,然后再对其解引用。但是这里指针变量p并没有指向任何变量,或者说它指向的是随机位置 ,我们都不知道它到底指向哪,更何况编译器呢?这时我们说未初始化的指针变量是野指针。
2.指针越界访问
假设这里有一个整型一维数组,假设我们已经利用循环遍历完了整个数组,但是呢有一天写代码的人粗心了一下,写出了如下遍历数组的程序:
cpp
#include <stdio.h>
int main()
{
int arr[10] = {0};
int *p = &arr[0];
int i = 0;
for(i=0; i<=11; i++)
*(p++) = i;
return 0;
}
我们发现for循环将会执行12次!那也就是说指针变量p(指向数组首元素的地址)会自增12次!可是原本数组的长度就只有10。那也就是说,指针在依次访问完每个数组元素后,还会再向后访问!
我们都知道,我们在创建一个数组时,都会向内存申请一块连续的空间专门用来存放数组中的元素。但是在上面代码中即便访问完数组的每个元素,指针还是会向后移动,但是后续的空间不再属于这个数组了。这会系统就不满意了,这块空间不是你的你为什么还要访问,为什么创建数组的时候不多要点空间,要打印的时候你又访问上了!这就是指针的越界访问。我们说越界访问的指针是野指针。
3.指针指向的空间释放
cpp
#include <stdio.h>
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
这时有人突发奇想了,我们可不可以将自定义函数返回变量的地址赋给main函数中的指针变量呢?我们假设test()函数中n变量的地址是0x12ff40,函数的返回类型是int*,也就是将变量n的地址赋给指针变量p,此时p的值就是0x12ff40。但是仔细想想,我们真的实打实的将变量n的地址传给指针变量p了吗?
我们在C语言:函数-CSDN博客中了解过变量的作用域和生命周期。这里变量n是局部变量,它的作用域仅限于test()函数内。现在我们以调试的视角来看待这个函数:
程序执行,先进入test()函数,随之创建的就是变量n,这时就会在内存中申请一块空间用来存放变量n,然后函数返回的正是指向这块空间的指针。到目前为止没有任何问题。但是一旦出了test()函数,局部变量n就离开的作用域了,它的生命周期也就结束了,也就是存放它的那块空间已经归还给内存了,这块原本属于n的空间已经不再属于n了。这块空间的指针确实是表面上传给了main()函数中的指针变量p,但是这块空间实际存放的是谁已经不知道了,有可能确实是100,但绝大概率不会是100。也就是说指针变量p本质上指向了一个随机的空间,这时也称指针变量p是野指针。
4.如何规避野指针
针对上述野指针的成因,我们将规避野指针的办法记为以下四种:
①对指针变量进行初始化。如果不知道指针指向哪或后续有别的用处,请先将指针置为空指针(NULL)!
②小心指针越界。例如访问数组时要注意数组的长度。
③指针变量不再使用时要及时置为空指针(NULL);指针使用之前检查其有效性
④避免返回局部变量的地址。
七、assert()断言
断言是一种编程中用于调试的机制,用于验证某个条件是否为真。如果断言失败,即条件为假,程序通常会终止运行,并提供错误信息。断言主要用于确保程序在继续执行之前满足某些关键的前提条件。
在C/C++中,<assert.h>头文件定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。用法如下:
void assert (int expession);
例如下述代码在程序运行到这一行语句时,就会判断变量p是否等于NULL。如果确实不等于NULL 程序会继续运行;否则就会终止运行,并且给出报错信息提示。
assert(p != NULL);
assert()宏接受一个表达式作为参数。如果该表达式为真(返回值为非零),assert()不会产生任何作用,程序会继续运行;如果该表达式为假(返回值为零),assert()就会报错,在标准错误流stderr中写入一条错误信息,显示没有通过的表达式 ,以及包含这个表达式的文件名和行号。
assert()的使用对程序员是非常友好的,使用assert()有几个好处:它不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭assert()的机制。如果已经确认程序没有问题,不需要再做断言,就在#include语句的前面,定义一个宏NDEBUG:
#define NDEBUG
#include <stdio.h>
写入这个宏定义后重新编译程序,编译器就会禁用文件中所有的assert()语句。如果程序又出现问题,可以移除这条#define NDBUG指令(或者把它注释掉)再次编译,这样就重新启用了assert()语 句。
assert()的缺点是因为引入了额外的检查增加了程序的运行时间。
一般我们可以在Debug版本中使用,在Release版本中选择禁用assert()。在VS这样的集成开发环境下的Release版本中,assert()会直接被优化掉。这样在Debug版本中更有利于程序员排查问题, 并且在Release版本不影响用户使用时程序的效率。
八、指针的使用和传址调用
学习指针的目的是使用指针解决问题,那什么问题是非指针不可呢?
例如我们要写一个函数来交换两个整型变量的值
一番思考后我们可能写出这样的代码:
cpp
#include <stdio.h>
void Swap1(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap1(a, b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
输出结果如下:
我们可以发现Swap1()函数并没有起到交换数字的作用。我们按F10键来监视一下这些变量的变化。当调试到scanf()这一行代码时,编译器就弹出输出窗口提示我们输入a和b的值,这里我们不妨输入4和5:
我们发现在main()函数内部创建了a和b,a的地址是0x00cffdd0,b的地址是0x00cffdc4,在调用Swap1()函数时,将a和b传递给了Swap1()函数,在Swap1()函数内部创建了形参x和y分别接收a和b的值,但是x的地址是0x00cffcec,y的地址是0x00cffcf0,x和y确实接收到了a和b的值,不过x的地址和a的地址不一样,y的地址和b的地址不一样,这就相当于x和y是独立于a和b的另外两块空间,那么在Swap1()函数内部交换x和y的值,自然不会影响a和b的值。当Swap1()函数调用结束后回到main()函数,a和b的值实际上并没有交换。Swap1()函数在使用的时候,是把变量本身 直接传递给了函数,这种调用函数的方式我们之前讲解函数的时候C语言:函数-CSDN博客就知道了,这种方式叫传值调用。
结论:
实参在传递给形参时,形参会单独创建一份临时空间来接收实参,所以对修改形参并不会影响实参
那该怎么办呢?
我们现在要解决的就是当调用Swap2()函数的时候,Swap2()函数内部操作的就是main()函数中的a和b,然后将a和b的值交换。那么我们就要用到指针了。在main()函数中将a和b的地址传递给Swap()函数,Swap2()函数里通过地址间接的操作main()函数中的a和b,达到交换值的效果。
Swa2p()函数的形参就是要分别接收main()函数中的x和y的地址,所以形参的类型是int*。我们平常在交换两个变量的值时会创建一个中间变量便于我们进行值的交换,那么在Swap2()函数中也要有中间变量来接收其中一个变量的值便于我们后续进行数值的交换。由于Swap2()函数只是起到交换数值的作用,所以它的返回类型是void。这里就不再过多的做解释了,直接上代码:
cpp
#include <stdio.h>
void Swap2(int*px, int*py)
{
int tmp = 0;
tmp = *px;
*px = *py;
*py = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap2(&a, &b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
结果:
我们可以看到Swap2()函数顺利的将a和b的值进行交换,这里调用Swap2()函数时是将变量a和b的地址传递给了函数 ,这种函数调用方式叫传址调用。
传址调用可以让函数和main()函数之间建立真正的联系------在函数内部可以修改主调函数中的变量;所以未来函数中只是需要main()函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。
完