综合实战:学生成绩管理系统
说实话,每次带新人做项目,我都会让他们先写一个学生成绩管理系统。为什么?因为这东西麻雀虽小五脏俱全——增删改查、排序、文件操作,几乎涵盖了C语言数据结构的全部核心操作。我自己当年入行时,第一个拿得出手的项目就是这个。
今天咱们就一步步把它拆开,揉碎了讲清楚。
需求分析:别急着写代码
我见过太多人上来就敲键盘,结果写到一半发现结构设计不合理,又推倒重来。嗯,这是典型的「手比脑子快」。
先想清楚我们要做什么:
- 存储什么:学生信息,包括学号、姓名、三门课成绩(语文、数学、英语)
- 能干什么:添加学生、删除学生、修改成绩、按学号查找、按总分排序
- 数据怎么存:运行时用内存结构,退出时保存到文件
说白了,这就是一个「带持久化功能的简易数据库」。你想想看,如果连这个都搞不定,后面那些复杂的嵌入式系统怎么玩?
核心功能清单:
- 添加学生记录
- 删除指定学号的学生
- 修改学生成绩
- 按学号查找并显示
- 按总分从高到低排序
- 显示所有学生信息
- 数据保存与加载
数据结构设计:顺序表还是链表?
这个问题我每次都会被问到。我的回答是:看场景。
顺序表(数组)的优点是随机访问快,缺点是插入删除要移动大量元素。链表正好相反。对于学生管理系统,学生数量一般不超过几百人,而且增删操作不频繁,我个人习惯用顺序表——代码简单,调试方便。
来看一下我设计的数据结构:
#define MAX_STUDENTS 100
#define NAME_LEN 20
typedef struct {
int id; // 学号
char name[NAME_LEN]; // 姓名
float chinese; // 语文
float math; // 数学
float english; // 英语
float total; // 总分
} Student;
typedef struct {
Student data[MAX_STUDENTS];
int count; // 当前学生数量
} StudentList;
这里有个小细节:total 字段我选择实时计算还是存起来?我的建议是存起来。虽然多占了一点内存,但排序时不用每次都重新算,效率高很多。我在项目中遇到过类似的选择题,后来发现「空间换时间」在嵌入式领域往往是正确的。
我的经验:结构体里尽量把常用计算结果缓存起来。比如总分,每次修改成绩时同步更新,排序时直接拿来用,省心。
功能模块实现:一个一个来
1. 添加学生
添加前先检查有没有满。满了就报错,没满就追加到末尾。注意学号不能重复——这个检查不能省。
int addStudent(StudentList *list, int id, char *name,
float ch, float ma, float en) {
if (list->count >= MAX_STUDENTS) {
printf("学生已满,无法添加\n");
return -1;
}
// 检查学号是否重复
for (int i = 0; i < list->count; i++) {
if (list->data[i].id == id) {
printf("学号 %d 已存在\n", id);
return -1;
}
}
Student *s = &list->data[list->count];
s->id = id;
strcpy(s->name, name);
s->chinese = ch;
s->math = ma;
s->english = en;
s->total = ch + ma + en;
list->count++;
return 0;
}
2. 删除学生
删除的核心是「找到它,然后把它后面的元素往前挪」。我曾经犯过一个低级错误——删除后忘了把 count 减1,结果后面添加学生时直接覆盖了最后一个有效数据。嗯,这种bug查起来真要命。
int deleteStudent(StudentList *list, int id) {
int pos = -1;
for (int i = 0; i < list->count; i++) {
if (list->data[i].id == id) {
pos = i;
break;
}
}
if (pos == -1) return -1; // 没找到
for (int i = pos; i < list->count - 1; i++) {
list->data[i] = list->data[i + 1];
}
list->count--;
return 0;
}
3. 修改成绩
这个简单,找到学号,直接改。别忘了同步更新总分。
int updateScore(StudentList *list, int id,
float ch, float ma, float en) {
for (int i = 0; i < list->count; i++) {
if (list->data[i].id == id) {
list->data[i].chinese = ch;
list->data[i].math = ma;
list->data[i].english = en;
list->data[i].total = ch + ma + en;
return 0;
}
}
return -1;
}
4. 查找与排序
查找用顺序查找就行,数据量不大。排序我选冒泡排序——虽然效率不是最高的,但代码可读性好,教学意义强。
void sortByTotal(StudentList *list) {
for (int i = 0; i < list->count - 1; i++) {
for (int j = 0; j < list->count - 1 - i; j++) {
if (list->data[j].total < list->data[j + 1].total) {
Student tmp = list->data[j];
list->data[j] = list->data[j + 1];
list->data[j + 1] = tmp;
}
}
}
}
注意:排序会改变元素顺序。如果你需要保持原始插入顺序,建议加一个「序号」字段,或者排序前先备份。我曾经在项目中因为排序破坏了原始顺序,导致后面的「按学号查找」逻辑出错——教训深刻。
数据持久化:文件读写
程序关了数据就丢了?那不行。我们用二进制文件保存,读写速度快,而且不会出现浮点数精度丢失的问题。
int saveToFile(StudentList *list, const char *filename) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
fwrite(&list->count, sizeof(int), 1, fp);
fwrite(list->data, sizeof(Student), list->count, fp);
fclose(fp);
return 0;
}
int loadFromFile(StudentList *list, const char *filename) {
FILE *fp = fopen(filename, "rb");
if (!fp) return -1;
fread(&list->count, sizeof(int), 1, fp);
fread(list->data, sizeof(Student), list->count, fp);
fclose(fp);
return 0;
}
这里有个坑:如果你修改了 Student 结构体的定义(比如加了字段),旧的文件就没法读了。解决办法是加一个版本号,每次读文件时检查版本是否匹配。嗯,这个技巧我在做嵌入式OTA升级时用过,很实用。
系统整体流程
下面这张图展示了整个系统的核心逻辑,从启动到退出,每一步都清清楚楚:
代码整合与测试
把所有功能模块整合到一个 main.c 里,用菜单循环驱动。我习惯把每个功能封装成独立的函数,这样测试时可以直接调用,不用每次都走菜单流程。
测试用例我一般这么设计:
| 测试项 | 输入 | 预期结果 |
|---|---|---|
| 添加学生 | 学号1001,张三,90,85,88 | 添加成功,总分263 |
| 重复学号 | 再次添加1001 | 提示学号已存在 |
| 删除学生 | 删除学号1001 | 删除成功,数量减1 |
| 排序 | 添加多个学生后排序 | 按总分降序排列 |
| 文件持久化 | 保存后重启程序 | 数据完整恢复 |
测试小技巧:写一个自动测试函数,批量添加几十个随机学生数据,然后验证排序和查找的正确性。手动点菜单太慢了,我一般写个脚本跑一遍。
最后说一句:这个系统虽然简单,但它是你理解「数据结构+算法+工程实践」的绝佳起点。把代码写出来,跑通,再回头看看——你会发现很多之前模糊的概念一下子就清晰了。
公众号:蓝海资料掘金营,微信deep3321