ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++学生选修课程系统设计:从类结构到选课与持久化实战

C++学生选修课程系统设计:从类结构到选课与持久化实战

简介:面向C++课程设计经典题目——学生选修课程系统的完整参考文档,适合高校计算机科学与技术专业学生、参加课设的开发者用于方案设计与报告撰写。资源仅含1个doc文档,压缩包约259KB,内容为青岛理工大学C++面向对象课程设计报告,覆盖需求分析、内容设计、模块图与流程图、关键代码、调试分析、用户手册等完整章节。报告以学生登录、课程浏览、选课操作、个人信息管理、成绩查询和数据统计为主要功能目标,详细设计了用户管理、课程信息、选课等模块,并针对选课冲突判断、数据持久化、异常处理等关键问题给出了具体解决思路。通过阅读该报告,读者可以完整了解一个课程设计项目从需求分析到编码实现的推进过程,也能参考其目录与写作结构来组织自己的课设报告,对理解C++面向对象设计、提升软件工程实践能力很有帮助。目前该资源已有1441人学习浏览,是同类C++课程设计中较具参考价值的资料。

1. 先想清楚再写码:学生选修课程系统到底在做什么

很多人一拿到“学生选修课程系统设计 c++”这个题目,第一反应是“不就是控制台菜单加几个类吗”,结果动手三天后翻车在选课冲突、退选后名额不恢复、文件读取乱码这些细节上。这个题目真正考的并不是 C++ 语法,而是你能否把一个有状态、有规则、有约束的业务流,用对象和数据结构表达清楚。课程有容量、学生有学分上限、同一时间不能选两门课、退课要释放名额、数据要落盘后能重启恢复——这才是系统的核心。

我见过 A 同学花了大半天把菜单和增删改查写得漂漂亮亮,却忽略了“选课前检查冲突”这个最关键的动作,最后答辩演示时被问“周三晚上选了甲课还能不能选乙课”当场卡壳。所以这篇笔记不讲虚的,直接按我平时做这类管理系统的思路拆开:需求边界怎么定、类怎么设计、选课规则怎么写成可测试的函数、文件怎么存才不容易坏,以及那些只有自己敲过一遍才能记住的坑。适合正在做课程设计、毕业设计,或者想用 C++ 练手一个小型管理系统的开发者,跟着把每一步落进代码,做完就是能跑、能存、能验证的成品。

2. 从菜单到内存:模块划分与类设计

2.1 需求清单:把“选修课系统”这句话拆成能写进代码的条目

写代码之前先列功能清单,不然写着写着就会发现要改类结构。一个最常见可靠的学生选课系统,核心功能就那么几项:学生信息维护、课程信息维护、学生选课与退选、选课结果查询、课表冲突检测、数据持久化。我一般会把它们拆成“必做”和“加分”两层,先保证基础跑通,再上高级功能。

必做项里,“学生信息维护”至少要支持增加、删除、按学号查找、显示列表;“课程信息维护”要支持增加、删除、修改容量和学分、显示列表;“选课”要检查课程是否存在、学生是否存在、是否已选过、课程是否已满、是否与已有选课时间冲突;“退选”要释放名额;“查询”要能看某学生选了哪些课、某课程被哪些学生选了。加分项包括按总分排序、学分累计、批量导入导出、登录验证等,但这些不是一开始就急着加的。

必须注意一个边界:这些功能里,哪些是“刚性规则”,哪些是“柔性提示”?刚性的必须写进代码里拦截,比如课程容量为 0 时不能再选;柔性的可以只给提示,比如允许选课时段重叠但警告。设计阶段最好把刚性规则列成一张表,后面写代码才不会前后矛盾。我的做法是先把规则表写进注释里,作为类的设计依据,代码只是规则的翻译。

规则处理方式涉及对象
课程容量固定,超容量不可选刚性拦截Course::capacity, Selection
同一学生同一课程不可重复选刚性拦截Selection::record
同一时间段最多选一门课刚性拦截Course::timeSlot, Selection
学分上限(如 30 学分)可选拦截或警告Student::credits
退课后容量恢复刚性恢复Selection::remove, Course::selectedCount

2.2 类的设计:Student、Course、Selection 各自管好自己的一亩三分地

面向对象设计的原则很简单:每个类只关心自己那一类数据和操作。Student 管自己的学号、姓名、已修学分;Course 管自己的课程编号、名称、学分、上课时间、总容量、当前已选人数;Selection 是连接表和业务规则的中枢,它管选课记录以及选课/退选的逻辑。

我见过有人把选课记录直接塞进 Student 类里的,学生类里挂一个 vector ,课程类里再挂一个 vector ,双向引用非常难维护,删除课程时要遍历所有学生删记录,删除学生时要遍历所有课程删学生,最后自己把自己绕晕。正确做法是单独建一个选课记录结构,所有关联关系都通过这个表来查,Student 和 Course 保持“无双向指针”的松耦合。

下面给出一个最小但完整的类声明,这是后续所有代码的基础。实际运行时你需要一个容器把所有对象管理起来,我习惯用 vector 全局存储,因为系统量级小,不需要数据库。

#include <iostream> #include <vector> #include <string> #include <fstream> struct Course { std::string id; // 课程编号,如 CS101 std::string name; // 课程名称 int credit = 0; // 学分 std::string timeSlot; // 上课时间段,如 "周一 3-4节" int capacity = 0; // 总容量 int selected = 0; // 已选人数 }; struct Student { std::string id; // 学号 std::string name; // 姓名 int totalCredits = 0; // 当前总学分,用于学分上限检查 }; struct Selection { std::string studentId; std::string courseId; };

这段代码里我用 struct 而不是 class,因为现阶段数据成员公开操作更直接。如果后续要做更严格的封装,可以改成 class 并提供 getter/setter,但小型课程设计里 struct 足够。注意 Course 里存的是“上课时间段”字符串而不是结构化时间,这样做冲突检测时只能做“字符串相等”判断,优点是简单,缺点是无法判断“周二 1-2 节”和“周二 2-3 节”是否重叠,这是我后面要讲的进阶优化点,但目前在基础版里足够用。

2.3 全局存储与状态一致性:让所有函数都操作同一份数据

有了类之后,下一步就是定义程序运行期间的数据容器。我用全局变量vector<Student> students; vector<Course> courses; vector<Selection> selections;来保存数据状态,所有功能函数都直接操作这组全局变量。为什么不用局部变量?因为选课、退选、查询这些操作会交替修改同一个状态,如果每个函数都自己拷贝一份,数据一致性就崩了。全局变量在大型工程里不推荐,但在这种千行级小系统里是最高效直接的方案。

这里有一个常见问题:函数参数传值还是传引用?比如“选课”这个操作,需要同时检查学生、课程、已有选课记录,你可能是这样写:

bool selectCourse(const std::string& studentId, const std::string& courseId) { // 找到学生,找不到返回 false Student* stu = nullptr; for (auto& s : students) { if (s.id == studentId) { stu = &s; break; } } if (!stu) return false; // 找到课程,找不到返回 false Course* cou = nullptr; for (auto& c : courses) { if (c.id == courseId) { cou = &c; break; } } if (!cou) return false; // 检查课程是否已满 if (cou->selected >= cou->capacity) return false; // 检查是否重复选课 for (const auto& sel : selections) { if (sel.studentId == studentId && sel.courseId == courseId) return false; } // 检查同一时间冲突:遍历该学生已选课程,比较 timeSlot for (const auto& sel : selections) { if (sel.studentId != studentId) continue; for (const auto& c : courses) { if (c.id == sel.courseId && c.timeSlot == cou->timeSlot) return false; } } // 通过所有检查,写入记录并更新已选人数 selections.push_back({studentId, courseId}); cou->selected++; return true; }

这里我用的是容器里的数组下标,但注意我用了指针stu和cou指向 vector 中的元素。关键坑点:在selections.push_back之前,我可能会误操作导致courses或students发生扩容(比如在中间插入新课程),那之前拿到的指针就失效了。上面这段代码里 push_back 发生在检查之后,且 push_back 的是selections,不影响cou指向的容器,所以安全。但如果把 push_back 或 erase 写在获取指针之后、使用指针之前,就要小心迭代器失效。

参数说明:函数入参采用const std::string&,避免字符串拷贝,因为选课系统里学号和课程号会频繁传参。返回值设计为 bool,调用方根据返回值提示“选课成功”或“选课失败及失败原因”。但这里还有个问题:调用方只知道成功与否,不知道具体失败原因。更完善的做法是传入一个std::string& errorMsg引用,失败时写入具体原因,这个我们放在后面的进阶里。

3. 用 vector 和 map 实现选课核心逻辑

3.1 为什么选 vector 和 map 作为主要容器

很多教程喜欢用链表手写数据结构,但那是为了考算法,不是工程最优解。STL 里vector在尾部插入删除效率高,随机访问是 O(1),对于按学号遍历查找完全够用。std::map适合建索引,比如从课程编号快速定位课程对象,但要注意 map 的 key 需要比较函数,string 默认支持。

一个我常用的做法:除了 vector 之外,再维护一个std::map<std::string, size_t>用来存储“学号到 vector 下标的映射”,这样按 id 查找就从 O(n) 降到 O(log n)。但代价是每次增删都要同步更新映射,否则会出错。在小系统里,n 最多几百个,线性遍历根本不慢,所以加 map 只是为了练习技巧,不是必须。如果你追求简洁,完全可以直接 for 循环查找。下面的代码我先用线性查找,因为可读性最好,性能在这个规模下不是问题。

3.2 选课函数的完整实现:从检查到写入一条龙

把上一章的函数扩展成具备错误原因输出的版本,同时处理学分上限检查。这样调用方就可以精准提示用户。注意这段代码故意把检查步骤拆得很细,每一步都能对应一个业务规则。

// 选课函数,errorMsg 为输出参数,用于返回失败原因 bool selectCourseWithMsg(const std::string& studentId, const std::string& courseId, std::string& errorMsg) { Student* stu = nullptr; for (auto& s : students) { if (s.id == studentId) { stu = &s; break; } } if (!stu) { errorMsg = "学生不存在"; return false; } Course* cou = nullptr; for (auto& c : courses) { if (c.id == courseId) { cou = &c; break; } } if (!cou) { errorMsg = "课程不存在"; return false; } // 重复选课检查 for (const auto& sel : selections) { if (sel.studentId == studentId && sel.courseId == courseId) { errorMsg = "该课程已选,请勿重复选课"; return false; } } // 容量检查 if (cou->selected >= cou->capacity) { errorMsg = "课程已满"; return false; } // 时间冲突检查:遍历当前学生所有选课记录 for (const auto& sel : selections) { if (sel.studentId != studentId) continue; for (const auto& c : courses) { if (c.id == sel.courseId && c.timeSlot == cou->timeSlot) { errorMsg = "该时间段已有课程,冲突:"; errorMsg += c.name; return false; } } } // 学分上限检查(可以设为 30 分) if (stu->totalCredits + cou->credit > 30) { errorMsg = "超过学分上限 30 分"; return false; } // 所有检查通过,执行选课 selections.push_back({studentId, courseId}); cou->selected++; stu->totalCredits += cou->credit; errorMsg.clear(); return true; }

逻辑说明:这个函数把上一节分散的检查聚在一起,并加入了学分累计。注意stu->totalCredits在这里的面积是“入读以来所有已选课程学分之和”,但实际上学生可能已经修过一些课程,这个总数应该从文件加载时初始化,而不是每次选课时重新计算。我这里把totalCredits作为 Student 的一个字段,选课成功后累加,退课后要减回去。如果你只在选课时遍历选课记录重新算学分,则不需要额外维护这个字段,但那样每次查询都要循环,不如字段直接。

参数说明:errorMsg必须是非 const 引用,因为我们要在函数内部修改它。调用方应该先把传入的字符串清空,但我在函数内部也处理了清空逻辑。这个函数的返回值是 bool,调用方拿到 false 时直接把 errorMsg 内容提示给用户即可。注意时间冲突比较用的是==,也就是同一个字符串才算冲突,如果你想支持重叠判断,需要把 timeSlot 改成起始节次和结束节次两个整数,这是进阶话题。

3.3 退选函数:不要忘了恢复容量和扣回学分

退选是选课的逆操作,容易写漏两个地方:一是要把课程对象的 selected 减回去,二是要把学生的 totalCredits 减回去。更麻烦的是,如果学生已经选了某门课,退选要同时删除 Selection 记录,而 vector 中删除元素会导致后面元素下标前移。所以我在遍历时用了迭代器,找到后直接用 erase 返回下一个迭代器。

// 退选函数:删除选课记录,恢复容量,扣回学分 bool dropCourse(const std::string& studentId, const std::string& courseId, std::string& errorMsg) { for (auto it = selections.begin(); it != selections.end(); ++it) { if (it->studentId == studentId && it->courseId == courseId) { // 找到课程,恢复容量 for (auto& c : courses) { if (c.id == courseId) { c.selected--; // 扣回学生学分 for (auto& s : students) { if (s.id == studentId) { s.totalCredits -= c.credit; break; } } break; } } selections.erase(it); errorMsg.clear(); return true; } } errorMsg = "选课记录不存在"; return false; }

这里有一个容易翻车的点:selections.erase(it)后,迭代器 it 失效,但我们已经 return,所以没问题。如果函数后面还要继续使用 it,就必须用it = selections.erase(it)来更新。另外,这里我先修改 course 和 student,再删除 selection,顺序上没问题。但如果你改成先删除 selection 再恢复容量,万一 course 循环里没找到课程,就会导致容量没恢复,所以最好在删除前先检查能否恢复,或者统一用“先查找,再修改,再删除”的顺序。

3.4 菜单驱动与命令分发:为什么我推荐用数字编号而不是字符串

控制台系统的功能入口一般是一个菜单,用户输入数字选择操作。我见过有人用字符串比如 "add" "drop" 来分发,但那是命令行风格,对普通用户不友好。数字菜单需要注意输入非法值时的处理:如果std::cin >> opt输入了字母,cin 会进入错误状态,后面的读取全部失效,必须用cin.clear()和cin.ignore()恢复。新手最容易在这里踩坑:输入了 7,程序报“无效选项”后再次显示菜单,但下一次输入居然直接跳过,就是因为 cin 流状态没恢复。

下面是一个如何处理菜单输入的框架代码,它还有一个好处:把所有功能函数都串联起来,形成完整闭环。

void showMenu() { std::cout << "======== 学生选修课程系统 ========\n"; std::cout << "1. 学生管理\n"; std::cout << "2. 课程管理\n"; std::cout << "3. 选课操作\n"; std::cout << "4. 查询统计\n"; std::cout << "5. 保存数据\n"; std::cout << "0. 退出\n"; } int main() { int opt = -1; while (true) { showMenu(); std::cout << "请输入选项: "; std::cin >> opt; if (!std::cin) { // 输入了非数字 std::cin.clear(); std::cin.ignore(1000, '\n'); std::cout << "输入无效,请重新输入数字。\n"; continue; } if (opt == 0) break; // 根据 opt 分发到不同的处理函数,这里省略 } return 0; }

逻辑说明:std::cin >> opt如果读取失败,会把opt置为 0(C++11 起),所以不能简单用if (opt == 0)来判断退出,必须先检查流状态。cin.clear()清除错误标记,cin.ignore(1000, '\n')丢弃缓冲区里的非法字符,直到换行,避免下一次读取继续撞到残留的字母。这是控制台程序最基础的防护,很多人直到演示时才发现输错了会死循环。参数说明:ignore的第一个参数是最大丢弃字符数,我这里写 1000,够用;实际更严谨应该用std::numeric_limits<std::streamsize>::max()。

4. 把数据跑进 txt:文件持久化的三种做法

4.1 为什么不用标准序列化库,而选择手写文本格式

C++ 标准库里没有直接把 struct 序列化成文件的现成方案。你可以用二进制直接write结构体到文件,但这样做有几个大坑:一是 struct 里如果有std::string,它内部保存的是堆指针,直接写指针地址到文件,下次读出来指针悬空,程序崩溃;二是不同编译器对 struct 的 padding 不同,文件格式不跨平台。所以最常见可靠的方案是手写文本格式,每个字段以分隔符隔开,每行一个对象。

文本格式还有一个好处:出了 bug 可以直接用记事本打开看,不像二进制那么黑匣子。我一般选择用逗号分隔,但要注意课程名称里不要有逗号,否则解析会错位。如果你要保险,可以把分隔符设成|或\t,不容易出现在普通文本里。

4.2 数据存储格式设计:一个文件还是三个文件

通常有三种设计:

  1. 三个文件分别存学生、课程、选课记录,比如 students.txt, courses.txt, selections.txt。优点是结构清晰,缺点是在构造对象时,学生的 totalCredits 需要从选课记录里算,加载顺序有讲究。
  2. 一个超级文件,把所有数据按段存储,标记[Students]、[Courses]、[Selections]。优点是读写在同一处,缺点是一处写坏全盘崩。
  3. 用内存快照的方式,把整个运行时状态写入一个文本文件,类似游戏存档。优点是恢复状态简单,缺点是文件格式变化时旧数据作废。

我推荐第一种:三个文件分离,因为选课记录是独立的,而且存在“学生文件里存好的 totalCredits 可能与选课文件不一致”的问题。更稳健的加载顺序是:先读学生(但不依赖学分字段),再读课程,再读选课记录,然后根据选课记录回算学生总学分和课程已选人数。这样即使 someone 手工把 students.txt 里的总学分改了,加载后也会被修正覆盖。

课程文件的格式我这样定义:

// courses.txt 每行格式: // id,name,credit,timeSlot,capacity,selected // 示例: // CS101,数据结构,3,周一 3-4节,60,2

加载课程代码:

bool loadCourses(const std::string& filename, std::vector<Course>& out) { std::ifstream fin(filename); if (!fin.is_open()) return false; std::string line; while (std::getline(fin, line)) { if (line.empty()) continue; // 用 stringstream 按逗号分割 std::stringstream ss(line); Course c; std::string capStr, selStr; if (std::getline(ss, c.id, ',') && std::getline(ss, c.name, ',') && ss >> c.credit && // 注意:credit 后还有逗号,不能直接用 getline ss.ignore(1, ',') && std::getline(ss, c.timeSlot, ',') && std::getline(ss, capStr, ',') && std::getline(ss, selStr)) { c.capacity = std::stoi(capStr); c.selected = std::stoi(selStr); out.push_back(c); } else { std::cerr << "跳过无效行: " << line << "\n"; } } return true; }

这里有个很隐蔽的坑:ss >> c.credit之后,流的读取位置停在下一位,也就是逗号后面。如果直接用ss.ignore(1, ',')跳过逗号,没问题;但如果我改用std::getline(ss, c.timeSlot, ','),它会从当前位置读到下一个逗号之前,然而当前位置是逗号,getline 会立刻读到空字符串从而失败。所以必须先 ignore 掉逗号。更稳妥的做法是把每行所有字段都按字符串读入,再用stoi转换,避免类型切换和流状态纠缠。

参数说明:std::stoi在遇到非数字时会抛异常,所以这段代码里的capStr和selStr必须是纯数字。如果文件是从手工修改来的,可能混入空格,stoi会抛出std::invalid_argument。一个保险做法是写一个safe_stoi函数,用 try-catch 包裹,失败时给默认值。但作为课程设计,我们先把核心跑通。

4.3 保存时如何保证不写坏数据:先写临时文件再替换

保存数据最怕程序写了一半突然崩溃,导致原文件被截断。有个很实用的做法:先写一个临时文件,全部写入成功后,用std::remove把旧文件删掉,再用std::rename把临时文件改名成正式文件。这样至少保证文件要么是旧版本,要么是新版本,不会出现半新半旧的状态。

bool saveAllData() { // 先写学生到临时文件 std::ofstream fout("students.tmp"); if (!fout.is_open()) return false; for (const auto& s : students) { fout << s.id << "," << s.name << "," << s.totalCredits << "\n"; } fout.close(); // 同理写课程和选课记录... std::ofstream cout("courses.tmp"); for (const auto& c : courses) { cout << c.id << "," << c.name << "," << c.credit << "," << c.timeSlot << "," << c.capacity << "," << c.selected << "\n"; } cout.close(); std::ofstream sout("selections.tmp"); for (const auto& sel : selections) { sout << sel.studentId << "," << sel.courseId << "\n"; } sout.close(); // 替换旧文件 std::remove("students.txt"); std::rename("students.tmp", "students.txt"); std::remove("courses.txt"); std::rename("courses.tmp", "courses.txt"); std::remove("selections.txt"); std::rename("selections.tmp", "selections.txt"); return true; }

逻辑说明:这里我用了三个临时文件,但注意如果三个文件中任何一个写入失败,旧文件已经被删掉了,数据就丢了一部分。更稳的做法是先全部写入 temp,检查所有文件都成功,再做替换操作。为了演示简洁,上面这个版本在工程上仍然有风险。实际我一般会定义一个辅助函数writeFileSafely(tmpPath, finalPath, writerLambda),用 lambda 封装写入内容,先写完临时文件再统一替换。如果你不想写 lambda,用两个步骤也能接受,但要意识到这个边界。

参数说明:std::rename在不同平台上行为略有差异,Windows 下如果目标文件存在,rename 可能失败,所以需要先 remove。我们这样先删后改会有一个时间窗口,但比不处理好得多。另一种方案是用std::filesystem::rename,C++17 里它可以直接覆盖目标文件,但如果你用的是老标准,就保持上面的写法。

5. 选课系统常见坑与排查清单

5.1 坑一:vector 扩容导致指针失效,程序直接崩溃

现象:我在选课函数里先通过引用拿到Student*和Course*,然后调用addStudent新增一个学生,再使用之前的Student*访问数据,程序崩溃。原因:vector在 push_back 时会可能重新分配底层数组,原来的指针指向的内存被释放,成了悬垂指针。解决:要么在使用指针期间避免任何改变 vector 的操作,要么用下标而不是指针,每次使用时通过新的查找函数获取对象。我的习惯是:在一个函数里,如果需要同时操作多个对象,先用下标索引,再通过容器访问,不要长期保存指针跨操作。比如我把size_t studentIndex保存下来,后续students[studentIndex]访问,就算 vector 扩容了,下标依然有效,因为容器内部会自动重新定位。

5.2 坑二:从文件读取中文变成乱码,Windows 控制台背锅

现象:学生在 courses.txt 里保存了“数据结构”,重新加载后控制台输出变成了一堆问号。原因:Windows 控制台的默认代码页是 GBK/936,而记事本另存为 UTF-8 时文件头有 BOM,std::ifstream读取时不会自动跳过 BOM,导致第一个字段之前多了一个\xEF\xBB\xBF,整个 ID 字符串无法匹配。解决:第一行第一字段会带 BOM,加载学生文件时先检查并跳过。另外,如果你在编译时用 MSVC,源码里字符串常量如果是 UTF-8,输出到控制台也会乱码。更省心的方案是:所有文本文件统一使用 UTF-8 编码,在程序初始化时执行SetConsoleOutputCP(CP_UTF8)(Windows 专用),或者在读取文件后把中文转成 UTF-8 内部表示。我这里给一个通用的 BOM 跳过函数:

std::string stripUtf8Bom(const std::string& line) { if (line.size() >= 3 && (unsigned char)line[0] == 0xEF && (unsigned char)line[1] == 0xBB && (unsigned char)line[2] == 0xBF) { return line.substr(3); } return line; }

在每行读取后先调用 stripUtf8Bom,再去做分割,基本能躲过 80% 的乱码坑。至于源文件里中文和字体问题,那是另一层,不展开。

5.3 坑三:退课恢复了容量,但忘记扣回学分,总分虚高

现象:学生退了一门 3 学分的课,查询总学分却还是那个数字,下学期选课被学分上限挡住。原因:退选函数里我只删除了选课记录,没写totalCredits -= credit。这种逻辑缺失在测试时很容易漏,因为“查询总学分”和“退选”是两个独立菜单,你要跨菜单核对数据才能发现。解决:把“总学分”和“已选人数”这两个派生字段从文件加载后,统一通过选课记录重算,而不是信任文件里的存储值。这样就算退课函数哪天忘了扣学分,只要加载时基于 selections 重新计算,也能自动修正。我发现一个很好的自检方法:在查询界面上显示“学生总学分”和“该学生选课记录里的学分总和”,如果两者不一致,说明代码里某个更新语句漏掉了。

5.4 坑四:控制台菜单输入一个字母后,所有后续输入全部失效

现象:用户输错了一次选项,输入了字母 'a',之后不管输什么都提示“请输入数字”,循环卡死。原因:std::cin进入错误状态,opt变量保持旧值或变成 0,而while(true)继续执行读操作,读失败后继续循环,表现就是死循环。解决:在每次读取后检查if (!std::cin),然后clear()和ignore()。还有一个进阶预防:把输入行用std::getline(std::cin, line)读成字符串,再用std::stoi转换,这样即使输入了字母也不会污染流状态。我把这个封装成一个readOption()函数,比直接cin >> opt稳得多。

5.5 坑五:时间冲突检测只比较字符串,周一 1-2 和周一 1-2 能拦住,周三 1-2 和周三 2-3 拦不住

现象:系统允许学生选了“周三 1-2节”又选“周三 2-3节”,因为 timeSlot 字符串不同。原因:时间冲突的本质是节次区间是否有交集,字符串相等判断只能拦截完全相同的时段。解决:把 timeSlot 拆成day、startSection、endSection三个字段,冲突条件改为“同一天且 startSection <= 对方 endSection 且对方 startSection <= 自己的 endSection”。我后来在课程类里加了std::string day和int start, end,选课检查时改成区间重叠判断,才彻底解决这个“半玄学”问题。下面是把冲突检查改成区间的示例:

struct Course { std::string id; std::string name; int credit; std::string day; // "周一" int startSection; // 1 表示 1-2节 int endSection; // 2 表示 1-2节,3 表示 3-4节 }; bool isTimeConflict(const Course& c1, const Course& c2) { if (c1.day != c2.day) return false; return !(c1.endSection < c2.startSection || c2.endSection < c1.startSection); }

这段代码的!取反看起来绕,但实际等价于“c1 结束早于 c2 开始,或 c2 结束早于 c1 开始,则无冲突;否则冲突”。把它用在选课检查里,就能覆盖连续节次的重叠。如果你有时间再去解析“双周/单周”这种更细的规则,建议在 Course 里加一个 weekType 字段,但这已经进入进阶范围了。

6. 把课表做成能验证的成品:测试、边界与下一步

6.1 手工自测用例清单:关键场景必须一条条走通

系统写完后,我习惯列一个“验收清单”,用真实数据走一遍。下面是一个最小自测用例,覆盖了选课系统最重要的几个分支:

测试操作预期结果通过
添加学生 S001,课程 C001(容量1),C002(同时间)两个操作返回成功是
S001 选 C001成功是
S001 重复选 C001返回“已选”是
S001 选 C002(同时间)返回“时间冲突”是
S002 选 C001(容量已满)返回“已满”是
S001 退选 C001成功,C001 容量恢复是
S002 选 C001成功是

这一步能帮你发现至少一半的隐藏问题。我还会在测试完后保存数据、重启程序、再加载,确认数据恢复一致。如果这三个环节都能过,系统基本可以交差了。

6.2 进阶方向:从控制台到带界面的演进

如果你还有余力,可以在现有代码基础上加三个进阶点:用std::map建立索引提速;引入日志记录每次选课退课操作;把数据存储换成 SQLite,避免文件并发写坏。我做过的某跨平台系统就是从 txt 升级到 SQLite,改动量并不大,因为核心业务逻辑已经和存储层分离了,只需要替换加载和保存两个函数。这个设计好处在多年后依然成立:业务逻辑不应该知道数据存在文本还是数据库里。

这次做完这个项目,我最大的习惯是开始给所有“更新数据”的函数写一个“反向操作”,比如选了课就一定要有退选,加了人就一定要允许删人,哪怕需求里没提,也要补上。因为系统的完整性不是功能多少,而是状态能不能回到起点。这个习惯救了我很多次,让我在演示现场被老师随便翻来覆去点菜单时,数据始终自洽。希望这些代码和踩坑记录能帮你少走几段弯路,顺利把这份设计做成能拿得出手的成品,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进