4-7 堆和动态数组:学生再多也能保存
⏱ 本节预计用时:45分钟
本节意义:很多时候,你没办法提前确定数据的多少,开辟小容量数组可能会溢出,开辟过大容量的数组又会浪费空间,这时就要用到动态数组,把数据放在堆上。实际工作中必用。
本节目标:知道栈和堆是内存上的两块地,会用malloc/free/realloc从堆上申请释放内存;把系统的数组从"写死 10 个"改成"满了自动翻倍"。
1 内存的两块地
我们声明数组的语法长这样。
Student students[10];/* 编译的时候就把大小定死了 */这句话的意思是,从内存的栈区域里要一块10个(Student类型)变量大小的空间。
栈是内存的一块地。它上边存放的数据在编译时就确定好占用大小了,运行时没办法再改变大小。
内存里还有另一块地,叫堆,那里存放数据的大小是在运行时确定。换言之,我们把数组放在那里,就可以实现跑起来程序之后按需决定大小。
堆和栈的更多区别请看下节。
下面我们来看从堆上要空间的具体语法。
2 去堆上要空间
2.1malloc:大小运行时才定
申请一块地,用malloc(memory allocation,“分配内存”):
#include<stdio.h>#include<stdlib.h>intmain(){intn;printf("想开多大的数组?");scanf("%d",&n);int*a=malloc(n*sizeof(int));/* 大小运行时才定 */if(a==NULL){printf("要地失败!\n");return1;}for(inti=0;i<n;i++){a[i]=i+1;}printf("有 %d 个位置,最后一个装的是 %d\n",n,a[n-1]);free(a);/* 用完还回去 */a=NULL;return0;}跑一下,输入 5:
想开多大的数组?5 有 5 个位置,最后一个装的是 5输入 5,就真的拿到 5 个位置;输入 500,就有 500 个。
需要说明:
malloc(n * sizeof(int)):sizeof(int)是一个 int 占几个字节(我们的机器上是 4),乘上n,就是要"多少字节的地"。- 返回的是地址:
malloc把这块地的首地址交给你,所以要用int *a接着。拿到地址,a[i]照常用。 - 必须判 NULL:内存不够时会返回
NULL(空指针)。不判就用,程序会崩。
2.2free:用完还回去
malloc要来的地,不会自己消失。函数结束了、程序忘了它,那块地也一直被占着——这叫内存泄漏。所以用完必须还:
free(a);/* 把地还回去 */a=NULL;/* 指针置空,免得再误用 */malloc和free必须配对使用,写一次malloc就要有一次free;malloc之后判NULL,free之后置NULL。
2.3realloc:地不够了,换块更大的
要了 5 个位置,装到第 6 个怎么办?总不能重新malloc一遍、再自己把数据搬过去。用realloc(re-allocation,“重新分配”):
int*p=malloc(5*sizeof(int));/* 先要 5 个位置 *//* ……装着装着,5 个不够了…… */int*q=realloc(p,10*sizeof(int));/* 换一块能装 10 个的 */realloc干三件事:去要一块新地 → 把老数据搬过去 → 把老地还掉。你只要接住它返回的地址。
有两个坑:
坑一:它不一定在原地扩,可能搬家。所以要用返回值更新指针,别指望老地址还管用。
坑二:失败了返回NULL,而原来的地还在。所以别这么写:
p=realloc(p,10*sizeof(int));/* ❌ 危险写法 */万一是NULL,p就成了空指针,老地址一丢,那块地再也还不了(泄漏)。稳妥的写法是先用临时指针接住:
int*bigger=realloc(p,10*sizeof(int));if(bigger==NULL){/* 扩不成,p 还能照常用,至少没把它弄丢 */}else{p=bigger;/* 换到新地 */}记住:realloc的返回值,先接住、判空、再更新。
完整示例:
/* 7-2realloc.c —— realloc:地不够了,换块更大的 */ #include <stdio.h> #include <stdlib.h> int main(void) { int *p = malloc(5 * sizeof(int)); /* 先要 5 个位置 */ if (p == NULL) { printf("要地失败!\n"); return 1; } for (int i = 0; i < 5; i++) { p[i] = i + 1; /* 先把 5 个位置填满 */ } printf("现在有 5 个位置:"); for (int i = 0; i < 5; i++) { printf("%d ", p[i]); } printf("\n"); /* 装着装着,5 个不够了,干脆换一块能装 10 个的 */ printf("5 个不够了,换一块更大的……\n"); int *bigger = realloc(p, 10 * sizeof(int)); /* 先接住返回值 */ if (bigger == NULL) { /* 扩不成,老地还在,p 还能照常用,至少没把它弄丢 */ printf("要地失败!老地还在。\n"); free(p); return 1; } p = bigger; /* 确认成功,才更新指针 */ for (int i = 5; i < 10; i++) { p[i] = i + 1; /* 新位置照填 */ } printf("现在有 10 个位置,老数据一个没丢:"); for (int i = 0; i < 10; i++) { printf("%d ", p[i]); } printf("\n"); free(p); /* 用完还回去 */ p = NULL; return 0; }运行情况:
2.4 栈上能不能大小运行时才定*
这块内容可以跳过不看。
从 C99 开始,还真行。栈上也可以像malloc一样,拿输入的值作为数组的大小来开辟数组。我们管着这叫变长数组。
#include<stdio.h>intmain(){intn;printf("要几个位置?");scanf("%d",&n);inta[n];/* C99 变长数组:大小运行时才定,但地还在栈上 */for(inti=0;i<n;i++){a[i]=i+1;}printf("有 %d 个位置,最后一个装的是 %d\n",n,a[n-1]);return0;/* 函数一返回,这块地自动回收 */}输入 5,输出和上面malloc那个一模一样:
要几个位置?5 有 5 个位置,最后一个装的是 5但也就只能这样了。栈上的地有两个硬限制:
一、不能重新分配(用不了realloc)。
inta[n];a=realloc(a,10*sizeof(int));/* 编译直接报错 */报错是error: assignment to expression with array type——a是数组名,数组名不能赋值(4-1 讲过:它是"门牌号",不是能改的变量)。就算你不接返回值、光调一下realloc(a, ...),也管不了这块地:它不在堆上,realloc管不着。
二、不能自己销毁(用不了free)。
inta[n];free(a);/* 编译能过,但一运行就崩 */这行 gcc 会给一句警告('free' called on pointer to an unallocated object),可真跑起来程序直接崩——你把一块不属于堆的地"还"了出去。
一张表对比:
| 栈上变长数组 | 堆上动态数组 | |
|---|---|---|
| 大小什么时候定 | 运行时,声明时就完全确定好了 | 运行时,随时 |
| 想变大 | ❌ 不行 | ✅realloc |
| 想提前销毁 | ❌ 不行 | ✅free |
| 谁回收 | 函数返回自动回收 | 你手动free |
| 适合多大 | 小(栈就几 MB) | 大 |
| 优点 | 比堆快一丢丢、语法简单 | 在扩容问题里,几乎全是优点 |
理论上来说,大小一次定死、用完就走的,可以用栈上的变长数组;需要长大、需要跨函数活着、需要自己掌控生死的,上堆。
实际使用时,无脑上堆,因为运行时定大小它一般不是只开始定一次,而是一直变。
就比如我们的学生管理系统,学生数会一直涨,所以必须得用malloc+realloc。
3 改造StudentSystem:从"写死"到"会长大"
知识铺垫结束,开始改项目。
这一节要动五个文件:model/student.h、model/student.c(改数据结构、加要地/还地/扩容)、fileio/fileio.c(读盘改成按需要地)、controller/controller.c(多报一句扩容),还有main.c(开机要地、关机还地)。
3.1 先改数据结构
model/student.h里,把写死的数组换成"指针 + 两个计数":
#defineINITIAL_CAPACITY10/* 一开始先要 10 个位置,不够再长 */typedefstruct{Student*students;/* 不再写死大小:指向堆上的一块地 */intcount;/* 当前有几个学生 */intcapacity;/* 这块地现在能装几个 */}StudentSystem;三个字段各管一摊,想清楚:count是"现在有几个人",capacity是"现在能装几个人"。多数时候count < capacity,一旦相等,就该长大了。
同步加两个新函数的声明:
intsystem_init(StudentSystem*sys);/* 开机:去堆上要地 */voidsystem_destroy(StudentSystem*sys);/* 关机:把地还回去 */3.2 开机要地,关机还地
在model/student.c里加上(记得#include <stdlib.h>):
/* 开机:去堆上要一块地,能放 INITIAL_CAPACITY 个学生 */intsystem_init(StudentSystem*sys){sys->students=malloc(INITIAL_CAPACITY*sizeof(Student));if(sys->students==NULL){/* 要地失败,别硬往下走 */sys->count=0;sys->capacity=0;return0;}sys->count=0;sys->capacity=INITIAL_CAPACITY;return1;}/* 关机:把地还回去(malloc 一次,free 一次) */voidsystem_destroy(StudentSystem*sys){free(sys->students);sys->students=NULL;sys->count=0;sys->capacity=0;}以前StudentSystem sys = {0};就能用,现在不行了,得先system_init去要地。所以下面 main 也要改。
3.3 满了就空间翻倍再搬家
system_add_student里,原来"满了就返回ERR_FULL",现在改成"满了就搬到一块更大的空间里",单抽象出来一个函数system_grow(写在student.c,记得要有声明在system_add_student函数的前面)。
/* 地不够大就翻倍再要一块(老数据搬到新地上) */intsystem_grow(StudentSystem*sys){intnew_capacity=sys->capacity*2;Student*p=realloc(sys->students,new_capacity*sizeof(Student));if(p==NULL){/* 要不到更大的地 */return0;}sys->students=p;/* 换到新地 */sys->capacity=new_capacity;return1;}这里用的就是我们 2.3 刚学的realloc。还是那三步:要更大的地、搬老数据、还老地。
add里加一步:
Resultsystem_add_student(StudentSystem*sys,Student stu){/* 1. 学号重不重? */for(inti=0;i<sys->count;i++){if(sys->students[i].id==stu.id){returnERR_DUPLICATE;}}/* 2. 地够不够?不够就长大 */if(sys->count>=sys->capacity){if(!system_grow(sys)){returnERR_NOMEM;}}/* 3. 算好总分平均分,整体放进去 */calculate_student_total(&stu);sys->students[sys->count]=stu;sys->count++;returnRESULT_OK;}翻倍而不是每次只加 1 个, 这是动态数组的标准做法。
这么想,加 1 个位置也要搬家,太亏;翻倍搬家次数少,摊下来每次添加几乎不花力气。翻倍也没有一下就占很大内存空间。
整个逻辑是,第 11 个学生进来时,地是 10,不够了 → 翻倍要到 20 → 老 10 个人搬过去 → 第 11 个人住进新地。
3.4 换状态码
数组会长大了,"装满了"这件事就不存在了,所以删掉ERR_FULL,现在是可能会扩容失败,加入ERR_NOMEM。
typedefenum{RESULT_OK,/* 成功 */ERR_DUPLICATE,/* 学号已存在 */ERR_NOT_FOUND,/* 没找到该学号 */ERR_SUBJECT,/* 科目序号不对 */ERR_NOMEM/* 内存不够,扩容失败 *//* 注意:ERR_FULL(容量满)没了——数组会长大,"装满"这件事不再存在 */}Result;这里model依旧不printf,只返回状态码,printf的事情交给controller。
4 修改文件读写
fileio/fileio.c里,上一节的保护代码现在可以删了:
if(count>MAX_CAPACITY){printf("警告:文件中有 %d 个学生,当前版本最多装 %d 个,只装前 %d 个。\n",...);count=MAX_CAPACITY;}改成"文件里有几个,就去要几块地(注意文件开头加#include <stdlib.h>):
intload_system(StudentSystem*sys,constchar*filename){FILE*fp=fopen(filename,"r");if(!fp){/* 文件不存在(第一次启动)直接返回失败 */return0;}intcount;fscanf(fp,"%d",&count);/* 地不够大,先要一块刚好装得下的(不用再"只装前 10 个"了) */if(count>sys->capacity){Student*p=realloc(sys->students,count*sizeof(Student));if(p==NULL){fclose(fp);return0;}sys->students=p;sys->capacity=count;}sys->count=count;/* 再一个个读学生 */for(inti=0;i<sys->count;i++){Student*s=&sys->students[i];fscanf(fp,"%d %s %f %f %f",&s->id,s->name,&s->scores[0],&s->scores[1],&s->scores[2]);/* 读到了,重新算总分平均分 */calculate_student_total(s);}fclose(fp);return1;/* 成功 */}5 修改main:启动时从堆上要空间,退出时释放空间
main.c改三处:开头system_init,退出前system_destroy。
完整main.c:
/* main.c —— 指挥:开机要地、启动加载、退出保存并还地 */#include<stdio.h>#include"model/student.h"#include"view/view.h"#include"util/input.h"#include"controller/controller.h"#include"fileio/fileio.h"intmain(void){StudentSystem sys;/* 第一件事:去堆上要地(要不到就不干了,直接报错退出) */if(!system_init(&sys)){printf("初始化失败:内存不够。\n");return1;}/* 第二件事:从文件读进来 */if(load_system(&sys,"students.dat")){printf("\n 已加载上次保存的 %d 名学生。\n",sys.count);}while(1){printf("\n========== 学生成绩管理系统 ==========\n");printf(" 1. 查看所有学生\n");printf(" 2. 添加学生\n");printf(" 3. 删除学生\n");printf(" 4. 修改成绩\n");printf(" 5. 按学号查询\n");printf(" 6. 按姓名查询\n");printf(" 7. 统计信息\n");printf(" 8. 退出\n");printf("请选择操作(1-8):");intchoice=read_int(1,8);switch(choice){case1:view_show_all(&sys);break;case2:flow_add_student(&sys);break;case3:flow_delete_student(&sys);break;case4:flow_update_score(&sys);break;case5:flow_find_student(&sys);break;case6:flow_find_student_by_name(&sys);break;case7:view_show_statistics(&sys);break;case8:save_system(&sys,"students.dat");system_destroy(&sys);/* 走之前把地还回去 */printf("数据已保存,感谢使用,再见!\n");return0;default:printf("× 无效输入,请输入 1-8!\n");}}}controller 改一处:添加成功后,顺便看一眼地是不是长大过,长过了就打印对应信息。
intold_capacity=sys->capacity;/* 开头先记一下原来多大 */switch(system_add_student(sys,stu)){caseRESULT_OK:printf("添加成功!现在共 %d 名学生。\n",sys->count);if(sys->capacity!=old_capacity){/* 地长大过了,报一声 */printf("(装不下了,系统已自动扩容到 %d 个位置)\n",sys->capacity);}break;caseERR_DUPLICATE:printf("学号已存在!\n");break;caseERR_NOMEM:printf("内存不够,扩容失败,添加没成功。\n");break;default:break;}6 编译运行验收
编译:
gcc main.c model/student.c view/view.c controller/controller.c util/input.c fileio/fileio.c -o student_mgr.exe ./student_mgr.exe运行:
修改student.dat文件为10个学生(可以直接让ai根据格式给你生成)。
打开后,添加到第11 个学生,可以看到很丝滑地添加进去了。
关掉程序,打开student.dat,可以看到这11个学生的数据。现在我们修改为20个学生。
依旧能正常打开。
添加到第21个学生,又是丝滑地添加进去了。
7 总结
- 栈上的地,大小定下来就改不了;堆上的地可以随时变大——
malloc要地,realloc长大,free还地。 malloc给的是首地址,用指针接住,p[i]照样能用。realloc= 要更大的地 + 搬老数据 + 还老地;返回值先接住、判空、再更新;"满了翻倍"是动态数组的标准做法。- 栈上也能按需开地(C99 变长数组),但只能定一次,不能长大、不能提前销毁——要"会长大"就得上堆。
- 纪律:
malloc一次、free一次;要完判NULL,还完置NULL。
张老师的三个问题,到这一节全部解决了:
| 问题 | 状态 |
|---|---|
| 关掉数据全没 | ✅ 4-5 文件读写搞定 |
| 按名字找人 | ✅ 4-6 字符串处理搞定 |
| 40 人装不下 | ✅ 本节搞定 |
恭喜你,做出来了一个命令行版的学生成绩管理系统,下一章,我们将把它变成图形版,在raylib的基础上,引入大量全新的内容,你将掌握更多指针的高阶用法和编程思想。敬请期待!