一、为什么存在动态内存分配
我们已经掌握的内存开辟方式有:
int val = 20; //在栈空间上开辟四个字节 char arr[10] = {0}; //在栈空间上开辟10个字节的连续空间
但是上述的开辟空间的方式有两个特点:
- 空间开辟大小是固定的
- 数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配
- 那此时呢我们就希望有一种方式,可以在程序运行的过程中动态地去开辟当前程序所需要的内存空间,此时就需要使用到我们的【动态内存函数】了
二、动态内存函数的介绍
本文我总共会介绍三种动态内存函数,分别是
malloc()
、calloc()
、realloc()
,与之对应内存释放函数还有free()
1、malloc和free
【函数原型】:
void* malloc (size_t size);
【函数解读】:
- 首先我们来看一下
malloc()
这个函数,它会向内存申请一块连续可用的空间,并返回指向这块空间的指针
【特点】:
- 如果开辟成功,则返回一个指向开辟好空间的指针
- 如果开辟失败,则返回一个
NULL
指针,因此malloc的返回值一定要做检查 - 返回值的类型是
void*
,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定
这里我们来举一个例子说明一下
- 可以看到,我在使用一个动态开辟出来的内存时分配四步走(不止四步),首先使用到
malloc()
函数去向内存申请大小为40的空间,由于其返回值是一个void*
的指针,可以接收任何类型的指针,所以这里我去做了一个强转,将这块空间强制类型转换为int*
- 上面说到在开辟空间的时候会有失败的可能性,所以我们要去做一个异常判断,若是这个指针为空的话,表明我们完全没有申请到相应的空间,那这个时候再去对这块地址进行操作的话就会造成==空指针异常==的问题
- 在明确这块空间被开辟出来后,我们要先去做一个初始化操作,指针的访问这一块就不细说了,不太懂的同学可以去看看C语言指针一文。在初始化后就是将其去进行一个打印的操作
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // 1.开辟空间 int* p = (int *)malloc(40); // 2.异常判断 if (NULL == p) { perror("malloc fail"); exit(-1); } // 3.初始化空间 for (int i = 0; i < 10; ++i) { *(p + i) = i + 1; } // 4.打印观察 for (int i = 0; i < 10; ++i) { printf("%d ", *(p + i)); } return 0; }
- 我们通过调试来进行观察,便可以发现我们刚好将所开辟的40个空间存放了10个整型数据
- 如果参数
size
为0,malloc的行为是标准是未定义的,取决于编译器。
- 还有一个特点,单独再说一下,看了上面的函数解读后可以知道我们需要给
malloc()
函数传递进去一个size大小,它便会为我们开辟出指定的空间,但若是我们传递的参数为0的话,就显得很荒唐。 - 举个例子:就好比你向别人借钱,如果你说要借50、100那还算正常,但是说 “我要借0元”,那对方就会感觉到很奇怪,他到底要给你些什么东西呢?那编译器其实也是一样的,不过呢,既然你去要东西了,它还是会给你点什么。通过调试可以观察到虽然我们没有申请到任何的东西,但是呢却有了这么一块地址,这还是要看不同的编译器,反正在VS下还是会给你一个反应的
但是呢就上面这一些操作还是不够的,别忘了我们还有一个
free()
函数还没介绍呢
【函数原型】:
void free (void* ptr);
【函数解读】:
- 然后我们来看看这个函数,它主要用来释放动态开辟的内存
【特点】:
- 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的
- 如果参数 ptr 是NULL指针,则函数什么事都不做
- 所以我们在刚才那段代码的下面应该再加上一个
free(p)
才行,但是这样真的就可以了吗?
free(p);
- 我们可以通过调试来观察一下,当执行完这句代码后初始化的1 ~ 10变成了一些随机值,这也就意味着我们一开始申请的这块空间还给操作系统了,所以里面所存放的这些内容都销毁了,不过从上面对于这个函数的解读中我们可以看出即使我们将这块空间还给操作系统了,但是这块申请空间的地址还是在的
- 那么也就意味着这个指针p现在变成【野指针】了,变得非常危险
- 若是我们想化解这个危机的话,可以在
free(p)
之后再将其置为NULL即可,此时就无法再找到之前的那块地址了
【注意实现】:
- malloc和free都声明在
stdlib.h
头文件中,记得要引头文件 - 每次在使用【malloc】申请完一块空间后,一定要去做一个判空,预防申请失败的情况。而且在使用完这块空间后还要将其归还给操作系统,并且将指针所指向的这块地址置为空,防止野指针
2、calloc
讲完【malloc】之后我们再来讲讲另一个动态内存函数【calloc】
【函数原型】:
void* calloc (size_t num, size_t size);
【函数解读】:
- C语言还提供了一个函数叫 calloc , calloc 函数也用来动态内存分配。原型如下:
【特点】:
- 函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0
- 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0
- 一样,我们可以通过调试来进行观察,与【malloc】不同的地方在于当我们申请到10个大小为4字节的空间后,发现这10个数据均为0,即在申请的同时就已经为初始化好了,不需要我们自己再去初始化
- 如果还是觉得有点不可思议的话,我们可以再通过汇编去仔细看看
💬 所以如何我们对申请的内存空间的内容要求初始化,那么可以很方便的使用calloc函数来完成任务
3、realloc
最后再来讲讲另一个动态内存函数【realloc】
【函数原型】:
ptr
是要调整的内存地址、size
是调整之后新大小、返回值为调整之后的内存起始位置
void* realloc (void* ptr, size_t size);
【函数解读】:
- realloc函数的出现让动态内存管理更加灵活,有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的使用内存,我们一定会对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小的调整
具体地我们来看一下要如何去使用这个
realloc()
进行一个扩容
- 可以看到,在下面我首先申请了5个整型空间的大小,对其做了初始化之后就去做了一个扩容,要扩容的地址即为
p
,扩充后的容量便是10个整型数据
int main(void) { int* p = (int*)malloc(sizeof(int) * 5); if (NULL == p) { perror("malloc fail"); exit(-1); } for (int i = 0; i < 5; i++) { *(p + i) = i; } // 不够了,增加5个整型空间 p = (int*)realloc(p, sizeof(int) * 10); return 0; }
💬 不过呢,我这里还要讲一下这个realloc
到底是怎么进行扩容的,因为它有一个扩容机制,分为【本地扩容】和【异地扩容】
realloc扩容机制:【本地扩容】和【异地扩容】
- 本地扩容,即在本地就有足够的空间可以扩容,此时直接在后面续上新的空间即可
- 异地扩容:当后边没有足够的空间可以扩容,realloc函数会找一个满足空间大小的新的连续空间。把旧的空间的数据,拷贝到新空间的前面的位置,并且把旧的空间释放掉(无需手动释放),同时返回新的空间的地址
可能这么说还不是很好理解,我这里再通过一个生活小案例来帮助理解
- 平常我们外出旅游的时候由于比较遥远,无法一天之内回来,就会选择一些酒店或者旅馆🏠暂住一宿,那假设这个时候有一个旅行团要去住酒店,因为这家酒店只有单人间,可是呢他们有四个人,因为感情好,想住在一起,所以就让酒店前台开了一些连着的四个房间,互相之间串门方便一些,对于给出的这四个房间其实就是一开始为数组malloc空间,比较小一些
- 然而这个时候呢,他们四个人又分别叫了自己的伙伴来,一起进行下一天的结伴旅行,想要和他们住在一起,于是问酒店前台小赵🤵可以不可以在已经为他们开的四个房间的后面再连续地开四个房间,这样他们8个人就可以住在一块了,虽然这很过分😀,但是刚好真的有连续的房间空出来,所以就又为他们开了四间房,这个时候新开的四间房就叫做==本地扩容==,就是在与上一次开辟空间后临接着开辟的
- 这个时候这个前台小萌新就不知道怎么办了,于是去隔壁找了一个经验丰富的管理人员【老王】,老王这个时候想,既然他们是朋友,那就找一个一排空房间给到他们好了,原来的四间房还可以空出来。就在酒店的另一个大区域为他们开了八间房,然后让服务员把原来的四个人叫出来,把他们安置到新的四间房内,然后他们住过的房间就可以重新空出来为其他房客用了,接下去呢又把新来的四个人安排在他们的后面的接连房间内,于是他们8个人就并排地住在了一起,过上了幸福美满的生活。。。。哦,不对,应该是度过了一个美好的晚上🏠
- 这里说的为他们8个人重新找一块区域安置就叫做==异地扩容==,也就是将原本开辟的空间中所存放的内容拷贝过来,然后放到新的空间中,接着把需要新放入的内容接着旧的内容之后
💬 通过上述这样一个例子,你是否理解了【本地扩容】和【异地扩容】呢 👈
【注意事项】:
- 这里我还要讲一个注意点,如果仔细一点点学习下来的同学一定会想到一个问题,如果在扩容的时候失败了怎么办呢?此时
realloc
就会返回一个空指针 - 但是当我们上面对这个指针p所指向的地址进行扩充后,又将其赋值给了自己,若真像我们上面所扩容失败返回空指针的情况,此时再去使用p的时候就会出现【空指针异常】的问题
p = (int*)realloc(p, sizeof(int) * 10);
💬 那有同学说:这该怎么办呀🤔
- 对于这个问题,我们的解决办法一般是这样的,定义一个新的指针
tmp
去指向这块空间,再扩容结束后再去判断一下这个指针是否NULL
,若是为NULL的话代表扩容失败,此时应该打印错误信息然后结束程序,不要再往下执行了,而是当这个地址不为空的时候再将让原先的指针p
指向它,让我们从头至尾都在维护同一个指针 - 因此我们在扩容之后应该再去加上这么一个判断才行,在赋值完后别忘了把临时的
tmp
指针置为空,防止其变为【野指针】
// 不够了,增加5个整型空间 int* tmp = (int*)realloc(p, sizeof(int) * 10); if (tmp == NULL) { perror("fail realloc"); exit(-1); } p = tmp; tmp = NULL; // 这个指针不用了,记得置为空
当代码补充完整后,我们再通过调试来观察一下本地扩容和异地扩容
- 首先是本地扩容,可以看到
realloc
返回的地址就是原先开辟出来那块空间的首地址
- 然后是异地扩容,我们可以将需要扩充后的容量调大,这样后续的容量就会不够了,此时编译器便会在内存中再去找一块合适大小的空间,然后将原先的5个整型数据先拷贝过去,然后再在其后开辟出剩余的空间,最后再释放掉原先的那块空间
实际应用:数据结构之【顺序表】与【顺序栈】
对于这个【realloc】,它是有实际的应用场景的
- 首先第一个就是我们在数据结构之顺序表中在进行【尾插】的时候所做的扩容检查工作
- 以下具体的代码实现,对于顺序表来说,我在一开始是没有给他分配任何空间的,因此在进行第一次尾插的时候就会进入到下面这段扩容机制中,首先就判断当前顺序表的容量是多少,再来决定需要扩容的大小,这里就很好地利用了
realloc
的一个机制:当传递的指针为空的时候,其所表现得行为就和malloc
是一样的
//检查是否需要扩容 void SLCheckCapacity(SL* ps) { if (ps->size == ps->capacity) { int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2; SLDataType* tmp = (SLDataType*)realloc(ps->a, newCapacity * sizeof(SLDataType*)); //判断是否开辟空间成功【失败会返回空指针null pointer】 if (tmp == NULL) { perror("realloc fail\n"); exit(-1); //结束掉程序【程序异常结束】 } //扩容成功 ps->a = tmp; ps->capacity = newCapacity; } }
- 我们在数据结构之顺序栈中在讲入栈操作的时候也有使用到它,因为对于顺序栈来说会出现空间不够的情况,所以我们也需要去实现一个扩容的机制,
- 以下就是具体的入栈代码实现,判断当前栈顶指针是否达到了栈的容量大小,如果是的话就找执行扩容逻辑,每次扩容的大小为原先的2倍,也是使用到了临时的指针
tmp
去做一个接受,判断其不为空后再去使用扩容之后的这块空间
/*入栈*/ void PushStack(ST* st, STDataType x) { //栈满扩容逻辑 if (st->top == st->capacity) { //初始化时已经malloc开辟过空间了,因此无需考虑容量为空的情况 STDataType* tmp = (STDataType*)realloc(st->a, st->capacity * 2 * sizeof(STDataType)); if (tmp == NULL) { perror("fail realloc"); exit(-1); } st->a = tmp; st->capacity *= 2; } st->a[st->top] = x; //top指向栈顶元素的后一元素,因此直接入栈即可 st->top++; //然后栈顶指针后移,为下一次入栈做准备 }