☰
C++课程设计:学生选课系统代码拆解与避坑指南
2026/11/2 17:47:27 网站建设 项目流程

简介:学生选修课程系统设计是面向计算机及相关专业学生与C++课程设计初学者的一份完整课程设计报告,围绕学生登录、课程浏览、选课与退选、个人信息管理、成绩查询和数据统计等功能展开,并覆盖需求分析、模块图与流程图、关键代码、调试分析、用户手册与设计总结等实践环节。压缩包共1个doc文件,约259KB,以Word文档形式集中呈现选题背景、功能设计、实现思路与测试记录,便于对照课程设计任务书梳理报告结构与编码重点。已有1444人学习,说明其在C++面向对象课程设计场景中具有一定参考价值。读者可从中了解用户认证、课程信息管理、选课冲突检查、数据持久化等模块的组织方式,也能借鉴问题分析、性能说明、改进设想和文档编写方法,适合作为课程设计报告撰写与系统实现的对照材料。

1. 一套控制台选课系统的真实样子

答辩前一晚,机房机器上程序一跑就闪退,查了半天发现是写文件后没close。这份学生选修课程系统设计属于 c++课程设计 里最经典的一类题目:控制台菜单、三个数据类、一个manage管理类、三份 txt 数据文件,教师端管学生和课程的增删改查与统计,学生端只做课程浏览和选课。整套代码几百行,单个 cpp 文件就能编译跑通,适合刚学完类、继承和文件流的同学拿来做骨架,也适合照着改出自己的版本。

2. 类结构拆解:三个数据类加一个 manage 大管家

先把模块图落到代码上。报告里的模块图是「主模块 → 学生端 / 教师端 / 退出」,学生端下面挂「显示课程、选课、退出」,教师端下面挂「添加、查询、显示、修改、删除、统计、读取、保存」。这套分层在控制台程序里最终只体现为两类东西:数据类负责存一条记录,管理类负责把一堆记录按功能处理掉。

2.1 student、course、xuanke:三个只存数据的类

代码里student和course都是纯数据类,成员全是public,加两个方法put_xxx()负责从cin读一条,display()负责往cout打一行。这种写法在工程里会被骂,但在课程设计里非常实用——它把「读一条记录」和「打印一条记录」的格式固定在类内部,增删改查十几个函数就不用各写一遍cin >>序列。

class student { public: // 数据成员全公开,图省事,也埋下了被随意改的隐患 int SID; // 学号,唯一键,所有查找都靠它 char name[10]; // 姓名,定长字符数组,别指望存长名字 char sex; // 性别,单个字符 M/F int age; char SDEPT[10]; // 系别 char grade[10]; // 班级 char TEL[10]; // 联系方式 public: void put_student() { cout << "请输入学生的 学号、姓名、性别、年龄、系别、班级、联系方式:\n"; cin >> SID >> name >> sex >> age >> SDEPT >> grade >> TEL; } void display() { cout << SID << "\t" << name << "\t" << sex << "\t" << age << "\t" << SDEPT << "\t" << grade << "\t" << TEL << endl; } };

cin >> name对char[]的行为是「跳过空白、读到下一个空白为止」,所以姓名里带空格会被截断,这是后面所有「显示不全」问题的源头之一。name[10]的容量也要心里有数:GBK 编码下一个汉字占 2 字节,最多塞 4 个汉字加'\0';UTF-8 下占 3 字节,只能塞 3 个汉字。写测试数据时随手敲个「欧阳建国」就要出事。

course类字段是课程代码、名称、性质、总学时、学分、开课学期,另外多了一个int vis[90];。这个vis全篇没有任何地方用到,明显是早期想做「每门课被谁选过」的标记数组,后来改用xuanke类就忘了删。看到这种字段不要直接删——先把全文搜一遍确认无引用,再删,否则容易连编译都过不去。

xuanke只有两个int:c_num(课程代码)和s_num(学号)。它本质上是一条关系记录,不是实体,所以没有display(),也没有唯一性检查。这个设计决定了选课数据只能追加、不能去重,第五章会专门说它带来的坑。

2.2 manage 的多重继承:画类图好看,写代码难受

manage的声明是全文最值得停下来看一眼的地方:

class manage : public student, public course, public xuanke { private: student st[maxn]; // 又持有 student 数组 course co[maxn]; xuanke xk[maxn]; public: void c_read(); void s_read(); void add_course(); void add_student(); // ... 其余功能函数 };

它同时继承student、course、xuanke,又各自声明了一个数组。继承来的那份成员(SID、c_code之类)在整个程序里一次都没用过,manage对象本身只当容器使。这么写唯一的好处是类图上「manage 继承三个类」的箭头很好看,符合课程设计报告里对「继承与派生」的考察点。

代价是实打实的:一是manage对象凭空多出十几个无用字段,二是三个父类的put_xxx()、display()都被继承进来,调用时容易出现m.display()这种到底打谁的歧义,三是后面想把student换成std::string字段,得连带改父类。

常见的改法是换成组合,把三个数组包进来就行,功能一行不用动:

class manage { private: student st[maxn]; course co[maxn]; xuanke xk[maxn]; int c_cnt = 0, s_cnt = 0; // 顺手把全局计数收进来,见 2.3 public: // 功能函数不变 };

如果你正在写报告、需要体现继承,可以保留manage的多重继承,但把数组改成继承来的那份,别再声明一遍;如果只是想让程序干净点,直接组合。

2.3 定长数组与全局计数:maxn=100 的天花板

容量靠一个全局常量卡死:

const int maxn = 100; int c_cnt = 0, s_cnt = 0; // 课程数、学生数,全局变量

所有数组都是st[maxn]、co[maxn],所有功能函数直接读写这两个全局计数。好处是函数签名干净,add_course()不需要参数也不需要返回值,坏处是没人管边界。看add_course的写入分支:

if (i < c_cnt) // 找到同号课程,说明重复 cout << "有重复,添加失败!" << endl; else { co[c_cnt++] = c; // 直接往后追加,没判断 c_cnt 是否已经到 maxn cout << "添加成功!" << endl; }

第 101 门课就会写到数组外面去,控制台程序未必立刻崩,可能只是把s_cnt或别的全局变量踩坏,表现成「加了课之后学生人数莫名其妙变了」这种玄学现象。补一行判断就能挡住:

else if (c_cnt >= maxn) cout << "课程数量已达上限 " << maxn << ",请先清理数据" << endl; else { co[c_cnt++] = c; cout << "添加成功!" << endl; }

一个功能落到哪些函数、动哪份数据,可以先理成一张表,改的时候不容易漏:

功能函数操作对象落盘时机
读取c_read/s_readco[]/st[]程序启动时
添加add_course/add_studentco[]/st[]手动save
查询c_search/s_search按编号线性扫描不落盘
显示c_display/s_display全量遍历不落盘
修改c_edit/s_edit先备份再覆盖手动save
删除c_delete/s_delete后续元素前移手动save
统计cnt计数 + 读选课文件不落盘
选课xuankxk立即fwrite

读懂这张表,基本就掌握了这套代码的数据流向:内存里的对象数组是主副本,txt 是快照,选课记录是个例外,绕开了内存数组直接写文件。

3. 一次操作的完整链路:从菜单到文件落盘

控制台程序的骨架就三件事:菜单循环、分派到功能函数、退出前把内存写回文件。这套代码在前两件事上做对了,第三件留了个大坑。

3.1 主循环:为什么必须用 while 包住菜单

报告里明确记了一条问题:「进入教师端时如果选择某项操作,操作完成后就会退出系统」。原因很直白——main里如果用if分派,执行完一个分支就走到return 0。修法是把菜单和分派都塞进while,用一个专属的退出值当条件。原文只给了主流程图(a=1 学生端、a=2 教师端、a=3 退出),函数名按报告里的命名还原出来大致是这样:

int main() { manage m; m.c_read(); // 先把两份数据文件读进对象数组 m.s_read(); // 之后所有操作都在内存里做,不再碰文件 int a = 0; while (a != 3) // 3 是退出,循环保证一次操作完还能回主菜单 { cout << "1.学生端 2.教师端 3.退出" << endl; cin >> a; if (a == 1) { int b = 0; while (b != 2) // 学生端二级菜单:1 显示课程 2 退出 { cout << "1.查看课程 2.选课 3.返回" << endl; cin >> b; if (b == 1) m.c_display(); else if (b == 2) m.xuank(); } } else if (a == 2) { int c = 0; while (c != 9) // 教师端二级菜单,9 返回 { cout << "1.添加课程 2.添加学生 3.查询 4.显示 5.修改 6.删除 7.统计 8.保存 9.返回" << endl; cin >> c; switch (c) { case 1: m.add_course(); break; case 2: m.add_student(); break; case 7: m.cnt(); break; case 8: m.save(); break; // ... 其余分支 } } } } return 0; // 注意:这里没有调 save(),退出即丢弃修改 }

几个参数值得说清楚。外层a的三个取值是硬编码的,改动菜单顺序就得同步改判断,工程里一般用枚举或常量替代。内层b、c的退出值分别是 2 和 9,跟外层 3 不冲突,这是刻意的——如果都用 3,从二级菜单返回时会直接把外层循环也干掉。switch里每个case都写了break,漏一个就会「删完课程接着执行统计」,这种 fall-through 在答辩现场非常致命。

return 0之前没有save(),是这套代码最贵的教训:报告里第 7 条「下次再读取文件时发现上次修改的记录没有显示,其原因是当运行完程序时没有进行保存」说的就是它。修法有两种,省事的一种是在main返回前补一行m.save();,稳妥的一种是用atexit注册或者在每次增删改之后立刻落盘。

3.2 读与存:c_read / s_read 和 save 必须成对

读文件用ifstream加>>运算符,靠「读到文件尾就失败」结束循环:

void c_read() // 从 course_information.txt 读课程记录到对象数组 { ifstream in; in.open("course_information.txt", ios::in); if (!in) { cout << "打开失败!" << endl; return; // 文件不存在就当没有数据,不能让流程断掉 } while (in >> co[c_cnt].c_code >> co[c_cnt].c_name >> co[c_cnt].c_nature >> co[c_cnt].c_hour >> co[c_cnt].c_grade >> co[c_cnt].c_semester) { c_cnt++; // 每成功读满一条,计数加一 } in.close(); // 必须关,否则下一次打开同一文件会失败 }

while (in >> ...)的语义是:把整条记录的六个字段依次读进数组的当前槽位,六个都读成功才算表达式为真。如果某一行只写了五个字段,第六个读失败,c_cnt不会自增,那条半截数据就被丢弃,程序不会崩也不会提示——所以 txt 里每行的字段数必须严格一致。ios::in是只读模式,文件不存在时open失败,代码直接return而不报死,这是对的行为:第一次运行本来就没有数据文件。

写回用的是ofstream加ios::out,ios::out默认截断,等于全量覆盖:

void save() // 把对象数组里的内容写回两份 txt { ofstream out("course_information.txt", ios::out); if (!out) { cout << "打开失败!" << endl; return; } for (int i = 0; i < c_cnt; i++) { out << co[i].c_code << " " << co[i].c_name << " " << co[i].c_nature << " " << co[i].c_hour << " " << co[i].c_grade << " " << co[i].c_semester << endl; } out.close(); // 先关课程文件 out.open("student_information.txt", ios::out); // 同一个流对象复用 if (!out) { cout << "打开失败!" << endl; return; } for (int i = 0; i < s_cnt; i++) { out << st[i].SID << " " << st[i].name << " " << st[i].sex << " " << st[i].age << " " << st[i].SDEPT << " " << st[i].grade << " " << st[i].TEL << endl; } out.close(); }

这里有个写法上的依赖:字段之间用空格分隔,读的时候>>也按空白切,所以格式天然自洽。代价是任何字段内部都不能有空格,课程名写成「数据结构 与算法」就会把整行读歪。要支持空格,得改用getline加分隔符(比如逗号或竖线),读的时候也要相应改成getline,两边必须同时改,只改一边直接完蛋。

提示:save()是「全量覆盖」,它会把文件里原有的内容整个替换成内存里数组的内容。如果程序启动时c_read()失败(文件被占用、路径不对),数组是空的,此时再点保存,原有的数据就被清空了。我一般会在save()开头加一句c_cnt == 0 && s_cnt == 0的确认提示。

3.3 增删改:三个函数共用一套「线性扫描 + 唯一性检查」

增删改查四个动作里,查最简、改最容易出错。先看添加,核心是查重:

void add_course() { course c; c.put_course(); // 先读进临时对象,不污染数组 int i; for (i = 0; i < c_cnt; i++) // 线性扫一遍找同号 if (co[i].c_code == c.c_code) break; // i 停在下标上,说明重复 if (i < c_cnt) cout << "有重复,添加失败!" << endl; else co[c_cnt++] = c; // 只有确认不重复才落进数组 }

「先读进临时对象,检查通过再入数组」是一个值得固化的习惯。反过来写——先co[c_cnt].put_course()再检查 ——一旦重复,数组尾部就留了一条脏数据,还得回滚。

删除用的是顺序表经典套路:找到下标,后面元素依次前移,计数减一:

void c_delete() { int c_num, i; cout << "请输入要删除课程的代码: "; cin >> c_num; for (i = 0; i < c_cnt; i++) if (co[i].c_code == c_num) break; if (i == c_cnt) { cout << "没有该项记录!" << endl; return; } for (int j = i; j < c_cnt - 1; j++) // 注意上界是 c_cnt-1,最后一位不用搬 co[j] = co[j + 1]; c_cnt--; // 计数减一,尾部那条变成逻辑上的垃圾 cout << "记录删除成功!" << endl; }

循环上界写成j < c_cnt - 1而不是j < c_cnt,是为了避免co[c_cnt - 1] = co[c_cnt]读到数组有效区之外。这个边界错一次,删除最后一条记录时就会把一个越界对象拷进来,后面显示随机乱码。

修改是三处里唯一需要「后悔药」的,报告里也专门记了这条坑:

void c_edit() { int c_num, i; cout << "请输入要修改课程的代码: "; cin >> c_num; for (i = 0; i < c_cnt; i++) if (co[i].c_code == c_num) break; if (i == c_cnt) { cout << "没有此项记录!" << endl; return; } course backup = co[i]; // 关键:改之前先备份原记录 co[i].put_course(); // 直接把新内容读进数组,原内容已被覆盖 int flag = 0; for (int j = 0; j < c_cnt; j++) { if (j == i) continue; // 跳过自己,否则永远和自己重复 if (co[j].c_code == co[i].c_code) { flag = 1; break; } } if (flag) { co[i] = backup; // 撞号了,用备份还原 cout << "存在相同编号的课程,修改失败!" << endl; } else cout << "修改成功!" << endl; }

backup这一步不能省。put_course()是直接往co[i]的字段里写的,写完原始数据就没了;如果此时发现新编号和别的课程撞了,没有备份就还原不回去,只能让用户重新录一遍。这个「先备份、后覆盖、失败回滚」的套路在编辑场景里通用,写数据库更新也是一个思路。

4. 选课与统计:两套持久化方案的并轨

学生端只有两个动作:看课程、选课。看课程复用c_display(),选课却单开了一条路——绕开内存数组,直接用FILE*写二进制。

4.1 xuank 为什么用 fwrite 而不是 ofstream

void xuank() { FILE *fp; xuanke xk; cin >> xk.c_num >> xk.s_num; // 课程代码 + 学号,构成一条选课记录 if ((fp = fopen("xuanke_information.txt", "ab+")) == NULL) // 追加模式打开 if ((fp = fopen("xuanke_information.txt", "wb+")) == NULL) // 打不开就新建 { cout << "不能建立 xuanke_information.txt 文件" << endl; exit(0); } fseek(fp, 0, 2); // 定位到文件末尾 fwrite(&xk, sizeof(xuanke), 1, fp); // 整块写一条记录 cout << "选课成功!" << endl; fclose(fp); }

选这个方案的理由,报告里写得很实在:一开始想用「每门课挂一个链表存学号」,读文件时很麻烦,索性单开一个定长的xuanke类加一份二进制文件。sizeof(xuanke)是两个int,8 字节,每条记录长度固定,这给统计带了巨大方便——文件总字节数除以 8 就是记录条数,不用逐行解析。

代价也明显。二进制文件用记事本打开是乱码,课程设计验收时老师想直接看一眼数据都看不见;ab+模式本身就能创建文件,所以里层那次fopen是冗余的,写fopen(..., "ab+")一行就够;最要紧的是xuank()完全没做校验——课程代码存不存在不管、同一个学生重复选同一门课也不管。补校验不难:

void xuank() { int c_num, s_num; cin >> c_num >> s_num; int i; for (i = 0; i < c_cnt; i++) if (co[i].c_code == c_num) break; if (i == c_cnt) { cout << "课程不存在,选课失败" << endl; return; } // 查重:先扫一遍已有的 xk 数组(需要把选课记录也读进内存) for (int k = 0; k < xk_cnt; k++) if (xk[k].c_num == c_num && xk[k].s_num == s_num) { cout << "已经选过这门课" << endl; return; } xk[xk_cnt].c_num = c_num; xk[xk_cnt].s_num = s_num; xk_cnt++; // 落盘交给统一的 save(),别再单独 fwrite }

要做到这一步,前提是把选课记录也像课程、学生一样在启动时读进xk[]数组,让三种数据用同一套机制。这属于二次开发里性价比最高的一处改造。

4.2 统计某门课的选课人数:ftell 除以 sizeof

统计函数用一个switch分三个分支:学生人数、课程门数、某门课的选课名单。前两个直接读全局计数,第三个才真正读文件:

void cnt() { int a; cin >> a; switch (a) { case 1: cout << "学生人数为: " << c_cnt << endl; // 注意:这里用的是 c_cnt break; case 2: cout << "课程门数为: " << s_cnt << endl; // 这里用的是 s_cnt break; case 3: { FILE *fp = fopen("xuanke_information.txt", "rb+"); if (fp == NULL) { cout << "文件打开失败!" << endl; exit(0); } int c_num, n, count = 0, A[60]; cin >> c_num; // 要统计的课程代码 fseek(fp, 0, 2); n = ftell(fp) / sizeof(xuanke); // 文件字节数 ÷ 记录长度 = 记录条数 rewind(fp); // 回到文件头才能顺序读 xuanke t; for (int i = 0; i < n; i++) { fread(&t, sizeof(xuanke), 1, fp); if (t.c_num == c_num) A[count++] = t.s_num; // 命中就存学号 } cout << "选修该课程的人数为: " << count << endl; for (int i = 0; i < count; i++) cout << A[i] << " "; fclose(fp); break; } } }

ftell拿当前偏移(已经fseek到末尾,所以是文件总长),除以sizeof(xuanke)得到记录数,这是定长二进制文件最省事的计数方式,比一行行读快得多,也不怕文件末尾多一个换行。rewind(fp)不能漏,它等价于fseek(fp, 0, SEEK_SET),漏了就一条都读不到,count恒为 0。

A[60]是个裸奔的定长数组,选课人数超过 60 就写越界。改法要么开大一点并加边界判断,要么用std::vector<int>直接push_back。另外int count = 0;这个局部变量名跟标准库的std::count撞了,虽然这里没using namespace std之外的影响(实际有,using namespace std;在文件头部),但属于自己给自己下绊子,改叫hit更安全。

case 1和case 2那两行有个真实的 bug:统计学生人数输出的是c_cnt(课程计数),统计课程门数输出的是s_cnt(学生计数),两个变量写反了。这种错误在测试时很容易漏,因为如果你先加 3 门课、再加 3 个学生,两个数字一样,看不出问题;一旦数量不等就露馅。验收前务必造不对等的数据测一遍。

5. 调试与避坑:五条上机血泪经验的代码对照

报告里列了七条上机问题,挑出最有代表性的五条,按「现象 → 原因 → 解决」重新捋一遍,都是能直接对到代码行的。

5.1 存出来的文件是乱码

现象:用fwrite把课程记录写进 txt,用记事本打开全是方块和乱码。

原因:fwrite写的是内存的二进制镜像,int占 4 字节、char[10]占 10 字节,全是原始字节,记事本按文本解码当然看不懂。

解决:换成 C++ 的ofstream加<<输出,字段之间用空格分隔,就是 3.2 里save()的写法。只要程序自己读得回来、人能看懂,用文本格式比二进制格式划算得多。xuanke_information.txt保留二进制是另一回事——它只给程序读,不给人看,而且定长记录让统计变简单。

5.2 文件明明存在,读的时候却打不开

现象:c_read()里in.open(...)失败,但去文件夹里看文件确实在。

原因:上一次save()或写操作结束时没有close(),文件句柄还挂着,再次打开就失败。

解决:所有open必须有配对的close,包括提前return的分支。稳妥写法是在每个函数末尾和所有错误返回前都调一次,或者干脆封装个 RAII 小类,构造时打开、析构时关闭。

5.3 改了编号撞车,原记录也被改没了

现象:修改课程信息时填了一个已存在的编号,程序提示「修改失败」,但回去一看那条记录的内容已经变成新填的了。

原因:co[i].put_course()直接往数组元素里写,原数据当场被覆盖;后面的查重是在覆盖之后做的,发现重复时已经没有原值可还原。

解决:改之前先course backup = co[i];,查重失败就co[i] = backup;回滚。3.3 里的c_edit()就是补好的版本。这个模式记住一句话:任何「先写后校验」的地方,都要先留一份快照。

5.4 新加的记录显示不出来

现象:添加了课程,返回列表看,最后一条永远是旧的。

原因:添加时只写了co[c_cnt] = c;,忘了c_cnt++。数组那一位确实有数据,但所有遍历都按c_cnt做上界,第c_cnt位永远不会被访问到。

解决:统一用co[c_cnt++] = c;这种「先取下标再自增」的写法,别拆成两句。同理,删除时c_cnt--也不能忘,否则列表末尾会多出一条幽灵记录。

5.5 关掉程序再打开,改动全丢了

现象:加了一堆数据,退出程序,重新运行,全没了。

原因:main里return 0之前没调save()。内存里的数组只在内存里,不落盘就是一次性的。

解决:最省事是在main退出前补m.save();;更稳的是每次增删改成功后立刻落盘,代价是频繁 IO。折中方案是在菜单里留一个「保存」项并把它做成退出前的必经步骤,同时在退出提示里写清楚「未保存的修改将丢失」。

注意:还有一个隐蔽的坑没在上面五条里——cin >>读int时如果用户敲了字母,流会进入失败状态,之后所有cin都失效,程序开始疯狂打印菜单。修法是在每次读失败后cin.clear(); cin.ignore(1024, '\n');。控制台程序被输入搞死的情况,一半是这个原因。

6. 二次开发:把它改成能拿得出手的版本

这套代码改起来有几个明确的着力点,按投入产出排序。

6.1 先做三处小改造

第一,把student、course里的char[]全换成std::string,name长度、中文截断、空格截断这些问题一次性消失,代价是c_read里的>>要改成getline配分隔符。第二,把全局的c_cnt、s_cnt收进manage类做成员,顺手在每个写入分支加上>= maxn的判断。第三,把case 1、case 2里写反的两个计数变量换回来,用「3 门课 + 5 个学生」这种不对等数据测一遍。

6.2 结构上补两块:选课进内存、加退选

现在的选课是「立即 fwrite、统计时再读文件」,三种数据两套机制,维护起来别扭。改法是在manage里加一个xuanke xk[maxn]; int xk_cnt = 0;,程序启动时从xuanke_information.txt读进数组,选课时先查课程存在、再查是否已选、然后才追加,save()里增加第三段写回。这样选课就成了内存操作,退选也就顺理成章——找到xk[i]里匹配的(c_num, s_num),后面元素前移、xk_cnt--,跟c_delete()完全同构。

6.3 怎么验证改对了

不要靠肉眼扫菜单。给每种操作配一组固定数据:两门同号课程(测添加查重)、一条改号撞车的记录(测回滚)、一条删除中间记录后的列表(测前移)、一次重复选课(测去重)、一次退出重进(测持久化)。跑完一轮,去 txt 里数行数、对字段,比在控制台里翻屏可靠得多。

// 快速自检:把内存状态打出来,和 txt 对一遍 void dump() { cout << "课程 " << c_cnt << " 门,学生 " << s_cnt << " 人,选课记录 " << xk_cnt << " 条" << endl; for (int i = 0; i < xk_cnt; i++) cout << " " << xk[i].s_num << " -> " << xk[i].c_num << endl; }

我最早做这类作业时也栽过同一个坑:数据加完直接关窗口,第二天打开一片空白,白干两小时。从那以后我每次写完增删改,都强制走一遍「操作 → 保存 → 关程序 → 重开 → 看列表」的闭环,绝不靠「应该存上了」这种自我安慰。这份代码本身不难,难的是把这些细节一次性考虑全。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询