C语言的灵魂——-指针
相关视频——强烈推荐【强烈推荐】4小时彻底掌握C指针 - 顶尖程序员图文讲解 - UP主亲自翻译校对 (已完结)_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili
在学习这个之前,你需要了解函数、循环、数组等C语言知识
指针基本介绍
计算机的每一个字节都有一个地址。
int a,当代码运行的时候,计算机会在内存中开辟一些空间给a。分配多少空间,取决有具体的数据类型。
指针是一个变量,他存放这另一个变量的地址。
1 |
|
p是一个指针变量,换句话说p是一个可以存放整型变量地址的变量。
&叫做取地址符,放在一个变量的前面,我们就得到了那个变量的地址,它返回一个指针,指向那个特定的变量。
*叫做解引用操作符,操作指针所指向的那个地址的内容(值)。
代码示例
1 | //下面的结果是什么? |
1 | printf("%d\n",a);//a的值 |
int* a;意味着指向整型的指针然后写出变量名。
指针的算数运算
步长:与是指向什么类型的指针有关系,就是走一步能跨过几个字节的距离。
下面两个输出,相差4个字节。
1 |
|
这里输出的是一个垃圾值,因为我们根本就没有对这个地址分配一个整型变量,所以解引用会出现一个随机值(垃圾值)。
1 | printf("%d\n",*(p+1)); |
指针的类型
指针是强类型的,你需要一个特定类型的指针变量来存放特定类型变量的地址,
例如对于int*来说,你就需要一个指向整型类型的指针,来存放整型数据的地址。
为什么指针是强类型的?
因为,我们不仅使用指针来存储内存地址,同时也用来解引用他所存储的地址所对应的内容,这样我们就能访问并且修改这些地址对应的值了。
不同的数据有不同的大小,例如整型在内存中占四个字节,字符型占一个字节。
假设int a = 1021;
在内存中占4个字节,32个比特位,如图:
其中,最左边的位来表示符号位,0为正,1位负,剩下的32位来存储值。
现在声明一个整型指针来指向a,
int* p = &a;
现在打印p——printf(”%d\n”,p);
得到的结果200,也就是说该整型变量在内存中的起始地址是200。
现在打印p所指向地址所对应的值——pintf(“%d\n”,*p);
从200开始,int类型占4个字节,到203,提取这个整型的值,得到的结果1025。
代码示例:
1 |
|
输出结果:
接着我们在上面的基础上生命一个字符型指针,并且将整型指针的值赋给该字符型指针。
1 | char* p0; |
这会提示一个编译错误,因为p0是一个字符型指针,而p是一个整型指针。
下面我们进行强制类型转换,并进行输出。
1 | p0 = (char*)p; |
输出结果:
我们发现所对应的内存地址变了,因为程序在每次运行的时候,都会重新为变量分配内存地址。
这里p0所指向的地址所对应的值也变了,我们发现跟p并不一样,这是为什么呢?
同上面的图,这是1025作为整型在内存中的分部,整型在内存中占4个字节,32个比特位,而我们这里将他强制存进了字符型指针中,字符型在内存中占1个字节,8个比特位,
所以只获得了最左边的一个字节,也就是1。
算数运算
这里我们再次进行指针的算数运算,将p0+1获得新地址,并且对他进行解引用得到该地址所对应的值。
1 | printf("p0+1所指向的地址是%d\np0+1所指向的地址对应的值是%d\n", p0+1, *(p0+1)); |
结果如下图表示。
因为char类型所占字节数是1,所以步长(+1跳过的字节数)是1,该地址所对应的值是,
二进制转化为十进制表示得4(0*2^0+0 *2^1+ 1 * 2^2 = 4)。
进制转化忘了的同学可以来看一下我的这篇笔记——进制之间的转换 | 半生瓜のblog (doraemon2.xyz)
void空指针
现在我们讨论一种通用的指针类型,它不针对某个特定的数据类型,这种类型的指针被称为void类型的指针,我们使用void来声明这种特定的指针类型。
1 | void* p1;p1 = p; |
这里我们不需要显式的类型转换,p1 = p是合法的,不会有编译错误。
但是它没有映射到特定的类型,所以我们不能对它进行解引用,*p1是违法的。
我们只能打印出地址。(p0+1也是不行的,也会有编译错误)
1 | printf("%d\n",p1); |
指向指针的指针
直接上代码
1 |
接下来让我们打印一些东西
1 | printf("%d\n", *p);printf("%d\n",*q);printf("%d\n",*(*q));printf("%d\n",*(*r));printf("%d\n",*(*(*r))); |
结果:
解释:
第一次没读懂一定要多读几次。
p存的是x的地址,对x进行解引用得到x的值。
q存的是p的地址,p存的是x的地址,对q进行解引用得到x的地址,再进行一次解引用就是通过x的地址寻找对应的值,那就是x的值。
r存的是q的地址,q存的是p的地址,第一次解引用操作就是通过q的地址找到q所存的p的地址,p存的是x的地址,再对r进行一次解引用操作,就是p的地址里面存的是x的地址,结果得到x的地址,再对r进行一次解引用操作,就是通过x的地址寻找对应的值,那就是x的值。
我们通过三级指针可以直接修改x的值
就是通过对指针变量的解引用来修改对应地址所对应值。
1 | ***r = 10;//此时x的值由5变成了10 |
1 | **q = *p + 2;//此时x的值由10变成了12 |
函数传值&传(址)引用
函数与指针。
传值。
1 |
我们发现值并没有改变,我们打印下两个地址看一下,
是两个不同的内存空间,所以值没被修改。
解释:
main()是主调函数,Increment()是被调函数,当我们在主调函数中调用其他函数时,这个参数叫做实参,这个被调函数的参数叫做形参,实参会被映射到形参,当这个函数被调用的时候,主函数中的实参’a’会被映射到Increment函数的形参’a’里面,当我们进行这样的调用的时候,基本上就是把一个变量拷贝到另一个变量,这种形式的函数掉用也被称为,传值调用。
如果修改成功的话,这两个应该是一个地址,引出传(址)引用。
传(址)引用,只需要一点点的修改。
1 |
解释:
这样的函数调用不是传值,而是将变量的地址传了过去,我们可以引用这个变量,解引用并且进行一些操作,这就是传(址)引用,传(址)引用可以节省很多内存空间,相比之下引用所占的内存也会小得多,避免复杂的数据类型的拷贝,可以让我们节省内存。
指针和数组
二者之间有很强的联系。
数组名就是一个指针。
如果使用数组名,会得到一个指向数组首元素的指针。
例如:
int* p = a;我们甚至都不需要在a前写&。
1 |
如果我们打印a,会得到数组a的首元素地址。
1 | printf("%d\n",a); |
如果对它解引用会得到他首元素的值,1。
1 | printf("%d\n",*a); |
如果打印a+1,则会打印数组a第二个元素的地址。
同理对他解引用也会得到该地址所对应的元素值。
1 | printf("%d\n",a); |
完整代码示例:
1 |
结果:
需要注意的是,当把数组名字作为指针的时候,不能对它进行自增操作,会报错。
1 | int a[] = { 1,2,3,4,5,6 }; int* p = a; p++;//可以 a++;//不可以 |
数组作为函数参数
数组作为函数参数传入。
代码示例:
1 |
现在我们将求元素个数的代码放入SumOfElement函数中。代码如下,我们发现此时结果变成了1。
1 |
结果:
这是为什么呢?
当编译器看到数组作为函数参数的时候,他不会拷贝整个数组,而是仅仅创建一个同名的指针,我们这里就是创建了一个整型指针,编译器只是拷贝了主调函数的数组首元素地址。
不管你在被调函数的参数中写int a[],还是int *a,结果都是一样的,它都只是一个整形指针而已。
这里我们不是拷贝变量的值,而仅仅拷贝了一个变量的地址,所以这里是传(址)引用,这个很有意义,因为数组可以很大, 每次拷贝整个数组没有意义,他会消耗大量的内存,一次对于数组来说不使用传值引用,而是传(址)引用。
这就是为什么我们这次数组的结果是1了, 因为被调函数中的a是个整型指针,而在主函数中a是一个数组。
所以计算数组元素个数的代码,还是应该放到主函数中。
指针和字符数组
字符数组
在C语言中为了更高效的操作字符串,我们需要理解一些事情,
我们如何把字符串存入和字符数组,
为了能够在字符数组中存储字符串,首要的需求就是字符数组必须要足够大,大到能够容纳字符串,字符数组的大小要大于等于字符的数量+1,
C语言的字符串必须以null结尾,这就是为什么我们需要一个额外的空间,是用来存放null的。
代码示例:
错误例子
1 |
我们发现字母后面出现了几个乱码,这是因为我们破坏了printf的默认规则,也就是字符串必须是以null结尾吧,这就是发生未定义行为的原因。
下面我们进行一下修改
1 |
结果就正常了
其他的字符操作函数也同样需要遵守这个固定,以null为结尾。
写在同一行,系统会隐式的自动添加null为结尾
1 | char c[5] = "NSSB"; |
不声明字符数组的个数也是可以的,系统会自动分配对应的字节数,例如
1 | char c[] = "ABCD";sizeof(c);//sizeof的结果就是5,它刚好同来存放ABCDstrlen(c);//长度是4,不包括null |
如果我们这么声明,代码如下,这时我们需要显式的声明它的结束。
1 | char c[5] = {'A','B','C','D','\0'}; |
引入指针
声明一个字符数组和一个字符指针。
1 | char c1[5] = "hello";char* c2; |
我们使用这个数组的名字和这个字符指针的名字放到等式中是成立的。
1 | 但是,将换个位置, c1 = c2;是非法的 |
c2中存的就是字符数组c1中首元素的地址(同上面的整型数组)。
1 | c2 =c1; |
解引用操作同上面的数组。
代码示例:打印数组中的所有元素。
1 |
或者
1 | void print(char* c){ while (*c!= '\0') { printf("%c", *c); c++; }} |
解释:此时的字符数组就是一个指针,存的就是元素的地址,从首元素开始只要不是null就进入循环,然后元素地址进行自增,因为是字符型指针,所以步长就是1,遍历每个元素,直到null。
指针和二维数组
简单复习一下什么是多维数组。
就是在数组中储存数组。
学习此部分之前,可以再复习一下上面指针和一维数组。
个人理解:当多维数组名被当做指针的时候,多维数组就是指向指针的指针。
代码示例:
1 |
为什么我这么说呢?
对比理解
就是,一个二级指针,存的是一个一级指针的地址(首元素地址),然后再对该一级指针的地址进行解引用,得到该一级指针所存地址的值。
例如:
1 | printf("%d\n",**B);//当数组名被作为指针的时候,B和*B意思一样。 |
当二维数组的数组B被当成指针的时候,他里面存的是B [0] [0]的地址,再对他进行解引用得到的是对应的值,1。
((同上)因为直接使用数组名会返回该数组的首元素的指针,是二维数组中的第一个一维数组的首元素地址,然后对这个首元素进行解引用操作,得到的是第一个元素的值。)
不同点:
B返回一个指向一个一维数组的指针,而*B返回一个指向整型的指针,当我们只是打印地址的时候,一维数组B[0]和B[0]的首元素的起始地址是一样的,所以打印的地址是相同的,指针类型会在你尝试解引用时或者尝试做指针算术的时候起作用。
B [I] [J]可以写成 *(B[i]+j) 或者 *( *(B +i)+j )。—就是把B[i]写成 *(B+i)
解释:直接用 数组名返回一个指向首元素的指针,(该二维数组中对应的哪个一维数组),然后+j是对应一位数组中的,跳过的元素个数,也就是往后跳过几个字节,得到新的元素地址,最后,解引用得到该元素的值。
指针和多维数组
(如何理解多维数组,最左边的维数就是一共划分了几块,第二位就是在每一块中继续分为几块,依次类推。)
(定义多维数组的指针的时候,后面的参数是该数组除去一个参数的几个参数)
多维数组-例如:三维数组就是二维数组的数组。
代码示例:
1 |
同上面指针和二维数组
1 | C[i][j][k] = *(C[i][j]+k) = *(*(C[i][j]+k)) |
如果你已经理解了,请问下面这个结果是多少?
1 | printf("%d\n", *(C[0][1] + 1));//结果是4,上面示例中的代码。 |
多维数组作为参数传给函数
(是几维数组,使用数组名作为指针就返回几维度-1的指针)
(例如:一维数组返回指向整型的指针,二维数组返回指向一维数组的指针,三维数组返回指二维数组的指针……)
多维数组作为函数参数的时候,数组的第一个维度可以省略,但是其余的维度需要指定。
1 |
指针和动态内存
内存是机器中很关键的资源。
也可以看看我的这篇笔记——C语言动态内存开辟 | 半生瓜のblog (doraemon2.xyz)
相关函数:
堆上分配内存的相关函数malloc calloc realloc
释放内存free
malloc
malloc返回一个void指针,这个指针指向了分配给我们的内存块的第一个字节的地址。
1 | void*p = malloc(n*sizeof(int)); |
我们不能解引用一个void指针,通常需要将它转化为一个特定类型的指针,然后再使用它。
因为malloc只是个通用的函数,在堆上分配一些内存,它并不关心你用这块内存存什么,它只是简单返回指向开辟出来的内存起始地址的指针。
为了使用这块内存我们需要进行指针类型转换。
1 | void* p = (int *)malloc(n*sizeof(int)); |
动态内存的操作都是基于指针的,你拥有一个基地址指针。
calloc
calloc 和malloc类似,callo也是返回一个void型指针, 但是calloc接收两个参数,第一个参数是特定的元素数量,第二个参数是类型的大小。
1 | void *p = (int *)calloc(3,sizeof(int)) |
还有一个区别是,calloc在分配完内存之后会对其进行初始化,而malloc不会。
realloc
如果你有一块内存,动态分配的内存,你想修改内存块的大小,那你就可以使用realloc,realloc接收两个参数,第一个参数是指向已分配内存的起始地址的指针,第二个参数是新的内存块的大小。
如果去掉第一个参数,那么他和malloc是一样的效果
这时会创建一个空的内存块,而不会从之前的内存块拷贝任何数据。
1 | int *b = (int *)realloc(NULL,n*sizeof(int)); |
第二个参数是0,那么他和free是一样的效果
1 | int *b = (int*)realloc(A,0);//将A释放 = free(A) |
reallo可能会有多种场景
- 比如我们想要的新内存块可能比原来的内存要大,这种情况下机器可能会创建一块新的内存然后把原来的值拷贝过去,然后释放之前的内存,如果之前的那块内存的相邻处,还有还有连续的内存可用,那么可能会直接扩展之前的那块内存。
free
free()将开辟出来的内存空间释放。
代码示例
数组的个数不能是个变量,这时候我们就能用到动态内存开辟
创建一个大小为n的数组
1 |
内存泄漏
这种情况是由于不正确的使用动态内存引起的。
内存泄漏就是在堆上增长垃圾。
不正确的动态内存(堆)的使用引起。由于程序中已动态分配的没有释放,造成的系统内存浪费,导致系统运行减慢或者崩溃。——百度百科。
函数返回指针
1 |
加入一个简单的函数并且调用之后,我们发现程序运行错误
1 |
如图:
这是为什么呢?
栈,先进后出,先进的被压倒栈底, 当Add函数调用完成,返回一个指向结果的指针(地址),然后内存被释放,所指向的地址对应的值就是个垃圾值,尽管他指向这个地址,但是他的值是不能被保证的,因为内存被释放了,为后面的函数分配空间。
之后为Print函数分配栈空间,覆盖之前的空间。
如果我们尝试将返回一个被调函数的局部变量给主函数,就像我们要返回一个Add函数的局部变量给main函数,当被调函数结束控制返回给主函数的时候,那块内存已经释放了,因此从栈顶向上传参数是可以的。
但是,
从栈顶向下传一个局部变量或者一个局部变量的地址是不可以的,
那么,什么情况下我们想要从函数返回一个指针呢?
如果我们在堆上有一个内存地址或者,在全局区有一个变量,那么我们就可以安全地返回他们的地址,因为堆上分配内存需要显示释放,由我们来控制他的释放。
修改后的代码
malloc是在堆上开辟的空间不会被显式的释放。
1 |
因此,从函数返回指针的时候,我们需要小心它的作用范围,我们必须保证地址没有被重用(用来存储其他东西),以及那个地址的数据没有被清除。
函数指针
用来存储函数的地址
它指向或引用内存中的数据,这里的数据未必一定指变量,也可以是常量。
可以使用这样的指针来解引用和执行函数。
当我们说函数指针存放函数地址的时候, 我们是在说函数指针存放了函数在内存中的起始地址或者入口点。
1 |
1 |
回调函数
函数指针可以被用来作为函数参数,接收函数指针的这个函数,可以回调函数指针所指向的那个函数,
就是一个函数作为参数传递给另外一个函数。
格式-返回类型(*函数名)(参数类型,参数类型,……)
1 |
可以根据情况的不同写不同的回调函数
例如:比较绝对值后的大小,升序排列
1 | int absSort(int a, int b){ if (abs(a) > abs(b)) return 1; else return -1;} |
调用库函数
1 |
qsort能对任何数组进行排序,不仅仅是整形数组。只是你需要给出比较逻辑。