【C语言】指针1
1、内存和地址
在学习内存和地址前,我们讲一个生活中的案例:
我们在入学的时候都需要找到自己的宿舍,但是现在你所在的宿舍楼,有一百个房间,但是 每个房间都没有编号,而且每个宿舍都是一样的,只是在每个宿舍的门口贴了这个宿舍住的 人,那么我们要想找到我们是那个宿舍的是不是就只能一个一个找了,直到找到为止。这样 的效率就很低了。但是如果我们给每个宿舍都给上编号,然后和你说你的宿舍是那个编号那 么就不用一个一个找了。
将上面的例子和计算机中对照起来是啥样子的?
我们知道计算机上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的, 处理后的数据也会放回内存中,我们在买电脑的时候,电脑上的内存是8G/16G/32G等,那这 些内存如何高效管理呢?
1、内存
其实也是将内存分为一个一个的内存单元,每个内存单元取一个字节。
下面为计算机中常用的内存单位:
1、bit—比特位 :一个比特位可以存一个二进制的位0或1
2、Byte—字节 1Byte = 8bit
3、KB 1KB = 1024Byte
4、MB 1MB = 1024KB
5、GB 1GB = 1024MB
6、TB 1TB = 1024GB
7、PB 1PB = 1024TB
其中。每个内存单元,相当于一个学生宿舍,一个字节空间里面可以放八个比特位,就好比 如一个宿舍住八个人,每个人是一个比特位。
每个内存单元也有其编号(这个编号就相当于我们每个宿舍的门牌号),有了这个内存单元 的编号,CPU就可以快速找到这个内存空间。
生活中我们把门牌号也叫做地址,在计算机中我们将内存单元的编号也称为地址。在C语言中 我们给地址起了一个新的名字-------指针,我们可以理解为:内存的编号=地址=指针
2、对于编址的理解
首先计算机是由很多硬件单元组成的,硬件单元是要互相协同工作的。而所谓的协同工作,其 之间至少能够进行数据传递。
而硬件与硬件之间是相互独立的,那么其是如何进行信息的传递的呢?
硬件与硬件间是通过" 线 "连接起来的。也叫做地址总线。
CPU在访问某个内存中的某个字节空间的时候,必须要知道这个这个字节空间在内存的什 么位置,而因为内存中字节是非常多的,所以需要给其进行编址(和宿舍号一样给个编号)
计算机中的编址不是把没个字节的地址都记录下来,而是通过硬件设计完成。
我们可以简单理解,32位机器有32根地址总线, 每根线只有两态,表⽰0,1【电脉冲有⽆】, 那么 ⼀根线,就能表⽰2种含义,2根线就能表⽰4种含 义,依次类推。32根地址线,就能表⽰ 2^32种含 义,每⼀种含义都代表⼀个地址。 地址信息被下达给内存,在内存上,就可以找到 该地址对应的数据,将数据在通过数据总线传⼊ CPU内寄存器。
2、指针变量和地址
1、取地址操作符&
理解了内存和地址的关系,我们再回到C语言中,再C语言中创建变量其实就是向内存中申请空 间
例如:
上述代码就是创建了整型变量a,内存中申请了4个字节,用于存放整数10,其实每个字节都 有地址。
上图中的每个字节的地址就为:
0x009DFBFC 0x009DFBFD 0x009DFBFE 0x009DFBFF
那么我们改如何得到a的地址呢?
这里我们就需要使用到我们的取地址符号&了 。
&取出的地址是较小的那个字节的地址,我们知道了最小的那个地址,顺藤摸瓜也就可以得 到剩下三个字节的地址
2 指针变量
我们通过取地址符号(&)得到的地址是一个数值,这个数值有时候也是需要存起来的,为了 方便未来再使用。那么我们把这样的地址存放在上面地方呢?就存放在指针变量中。如下:
指针变量也是一种变量,这种变量就是用来存放地址的,存放在指针中的值都会理解为地址。
指针变量也是分类型的,那么其类型是咋样的呢,下面我们对指针类型进行分析:
在上面的指针变量pa中,其有两部分组成其中int表明指针变量存放的是整型变量的地址,即其 指向的对象是整型类型的,*表明pa是指针变量。那么如果我们要指向一个字符类型的变量, 那么它的指针变量pc就如下书写:
char a = 10;
char * pc =&a;
和上面的整型变量的指针一样,char表明其指向的变量是char类型,*表明pc是一个指针变量。
3、解引用操作符(*)
我们将地址存储起来肯定是未来需要使用到的,那么我们要如何使用呢?
在现实生活中,我们可以通过地址找到自己的快递,找到自己的宿舍。
在C语言中也是如此,我们只要拿到了地址(指针),就可以通过地址(指针)找到指针指向的 对象,这里我们就需要使用到一个操作符、解引用操作符(*)
使用方法如下:
此时我们创建了一个整型变量b,其初始化为20,然后有一个指针变量pc指向变量b
我们如何通过指针变量pc,也就是变量b的地址去对b进行操作呢?这时候就得使用我们的解引 用操作符(*)使用方法如下:
在指针变量前加一个解引用操作符*,就可以通过指针变量存储的地址解引用,找到变量b, 然 后就可以对变量b进行操作了。如下:
可以看到b的值通过对其指针变量pc的操作就发生改变了。
4、指针变量的大小
前面我们提到,32位机器假设有32根地址总线,每根地址线出来的电信号转换成数字信号后 是1或者0,那么我们把32根地址线产生的2进制序列当做一个地址,那么一个地址就是32个bit 位,需要4个字节才可以存储。
如果指针变量是用来存放地址的,那么指针变量的大小就得是4个字节才可以。
同理在64位机器中,假设有64根地址总线,那么一个地址就是64个二进制位组成的二进制序 列,存储起来就需要8个字节的空间,指针变量的大小就应该为8个字节。
下面为不同环境下指针变量的大小:
在32位下:
在64位环境下:
结论:
1、32位平台下地址是32个bit位,指针变量为4个字节
2、64位平台下地址是64个bit位,指针变量为8个字节
3、注意:指针变量的大小和类型是无关的,只要指针类型的变量,在相同的环境下,大小都是 相同的
3、指针变量类型的意义
指针变量的大小和类型无关,只要是指针变量,,在同一环境下,大小都是一样的,那么为 什么还要有各种各样的指针类型呢?其实指针类型是有其特殊意义的。
1、指针的解引用
下面我们通过调试两个不同的指针变量来看他们的不同:
int型指针:
其调试为:
可以看到其通过*pi找到了n,然后将n的4个字节的内容都改成了0。
下面为char型的指针 :
下面为其调试:
可以看到这里的通过*pi只修改了一个字节,而且n是一个整型变量,应该是4个字节的空间就大 小的。那是因为指针变量的类型决定这个指针能够访问的空间,第一个是整型指针,所以可以 访问4个字节的空间,第二个是将整型指针强制转换为了char类型指针,所以只能访问一个字 节的空间,所以也只能修改一个字节的空间。
结论:指针的类型决定了,对u指针解引用的时候的权限有多大(一次可以操作的空间)。
比如:整型指针一次可以操作4个字节,而字符型指针一次只能操作1个字节。
2、指针+-整数
我们先看下面的一段代码和其运行结果:
运行结果:
可以看到int类型的指针+1后是往后走4个字。而char类型的指针往后走了1个字节。这就是指针 变量类型带来的差异。而且也可以-1这样子走。
结论:指针的类型决定了指针向前或者向后走一步的距离
3、void*指针
在指针类型中有一种特殊的类型是void*类型的,可以理解为无具体类型的指针(或者叫做泛 型指针)。这种类型的指针可以用来接受任意类型的指针。但其也有局限性,它不可以直接进 行指针的+-整数和解引用的运算。一般在对于不确定指针类型的时候使,或者需要的类型多 样。
如下例子:
在上面代码中,我们需要将一个int类型的变量的地址赋到一个char指针变量,这个时候编译器 就会给我们有一个警告,是因为类型不兼容。而使用void*类型就不会有这样的问题了。
下面为使用void类型指针:
在这个代码中,void指针可以接收到a的地址,但是由于void指针不可以解引用,那么我们就 无法通过void类型指针来对a的值进行修改。也就是void指针可以接收不同类型的地址,但是 无法直接进行指针运算。
那么void指针具体是干嘛用的呢
其一般是使用在函数参数部分用来接收不同数据类型的地址,这样可以实现泛型编程的效果。
4、const修饰指针
1、conat修饰变量
变量是可以修改的,如果我们希望一个变量加上一些限制,不能被修改,那么就可以使用 const来修饰这个变量,即在定义变量的时候在前面加上const,这样变量就不能被修改了。
例:
可以看到n我们在定义的时候用了const修饰,然后在对n进行修改的时候发现编译器进行了报 错。
但是如果我们在某种情况下一定要对进行修改的话,是不是没办法修改呢?
我们可以创建一个指针变量,将n的地址存进这个指针,然后通过解引用的方法对这个指针进 行修改,从而进可以修改这个n了。
如下:
但是我们用const修饰变量的初衷是为了让其不可以被修改,但是现在通过一个指针就使其值 给修改了,那我们要如何操作才可使其真正实现不可被修改呢? 下面我们就对const修饰指针 变量进行学习。
2、const修饰指针变量
对于const的使用有两种:一种是将const放在*的左边,一种是放在*的右边。
放在左边:
当const放在左边修饰变量的时候,就可以起到解引用不可以修改指向的变量的效果,此时的 const可以写在最左边,或者 写在类型的右边,*的左边,就是在*的左边即可。
如下:
可以看到代码中对于那个被const修饰的指针,其指向的变量就无法修改了。那么我们是否可以 修改指针变量呢?
此可以看到当const在*左边的时候,只能限制其指向的变量,但是不能限制指针变量。
放在右边:
限制的是指针变量,此时指针变量是不可以修改的,但是指针变量指向的变量是可以修改的。
如:
所以const放在*右边修饰指针变量的时候是限制这个指针变量无法给修改。
那我们需要其指针变量和变量都不可以个修改,我们该如何操作呢?此时我们可以在*两边都 加上const来修饰指针变量。
可以看到当我们对指针变量和指针边指向的变量都无法修改了。
结论:
const如果放在左边,修饰的是指针指向的内容,保证指针指向的数据无法通过指针来修改但 是指针变量自己是可以变化的。
const如果放在右边,那么修饰的是指针变量自己,保证指针变量的内容是不可以给改变的, 不过指针指向的内容还是可以通过指针来修改的。
如果需要这个指针变量本身不可以给修改,指针指向的变量也不可以给修改,那么我们可以 在指针变量定义的*的左右两边都加上const。
5、指针运算
指针的运算主要有以下三种:
1、指针+-整数
2、指针-指针
3、指针的关系运算
1、指针+-整数
因为数组在内存是连续存放的,只要知道第一个元素的地址,那么我们就库顺藤摸瓜找到后面 的所有元素
如上面的数组我们拿到第一个元素1的地址,然后对这个地址进行+1会是怎么样的结果呢?
因为这个数组是一个整型数组,那么其一个元素是占4个字节的空间,那么其+1就会往后走4 个字节的空间,到第二个元素的地址,下面我们通过代码来看其效果。
可以看到其打印的结果是第二个元素的结果了,那么我们也可以使用这个方法来对数组的元素 进行遍历。
2、指针-指针
我们使用这部分的知识来模拟实现前面我们所学习到的一个库函数strlen。我们通过这个例子来 学习指针-指针这部分的知识吧,这个函数的作用就是求一个字符串的长度,参数的话可以是一 个字符串也可以是一个数组。
下面我们自己模拟一个strlen函数,其大致思路就为:从第一个字符开始,直到碰到\0才结 束,然后统计\0前的字符个数,如果返回这个个数。那么我们的返回类型就应该为size_t类型 然后传入的参数应该为一个子字符串或者一个数组,然后我们需要的是从传入的参数的第一个 字符开始往后开遍历。那么我们这里可以是使用指针变量来接收这个字符串的地址或者数组的 首元素地址。然后数组名字就是首元素的地址,或者我们可以直接去取地址首元素。然后此时 我们的形参应该设置为char *类型。
下面为代码展示:
我们在函数中使用一个指针变量来接受传入的地址,然后在函数中使用循环,当这个p到\0的 时候就可以结束循环,那么我们可以让while的判断条件为p,这样当其往后走的时候到0就可 以结束了。 然后就可以用此时的p减去开始的时候的p就可以知道这个字符串的长度了,所以 我们在开始的时候就使用一个指针变量来存储这个地址了。所以我们最后使用p-start就是这个 长度了。
3、指针的关系运算
6、野指针
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
1、野指针成因
野指针的成因一般有三种
指针未初始化:
可以看到此时编译器就会给我们报错,提示我们指针变量未初始化,因为当指针变量未初始化 的时候,默认为随机值,那么也就不可以解引用。
指针越界访问
分析上面的代码可以发现,当i=10时,此时的p会变成指向数组的第11个元素但是我们的数组 不存在第11个元素,那么此时指针的位置也就不确定,此时的指针就越界访问了,这种也是野 指针。
指针的空间释放:
在上面的代码中,函数test返回的n地址,,但是n是函数中的一个局部变量,当函数执行完 后其地址就会给释放了,这就会导致给接收这个返回值的指针变量p指向的那个地址的空间 已经给释放了,那么其也就不可以进行解引用的操作的。
2、规避野指针的方法
指针初始化:
如果明确知道指针指向哪⾥就直接赋值地址,如果不知道指针应该指向哪⾥,可以给指针赋值 NULL。NULL 是C语⾔中定义的⼀个标识符常量,值是0,0也是地址,这个地址是⽆法使⽤ 的,读写该地址会报错。
⼩⼼指针越界:
⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出 了就是 越界访问。
指针变量不再使⽤时,及时置NULL:
指针使⽤之前检查有效性
当指针变量指向⼀块区域的时候,我们可以通过指针访问该区域,后期不再使⽤这个指针访问 空间的时候,我们可以把该指针置为NULL。因为约定俗成的⼀个规则就是:只要是NULL指针 就不去访问,同时使⽤指针之前可以判断指针是否为NULL
避免返回局部变量的地址
7、assert断⾔
assert.h头文件定义了宏assert()用在运行时确保程序符合指定条件,如果不符合就就报错 终止运行,这个宏常常被叫做“断言”。下面为其语法。
assert(p != NULL);
上⾯代码在程序运⾏到这⼀⾏语句时,验证变量 p 是否等于 NULL 。如果确实不等于 NULL 程序继续运⾏,否则就会终⽌运⾏,并且给出报错信息提⽰
assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值⾮零), assert() 不会产 ⽣任何作⽤,程序继续运⾏。如果该表达式为假(返回值为零), assert() 就会报错,在标 准错误流 stderr 中写⼊⼀条错误信息,显⽰没有通过的表达式,以及包含这个表达式的⽂件 名和⾏号
assert() 的使⽤对程序员是⾮常友好的,使⽤ assert() 有⼏个好处:它不仅能⾃动标识⽂件 和出问题的⾏号,还有⼀种⽆需更改代码就能开启或关闭 assert() 的机制。如果已经确认程 序没有问题,不需要再做断⾔,就在 #include <assert.h> 语句的前⾯,定义⼀个宏NDEBUG
如下所示:
然后,重新编译程序,编译器就会禁⽤⽂件中所有的 assert() 语句。如果程序⼜出现问题, 可以移除这条 #define NDEBUG 指令(或者把它注释掉),再次编译,这样就重新启⽤了 assert() 语句。
assert() 的缺点是,因为引⼊了额外的检查,增加了程序的运⾏时间。
⼀般我们可以在 Debug 中使⽤,在 Release 版本中选择禁⽤ assert 就⾏,在 VS 这样的集成 开发环境中,在 Release 版本中,直接就是优化掉了。这样在debug版本写有利于程序员排 查问题,在 Release 版本不影响⽤⼾使⽤时程序的效率
我们在前面写的strle函数的模拟实现中我们可以在函数中使用这个语句,判断其传入的地址是 不是有效的,即其传入的地址不可以为空地址,此时我们可以在函数的开始使用一个assert段 断言。
如下所示:
8、值传递和地址传递
学习指针的⽬的是使⽤指针解决问题,那什么问题,⾮指针不可呢?
例:写一个函数,交换两个整型变量的值。
在一番思考后我们会先写出下面的代码:
我们试试其运行结果:
我们发现这个函数并没有将这两个值进行交换,这是实参的值传给形参后,在函数的栈帧中 会创建一个属于a和b的空间,那么其是在a和b的地址上进行交换的,在函数结束后,这个函 数的栈帧也就销毁了,那么也就没将实参的值改变。
我们需要将main函数中的这两个值交换,那么咋办呢?
我们可以通过a和b的地址对其进行修改。即将其地址做为实参传给形参 。
如下代码;
运行结果:
我们可以看到实现成第二种的⽅式,顺利完成了任务,这⾥调⽤这个函数的时候是将变量 的地址传 递给了函数,这种函数调⽤⽅式叫:传址调⽤。 传址调⽤,可以让函数和主调函 数之间建⽴真正的联系,在函数内部可以修改主调函数中的变量;所 以未来函数中只是需要 主调函数中的变量值来实现计算,就可以采⽤传值调⽤。如果函数内部要修改 主调函数中的 变量的值,就需要传址调⽤。