C语言:深入理解指针(1)
✿༺小陈在拼命༻✿ 2024-06-23 12:35:01 阅读 77
创作不易,友友们给个三连吧!!
大家好,今天来给大家分享一下我对指针的理解
一、内存和地址
1.1 内存
学习内存之前,引用一下生活中的一个案例
假设你知道你的一个朋友住在一个小区,这个小区有10栋楼,每栋楼10个楼层,每个楼层又有10个房间,你想找到他就得挨个挨个房间去找,这样效率是非常低的,但如果根据这些楼层和楼层房间的情况,给每个房间编上号,比如说1号楼1楼第1个房间,编上1#101,以此类推,那么你的朋友得到了房间号并告诉你,你就能迅速地根据信息找到他所在的房间。
所以在生活中,一些小区、酒店通过设置了房间号,方便客人快速找到房间。
类比到计算机,我们知道计算机上有CPU(中央处理器),CPU需要通过该内存的存储信息(地址)去读取相应的内存,处理完之后再放回到内存中去。
那计算机是如何高效处理内存空间的呢?
其实就是把内存划分为一个个的内存单元,每个内存单元的大小的1个字节
1个比特位可以存储一个2进制的1或者0,所以每个内存单元里面可以放8个比特位,而这8个比特位的信息就相当于是这个内存单元的编号,有了这个内存单元的编号,CPU就能快速找到这块内存空间,计算机中我们把内存单元的编号称为地址,C语言又起了个新名字叫指针,所以可以理解成 内存单元的编号==地址==指针
1.2 深入理解计算机编址
1.1提到,CPU想要访问某个字节空间,就必须知道这个字节空间在内存的什么位置,因为计算机的字节空间是非常多的,所以需要对每个字节空间进行编址,计算机中的编址实际上是通过硬件设计去实现的
比方说,吉他上面有“都瑞咪发嗖拉西多”这样的信息,但是演奏者可以寻找到每一个琴弦的每一个位置,原因就是制造商已经在乐器硬件层面设计好了,并且所有的演奏者都知道。所以本质上是一种约定出来的共识。
类比到计算机,我们知道计算机有非常多的硬件单元,硬件单元在设计的时候,我们希望他能够互相之间协同工作,所谓的协同,至少可以做到对数据进行传输。但是硬件本身又是相互独立的,所以必须通过“线”来连接。
CPU作为计算机的中央处理器,需要和内存之间建立大量的数据交互,也是通过“线”去实现的。
我们可以这样去理解,比方说我的电脑是64位,每根线有两态,表示0,1(电脉冲有无)那么一根线就代表着2个含义(电信号转换成数字信号),64跟地址线就代表2^64种含义,每一种含义都代表了一个地址,而这些地址信息的总和,就是计算机种的内存,在内存上,我们可以找到该地址对应的数据,再将数据通过数据总线传入CPU内寄存器中进行处理。
对于地址总线、数据总线、控制总线我是这样去理解的:
控制总线:相当于一个控制台,传递指令。
数据总线:相当于内存数据传输的通道。
地址总线:相当于一个内存仓库。
二、指针变量和地址
2.1 取地址操作符(&)
理解了1中的内存和地址,在c语言中创建变量其实就是在向内存申请一块空间,如int a=10,他的实际意义就是向内存申请4个字节的空间来存储10这个数据,该变量的数据类型决定了具体需要申请几个字节的空间,如char类型就是申请1个字节的空间
对int a=10来说,创建了四个字节的空间,通过调试发现这四个字节都是有地址的,当我们通过&取地址符来得到a的地址(%p是专门用来取地址的占位符)时,实际上取出的时a所占4个字节中地址较小的字节的地址。虽然整形变量占了4个字节,但是只要知道了第1个字节的地址,顺藤摸瓜就可以访问到4个字节的数据。
2.2 指针变量和解引用操作符
2.2.1 指针变量
通过2.1我们通过&取地址符拿到了地址,这个地址是一个数值,而将这个数值存储起来方便后期使用,就需要我们把地址值存在指针变量里。
int main(){int a = 10;&a;printf("%p\n", &a);return 0;}
int *p=&a 本质上就是取出a的地址并存储到指针变量p中,指针变量本身也是一种变量,只不过是专门用来存放地址的,int*中的*相当于说明该变量是一个指针变量,而int对应的是该变量指向对象的数据类型是整形,如果要创建一个存放char类型变量a的指针变量,则书写方法char *p=&a(指针变量p本身也是有自己的地址的,该地址存放的内容是a的地址,可以通过该地址访问a的数据)
2.2.2 解引用操作符
通过2.2.1,我们学会了怎么将地址保存起来,那未来我们也要有方法去取用他,就跟我们生活中我们找到一个房间,我们希望可以在这个房间里存放或者取走物品,同理,我们通过了指针变量存储的地址,通过地址找到了该地址指向的空间,这里就需要用到解引用操作符*,来取用空间里数据。
int main(){int a = 10;int* p = &a;*p = 0;return 0;}
*p的意思就是通过p中存放的地址,找到指向的空间,因为p存放的是a的地址,所以*p其实就是变量a,*p=0的操作,其实等价于通过指针间接改变了a的值,所以此时a=0,相比较于直接写a=0,相当于多了一种途径,写代码会更加灵活,在后面的传址调用可以体现出来。
2.3 指针的大小
既然指针可以存储地址,那么指针究竟有多大呢?假设我的电脑是64位机器,由64根地址总线,每根地址总线的电信号转化成数字信号是0或者1,所以将64根地址产生的二进制序列当成一个地址,那么一个地址就是64个bit位,需要8个字节才能存储,同理,32位的机器就需要4个字节来存储。
我们可以通过sizeof来测试不同类型指针的大小,发现结果都是一致的,说明了指针的变量大小与类型是无关的,只要是指针类型的变量,在相同的平台下,大小都是一样的(32位平台指针大小是4个字节,64位平台下指针大小是8个字节)
int main(){printf("%zd\n", sizeof(char*));printf("%zd\n", sizeof(int*));printf("%zd\n", sizeof(short*));printf("%zd\n", sizeof(double*));return 0;}
32位:4 4 4 4
64位:8 8 8 8
三、指针变量类型的意义
既然指针的大小和类型无关,同一个操作平台下指针大小是一样的,那么指针的数据类型有什么用呢?
以下来解析指针的数据类型究竟有什么特殊的意义。
3.1 指针的解引用
通过上面2段代码可以发现代码1将a的4个字节都改成0,但是代码2只将a的第一个字节改为0。这说明了指针的类型决定了对指针解引用的权限有多大(就是一次能操作几个字节),比如int*类型指针解引用能访问4个字节,但是char *类型的指针解引用只能访问1个字节。
3.2指针+-整数运算
int main(){int a =10;char* pc = (char*) & a;int* pi = &a;printf("&a\t=%p\n", &a);printf("pc\t=%p\n", pc);printf("pc+1\t=%p\n", pc+1);printf("pi\t=%p\n", pi);printf("pi+1\t=%p\n", pi+1);return 0;}
&a =0000006A054FF9A4
pc =0000006A054FF9A4
pc+1 =0000006A054FF9A5
pi =0000006A054FF9A4
pi+1 =0000006A054FF9A8
通过上述代码以及运行结果我们可以看出char*数据类型的指针变量+1跳过了1个字节,而int*类型的指针变量+1跳过了4个字节,所以这说明了指针的类型决定了指针向前或者向后走一步有多大。
3.3 void*指针
void*叫做无类型指针,这类指针可以用来接受任意类型的地址,但是也有局限性,就是void*不能直接进行指针的+-整数和解引用运算。
int main(){int a = 10;void* p = &a;*p = 0;//errprintf("%p", p + 1);//err}
上面这个代码我们可以证实这个结论, 我们可以把void*想象成一个垃圾桶,可以收集任意类型数据的指针,但是无法直接去运用(解引用和+-运算)。其实void*的设计可以实现泛型编程的效果,使得一个函数可以处理多种类型的数据。
四、const修饰变量
4.1 const修饰指针
变量是可以修改的,如果将变量的地址交给一个指针变量,那么通过指针变量也是可以间接修改这个变量的,如果我们希望这变量不能被修改,就可以使用const来修饰指针。
上面这段代码a是可以修改了,b用const修饰后再修改系统会报错,其实无论a还是b,本质上都是变量,const的作用只不过实在语法上进行了限制,当你修改了const修饰的变量,系统就会因为不符合语法的操作进行报错,无法修改。
这段代码中的b虽然被const修饰后无法进行修改,但是如果我们绕过b,从b的地址下手,也可以间接去改变b的值。
我们利用const修饰,就是希望这个变量不被修改,可通过这个变量的地址还是可以修改,就打破了const的限制,达不到我们的预期,所以我们就需要学习利用const修饰指针变量。
4.2 const修饰指针变量
创建指针变量p(int*p=&a)之前,我们首先要知道3点含义。
1.p内部存放的是a的地址,*p可以通过这个地址访问到a。
2.p本身也是变量,他有自己的地址。
3.*p是p指向的空间,也可以理解成解引用p,改变*p其实就是改变a。
通过如图的代码,可以得到3个结论:
1.const如果在*左边,const修饰的是*p,也就是修饰指针指向的内容,保证指针指向的内容不能通过指针来改变,但是指针变量p本身的内容是可以改变的。(*p=20是不可行的,p=&a是可行的)
2.const如果在*右边,const修饰的是p本身,保证指针变量p的内容不能被修改,但是指针指向的内容是可以改变的。(*p=20是可行的,p=&a是不可行的)。
3.如果*的两边都有const,则const不仅修饰了*p,也修饰了p本身,所以无论是指针指针指向的内容,还是指针变量本身,都是不可以被改变的。(*p=20和p=&a都不可行的)
下面通过一个实例来记忆,比方说p是1个女孩(指针变量),a和b是2个男孩(整型变量),a有存款10元(a=10),b有存款100元(b=100),p喜欢a,p希望能使用a的存款(p=&a),希望用a的存款去吃1份10元的水饺(*p=10---->*p=0),a不希望p去使用,所以他使用了const放在*p的左边来限制p的操作(int const * p=&a),此时p无法去改变a的存款,所以(*p=0)这个行为无法实现,这时候p生气了,你对我不好,我可以换人喜欢,想去有钱的b家里和b在一起(p=&b),这时候a害怕了,将p追了回来,并希望限制p永远属于自己(int *const p=a),但是p提出了条件,就是p可以使用a的存款(*p=10---->*p=0),a也提出了条件,就是p不允许去找别的男生,所以(p=&b)这个行为无法实现。
五、指针运算
5.1 指针+-运算
3.2已经介绍了指针+-运算,而我们知道数组在内存中是连续存放的,只要我们知道第一个元素的地址,就可以顺藤摸瓜地找到其他所有元素。
int main(){int arr[10];int i = 0;for (i = 0; i < 10; i++){arr[i] = i + 1;//数组内10个元素分别为1 2 3 4 5 6 7 8 9 10}//通过指针来访问并打印这个数组int sz = sizeof(arr) / sizeof(arr[0]);//sz为数组元素个数//我们需要知道arr的首地址,再通过+-运算顺藤摸瓜找到后面所有元素int* p = &arr;//数组名代表数组首元素的地址for (i = 0; i < sz; i++)//如果我想访问1-10{printf("%d ", *p);p++;} //如果想访问1 3 5 7 9,则改成p+=2即可return 0;}
1 2 3 4 5 6 7 8 9 10
如果用char*来接受arr的地址,该怎么写呢?
int main(){int arr[10];int i = 0;for (i = 0; i < 10; i++){arr[i] = i + 1;//数组内10个元素分别为1 2 3 4 5 6 7 8 9 10}//通过char指针来访问并打印这个数组 char* p =(char*) & arr;//数组名代表数组首元素的地址int sz = sizeof(arr) / sizeof(arr[0]);//sz为数组元素个数//我们需要知道arr的首地址,再通过+-运算顺藤摸瓜找到后面所有元素for (i = 0; i < sz*4; i+=4){printf("%d ", *p);p+=4;//因为char类型+1会移动1个字节,所以需要+=4} return 0;}
1 2 3 4 5 6 7 8 9 10
5.2 指针-指针
通过5.1,我们知道指针+整数=指针。所以指针-指针得到的是一个整数。
可以模拟实现strlen函数来观察指针的减法,strlen函数本质是字符串/0前面出现的元素个数,其实strlen函数传入的是字串串首元素的地址,如何通过该地址顺藤摸瓜地寻找后面的元素,知道遇到/0。
int my_strlen(char* s){char* p = s;while (*p != '\0')//这里也可以写成*p,因为'\0'的ascii值是0p++;//p加1一次就往后移动4个字节return p - s;//指针-指针得到的绝对值是指针之间的元素个数(前提条件:两个指针指向同一块空间。)}int main(){int ret = my_strlen("abc");printf("%d", ret);return 0;}
3
指针-指针得到的是一个整数,而这个整数其实就是指针与指针之间的元素个数,但是有个前提条件就是两个指针必须指向同一块空间(比如arr[0]-crr[1]就不行)。
5.3 指针的关系运算
指针的关系运算就是指针比较大小,可以通过运用该知识来访问数组。
int main(){int arr[10];int i = 0;for (i = 0; i < 10; i++){arr[i] = i + 1;//数组内10个元素分别为1 2 3 4 5 6 7 8 9 10}//通过指针来访问并打印这个数组int sz = sizeof(arr) / sizeof(arr[0]);//sz为数组元素个数//我们需要知道arr的首地址,再通过+-运算顺藤摸瓜找到后面所有元素int* p = &arr;//数组名代表数组首元素的地址while (p < arr + sz){printf("%d ", *p);p++;}return 0;}
1 2 3 4 5 6 7 8 9 10
p接收的是arr的首地址,而sz是元素个数,所以通过p++,p会无限接近arr最后一个元素arr[sz-1],直到打印出来之后,while循环结束。
六、野指针
概念:野指针就是指针指向的位置是不可知的
6.1.野指针产生原因
6.1.1 指针未初始化
未初始化的变量(int *p),变量的值是随机的,无法访问(此时写*p=20会报错)
6.1.2 指针越界访问
将for循环中的i<10改成i<20,此时出现越界访问。
当指针指向的返回超出数组的范围,就是越界访问,此时p是野指针。
6.1.3 指针指向的空间释放
上面这段代码中,调用test函数,test函数的返回值是一个局部变量,test运行后已经被释放了,但是第一张图运行还是可以运行出10这个数据,原因是我们理解的销毁其实时空间所有权被释放,当其他函数执行需要开栈帧时,会把这里给占用,但是第一张图运行时还没有函数来占用,所以10这个数据被保存了下来,而第二张图在调用test函数后面又加了一段打印hehe的代码,此时printf的调用占用了这块空间,此时再去访问得到的就是一个随机值。
当指针指向的空间已经被释放(常见的就是调用的函数的返回值是一个局部变量,函数一调用结束该变量立刻被销毁。),p指向一块无法访问的内容,此时p是野指针。
6.2 如何规避野指针
6.2.1 指针初始化
在指针变量创建的时候就要进行初始化,如果不知道指针应该指向哪里,那么可以将指针赋值给NULL,NULL是C函数中定义的一个标识符常量,他的值是0,地址也是0,所以读取该地址时程序会报错,相当于程序会提醒你这是个野指针,不要去使用。
6.2.2 避免越界访问
比如程序向内存申请了一个存放arr数组的空间,那么指针也只能访问这些空间,一定不要超出这个范围去访问。
6.2.3 当指针不再使用时,即使置NULL,指针使用前检查有效性
当我们后期不需要使用这个指针去访问空间时,即使内置NULL,因为将指针变量设置成NULL,一旦误用后系统就会报错,这样可以把野指针暂时管理起来。
另一方面,当我们书写了大量代码后,可能会没有及时发现野指针的出现,这时候我们可以在使用前判断是否是NULL,根据情况决定是否继续使用这个指针
6.2.4 避免返回局部变量的地址
局部变量在函数执行完,空间所有权就会被释放,一但其他函数执行需要开栈帧,就会占用该空间。
七、assert断言
assert.h 头⽂件定义了宏 assert() ,⽤于在运⾏时确保程序符合指定条件,如果不符合,就报 错终⽌运⾏。这个宏常常被称为“断⾔”。
assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值⾮零), assert() 不会产⽣ 任何作⽤,程序继续运⾏。如果该表达式为假(返回值为零), assert() 就会报错,在标准错误 流 stderr 中写⼊⼀条错误信息,显⽰没有通过的表达式,以及包含这个表达式的⽂件名和⾏号。
assert() 的好处:
1.⾃动标识⽂件和 出问题的⾏号
2.⽆需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问 题,不需要再做断⾔,就在 #include 语句的前⾯,定义⼀个宏 NDEBUG (#define NDEBUG)。
assert() 的坏处:
1.因为引入了额外的检查,增加了程序的运行时间。
2.release版本中需要确保代码没问题的情况下禁用assert操作,确保影响用户使用程序的效率。(一些编译器的release需要禁用,但是vs这样的集成开发环境直接就是优化掉了)
八、理解传值调用和传址调用
传值调用和传址调用本质区别就是有无用到指针,指针-指针运算模拟strlen函数的实现,其实就是传址调用的一种方法,其实有一些问题的解决不使用指针是无法解决的,比方说下面模拟swap函数的实现。
swap函数,即通过这个函数交换两个整型变量的值。在没学习指针前,我会这样写-----
void swap1(int x, int y){int temp = x;x = y;y = temp;}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);}
交换前:a=10 b=20
交换前:a=10 b=20
但是没有产生我们想要的效果,原因是实参传递给形参时,形参会单独创建一份临时空间来接受实参,对形参的修改不会影响到实参的值,x和y确实接收到了a和b的值,不过x的地址和a不一样,y的地址和b不一样,所以在swap函数内部去交换x和y的值,本质上不会影响到a和b,说明swap函数是失败的,这种函数调用方法在学习函数的时候就已经了解了,就是传值调用,其特点就是对形参的改变不会影响实参的数据。
所以我们想要实现swap函数,就需要使用传址调用,让swap函数可以通过地址间接操作main函数中的a和b,达到交换的效果。
void swap2(int* px, int* py){int temp = *px;*px = *py;*py = temp;}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);}
交换前:a=10 b=20
交换前:a=20 b=10
通过传址调用,swap函数成功了,我们可以总结出以下结论:传址调用可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量,所以未来我们仅仅只是需要主调函数中的变量值来进行计算而不改变变量值,那么可以采用传值调用,如果函数内部要修改主调函数中变量的值,那么就需要传址调用。
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。