简介:这份《学生选修课程系统设计》C++课程设计文档面向计算机相关专业学生与初学面向对象编程者,围绕学生登录、课程浏览、选课与退课、个人信息管理、成绩查询及数据统计等功能,串联需求分析、模块图与流程图、关键代码、调试分析和用户手册等报告环节,可用于课程设计参考、项目复盘与答辩准备。压缩包共1个doc文件,约259KB,主体为一份较完整的课程设计报告,包含功能说明、模块划分、选课冲突检查与数据持久化思路、上机问题及解决方案、可改进与可扩展功能等内容,并附有目录、设计总结、参考文献与附录,便于按章节查阅和对照整理。已有1444人学习,适合需要理解选课系统业务逻辑、梳理C++面向对象实现思路或完善课程设计文档的读者参考。
1. 从一份课设报告拆起:C++课程设计里的选课系统到底练什么
每年六月底机房坐满人的时候,C++课程设计选题里出现频率最高的就是学生选修课程系统。它看起来只是增删改查,真动手写,几乎所有人都会在同一个位置卡住:文件读进来是乱码、改完的记录下次打开没了、删掉一条之后列表里凭空多出一条重复数据。这份资源是一套完整的课程设计报告加可编译源码,围绕 student、course、xuanke 三个数据类和 manage 管理类展开,教师端负责学生与课程的增删改查和统计,学生端负责浏览课程与选课,数据靠文件落盘。适合正在赶课设、需要一份能跑通又能讲清设计取舍的参考实现的人,也适合想复习面向对象基本功的从业者。下面按类设计、持久化、四件套、避坑、验收的顺序往下拆。
2. 四个类的职责切分:student、course、xuanke 与 manage 该怎么摆
面向对象课设最容易走偏的地方不是语法,而是类的边界没划清。原始实现里三个数据类各自管自己的输入输出,manage 负责把所有功能函数串起来,这个思路本身没问题,问题出在 manage 用了多继承,又在内部重新定义了一遍对象数组。这一章先把字段和职责定下来,后面所有功能才有地方落。
2.1 三个数据类:字段定长与唯一键的选择
student 和 course 都是典型的数据载体,字段固定、行为简单,只需要 put_xxx 负责录入、display 负责输出。真正的设计决策在两点:唯一键用哪个字段,字符串用定长数组还是 string。
原始代码用 int SID 做学号、int c_code 做课程代码,这个选择是对的。整数比较不会出现大小写、前后空格、全半角差异,查重和查找都能直接==,比字符串比对省事得多。如果非要用字符串学号,就得在比较前统一 trim,课设阶段没必要给自己找麻烦。
字符串字段用的是 char 数组,char name[10]、char SDEPT[10]、char TEL[10]。这是教材年代的写法,放到今天有两个硬伤:一是 UTF-8 编码下一个汉字占 3 字节,10 字节只够存三个汉字,姓名稍长就被截断;二是cin >> name遇到空格就停,名字里带空格(比如英文名)会直接读错位,后面的 age、SDEPT 全部跟着串。我在复现时把长度统一放宽到 20 并改用 string,读写文件时用getline配合分隔符,稳定性提升明显。
class student { public: int SID; // 学号,唯一键,查重和查找都靠它 char name[20]; // 姓名,原版 10 字节,UTF-8 下三个汉字就满 char sex; // 'M' / 'F',单字符够用 int age; // 年龄 char sdept[20]; // 系别 char grade[20]; // 班级 char tel[20]; // 联系方式,纯数字也建议用字符数组,避免前导零丢失 void put_student(); // 从 cin 录入一条记录 void display() const; // 单行输出,字段之间用制表符对齐 };course 类的字段多一点,但逻辑一样:c_code是唯一键,c_name、c_nature、c_semester是描述性字符串,c_hour、c_grade是数值。注意c_grade这个名字有歧义,它表示学分而不是年级,改名叫credit可读性会好很多,改完记得同步改文件读写顺序,否则读进来的学分和学期会错位。
class course { public: int c_code; // 课程代码,唯一键 char c_name[20]; // 课程名称 char c_nature[20]; // 课程性质:必修 / 选修 / 限选 int c_hour; // 总学时 int credit; // 学分,原名 c_grade 容易和年级混淆 char c_semester[20]; // 开课学期 void put_course(); void display() const; };xuanke 类只有两个 int:c_num和s_num,代表一条「某学生选了某课程」的关联记录。它没有名字、没有成绩,纯关联表。这个类单独存在的价值在第 3 章会体现——选课记录条数会随时间增长,用定长对象数组容易溢出,用二进制追加写文件更合适。
提示:三个数据类的字段顺序就是文件里的字段顺序。中途加字段一定要同时改读取函数和保存函数,只改一边的后果是整份数据错位,而且不会报错。
2.2 manage 用继承还是组合:多继承写法埋了什么雷
原始实现里 manage 的声明是class manage : public student, public course, public xuanke,同时类内部又定义了student st[maxn]; course co[maxn]; xuanke xk[maxn];。这就是典型的既继承又包含:继承来的那部分成员一次都没用过,真正干活的是内部数组。
多继承在这里带来三个实际问题。第一,语义上讲不通,manage 不是一种 student,也不是一种 course,用 public 继承表达「是一个」的关系本身就是错的。第二,如果 student 和 course 里恰好有同名字段(比如都叫id),在 manage 内部访问就会产生二义性,编译器报 ambiguous,得写student::id才能消歧。第三,继承会把三个类的全部成员复制进 manage 对象,对象体积膨胀,栈上开manage m;的时候更容易爆栈,尤其是 MAXN 开到 100 的时候。
改法很简单,删掉继承列表,只保留组合:
const int MAXN = 100; class manage { private: student st[MAXN]; course co[MAXN]; int s_cnt = 0; // 学生条数,与 st 绑定 int c_cnt = 0; // 课程条数,与 co 绑定 public: void s_read(); void c_read(); // 启动时一次性把文件读进数组 void add_student(); void add_course(); void s_search(); void c_search(); void s_display(); void c_display(); void s_delete(); void c_delete(); void s_edit(); void c_edit(); void xuanke(); void cnt(); void save(); // 退出前统一落盘 void run(); // 菜单主循环 };把 s_cnt、c_cnt 从全局变量挪进 manage 的私有成员,是这份代码里性价比最高的一处改动。原始版本用的是全局int c_cnt = 0, s_cnt = 0;,好处是任何函数都能直接改,坏处也是任何函数都能直接改——一个函数忘了自增、另一个函数误减,排查起来毫无线索。变成成员变量之后,谁改了计数至少能从成员函数列表里缩小范围。
2.3 最小可编译骨架:先读文件再进菜单
很多人的第一版是每个功能函数内部各开一次文件、各读一遍数据,结果是添加完之后查询看不到新记录,因为查询函数读的是磁盘上的旧文件。正确做法是在 main 里先调一次读文件,之后所有操作都作用于内存数组,退出时统一保存。
int main() { manage m; m.c_read(); // 先把课程读进 co[],c_cnt 同步更新 m.s_read(); // 再把学生读进 st[],s_cnt 同步更新 m.run(); // 菜单循环,内部全部操作内存数组 m.save(); // 退出前落盘,避免改动丢失 return 0; }这段 main 只有六行,却决定了整个程序的数据流:文件是冷存储,数组是热数据,两者只在启动和退出时同步一次。参数上唯一要注意的是两个读函数必须有「文件不存在就静默返回」的分支,第一次运行程序时文件还没生成,直接in.open()失败就 return,不能让程序挂掉。
run 函数用 while 包住整个菜单,把「教师端 / 学生端 / 退出」作为一级选择,进入某一端之后再用一层 while 做二级菜单。原始版本没做这层循环,选一次功能就退出程序,测试时得反复重启,这个坑在第 5 章还会展开。
void manage::run() { int a = 0; while (a != 3) { // 3 表示退出,循环条件写反会直接进不去 cout << "1.教师端 2.学生端 3.退出" << endl; cin >> a; if (a == 1) { /* 教师端二级菜单 while 循环 */ } else if (a == 2) { /* 学生端二级菜单 */ } } }3. 落盘方案改造:从 fwrite 乱码到 fstream 文本可读
课程设计报告里通常会写一句「将记录存入文件」,但存成什么格式、怎么读回来,差别很大。原始实现中途换过一次方案,从 C 的 fwrite 改成 C++ 的 fstream,原因是前者写出来的文件用记事本打开全是乱码,答辩时没法演示。这一章把两种方案摊开对比,再给出可以直接抄的读写骨架。
3.1 两种持久化方式的对照与选型
| 维度 | fwrite / fread(二进制) | ofstream / ifstream(文本) |
|---|---|---|
| 文件可读性 | 记事本打开是乱码,需专用工具 | 直接可读,方便演示和手工核对 |
| 存储体积 | 紧凑,按结构体内存布局写 | 稍大,数字转字符有额外开销 |
| 字段变更影响 | 加字段后旧文件全废,需重写 | 加字段后按新顺序读,旧文件报错但可手工修 |
| 跨平台 | 结构体对齐、字节序不同会出问题 | 基本无差异 |
| 适用场景 | 定长关联记录、追加写 | 主数据表、需要人眼核对的场景 |
结论很清楚:student 和 course 这类主数据表用文本流,xuanke 这类纯关联记录用二进制追加。原始代码也是这么分的,只是报告里没写清楚为什么,答辩被问到容易答不上来。
改成文本流之后还有一个附带好处:调试时可以直接打开course_information.txt看内容,字段错位、少写一个字段、多写一个空格,肉眼就能发现。二进制文件出问题时只能靠打印数组来比对,效率差一个量级。
3.2 读文件函数的骨架与 eof 边界
读函数的写法看着简单,实际有三个容易翻车的细节:打开失败要返回、循环条件用流状态而不是 eof()、读完要关闭。
void manage::c_read() { ifstream in("course_information.txt"); // 默认 ios::in,文本模式 if (!in) { // 文件不存在是正常情况,静默返回 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].credit >> co[c_cnt].c_semester) { c_cnt++; // 每成功读一条才自增,读失败不自增 } in.close(); // 必须关,不关下次可能打不开 }逻辑说明:while (in >> ...)的返回值是流对象本身,读取成功时为真,遇到文件末尾或格式不匹配时为假,循环自然终止。这种写法比while (!in.eof())安全,后者在文件最后一行没有换行符时会多读一次,把上一条记录重复计入,表现为列表末尾凭空多一行。
参数说明:c_name是 char 数组,>>会跳过前导空白并在下一个空白处停止,所以课程名里不能有空格,否则后面的 c_nature 会读到课程名的后半段,整条记录错位。要支持带空格的名称,得把分隔符改成逗号并用 getline 切分,读函数和保存函数的格式必须同时改。
保存函数是读函数的镜像,用ios::out截断重写整份文件,字段之间用空格或制表符分隔,每条记录一行:
void manage::save() { 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].credit << ' ' << co[i].c_semester << '\n'; } out.close(); }注意ios::out是截断写,文件原有内容会被清空。如果误用ios::app追加模式,每保存一次文件就翻倍,跑三遍之后数据全乱。这是我在别的项目里真实翻过的车,改的时候只改了一个枚举值,现象是记录条数指数增长。
3.3 选课记录的二进制追加:fseek 与 fwrite 的参数
选课记录用二进制追加写,核心就三行:以ab+打开、定位到文件末尾、写一条记录。
void manage::xuanke() { FILE *fp = fopen("xuanke_information.txt", "ab+"); // a 追加,b 二进制,+ 可读 if (fp == NULL) { fp = fopen("xuanke_information.txt", "wb+"); // 首次运行文件不存在,改为新建 if (fp == NULL) { cout << "无法建立文件" << endl; return; } } xuanke xk; cout << "输入课程代码和学号:" << endl; cin >> xk.c_num >> xk.s_num; fseek(fp, 0, SEEK_END); // 定位到末尾,追加不覆盖 fwrite(&xk, sizeof(xuanke), 1, fp); // 写 1 个 sizeof(xuanke) 大小的块 fclose(fp); }参数说明:"ab+"里 a 表示追加写、b 表示二进制、+ 表示同时允许读;fseek(fp, 0, SEEK_END)的第三个参数是基准位置,SEEK_END 表示从文件末尾算起,偏移 0 就是末尾本身;fwrite的三个参数依次是数据地址、单块大小、块数量,这里写 1 块。
统计选课人数时靠文件长度反推记录条数:
fseek(fp, 0, SEEK_END); long n = ftell(fp) / sizeof(xuanke); // 总字节数除以单条大小 = 记录条数 rewind(fp); // 回到开头再逐条读这里有个隐蔽的坑:sizeof(xuanke)只有在结构体没有填充字节时才等于真实记录大小。xuanke 是两个 int,通常 8 字节,没有对齐填充,算出来是对的。但如果哪天给它加一个 char 字段,编译器为了对齐会插入 3 字节填充,sizeof 变成 12,而实际写入的布局也是 12,所以按 sizeof 算条数仍然对——真正会错的是「用固定 8 字节去推算」的写法。稳妥做法是永远用 sizeof 而不是硬编码数字。
注意:同一份文件不能一半用 fwrite 写、一半用 ofstream 写。两种方式对换行符和数字格式的处理不同,混用之后读出来的记录会错位。
4. 增删改查四件套:编号查重、数组前移与修改回滚
功能函数是课设的主体,也是答辩时被追问最多的地方。这一章按添加、查询、删除、修改的顺序,把每个函数的关键判断和边界条件过一遍,代码可以直接替换进自己的工程。
4.1 添加:先查重再落数组,计数跟着走
添加的流程是「录入 → 遍历查重 → 没重复才写入 → 计数自增」。顺序不能反,先写入再查重的话,发现重复时已经把脏数据放进数组了。
void manage::add_course() { if (c_cnt >= MAXN) { // 数组容量保护,不加这句会越界写 cout << "记录已满" << endl; return; } course c; c.put_course(); // 从 cin 读入全部字段 for (int i = 0; i < c_cnt; i++) { if (co[i].c_code == c.c_code) { // 唯一键比对,整数比较无歧义 cout << "课程代码重复,添加失败" << endl; return; // 直接返回,不改 c_cnt } } co[c_cnt] = c; // 先赋值 c_cnt++; // 再自增,两步不能合并成 co[c_cnt++] }参数说明:MAXN是数组上限,原始代码写死 100,实际课设数据量几十条就够,但一定要有这个判断,否则第 101 条会写到数组之外,症状是程序不报错但其他变量莫名其妙被改。co[c_cnt] = c; c_cnt++;这两行分开写是刻意的,合并成co[c_cnt++] = c;语法上没问题,但调试时看不出到底是赋值出错还是计数出错,分开写方便下断点。
学生添加同理,把 c_code 换成 SID 即可。这里有个常见误用:有人用for (i = 0; i < c_cnt; i++) if (...) break;然后判断if (i < c_cnt)表示重复,这个写法本身没错,但循环变量 i 在循环外还要用,必须声明在循环外面,写成for (int i = ...)会编译不过。
4.2 查询与显示:命中即 break 的写法与空表提示
查询按唯一键线性扫描,找到就输出并 break,循环结束后用i == c_cnt判断未命中。
void manage::c_search() { int code, i; cout << "请输入课程代码:" << endl; cin >> code; for (i = 0; i < c_cnt; i++) { if (co[i].c_code == code) { co[i].display(); break; // 找到就停,唯一键不会有多条 } } if (i == c_cnt) { // 循环跑完没 break,说明没找到 cout << "没有找到相关课程" << endl; } }逻辑说明:i == c_cnt是判断未命中的标准写法,依赖的是「break 会打断循环导致 i 停在命中位置」这一行为。如果循环里忘了 break,i 最终一定等于 c_cnt,未命中提示会永远触发,症状是明明有这条记录却提示找不到。
显示函数是遍历全部,前面加一个空表判断:
void manage::c_display() { if (c_cnt == 0) { // 空表单独提示,避免输出一个空表头 cout << "文件中无记录" << endl; return; } for (int i = 0; i < c_cnt; i++) { co[i].display(); // 每条一行 } }空表判断看着是细节,实际很影响演示效果:没有这句的时候,查询结果区域只有一行表头,看起来像程序卡住了。
4.3 删除:循环前移的边界在 c_cnt-1
数组删除的标准做法是把目标位置之后的所有元素依次前移一位,然后计数减一。边界写错是高频 bug。
void manage::c_delete() { int code, i; cin >> code; for (i = 0; i < c_cnt; i++) { if (co[i].c_code == code) break; } if (i == c_cnt) { // 没找到,直接返回,不能减计数 cout << "没有该项记录" << endl; return; } for (int j = i; j < c_cnt - 1; j++) { // 上界是 c_cnt-1,不是 c_cnt co[j] = co[j + 1]; // 后一个覆盖前一个 } c_cnt--; // 前移完成后计数减一 }参数说明:内层循环的上界必须是c_cnt - 1,因为循环体里访问了co[j + 1],j 取到 c_cnt-1 时访问的是 co[c_cnt],那已经是有效数据之外的位置。写成j < c_cnt的后果是最后一次把数组外的脏数据搬进最后一条有效记录,表现为删除后列表末尾出现一条垃圾数据,有时候是上一条的重复,有时候是乱码。
另一个容易忽略的点是「删除后计数没减」。前移做完但忘了c_cnt--,数组实际有效数据少一条,但程序认为还是原来那么多,显示时最后一条会重复出现。这个现象和 4.4 的修改回滚失败长得很像,排查时先看计数。
4.4 修改:先备份原对象,冲突再写回
修改是四个功能里最容易写错的。原始报告里专门记了一条血泪经验:找到记录后直接让用户输入新内容覆盖原对象,然后再检查新编号是否和别的记录冲突,冲突时想恢复却发现原内容已经被覆盖没了。
解决办法是先备份,冲突时整体写回:
void manage::c_edit() { int code, i; cin >> code; for (i = 0; i < c_cnt; i++) { if (co[i].c_code == code) break; } if (i == c_cnt) { cout << "没有此项记录" << endl; return; } course backup = co[i]; // 关键一步:整对象备份 co[i].put_course(); // 用户输入新内容,原内容已被覆盖 for (int j = 0; j < c_cnt; j++) { if (j == i) continue; // 跳过自己,否则永远判定重复 if (co[j].c_code == co[i].c_code) { // 新编号撞上别人 co[i] = backup; // 整体回滚到修改前 cout << "编号重复,修改失败" << endl; return; } } cout << "修改成功" << endl; }逻辑说明:backup是值拷贝,course 里全是定长数组和 int,默认拷贝构造就能完整复制,不需要手写。如果字段里有指针或 string 的动态内存,值拷贝也够用,但要注意深拷贝语义。
参数说明:if (j == i) continue;这句不能省。少了它,循环第一次就比对到自己,新编号和自己相等,直接判定重复,症状是任何修改都失败。原始报告里描述的是「不论有没有重复编号,结果都会修改」,说明当时连回滚分支都没进,问题出在别处;但这两处都要检查。
提示:修改函数里先 display 一遍旧内容再让用户输入,操作者能看清改的是哪条,也方便测试时确认定位正确。
5. 避坑与排查:课设里最容易翻车的五个点
这一章的五条都是原始报告里明确记录过的,加上我复现时补的两条,每条按现象、原因、解决三段写。赶课设的时候对着这几条过一遍,能省掉大半天。
5.1 文件没关,下一次 open 直接失败
现象:程序里先写文件再读文件,写的时候一切正常,读的时候in.open()返回失败,提示「打开失败」,但去目录里看文件确实存在,内容也在。
原因:写文件的流对象没有调用 close,缓冲区里的内容还没刷到磁盘,同时文件句柄被占用。有些平台允许多个读句柄,但写入中的文件再以读模式打开会被拒绝,表现出来就是「文件存在却打不开」。
解决:所有 ofstream 在函数结束前显式 close,或者在作用域结束时依赖析构自动关闭。更稳妥的做法是把读写拆成独立的函数,函数内部用局部流对象,出了作用域自动析构。原始代码里 save 函数连续打开两个文件写课程和学生,第一个必须 close 之后才能打开第二个,否则第二个 open 会失败。
5.2 计数没自增,新记录「隐身」
现象:添加一条课程提示成功,显示列表也能看到,但退出重进之后这条记录没了;或者显示列表里能看到,但按编号查询查不到。
原因:数组写入成功,但对应的计数变量没自增。显示函数如果恰好用的是另一个计数,就会出现「显示能看到、查询看不到」的错位;如果显示函数也用同一个计数,那就是显示也看不到。原始报告里记的是「新加入的课程不会显示」,本质就是添加时c_cnt没跟着加。
解决:把「写数组」和「计数自增」绑成一个原子操作,两行紧挨着写,中间不插任何语句。更彻底的办法是把计数改成std::vector的 size(),从根上消除计数与容量不同步的可能。课设阶段如果必须用数组,就在添加函数末尾加一句断言或打印,确认计数确实变了。
5.3 统计里学生数与课程数写反
现象:统计菜单里选「统计学生人数」,输出的是课程门数;选「统计课程门数」,输出的是学生人数。两个数字单独看都合理,只有对照实际数据才能发现串了。
原因:switch 分支里的变量写反,case 1输出了 c_cnt,case 2输出了 s_cnt。这类错误编译器不会报,因为两个变量类型相同,只有逻辑上不对。
解决:把统计输出拆成独立函数,函数名里带清楚统计对象,比如count_students()和count_courses(),switch 分支只负责调用。另外测试时准备一份学生 3 人、课程 5 人的数据,两个数字不一样,一眼就能看出串没串。如果两边数量恰好相同,这个 bug 能藏很久。
5.4 菜单做完一次操作就退出
现象:进入教师端,选「添加课程」,添加成功后程序直接结束,想看结果得重新运行;或者退回主菜单而不是留在教师端。
原因:菜单只用了一层 if-else,没有用 while 包住,函数执行完自然往下走到底就 return 了。原始报告里明确写了这个问题,解决办法是用 while 循环控制。
解决:每个层级的菜单都套一层 while,退出条件用一个专门的选项值(比如 0)控制。
void manage::teacher_menu() { int op = -1; while (op != 0) { cout << "1.添加 2.查询 3.删除 4.修改 5.显示 6.统计 0.返回" << endl; cin >> op; switch (op) { case 1: add_course(); break; case 2: c_search(); break; case 3: c_delete(); break; case 4: c_edit(); break; case 5: c_display(); break; case 6: cnt(); break; case 0: break; // 退出循环 default: cout << "输入无效" << endl; } } }参数说明:op 初始化为 -1 而不是 0,否则循环条件op != 0一开始就不成立,菜单一次都不显示。default 分支也别省,输错字符时 cin 会进入失败状态,后续所有输入都读不进来,加一句cin.clear(); cin.ignore();可以把状态复位。
5.5 改完不保存,下次运行全部还原
现象:一轮操作全部成功,显示也正常,关掉程序再打开,所有改动消失,数据回到上次保存时的状态。
原因:所有操作都作用在内存数组上,只有调用了 save 才写回文件。原始报告里记的就是「运行完某次程序时,下次再读取发现上次修改的记录没有显示」,根因是退出前没保存。
解决:最省事的做法是在 main 的 return 之前调一次 save,覆盖正常退出路径。但如果程序中途被强制关闭,这条路就断了。更稳的是每次增删改之后立即调 save,代价是频繁 IO,课设数据量小完全扛得住。原始报告里也把「自动调用保存函数」列进了改进方向,说明作者自己也意识到了。
注意:调试阶段频繁改数据结构时,自动保存反而是负担,容易把半成品数据写进文件。我一般会在调试期关掉自动保存,验收前再打开。
6. 验收与扩展:一组自造数据把系统跑到边界
代码能编译不等于能交付。答辩现场最常被要求的就是「你现场加一条、删一条、改一条给我看看」,这时候如果数据结构没测过边界,很容易当场翻车。我习惯在交付前用一组固定数据把五条路径全跑一遍,学生 3 条、课程 5 条,两个数字刻意不同,方便发现 5.3 那种串变量的问题。
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 正常添加 | 新增课程代码 101 | 提示成功,显示列表末位出现该课程 |
| 重复添加 | 再新增代码 101 | 提示重复,列表条数不变 |
| 查询命中 | 按代码 101 查询 | 输出该课程全部字段 |
| 查询未命中 | 按代码 999 查询 | 提示没有找到 |
| 删除中间项 | 删除列表第 2 条 | 后续记录整体前移,末位不重复 |
| 修改冲突 | 把课程 A 的代码改成课程 B 的代码 | 提示失败,A 的内容保持原样 |
| 空表显示 | 清空所有课程后显示 | 提示文件中无记录 |
| 跨文件统计 | 统计某课程的选课人数 | 数字与 xuanke 文件中的记录条数一致 |
边界用例里最值得单独测的是「修改冲突」这条,因为它同时验证了备份、比对、回滚三个动作。测试方法是先记下原记录的完整内容,故意改成重复编号,确认失败之后再显示一遍,逐字段对照。只要有一个字段对不上,就说明回滚不完整。
// 验收脚本化:不用手工点菜单,直接把关键路径跑一遍 void manage::selftest() { course a{101, "数据结构", "必修", 64, 4, "2024春"}; course b{102, "操作系统", "必修", 56, 3, "2024秋"}; co[c_cnt++] = a; co[c_cnt++] = b; assert(c_cnt == 2); // 计数必须跟数组同步 course backup = co[0]; co[0].c_code = 102; // 人为制造重复 bool dup = false; for (int j = 0; j < c_cnt; j++) { if (j != 0 && co[j].c_code == co[0].c_code) { dup = true; break; } } if (dup) co[0] = backup; // 与 c_edit 相同的回滚逻辑 assert(co[0].c_code == 101); // 回滚后必须还原 }这段自测代码不依赖菜单输入,编译后直接跑,出错时 assert 会停在具体行。参数上注意co[c_cnt++] = a;这里可以合并写,因为已经在测试环境里,出问题好定位;正式功能函数里还是建议拆开写。
扩展方向按落地成本从低到高排:自动保存最简单,在增删改函数末尾加一行 save 就行;退选功能次之,需要按 c_num 和 s_num 两个条件定位 xuanke 文件里的记录,二进制文件不支持原地删除,得整份重写;图形界面成本最高,原始报告里提到的 MFC 方案属于这一档,先把控制台版本的数据层和界面层拆开,界面层只负责收集参数和展示结果,换起来才不痛苦。我吃过一次亏:功能函数里混着 cout 和 cin,想换界面时发现业务逻辑和输入输出缠在一起,只能重写。从那以后我每次写这类小系统,都强制把「读输入」「做逻辑」「打输出」拆成三层,哪怕课设只有一个人写。希望帮到你。
本文还有配套的精品资源,点击获取