简介:面向计算机相关专业学生与C++初学者的一套学生成绩管理系统完整源码,可直接用于毕业设计、课程设计,也可作为C++综合练习的参考实现。系统采用教师与学生双角色登录,学生端可查看本人全部信息,教师端集成课程管理、成绩录入、成绩查询、成绩修改、成绩删除和学生管理六大模块,支持按学号、姓名、课程多条件检索,也支持通过成绩文件批量导入,覆盖了从数据录入到日常维护的完整业务闭环。压缩包共26个文件、约6.49MB,以5个cpp源文件与4个头文件为核心实现,另含Xcode工程配置、数据库说明文档、项目报告PDF与小结RTF,便于读者理解工程组织与设计思路。目前已有10194人学习下载。读者可据此理清类与模块划分、文件与数据库读写流程,并在此基础上替换界面或扩展字段,作为答辩材料与二次开发底稿都较为实用。
1. 答辩现场被临时加需求:这套 C++ 学生成绩管理系统能顶住什么
「再加一个按班级统计不及格率的功能,现在就要。」——很多拿 C++ 学生成绩管理系统做毕业设计的人,最怕听到这句话。菜单里只有录入、查询、删除,统计按钮点下去是空的;源码打开一看,几百行业务逻辑全挤在 main 里,想加一行就得改十处。这套带源码的资源想解决的就是这个局面:学生、课程、成绩拆成三个清晰的数据结构,增删改查沉到独立函数,存储层同时给文件和数据库两条路,控制台界面虽然朴素,但每个功能都能顺着调用链找到落点。它适合三类人:赶毕业设计进度、需要一个能讲清楚架构的底稿的人;想把 C++ 语法课作业升级成完整项目的人;以及带课设、需要一份可改可讲的参考实现的开发者。数据库、文件读写、排序统计这几个答辩必问的点,下面逐层拆开。
2. 数据模型先定下来:Student 与 Course 结构体决定了后面八成的返工
拿到源码先别急着编译。成绩管理系统的骨架差异其实不大,真正决定这套代码能不能改成你题目的,是数据模型。字段少一个「学期」,后面所有统计都得推倒重来;学号用 int 存,遇到0123这种带前导零的编号直接翻车,而且写回文件后原始信息永久丢失。这一章先把三个结构体定死,后面的增删改查、排序统计才有地方落。
2.1 字段类型怎么定:学号用 string,分数用 double
学号必须是std::string。真实教务系统里学号是定长字符串,可能有前导零,个别学校还会混入字母校验位。用 int 存,读进来0012变成12,写回 CSV 就是12,两次往返之后谁也说不清原始学号是什么。字符串比较按字典序,虽然和数值序不同,但学号本来就不参与算术运算,排序时按班级 + 学号排反而更符合习惯。
姓名同样用std::string,不要用char name[20]。UTF-8 下一个汉字占三字节,char[20]实际只能放六个汉字,遇到四字姓名或少数民族姓名会被strncpy悄悄截断,最后打印出来是半个字。源码文件、终端、数据文件三处的编码要统一成 UTF-8,这一条后面还会单独说。
成绩用double,理由有三个。总评常见算法是「平时 30% + 期末 70%」,加权后必然出现小数;用float累加几十门课再求平均,误差能到 0.01 分,恰好跨过排名分界线时就是事故;double配std::fixed << std::setprecision(2)输出最省心。总评本身不要存进数据文件,用成员函数现算,权重从硬编码改成常量,答辩时老师问「权重变了怎么办」你有话可说。
学分也用double,因为存在 0.5 学分的课,GPA 是学分加权,用 int 会把这半学分吞掉。性别、在读状态这类只有几个取值的东西,用char或enum class,别用字符串存「男/女」,统计和排序都别扭。
| 字段 | 推荐类型 | 理由 | 常见错误写法 |
|---|---|---|---|
| 学号 | std::string | 保留前导零、可含字母 | int id,读入即丢零 |
| 姓名 | std::string | UTF-8 变长,不会截断 | char name[20] |
| 成绩 | double | 加权后有小数,累加误差小 | float、int |
| 学分 | double | 存在 0.5 学分课程 | int credit |
| 性别 | char/enum class | 取值有限,便于校验 | std::string "男" |
| 总评 | 成员函数计算 | 权重集中一处,便于修改 | 存进文件,改权重后数据作废 |
2.2 存储层选型:纯文本、CSV、SQLite 三条路的边界
这套源码通常给的是文件存储版本,因为零依赖、双击就能跑。但关键词里带了「数据库」,说明题目大概率要求体现数据库能力。三条路的边界要提前想清楚,别写到一半推倒。
纯文本txt用空格或制表符分隔,实现最快,但字段里一旦出现空格(比如姓名带空格、课程名带括号)就解析错位。CSV 加了引号转义规则,可读性和健壮性都好一些,用 Excel 打开还能直接给老师看效果,缺点是没有索引,每次查询都要全表扫描。
SQLite 是单文件数据库,不需要装服务、不需要配账号,C++ 侧链接sqlite3.c就能用,答辩时能讲建表约束、能讲事务、能讲索引,含金量比纯文本高一个档次。数据量到几万条时,带索引的查询优势肉眼可见。
MySQL 这类需要独立服务的方案,在答辩机上是负担:装服务、建账号、配防火墙,任何一步出问题当场演示就废了。除非题目明确要求客户端/服务端架构,否则不建议。
| 维度 | 纯文本 | CSV | SQLite |
|---|---|---|---|
| 实现成本 | 最低 | 低 | 中 |
| 人工可读性 | 中 | 高(Excel 可开) | 低(需工具) |
| 查询能力 | 无,全表扫描 | 无,全表扫描 | 有索引、有 SQL |
| 一致性保障 | 靠代码自觉 | 靠代码自觉 | 事务、约束 |
| 答辩可讲点 | 文件流 | 序列化与转义 | 建表、事务、索引 |
| 适用数据量 | 千级以内 | 千级以内 | 十万级以内 |
我的做法是把存储抽象成一个Repository接口,CSV 和 SQLite 各实现一份,主程序只依赖接口。这样默认跑文件版,演示数据库时切一个编译开关,两份代码互不干扰。但要注意,这个重构放在功能全部跑通之后做,截止前一周千万别动架构。
2.3 把结构体写成可编译的头文件
先落一个干净的头文件,三个结构体加一个计算函数,后面所有模块都包含它。
// models.h #pragma once #include <string> #include <vector> // 成绩权重集中定义,改权重只改这两行 constexpr double kUsualWeight = 0.3; // 平时分占比 constexpr double kFinalWeight = 0.7; // 期末分占比 struct Student { std::string id; // 学号,主键,字符串保留前导零 std::string name; // 姓名,UTF-8 编码 std::string clazz; // 班级,如 "软工2101" char gender; // 'M' / 'F' int enrollYear;// 入学年份,用于区分同名同班 }; struct Course { std::string code; // 课程编号,主键 std::string name; // 课程名 double credit; // 学分,允许 0.5 }; struct Score { std::string studentId; // 外键 -> Student::id std::string courseCode;// 外键 -> Course::code std::string term; // 学期,如 "2023-2024-1" double usual; // 平时分 0~100 double final; // 期末分 0~100,缺考记 -1 单独处理 // 总评现算,不落盘,权重修改后历史数据自动跟随 double total() const { if (final < 0) return -1; // 缺考不参与加权 return usual * kUsualWeight + final * kFinalWeight; } };这段代码的关键点有三个。constexpr把权重提到编译期常量,比宏安全,也比在函数里写0.3好维护。total()定义在结构体内部,默认是 inline,多个 .cpp 同时包含这个头文件也不会产生重复符号;如果把函数体搬到 .cpp 里却忘了在头文件声明,链接期就会报undefined reference,这是后面避坑章的第一条。缺考用final < 0表示而不是 0 分,是因为 0 分是有效成绩,会拉低平均分,缺考通常不计入均分分母,这两种语义必须分开。
Score没有自己的自增 id,用(studentId, courseCode, term)三元组做联合主键,含义是「一个学生的一门课在一个学期只有一条成绩记录」。选课重复录入时靠这个约束拦住,比事后去重靠谱。
2.4 唯一性与引用完整性在内存里怎么保证
文件存储版本没有数据库的约束,重复学号、删了学生留下孤立成绩记录,全靠代码自己兜。最省事的做法是增删改入口处做校验,别指望调用方自觉。
#include <unordered_map> #include "models.h" // 插入学生:主键冲突直接返回 false,调用方负责提示 bool addStudent(std::vector<Student>& db, const Student& s) { for (const auto& x : db) { if (x.id == s.id) return false; // 学号已存在 } db.push_back(s); return true; } // 删除学生:级联清理其全部成绩,避免孤立记录 bool removeStudent(std::vector<Student>& db, std::vector<Score>& scores, const std::string& id) { bool found = false; for (auto it = db.begin(); it != db.end(); ) { if (it->id == id) { it = db.erase(it); found = true; } else ++it; } if (!found) return false; for (auto it = scores.begin(); it != scores.end(); ) { if (it->studentId == id) it = scores.erase(it); else ++it; } return true; }addStudent用的是线性扫描,数据量上千条时每次插入都要遍历一遍,实际体验会明显变卡。常见做法是再维护一个std::unordered_map<std::string, size_t>,键是学号、值是下标,查重变成 O(1)。但要注意:vector一旦扩容,下标仍然有效(扩容只是搬内存,逻辑下标不变),而删除元素会让被删位置之后的所有下标整体前移,此时要么重建索引,要么改用std::list加迭代器。我一般选前者,删除是低频操作,删完重建一次索引,代码简单不易错。
removeStudent里两个erase循环有细节:erase返回下一个有效迭代器,所以循环体里不要再写++it,否则会跳过一个元素。这个写法如果写成for (auto it = ...; it != end; ++it) { if (...) v.erase(it); },就是经典的迭代器失效,轻则漏删,重则崩溃。
提示:引用完整性不只有「删学生删成绩」这一条。删课程时同样要清理该课程的全部成绩记录,否则统计某学生 GPA 时会因为找不到课程学分而算出 0 分,排名直接错位。
3. 菜单到业务分发:main 里只留 20 行,剩下的全拆出去
能跑的系统和能改的系统差别就在这里。很多课设代码的 main 函数有四百行,switch里嵌if,if里再嵌循环,加一个功能要通读全文。这套源码的组织方式值得学:main 只负责循环读菜单、分发到函数,业务函数各自处理自己的输入输出和错误提示。
3.1 用状态机写菜单,别用层层嵌套的 if
菜单本质是一个循环状态机:打印选项、读入选择、执行动作、回到起点,只有选到退出才跳出。用while (true)配switch是最直白的写法,每个case只调一个函数,函数返回后自然回到菜单。
// main.cpp #include <iostream> #include "models.h" #include "service.h" // 业务函数声明 int main() { std::vector<Student> students; std::vector<Course> courses; std::vector<Score> scores; loadAll(students, courses, scores); // 启动时一次性载入 while (true) { std::cout << "\n1.录入学生 2.录入课程 3.录入成绩 " "4.查询 5.统计 6.保存 0.退出\n请选择: "; int cmd = readInt(0, 6); // 带范围校验的读入 switch (cmd) { case 0: saveAll(students, courses, scores); return 0; case 1: menuAddStudent(students); break; case 2: menuAddCourse(courses); break; case 3: menuAddScore(students, courses, scores); break; case 4: menuQuery(students, courses, scores); break; case 5: menuStat(students, courses, scores); break; case 6: saveAll(students, courses, scores); break; default: break; // readInt 已拦住越界,不会到这 } } }三个容器在主函数里创建、按引用传给各个函数,谁都不持有全局变量,调试时可以在任意断点看到完整数据。loadAll放在循环外,只在启动时读一次盘;saveAll在退出和手动保存时调用。有人喜欢每次修改都立刻落盘,好处是断电不丢数据,坏处是频繁 IO,5000 条数据下每次操作都要重写整个文件,体验很差。折中方案是退出时保存 + 每 N 次修改自动保存一次。
readInt是自己封装的输入函数,负责吃掉非法输入、重试直到合法。这一个函数能省掉后面一半的崩溃,值得单独写。
3.2 四个核心函数的签名与返回值约定
业务函数的签名要统一,否则调用方没法统一处理错误。我的约定是:查询类函数返回值或bool加输出参数,修改类函数返回bool表示成功,失败原因通过引用参数带出或者打印到std::cerr。
// service.h #pragma once #include <string> #include <vector> #include "models.h" // 输入:读一行并去掉首尾空白,空输入重试 std::string readLine(const std::string& prompt); // 读整数并限制在 [lo, hi],非法输入清空缓冲区后重试 int readInt(int lo, int hi); // 读分数,允许 -1 表示缺考,其余必须落在 [0, 100] double readScore(); // 录入类:成功 true,失败时 err 带出原因 bool addScore(std::vector<Score>& scores, const std::vector<Student>& students, const std::vector<Course>& courses, const std::string& sid, const std::string& ccode, const std::string& term, double usual, double final, std::string& err); // 统计类:返回该班不及格率,班级不存在时返回 -1 double failRateOfClass(const std::vector<Score>& scores, const std::vector<Student>& students, const std::string& clazz);addScore的参数看着长,但它把「校验」和「录入界面」彻底分开了:menuAddScore负责用readLine逐个问用户,addScore只做纯逻辑——学号是否存在、课程是否存在、同一学期是否已录过、分数是否越界。分开之后,单元测试可以直接调addScore传构造数据,不用模拟键盘输入。答辩时老师问「你怎么测的」,这就是答案。
failRateOfClass返回 -1 表示班级不存在,和「不及格率为 0」区分开。用返回 0 表示错误是最常见的坑,前端拿到 0 会当成「这个班全过了」,实际是查无此班。
3.3 cin 与 getline 混用:输入缓冲区那个老坑
控制台程序崩溃,十次里有三次是输入没读干净。std::cin >> x读到数字就停,把后面的换行符留在缓冲区;紧接着的getline一看缓冲区里有内容,立刻返回一个空串,用户还没输入就被跳过了。
#include <iostream> #include <limits> #include <string> int readInt(int lo, int hi) { int v; while (true) { if (std::cin >> v && v >= lo && v <= hi) { // 读成功后把本行剩余字符(含换行)全部丢弃 std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); return v; } if (std::cin.eof()) { std::cerr << "输入结束\n"; std::exit(1); } std::cin.clear(); // 清错误标志,否则后续读全部失败 std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); std::cout << "请输入 " << lo << "~" << hi << " 之间的整数: "; } }ignore的两个参数是「最多丢多少个字符」和「丢到哪个字符为止」,用numeric_limits<streamsize>::max()表示不设上限,丢到换行为止。clear()必须在ignore之前调,因为它清的是流的状态位(failbit),不是缓冲区;顺序颠倒的话缓冲区照样堵着,重试逻辑永远走不到。
readLine则统一用getline,读完整行后手动去掉\r(Windows 换行遗留)和首尾空格:
std::string readLine(const std::string& prompt) { std::string s; while (true) { std::cout << prompt; if (!std::getline(std::cin, s)) std::exit(1); while (!s.empty() && (s.back() == '\r' || s.back() == ' ')) s.pop_back(); size_t b = s.find_first_not_of(" \t"); if (b == std::string::npos) { s.clear(); } else s = s.substr(b); if (!s.empty()) return s; std::cout << "不能为空,请重新输入\n"; } }3.4 编译、链接与中文控制台
源码包如果是多文件组织,编译时要把所有.cpp一次列全,只编 main 会出现一堆undefined reference。
# Linux / macOS 单条命令编译,-std 指定标准,-Wall 打开常用警告 g++ -std=c++17 -Wall -Wextra -O2 \ main.cpp service.cpp storage_csv.cpp \ -o sms # Windows + MinGW 同理,输出 sms.exe g++ -std=c++17 -Wall main.cpp service.cpp storage_csv.cpp -o sms.exe用 CMake 的话,add_executable(sms main.cpp service.cpp storage_csv.cpp)把源文件列全,set(CMAKE_CXX_STANDARD 17)指定标准。带 SQLite 的版本再加sqlite3.c并链接-lpthread -ldl(Linux 下)。
中文乱码的根源是编码不一致:源文件是 UTF-8,Windows 控制台默认代码页是 GBK(936),两者对不上就出方块。要么在程序开头切代码页,要么把控制台改成 UTF-8。
#ifdef _WIN32 #include <windows.h> // 把控制台输出代码页切到 UTF-8,避免中文变成乱码 // 注意:源文件本身必须保存为 UTF-8(无 BOM 或有 BOM 视编译器而定) void initConsole() { SetConsoleOutputCP(CP_UTF8); SetConsoleCP(CP_UTF8); } #else void initConsole() {} #endif如果懒得改代码,命令行里先执行chcp 65001再运行程序也能救急,但每次开新窗口都得重来,写进initConsole更省事。
4. 落盘这一步:CSV 序列化与 SQLite 建表各写一遍
程序关掉数据就没了,是课设演示里最尴尬的场面。存储层的实现质量,直接决定这套成绩管理系统的源码是「玩具」还是「能交差」。这一章给两条路径:CSV 适合快速跑通、方便用 Excel 展示;SQLite 适合题目里明确要求数据库、或者你想在答辩上多讲几句。
4.1 CSV 读写:分隔符、引号与最后一行
CSV 看着简单,坑全在转义。字段里出现逗号(课程名常有「数据结构, 实验」)、引号、换行时,必须用双引号包起来,内部的双引号写成两个。
#include <fstream> #include <iomanip> #include <sstream> #include "models.h" // 转义:含逗号/引号/换行的字段整体加引号,内部引号翻倍 static std::string esc(const std::string& s) { if (s.find_first_of(",\"\n") == std::string::npos) return s; std::string out = "\""; for (char c : s) { if (c == '"') out += "\"\""; else out += c; } out += '"'; return out; } bool saveScores(const std::vector<Score>& scores, const std::string& path) { std::ofstream f(path, std::ios::trunc); // 整表重写,不用追加模式 if (!f) return false; f << "studentId,courseCode,term,usual,final\n"; // 表头固定 for (const auto& s : scores) { f << esc(s.studentId) << ',' << esc(s.courseCode) << ',' << esc(s.term) << ',' << std::fixed << std::setprecision(1) << s.usual << ',' << s.final << '\n'; } return f.good(); // 写完检查流状态,磁盘满时会置 failbit }std::ios::trunc是默认行为,但写出来更明确:整表覆盖,不做增量追加。增量追加的写法一旦中途崩溃,文件里就是半条记录,下次读入库直接错位。return f.good()这点很多人漏掉,磁盘写满或路径无权限时ofstream构造可能成功、写入失败,不看流状态就报告「保存成功」,是最难查的一类 bug。
读入侧的正确写法是「读一行、解析一行」,绝对不要用while (!f.eof()):
#include <fstream> #include <sstream> #include <string> #include <vector> // 按 CSV 规则切分一行,支持引号包裹与双引号转义 static std::vector<std::string> splitCsv(const std::string& line) { std::vector<std::string> out; std::string cur; bool inQuote = false; for (size_t i = 0; i < line.size(); ++i) { char c = line[i]; if (inQuote) { if (c == '"' && i + 1 < line.size() && line[i + 1] == '"') { cur += '"'; ++i; } else if (c == '"') inQuote = false; else cur += c; } else { if (c == '"') inQuote = true; else if (c == ',') { out.push_back(cur); cur.clear(); } else cur += c; } } out.push_back(cur); return out; } bool loadScores(const std::string& path, std::vector<Score>& out) { std::ifstream f(path); if (!f) return false; std::string line; std::getline(f, line); // 跳过表头 while (std::getline(f, line)) { // 以「读到行」为循环条件,不是 eof() if (line.empty()) continue; auto v = splitCsv(line); if (v.size() != 5) continue; // 列数不对直接跳过,别硬塞 Score s; s.studentId = v[0]; s.courseCode = v[1]; s.term = v[2]; s.usual = std::stod(v[3]); s.final = std::stod(v[4]); out.push_back(s); } return true; }while (!f.eof())的问题在于eof()只有在「尝试读并失败」之后才置位,最后一次成功读取后循环还会再执行一遍,于是最后一条记录被处理两次。文件末尾有换行时表现是「多一条重复」,没换行时表现是「多一条空记录」,两种现象都很迷惑。用getline的返回值当条件,读失败就退出,天然没有这个问题。
4.2 SQLite 版本:建表、预处理语句与事务
题目要求数据库时,SQLite 是性价比最高的选择。建表语句把约束写进去,主键、外键、非空、检查约束一次到位,代码里就能少写一半校验。
-- schema.sql PRAGMA foreign_keys = ON; -- SQLite 默认关闭外键,必须显式打开 CREATE TABLE IF NOT EXISTS student ( id TEXT PRIMARY KEY, -- 学号,字符串主键 name TEXT NOT NULL, clazz TEXT NOT NULL, gender TEXT CHECK (gender IN ('M','F')), enroll_year INTEGER ); CREATE TABLE IF NOT EXISTS course ( code TEXT PRIMARY KEY, name TEXT NOT NULL, credit REAL NOT NULL CHECK (credit > 0) ); CREATE TABLE IF NOT EXISTS score ( student_id TEXT NOT NULL REFERENCES student(id) ON DELETE CASCADE, course_code TEXT NOT NULL REFERENCES course(code) ON DELETE CASCADE, term TEXT NOT NULL, usual REAL NOT NULL CHECK (usual BETWEEN 0 AND 100), final REAL, -- NULL 表示缺考 PRIMARY KEY (student_id, course_code, term) ); CREATE INDEX IF NOT EXISTS idx_score_student ON score(student_id); CREATE INDEX IF NOT EXISTS idx_student_class ON student(clazz);ON DELETE CASCADE就是上一章手写的级联清理,交给数据库做,代码里删学生只需执行一条DELETE FROM student WHERE id=?。PRAGMA foreign_keys = ON这句必须放在连接打开之后立刻执行,SQLite 为了兼容旧版本默认不启用外键,忘了这句,REFERENCES写了也白写。
插入成绩用预处理语句绑定参数,别用字符串拼接 SQL——拼接遇到姓名里的单引号就语法错误,也是 SQL 注入的入口。
#include <sqlite3.h> #include <string> // 插入一条成绩,返回 true 表示成功 bool insertScore(sqlite3* db, const Score& s, std::string& err) { const char* sql = "INSERT INTO score(student_id,course_code,term,usual,final) " "VALUES(?,?,?,?,?);"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db); return false; } sqlite3_bind_text(stmt, 1, s.studentId.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, s.courseCode.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, s.term.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_double(stmt, 4, s.usual); if (s.final < 0) sqlite3_bind_null(stmt, 5); // 缺考绑定 NULL else sqlite3_bind_double(stmt, 5, s.final); int rc = sqlite3_step(stmt); bool ok = (rc == SQLITE_DONE); if (!ok) err = sqlite3_errmsg(db); sqlite3_finalize(stmt); // 无论成败都必须释放 return ok; }SQLITE_TRANSIENT告诉 SQLite 自己复制一份字符串,因为s.studentId的生命周期到函数返回就结束了,用SQLITE_STATIC会留下悬空指针。sqlite3_finalize必须调,漏掉的话每执行一次泄漏一份语句资源,批量导入几千条数据后内存占用飙升。
批量导入时把BEGIN/COMMIT包起来,几千条插入不套事务要跑几秒甚至几十秒,套上事务通常一秒内完成,这是最划算的性能优化。
4.3 统计口径对齐:GPA、加权平均、并列排名
统计功能最容易出的不是代码错误,是口径不一致。同一份数据,老师算出来的绩点和系统算出来的对不上,多半是缺考处理和并列排名的规则不同。
| 指标 | 计算口径 | 常见错误 |
|---|---|---|
| 加权平均分 | Σ(总评 × 学分) / Σ学分 | 直接算术平均,忽略学分 |
| GPA(五分制) | 总评 ≥90 记 4.0,按分数段线性映射后学分加权 | 每门课固定 4 分或 3 分 |
| 班级不及格率 | 总评 <60 的记录数 / 参与考试记录数 | 分母把缺考算进去 |
| 排名 | 按加权平均降序,同分同名次,下一名跳号 | 同分不同名次,或排序后学号错位 |
排名的坑在排序。如果只对分数数组排序,再按位置去原数组里找学生,遇到并列分数就会张冠李戴。正确做法是把整条记录一起排序,比较函数只比分数,同分时再按学号稳定排序。
#include <algorithm> #include <vector> struct RankRow { std::string sid; double avg; }; // 按均分降序;同分时按学号升序,保证结果可复现 void sortAndRank(std::vector<RankRow>& rows) { std::stable_sort(rows.begin(), rows.end(), [](const RankRow& a, const RankRow& b) { if (a.avg != b.avg) return a.avg > b.avg; return a.sid < b.sid; // 同分时按学号,避免顺序随机 }); } // 生成名次:同分同名次,下一名跳号(1,2,2,4) std::vector<int> makeRank(const std::vector<RankRow>& rows) { std::vector<int> r(rows.size()); for (size_t i = 0; i < rows.size(); ++i) { if (i > 0 && rows[i].avg == rows[i - 1].avg) r[i] = r[i - 1]; else r[i] = static_cast<int>(i) + 1; } return r; }用stable_sort而不是sort,是为了在比较函数只涉及部分字段时,仍能保证等价元素的相对顺序稳定,结果可以逐次复现。答辩演示时两次运行排名不一样,是很难解释的。
4.4 删学生的时候,成绩记录怎么办
前面用内存版手写级联,SQLite 版交给ON DELETE CASCADE,但 CSV 版没有数据库兜底,必须显式处理。我的策略是「逻辑删除 + 定期清理」:给Student加一个status字段,标记为退学或转专业时置 0,查询时默认过滤,物理清理放到单独的维护菜单里。这样做的好处是历史成绩可追溯,坏处是每处查询都要记得加过滤条件,容易漏。
更严格的做法是删除前强制检查:如果该学生有成绩记录,先弹确认「将同时删除 N 条成绩记录,是否继续」,用户确认后在一个函数里完成两件事的删除,中间不插任何 IO。CSV 版没法做到真正的原子性,至少要做到「先写临时文件、全部成功后再重命名覆盖」,避免写一半崩溃导致原文件损坏。
5. 避坑与排查:这套源码跑不起来时,先看这五条
课设代码在别人机器上跑不起来,九成集中在五类问题上。下面每条都按「现象 → 原因 → 解决」写,照着对一遍,基本能定位到具体位置。
5.1 编译通过但链接报 undefined reference
现象:g++ main.cpp -o sms编译无报错,链接阶段抛出一串undefined reference to 'addStudent(...)'。
原因:业务函数的定义在service.cpp里,编译命令只列了main.cpp,链接器找不到目标文件。另一种情况是把函数定义写在了头文件里,却没加inline,多个.cpp包含同一头文件时产生重复定义,报错则变成multiple definition。
解决:编译命令把全部.cpp显式列出,或者用g++ *.cpp -o sms(限当前目录,子目录要单独加)。头文件里只放声明,定义放.cpp;确实要在头文件里写函数体,就加inline或者放进匿名命名空间。用 CMake 的话检查add_executable里的文件列表是否漏了新增的源文件。
5.2 中文全变方块或问号
现象:菜单里的中文正常,从文件读出来的姓名是乱码;或者反过来,源码里的提示语正常,运行输出花屏。
原因:三个环节的编码不一致——源文件保存格式(GBK 还是 UTF-8)、编译器执行字符集、终端代码页。Windows 下用记事本存成 ANSI,源码里的中文就是 GBK,.cpp里"性别"那几字节按 UTF-8 解释必然错。
解决:全部统一 UTF-8。编辑器里把文件「另存为 UTF-8」,g++可以加-finput-charset=UTF-8 -fexec-charset=UTF-8明确指定,程序启动时调SetConsoleOutputCP(CP_UTF8)。已经写坏的 CSV 用文本工具转码一次再读,别指望程序兼容两种编码。
5.3 从文件读出来的记录比实际多一条
现象:CSV 里明明 50 条成绩,读进来是 51 条,最后一条学号为空或者和倒数第二条重复。
原因:循环条件写成了while (!f.eof())或者while (f.good())。eof()在「读到文件末尾之后」才置位,最后一次成功读取后循环体还会再跑一轮,此时getline已经失败,变量保持上一轮的值,于是多出一条重复记录;文件末尾没换行时则多一条空记录。
解决:改成while (std::getline(f, line)),或者while (f >> a >> b),用读取操作的返回值当条件。同时在循环体里加一行if (line.empty()) continue;拦住空行,双保险。
5.4 录入成功但重启程序数据全没了
现象:界面上一条条录进去,统计查询都正常,关掉程序再开,空空如也。
原因:改的只是内存里的vector,忘了调saveAll;或者调了保存,但ofstream打开失败没检查,路径写成了相对路径,在 IDE 里运行时工作目录和 exe 所在目录不一致,文件写到了别处。
解决:退出分支里显式调用保存并打印保存路径;ofstream构造后立刻if (!f) { std::cerr << "无法写入: " << path; }。想彻底避免工作目录问题,就用绝对路径,或者在程序启动时打印std::filesystem::current_path()。我一般会在保存成功后输出「已保存 N 条记录到 xxx」,看着啰嗦,但能省掉半小时的排查。
5.5 排名跟手工算的对不上
现象:某学生总评 87.5,系统算出 GPA 3.2,按学校规则应该是 3.7;或者两个同分学生,一个第 3 名一个第 5 名。
原因:一是 GPA 映射表没按学校文件来,用了一套通用五分制;二是同分处理逻辑写成了「按排序下标直接当名次」,同分不同名;三是缺考记录参与了平均分计算,把分母撑大了。
解决:把学校的换算规则整理成一张表,写成函数单独测试,边界分数(89.5、90、60、59.5)逐条验证。排名用「同分同名次、下一名跳号」的规则,比较时用total()的返回值而不是显示用的两位小数,避免 87.496 和 87.504 在显示时都是 87.50 却被判成不同分。缺考记录在统计时按final < 0排除,分母只算实考。
6. 答辩前的一次压力验证:拿 5000 条数据把统计跑一遍
功能点全点一遍不算验证,那只证明「点了不崩」。真正能救场的是用大批量数据跑一遍统计,看排名、均分、不及格率是否和独立算出来的结果一致。我习惯在交之前用 Python 造一份数据灌进去,再写一段对拍脚本比对输出。
# gen_scores.py:生成 5000 条成绩,覆盖 0 分、100 分、缺考、同分 import csv, random random.seed(20240501) # 固定种子,保证每次生成的数据一致,便于复现 classes = ["软工2101", "软工2102", "计科2101"] courses = [("CS101", "程序设计", 3.0), ("CS102", "数据结构", 4.0), ("CS103", "数据库原理", 3.5), ("CS104", "操作系统", 3.0)] with open("students.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["id", "name", "clazz", "gender", "enrollYear"]) for i in range(1, 501): w.writerow([f"2021{i:04d}", f"学生{i}", random.choice(classes), random.choice("MF"), 2021]) with open("scores.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["studentId", "courseCode", "term", "usual", "final"]) for i in range(1, 501): for code, _, _ in courses: r = random.random() if r < 0.02: # 2% 缺考,final 记 -1 usual, final = random.randint(60, 90), -1 elif r < 0.05: # 3% 边界分 usual = final = random.choice([0, 59.5, 60, 89.5, 90, 100]) else: usual, final = random.randint(50, 100), random.randint(40, 100) w.writerow([f"2021{i:04d}", code, "2023-2024-1", usual, final])500 个学生 × 4 门课 = 2000 条记录,再乘几个学期就够 5000。种子固定住,出问题时可以精确复现到某一条数据。生成完把两个 CSV 放进程序目录,跑一遍导入,重点看三处:导入耗时是否在可接受范围(CSV 版应该秒级,超过十秒说明每次插入都重写了整个文件);同分学生的名次是否并列;缺考记录有没有被算进均分分母。
对拍这一步别省。用 Excel 或者几行 Python 独立算一遍加权均分和班级不及格率,和程序输出逐项比。我吃过一次亏:程序里 GPA 映射用的是>=90对应 4.0,学校文件写的是>=90对应 3.7(五分制里的优秀档),两个数字差 0.3,排名前二十全乱,答辩前两小时才发现。从那以后我每次交课设前都强制走一遍这个流程——固定种子造数、独立脚本对拍、边界分值逐条验。希望帮到你。
本文还有配套的精品资源,点击获取