C语言项目实战:学生成绩管理系统(支持登录注册、随机考试、分数区间统计)

🎬 博主名称键盘敲碎了雾霭
🔥 个人专栏 : 《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语言的你更好地理解综合项目的开发流程,并掌握一些实用的编程技巧。


一、系统功能概述

本系统分为两个主要模块:登录注册模块学生管理模块。整体业务流程如下:

  1. 启动程序:显示登录菜单,用户可选择登录、注册、游客登录或退出。
  2. 登录成功 :进入管理菜单。游客登录后只能查看菜单(输入指令无效),输入 10 可切换至操作员模式(权限提升)。
  3. 管理功能 :操作员可进行以下操作:
    • 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 字段记录链表中学生个数,方便判断空链表和获取长度。prenext 分别指向前驱和后继节点,循环的特性使得尾插和遍历非常方便。

为什么选择双向循环链表?

  • 双向:可以方便地向前或向后遍历,对于删除节点等操作更加灵活(无需额外记录前驱)。
  • 循环 :头结点的 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,后面会说明

这里存在一个小问题:Keylogin.cManage.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);

头结点初始化时,prenext 都指向自己,形成空循环链表。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 不存在时,IsUserExitIsRight 应返回 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 收获

  1. 链表操作能力提升:通过实现双向循环链表的增删改查,深刻理解了指针的运用和内存管理。
  2. 文件持久化 :学会了使用二进制文件保存结构体数据,掌握了 fread/fwrite 的正确用法。
  3. 模块化编程:将不同功能拆分到多个文件中,学会了头文件保护和全局变量声明。
  4. 调试技巧:通过解决内存泄漏、野指针等问题,掌握了使用断言和调试器的方法。
  5. 用户体验设计:虽然只是控制台程序,但通过光标定位、倒计时、错误提示等细节,提升了程序的友好度。

9.2 不足与改进方向

  1. 密码安全性:密码以明文存储,实际项目中应使用哈希加密(如MD5)。
  2. 输入限制:未对姓名长度、分数范围(0-100)进行严格校验,可能导致数据异常。
  3. 排序算法效率低:冒泡排序时间复杂度O(n²),当数据量大时性能差,可改用快速排序。
  4. 代码复用性:一些重复代码(如遍历链表)可以封装成宏或函数。
  5. 缺少异常处理:对内存分配失败、文件读写错误处理不够完善,可能导致程序崩溃。
  6. 游客模式功能单一:游客只能查看,无法操作,实际中可以增加查看权限。

9.3 扩展建议

  • 增加课程管理:支持多门课程成绩。
  • 图形界面:使用 EasyX 或 Qt 开发图形界面。
  • 网络功能:实现客户端-服务器架构,支持远程访问。
  • 数据导出:支持将数据导出为 Excel 或文本文件。

十、完整代码与运行截图

由于文章篇幅限制,完整代码已上传至 GitHub:点击访问(虚拟链接)。读者可以下载源码自行编译运行。

十一、结语

从零开始,完成一个完整的学生成绩管理系统,虽然代码还有诸多不足,但这个过程让我收获颇丰。C语言作为一门底层语言,虽然开发效率不如高级语言,但通过它我们可以深入理解计算机的工作原理,掌握内存管理、指针操作等核心技能。

希望这篇文章能够帮助正在学习C语言的你,如果你也对这个小项目感兴趣,不妨自己动手实现一遍,相信你会有不一样的收获。如果在阅读或实践中有任何问题,欢迎在评论区留言交流!

相关推荐
Trouvaille ~2 小时前
【Linux】数据链路层与以太网详解:从 MAC 地址到 ARP 的完整指南
linux·运维·服务器·网络·以太网·数据链路层·arp
小鸡食米2 小时前
LVS(Linux Virtual Server)
运维·服务器·网络
Ronin3052 小时前
【Linux网络】Socket编程:UDP网络编程实现ChatServer
linux·网络·udp
User_芊芊君子2 小时前
WebSocket实时通信入门,感谢我的好搭档脉脉
网络·人工智能·websocket·网络协议·测评
码农阿豪2 小时前
解决HTTP 413错误:请求实体过大(Request Entity Too Large)的终极指南
网络·网络协议·http
天上飞的粉红小猪3 小时前
传输层UDP&&TCP
网络·tcp/ip·udp
DeeplyMind3 小时前
第17章 Docker网络实战与高级管理
网络·docker·容器
hjhcos3 小时前
【链观】一个面向经济社交的智能体网络
网络
『往事』&白驹过隙;4 小时前
浅谈PC开发中的设计模式搬迁到ARM开发
linux·c语言·arm开发·设计模式·iot