一、引言
为什么要有动态内存管理呢?
想想我们之前都怎么像内存申请空间的,创建一个int char longlong double等,都是有固定的大小的,更高级一点我们申请一个数组int arr[5];char arr[20],甭管几个字节的大小,它们都是固定的,当然,根据创建的位置作用域就不同,即在函数内部的形参了,变量了,都是临时变量,储存在内存的栈区;静态变量了,全局变量了,都存储在内存的静态区。
内存大概就是这样的:
假如我们想申请一个可变的空间实际上就束手无策了。
(变长数组可不是一段可变的内存空间,因为变长数组充其量就是可以根据变量的值申请大小,这个大小不还是不可变的吗,如果还有疑问,听完动态内存管理再对比一下)
那么堆区是用来干嘛的呢?诶!动态内存管理,这里是专门用来申请动态的内存的。
C语言给我们提供了几个动态管理内存的函数:
放在了<stdlib.h>这个头文件里面。
帮助我们进行一系列的内存操作。
学习前先要提前有个意识,动态内存管理是一把双刃剑,用的好了可以让程序更加高效,但是用不好可能导致一系列问题,所以好好学,谨慎使用。
二、malloc函数
什么叫malloc函数呢,memory allocate逐字逐句的话是内存 分配/划分的意思,我们一般译为动态内存开辟,也就是说这个函数是用来给程序员动态开辟内存用的。
1.malloc函数概述
从长度上来看,其实没有多难,参数是size_t size也就是你要开辟多少个字节的大小,malloc申请的是一块连续可用的空间。
如果开辟成功,返回一个void* 的指针,这个指针存的地址是动态开辟的内存空间的地址,实际上指向这块内存空间最开头那个字节。
如果开辟失败(内存不够之类的问题),返回一个NULL。
其中,对于开辟成功的void* 该怎么理解呢?
void*首先是一个指针变量,我向系统申请了一块空间,肯定是想用来存某些东西的,想办法管理,对于内存,最方便的就是用指针管理。
为什么是void*,而不是int* ,char*等?
那是因为我只是用一个指针接收了开辟内存空间的起始地址,但由于这块内存空间是用来存int类型的数据,char类型的数据,甚至结构体等类型的数据,这些行为是未知的,在最开始接触void*的时候就说了,void*可以接受任意类型的地址。
2.malloc函数的使用
很简单,比如想申请一个int类型的数组,存放5个变量:
这么看来也就那样吧,我感觉我int arr[5]也挺舒服的。
特别注意
对于上面这段代码,与直接int arr[5]有什么区别,以及风险是什么呢?
①malloc申请空间失败返回一个空指针的情况被忽略
我们在刚才说的时候已经提到这一点了,如果开辟成功,返回那块空间的地址,开辟失败,返回一个NULL指针,如果是空指针的话,你解引用去赋值,解引用去打印这是不合适的,因为NULL你怎么去解引用,访问多少个字节是不知道的,所以这样写代码是有风险的。
在VS中实际上给出我们提示了:
报警告说,p可能是NULL,你这个时候解引用是不合适了。
所以一般对于malloc的返回值,我们要判断一下是否合法再继续进行下面的操作:
如果为空指针,我需要你给我打印出来开辟空间失败的原因,并return 1(程序,或者说主函数正常返回就return 0,但是如果异常返回就不return 0就行)。
②思考动态开辟内存和我们直接申请一个变量、数组等的区别
多的不说,在引言中实际上已经提出来说,动态开辟内存的位置是在内存的堆区,而我们平常创建的那些局部变量,函数形参等都是在内存的栈区里。
堆区的内存的特点是全权交由程序员自行管理,包括内存的申请,内存的释放等。
而栈区出了局部变量作用域或者出了函数以后,这些内存空间就会被释放了,这个时候你存的什么值都不再有效,不能再次使用。
二、free函数
在malloc函数的讲解中我们提到了一个东西,就是说堆区的内存是全权交由程序员自行管理的,内存的申请我们了解过了一个malloc函数,那么内存的释放是由谁来进行的呢?
很显然,free函数。
1.free函数概述
free函数是用来释放和回收动态开辟内存的内存空间。
参数是一个指针,接收的是动态开辟的内存空间的指针。
如果ptr是动态开辟的内存的指针的话,那么就会被free函数释放。
如果ptr不是动态开辟的内存的指针,free的函数的行为是未定义的。
如果ptr是NULL,那么free函数什么都不做。
2.free函数的使用
对于上面那段代码,实际上是缺了一个free函数的:
但是这样就结束了吗?
实际上不是的,我对malloc返回的指针进行是否为空的判断有了,我动态开辟的内存也还给系统了,这样其实也隐隐感到不安。
因为动态开辟的内存我们访问用的就是指针,指针这个工具非常好用,但就像一把锋利的菜刀一样,我用来砍瓜切菜很流利,但同样的,如果稍有不慎,也是非常容易伤到自己的。之前我们也把指针比作野狗一样。
来分析一下:
malloc函数申请了一块动态内存空间,开辟成功了,我们用完这块内存以后我们又把这块内存还给系统了,但是p呢?
p可没有变啊,当时申请的空间由p指向,内存空间反正是还给系统了,但是指针却还指向这块位置,那这个时候再对p进行解引用了,赋值了,就是非法访问内存,所以要栓住这个脱缰的野狗就要用:
所以一般对于动态开辟内存就是三部曲:
动态开辟;
释放;
指针置空;
另外,实际上举的例子还是比较简单的,不释放也没啥事,因为就用了一点内存,并且p并没有被继续使用,系统在程序结束(main函数执行完毕)的时候就会释放内存,但是自己写代码的时候要养成良好的习惯,不能因为有人擦屁股就随随便便的,用完也不释放,指针也不置空,这点懒会在我们将来写复杂代码时带来意想不到的麻烦。
三、calloc函数
c alloc实际上是clear allocate,跟malloc的区别在于,malloc就仅仅是开辟一块内存空间,开辟完以后啥也不管了,我们知道内存并没有被利用的时候存的都是随机值,也就是说malloc开辟的内存如果不对其进行初始化,直接访问得到的就是随机值;calloc不一样,他不仅会申请一段内存空间,而且还会删去这块内存空间的所有随机值,也就是全部置为0。
全部置为0了。
还没介绍calloc参数返回值,参数一个是size_t的num,一个是size_t size,num是开辟内存的元素个数,size是每个元素的大小,返回值同malloc函数,是动态开辟的内存的地址。
实际上malloc也可写成类似格式:malloc(5 * sizeof(int)),只不过这还是一个参数,而且不会对随机值进行清空。
使用不再赘述,开辟好该检验检验,用完该释放释放该置空置空。
四、realloc函数
re allocate再开辟,专门用来对已经开辟过的内存空间进行修改。
两个参数,void* ptr,size_t size.
ptr指向一块被动态开辟过的空间,也就是将要被修改的空间。
size是期望将这块空间改成多大。
大改小不用多说,大的都能申请到,变小一点那肯定也没啥问题。
小改大情况就多了:
情况1:
后面的内存符合扩大的容量,即
本来第一次开辟开辟了20个字节大小的内存,想扩大到60个字节,后面还有100个字节大小的内存空间并没有被占用,那往后再申请40接上去就可以扩大。ptr位置不变。
情况2:
后面的内存不够扩大的容量,但内存其他地方有符合的大小:
如果想扩大到60个字节,但是后面不够,这时候在内存中找啊找,发现有一块100个字节的内存空间,那这个时候直接截出来60个字节的空间,再把ptr存的地址换成这个地方的即可。
当然,这个时候原来那块就会被realloc直接释放。
情况3:
没有任何一个空间放得下这60个字节,扩容失败,返回一个NULL。
五、常见的动态内存错误
1.对NULL指针解引用
怎么个事呢?
不知道开辟是否成功,也就是不知道p是否为空指针,直接给我解引用了,那合理吗?
一判断就发现出错了,出错的原因是没有足够的空间去开辟你要求的空间,赶紧return 1即非正常返回。
2.对动态开辟空间的越界访问
这种情况其实也比较常见:
我最开始学循环语句的时候,特别是for循环,因为一般要求循环几次的话顺手都是从 i = 0开始,我经常从1开始数,所以这么数,老得一点一点数。
这段代码也是数错了才造成,你本来是开辟了10个int的大小,但是数错循环次数了,不小心循环了11次,第11次那四个字节根本不属于动态开辟的内存,形成非法访问。
3.对非动态开辟内存释放
这个不用多说。
4.free只释放动态开辟的内存的一部分
还是最开始的例子:
开辟10int大小的内存空间,并初始化,用完回收空间
是否为空检验了,赋值得时候想的是解引用赋值,并且指针后移,到这只能说所想即所得,但是这个时候free不就有问题了吗,因为p并不是指向整块动态开辟的内存,而只是其中的一部分。
所以你要不然就在初始化前记录一下p的初始值,要不然不要让指针移动。
5.对一块内存多次释放
这个问题没有那么大,还是这段代码:
如果你严格遵循动态开辟的内存用完就释放,并且指针置空的话,后面突然想起来这块空间了,忘了释放没释放,又释放了,这个行为其实不太好,但是吧因为p置空,free(p)相当于什么也没做。
6.动态开辟内存忘记释放
前面有几个,比如对空指针解引用了,多次释放了,其实都还好,要不然直接给你报错,要不然就没啥太大影响。
但是动态开辟的内存不释放可就事大了,你能借用的系统的内存是有限的,如果你一直光借不换,就好像去图书馆借书一样,光借不还,假如允许你一直借,那图书馆的书早晚被你搬空,内存都枯竭了,那你后面的代码去哪实现呢?
这个代码没啥具体含义,只是为了表达,假如你一直申请内存空间,不释放,就等着程序结束给你回收,要是占用的内存不大,还可以接受,假如累积使用的内存大小超过能够申请的最大那不就执行失败了嘛。
所以,再次强调,牢记三部曲,返回值验空,用完释放,指针置空。
六、动态内存经典题目分析
题1
大概分析起来就是main函数调了一个Test函数,Test函数上来定义了一个指针变量str,并初始化为NULL,下一步就是调用Getmemory并将str当作参数传过去,这个传参可就有讲究了:
str是一个指针变量,传过去的肯定是地址,但是是传值调用还是传址调用呢?
传值调用和传值调用可不是说你传过去的是数值就是传值调用,传过去的是地址就是传址调用,而是传过去的是这个变量的地址还是这个变量存的值,很明显,&str传的才是变量的地址,所以这里是传值调用,为什么花这么大劲来这考虑传值还是传址呢?
继续往下看:
如果是传值,那么形参p的值就是NULL,然后动态开辟了一块内存空间,将地址存到了p那里,也没有返回值,但是这样的话岂不是动态开辟了一段内存空间,然后把好不容易返回的地址丢了嘛,因为没有返回值的话,p出了函数就没了。
而且你传参传的可以说是屁用都没有,你传不传我p都得被修改,这里删去参数:
其实跟上面那段代码效果可以说是一模一样。
而且还是我们说的,你动态开辟的内存,用完了(这里明显是想存一段字符串,并打印),那你就释放内存并置空啊,结果一句没写。
写这段代码的人,最核心的想法就是调用一个函数来开辟内存,结果弄不清传值调用和传值调用,或者没细想,觉得str只要传进GetMemory里值就会被修改,这个就是搞不清形参实参的关系了。
改写1
参数不要了,反正进GetMemory就给我申请100个字节的空间,并且把这个空间的地址给我返回来给str,再检验str是否为空,用完就释放并置空。
改写2
非得传str可以,你无非是想让str所指向的地址被修改呗,也就是str内存放的地址,传值调用只是用str的值进行相应的运算,并不能对str的值进行修改,所以要用传址调用,一级指针的地址由二级指针存放,所以形参就给一个char** p,这个时候p存的就是str的地址,或者按指针的说法的话,现在p指向的就是str,*p就可以修改str的值,即str的指向,所以将动态开辟的内存地址给*p即可完成目的。
画图理解:
最开始是这样的
想变成这样
但是传值又不能通过形参修改值,所以:
*p可以改str的值,使其成为:
题2
这个题其实乍一看还是感觉没啥大毛病,实际上仔细想想就知道,char p[]创建了一个数组,存的hello world,也成功返回了,但是还是老生常谈,局部变量只能在局部作用域,在这里是在GetMemory这个函数里才有效,出了作用域,直接失效了,这个时候你再记录个p不就是指向了一片已经释放的内存空间,这岂不是成野指针了,这个时候printf(str)肯定只能搞出来随机值。
改写的话就动态开辟内存并初始化即可。
题3
做了前两题这个就没啥话可说了,你没验str是否为空(开辟成功没有),也没有释放和置空。
题4
不检验malloc的返回值就直接strcpy了,free以后还敢继续借助str非法访问内存。
七、柔性数组
咋冷不丁的又扯出来数组,肯定带有这样的疑惑,原因就是动态内存管理这4个函数怎么用,效果是什么,需要注意什么,错误的例子都说过以后,剩下的就得自己在以后的编程上慢慢体会去了。
接下来就是动态开辟内存的最后一个内容,柔性数组。说是数组,其实是结构体里的数组:
C99中,结构中的最后一个元素允许是未知大小的数组,这就叫做『柔性数组』成员。
1.柔性数组的声明
两种声明方式,有的编译器可能只适用其中一种,知道就行了。
2.柔性数组的特点
大小
这个时候就有人要问了,但是学结构体嵌套自己的时候说连大小都算不出来,那你这结构体int n 是4个字节咱就不说了,int arr[]也没初始化,岂不是没有元素,占0个字节,int arr[0]也是同理。
hh,其实一点没想错,柔性数组就是这样的,你如果硬要算包含柔性数组的结构体的大小,那最后得到的是除了柔性数组外所有的元素所占的内存大小(不说内存大小和是因为存在对齐,已经学过了)
开辟内存空间
这个时候就要问了,那柔体现在哪呢?你这大小我知道了有用吗?
还真有用:
对于这种结构体,如果要让他合理就得用malloc函数等进行动态的内存分配。
如:
利用计算内存大小的特点,sizeof(S)得到的是除了柔性数组以外其他元素所需要开辟的内存空间,对于最后的int的数组用5 * sizeof(int)表达开辟5个内存空间大小的数组,就不用一个成员一个成员去sizeof()了,而且你一个一个成员算肯定还得顾对齐,充分利用这个特点就行。
3.柔性数组的使用
柔性数组的大小是动态开辟的,是由我们掌控的,那么,使用场景有什么呢?
开辟,用指针维护,不够了再扩容,再使用,最后free再释放,并置空。