深入理解指针
- 内存和地址
- 内存
- 究竟该如何理解编址呢?
- 指针变量和地址
- 取地址操作符(&)
- 指针变量和解引用操作符(*)
- 指针变量
- 如何拆解指针类型
- 解引用操作符
- 指针变量的大小
- 指针变量类型的意义
- 指针的解引用
- 指针+-整数
- void* 指针
- const修饰指针
- const修饰变量
- const修饰指针变量
- 指针运算
- 指针+-整数
- 指针-指针
- 指针的关系运算
- 野指针
- 野指针的成因
- 指针未初始化
- 指针越界访问
- 指针指向的空间释放
- 如何规避野指针
- 指针初始化
- 小心指针越界
- 避免返回局部变量的地址
- 指针变量不再使用,及时置NULL,指针使用之前检查有效性
- assert断言
- 指针的使用和传址调用
- strlen的模拟实现
- 传值调用和传址调用
内存和地址
只要讲指针就离不开内存,因为指针就是用来访问内存的。
内存
在学习内存和地址之前,我们有个生活中的案例:
假设有一栋宿舍楼,把你放在楼里,楼上有100个房间,但是房间没有编号,你的一个朋友来找你玩,若想找到你,就得挨个房间去找,这样就很难找到,并且效率很低。
但是如果我们根据楼层和楼层房间的情况,给每个房间编上号,如:
一楼:101,102,103...
二楼:210,202,203...
...
有了房间号之后,提升了效率,就可以快速定位到位置找到房间
如果把上面的例子对照到计算机中,又是怎么样呢?
我们知道计算机上CPU(中央处理器)在处理数据的时候,需要的数据是从内存中读取的,处理后的数据也会放回内存中。
电脑中内存有:8GB/16GB/32GB等,那这些内存空间如何高效的管理呢?
计算机中的内存管理与我们现实生活中对房间的管理是一模一样的,也就是把内存划分为一个个的内存单元,每个内存单元的大小取1字节,1字节为8比特位
计算机中常见的单位(补充):
一个比特位可以储存一个2进制的位(0或1)
bit——比特位
Byte——字节
KB
MB
GB
TB
PB
1Byte = 8bit
1KB = 1024byte
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB
1PB = 1024TB
其实,每个内存单元,相当于一个学生宿舍,一个字节空间里面能放8个比特位,就好比同学们住的八人间,每一个人是一个比特位。
每个内存单元也都有一个编号(这个编号就相当于宿舍房间的门牌号),有了这个内存单元的编号,CPU就可以快速找到一个内存空间。
生活中我们把门牌号叫做地址,在计算机中我们把内存单元的编号也称为地址。
C语言中给地址起了新的名字,叫:指针。
所以我们可以理解为:
内存单元的编号==地址==指针
究竟该如何理解编址呢?
就是CPU到底是如何从内存中拿到数据,又是怎么将它放回去的呢?
首先,必须得理解,计算机内是有很多的硬件单元,而硬件单元是要互相协同工作的,所谓的协同,至少相互之间要能够进行数据传递。
但是硬件与硬件之间是互相独立的,那么如何通信呢?
答:很简单,用“线”连起来。
因为CPU和内存之间有大量的数据交互,所以,两者之间必然有线:
当我们要进行读的操作的大概思路是:
首先控制线发出进行读的操作,地址总线就根据地址在内存中找到需要读的那块内存单元空间,数据拿到之后通过数据线送到CPU中
所以当CPU在访问内存中的某个字节空间时,必须得知道这个字节空间在内存中的什么位置,并且内存中的字节很多,所以需要给内存进行编址(就如同宿舍很多,需要给宿舍编号⼀样)。
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。
就像吉他的每根弦上没有写上“都瑞咪发嗦啦”这样的信息,但演奏者照样能够准确弹对音,这是因为制造商已经在乐器硬件上面设计好了,弹奏者也都知道,这是个共识,双方都知道。
编址也是如此,计算机硬件已经将它设计好了
我们可以简单理解:32位机器有32根地址总线,1根线就是一个比特位,32根线就有32个比特位。
且一根线只有两态,分别为0或1(电脉冲的有无)。
1根线有两种形态(0、1),2根线就有四种形态(00、01、10、11),一共有32根地址线,所以一共就有2^32种形态,也就是一共有2^32个地址。
即:地址是由硬件设计的。
地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据通过数据总线传入CPU内存寄存器。
所以当要去读某块数据的时候,通过地址线这个物理的线,把信号传给内存,表示要读这个地址的数据,拿到数据后通过数据线传回来。
所以不需要将内存编号存起来。
指针变量和地址
取地址操作符(&)
理解了内存和地址的关系,我们再回到C语言,在C语言中创建变量其实就是向内存申请空间。就像有一本书,如果要把书放在书架上,首先得保证书架上有空间可以放这本书。
比如:
int main()
{int a=10;return 0;
}
上述的代码就是创建了整型变量a,向内存申请了4个字节(因为变量a的类型为整型),用于存放整数10
所以创建变量的本质是向内存申请一块空间
我们可以看到,其实每个字节都有地址编号。所以,我们通过调试的方式来观察一下:
- 我们按F10进入调试
- 再按F11使
a
完成创建后打开内存观察 - 在地址栏写
&a
- 地址出来后不方便查看
设置为一行显示一列:此时再观察:
当我们知道了a
变量占4个字节,那我们该怎样拿到a
变量的地址呢?
答:这时候就要用到取地址操作符:&
那么我们就用它对a
进行取地址,看能不能将它的地址打印出来。
但是取的地址是4个字节中的其中的1个字节的地址,而不是4个字节的地址。并且这个地址是4个字节中地址较小的那个字节的地址。
即&a
取出的是a
所占4个字节中地址较小的字节的地址。
虽然只知道了第一个字节的地址,但是顺藤摸瓜访问到4个字节的数据也是可行的。
指针变量和解引用操作符(*)
指针变量
我们可以看到,我们通过取地址操作符(&)拿到的地址是一个数值。比如:000000FD919FFBE4。
这个数值有时候也是需要储存起来,方便后期再使用的,那我们把这样的地址存放在哪里呢?
答:指针变量中
比如:
int main()
{int a=10;p=&a;//将取出的地址放在指针变量p中return 0;
}
那么为什么这个p
叫指针变量呢?
答:我们说内存单元的编号就是地址,而地址就是指针(编号==地址==指针)
我们用&a
拿到了编号,也就相当于拿到了地址,也就相当于拿到了指针。
我们要把指针存起来放到p
这个变量里去,这个p
就叫指针变量了。
所以存放指针(地址/编号)的变量,就叫指针变量
指针变量也是⼀种变量,这种变量是专门用来存放地址的,存放在指针变量中的值都会被理解为地址。
那么此时我们搞清楚了什么是指针,什么又是指针变量了嘛?
指针——就是地址
指针变量——就是存放地址的变量当我们现实生活中口头语说:p是个指针的时候。意思就是p是个指针变量
当我们创建a
的时候,a
的类型是int
。当我们确定p
是个指针变量的时候,那么p
的类型该怎么写呢?
答案:p
的类型是int *
,所以上段代码完整写法:
int main()
{int a=10;int * p=&a;return 0;
}
如何拆解指针类型
我们该怎么去理解这个int*
呢?
所以我们看到的
int a=10;
int * p=&a;
- *是在说明p是指针变量
- int是在说明p指向的是对象是int类型的
假如一个char
类型的变量ch
,取出ch
的地址要放到pc
里面去,请问pc
的类型该怎么写呢?
char ch='w';
pc=&ch;//pc的类型该怎么写呢?
答:
char ch='w';
char * pc=&ch;
//*表示pc是个指针变量
//char表示pc指向的对象是char类型的
解引用操作符
我们将地址保存起来,未来是要使用的,那怎么使用呢?
在现实生活中,我们可以利用地址去找到一个房间,然后从房间里拿去或存放物品。
在C语言中其实也是一样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象(房间),然后进行数据的拿取或应用。
所以,这里必须学习一个操作符叫解引用操作符(*
)
int main()
{int a=10;int* p=&a;//现在取出a的地址放到指针变量p里面去了//p中存了a的地址后,就可以通过p找到a了//怎么找呢?//在p前面放一个*就可以了*p;// * ——解引用操作符(间接访问操作符)//*是解引用运算符,*p 表示访问 p 所指向的内存位置//p 所指向的内存位置是a的起始地址,所以*p就是areturn 0;
}
有没有理由说明*p
就是a
呢?
我们将0赋值给*p
,然后再将a
打印出来,看会出现什么情况
int main()
{int a=10;int * p=&a;*p=0;//通过p里面的地址找到a,也就是a=0printf("%d",a);return 0;
}
此时a
的值改为0 ,说明*p就是a
*p
就是表示访问 p
所指向的内存位置
*p==a==10
所以*p
其实就是a
,即:a=0
,这个操作就把a
重新赋值为0了.
我们对地址不仅仅是存,还会使用解引用操作符
*
(例如上段代码中的*p
)通过指针变量里存的地址,找到该地址的内存空间对应的变量,进行相应的操作。
取地址操作符(&
)与解引用操作符(*
)是一对。
用取地址操作符(&
)取出地址,再通过解引用操作符(*
)找回去,这样就像一来一回,在一定意义上可以互相抵消。
例如,上端代码还可以这样写:
int main()
{int a=10;//int * p=&a;//*p=0;*&a=0;//用取地址操作符取出a的地址//再通过解引用操作符找到这个地址,也就是aprintf("%d",a);return 0;
}
我们用代码运行起来看看效果:
所以这条语句相当于a=0
:
*&a=0; //a=0
//因为取出a的地址,再解引用,找到的就是a,最后就是a=0
这里如果就是想把a
改成0
的话,直接写成a=0
不就完了嘛,为什么非要使用指针呢?
因为这里其实是把a
的修改交给了p
来操作,这样对a的修改就多了一种途径,写代码就会更加灵活,后期就会慢慢理解。
指针变量的大小
在面前的内容中,我们为了存放a地址,所以创建了一个变量p用来存放a的地址。我们知道a是4个字节,那么p该占多大的空间呢?
答:我们知道,32位机器设有32根地址总线,我们把这32根地址线产生的2进制序列当做⼀个地址,所以一个地址是32个bit位,也就是需要4个字节(一字节为8比特)才能存储。
所以指针变量如果是用来存放地址的,那么指针变量的大小就得是4个字节的空间才可以。
指针变量——就是用来存放地址的
地址是怎么产生的?——地址线上传输的电信号转化为数字信号
32根地址线——就是32个0/1组成的二进制序列(每根地址线为一个bit位)
要储存这样的地址:就得有32个bit位的空间==4个字节
所以我们就说,在32位的平台上,一个指针变量的大小应该是4个字节。
同理64位机器,假设有64根地址线,⼀个地址就是64个⼆进制位组成的⼆进制序列,存储起来就需要8个字节的空间,指针变量大小就是8个字节。
注意:指针变量的大小是由地址长度说的算
现在我们用sizeof(计算数据类型的长度)
计算指针变量的大小:
所以不管是什么类型的指针,只要创建出来就是用来存放地址的,指针变量的大小只与地址的长度有关,跟类型无关。
地址长度与地址线的多少有关
我们再试试64位时的情况:
确实是8个字节
结论:
32位平台(x86)下地址是32个bit位,指针变量大小是4个字节
64位平台下地址是64个bit位,指针变量大小是8个字节
注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的
指针变量类型的意义
指针变量的大小和类型无关,只要是指针变量,在同一个平台下,大小都是一样的,那么为什么还要有各种各样的指针类型呢?
其实指针类型是有特殊意义的
指针的解引用
对比下面2段代码,主要在调试时观察内存的变化
int main()
{int a=0x11223344;//上面16进制数两个16进制位为一个字节//所以共为4个字节,刚好a的类型是int为4个字节return 0;
}
因为一个16进制位占4个2进制位,两个16进制位占8个二进制位,所以两个16进制位是一个字节
我们取出a
的地址,放进变量pa
里面去,再补些代码,我们调试看会发生什么效果呢
- 按F10,执行代码到取地址那条语句时,在内存中输入
&a
- 找到
a
的4个字节将列数设为4列,此时这一行就是a的4个字节,以便观察
- 继续按F10调试,当代码执行完
*pa
后观察内存
我们可以看到,当*pa
执行完后,4个字节全变为0了
我们再换另一种写法:
将int类型的指针,变为char类型的
我们发现,将int *
改为char *
后进行相同的操作,效果就发生了变化,此时只有一个字节变为0。
所以指针类型还是有用的,那么有什么用呢?
指针类型决定了指针进行解引用操作符的时候访问几个字节,也就是决定了指针的权限
整型指针进行解引用的时候能访问4个字节,因为整型指针指向的是整型。
而字符型指针骨子里就认为自己是一个指向字符的指针,进行解引用的时候就只访问一个字节。
这就是指针类型的意义
所以将来写代码要选择适当的指针:
例如:
从这个位置向后只是想访问一个字节,那么就用char
类型的指针;
从这个位置向后只是想访问两个字节,那么就用short
类型的指针;
从这个位置向后想访问四个字节的整型,那么就用int
类型的指针;
从这个位置向后想访问四个字节的浮点数,那么就用float
类型的指针。
指针±整数
先看一段代码:
int main()
{int a = 10;int* pa = &a;char* pc = &a;printf("pa=%p\n", pa);printf("pc=%p\n", pc);return 0;
}
此时pa
与pc
中存的都是a
的地址,所以打印出来的应该一模一样,我们运行程序看看结果:
确实一模一样。
我们改变一下代码,给pa
与pc
分别加上一个1会怎么样呢?
我们将它们对齐一些好观察:
我们发现pa
与pc
打印的结果还是相同的,都是a的地址。
而pa
与pa+1
之间多了4个字节
pc
与pc+1
之间多了1个字节
那么为什么会一个差了4个字节,一个差了1个字节呢?
答:因为pa
是int*
,pc
是char*
整型指针加1会跳过4个字节;字符指针加1,会跳过一个字节。
所以,指针类型决定了指针进行+1或-1操作的时候,一次跳过多少个字节。
int*+1
——加4个字节(整型的大小)
char*+1
——加1个字节(字符型的大小)
void* 指针
在指针类型中有一种特殊的类型是void*
类型的,可以理解为无具体类型的指针(或者叫泛型指针),这种类型的指针可以用来接受任意类型的地址,但是也有局限性:void*
类型的指针不能直接进行指针的+-
整数,以及解引用的运算。
指针类型:
char*
——指向字符的指针short*
——指向短整型的指针int*
——指向整型的指针float*
——指向单精度浮点型的指针- …
void*
——无具体类型的指针
举例:
我们可以将这段代码的char*
改回int*
,就不会报警告。
但还有一种办法,将char*
改为void*
:
此时编译器也不会报警告了,所以改为void*
也可以接收整型的地址。
再如,我们再加一个变量f:
为什么后面加了f?
在 C 语言中,不加 “f” 后缀的浮点数常量(如 0.0f)默认是双精度浮点数(double 类型)。当把一个双精度浮点数赋值给一个单精度浮点数变量(float f
)时,会发生隐式类型转换。
加上 “f” 后缀(如 0.0f)就明确地告诉编译器这个常量是单精度浮点数,这样在赋值给单精度浮点数变量时,就不会出现从双精度到单精度的隐式转换,使代码的意图更加清晰。
上面将理应是字符型指针变量的地址放在泛型指针里依旧没有任何问题。
此时被定义为泛型指针的p
就像个垃圾桶一样,什么类型的指针往里面放。
所以当什么情况下用这个泛型指针呢?
当我们传地址的时候,既可能传整型的地址,又有可能传浮点型的地址,也有可能传其他类型的地址,反正不确定、或是不是固定某一种类型的地址的时候,就用void*
类型的指针去接收。
优点:什么类型的地址都可以接收;
局限性:不能直接进行指针的+-
整数,以及解引用的运算。
比如:
这里void*
类型的指针变量去解引用了,编译器就报错了。
为什么非法的进行寻址了呢?
因为这个p
是无具体类型的指针变量,无具体类型的话那么解引用的时候应该访问几个字节呢?一个字节,两个字节,还是四个字节呢?所以void*
类型的指针不能解引用运算。
再例如:
此时编译器又报错了:void*
未知大小
什么意思呢?
意思就是这个指针变量无具体的类型,+1到底跳过几个字节呢?这个是不确定的,所以void*
类型的指针也不能进行+-
整数的运算。
一般
void*
类型的指针是使用在函数参数的部分,用来接收不同类型数据的地址,这样的设计可以实现泛型编程的效果,使得一个函数来处理多种类型的数据。
const修饰指针
const修饰变量
变量是可以修改的,如果把变量的地址交给一个指针变量,通过指针变量的也可以修改这个变量。
但是如果我们希望一个变量加上一些限制,不能被修改,该怎么做呢?这就是const的作用。
我们写段代码,将变量a
从10改为20,然后打印出来
当我们不想让a
改变,有什么办法呢?
我们在变量前面加上const
修饰后再运行就报错了,报错说:表达式必须是可修改的左值。
就说明a
被const
修饰后就不能被修改了。
int main()
{int a=10;a=20;//a是可以被修改的//改为:const int a=10;a=20;//a是不能被修改的
}
所以我们说:
被const
修饰后a
具有了常属性(常量的属性,不能被修改的属性)
int main()
{const int a = 10;//a具有了常属性(不能被修改了)a = 20;printf("%d\n",a);return 0;
}
a
具有了常属性,那么它是不是个常量呢?
我们写代码证明一下是不是个常量:
我们写个一维数组,因为数组的方块里是需要放个常量的:
int main()
{int arr[10];
}
所以我们将代码改一下来验证a
是否为常量:
int main()
{const int a = 10;int arr[a];printf("%d\n",a);return 0;
}
运行起来后发现编译器报错:“应输入常量表达式”,那就说明此时此刻的a
并不是个常量。
所以a
虽然被const
修饰后变得有了常属性(不能被修改),但是本质上还是一个变量。
所以我们把a
叫做常变量——本质上是个变量,但是又像常量一样不能被修改,具有常属性,就叫常变量。
但是在C++中,const修饰的变量就是常量,不是常变量。
而在C语言中,const修饰的变量是常变量,本质上还是一个变量,只是具有了常属性。
注意:这是计算机语言上的差异,不是编译器不同的问题。
我们从上面的内容知道,被const
修饰后就修改不了了,那么我们是不是可以先把地址取出来,把地址存到一个指针变量中,然后通过解引用操作符来修改呢?
根据打印出来的结果,我们发现a
被成功修改了
而我们加const
的本质需求就是让a
不能被修改,如果p拿到a的地址就能修改a,这样就打破了const
的限制,这是不合理的。所以我们应该要让p即使拿到a的地址也不能修改a,所以我们接下来要学习const修饰指针变量
const修饰指针变量
一般来讲const
修饰指针变量,可以放在*
的左边,也可以放在*
的右边,意义是不一样的。
int * p;//没有const修饰
int const * p;//const放在*的左边做修饰
int * const p;//const放在*的右边做修饰
举例:
我们写段代码将a的地址赋值给被const修饰的指针变量p,编译器运行后没问题,意味着a的地址成功给p了
我们再做些修改:
将b的地址再赋值给指针变量p,看能不能将p修改
运行后编译器报错,说明p是不可修改的值。
所以被const修饰后,p不可被修改,变为具有常属性的变量了。
既然p不能被修改,那么我们能不能通过p将a的值改掉呢?
成功打印。
所以,const
放在*
右边的时候,限制的是变量本身,指针变量不能再指向其他变量了,但是可以通过指针变量修改指针变量指向的内容。
也就是p不能再变了,*p的内容是可以变的
那么我们将const
放到*
的左边呢?
当我们将const
放到*
的左边时,再将b的地址放进p中,发现编译的时候并没有问题,编译器没报错。
所以当const
放到*
的左边时并没有限制p,p中的地址(p指向的对象)是可以被改变的。
那么我们使用解引用操作符将a改为100看行不行:
此时编译器却不报错了,所以这种写法是错误的。
所以:const
修饰指针变量时,放在*
的左边,限制的是:指针指向的内容(a的值)不能通过指针来修改(不能通过*p来修改),但是可以修改指针变量本身的值(修改的是指针变量的指向)
那么我们怎么判断const放在左边还是右边呢?
根据具体的代码情况:
如果想限制指针变量本身(地址)不能被改变,只改变指针变量指向的内容,就放在右边;
如果想限制指针指向的内容不能被改变,只改变指针变量本身(地址),就放在左边。
即想限制p就放在右边,想限制*p就放在左边。
并且,可以左右两边同时加上const,代表着既不能将地址修改,也不能修改指针变量指向的内容:
结论:const修饰指针变量的时候
cons
t如果放在*
的左边(int const * p
),修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。但是指针变量本身的内容(指针变量里存的地址)可变。const
如果放在*
的右边(int * const p
),修饰的是指针变量本身,保证了指针变量的内容(指针变量里存的地址)不能修改,但是指针指向的内容,可以通过指针改变。
指针运算
指针的作用就是访问内存的,那么怎么能更好的访问内存呢?
这时候就要依赖于指针的各种运算。
指针的基本运算有三种,分别是:
- 指针±整数
- 指针-指针
- 指针的关系运算
指针±整数
我们前面其实已经讲了一点指针±整数:
int a=10;
int* p=&a;指针+1:
p+1——会跳过4个字节——其实就是:1*sizeof(int)跳过的是一个整型的大小
所以,我们给个通式,type* p
type* p;
p+1——其实是跳过1*sizeof(type)跳过的是一个type的大小
当type
是char
类型的时候
char* p;
p+1——其实是跳过1*sizeof(char)的大小
那么p+n
呢?
type* p;
p+1——跳过 1*sizeof(type) 的大小 p+n——跳过 n*sizeof(type) 的大小
注意:指针+1是向后跳某个字节,-1则是向前走几个字节。
那么指针±整数有什么意义呢?
比如说,在数组里:
我们写段代码创建一个数组,再将数组里的元素都打印出来:
这段代码我们是通过下标访问来打印的,那么通过指针的方式打印该怎样写呢?
我们知道,数组在内存中是连续存放的
只要知道起始元素的地址,就能通过计算得到后面所有元素的地址,从而访问到所有元素。
我们首先得对第一个元素取地址,得到起始地址。再将这个地址放进指针变量中存起来,因为这个数组是整型数组,所以第一个整形元素的地址应该放进整型指针变量p中(int* p
)。
我们对这个整型指针进行解引用* p
,就可以通过p中存的地址找到该元素,然后打印出来。
第一个元素打印出来后让指针+1,起始地址就可以直接跳4个字节,到第二个元素的地址,再通过解引用操作符找到第二个元素,将它打印出来。
以此类推进行10次就行了。
完整代码:
我们还有另外一种实现方式:
从起始地址+1就向后跳一个整形到第二个元素了;
从起始地址+2就向后跳两个整形到第三个元素了;
从起始地址+3就向后跳三个整形到第四个元素了;
…
所以在我们写代码的时候,就可以将需要加的数当i
因为i
从0开始,一直到9
完整代码:
注意:不能写成
*p+i
因为*
的优先级高,会先算*p
再+i
,此时就变成了解引用后的值加i了。
所以主要要加括号,先算p+i
再解引用
这两段代码的不同点在:
第一种写法是p在移动,而第二种写法p没有移动,是在p的基础上加i,i在变化。
指针-指针
我们知道,日期加天数还是个日期
日期+天数=日期
那么日期减去天数也为一个日期
日期-天数=日期
而两个日期相减的话,得到的就是两个日期中间差的天数了
日期-日期=天数
同理,我们知道:一个指针,加上一个整数,就会得到一个新的指针。
指针1+整数 = 指针2
可得出:
整数=指针2-指针1
就如上题中p+4
-p
=4
所以
日期-日期=中间的天数
指针-指针=两个指针之间的元素个数
注意,没有日期+日期这种操作,例如:2月3日+3月3日。
所以也没有指针加上指针这种操作。
接下来我们测试一下:两指针相减是否真为两指针之间的元素个数。
根据图来看,这两个指针之间确实有9个元素。
数组随着下标的增长,地址是由低到高变化的。
此时是大的地址减去小的地址,我们试试小的地址减去大的地址,会得出什么呢?
发现是-9
,所以我们上面的结论不够严谨。应该是:
两指针相减结果的绝对值是两指针之间的元素个数。
注意:指针-指针的计算前提条件一定是:两个指针指向了同一块空间。
这种情况是不行的:
int main()
{int arr[10]={0};char ch[5]={0};printf("%d\n",&ch[4]-&arr[6]);//errreturn 0;
}
因为这两个指针根本没有指向同一块空间,这种写法是错误的。
我们之前学过strlen
求字符串长度,它统计的是字符串中\0
之前的字符个数。
我们写段代码:
这段代码用strlen
成功打印了\0
之前的字符个数,那么我们现在想模拟实现my_strlen
这个函数,该怎么办呢?
代码实现:
还有另外一种写法:如果我们能得到第一个元素a
的地址以及最后的\0
的地址时,指针-指针(大地址-小地址)不就是中间元素的个数嘛?
在循环中str
的地址从起始地址一直加1,当地址变为\0
的地址时会跳出循环,此时str
刚好指向\0
的地址。
指针的关系运算
指针的关系运算就是指针和指针比较大小,其实就是地址和地址比较大小。
接下来我们写段代码将下列数组元素全部打印出来:
当我们拿到首元素地址的时候,放进指针变量p中,然后通过指针+整数遍历整个数组,打印出数组里的所有元素。
所以
-
p
不断的往后加1,遍历打印元素。当到小于最后那个箭头时停止。
那么最后一个箭头的地址该怎么表示呢?
我们发现,当p跳过数组的所有元素刚好指向最后箭头的那个地址,所以用sizeof(arr)/sizeof(arr[0])
求出要跳的元素,再p+10
就可以到最后一个箭头的位置了。
从起始地址开始遍历,遍历到p
变为p+9
时,打印出了*(p+9)
的元素10
,接着p+10
结束循环。 -
或是
p<=&arr[9]
(倒数第二个箭头那个地址),也可以将全部元素打印出来。
p的地址一直遍历,遍历到与下标为9的元素地址相同时,将最后一个元素打印出来后就停止循环。
野指针
概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、或是没有明确限制的)
指针指向内存,原本是有明确指向的,如果一个指针没有明确的指向,就叫野指针。
野指针的成因
指针未初始化
指针变量未初始化的时候就是一个野指针。
代码中正确写法:
指针p
未初始化的写法:
此时p
未初始化,并不知道指向哪里(哪块空间)。
此时p
是一个局部变量,一个局部变量不初始化的话,默认存的是随机值
所以当p
没初始化的时候,这个指针里面存的是一个随机值的地址。
当我们在下面使用p
的时候,就把这个随机值地址对应的空间变为20了。可这块空间并不应该是p
的,所以就形成了非法访问。
指针越界访问
我们写段代码感受一下:
这段代码中数组只有10个元素,但是总共循环了11次
当指针指向的范围超出数组arr的范围时,p就是野指针
数组越界也是有可能造成野指针问题的。
指针指向的空间释放
举例:
注意:原本给a创建的内存空间其实一直在内存里,并没有消失(内存空间不会消失),只是出了test函数后不属于我们了,还给操作系统后我们没有访问权限了。
就像在酒店定了个房间,第二天退房了,没有使用权限了,就算是知道房间的地址也用不了了(但是这个酒店的房间是一直在的)
如何规避野指针
指针初始化
-
明确知道指针应该指向哪里,就给初始化一个明确的地址
-
如果不知道指针应该指向哪里,那就初始化为
NULL
.
NULL
是C语言中定义的一个标识符常量,值是0,0也是地址。
当我们给p1
初始化后可以直接使用:
那我们给p2
初始化为空指针时,这个空指针可以使用嘛?
当我们将100赋值给p2
这种写法是错误的,虽然0也是地址,但是这个地址是无法使用的,读写该地址会报错,所以并不是所有的地址都是可以使用的。(有些空间只能系统内核使用,我们的用户程序并不能使用,0这个地址就是分给系统内核了)
那既然0这个地址不能使用,那么我们不知道给指针初始化为什么的时候,还依旧给它赋值为空指针呢?
因为这种写法虽然不能直接去访问p2
,但是一旦p2
被初始化为空指针时,就会被标识起来,让我们注意这是个空指针,不要像*p2=20
这样去使用这个指针。
被标识为空指针后的意思就是不要再去使用这个指针了
小心指针越界
一个程序向内存申请了哪些空间,通过指针也就只能访问那些空间,不能超出范围访问,超出了就是越界访问。
避免返回局部变量的地址
如造成野指针的第3个例子,不要返回局部变量的地址。
指针变量不再使用,及时置NULL,指针使用之前检查有效性
当指针变量指向⼀块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL。
因为约定俗成的⼀个规则就是:只要是NULL指针就不去访问,所以使用指针之前可以先判断指针是否为NULL。
指针要么有明确的指向,要么就是空指针,在我们使用指针之前先判断是否为空指针,不为空指针再去使用它。
assert断言
assert
其实是assert.h
这个头文件所定义的宏assert()
。
用于在运行时确保程序符合指定的条件,如果不符合,就报错终止运行,这个宏常常被称为“断言”。
assert(p != NULL);
上面代码在程序运行到这行语句时,会验证变量p
是否等于NULL
。
如果确实不等于NULL
,程序就继续运行。
如果变量p
等于NULL
,就会终止运行,并且给出报错信息提示。
在这个断言的括号里加条件,如果为真,那么什么也不会发生,程序继续运行。
如果为假,程序终止运行,并且给出报错信息提示。
assert()
宏接受一个表达式作为参数,如果该表达式为真(返回值非零),assert()
不会产生任何作用,程序继续运行,如果该表达式为假(返回值为零),assert()
就会报错,在标准错误流(屏幕上)stderr
中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
我们写段代码,使表达式为假:
报错显示的错误信息:
当我们让表达式为真:
发现都没任何问题。
我们再改下代码:
此时又会报错。
注意:断言不仅仅只能断言指针
我们写段代码,输入大于3的5,没有任何问题
当我们输入小于3的2时就会报错
所以断言里只要是个表达式就行,不一定非要指针。
当然,我们也可以用if
语句
但assert()
的使用对程序员是非常友好的
- 出现错误时,能自动标识文件和出问题的行号
- 还有一种无需更改代码就能开启或关闭
assert()
的机制:如果已经确定程序没有问题,不需要再做断言,就在#include<assert.h>
语句的前面,定义一个宏NDEBUG
定义了宏后,尽管输入了小于3的2,也不会触发断言报错。
此时编译器禁用了文件中所有的assert()
语句。
而在我们确定程序没有问题,无需再做断言时用的是if
语句的话,就必须得删除if
语句的相关代码,断言则无需修改代码。
如果程序又出现问题,想开启assert()
语句的话,可以移除这条#define NDEBUG
指令(或者把它注释掉)。再次编译,就重启了assert()
语句。
assert()
的缺点是:因为引入了额外的检查,增加了程序的运行时间。
所以,在VS这样的集成开发环境中,在Debug
版本中正常使用,它有利于程序员排查问题。但在Release
版本中默认是关掉的,这样不影响用户使用时程序的效率。
指针的使用和传址调用
strlen的模拟实现
库函数strlen
的功能是求字符串的长度,统计的是字符串\0
之前的字符的个数。
如果要模拟实现只要从起始地址开始向后逐个字符的遍历,只要不是\0
字符,计数器就+1,这样直到\0
就停止。
我们将上面的代码重新写一遍,写得更专业一点:
先把框架写出来:
int main()
{char arr[] = "abcdef";int len = my_strlen(arr);printf("%d\n",len);return 0;
}
开始模拟实现从起始地址开始向后逐个字符的遍历(\0之前):
在遍历时我们势必会对指针进行解引用,所以首先得防止传个空指针这种情况:
所以我们要断言一下指针
我们在求字符串的长度的时候也不希望字符串被修改,所以在*
左边加上const
来修饰指针,此时限制的就是str
指向的内容。
加上后就不能通过*str
改变字符串的内容了,如果想通过*str
改变字符串的内容就会报错,这样写代码就非常抗打,若有人修改了字符串的内容,就会报错:修改后的代码:
我们打开cplus,搜索strlen
函数,发现它本身的返回类型是size_t
类型为什么它本身的返回类型是
size_t
类型的呢?
因为它是用来求字符串的长度的,而长度不可能是负数,所以返回size_t
类型——无符号的整形
所以我们再将代码修改一下:
传值调用和传址调用
学习指针的目的是使用指针解决问题,那么是什么样的问题非指针不可呢?
例如:写一个函数,交换两个整形变量的值
我们打印出来看有没有实现交换:
我们发现并没有实现交换,这是为什么呢?
我们发现在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是独立的空间,那么在Swap1函数内部交换了的x和y的值,自然不会影响a和b。所以,当Swap1函数调用结束后回到main函数,a和b没法交换。
结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实参。
所以Swap1是失败的。
Swap1函数在使用的时候,是把变量本身(a、b)直接传递给了函数,这种调用函数的方式我们之前在函数的时候就知道了,这种叫传值调用。
a、b与x、y没有任何关系,所以改变x、y不会影响到a、b。那么我们该怎么解决这个问题呢?
用指针。既然a、b传过去产生不了联系,那么将a、b的地址传过去呢?
通过指针让Swap
函数内部与主函数建立联系
通过指针找到主函数中的值,再创建一个变量,基于第三个变量,在Swap2
函数中实现交换。
这里调用Swap2
函数的时候是将变量的地址传递给了函数,这种函数调用方式叫:传址调用。
传址调用,可以让函数(Swap2
)和主调函数(main
)之间建立真正的联系,在函数内部可以修改主调函数中的变量。
所以,未来函数中只是需要主调函数中的变量值来实现计算的话,只需要采用传值调用。
如果函数内部要修改主调函数中的变量的值,则就需要传址调用。