校园身份预约系统
# 第三章:C++ 校园身份预约系统
本章是综合案例的第三关·继承多态实战——从 02.银行账户 (opens new window) 的"单业务 + 单 CSV"升级到三身份多模块 + 三文件多表协同的类真实系统。本案例会做四件升级:
1.三态身份抽象:User 基类 + Student / Teacher / Admin 三种身份子类——比银行账户的"账户类型"层次更深,每种身份有自己的菜单、权限、操作。
2.STL 全家桶:std::map<int, Computer> + std::multimap<int, Speech> + std::set<int> + std::sort + lambda——一次案例练遍卷一第 16 章核心容器和算法。
3.多模块文件矩阵:users.csv + rooms.csv + reservations.csv 三个文件协同读写,模拟真实系统的"多张表"模型。
4.lambda 排序与统计:用 lambda 写比较器、过滤器、聚合器,告别"为了一次排序写一个比较函数"。
学习方式:本案例按"身份分人 → 阶段拆解 → 写代码 → 跑编译 → 看输出 → 避陷阱"六步法推进。**总共7大阶段【§02 是阶段①、§03 是阶段②、§04 是阶段③、§05 + §06 同属阶段④后拆出阶段⑤、§07 是阶段⑥、§08 是阶段⑦、§09 是阶段⑧】、约 12 小时,建议分 3-4 天完成(身份与实体 · 三身份业务 · 持久化)。每个阶段都遵循"写一点 → 编译 → 看输出 → 再写下一点"的节奏。
# 渐进学习节奏
先读这段,再开始敲代码!本案例严格按照真实工程师的开发节奏推进,不会一上来甘你 1200 行代码让你直接抄。我们的节奏是这样的:
阶段 ① 登录骨架(§02) · 30 min
└ Step 1.1: main + login 返回 nullptr跟通循环
└ Step 1.2: 输入 0 能退出
阶段 ② User 抽象三态(§03) · 60 min
└ Step 2.1: User.h 抽象基类 + 虚析构
└ Step 2.2: 先写 Student 一个子类跑通多态
└ Step 2.3: 镜像复制 Teacher / Admin
阶段 ③ 实体三件套(§04) · 60 min
└ Step 3.1: Computer(最简单)
└ Step 3.2: Speech(加 round 概念)
└ Step 3.3: Reservation(双外键 + enum class)
阶段 ④ CampusSystem 最小骨架(§05) · 60 min 【高阶决策】
└ Step 4.1: 只有 1 个 users map + 2 个方法
└ Step 4.2: 接通 main→sys.login→多态分发
阶段 ⑤ Student 业务(§06) · 90 min 【高峰:故意制造 bug → 修复】
└ Step 5.1: 业务驱动追加 rooms (map) + listRooms
└ Step 5.2: reserveRoom → 故意只追 vector → bug 暴露 → 追加 set 修复
└ Step 5.3: cancelReservation 只追方法不追字段 + lambda find_if
└ Step 5.4: signupSpeech 追加 multimap + 选型反思
阶段 ⑥ Teacher 业务(§07) · 90 min
└ Step 6.1: 审核待审预约 + count_if
└ Step 6.2: 演讲评分 + multimap.equal_range
└ Step 6.3: 排名 + sort + lambda 比较器高光
阶段 ⑦ Admin 业务(§08) · 60 min
└ Step 7.1: addRoom
└ Step 7.2: addUser 复用 §5.2 已有方法
└ Step 7.3: statistics + accumulate 三连
阶段 ⑧ 多文件持久化(§09) · 90 min
└ Step 8.1: users.csv 先跑通 1 个文件
└ Step 8.2: 追加 rooms.csv
└ Step 8.3: 追加 reservations.csv → 故意加载顺序错 → 修复
└ Step 8.4: 最终 main loadAll/saveAll 闭环
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就编译运行一次(看到 标志立刻动手)
- 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)
⚠️ 本案例独有的"故意造 bug → 修复"升级:阶段④ Step 4.3 会让你先写一个看起来没问题的 reserveRoom,运行一次后繁荣映现东油在殌责。你会亲眼看到"vector 和 set 不同步"导致的错乱输出——这是 STL 多容器协作场景的经典坑,踩过一次才能记得住。
✅ 每个阶段的结构(你在正文里会反复看到):
┌─ 🎯 阶段目标 ──────────────┐ ← 阶段开头:明确做什么/不做什么 │ 完成什么、不做什么、验收标准 │ └──────────────────────────────┘ Step X.1:先写最小可编译版(5-20 行) Step X.2:编译 → 运行 → 看到输出 ✅ Step X.3:再加一个小功能(10-30 行) Step X.4:编译 → 运行 → 看到新输出 ✅ ... ┌─ 🧪 运行验证 ─────────────┐ ← 阶段结尾:完整命令 + 预期输出 + 排错 │ 编译命令 / 预期输出 / 排错指南 │ └──────────────────────────┘ ┌─ 📌 阶段小结 ─────────────┐ ← 阶段结尾:今天学到了什么 │ ✅ 已掌握 / ⏸ 暂未涉及 │ └────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 案例元信息
| 项目 | 说明 |
|---|---|
| 难度 | ★★★★☆ |
| 预估时长 | 12 小时(建议分 3-4 天,每天 3-4 小时) |
| 前置章节 | 卷一第 9 章(类与对象)、第 10 章(继承多态)、第 13 章(IO 与文件)、第 16 章(STL)、第 7 章(lambda) |
| 覆盖知识点 | User 抽象身份 + 三态派生 / std::map、std::multimap、std::set、std::vector / lambda 比较器、std::sort、std::find_if、std::count_if、std::accumulate / std::function 回调 / 三文件协同序列化 / 结构化绑定 / 范围 for |
| 设计亮点 | 身份驱动的菜单分发 + STL 容器选型决策 + 跨文件外键关联 |
| ⚠ 已知局限 | 仍用裸 Account* 思路(std::shared_ptr<User>)作为过渡——案例 04 会全面 RAII 化 |
| 最终产物 | 可执行文件 campus_system + 三个数据文件 |
| 代码规模 | 约 1200 行 / 14 个文件 |
# 项目结构
campus_system/
├── main.cpp # 入口:按身份登录后分发到不同菜单
├── User.h / User.cpp # 抽象身份基类
├── Student.h / Student.cpp # 学生:可预约机房 + 报名演讲
├── Teacher.h / Teacher.cpp # 教师:可审核预约 + 评分演讲
├── Admin.h / Admin.cpp # 管理员:管理用户和机房
├── Computer.h / Computer.cpp # 实体:机房(编号 + 配置 + 状态)
├── Speech.h / Speech.cpp # 实体:演讲(学生 + 主题 + 评分)
├── Reservation.h / Reservation.cpp # 实体:预约(外键关联 user + computer)
├── CampusSystem.h / CampusSystem.cpp # 总管理器(含三个 STL 容器 + 业务逻辑)
├── FileStore.h / FileStore.cpp # 持久化(多文件矩阵)
├── users.csv # 运行时生成
├── rooms.csv
└── reservations.csv
2
3
4
5
6
7
8
9
10
11
12
13
14
# 一条命令编译运行
cd campus_system
g++ -std=c++17 *.cpp -o campus_system
./campus_system
2
3
# 目录快速导航
点击以下条目即可跳转到对应节。【🔑 重点节】推荐优先阅读,⭐ 表示本案高光段落。
- 01.项目需求和功能
- 02.菜单与登录框架 【阶段①骨架】
- 03.User 抽象身份基类 【阶段②抽象】
- 04.实体类设计 【阶段③数据模型】
- 05.CampusSystem 总管理器 【阶段④骨架·渐进生长⭐】
- 06.Student 业务展开 【阶段⑤高峰·故意造bug⭐】
- 07.Teacher 业务 【阶段⑥STL 算法⭐】
- 08.Admin 业务 【阶段⑦三角色闭环】
- 09.多文件持久化 FileStore 【阶段⑧最终阶段】
- 10.项目总结分析
- 11.项目技术思考
- 12.衔接与延伸
# 01.项目需求和功能
# 1.1 需求介绍
校园里有三类人需要协同:学生预约机房做实验、教师审核预约并评分演讲、管理员维护系统数据。本案例用一个控制台程序模拟这三类身份的全部交互,让读者一次掌握 OOP 多态 + STL 全家桶 + 多文件持久化的协同。
# 1.2 三类用户的功能矩阵
| 功能 | Student | Teacher | Admin |
|---|---|---|---|
| 登录 | ✅ | ✅ | ✅ |
| 浏览机房 | ✅ | ✅ | ✅ |
| 预约机房 | ✅ | ❌ | ❌ |
| 取消自己预约 | ✅ | ❌ | ❌ |
| 报名演讲 | ✅ | ❌ | ❌ |
| 审核预约 | ❌ | ✅ | ✅ |
| 演讲评分 | ❌ | ✅ | ❌ |
| 添加用户 | ❌ | ❌ | ✅ |
| 添加机房 | ❌ | ❌ | ✅ |
| 数据统计 | ❌ | ✅ | ✅ |
这种"按身份分发功能"的设计在工业界叫 RBAC(基于角色的访问控制)。本案例只做最简单的"硬编码 RBAC",企业级 RBAC 见卷四。
# 1.3 身份驱动容器分工
身份驱动:登录后 main 拿到一个 std::shared_ptr<User>,调 user->mainMenu() 进入该身份独有的菜单——这就是多态分发。
容器分工:
| 实体 | 容器 | 选型理由 |
|---|---|---|
| 用户 | std::map<string, shared_ptr<User>> | 用账号作主键,O(log n) 查找 |
| 机房 | std::map<int, Computer> | 用编号作主键,按编号有序遍历 |
| 演讲报名 | std::multimap<int, Speech> | 一个轮次(key = round 1/2)有多个演讲 |
| 已被预约的机房编号 | std::set<int> | 快速判断"这个机房是否被预约",O(log n) |
| 预约记录 | std::vector<Reservation> | 顺序追加 + 全量遍历审核 |
学完这个案例你会懂:选 STL 容器不是"我习惯用 vector",而是要根据访问模式 + 顺序需求 + 是否允许重复三个维度选择。
# 1.4 涉及知识点
| 卷一章节 | 知识点 | 在本案例中的位置 |
|---|---|---|
| 第 7 章 函数 | lambda 表达式作比较器 / 闭包捕获 | 07、08 节 |
| 第 9-10 章 类 | 抽象基类 / 三态派生 / 虚析构 | 03 节 |
| 第 13 章 IO | 三文件协同 / 字符串流 | 09 节 |
| 第 16 章 STL | map / multimap / set / vector / sort / find_if / count_if / accumulate | 05、06、07、08 节 |
| 第 18 章 现代特性 | std::function 回调 / 结构化绑定 / 范围 for + auto | 全章 |
# 02.菜单与登录框架
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:跑通"显示登录菜单 → 输入身份和账号 → 输入 0 退出" │
│ 不做什么:不写 User 派生类、不接文件、不做密码校验 │
│ 验收标准:能循环显示登录界面,输入 0 能正常退出程序 │
│ 预计耗时:30 分钟 │
│ 关键思路:先打通"输入循环"管道,让程序能跑、能退 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
# 2.1 创建项目空文件
我们先把项目目录和所有空文件一次性创建好——但只有 main.cpp 这一个有内容,其他全是空的占位文件,让你能一眼看到后面要填多少东西:
mkdir campus_system && cd campus_system
touch main.cpp \
User.h User.cpp \
Student.h Student.cpp \
Teacher.h Teacher.cpp \
Admin.h Admin.cpp \
Computer.h Computer.cpp \
Speech.h Speech.cpp \
Reservation.h Reservation.cpp \
CampusSystem.h CampusSystem.cpp \
FileStore.h FileStore.cpp
2
3
4
5
6
7
8
9
10
11
📌 新手提示:14 个空文件看起来吓人,但不是一次性写完的——我们会按 7 个阶段、每次只填 1-2 个文件的节奏推进。每填完一组就编译验证一次。
# 2.2 登录骨架做什么
在动手之前先停 30 秒:
❓ 登录界面要让用户输入什么? 答:身份类型(学生/教师/管理员)+ 账号 + 密码——三件事。
❓ 登录成功后做什么? 答:返回一个能调 mainMenu() 的对象——但这个对象的具体类(Student/Teacher/Admin)要等阶段 ② 才有。所以现在先返回 nullptr 占位。
❓ 现在第一步要先做哪一个? 答:让循环跑起来 + 能正常退出——这是后续所有功能的根。
🔑 教学要点:和银行案例 §6.1 一样——先做最底层的依赖项。"循环 + 退出"是所有 UI 程序的根,先打通这个管道。
# 2.3 让程序能跑能退
main.cpp(阶段 ① 骨架版):
#include <iostream>
#include <string>
#include <memory>
using namespace std;
// 占位声明(阶段 ② 才会真正实现)
// User 类是一个抽象基类,用于定义用户的基本接口。1.支持多态性,允许派生类实现不同的行为。2.拓展性,确保所有用户类型都具有一致的行为。
class User {
public:
// 虚析构函数,使用 = default 表示使用编译器生成的默认实现。
// 确保在通过基类指针删除派生类对象时,能够正确调用派生类的析构函数。
virtual ~User() = default;
// 纯虚函数,表示 User 类是一个抽象基类,不能直接实例化。派生类必须实现 mainMenu 方法。
virtual void mainMenu() = 0;
virtual char roleTag() const = 0;
};
shared_ptr<User> login() {
cout << "\n=== 校园系统登录 ===\n";
cout << "1. 学生 2. 教师 3. 管理员 0. 退出\n";
cout << "选择身份: ";
int role; cin >> role;
if (role == 0) return nullptr;
string id, pwd;
cout << "账号: "; cin >> id;
cout << "密码: "; cin >> pwd;
// TODO(阶段 ⑦): 接 FileStore 校验账号密码
// TODO(阶段 ②): 根据 role 返回 Student/Teacher/Admin 对象
cout << "[Login] 占位:模拟登录成功 - role=" << role << " id=" << id << "\n";
return nullptr; // 阶段 ① 暂时返回空,循环会自然结束
}
int main() {
while (true) {
auto user = login();
if (!user) {
cout << "再见!\n";
return 0;
}
user->mainMenu();
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
🧪 立刻编译运行(阶段 ① 验收)
g++ -std=c++17 main.cpp -o campus_system
./campus_system
2
操作:选 1 学生 → 输入账号 S001 密码 123 → 看到占位日志 → 再次出现登录界面 → 选 0 退出
预期输出:
=== 校园系统登录 ===
1. 学生 2. 教师 3. 管理员 0. 退出
选择身份: 1
账号: S001
密码: 123
[Login] 占位:模拟登录成功 - role=1 id=S001
再见!
2
3
4
5
6
7
等等!为什么"再见"立刻出现了?因为 login 现在返回 nullptr——main 里的 if (!user) 直接退出。
✅ 这正是阶段 ① 的预期行为:管道通了,但还没接子类对象。阶段 ② 把 User 真正实现后,这里就会进入子类菜单循环。
排错指南:
| 现象 | 原因 |
|---|---|
编译报 'cout' was not declared | 漏写 #include <iostream> 或 using namespace std; |
| 中文乱码 | macOS/Linux 终端默认 UTF-8 一般没问题,Windows 需要 chcp 65001 |
| 输入数字后程序卡住 | cin 状态被破坏,按 Ctrl+C 退出 |
┌─ 📌 阶段 ① 小结 ────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • main → login → nullptr → 自然退出 的循环管道 │
│ • 用 forward declaration 占位 User 让程序先编译过 │
│ • cin/cout 处理 int + string 双输入 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • User 抽象基类(阶段 ②) │
│ • Student/Teacher/Admin 派生类(阶段 ②) │
│ • 真正的多态分发(阶段 ② 末尾验收) │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage1: login skeleton" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
# 03.User 抽象身份基类
┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:抽象出 User 基类 + 三个派生类的最小骨架 │
│ 不做什么:不写业务逻辑(mainMenu 内只放占位 cout) │
│ 验收标准:login 能 make_shared 出三种身份 → 调用 mainMenu 不崩 │
│ 预计耗时:60 分钟 │
│ 关键思路:先写 User.h 让程序能编译过 → 再加一个子类编译跑通 │
│ → 再加另一个子类——绝不一口气写三个 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 3.1 灵魂三问:为什么要抽象基类?
❓ 三种身份能不能各写各的,不要 User 基类?
来看反例:
class Student { string id, name, pwd; void studentMenu(); };
class Teacher { string id, name, pwd; void teacherMenu(); };
class Admin { string id, name, pwd; void adminMenu(); };
// main 里的 login 必须返回什么类型?
??? login() {
if (role == 1) return Student(...); // 三种返回类型
if (role == 2) return Teacher(...); // C++ 不允许函数返回多种类型
}
2
3
4
5
6
7
8
9
问题暴露:
id/name/pwd三个字段在三个类里重复 3 遍(违反 DRY)login()函数没法写返回类型——C++ 必须有统一的返回类型main里要写if (role==1) s.studentMenu(); else if (role==2) ...——每加一种身份就要改 main(违反开闭原则)
✅ 正确做法:用 User 抽象基类 + 三态派生 + mainMenu() 纯虚函数。login 返回 shared_ptr<User>,main 一句 user->mainMenu() 通吃所有身份。
# 3.2 User.h 头文件
User.h(这是阶段 ② 的第一份代码):
#pragma once
#include <string>
#include <iostream>
class User {
protected:
std::string userId;
std::string userName;
std::string password;
public:
User(const std::string& id, const std::string& name, const std::string& pwd)
: userId(id), userName(name), password(pwd) {}
virtual ~User() = default;
// 纯虚:每种身份有自己的菜单
virtual void mainMenu() = 0;
// 纯虚:返回身份标签 'S'/'T'/'A',用于 CSV 反序列化(阶段 ⑦ 用)
virtual char roleTag() const = 0;
// 校验密码
bool verify(const std::string& pwd) const { return password == pwd; }
const std::string& getId() const { return userId; }
const std::string& getName() const { return userName; }
// 序列化为 CSV 行(阶段 ⑦ 才会真正用上)
virtual std::string toCsv() const {
return std::string(1, roleTag()) + "," + userId + "," + userName + "," + password;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
设计要点解析:
| 要点 | 写法 | 作用 |
|---|---|---|
#pragma once | 头文件保护 | 比 #ifndef/#define 简洁 |
protected: | 派生类可见、外部不可见 | 让 Student 能直接访问 userId 等字段 |
virtual ~User() = default; | 虚析构 | 让 delete User* 能正确析构子类(铁律) |
= 0 | 纯虚函数 | 强制子类实现,让 User 变成抽象类 |
roleTag() const = 0 | 纯虚 + const | 子类必须返回身份标签字符 |
📚 重申虚析构铁律(银行案例 §3.4 详细讲过):只要类有 virtual 函数,析构必须 virtual——否则 shared_ptr<User> 释放时会泄漏子类资源。
# 3.3 先写一个 Student
🔑 教学要点:很多教程会一次给你三份子类代码让你抄。我们不这么做——先写最简单的一个,编译跑通,再写下一个。
📁 Student.h(先放最小骨架,业务方法留到阶段 ④):
#pragma once
#include "User.h"
class CampusSystem; // 前向声明,避免循环 include
class Student : public User {
private:
CampusSystem* sys; // 反向引用,用来调用业务方法(阶段 ④ 才会用到)
public:
Student(const std::string& id, const std::string& name, const std::string& pwd,
CampusSystem* s = nullptr)
: User(id, name, pwd), sys(s) {}
void mainMenu() override;
char roleTag() const override { return 'S'; }
void setSystem(CampusSystem* s) { sys = s; }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
⚠️ 设计陷阱:Student 持有 CampusSystem* 不是疏忽——User 和 CampusSystem 互相引用,如果都用 shared_ptr 会形成循环引用导致内存永不释放。这就是用裸指针表达**"非拥有的反向引用"**的标准模式。卷三会用 weak_ptr 真正解决这类问题。
📁 Student.cpp(第一版只放占位 mainMenu):
#include "Student.h"
#include <iostream>
using namespace std;
void Student::mainMenu() {
cout << "\n--- 学生 " << userName << " 已登录(占位菜单,阶段 ④ 实现)---\n";
}
2
3
4
5
6
7
📁 修改 main.cpp(用真的 Student 替换占位 User):
#include "Student.h" // ← 加这一行
shared_ptr<User> login() {
// ... 输入身份、账号、密码 ...
switch (role) {
case 1: return make_shared<Student>(id, "学生" + id, pwd);
// case 2: return make_shared<Teacher>(...); ← 下一步再加
// case 3: return make_shared<Admin>(...);
}
return nullptr;
}
2
3
4
5
6
7
8
9
10
11
⚠️ 同时移除 main.cpp 顶部的 class User { ... }; 占位声明——现在 User.h 提供了真正的定义。
立刻编译运行(验证 Student 能多态分发)
g++ -std=c++17 main.cpp Student.cpp -o campus_system
./campus_system
2
操作:选 1 → 输入 S001 密码 123
预期输出:
=== 校园系统登录 ===
1. 学生 2. 教师 3. 管理员 0. 退出
选择身份: 1
账号: S001
密码: 123
--- 学生 学生S001 已登录(占位菜单,阶段 ④ 实现)---
=== 校园系统登录 === ← 注意:因为 mainMenu 不带循环,立刻回到登录界面
...
2
3
4
5
6
7
8
9
10
✅ 看到 "学生 X 已登录" = 多态分发链路通:main → user->mainMenu() → Student::mainMenu() 这条路打通了。
❌ 如果链接报错 undefined reference to Student::mainMenu:99% 是编译命令漏写了 Student.cpp。
🔑 教学要点:阶段 ② 的精髓不是写完三个子类,而是先验证一个子类的多态链路。Student 跑通了,Teacher 和 Admin 就是"复制粘贴 + 改类名 + 改 roleTag"。
# 3.4 再写 Teacher / Admin
现在 Student 这条路通了,Teacher 和 Admin 就是镜像复制:
📁 Teacher.h / Teacher.cpp:把 Student 全部替换为 Teacher,roleTag() 返回 'T',cpp 里 mainMenu 占位日志改成 "--- 教师 X ---"。
// Teacher 头文件
class Teacher : public User {
private:
CampusSystem* sys; // 反向引用,用来调用业务方法(阶段 ④ 才会用到)
public:
Teacher(const std::string& id, const std::string& name, const std::string& pwd,
CampusSystem* t = nullptr)
: User(id, name, pwd), sys(t) {}
void mainMenu() override;
char roleTag() const override { return 'T'; }
void setSystem(CampusSystem* t) { sys = t; }
};
// Teacher 实现文件
void Teacher::mainMenu() {
std::cout << "\n--- 老师 " << userName << " 已登录(占位菜单,阶段 ④ 实现)---\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
📁 Admin.h / Admin.cpp:同上,类名改 Admin,roleTag() 返回 'A'。这里跟上面代码几乎相似,就不一一展示了。
然后修改 login() 把另外两个 case 也接上:
switch (role) {
case 1: return make_shared<Student>(id, "学生" + id, pwd);
case 2: return make_shared<Teacher>(id, "教师" + id, pwd);
case 3: return make_shared<Admin>(id, "管理员" + id, pwd);
}
2
3
4
5
🧪 第二次编译运行(验证三态多态)
g++ -std=c++17 main.cpp Student.cpp Teacher.cpp Admin.cpp -o campus_system
./campus_system
2
依次测试:选 1 学生 → 选 2 教师 → 选 3 管理员 → 选 0 退出。预期输出:
=== 校园系统登录 ===
1. 学生 2. 教师 3. 管理员 0. 退出
选择身份: 1
账号: S0001
密码: 1
--- 学生 学生S0001 已登录(占位菜单,阶段 ④ 实现)---
=== 校园系统登录 ===
1. 学生 2. 教师 3. 管理员 0. 退出
选择身份: 2
账号: T001
密码: 1
--- 老师 教师T001 已登录(占位菜单,阶段 ⑤ 实现)---
=== 校园系统登录 ===
1. 学生 2. 教师 3. 管理员 0. 退出
选择身份: 3
账号: A0001
密码: 1
--- 管理员 管理员A0001 已登录(占位菜单,阶段 ⑥ 实现)---
=== 校园系统登录 ===
1. 学生 2. 教师 3. 管理员 0. 退出
选择身份: 0
再见!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
多态分发完整跑通:🎉 同一行 user->mainMenu(),根据实际对象类型自动调用三种不同的菜单。这就是继承多态的灵魂——main 函数不需要 if (role==1) studentMenu(); else if ...,类型分发由 vtable 自动完成。
┌─ 📌 阶段 ② 小结 ────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • User 抽象基类 + 纯虚 mainMenu + 虚析构 │
│ • 先写一个 Student 跑通多态链路 │
│ • 再镜像复制 Teacher/Admin │
│ • login 用 switch 选择子类,main 调用基类接口 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • Computer/Speech/Reservation 实体(阶段 ③) │
│ • CampusSystem 业务总管理器(阶段 ④起) │
│ • 三个 mainMenu 的真实业务逻辑(阶段 ④⑤⑥) │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage2: user trinity" │
│ 💡 本阶段最大领悟: │
│ "三种身份的差异通过 vtable 分发,main 函数零 if-else" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.5 roleTag 为何用 char
❓ 可能的疑问:roleTag() 返回 char 而不是 enum class Role,是不是不够现代?
权衡:
| 维度 | char 'S'/'T'/'A' | enum class Role |
|---|---|---|
| CSV 序列化 | 直接写入 1 字节 | 需要手动转 string |
| 反序列化 | line[0] 即可识别 | 需要 string→enum 映射表 |
| 类型安全 | 弱(任何 char 都合法) | 强(编译期校验) |
| 可读性 | 阶段 ⑦ 持久化时直观 | 业务代码更清晰 |
结论:因为身份标签的核心用途就是 CSV 持久化,char 更直接;如果是纯内存使用我们会选 enum。这是教学故意保留的"实用主义选型点"——挑战 D 可以让你改造为 enum class。
# 04.实体类设计
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:长出 3 个最简单的"数据载体"类 │
│ 不做什么:不写业务逻辑、不接 CampusSystem——它们只是数据袋 │
│ 验收标准:能 new 一个实体对象 + 打印字段值 │
│ 预计耗时:60 分钟 │
│ 关键思路:先长 Computer(最简单)→ 再长 Speech(多 1 个 enum)│
│ → 再长 Reservation(含外键)。每写完一个就编译验证 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 4.1 灵魂三问:实体类要存什么?
❓ 校园系统里"东西"和"事件"分别是什么? 东西:机房(编号 + 配置 + 容量);事件:演讲报名、机房预约——它们都"指向"具体的人和东西
❓ 实体类要不要包含业务方法? 答:不要——实体只装数据,业务逻辑放在 CampusSystem 里。这叫"贫血模型",是初学者最容易掌握的工程结构。
❓ 现在第一步要先做哪一个? 答:先做 Computer(最简单,3 个字段、无外键、无 enum)。Speech 和 Reservation 都是建立在"机房存在"的基础上——再次遵循"先做被依赖项"。
# 4.2 实体一:Computer
类的设计意图:
- 数据封装:将机房的相关信息(编号、容量、配置)封装在一个类中。
- 数据转换:提供 toCsv 方法,将对象转换为 CSV 格式,便于存储或传输。
- 数据解析:通过 fromCsv 方法,从 CSV 格式中恢复对象,便于读取或初始化。
📁 Computer.h:
#pragma once
#include <string>
class Computer {
public:
int id; // 机房编号(主键)
int capacity; // 容量(人数)
std::string spec; // 配置(如 "i7+RTX4060")
Computer() = default;
Computer(int id_, int cap, const std::string& s)
: id(id_), capacity(cap), spec(s) {}
std::string toCsv() const {
return std::to_string(id) + "," + std::to_string(capacity) + "," + spec;
}
// 阶段 ⑦ 才会真正实现,先放声明
static Computer fromCsv(const std::string& line);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
📁 Computer.cpp(先给 fromCsv 一个空实现,阶段 ⑦ 再回来填):
#include "Computer.h"
#include <sstream>
Computer Computer::fromCsv(const std::string& line) {
// TODO(阶段 ⑦): 真正解析 CSV
return Computer{};
}
2
3
4
5
6
7
💡 为什么 fields 是 public?因为 Computer 是纯数据类——没有不变量需要保护。如果加 private + getter/setter,只会让 90% 的访问代码变啰嗦却毫无收益。实体类的 public 字段是"贫血模型"的特征,工业界(尤其是数据传输层 DTO)非常常见。
🧪 立刻编译验证(用临时 main 测试),在 main.cpp 里临时加一段测试代码:
#include "Computer.h"
// ... 在 main 函数最开始加这几行 ...
Computer c(101, 50, "i7+RTX4060");
cout << "[Test] 机房 " << c.id << " 容量 " << c.capacity << " 配置 " << c.spec << "\n";
cout << "[Test] toCsv: " << c.toCsv() << "\n";
return 0; // 测完先 return,验证后再删除
2
3
4
5
6
g++ -std=c++17 main.cpp Student.cpp Teacher.cpp Admin.cpp Computer.cpp -o campus_system
./campus_system
2
预期输出:
[Test] 机房 101 容量 50 配置 i7+RTX4060
[Test] toCsv: 101,50,i7+RTX4060
2
✅ 看到 toCsv 输出 = 阶段 ⑦ 的持久化基础已就绪。验证后删除这段临时测试代码,恢复 main 的原循环。
# 4.3 实体二:Speech 与 enum
Computer 跑通了,现在加第二个实体。演讲多了一个"轮次"概念——这是新东西。
📁 Speech.h:
#pragma once
#include <string>
class Speech {
public:
std::string studentId; // 报名学生(外键关联 User)
std::string topic; // 主题
int round = 1; // 轮次(1=初赛,2=复赛)
double score = 0.0; // 评分(默认 0,等教师评分)
Speech() = default;
Speech(const std::string& sid, const std::string& t, int r)
: studentId(sid), topic(t), round(r) {}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
📚 设计点:round 用 int 而不是 enum——因为只有 1/2 两个值,且直接对应 multimap 的 key(阶段 ④ 会看到 multimap<int, Speech>)。如果将来扩展为"季赛/年赛",可以再升级为 enum。
✋ 暂停一下:你可能会问"为什么没有 toCsv 方法?" 答:Speech 的持久化先不做——本案例阶段 ⑦ 只持久化 users / rooms / reservations 三个文件,演讲数据放进程内存即可(这是案例的已知局限,挑战题之一就是补全演讲持久化)。
# 4.4 实体三:Reservation 外键
预约比前两个复杂——它关联两个外键(学生 + 机房)+ 有生命周期状态(待审/批准/拒绝/取消)。
📁 Reservation.h:
#pragma once
#include <string>
enum class ResStatus { Pending, Approved, Rejected, Cancelled };
class Reservation {
public:
int resId; // 预约 ID(自增)
std::string studentId; // 哪个学生(外键)
int computerId; // 哪个机房(外键)
std::string date; // 日期 YYYY-MM-DD
ResStatus status = ResStatus::Pending;
Reservation() = default;
Reservation(int rid, const std::string& sid, int cid, const std::string& d)
: resId(rid), studentId(sid), computerId(cid), date(d) {}
std::string statusText() const {
switch (status) {
case ResStatus::Pending: return "待审核";
case ResStatus::Approved: return "已批准";
case ResStatus::Rejected: return "已拒绝";
case ResStatus::Cancelled: return "已取消";
}
return "未知";
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
C++ 知识点 · enum class:相比 C 风格 enum,enum class 不会污染命名空间,类型也更严格——你必须写 ResStatus::Pending 而不能直接写 Pending,并且不能隐式转 int。这是卷一第 3 章的内容。这里第一次在实体里用 enum class,正是因为 4 种状态需要类型安全的语义表达。
🔑 外键 vs 拥有:studentId 是 string、computerId 是 int——它们是"引用其他对象的标识符",不是直接持有对象。这就是数据库外键关系在 OOP 里的体现。永远不要在 Reservation 里直接放 Student* student——那会让序列化、反序列化、生命周期管理全乱套。
🧪 第二次编译验证(三个实体都能 new)
临时在 main 里测试三个实体协同:
Computer c(101, 50, "i7+RTX4060");
Speech s("S001", "AI伦理", 1);
Reservation r(1, "S001", 101, "2026-06-01");
cout << "机房: " << c.toCsv() << "\n";
cout << "演讲: " << s.studentId << " " << s.topic << " round=" << s.round << "\n";
cout << "预约: #" << r.resId << " 学生 " << r.studentId
<< " 机房 " << r.computerId << " 状态 " << r.statusText() << "\n";
2
3
4
5
6
7
8
预期输出:
机房: 101,50,i7+RTX4060
演讲: S001 AI伦理 round=1
预约: #1 学生 S001 机房 101 状态 待审核
2
3
✅ 三个实体能各自创建 + 字段访问 + 状态文本 = 阶段 ③ 完成。
💡 注意 r.studentId="S001" 和 s.studentId="S001" —— 这就是外键关联:两个实体引用同一个学生标识。阶段 ⑦ 加载时这种关联要做完整性校验,否则就会产生"孤儿预约"(学生已删除但预约还在)。
┌─ 📌 阶段 ③ 小结 ────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Computer:最简单的纯数据类(3 字段 + toCsv) │
│ • Speech:引入"轮次"概念(int 直接对应 multimap key) │
│ • Reservation:首次使用 enum class + 双外键关联 │
│ • 三个实体都验证了 new + 打印 + toCsv 链路 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • Computer/Speech/Reservation 的容器选型(阶段 ④) │
│ • 实体之间通过 CampusSystem 协同(阶段 ④起) │
│ • 外键完整性校验(阶段 ⑦) │
│ 📌 进入下阶段前务必: │
│ 1. 删除 main 里的临时测试代码,恢复 login 循环 │
│ 2. git add . && git commit -m "stage3: entities" │
│ 💡 本阶段最大领悟: │
│ "实体只装数据,业务放管理器——贫血模型让代码扩展性最强" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 05.CampusSystem 总管理器
┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────┐
│ 完成什么:CampusSystem 类的"最小可运行骨架"——1 个容器 + 1 个方法 │
│ 不做什么:不写预约、不写演讲、不写审核、不写评分 │
│ 那些都是后续阶段在"需要时"才长出来的字段和方法 │
│ 验收标准:能 add 用户 + login 校验账号密码 + main 真正分发到子类菜单 │
│ 预计耗时:60 分钟 │
│ 关键思路:和银行 §6.1-6.2 一模一样——先空壳后填肉, │
│ 容器和方法都不是事先全规划好,而是写一个业务才追加一个 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
本节是最容易踩坑的一节:很多同学拿到一个"管理器类"任务,会先花一小时把 vector<...> / map<...> / set<...> / multimap<...> 一次性全部定义好,然后再开始写业务——结果发现:
- 提前定义的字段一半根本用不上
- 写业务时发现需要的索引没有,回头改头文件
- 头文件一改,所有 cpp 都要重新编译
- 改了三次发现自己不知道哪个字段是干嘛的
✅ 正确做法:和银行案例 Bank 类一样——只先定义"绝对必要"的字段和方法,其他字段写业务时自然冒出来。
# 5.1 灵魂三问:先做什么
❓ CampusSystem 类一开始绝对必须存什么? 答:用户表。因为没有用户就没法登录,没法登录就没法做任何业务。
❓ CampusSystem 类一开始必须提供什么方法? 答:login(id, pwd)——这是一切交互的入口。加用户的方法 addUser 也得有(不然用户表永远是空的)。
❓ 机房表 / 预约表 / 演讲表 / 已被预约机房索引……要不要现在加? 答:不加! 这些都是后续阶段写具体业务时才需要的字段——到那时再追加才知道选哪种容器最合适。
🔑 教学要点:和银行案例 §6.1 完全相同——第一步只解决"能登录"这一件事。
# 5.2 第一版:最小骨架
📁 CampusSystem.h(第一版,只有 users 一个容器):
#pragma once
#include "User.h"
#include <map>
#include <memory>
#include <string>
class CampusSystem {
private:
// ⭐ 第一版:只有 1 个容器
std::map<std::string, std::shared_ptr<User>> users; // 账号 → 用户
public:
CampusSystem();
// ⭐ 第一版:只有 2 个方法
bool addUser(std::shared_ptr<User> u);
std::shared_ptr<User> login(const std::string& id, const std::string& pwd);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
📁 CampusSystem.cpp(第一版):
#include "CampusSystem.h"
#include <iostream>
using namespace std;
CampusSystem::CampusSystem() {
cout << "[System] 校园系统启动\n";
}
bool CampusSystem::addUser(shared_ptr<User> u) {
if (users.count(u->getId()) > 0) {
cout << "[System] 用户 " << u->getId() << " 已存在\n";
return false;
}
users[u->getId()] = u;
cout << "[System] 添加用户 " << u->getId() << " 成功\n";
return true;
}
shared_ptr<User> CampusSystem::login(const string& id, const string& pwd) {
auto it = users.find(id);
if (it == users.end()) {
cout << "[System] 账号不存在\n";
return nullptr;
}
if (!it->second->verify(pwd)) {
cout << "[System] 密码错误\n";
return nullptr;
}
return it->second;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
💡 你可能会问:为什么用 map<string, shared_ptr<User>> 不用 vector<shared_ptr<User>>?答:login 时按账号 id 查找——map 是 O(log n),vector 要 O(n)。容器选型按"主访问模式"决定。
# 5.3 接入 main 让登录工作
修改 main.cpp,让登录走 CampusSystem::login() 而不是直接 make_shared:
#include "CampusSystem.h"
#include "Student.h"
#include "Teacher.h"
#include "Admin.h"
int main() {
CampusSystem sys;
// ⭐ 临时:手动 add 三个测试用户(阶段 ⑦ 接 FileStore 后会从 CSV 加载)
sys.addUser(make_shared<Student>("S001", "张三", "123"));
sys.addUser(make_shared<Teacher>("T001", "李老师", "456"));
sys.addUser(make_shared<Admin>("A001", "王管理员", "789"));
while (true) {
cout << "\n=== 校园系统登录 ===\n";
cout << "1. 学生 2. 教师 3. 管理员 0. 退出\n";
cout << "选择身份: ";
int role; cin >> role;
if (role == 0) { cout << "再见!\n"; return 0; }
string id, pwd;
cout << "账号: "; cin >> id;
cout << "密码: "; cin >> pwd;
auto user = sys.login(id, pwd);
if (!user) continue;
// ⭐ 关键:让子类拿到 sys 引用(阶段 ⑤ 起 mainMenu 真业务时要用)
if (auto p = dynamic_pointer_cast<Student>(user)) p->setSystem(&sys);
else if (auto p = dynamic_pointer_cast<Teacher>(user)) p->setSystem(&sys);
else if (auto p = dynamic_pointer_cast<Admin>(user)) p->setSystem(&sys);
user->mainMenu(); // ⭐ 多态分发
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
📚
dynamic_pointer_cast<Derived>(basePtr):在shared_ptr上做安全的向下转型。如果实际类型不匹配,返回空指针。要求基类有 virtual 函数(多态类型),User 有虚析构 + 纯虚函数,符合条件。
# 🧪 立刻编译运行(阶段 ④ 第一次验收)
g++ -std=c++17 main.cpp Student.cpp Teacher.cpp Admin.cpp \
Computer.cpp CampusSystem.cpp -o campus_system
./campus_system
2
3
操作 1:选 1 学生 → 输入 S001 → 密码 123 → 看到学生菜单占位
操作 2:选 1 学生 → 输入 S001 → 密码 wrong → 看到 "密码错误"
操作 3:选 2 教师 → 输入 S001 → 密码 123 → ❓ 能登录成功吗?
实际行为:能登录成功!但是 dynamic_pointer_cast
✅ 看到登录链路通了 + 密码错误能拒绝 = 阶段 ④ 第一版完成。
# 5.4 渐进追加 Student 字段
本节最关键的教学点:CampusSystem 接下来的字段和方法全部都是配合 §06 Student 业务"按需长出来"的。
下面的表格是 §06 各个 Student 业务完成后,CampusSystem 类的演化路径——这只是预告,你不应该现在就把这些字段加进去!
| §06 节次 | 业务 | CampusSystem 新增字段 | CampusSystem 新增方法 |
|---|---|---|---|
| §6.3 | 浏览机房 | map<int, Computer> rooms | void listRooms() const |
| §6.4 | 预约机房 | vector<Reservation> reservations set<int> reservedRooms int nextResId = 1 | bool reserveRoom(...) |
| §6.5 | 取消预约 | (复用上面的字段) | bool cancelReservation(...) |
| §6.6 | 报名演讲 | multimap<int, Speech> speeches | bool signupSpeech(...) |
| §07.x | Teacher 业务 | (复用 reservations + speeches) | listPendingReservations / reviewReservation / scoreSpeech / rankSpeechesByScore |
| §08.x | Admin 业务 | (复用 users + rooms) | addRoom / statistics |
| §09.x | 持久化 | string usersFile/roomsFile/resFile | loadAll / saveAll |
为什么要这样长出来?三个原因:
1.每个字段的引入都有"前因":先写 reserveRoom 才发现"vector 装预约 + set 装快速索引"是必要的——这种业务驱动的选型才是真知识
2.避免提前过度设计:你不会一开始就加 map<int, vector<Speech>>——只有在 §6.6 才会发现 multimap 比这个更合适
3.每加一个字段就编译验证:不会出现"改头文件后 5 个 cpp 文件全报错"的灾难
# 5.5 容器选型灵魂指引
虽然字段是逐步加的,但选型决策框架你现在就该掌握。每次写一个新业务前问自己三个问题:
| 选型问题 | 候选 | 决策依据 |
|---|---|---|
| 要按 key 查找吗? | map / unordered_map | 按编号/账号查 → map 系列 |
| 要按 key 有序遍历吗? | map(红黑树) | 是 → map(O(log n));否 → unordered_map(O(1) 但无序) |
| 同一个 key 多个 value 吗? | multimap 或 map<K, vector<V>> | 主操作"按 key 分组遍历" → multimap;内层还要计算 → map<K, vector |
| 只关心存在性吗? | set | 是 → set(比 map 省一半内存) |
| 要"按时间顺序"遍历吗? | vector | 是 → vector(push_back 顺序就是时间序) |
反例对比(这是阶段 ④ 起会反复出现的选型陷阱):
// ❌ 全部用 vector
vector<User> users; // login 要 O(n) 遍历查账号
vector<Computer> rooms; // 同样 O(n) 查机房编号
// ❌ 全部用 unordered_map
unordered_map<int, Computer> rooms; // listRooms 时无法按编号有序输出,得再排序
// ❌ 自己写"按 round 分组"
vector<Speech> speeches;
// signupSpeech 要 push_back,rankByRound 要先按 round 过滤再排序——multimap 一行搞定
2
3
4
5
6
7
8
9
10
💡 为什么把这个表格放在 §5.5 而不是 §5.2? 因为只有当你真正写过一个用错容器的业务之后,这些选型规则才会刻进脑子。§6.4 reserveRoom 节会故意让你只用 vector 不用 set,让你看到 bug 再修复——那时候这张表才会真正发挥价值。
┌─ 📌 阶段 ④ 小结 ────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • CampusSystem 最小骨架:只有 users 一个容器 │
│ • addUser + login 两个方法跑通登录链路 │
│ • main 接通:addUser 注入 → login 校验 → mainMenu 多态分发 │
│ ⏸ 还没碰的(核心约定!下阶段才长出来): │
│ • rooms / reservations / speeches / reservedRooms 字段 │
│ • Student 业务方法 listRooms/reserveRoom/... │
│ • Teacher 业务方法 reviewReservation/scoreSpeech/... │
│ • Admin 业务方法 addRoom/statistics │
│ • 持久化方法 loadAll/saveAll │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage4: campus skeleton" │
│ 💡 本阶段最大领悟: │
│ "管理器类不是事先设计出来的,是写业务时一个字段一个字段长出来的" │
│ 回头看你的 CampusSystem.h——只有 1 个 map 1 个方法 │
│ 这是你目前最棒的工程习惯:永远不要提前定义你不知道怎么用的字段 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 06.Student 业务展开
┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────────┐
│ 完成什么:让 Student 跑通 4 个业务(浏览机房/预约/取消/报名演讲)│
│ 不做什么:不做审核、不做评分、不做文件保存——那是阶段 ⑥⑦⑧ 的事 │
│ 验收标准:学生登录后能看到机房 → 预约成功 → 同一机房不能再被预约 │
│ 预计耗时:90 分钟 │
│ 关键思路:每加一个业务就先给 CampusSystem 追加对应字段 + 方法 │
│ 编译运行 → 发现"两个容器要同步更新"的细节 │
└────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
本节是案例 03 最难的一节——你要在 §05 最小骨架(只有 1 个 users map)之上,逐步把 CampusSystem 长成 5 个容器 4 个业务方法的样子。每个业务都遵循固定节奏:
- CampusSystem.h 追加新字段(且只追加这一个业务必需的)
- CampusSystem.cpp 实现该业务方法
- Student.cpp 把对应 case 接通
- 编译运行 → 验证 → 才允许进下一个业务
如果一口气写完 4 个业务,任何一个容器忘记同步更新都会导致诡异 bug——本节会让你亲眼看到这样的 bug,再修复它。
# 6.1 灵魂三问
在敲键盘前停 30 秒:
❓ Student 类需要保存什么数据? 答:自己什么都不存——所有数据(机房、预约、演讲)都在 CampusSystem 里。Student 只持有一个 CampusSystem* 指针来调用业务。
❓ Student 对外提供什么动作? 答:浏览机房、预约、取消、报名演讲——4 个动作,每个对应一个 CampusSystem 的方法。
❓ 现在第一步要做哪一个? 答:浏览机房——因为预约/取消都要先"看到机房编号"才能操作,所以最底层的依赖项是显示。
🔑 这就是依赖分析的真实样子:先做被其他功能依赖的、信息流最上游的功能。
# 6.2 打通 Student 管道
仍然遵循"先空壳后填肉"——Student 菜单只放 cout 占位,先验证多态分发能跑:
📁 Student.cpp(先写这一份就够):
#include "Student.h"
#include "CampusSystem.h"
#include <iostream>
using namespace std;
void Student::mainMenu() {
while (true) {
cout << "\n--- 学生 " << userName << " ---\n";
cout << "1. 浏览机房 2. 预约机房 3. 取消预约 4. 报名演讲 0. 退出登录\n";
int op; cin >> op;
switch (op) {
case 1: cout << "[Student] 进入了 listRooms 占位\n"; break;
case 2: cout << "[Student] 进入了 reserveRoom 占位\n"; break;
case 3: cout << "[Student] 进入了 cancelReservation 占位\n"; break;
case 4: cout << "[Student] 进入了 signupSpeech 占位\n"; break;
case 0: return;
default: cout << "无效选择\n";
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
🧪 立刻编译运行(在 main 里手动 new 一个 Student 触发)
g++ -std=c++17 *.cpp -o campus_system
./campus_system
2
选择身份 1(学生)登录后,你应该看到 4 个菜单项 + 输入 1 看到 "进入了 listRooms 占位"。
✅ 看到占位字符 = 多态分发链路通:main → user->mainMenu() → Student::mainMenu() 这条路打通了。
❌ 如果直接退出/没有菜单:99% 是 login() 还返回 nullptr,需要先按 §3.3 把 login 改成 make_shared<Student>(...)。
🔑 教学要点:和银行案例 6.2 节一样——先验证管道,再考虑业务。
# 6.3 listRooms 与范围 for
第一次给 CampusSystem 长字段。要展示机房列表,CampusSystem 必须先有机房——这意味着头文件需要追加新内容。
📁 CampusSystem.h 追加(在 users 字段下方加 1 行 + 在 public 区加 1 行):
class CampusSystem {
private:
std::map<std::string, std::shared_ptr<User>> users;
std::map<int, Computer> rooms; // ⭐ 第 2 个容器
public:
CampusSystem();
bool addUser(std::shared_ptr<User> u);
std::shared_ptr<User> login(const std::string& id, const std::string& pwd);
void listRooms() const; // ⭐ 第 3 个方法
};
2
3
4
5
6
7
8
9
10
11
⚠️ 别忘了在头文件顶部加 #include "Computer.h"。
💡 选型决策:为什么用 map<int, Computer> 而不是 vector<Computer>?
- 业务核心需求:按编号查找机房 + 按编号有序展示
- vector 查找 O(n)、且追加无序;map 查找 O(log n)、按 key 自动有序——完胜
#include <iomanip> // setw / left
void CampusSystem::listRooms() const {
cout << "\n=== 机房列表 ===\n";
cout << left << setw(6) << "编号" << setw(8) << "容量"
<< setw(20) << "配置" << setw(8) << "状态\n";
cout << string(42, '-') << "\n";
if (rooms.empty()) { cout << "(暂无机房)\n"; return; }
// ⭐ 范围 for + 结构化绑定(C++17)
for (const auto& [id, room] : rooms) {
bool occupied = reservedRooms.count(id) > 0;
cout << left << setw(6) << id << setw(8) << room.capacity
<< setw(20) << room.spec
<< (occupied ? "占用中" : "空闲") << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
然后需要在入口的地方添加一下
void Student::mainMenu() {
while (true) {
cout << "\n--- 学生 " << userName << " ---\n";
cout << "1. 浏览机房 2. 预约机房 3. 取消预约 4. 报名演讲 0. 退出登录\n";
int op; cin >> op;
switch (op) {
case 1:
cout << "[Student] 进入了 listRooms 占位\n";
sys->listRooms();
break;
case 2: cout << "[Student] 进入了 reserveRoom 占位\n"; break;
case 3: cout << "[Student] 进入了 cancelReservation 占位\n"; break;
case 4: cout << "[Student] 进入了 signupSpeech 占位\n"; break;
case 0: return;
default: cout << "无效选择\n";
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
第二次编译运行(此时还没有数据,会显示"暂无机房")
操作:登录学生 → 选 1
预期输出:
=== 机房列表 ===
编号 容量 配置 状态
------------------------------------------
(暂无机房)
2
3
4
✅ 没机房也能显示标题 = 边界处理正确。 💡 临时塞两条假数据测试:在
CampusSystem构造函数里临时加rooms[101] = Computer(101, 50, "i7+RTX4060");——这个临时代码后面接上 FileStore 后会删掉,但现在能让你立刻看到表格效果,验证 setw 排版没歪。
再次运行,应该看到:
编号 容量 配置 状态
------------------------------------------
101 50 i7+RTX4060 空闲
2
3
⚠️ 代码会有一行编译错 —— bool occupied = reservedRooms.count(id) > 0; 报错说找不到 reservedRooms。别急着加这个 set——先把这一行改成 bool occupied = false;,让 listRooms 编译通过。reservedRooms 等到 §6.4 真正需要它时再加——这才是 "按需长出来" 的精髓。
C++17 结构化绑定:for (const auto& [id, room] : rooms) 自动把 pair<int, Computer> 拆成 id 和 room,比 it->first / it->second 优雅得多——这是卷一第 18 章的内容,第一次在业务代码里用上。
# 6.4 reserveRoom 容器同步
第二次给 CampusSystem 长字段。现在要做"预约"——你需要思考:预约这个业务到底要存什么数据? 灵魂三问再来一次:
❓ 预约的本体是什么? → Reservation 对象(含 resId/学生/机房/日期/状态)
❓ 怎么存这些 Reservation? → 顺序追加、全量遍历审核 → vector<Reservation>
❓ 还需要别的容器吗? → 取决于"是否要快速判断某机房有没有被占用"
第三问的答案先故意留空——下一步你会亲眼看到不加这个索引的 bug。
📁 CampusSystem.h 追加:
private:
// ... users / rooms 已有 ...
std::vector<Reservation> reservations; // ⭐ 第 3 个容器
int nextResId = 1; // ⭐ 自增 ID
public:
// ... 已有方法 ...
bool reserveRoom(const std::string& sid, int roomId, const std::string& date);
2
3
4
5
6
7
8
⚠️ 头文件顶部加 #include "Reservation.h"。
实现 reserveRoom。先在 Student.cpp 的 case 2 改成真调用:
case 2: {
int roomId; string date;
cout << "机房编号: "; cin >> roomId;
cout << "日期(YYYY-MM-DD): "; cin >> date;
sys->reserveRoom(userId, roomId, date);
break;
}
2
3
4
5
6
7
现在写 reserveRoom 的第一版——故意只更新 vector,不更新 set:
bool CampusSystem::reserveRoom(const string& sid, int roomId, const string& date) {
if (rooms.find(roomId) == rooms.end()) {
cout << "[预约] 机房不存在\n";
return false;
}
Reservation r(nextResId++, sid, roomId, date);
reservations.push_back(r); // 只塞 vector
cout << "[预约] 提交成功,预约号 " << r.resId << "\n";
return true;
}
2
3
4
5
6
7
8
9
10
第三次编译运行 —— 故意制造 bug 让你看见。操作:登录学生 → 预约机房 101 → 再预约一次机房 101
预期输出(坏的):
[预约] 提交成功,预约号 1
[预约] 提交成功,预约号 2 ← 同一个机房被两个学生抢到,业务出错!
2
再选 1 浏览机房,会发现 101 显示为"空闲"——因为我们根本没维护"哪些机房已被占用"的索引!
🚨 这就是真实工程师天天遇到的 bug:业务状态没有集中表达 → 判断时只能 O(n) 遍历整个 reservations,写起来啰嗦还容易漏。
第三次给 CampusSystem 长字段(这次是 bug 逼出来的) 。bug 暴露了一个新需求:"快速判断某个机房有没有被预约"。这正是 set<int> 大显身手的场景:只关心"在不在集合里",不关心 value、O(log n) 查询。
📁 CampusSystem.h 追加:
private:
// ... 已有字段 ...
std::set<int> reservedRooms; // ⭐ 第 4 个容器:已被占用的机房编号
2
3
⚠️ 头文件加 #include <set>。
现在回到 §6.3 把 listRooms 那行 bool occupied = false; 改回 bool occupied = reservedRooms.count(id) > 0;——之前的占位代码终于可以兑现了。
🛠 修复:补上 set 同步 + 占用校验
bool CampusSystem::reserveRoom(const string& sid, int roomId, const string& date) {
if (rooms.find(roomId) == rooms.end()) {
cout << "[预约] 机房不存在\n";
return false;
}
if (reservedRooms.count(roomId) > 0) { // ⭐ 新增:校验未被占用
cout << "[预约] 该机房已被预约\n";
return false;
}
Reservation r(nextResId++, sid, roomId, date);
reservations.push_back(r);
reservedRooms.insert(roomId); // ⭐ 新增:同步更新 set
cout << "[预约] 提交成功,预约号 " << r.resId << "(待审核)\n";
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
🧪 第四次编译运行 —— 验证 bug 已修复。再做同样操作:预约 101 → 再预约 101
预期输出:
[预约] 提交成功,预约号 1(待审核)
[预约] 该机房已被预约 ← bug 已修复
2
再选 1 浏览,应该看到 101 的状态变成"占用中"。
💡 教学要点:很多书会一上来就给出最终版正确代码,但你不会理解"为什么需要 set"。只有亲眼看到 bug,你才会真正记住"vector 和 set 必须同步更新"这条铁律。
回头看你的 CampusSystem.h——经过 §5.2、§6.3、§6.4 三次追加,它现在有 4 个容器、3 个业务方法。每一个字段都有它存在的理由,没有一个是"先放着以备万一"的。
🔑 这就是 STL 应用的常见模式:用 vector<Reservation> 存"完整记录"(业务事实),用 set<int> 存"快速索引"(查询加速)。两者必须始终保持同步。
# 6.5 cancelReservation 实现
🌱 第四次给 CampusSystem 长方法(这次只长方法,不长字段)
📁 CampusSystem.h 追加:
public:
bool cancelReservation(const std::string& sid, int resId); // ⭐ 第 5 个方法
2
💡 注意:这次只加方法不加字段——cancelReservation 复用 reservations + reservedRooms 即可。这就是上一节追加 set 的回报——一次投入,多个业务受益。
现在场景变了:用户给你一个 resId,你要在 vector 里找到对应记录。总不能写 for 循环吧? STL 给了你更优雅的工具:
// Student.cpp case 3
case 3: {
int resId;
cout << "预约号: "; cin >> resId;
sys->cancelReservation(userId, resId);
break;
}
// CampusSystem.cpp
bool CampusSystem::cancelReservation(const string& sid, int resId) {
// ⭐ std::find_if + lambda:在容器里找满足条件的元素
auto it = std::find_if(reservations.begin(),reservations.end(), [resId,&sid](const Reservation& r) {
return r.resId == resId && r.studentId == sid;
});
if (it == reservations.end()) {
cout << "[取消] 预约不存在或不属于你\n";
return false;
}
if (it->status != ResStatus::Pending) {
cout << "[取消] 该预约已被审核,无法取消\n";
return false;
}
it->status = ResStatus::Cancelled;
reservedRooms.erase(it->computerId); // ⭐ 同步清理 set(取消后机房空闲)
cout << "[取消] 已取消\n";
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
🧪 第五次编译运行 —— 验证取消后机房恢复空闲
操作:预约 101 → 取消预约号 1 → 浏览机房(应该看到 101 变"空闲")→ 再预约 101(应该成功)
预期输出:
[预约] 提交成功,预约号 1(待审核)
[取消] 已取消
(机房列表显示 101 空闲)
[预约] 提交成功,预约号 2(待审核)
2
3
4
💡 stop and think:这个函数动了几个数据结构?
reservations[i].status← 修改 vector 中的元素状态reservedRooms← 删掉这个机房编号
两个状态必须同步改——和 reserveRoom 是镜像操作。这种"成对的状态修改"在工业代码里出现频率极高,比如订单创建/取消、库存出入库、用户关注/取消关注。
📚 lambda 表达式拆解:
[resId, &sid] // 捕获列表:值捕获 resId,引用捕获 sid
(const Reservation& r) // 参数:每次遍历传入的元素
{ return r.resId == ...; } // 函数体:返回 bool(谓词函数)
2
3
这相当于现场写了一个匿名函数。如果不用 lambda,你得为每次 find_if 写一个全局函数 + 一堆参数 ——lambda 让你就在调用点把判断逻辑写出来,可读性大增。
# 6.6 signupSpeech 与 multimap
🌱 第五次给 CampusSystem 长字段(最后一次为 Student 业务追加)
演讲报名是个新场景:一个轮次(round)会有多个学生报名——这种"一对多"关系正是 multimap 的舞台。
📁 CampusSystem.h 追加:
private:
// 存储键值对(key-value pairs)。与 std::map 不同,std::multimap 允许键(key)重复,即多个值可以关联到同一个键。
std::multimap<int, Speech> speeches; // ⭐ 第 5 个容器:round → Speech
public:
bool signupSpeech(const std::string& sid, const std::string& topic, int round);
2
3
4
5
6
⚠️ 头文件加 #include "Speech.h"。
// Student.cpp case 4
case 4: {
string topic; int round;
cout << "演讲主题: "; cin >> topic;
cout << "轮次(1=初赛, 2=复赛): "; cin >> round;
sys->signupSpeech(userId, topic, round);
break;
}
// CampusSystem.cpp
bool CampusSystem::signupSpeech(const string& sid, const string& topic, int round) {
if (round != 1 && round != 2) {
cout << "[演讲] round 只能是 1 或 2\n";
return false;
}
// ⭐ multimap 允许同 key 多 value:一个 round 可以有很多人报名
speeches.insert({round, Speech(sid, topic, round)});
cout << "[演讲] 报名成功 - 第 " << round << " 轮 - " << topic << "\n";
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
🧪 第六次编译运行
操作:登录学生 S001 → 选 4 → 主题 "AI伦理" 轮次 1 → 再选 4 → 主题 "未来教育" 轮次 1
预期输出:
[演讲] 报名成功 - 第 1 轮 - AI伦理
[演讲] 报名成功 - 第 1 轮 - 未来教育 ← 同一 round 可以有多条
2
💡 你可能会问:为什么不用 map<int, vector<Speech>>?两者都能装"一个 round 多个演讲"啊。
两者对比:
| 对比 | multimap<int, Speech> | map<int, vector<Speech>> |
|---|---|---|
| 插入 | m.insert({1, s}) | m[1].push_back(s) |
| 按 round 全量取 | m.equal_range(1) 返回迭代器对 | m[1] 返回 vector 引用 |
| 跨 round 排序 | 直接全量遍历就有序 | 要先扁平化到 vector |
| 选用 | ✅ 主操作是"按 key 分组遍历" | ✅ 内层 vector 还要做计算 |
本案例 7.2 节会发现 rankSpeechesByScore 需要把同 round 的演讲单独排序——这其实是 map<int, vector<Speech>> 更适合的场景。这是教学故意埋的"选型反思点",留作挑战 A 改造。
┌─ 📌 阶段 ⑤ 小结 ────────────────────────────────────────┐
│ ✅ 你刚刚完成了 Student 业务的 4 个功能 + CampusSystem 5 次生长: │
│ • §6.2 空壳:先打通多态分发链路 │
│ • §6.3 listRooms:CampusSystem 长出 rooms (map) + 第 1 次生长 │
│ 👉 范围 for + 结构化绑定首秀 │
│ • §6.4 reserveRoom:先长 reservations(vector) → 故意 bug → │
│ 再长 reservedRooms(set) 修复 → 第 2、3 次生长 │
│ 👉 体会"vector + set 必须同步"的工程铁律 │
│ • §6.5 cancelReservation:只长方法不长字段(复用 set) → │
│ 第 4 次生长 + lambda find_if 首秀 │
│ 👉 体会"成对状态修改"的镜像逻辑 │
│ • §6.6 signupSpeech:长出 speeches(multimap) → 第 5 次生长 │
│ 👉 multimap 选型 + 选型反思点埋点 │
│ │
│ 📊 现在你的 CampusSystem.h 有 4 个容器 + 5 个方法 —— 不是"事先 │
│ 规划出来的",而是 5 次业务驱动的追加结果。每个字段都有它存在的 │
│ 理由,没有一个是"先放着以备万一"。这就是工程师真实的开发节奏。 │
│ │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 审核预约(Teacher 业务)—— 阶段 ⑥ │
│ • 演讲评分 + lambda 排序 —— 阶段 ⑥ │
│ • Admin 业务 —— 阶段 ⑦ │
│ • 文件持久化 —— 阶段 ⑧ │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage5: student business" │
│ │
│ 💡 本阶段最大的领悟: │
│ "管理器类的字段不是设计出来的,是每个业务一个一个长出来的" │
│ 这正是 §5.2 那个 1 字段 1 方法的最小骨架,最终长成了完整管理器 │
└────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 07.Teacher 业务
┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────┐
│ 完成什么:让 Teacher 跑通 3 个业务(审核预约/演讲评分/查看排名) │
│ 不做什么:不写 Admin、不写文件——继续延续阶段 ⑤ 的 CampusSystem │
│ 验收标准:教师登录后能列出待审 → 批/拒 → 评分 → 看到排名 │
│ 预计耗时:90 分钟 │
│ 关键思路:3 个业务 = 3 次给 CampusSystem 追加方法(不再追加字段, │
│ 因为审核和评分都是复用 §06 已有的 reservations + speeches)│
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 7.1 灵魂三问:与 Student 区别
❓ Teacher 业务要新增哪些 CampusSystem 字段? 答:一个都不需要!教师审核的是学生提交的 reservations,教师评分的也是学生报名的 speeches——所有数据 §06 已经准备好了。教师只是"换一个角度去操作同一份数据"。
❓ 那 Teacher 业务到底新加了什么? 答:3 个业务方法(listPending / reviewReservation / scoreSpeech / rankSpeechesByScore)。这就是阶段 ⑥ 的全部新增。
❓ 现在第一步要做哪一个? 答:和阶段 ⑤ 一样,先打通 Teacher 菜单的多态分发管道,再一个一个填业务。
🔑 教学要点:阶段 ⑥ 的精髓是**"业务驱动的多角色协同"**——同一份 reservations 数据,学生看到的是"我提交的预约",教师看到的是"我要审核的预约"。没有数据冗余,只有视角切换。
# 7.2 打通 Teacher 管道
📁 Teacher.cpp(先放占位菜单,验证多态分发):
#include "Teacher.h"
#include "CampusSystem.h"
#include <iostream>
using namespace std;
void Teacher::mainMenu() {
while (true) {
cout << "\n--- 教师 " << userName << " ---\n";
cout << "1. 待审预约 2. 审核预约 3. 演讲评分 4. 查看排名 0. 退出登录\n";
int op; cin >> op;
switch (op) {
case 1: cout << "[Teacher] listPending 占位\n"; break;
case 2: cout << "[Teacher] reviewReservation 占位\n"; break;
case 3: cout << "[Teacher] scoreSpeech 占位\n"; break;
case 4: cout << "[Teacher] rankSpeeches 占位\n"; break;
case 0: return;
default: cout << "无效选择\n";
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
🧪 立刻编译运行(验证 Teacher 占位菜单能跑)
g++ -std=c++17 *.cpp -o campus_system
./campus_system
2
操作:选 2 教师 → 输入 T001 密码 456 → 看到 4 项占位菜单
预期输出:
--- 教师 李老师 ---
1. 待审预约 2. 审核预约 3. 演讲评分 4. 查看排名 0. 退出登录
1
[Teacher] listPending 占位
2
3
4
✅ Teacher 多态分发链路通了——和 §6.2 Student 占位菜单是镜像操作。这就是模式复用的力量:阶段 ⑤ 走通的套路,阶段 ⑥ 几乎不用思考。
# 7.3 业务一:审核预约
🌱 给 CampusSystem 追加方法(注意:不追加字段)
📁 CampusSystem.h 追加(在 public 区加 2 行):
public:
// ... 已有的 Student 业务方法 ...
void listPendingReservations() const; // ⭐ Teacher 业务方法 1
bool reviewReservation(int resId, bool approved); // ⭐ Teacher 业务方法 2
2
3
4
📁 CampusSystem.cpp 实现:
void CampusSystem::listPendingReservations() const {
cout << "\n=== 待审核预约 ===\n";
// ⭐ count_if 先看有几个(没有就提前返回)
auto pendingCount = std::count_if(reservations.begin(), reservations.end(),
[](const Reservation& r) { return r.status == ResStatus::Pending; });
if (pendingCount == 0) { cout << "(无待审)\n"; return; }
for (const auto& r : reservations) {
if (r.status != ResStatus::Pending) continue;
cout << " 预约 " << r.resId << " | 学生 " << r.studentId
<< " | 机房 " << r.computerId << " | 日期 " << r.date << "\n";
}
}
bool CampusSystem::reviewReservation(int resId, bool approved) {
auto it = std::find_if(reservations.begin(), reservations.end(),
[resId](const Reservation& r) { return r.resId == resId; });
if (it == reservations.end()) {
cout << "[审核] 预约不存在\n";
return false;
}
if (it->status != ResStatus::Pending) {
cout << "[审核] 该预约已被处理过\n";
return false;
}
it->status = approved ? ResStatus::Approved : ResStatus::Rejected;
if (!approved) reservedRooms.erase(it->computerId); // ⭐ 拒绝时释放占用
cout << "[审核] 预约 " << resId << " - " << it->statusText() << "\n";
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
💡 复用前面写好的 lambda 谓词:std::find_if + lambda 这套组合在 §6.5 cancelReservation 已经登场过——这就是 §6.5 投入产生的复用价值。
📁 Teacher.cpp 把 case 1 / case 2 接成真调用:
case 1: sys->listPendingReservations(); break;
case 2: {
int resId; int approved;
cout << "预约号: "; cin >> resId;
cout << "通过(1)还是拒绝(0): "; cin >> approved;
sys->reviewReservation(resId, approved == 1);
break;
}
2
3
4
5
6
7
8
🧪 第二次编译运行(端到端验证:学生提交 → 教师审核)
操作:
- 登录学生 S001 → 选 2 → 预约机房 101 日期 2026-06-01 → 退出
- 登录教师 T001 → 选 1 → 看到一条 Pending 预约
- → 选 2 → 输入预约号 1 → 输入 1 通过
预期输出(教师侧):
=== 待审核预约 ===
预约 1 | 学生 S001 | 机房 101 | 日期 2026-06-01
预约号: 1
通过(1)还是拒绝(0): 1
[审核] 预约 1 - 已批准
2
3
4
5
6
🎉 多角色协同首次走通:学生写入 reservations → 教师从同一个 reservations 读取并修改状态。没有数据冗余,没有跨服务通信,纯 OOP 字段共享。
# 7.4 业务二:演讲评分
🌱 给 CampusSystem 追加方法
📁 CampusSystem.h 追加:
public:
void scoreSpeech(const std::string& sid, int round, double score); // ⭐ 业务方法 3
2
📁 CampusSystem.cpp 实现:
void CampusSystem::scoreSpeech(const string& sid, int round, double score) {
// ⭐ multimap::equal_range:拿到 round 这个 key 的所有 entry 的 [first, last)
auto range = speeches.equal_range(round);
for (auto it = range.first; it != range.second; ++it) {
if (it->second.studentId == sid) {
it->second.score = score;
cout << "[评分] " << sid << " 第 " << round
<< " 轮 - " << score << " 分\n";
return;
}
}
cout << "[评分] 未找到该报名\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
📚 equal_range vs find:multimap 的 find 只返回第一个匹配 key 的迭代器,但 multimap 同一 key 可能有多个 value——所以要用 equal_range 拿到所有匹配 entry 的范围。这是 multimap 的标准用法。
📁 Teacher.cpp 接 case 3:
case 3: {
string sid; int round; double score;
cout << "学生账号: "; cin >> sid;
cout << "轮次(1/2): "; cin >> round;
cout << "分数: "; cin >> score;
sys->scoreSpeech(sid, round, score);
break;
}
2
3
4
5
6
7
8
🧪 第三次编译运行
操作:先用学生 S001 报演讲(场景:选 4 → "AI伦理" → round 1)→ 切教师 T001 → 选 3 → 输入 S001 / 1 / 95
预期输出:
[评分] S001 第 1 轮 - 95 分
# 7.5 业务三:lambda 排名
🔑 本节是 lambda 排序的真正登场——前面用过 lambda 做谓词(find_if),这次用 lambda 做比较器(sort)。 🌱 给 CampusSystem 追加方法
📁 CampusSystem.h 追加:
public:
void rankSpeechesByScore(int round) const; // ⭐ 业务方法 4:lambda 排序高光
2
📁 CampusSystem.cpp 实现(重点看 lambda 比较器):
void CampusSystem::rankSpeechesByScore(int round) const {
// 1. 把 multimap 中 round 对应的 entry 抽到 vector(multimap 不能直接 sort)
std::vector<Speech> arr;
auto range = speeches.equal_range(round);
for (auto it = range.first; it != range.second; ++it) {
arr.push_back(it->second);
}
if (arr.empty()) { cout << "[排名] 第 " << round << " 轮无人报名\n"; return; }
// 2. ⭐ lambda 比较器:分数高的在前
std::sort(arr.begin(), arr.end(),
[](const Speech& a, const Speech& b) { return a.score > b.score; });
// 3. 输出排名
cout << "\n=== 第 " << round << " 轮排名 ===\n";
int rank = 1;
for (const auto& s : arr) {
cout << " " << rank++ << ". " << s.studentId
<< " - " << s.topic << " - " << s.score << " 分\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
📁 Teacher.cpp 接 case 4:
case 4: {
int round;
cout << "查看哪一轮(1/2): "; cin >> round;
sys->rankSpeechesByScore(round);
break;
}
2
3
4
5
6
🧪 第四次编译运行
操作:让多个学生报名第 1 轮 → 教师分别评分 → 查看第 1 轮排名
预期输出:
=== 第 1 轮排名 ===
1. S003 - 量子计算 - 98 分
2. S001 - AI伦理 - 95 分
3. S002 - 未来教育 - 87 分
2
3
4
📚 lambda 表达式语法五件套:[capture](param) -> ret { body }
[]捕获列表:本案例无捕获,所以空(const Speech& a, const Speech& b)参数:sort 要求二元比较-> ret返回类型:可省略,编译器推导{ return a.score > b.score; }函数体:返回 bool(第一个参数应排在前面则返回 true)
⚠️ 倒序的写法是 > 不是 <——这是新手最常犯的错。记忆方法:"我希望 a 排在 b 前面"对应的判断式。
# 7.6 STL 算法函数全景速查
经过 §06 + §07 的连续使用,你已经接触了 STL 算法库的核心 5 个函数:
| 算法 | 作用 | 在本案例中第一次出现 |
|---|---|---|
find_if(b,e,pred) | 找第一个满足条件的迭代器 | §6.5 cancelReservation |
count_if(b,e,pred) | 统计满足条件的个数 | §7.3 listPendingReservations |
sort(b,e,cmp) | 排序(lambda 自定义比较器) | §7.5 rankSpeechesByScore |
accumulate(b,e,init,op) | 累加(lambda 自定义运算) | §8.2 statistics(即将出现) |
for_each(b,e,fn) | 对每个元素执行 fn | (留作挑战题) |
🔑 共同点:都是 "算法 + 迭代器对 + lambda" 的三件套。这就是 STL 设计的核心理念:算法不知道数据装在什么容器里,只知道一对迭代器;判断/计算逻辑由调用方用 lambda 提供。这是 C++ 范型编程的灵魂。
┌─ 📌 阶段 ⑥ 小结 ────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • §7.2 Teacher 占位菜单:复用 §6.2 多态分发模式 │
│ • §7.3 审核预约:复用 §6.5 find_if + lambda │
│ • §7.4 演讲评分:multimap.equal_range 首次登场 │
│ • §7.5 排名 + lambda 排序:sort + 比较器三件套 │
│ 📊 CampusSystem 本阶段没有新增字段,只追加 4 个方法 │
│ - listPendingReservations / reviewReservation │
│ - scoreSpeech / rankSpeechesByScore │
│ → 本质是"对已有数据的多角色视角切换" │
│ ⏸ 还没碰的(下阶段才会做): │
│ • Admin 业务(addRoom + statistics)—— 阶段 ⑦ │
│ • 文件持久化 —— 阶段 ⑧ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage6: teacher business" │
│ 💡 本阶段最大领悟: │
│ "多角色协同的本质是视角切换,不是数据复制——同一份 reservations,│
│ 学生看到的是'我的预约',教师看到的是'要审的预约'" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 08.Admin 业务
┌─ 🎯 阶段 ⑦ 目标 ────────────────────────────────────┐
│ 完成什么:让 Admin 跑通 3 个业务(添加机房/添加用户/数据统计) │
│ 不做什么:不做权限收紧(任何人 addUser 都能注入)、不做文件 │
│ 验收标准:Admin 能创建新机房 → 学生能预约新机房 → 看到统计 │
│ 预计耗时:60 分钟 │
│ 关键思路:addUser 已经在 §5.2 写过(复用!);新增 addRoom + │
│ statistics 两个方法;本阶段 lambda + STL 算法收官 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 8.1 灵魂三问:Admin 特殊性
❓ Admin 业务和 Student/Teacher 有什么本质不同? 答:Admin 操作的是"系统初始数据"——用户和机房。学生预约的机房从哪来?管理员加的。教师审核的预约提交者是谁?管理员加的学生。Admin 是整个系统的"初始化者"。
❓ addUser 是不是已经写过了? 答:对! §5.2 CampusSystem 最小骨架时就实现过 addUser——本阶段直接复用。这就是 §5.2 那个最小骨架的远期回报。
❓ 第一步做哪个? 答:addRoom——它是 Student listRooms / reserveRoom 的依赖项。没有机房,§06 那些临时塞假数据的代码就没法替换。
# 8.2 打通 Admin 管道
📁 Admin.cpp(占位菜单):
#include "Admin.h"
#include "CampusSystem.h"
#include <iostream>
using namespace std;
void Admin::mainMenu() {
while (true) {
cout << "\n--- 管理员 " << userName << " ---\n";
cout << "1. 添加机房 2. 添加用户 3. 数据统计 0. 退出登录\n";
int op; cin >> op;
switch (op) {
case 1: cout << "[Admin] addRoom 占位\n"; break;
case 2: cout << "[Admin] addUser 占位\n"; break;
case 3: cout << "[Admin] statistics 占位\n"; break;
case 0: return;
default: cout << "无效选择\n";
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🧪 编译运行验证占位菜单
操作:登录 A001 / 789 → 看到 3 项菜单
预期输出:
--- 管理员 王管理员 ---
1. 添加机房 2. 添加用户 3. 数据统计 0. 退出登录
2
✅ Admin 多态分发链路通了。
# 8.3 业务一:添加机房
🌱 给 CampusSystem 追加方法
📁 CampusSystem.h 追加:
public:
bool addRoom(const Computer& c); // ⭐ Admin 业务方法
2
📁 CampusSystem.cpp 实现:
bool CampusSystem::addRoom(const Computer& c) {
if (rooms.count(c.id) > 0) {
cout << "[Admin] 机房 " << c.id << " 已存在\n";
return false;
}
rooms[c.id] = c;
cout << "[Admin] 添加机房 " << c.id << " 成功\n";
return true;
}
2
3
4
5
6
7
8
9
📁 Admin.cpp 接 case 1:
case 1: {
int id, cap;
string spec;
cout << "机房编号: "; cin >> id;
cout << "容量: "; cin >> cap;
cout << "配置: "; cin >> spec;
sys->addRoom(Computer(id, cap, spec));
break;
}
2
3
4
5
6
7
8
9
🧪 第二次编译运行(端到端验证:管理员加机房 → 学生看到 → 学生预约)
操作:
- 登录 A001 → 选 1 → 输入 102 / 30 / "i9+RTX5090"
- 退出,登录 S001 → 选 1(浏览) → 看到 102 → 选 2 预约 102
预期输出:
--- 管理员 王管理员 ---
1. 添加机房 ...
1
机房编号: 102
容量: 30
配置: i9+RTX5090
[Admin] 添加机房 102 成功
(切换学生 S001 后)
=== 机房列表 ===
编号 容量 配置 状态
------------------------------------------
101 50 i7+RTX4060 空闲
102 30 i9+RTX5090 空闲
2
3
4
5
6
7
8
9
10
11
12
13
14
🎉 三角色协同首次完整跑通:Admin 创建数据 → Student 消费 → Teacher 审核——这就是 RBAC 系统的样子。
# 8.4 业务二:复用 addUser
🌱 这一步不需要给 CampusSystem 追加任何代码。addUser 在 §5.2 CampusSystem 最小骨架时就已经实现了——这就是当时早期投入的回报。
📁 Admin.cpp 接 case 2(直接调 sys->addUser):
case 2: {
int role;
string id, name, pwd;
cout << "身份(1=学生 2=教师): "; cin >> role;
cout << "账号: "; cin >> id;
cout << "姓名: "; cin >> name;
cout << "密码: "; cin >> pwd;
shared_ptr<User> u;
if (role == 1) u = make_shared<Student>(id, name, pwd);
else if (role == 2) u = make_shared<Teacher>(id, name, pwd);
else { cout << "[Admin] 不允许添加管理员\n"; break; }
sys->addUser(u);
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
💡 业务约束:管理员不允许通过菜单创建另一个管理员——这是简易的"权限分离"。真实 RBAC 系统会用更复杂的"超级管理员/普通管理员"层级。
⚠️ Admin.cpp 顶部需要 #include "Student.h" 和 #include "Teacher.h"。
# 8.5 业务三:accumulate 统计
🔑 本节是 STL 算法库的收官登场——accumulate + lambda 是函数式编程的精髓。 🌱 给 CampusSystem 追加方法
📁 CampusSystem.h 追加:
public:
void statistics() const; // ⭐ 业务方法:lambda 三连 accumulate
2
📁 CampusSystem.cpp 实现(没有一个 for 循环):
#include <numeric> // accumulate
void CampusSystem::statistics() const {
cout << "\n=== 数据统计 ===\n";
cout << "用户总数: " << users.size() << "\n";
cout << "机房总数: " << rooms.size() << "\n";
// ⭐ accumulate + lambda 实现"按身份分类计数"
int students = std::accumulate(users.begin(), users.end(), 0,
[](int sum, const auto& kv) {
return sum + (kv.second->roleTag() == 'S' ? 1 : 0);
});
int teachers = std::accumulate(users.begin(), users.end(), 0,
[](int sum, const auto& kv) {
return sum + (kv.second->roleTag() == 'T' ? 1 : 0);
});
cout << " 学生: " << students << " | 教师: " << teachers
<< " | 管理员: " << users.size() - students - teachers << "\n";
cout << "预约总数: " << reservations.size() << "\n";
int pending = std::count_if(reservations.begin(), reservations.end(),
[](const Reservation& r) { return r.status == ResStatus::Pending; });
cout << " 待审: " << pending
<< " | 已审: " << reservations.size() - pending << "\n";
cout << "演讲报名: " << speeches.size() << " 人次\n";
cout << " 第 1 轮: " << speeches.count(1)
<< " | 第 2 轮: " << speeches.count(2) << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
📁 Admin.cpp 接 case 3:
case 3: sys->statistics(); break;
🧪 第三次编译运行(统计输出验证)
操作:先用 §06 + §07 + §8.3 + §8.4 创建多个用户/机房/预约/演讲后 → 登录 Admin → 选 3
预期输出:
=== 数据统计 ===
用户总数: 5
机房总数: 2
学生: 3 | 教师: 1 | 管理员: 1
预约总数: 4
待审: 1 | 已审: 3
演讲报名: 3 人次
第 1 轮: 2 | 第 2 轮: 1
2
3
4
5
6
7
8
💡 声明式 vs 命令式:statistics() 函数全是声明式的"我要什么":
- "我要 users 里 roleTag 是 'S' 的数量" →
accumulate + lambda- "我要 reservations 里状态是 Pending 的数量" →
count_if + lambda- "我要 speeches 里 round 是 1 的数量" →
multimap.count(1)
没有一个 for 循环,没有一个 if-else——这就是函数式编程的优雅。
┌─ 📌 阶段 ⑦ 小结 ────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • §8.2 Admin 占位菜单:第三次复用多态分发模式 │
│ • §8.3 addRoom:CampusSystem 追加 1 个方法 │
│ • §8.4 addUser:完全复用 §5.2 已有方法(早期投入收益) │
│ • §8.5 statistics:accumulate + count_if + lambda 三连击 │
│ 📊 三角色协同首次完整闭环:Admin 创建 → Student 消费 → │
│ Teacher 审核 → Admin 统计 │
│ ⏸ 还没碰的最后一块拼图: │
│ • 文件持久化 —— 阶段 ⑧(最终阶段) │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage7: admin business" │
│ 💡 本阶段最大领悟: │
│ "STL 算法 + lambda = 没有 for 循环的代码——声明你要什么, │
│ 不是描述怎么算" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 09.多文件持久化 FileStore
┌─ 🎯 阶段 ⑧ 目标 ────────────────────────────────────┐
│ 完成什么:让程序退出后数据不丢——3 个 CSV 文件协同读写 │
│ 不做什么:不做 JSON / 不做并发安全(案例 04/05 才做) │
│ 验收标准:开户 → 退出 → 重启 → 看到原账号可登录 + 原数据还在 │
│ 预计耗时:90 分钟 │
│ 关键思路:一个文件一个文件长出来——先 users.csv 跑通整个 save/ │
│ load 链路 → 再加 rooms → 再加 reservations + 外键校验│
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 9.1 灵魂三问:为何拆三文件
❓ 能不能用一个 all.csv 装下所有数据?
来看反例:
TYPE,id,name,pwd,role,roomId,capacity,spec,resId,studentId,date,status
USER,S001,张三,123,S,,,,,,,
ROOM,,,,,101,50,i7+RTX4060,,,,
RES,,,,,,,,1,S001,2026-06-01,Pending
2
3
4
问题:
- 每行字段类型完全不同,列定义膨胀——为了适配最长的行,每行都得有 12 列
- 空字段一大堆——一行 USER 后面 7 个字段全空
- 解析时要先看 TYPE 字段决定后续含义——逻辑分支爆炸
- 一个文件 corrupt 全部数据丢失
✅ 正确做法:按业务实体拆分文件,每个文件结构稳定——这是 RDBMS(关系数据库)的核心思想。
❓ 三个文件的加载顺序重要吗? 答:极其重要!reservations.csv 引用 users.csv 和 rooms.csv 的主键——如果先加载 reservations,会因为"找不到学生 / 找不到机房"导致全部预约被当作孤儿丢弃。正确顺序:users → rooms → reservations(先加载被引用方,再加载引用方)
❓ 第一步做什么? 答:先做 users.csv 的 save/load——它不依赖任何其他文件,最容易跑通整个文件 IO 链路。
# 9.2 三文件矩阵设计
| 文件 | 内容 | 列结构 | 阶段 |
|---|---|---|---|
users.csv | 所有用户 | tag,id,name,pwd | §9.3 |
rooms.csv | 所有机房 | id,capacity,spec | §9.5 |
reservations.csv | 所有预约 | resId,studentId,roomId,date,status | §9.6 |
外键关系:reservations.studentId → users.id、reservations.roomId → rooms.id
# 9.3 先跑通 users.csv
🌱 给 CampusSystem 追加方法和字段
📁 CampusSystem.h 追加:
private:
std::string usersFile = "users.csv"; // ⭐ 第一版:只一个文件名
public:
void saveAll();
void loadAll();
2
3
4
5
6
📁 CampusSystem.cpp 实现 saveAll / loadAll 第一版(只处理 users):
#include "Student.h"
#include "Teacher.h"
#include "Admin.h"
#include <fstream>
#include <sstream>
void CampusSystem::saveAll() {
// 第一版:只存 users
std::ofstream ofs(usersFile);
if (!ofs) { cout << "[Save] 打开 " << usersFile << " 失败\n"; return; }
for (const auto& [id, user] : users) {
ofs << user->toCsv() << "\n"; // ⭐ 多态:每个子类按自己格式输出
}
cout << "[Save] 已保存 " << users.size() << " 个用户到 " << usersFile << "\n";
}
void CampusSystem::loadAll() {
// 第一版:只加载 users
std::ifstream ifs(usersFile);
if (!ifs) { cout << "[Load] 文件不存在: " << usersFile << "(首次启动正常)\n"; return; }
std::string line;
int count = 0;
while (std::getline(ifs, line)) {
if (line.empty()) continue;
// 解析 CSV:tag,id,name,pwd
std::stringstream ss(line);
std::string tag, id, name, pwd;
std::getline(ss, tag, ',');
std::getline(ss, id, ',');
std::getline(ss, name, ',');
std::getline(ss, pwd, ',');
// ⭐ 根据 tag 多态创建子类(这就是简易工厂模式)
std::shared_ptr<User> u;
if (tag == "S") u = std::make_shared<Student>(id, name, pwd);
else if (tag == "T") u = std::make_shared<Teacher>(id, name, pwd);
else if (tag == "A") u = std::make_shared<Admin>(id, name, pwd);
else { cout << "[Load] 未知身份标签: " << tag << "\n"; continue; }
users[id] = u;
count++;
}
cout << "[Load] 已加载 " << count << " 个用户\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
🧪 第一次编译运行(验证 users.csv 跑通)
修改 main.cpp,启动加载 + 退出保存:
int main() {
CampusSystem sys;
sys.loadAll(); // ⭐ 启动时加载
// 临时:如果首次启动(无文件)就 add 三个测试用户
if (sys.login("S001", "123") == nullptr) {
sys.addUser(make_shared<Student>("S001", "张三", "123"));
sys.addUser(make_shared<Teacher>("T001", "李老师", "456"));
sys.addUser(make_shared<Admin>("A001", "王管理员", "789"));
}
// ... 主循环(同 §5.3)...
sys.saveAll(); // ⭐ 退出前保存
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
操作:编译运行 → 登录 → 退出 → 查看生成的 users.csv:
cat users.csv
预期内容:
A,A001,王管理员,789
S,S001,张三,123
T,T001,李老师,456
2
3
✅ 看到三行数据 = save 链路通了。注意是按 id 字典序排列的——因为 users 是 map(红黑树有序)。
再次启动程序,输入 S001 / 123 登录——应该立刻成功,不需要重新 addUser。
✅ 冷启动恢复跑通 = users 持久化第一版完成。
# 9.4 toCsv 多态对比 if-else
ofs << user->toCsv() << "\n"; // ⭐ 一行通吃三种身份
如果不用多态:
// ❌ 反例
if (user->roleTag() == 'S') {
ofs << "S," << user->getId() << "," << user->getName() << "...";
} else if (user->roleTag() == 'T') {
// 重复写法
} else if (user->roleTag() == 'A') {
// 又重复
}
2
3
4
5
6
7
8
对比:未来要添加"访客 Visitor"身份时——多态版只需要 Visitor 类自己实现 toCsv(),反例版要回头改 saveAll。这就是开闭原则在持久化层的应用。
# 9.5 第二步:追加 rooms.csv
users 跑通了,现在加第二个文件——模式完全一样,所以本节会非常快。
🌱 给 CampusSystem 追加字段和扩展 save/load
📁 CampusSystem.h 追加:
private:
std::string roomsFile = "rooms.csv"; // ⭐ 第二个文件
2
📁 CampusSystem.cpp 给 saveAll 加一段 + loadAll 加一段:
void CampusSystem::saveAll() {
// ... 原有 users 保存 ...
// 新增:保存 rooms
std::ofstream ofsR(roomsFile);
for (const auto& [id, room] : rooms) {
ofsR << room.toCsv() << "\n";
}
cout << "[Save] 已保存 " << rooms.size() << " 个机房到 " << roomsFile << "\n";
}
void CampusSystem::loadAll() {
// ... 原有 users 加载 ...
// 新增:加载 rooms
std::ifstream ifsR(roomsFile);
if (!ifsR) { cout << "[Load] " << roomsFile << " 不存在\n"; return; }
std::string line;
int count = 0;
while (std::getline(ifsR, line)) {
if (line.empty()) continue;
std::stringstream ss(line);
std::string idStr, capStr, spec;
std::getline(ss, idStr, ',');
std::getline(ss, capStr, ',');
std::getline(ss, spec, ',');
rooms[std::stoi(idStr)] = Computer(std::stoi(idStr), std::stoi(capStr), spec);
count++;
}
cout << "[Load] 已加载 " << count << " 个机房\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
🧪 第二次编译运行(验证 rooms 持久化)
操作:登录 Admin → addRoom 102 / 30 / "i9" → 退出 → 重启
预期:重启后登录 Student → 选 1 浏览 → 看到 102 机房还在。
✅ rooms 持久化跑通。
# 9.6 reservations 与外键校验
reservations 比前两个复杂——它引用 users 和 rooms 的主键,加载时必须做外键校验。
🌱 关键设计:加载顺序 + 外键校验
📁 CampusSystem.h 追加:
private:
std::string resFile = "reservations.csv";
2
📁 CampusSystem.cpp 扩展 saveAll:
void CampusSystem::saveAll() {
// ... users + rooms 保存 ...
// 新增:保存 reservations
std::ofstream ofsRes(resFile);
for (const auto& r : reservations) {
ofsRes << r.resId << "," << r.studentId << ","
<< r.computerId << "," << r.date << ","
<< static_cast<int>(r.status) << "\n";
}
cout << "[Save] 已保存 " << reservations.size() << " 条预约到 " << resFile << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
⚠️ 故意演示外键陷阱(教学高潮)
我们故意先写一个错误的 loadAll——把 reservations 的加载放在 users / rooms 之前:
// ❌ 错误版本:演示加载顺序问题
void CampusSystem::loadAll() {
// 第 1 步:先加载 reservations(错!)
std::ifstream ifsRes(resFile);
std::string line;
while (std::getline(ifsRes, line)) {
// ... 解析 ...
if (users.count(studentId) == 0) { // ⚠️ users 还没加载!永远 == 0
cout << "[Load] 跳过孤儿预约 - 学生 " << studentId << " 不存在\n";
continue;
}
// ...
}
// 第 2 步:然后加载 users / rooms
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
🧪 第三次编译运行(看到 bug)
操作:先用正确顺序生成 3 个 CSV → 关闭程序 → 改成上面错误版本 → 重启
预期输出(坏的):
[Load] 跳过孤儿预约 - 学生 S001 不存在
[Load] 跳过孤儿预约 - 学生 S002 不存在
[Load] 跳过孤儿预约 - 学生 S003 不存在
[Load] 已加载 0 条预约(应该是 3 条!)
[Load] 已加载 3 个用户
2
3
4
5
🚨 数据全丢了——文件里明明有 3 条预约,但因为加载顺序错,它们全部被当作孤儿丢弃。这就是真实数据库系统每天都要应对的"约束依赖"问题。
🛠 修复:调整加载顺序
// ✅ 正确版本
void CampusSystem::loadAll() {
// 第 1 步:先加载被引用方(users + rooms)
loadUsersFromFile(usersFile);
loadRoomsFromFile(roomsFile);
// 第 2 步:再加载引用方(reservations),此时外键校验才有意义
std::ifstream ifsRes(resFile);
if (!ifsRes) return;
std::string line;
int count = 0, skipped = 0;
while (std::getline(ifsRes, line)) {
if (line.empty()) continue;
std::stringstream ss(line);
std::string ridStr, sid, cidStr, date, statusStr;
std::getline(ss, ridStr, ',');
std::getline(ss, sid, ',');
std::getline(ss, cidStr, ',');
std::getline(ss, date, ',');
std::getline(ss, statusStr, ',');
int rid = std::stoi(ridStr);
int cid = std::stoi(cidStr);
int status = std::stoi(statusStr);
// ⭐ 外键校验
if (users.count(sid) == 0) {
cout << "[Load] 跳过孤儿预约 - 学生 " << sid << " 不存在\n";
skipped++;
continue;
}
if (rooms.count(cid) == 0) {
cout << "[Load] 跳过孤儿预约 - 机房 " << cid << " 不存在\n";
skipped++;
continue;
}
Reservation r(rid, sid, cid, date);
r.status = static_cast<ResStatus>(status);
reservations.push_back(r);
// ⭐ 重建索引:Pending/Approved 的预约要把机房标记为占用
if (r.status == ResStatus::Pending || r.status == ResStatus::Approved) {
reservedRooms.insert(cid);
}
nextResId = std::max(nextResId, rid + 1);
count++;
}
cout << "[Load] 已加载 " << count << " 条预约(跳过 " << skipped << " 条孤儿)\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
🧪 第四次编译运行(验证修复)
操作:用同样的 CSV 重启程序
预期输出:
[Load] 已加载 3 个用户
[Load] 已加载 2 个机房
[Load] 已加载 3 条预约(跳过 0 条孤儿)
2
3
✅ 加载顺序修复 + 外键校验 + 索引重建——三件事一次完成。
💡 教学要点:你可能会问"为什么不一开始就给我正确版本?"——因为只有亲眼看到"加载顺序错导致数据全丢"的现场,你才会真正记住"被引用方先加载"这条铁律。这和 §6.4 故意造 bug 是同一种教学手法。
# 9.7 完整启动流程
最后把 main.cpp 改成最终版(启动加载 + 退出保存):
int main() {
CampusSystem sys;
sys.loadAll(); // ⭐ 启动加载
while (true) {
cout << "\n=== 校园系统登录 ===\n";
cout << "1. 学生 2. 教师 3. 管理员 0. 退出\n";
cout << "选择身份: ";
int role; cin >> role;
if (role == 0) {
sys.saveAll(); // ⭐ 退出前保存
cout << "数据已保存,再见!\n";
return 0;
}
string id, pwd;
cout << "账号: "; cin >> id;
cout << "密码: "; cin >> pwd;
auto u = sys.login(id, pwd);
if (!u) continue;
if (auto p = dynamic_pointer_cast<Student>(u)) p->setSystem(&sys);
else if (auto p = dynamic_pointer_cast<Teacher>(u)) p->setSystem(&sys);
else if (auto p = dynamic_pointer_cast<Admin>(u)) p->setSystem(&sys);
u->mainMenu();
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
🧪 完整闭环验证(首次跑、二次跑)
第一次启动(无任何 CSV 文件):
[Load] 文件不存在: users.csv(首次启动正常)
[Load] rooms.csv 不存在
[Load] reservations.csv 不存在
2
3
操作流程:
- 进入选择 3(管理员)登录失败 → 看到提示"账号不存在"
- 退出,手动 add 一个 Admin 账号(首次启动专用代码,可以加 if 判断 users.empty 时插入种子账号)
- 重启 → 用种子 Admin 登录 → addRoom + addUser 一通操作
- 退出 → CSV 文件全部生成
第二次启动:
[Load] 已加载 5 个用户
[Load] 已加载 2 个机房
[Load] 已加载 3 条预约(跳过 0 条孤儿)
2
3
✅ 数据完整恢复 + 多角色协同 + 持久化全部跑通 = 阶段 ⑧ 完成。
┌─ 📌 阶段 ⑧ 小结 ────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • §9.3 users.csv:先做最简单的 1 个文件,验证 IO 链路通 │
│ • §9.4 toCsv() 多态:让 saveAll 通吃三种身份,开闭原则在 │
│ 持久化层的应用 │
│ • §9.5 rooms.csv:模式复用,几乎不用思考 │
│ • §9.6 reservations.csv:故意演示加载顺序错导致数据全丢 → │
│ 修复为"先被引用方后引用方" + 外键校验 + 索引重建 │
│ • §9.7 完整启动流程:loadAll 开机 → saveAll 关机 │
│ 📊 最终的 CampusSystem.h 长成什么样: │
│ • 5 个容器:users / rooms / reservations / reservedRooms / │
│ speeches │
│ • 3 个文件名字段 │
│ • 1 个自增 nextResId │
│ • ~12 个公开方法 │
│ → 全部都是 8 个阶段渐进追加的结果,没有一行是"事先规划的" │
│ 📌 大功告成!务必: │
│ git add . && git commit -m "stage8: persistence done" │
│ 💡 本阶段最大领悟: │
│ "加载顺序就是数据库的 ACID 之 'C'(一致性)的微观体现—— │
│ 被引用方先加载,索引重建放在最后" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 9.8 八阶段 .h 演化全景
最后,回头看 CampusSystem 这个核心类是怎么从 1 字段 1 方法长成最终样子的:
阶段 ④ §5.2 ┌─ users (map)
最小骨架 │ + addUser / login → 1 容器 2 方法
│
阶段 ⑤ §6.3 │ + rooms (map)
listRooms │ + listRooms → 2 容器 3 方法
│
阶段 ⑤ §6.4 │ + reservations (vector) + nextResId
reserveRoom │ + reservedRooms (set) ← bug 修复后追加
│ + reserveRoom → 4 容器 4 方法
│
阶段 ⑤ §6.5 │ + cancelReservation(只追加方法) → 4 容器 5 方法
│
阶段 ⑤ §6.6 │ + speeches (multimap)
signupSpeech │ + signupSpeech → 5 容器 6 方法
│
阶段 ⑥ §07 │ + listPendingReservations / reviewReservation
Teacher │ + scoreSpeech / rankSpeechesByScore → 5 容器 10 方法
│
阶段 ⑦ §08 │ + addRoom / statistics → 5 容器 12 方法
Admin │
│
阶段 ⑧ §09 │ + usersFile / roomsFile / resFile
持久化 │ + saveAll / loadAll → 5 容器 + 3 字符串 + 14 方法
└─
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这就是真实工程师写一个管理器类的节奏。绝不是"打开 IDE 直接定义 5 个 STL 容器 + 14 个方法"——而是 8 个阶段、每个阶段只追加当下业务必需的字段和方法。
🔑 如果你完整跟着做了下来,回头看你的 CampusSystem.h——里面没有一行是"用不上的"。这就是真实工程师的代码质量基线。
# 10.项目总结分析
| 模块 | 类 | 主职责 |
|---|---|---|
| 身份层 | User / Student / Teacher / Admin | 三态多态 + 身份独有菜单 |
| 实体层 | Computer / Speech / Reservation | 数据载体,无业务逻辑 |
| 业务层 | CampusSystem | 持有所有 STL 容器 + 业务方法 |
| 持久层 | FileStore | 三文件矩阵 + 外键校验 |
| 入口层 | main + login | 登录分发 + 多态调用 |
STL 用量统计:
map:2 处(用户表、机房表)multimap:1 处(演讲报名)set:1 处(已被预约机房)vector:1 处(预约记录)find_if/count_if/accumulate/sort:算法库 4 个核心函数- lambda:6+ 处(比较器 / 谓词 / 累加器)
# 11.项目技术思考
# 11.1 map 选型对比
| 维度 | map | unordered_map |
|---|---|---|
| 底层 | 红黑树 | 哈希表 |
| 查找 | O(log n) | O(1) 均摊 |
| 顺序 | 按 key 有序 | 无序 |
| 哈希冲突 | 无此概念 | 有,最坏 O(n) |
| 选用 | 需要"有序遍历"或"范围查询" | 只关心存在性和快速查询 |
本案例为什么全用 map:机房列表要按编号有序显示给用户、用户表也常按字典序查看——有序场景选 map,纯查询场景选 unordered_map。
# 11.2 multimap 与多值 map
两者功能可以互相替代,但侧重不同:
multimap<K, V>:每个 (K, V) 单独存储,迭代时每个 entry 是 pair。适合"按 key 分组遍历"。map<K, vector<V>>:每个 key 对应一个 vector。适合"vector 内还要做计算"(排序、去重、求和)。
本案例 rankSpeechesByScore 需要把同 round 的演讲单独排序——其实 map<int, vector<Speech>> 会更合适!这就是教学设计的"故意陷阱",请在挑战 A 中改造它。
# 11.3 智能指针选型
| 关系 | 推荐 |
|---|---|
| User 拥有自己的字段(name/pwd) | 值类型即可 |
| CampusSystem 拥有 User | shared_ptr<User>(map 中存这个) |
| User 引用回 CampusSystem | 裸指针 CampusSystem*(避免循环引用!) |
记住这条铁律:"拥有关系用智能指针,引用关系用裸指针"——否则 shared_ptr 循环引用,永远释放不掉。这就是为什么 C++ 还有个 weak_ptr(卷三再讲)。
# 12.衔接与延伸
# 12.1 与上一案例的差异
| 维度 | 案例 02 银行 | 案例 03 校园 |
|---|---|---|
| 身份多态 | 1 类业务 3 子类 | 3 类身份 + 3 实体 |
| 容器 | vector<Account*> | map / multimap / set / vector 全家桶 |
| lambda | 0 个 | 6+ 个(比较器/谓词/累加器) |
| 文件 | 1 个 CSV | 3 个 CSV + 外键校验 |
| 智能指针 | 仍用裸指针 | shared_ptr<User> 过渡 |
# 12.2 下一案例的递进
下一案例 04.JSON与内存数据库 会做四件升级:
- CSV → JSON:嵌套数据结构、
std::variant表达多态字段 - 裸指针 → unique_ptr:全面 RAII,告别手动 delete
- 错误码 → 异常体系:自定义异常类继承
std::runtime_error - string → string_view:解析时零拷贝,性能起飞
# 12.3 三个延伸挑战
挑战 A(基础)· 用 map<int, vector<Speech>> 替代 multimap<int, Speech>
按 11.2 的讨论改造 signupSpeech 和 rankSpeechesByScore,对比两种实现的可读性和性能。
挑战 B(进阶)· 加一种 Visitor(访客)身份
只能登录浏览机房和演讲列表,不能预约或评分。验证你是否能"不动现有代码,只新增子类"。
挑战 C(现代化)· 用 std::function<void()> 实现菜单动作表
把 mainMenu() 里的大 switch 改造为:
std::map<int, std::function<void()>> actions = {
{1, [&]{ sys->listRooms(); }},
{2, [&]{ /* ... */ }},
};
// 主循环:
int op; cin >> op;
if (auto it = actions.find(op); it != actions.end()) it->second();
2
3
4
5
6
7
这就是命令模式的 lambda 版本——案例 06 的 KV 引擎大量使用这种模式。
- ⬅ 上一案例:02.银行账户管理系统 (opens new window)
- ➡ 下一案例:04.JSON与内存数据库 —— 现代内存模型 + 智能指针 + 异常体系