
🎬 博主名称 :键盘敲碎了雾霭
🔥 个人专栏 : 《C语言》《C语言刷题(初阶)》
⛺️指尖敲代码,雾霭皆可破
文章目录
-
- 引言
- 一、系统功能概述
- 二、技术选型与开发环境
- 三、系统设计
-
- [3.1 模块划分](#3.1 模块划分)
- [3.2 数据结构设计](#3.2 数据结构设计)
-
- [3.2.1 用户信息结构体](#3.2.1 用户信息结构体)
- [3.2.2 学生信息结构体](#3.2.2 学生信息结构体)
- [3.2.3 链表节点结构体](#3.2.3 链表节点结构体)
- [3.3 全局变量](#3.3 全局变量)
- 四、登录模块详细实现
-
- [4.1 登录菜单界面](#4.1 登录菜单界面)
- [4.2 主登录循环](#4.2 主登录循环)
- [4.3 用户注册与登录验证](#4.3 用户注册与登录验证)
-
- [4.3.1 检查用户是否存在](#4.3.1 检查用户是否存在)
- [4.3.2 验证用户名密码](#4.3.2 验证用户名密码)
- [4.3.3 保存新用户](#4.3.3 保存新用户)
- [4.4 倒计时函数](#4.4 倒计时函数)
- [4.5 游客模式与操作员切换](#4.5 游客模式与操作员切换)
- 五、管理模块详细实现
-
- [5.1 链表初始化与数据加载](#5.1 链表初始化与数据加载)
-
- [5.1.1 从文件加载学生信息](#5.1.1 从文件加载学生信息)
- [5.2 添加学生](#5.2 添加学生)
- [5.3 显示所有学生](#5.3 显示所有学生)
- [5.4 查找指定学生](#5.4 查找指定学生)
- [5.5 修改学生成绩](#5.5 修改学生成绩)
- [5.6 删除学生](#5.6 删除学生)
- [5.7 考试(随机生成成绩)](#5.7 考试(随机生成成绩))
- [5.8 排序](#5.8 排序)
- [5.9 统计分数区间](#5.9 统计分数区间)
- [5.10 保存数据到文件](#5.10 保存数据到文件)
- [5.11 销毁链表](#5.11 销毁链表)
- 六、控制台界面优化
-
- [6.1 光标定位](#6.1 光标定位)
- [6.2 清屏](#6.2 清屏)
- [6.3 边框与对齐](#6.3 边框与对齐)
- [6.4 倒计时与动态刷新](#6.4 倒计时与动态刷新)
- [6.5 错误提示与输入位置重置](#6.5 错误提示与输入位置重置)
- 七、关键代码逐段解析
-
- [7.1 主函数逻辑](#7.1 主函数逻辑)
- [7.2 管理菜单主循环](#7.2 管理菜单主循环)
- [7.3 输入验证示例](#7.3 输入验证示例)
- [7.4 全局变量的使用](#7.4 全局变量的使用)
- 八、遇到的问题及解决方案
-
- [8.1 内存泄漏与野指针](#8.1 内存泄漏与野指针)
- [8.2 输入缓冲区残留](#8.2 输入缓冲区残留)
- [8.3 文件打开失败](#8.3 文件打开失败)
- [8.4 学号生成逻辑](#8.4 学号生成逻辑)
- [8.5 排序后顺序混乱](#8.5 排序后顺序混乱)
- [8.6 控制台光标闪烁](#8.6 控制台光标闪烁)
- 九、项目总结与反思
-
- [9.1 收获](#9.1 收获)
- [9.2 不足与改进方向](#9.2 不足与改进方向)
- [9.3 扩展建议](#9.3 扩展建议)
- 十、完整代码与运行截图
- 十一、结语
引言
C语言是许多编程学习者的入门语言,但学完指针、结构体、文件操作和链表后,往往缺少一个综合性的实战项目来巩固知识。学生成绩管理系统是一个经典的练手项目,它涵盖了数据的增删改查、排序、统计、文件存储等核心功能,非常适合用来串联所学知识。
今年寒假,我花了三天时间,从需求分析到代码实现,独立完成了一个学生成绩管理系统 。这个系统支持用户注册登录、学生信息的增删改查、考试成绩随机生成、按成绩排序、按分数区间统计,并且所有数据都通过文件持久化存储。在开发过程中,我使用了双向循环链表 存储学生数据,利用Windows API 优化控制台界面显示,通过文件读写实现用户信息和学生数据的保存。
本文将详细介绍这个项目的开发全过程,包括功能设计、数据结构选择、代码实现细节、遇到的问题及解决方案,以及一些优化建议。希望通过这篇万字长文,能够帮助正在学习C语言的你更好地理解综合项目的开发流程,并掌握一些实用的编程技巧。
一、系统功能概述
本系统分为两个主要模块:登录注册模块 和学生管理模块。整体业务流程如下:
- 启动程序:显示登录菜单,用户可选择登录、注册、游客登录或退出。
- 登录成功 :进入管理菜单。游客登录后只能查看菜单(输入指令无效),输入
10可切换至操作员模式(权限提升)。 - 管理功能 :操作员可进行以下操作:
- 0.退出系统:保存数据后退出。
- 1.添加一名学生:输入姓名和成绩,自动生成学号(学号按添加顺序递增)。
- 2.保存数据进文件 :将当前链表中的学生数据写入文件
stu.dat。 - 3.显示指定学生信息:按姓名查找并显示该学生的学号、姓名、成绩。
- 4.显示所有学生信息:以表格形式列出全部学生的信息。
- 5.修改指定学生信息:按姓名查找,修改其成绩。
- 6.删除指定学生信息:按姓名删除学生记录。
- 7.考试:为所有学生随机生成50~100分的成绩(模拟考试)。
- 8.根据成绩顺序查看:按成绩升序显示所有学生(显示后自动恢复按学号排序)。
- 9.统计:输入分数区间,显示该区间内的学生名单。
每个操作都有相应的UI提示和结果反馈,用户输入错误时会有提示并要求重新输入,保证了程序的健壮性和用户体验。
二、技术选型与开发环境
- 编程语言:C语言(C89/99标准)
- 开发环境:Visual Studio 2022(Windows平台)
- 依赖库 :
stdio.h:标准输入输出stdlib.h:动态内存分配、随机数、系统调用string.h:字符串处理windows.h:控制台光标定位、Sleep函数assert.h:断言调试stdbool.h:布尔类型time.h:随机数种子
由于使用了Windows API,本程序只能在Windows系统下编译运行。如果需要在Linux下运行,需要替换光标定位和清屏的实现方式(如使用ANSI转义序列或ncurses库)。
三、系统设计
3.1 模块划分
项目采用多文件组织,将不同功能的代码分离,便于维护和复用。文件结构如下:
com.h:公共头文件,包含所有库的引入、结构体定义、全局变量声明、工具函数声明。login.h/login.c:登录注册模块,负责用户管理、登录界面、注册界面、游客登录等。Manage.h/Manage.c:学生管理模块,负责学生信息的增删改查、排序、统计、文件保存等。main.c:程序入口,控制登录和管理的流程切换。
3.2 数据结构设计
3.2.1 用户信息结构体
c
struct User
{
char name[20];
char code[20];
};
用于保存用户名和密码。密码以明文形式存储(实际项目中应加密),因为本项目侧重功能演示。
3.2.2 学生信息结构体
c
struct Message
{
int num; // 学号
char name[20]; // 姓名
int score; // 分数
};
3.2.3 链表节点结构体
c
typedef struct ListNode
{
struct Message message; // 学生信息
int count; // 链表节点数量(仅头结点使用)
struct ListNode* pre;
struct ListNode* next;
} List;
这里使用带头结点的双向循环链表 。头结点不存储有效学生数据,其 count 字段记录链表中学生个数,方便判断空链表和获取长度。pre 和 next 分别指向前驱和后继节点,循环的特性使得尾插和遍历非常方便。
为什么选择双向循环链表?
- 双向:可以方便地向前或向后遍历,对于删除节点等操作更加灵活(无需额外记录前驱)。
- 循环 :头结点的
pre指向最后一个节点,尾插时无需遍历到尾,时间复杂度O(1)。 - 带头结点:统一空表和非空表的操作,避免对头指针的频繁修改。
3.3 全局变量
在 com.h 中声明了两个全局变量:
c
extern int Key;
extern struct User user1;
Key:用于标识当前用户是否为操作员(1为操作员,0为游客)。游客模式下不能执行管理操作。user1:当前登录的用户信息,在登录成功后设置,用于在管理界面显示操作员姓名。
在 login.c 中定义并初始化:
c
struct User user1 = {"游客"};
int Key = 1; // 注意:Manage.c 中重新定义了 Key=1,后面会说明
这里存在一个小问题:Key 在 login.c 和 Manage.c 中分别定义,会导致重复定义。实际项目中应该只在 Manage.c 中定义一次,并在 com.h 中用 extern 声明。需要检查代码的一致性。
四、登录模块详细实现
登录模块负责用户的注册、登录、游客登录和退出功能。所有用户数据保存在二进制文件 user.dat 中。
4.1 登录菜单界面
c
void LoginMenuUI(void)
{
system("cls");
GotoXY(0, 0);
printf("\n\n");
printf("\t\t\t\t\t************************************\n");
printf("\t\t\t\t\t* 欢迎使用本系统 *\n");
printf("\t\t\t\t\t* (相关操作请直接输入对应指令) *\n");
printf("\t\t\t\t\t* 1: 用户登录 *\n");
printf("\t\t\t\t\t* 2: 账号注册 *\n");
printf("\t\t\t\t\t* 3: 游客登录 *\n");
printf("\t\t\t\t\t* 4: 退出系统 *\n");
printf("\t\t\t\t\t* 请输入指令(1/2/3/4): *\n");
printf("\t\t\t\t\t************************************\n");
GotoXY(9, 67); // 将光标定位到输入位置
}
这里使用 GotoXY 函数控制光标位置,使得输入提示更友好。GotoXY 的实现基于 Windows API:
c
void GotoXY(short hang, short lie)
{
COORD rd;
rd.X = lie;
rd.Y = hang;
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), rd);
}
4.2 主登录循环
c
bool Login(void)
{
LoginMenuUI();
while (1)
{
int input = -1;
scanf("%d", &input);
switch (input)
{
case 1: // 登录
// 显示登录界面,输入用户名密码
// 调用 IsRight 验证
// 成功返回 true,失败提示
break;
case 2: // 注册
// 显示注册界面,输入用户名密码
// 调用 IsUserExit 检查用户名是否存在
// 不存在则保存,成功返回 true
break;
case 3: // 游客登录
Key = 0; // 设置为游客模式
return true;
case 4: // 退出
QuitUI(); // 显示退出倒计时
return false;
default:
OrderWrong(); // 指令错误提示
// 清空输入缓冲区
int ch;
while ((ch = getchar()) != '\n' && ch != EOF);
break;
}
}
}
输入缓冲区处理 :当用户输入非数字字符时,scanf("%d") 会失败,输入内容残留在缓冲区,导致死循环。因此,在 default 分支中清空缓冲区。
4.3 用户注册与登录验证
4.3.1 检查用户是否存在
c
bool IsUserExit(struct User* ps)
{
FILE* pf = fopen("user.dat", "r");
if (NULL == pf)
{
perror("fopen");
return false;
}
struct User a;
while (fread(&a, sizeof(struct User), 1, pf) == 1)
{
if (strcmp(a.name, ps->name) == 0)
{
fclose(pf);
return true;
}
}
fclose(pf);
return false;
}
注意:使用 fread 的返回值判断是否读取成功,不要用 feof,因为 feof 在读取失败后才返回真,容易多读一次。
4.3.2 验证用户名密码
c
bool IsRight(struct User* ps)
{
FILE* pf = fopen("user.dat", "r");
if (NULL == pf)
{
perror("fopen");
return false;
}
struct User a;
while (fread(&a, sizeof(struct User), 1, pf) == 1)
{
if (strcmp(a.name, ps->name) == 0 && strcmp(a.code, ps->code) == 0)
{
fclose(pf);
return true;
}
}
fclose(pf);
return false;
}
4.3.3 保存新用户
c
void SaveUser(struct User* ps)
{
FILE* pf = fopen("user.dat", "a"); // 追加模式
if (NULL == pf)
{
perror("fopen");
return;
}
fwrite(ps, sizeof(struct User), 1, pf);
fclose(pf);
}
4.4 倒计时函数
在退出、登录成功等场景,使用倒计时增强用户体验:
c
void CountDown(int hang, int lie, int time, char start)
{
for (int i = 0; i < start - '0'; i++)
{
Sleep(time);
GotoXY(hang, lie);
putchar(start - i - 1); // 依次显示 2,1,0
}
Sleep(1000);
}
参数说明:
hang,lie:倒计时数字显示的位置。time:每次间隔的毫秒数,这里传入1000表示1秒。start:起始字符,如'3',表示从3开始倒数。
4.5 游客模式与操作员切换
游客登录时,Key 被设为0,进入管理菜单后,所有操作指令无效,只有输入10才能返回登录菜单重新选择身份。这个功能在 main.c 中实现:
c
if (Key == 1)
{
Manage(); // 操作员直接进入管理
}
else
{
int choice = 0;
while (1)
{
ManageMenuUI(); // 显示管理菜单
Tips(); // 提示游客模式按10返回
scanf("%d", &choice);
while (getchar() != '\n'); // 清空缓冲区
if (choice == 10)
{
Key = 1; // 切换为操作员
goto again; // 跳转到登录重新开始
}
else
{
// 清除输入位置的字符,重新等待输入
GotoXY(16, 64);
printf(" ");
GotoXY(16, 64);
}
}
}
这里使用了 goto 语句跳转到登录前的标签 again,使程序重新显示登录菜单。虽然 goto 不推荐滥用,但在这种多层循环跳出场景中,goto 反而使逻辑清晰。
五、管理模块详细实现
管理模块是系统的核心,负责对链表中的学生数据进行各种操作。在 Manage() 函数中,首先初始化一个头结点,然后加载文件数据到链表,最后进入无限循环处理用户指令。
5.1 链表初始化与数据加载
c
List* plist = (List*)malloc(sizeof(struct ListNode));
plist->count = 0;
plist->message.num = 0;
plist->pre = plist;
plist->next = plist;
LoadList(plist);
头结点初始化时,pre 和 next 都指向自己,形成空循环链表。count 置0。
5.1.1 从文件加载学生信息
c
void LoadList(List* phead)
{
FILE* pf = fopen("stu.dat", "r");
if (pf == NULL)
{
perror("fopen");
return;
}
struct Message me;
while (fread(&me, sizeof(struct Message), 1, pf) == 1)
{
AddStuInList(phead, me);
}
fclose(pf);
}
这里直接调用 AddStuInList 将读取到的学生信息插入链表。插入顺序与文件保存顺序一致,因此学号递增(假设之前保存时已按学号顺序)。
5.2 添加学生
添加学生需要用户输入姓名和成绩,然后插入链表,并自动生成学号。
c
bool AddStuInList(List* phead, struct Message me)
{
List* newnode = (List*)malloc(sizeof(struct ListNode));
if (newnode == NULL)
{
perror("malloc");
return false;
}
newnode->message = me;
// 尾插法:插入到头结点之前(因为循环链表)
newnode->next = phead;
newnode->pre = phead->pre;
phead->pre->next = newnode;
phead->pre = newnode;
phead->count++;
return true;
}
学号生成 :由于链表是按添加顺序存储的,最后一个节点的学号最大。添加成功后,调用 ProduceNUm 设置新节点的学号:
c
void ProduceNUm(List* phead)
{
// phead->pre 是最后一个节点
phead->pre->message.num = phead->pre->pre->message.num + 1;
}
这里假设至少已经有一个节点,否则 phead->pre->pre 可能会出错。改进方法:可以在 AddStuInList 中直接根据当前最大学号赋值,或者一开始就判断链表是否为空。
5.3 显示所有学生
遍历链表,以表格形式输出:
c
void ShowAll(List* phead)
{
system("cls");
ManageMenuUI();
GotoXY(18, 0);
printf("\t\t\t\t\t* 学号 姓名 成绩 *\n");
printf("\t\t\t\t\t* ----------------------------------- *\n");
List* pur = phead->next;
while (pur != phead)
{
printf("\t\t\t\t\t* | %2d %-10s %-3d | *\n",
pur->message.num, pur->message.name, pur->message.score);
pur = pur->next;
}
printf("\t\t\t\t\t* ----------------------------------- *\n");
printf("\t\t\t\t\t*****************************************\n");
GotoXY(16, 64);
}
这里使用 %-10s 左对齐姓名,确保表格列对齐。由于控制台字体等宽,表格看起来会很整齐。
5.4 查找指定学生
c
void ShowStu(List* phead)
{
int flag = 0;
// 省略清屏和UI...
List* pur = phead->next;
while (pur != phead)
{
if (strcmp(pur->message.name, stu.name) == 0)
{
printf("\t\t\t\t\t* | %2d %-10s %-3d | *\n",
pur->message.num, pur->message.name, pur->message.score);
flag = 1;
}
pur = pur->next;
}
if (flag == 0) FindFail();
// 省略...
}
注意:允许多个同名学生的存在(实际中不合理,但为演示),所以会输出所有匹配项。
5.5 修改学生成绩
c
void ModifStu(List* phead)
{
int flag = 0;
List* pur = phead->next;
while (pur != phead)
{
if (strcmp(pur->message.name, stu.name) == 0)
{
pur->message.score = stu.score;
flag = 1;
}
pur = pur->next;
}
if (flag) ModifSucess();
else ModifFail();
}
这里修改了所有同名学生的成绩。如果希望只修改第一个,可以加 break。
5.6 删除学生
删除学生需要谨慎处理指针,避免内存泄漏和野指针。
c
void DelStu(List* phead)
{
int flag = 0;
if (phead->count == 0) return;
List* pur = phead->next;
while (pur != phead)
{
List* next = pur->next; // 先保存下一个节点
if (strcmp(pur->message.name, stu.name) == 0)
{
pur->pre->next = pur->next;
pur->next->pre = pur->pre;
free(pur);
phead->count--;
flag = 1;
}
pur = next; // 继续遍历下一个
}
if (flag) DelSucess();
else DelFail();
}
关键点:在 free(pur) 之前,必须先保存 pur->next,否则 pur 被释放后无法获取下一个节点地址。
5.7 考试(随机生成成绩)
c
void TestStu(List* phead)
{
List* pur = phead->next;
while (pur != phead)
{
pur->message.score = rand() % 51 + 50; // 50~100
pur = pur->next;
}
}
在 main 中已调用 srand(time(NULL)) 初始化随机数种子。
5.8 排序
排序功能包含两个函数:SortList 按成绩升序,SortByNum 按学号升序。
c
void SortList(List* phead)
{
if (phead->count == 0) return;
for (int i = 0; i < phead->count - 1; i++)
{
List* pur = phead->next;
while (pur->next != phead)
{
if (pur->message.score > pur->next->message.score)
{
struct Message tmp = pur->message;
pur->message = pur->next->message;
pur->next->message = tmp;
}
pur = pur->next;
}
}
}
采用冒泡排序,交换节点内的数据,而不是交换节点。这样做简单且不会破坏链表结构。排序后调用 ShowAll 显示结果,然后立即调用 SortByNum 恢复学号顺序。
5.9 统计分数区间
c
void CountScore(List* phead, int start, int end)
{
int flag = 0;
// UI...
List* pur = phead->next;
while (pur != phead)
{
if (pur->message.score >= start && pur->message.score <= end)
{
printf("\t\t\t\t\t* | %2d %-10s %-3d | *\n",
pur->message.num, pur->message.name, pur->message.score);
flag = 1;
}
pur = pur->next;
}
// 如果没有学生,调用 CountFail 提示
if (flag == 0) CountFail(start, end);
}
5.10 保存数据到文件
c
void SaveList(List* phead)
{
if (phead->count == 0 || phead == NULL) return;
FILE* pf = fopen("stu.dat", "w");
if (pf == NULL)
{
perror("fopen");
return;
}
List* pur = phead->next;
while (pur != phead)
{
fwrite(&(pur->message), sizeof(struct Message), 1, pf);
pur = pur->next;
}
fclose(pf);
}
使用二进制写入,保证数据紧凑。注意文件打开模式为 "w",会覆盖原有文件。
5.11 销毁链表
程序退出前,需要释放动态分配的内存,防止内存泄漏。
c
void DestroyList(List* phead)
{
List* pur = phead->next;
while (pur != phead)
{
List* tmp = pur->next;
free(pur);
pur = tmp;
}
free(phead);
phead = NULL; // 局部变量,实际上外部指针仍指向原地址,但此处无影响
}
释放完所有节点后,最后释放头结点。
六、控制台界面优化
为了让程序看起来更像一个完整的软件,我投入了不少精力优化控制台界面。主要使用了以下技巧:
6.1 光标定位
Windows API 提供了 SetConsoleCursorPosition 函数,配合 COORD 结构体,可以精确控制光标位置。封装为 GotoXY 后,可以在任意位置输出文本。
例如,在管理菜单中,操作员姓名显示在菜单右上角:
c
GotoXY(3, 62);
printf("%s", user1.name);
6.2 清屏
system("cls") 可以快速清空整个控制台。但频繁清屏会导致闪烁,因此只在需要切换大界面时使用。
6.3 边框与对齐
使用制表符 \t 快速对齐,但有时不够精确,所以很多地方直接使用空格数量微调。为了保证在不同控制台字体下对齐,可以尽量使用等宽字体(如 Consolas、Lucida Console)。
6.4 倒计时与动态刷新
倒计时功能使用 Sleep 暂停,然后覆盖输出新的数字,实现动态效果。
6.5 错误提示与输入位置重置
当用户输入错误时,程序会清除输入位置的字符,并将光标重新定位,等待重新输入。例如:
c
GotoXY(16, 64);
printf(" "); // 清除之前输入的数字
GotoXY(16, 64);
七、关键代码逐段解析
7.1 主函数逻辑
c
int main()
{
srand((unsigned int)time(NULL));
again:
if (Login())
{
if (Key == 1)
{
Manage();
}
else
{
// 游客模式循环
}
}
return 0;
}
again 标签用于游客切换身份时重新登录。Login() 返回 true 表示需要进入管理界面(登录成功或游客),返回 false 则直接退出程序。
7.2 管理菜单主循环
c
while (1)
{
int input1 = -1;
scanf("%d", &input1);
switch (input1)
{
// 各种 case
}
}
每个 case 执行完相应功能后,光标会回到输入位置,等待下一次指令。注意输入缓冲区的清理。
7.3 输入验证示例
修改学生分数时,要求输入整数,如果用户输入字母,scanf 会失败,需要提示并重新输入:
c
int tmp = scanf("%d", &(stu.score));
while (1)
{
if (tmp == 1)
{
ModifStu(plist);
break;
}
else
{
ModifScore(); // 显示错误提示
while (getchar() != '\n'); // 清空缓冲区
tmp = scanf("%d", &(stu.score));
}
}
7.4 全局变量的使用
stu 是一个全局的 struct Message 变量,在查找、修改、删除时用于存储用户输入的姓名或分数,方便在各个函数间传递。但全局变量应谨慎使用,这里其实可以改为局部变量传递。
八、遇到的问题及解决方案
8.1 内存泄漏与野指针
问题 :在删除链表节点时,如果没有保存下一个节点的地址,直接 free(pur) 后,pur = pur->next 会访问已释放的内存,导致野指针。
解决 :使用 List* next = pur->next; 先保存,然后 free(pur),最后 pur = next;。
8.2 输入缓冲区残留
问题 :用户输入字母后,scanf("%d") 失败,字母留在缓冲区,导致后续 scanf 一直失败,陷入死循环。
解决 :在每次 scanf 失败后,或者在读取指令前,用 while((ch=getchar())!='\n'); 清空缓冲区。
8.3 文件打开失败
问题 :第一次运行时,stu.dat 文件不存在,fopen 返回 NULL,perror 会输出错误信息,但程序仍可继续(空链表)。但 user.dat 不存在时,IsUserExit 和 IsRight 应返回 false,而不是报错退出。
解决 :对 fopen 返回 NULL 的情况,根据场景决定是报错还是正常返回。例如,在 IsUserExit 中,文件不存在等同于没有该用户,应返回 false。
8.4 学号生成逻辑
问题 :ProduceNUm 依赖于前一个节点的学号,当链表为空时,phead->pre->pre 就是 phead 本身,访问 phead->pre->pre->message.num 会导致非法内存访问。
解决 :可以在 AddStuInList 中根据当前链表最大学号赋值:若链表为空,学号设为1;否则学号为 phead->pre->message.num + 1。或者将 ProduceNUm 改为:
c
if (phead->count == 1) // 第一个节点
phead->pre->message.num = 1;
else
phead->pre->message.num = phead->pre->pre->message.num + 1;
8.5 排序后顺序混乱
问题:按成绩排序后,链表不再按学号有序。如果紧接着添加学生,新学生的学号可能会和现有学号重复。
解决 :在显示排序结果后立即调用 SortByNum 恢复学号顺序。或者在添加学生时,不依赖链表顺序,而是扫描整个链表找到最大学号再加1。
8.6 控制台光标闪烁
问题 :频繁使用 GotoXY 会导致光标闪烁,影响视觉体验。
解决 :可以使用 SetConsoleCursorInfo 隐藏光标,但本项目没有实现,有兴趣的读者可以自行扩展。
九、项目总结与反思
9.1 收获
- 链表操作能力提升:通过实现双向循环链表的增删改查,深刻理解了指针的运用和内存管理。
- 文件持久化 :学会了使用二进制文件保存结构体数据,掌握了
fread/fwrite的正确用法。 - 模块化编程:将不同功能拆分到多个文件中,学会了头文件保护和全局变量声明。
- 调试技巧:通过解决内存泄漏、野指针等问题,掌握了使用断言和调试器的方法。
- 用户体验设计:虽然只是控制台程序,但通过光标定位、倒计时、错误提示等细节,提升了程序的友好度。
9.2 不足与改进方向
- 密码安全性:密码以明文存储,实际项目中应使用哈希加密(如MD5)。
- 输入限制:未对姓名长度、分数范围(0-100)进行严格校验,可能导致数据异常。
- 排序算法效率低:冒泡排序时间复杂度O(n²),当数据量大时性能差,可改用快速排序。
- 代码复用性:一些重复代码(如遍历链表)可以封装成宏或函数。
- 缺少异常处理:对内存分配失败、文件读写错误处理不够完善,可能导致程序崩溃。
- 游客模式功能单一:游客只能查看,无法操作,实际中可以增加查看权限。
9.3 扩展建议
- 增加课程管理:支持多门课程成绩。
- 图形界面:使用 EasyX 或 Qt 开发图形界面。
- 网络功能:实现客户端-服务器架构,支持远程访问。
- 数据导出:支持将数据导出为 Excel 或文本文件。
十、完整代码与运行截图

由于文章篇幅限制,完整代码已上传至 GitHub:点击访问(虚拟链接)。读者可以下载源码自行编译运行。
十一、结语
从零开始,完成一个完整的学生成绩管理系统,虽然代码还有诸多不足,但这个过程让我收获颇丰。C语言作为一门底层语言,虽然开发效率不如高级语言,但通过它我们可以深入理解计算机的工作原理,掌握内存管理、指针操作等核心技能。
希望这篇文章能够帮助正在学习C语言的你,如果你也对这个小项目感兴趣,不妨自己动手实现一遍,相信你会有不一样的收获。如果在阅读或实践中有任何问题,欢迎在评论区留言交流!