编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

      • README
      • 学生管理通讯录系统
      • 银行账户管理系统
      • 校园身份预约系统
        • 渐进学习节奏
        • 案例元信息
          • 项目结构
          • 一条命令编译运行
        • 目录快速导航
        • 01.项目需求和功能
          • 1.1 需求介绍
          • 1.2 三类用户的功能矩阵
          • 1.3 身份驱动容器分工
          • 1.4 涉及知识点
        • 02.菜单与登录框架
          • 2.1 创建项目空文件
          • 2.2 登录骨架做什么
          • 2.3 让程序能跑能退
        • 03.User 抽象身份基类
          • 3.1 灵魂三问:为什么要抽象基类?
          • 3.2 User.h 头文件
          • 3.3 先写一个 Student
          • 3.4 再写 Teacher / Admin
          • 3.5 roleTag 为何用 char
        • 04.实体类设计
          • 4.1 灵魂三问:实体类要存什么?
          • 4.2 实体一:Computer
          • 4.3 实体二:Speech 与 enum
          • 4.4 实体三:Reservation 外键
        • 05.CampusSystem 总管理器
          • 5.1 灵魂三问:先做什么
          • 5.2 第一版:最小骨架
          • 5.3 接入 main 让登录工作
          • 🧪 立刻编译运行(阶段 ④ 第一次验收)
          • 5.4 渐进追加 Student 字段
          • 5.5 容器选型灵魂指引
        • 06.Student 业务展开
          • 6.1 灵魂三问
          • 6.2 打通 Student 管道
          • 6.3 listRooms 与范围 for
          • 6.4 reserveRoom 容器同步
          • 6.5 cancelReservation 实现
          • 6.6 signupSpeech 与 multimap
        • 07.Teacher 业务
          • 7.1 灵魂三问:与 Student 区别
          • 7.2 打通 Teacher 管道
          • 7.3 业务一:审核预约
          • 7.4 业务二:演讲评分
          • 7.5 业务三:lambda 排名
          • 7.6 STL 算法函数全景速查
        • 08.Admin 业务
          • 8.1 灵魂三问:Admin 特殊性
          • 8.2 打通 Admin 管道
          • 8.3 业务一:添加机房
          • 8.4 业务二:复用 addUser
          • 8.5 业务三:accumulate 统计
        • 09.多文件持久化 FileStore
          • 9.1 灵魂三问:为何拆三文件
          • 9.2 三文件矩阵设计
          • 9.3 先跑通 users.csv
          • 9.4 toCsv 多态对比 if-else
          • 9.5 第二步:追加 rooms.csv
          • 9.6 reservations 与外键校验
          • 9.7 完整启动流程
          • 9.8 八阶段 .h 演化全景
        • 10.项目总结分析
        • 11.项目技术思考
          • 11.1 map 选型对比
          • 11.2 multimap 与多值 map
          • 11.3 智能指针选型
        • 12.衔接与延伸
          • 12.1 与上一案例的差异
          • 12.2 下一案例的递进
          • 12.3 三个延伸挑战
      • Json与内存数据库
      • 订单票务购买系统
      • 迷你KV存储引擎器
      • 迷你编译器解释器
    • 专栏博客

    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 综合案例
杨充
2026-05-25
目录

校园身份预约系统

# 第三章: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 闭环
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
32
33
34
35
36
37
38
39

每个 Step 必须做的三件事:

  1. 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
  2. 写一小段代码就编译运行一次(看到 标志立刻动手)
  3. 看到预期输出再写下一个 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
1
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
1
2
3

# 目录快速导航

点击以下条目即可跳转到对应节。【🔑 重点节】推荐优先阅读,⭐ 表示本案高光段落。

  • 01.项目需求和功能
    • 1.1 需求介绍
    • 1.2 三类用户的功能矩阵
    • 1.3 身份驱动容器分工
    • 1.4 涉及知识点
  • 02.菜单与登录框架 【阶段①骨架】
    • 2.1 创建项目空文件
    • 2.2 登录骨架做什么
    • 2.3 让程序能跑能退
  • 03.User 抽象身份基类 【阶段②抽象】
    • 3.1 灵魂三问:为什么要抽象基类?
    • 3.2 User.h 头文件
    • 3.3 先写一个 Student
    • 3.4 再写 Teacher / Admin
    • 3.5 roleTag 为何用 char
  • 04.实体类设计 【阶段③数据模型】
    • 4.1 灵魂三问:实体类要存什么?
    • 4.2 实体一:Computer
    • 4.3 实体二:Speech 与 enum
    • 4.4 实体三:Reservation 外键
  • 05.CampusSystem 总管理器 【阶段④骨架·渐进生长⭐】
    • 5.1 灵魂三问:先做什么
    • 5.2 第一版:最小骨架
    • 5.3 接入 main 让登录工作
    • 5.4 渐进追加 Student 字段
    • 5.5 容器选型灵魂指引
  • 06.Student 业务展开 【阶段⑤高峰·故意造bug⭐】
    • 6.1 灵魂三问
    • 6.2 打通 Student 管道
    • 6.3 listRooms 与范围 for
    • 6.4 reserveRoom 容器同步
    • 6.5 cancelReservation 实现
    • 6.6 signupSpeech 与 multimap
  • 07.Teacher 业务 【阶段⑥STL 算法⭐】
    • 7.1 灵魂三问:与 Student 区别
    • 7.2 打通 Teacher 管道
    • 7.3 业务一:审核预约
    • 7.4 业务二:演讲评分
    • 7.5 业务三:lambda 排名
    • 7.6 STL 算法函数全景速查
  • 08.Admin 业务 【阶段⑦三角色闭环】
    • 8.1 灵魂三问:Admin 特殊性
    • 8.2 打通 Admin 管道
    • 8.3 业务一:添加机房
    • 8.4 业务二:复用 addUser
    • 8.5 业务三:accumulate 统计
  • 09.多文件持久化 FileStore 【阶段⑧最终阶段】
    • 9.1 灵魂三问:为何拆三文件
    • 9.2 三文件矩阵设计
    • 9.3 先跑通 users.csv
    • 9.4 toCsv 多态对比 if-else
    • 9.5 第二步:追加 rooms.csv
    • 9.6 reservations 与外键校验
    • 9.7 完整启动流程
    • 9.8 八阶段 .h 演化全景
  • 10.项目总结分析
  • 11.项目技术思考
    • 11.1 map 选型对比
    • 11.2 multimap 与多值 map
    • 11.3 智能指针选型
  • 12.衔接与延伸
    • 12.1 与上一案例的差异
    • 12.2 下一案例的递进
    • 12.3 三个延伸挑战

# 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 分钟                                           │
│ 关键思路:先打通"输入循环"管道,让程序能跑、能退               │
└──────────────────────────────────────────────────┘
1
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
1
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;
}
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
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
1
2

操作:选 1 学生 → 输入账号 S001 密码 123 → 看到占位日志 → 再次出现登录界面 → 选 0 退出

预期输出:

=== 校园系统登录 ===
1. 学生  2. 教师  3. 管理员  0. 退出
选择身份: 1
账号: S001
密码: 123
[Login] 占位:模拟登录成功 - role=1 id=S001
再见!
1
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 . &amp;&amp; git commit -m "stage1: login skeleton"      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

# 03.User 抽象身份基类

┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:抽象出 User 基类 + 三个派生类的最小骨架            │
│ 不做什么:不写业务逻辑(mainMenu 内只放占位 cout)            │
│ 验收标准:login 能 make_shared 出三种身份 → 调用 mainMenu 不崩 │
│ 预计耗时:60 分钟                                           │
│ 关键思路:先写 User.h 让程序能编译过 → 再加一个子类编译跑通     │
│           → 再加另一个子类——绝不一口气写三个                  │
└──────────────────────────────────────────────────┘
1
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++ 不允许函数返回多种类型
}
1
2
3
4
5
6
7
8
9

问题暴露:

  1. id/name/pwd 三个字段在三个类里重复 3 遍(违反 DRY)
  2. login() 函数没法写返回类型——C++ 必须有统一的返回类型
  3. 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;
    }
};
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
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; }
};
1
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";
}
1
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;
}
1
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
1
2

操作:选 1 → 输入 S001 密码 123

预期输出:

=== 校园系统登录 ===
1. 学生  2. 教师  3. 管理员  0. 退出
选择身份: 1
账号: S001
密码: 123

--- 学生 学生S001 已登录(占位菜单,阶段 ④ 实现)---

=== 校园系统登录 ===   ← 注意:因为 mainMenu 不带循环,立刻回到登录界面
...
1
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";
}
1
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);
}
1
2
3
4
5

🧪 第二次编译运行(验证三态多态)

g++ -std=c++17 main.cpp Student.cpp Teacher.cpp Admin.cpp -o campus_system
./campus_system
1
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
再见!
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

多态分发完整跑通:🎉 同一行 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 . &amp;&amp; git commit -m "stage2: user trinity"            │
│  💡 本阶段最大领悟:                                              │
│    "三种身份的差异通过 vtable 分发,main 函数零 if-else"          │
└──────────────────────────────────────────────────┘
1
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(含外键)。每写完一个就编译验证   │
└──────────────────────────────────────────────────┘
1
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);
};
1
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{};
}
1
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,验证后再删除
1
2
3
4
5
6
g++ -std=c++17 main.cpp Student.cpp Teacher.cpp Admin.cpp Computer.cpp -o campus_system
./campus_system
1
2

预期输出:

[Test] 机房 101 容量 50 配置 i7+RTX4060
[Test] toCsv: 101,50,i7+RTX4060
1
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) {}
};
1
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 "未知";
    }
};
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

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";
1
2
3
4
5
6
7
8

预期输出:

机房: 101,50,i7+RTX4060
演讲: S001 AI伦理 round=1
预约: #1 学生 S001 机房 101 状态 待审核
1
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 . &amp;&amp; git commit -m "stage3: entities"             │
│  💡 本阶段最大领悟:                                              │
│    "实体只装数据,业务放管理器——贫血模型让代码扩展性最强"          │
└──────────────────────────────────────────────────┘
1
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 一模一样——先空壳后填肉,               │
│           容器和方法都不是事先全规划好,而是写一个业务才追加一个   │
└──────────────────────────────────────────────────┘
1
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);
};
1
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;
}
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

💡 你可能会问:为什么用 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;
}
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
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
1
2
3

操作 1:选 1 学生 → 输入 S001 → 密码 123 → 看到学生菜单占位 操作 2:选 1 学生 → 输入 S001 → 密码 wrong → 看到 "密码错误" 操作 3:选 2 教师 → 输入 S001 → 密码 123 → ❓ 能登录成功吗?

实际行为:能登录成功!但是 dynamic_pointer_cast 会返回 nullptr,setSystem 不会被调用——这是 教学故意保留的小坑。挑战 E 让你修复"身份选择和实际类型不匹配"的问题(应该在 login 里增加 role 与 roleTag 的一致性校验)。

✅ 看到登录链路通了 + 密码错误能拒绝 = 阶段 ④ 第一版完成。

# 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 一行搞定
1
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 . &amp;&amp; git commit -m "stage4: campus skeleton"          │
│  💡 本阶段最大领悟:                                              │
│    "管理器类不是事先设计出来的,是写业务时一个字段一个字段长出来的"  │
│    回头看你的 CampusSystem.h——只有 1 个 map 1 个方法              │
│    这是你目前最棒的工程习惯:永远不要提前定义你不知道怎么用的字段    │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 06.Student 业务展开

┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────────┐
│ 完成什么:让 Student 跑通 4 个业务(浏览机房/预约/取消/报名演讲)│
│ 不做什么:不做审核、不做评分、不做文件保存——那是阶段 ⑥⑦⑧ 的事 │
│ 验收标准:学生登录后能看到机房 → 预约成功 → 同一机房不能再被预约 │
│ 预计耗时:90 分钟                                              │
│ 关键思路:每加一个业务就先给 CampusSystem 追加对应字段 + 方法    │
│           编译运行 → 发现"两个容器要同步更新"的细节               │
└────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

本节是案例 03 最难的一节——你要在 §05 最小骨架(只有 1 个 users map)之上,逐步把 CampusSystem 长成 5 个容器 4 个业务方法的样子。每个业务都遵循固定节奏:

  1. CampusSystem.h 追加新字段(且只追加这一个业务必需的)
  2. CampusSystem.cpp 实现该业务方法
  3. Student.cpp 把对应 case 接通
  4. 编译运行 → 验证 → 才允许进下一个业务

如果一口气写完 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";
        }
    }
}
1
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
1
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 个方法
};
1
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";
    }
}
1
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";
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第二次编译运行(此时还没有数据,会显示"暂无机房")

操作:登录学生 → 选 1

预期输出:

=== 机房列表 ===
编号  容量    配置                状态
------------------------------------------
(暂无机房)
1
2
3
4

✅ 没机房也能显示标题 = 边界处理正确。 💡 临时塞两条假数据测试:在 CampusSystem 构造函数里临时加 rooms[101] = Computer(101, 50, "i7+RTX4060");——这个临时代码后面接上 FileStore 后会删掉,但现在能让你立刻看到表格效果,验证 setw 排版没歪。

再次运行,应该看到:

编号  容量    配置                状态
------------------------------------------
101   50      i7+RTX4060          空闲
1
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);
1
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;
}
1
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;
}
1
2
3
4
5
6
7
8
9
10

第三次编译运行 —— 故意制造 bug 让你看见。操作:登录学生 → 预约机房 101 → 再预约一次机房 101

预期输出(坏的):

[预约] 提交成功,预约号 1
[预约] 提交成功,预约号 2     ← 同一个机房被两个学生抢到,业务出错!
1
2

再选 1 浏览机房,会发现 101 显示为"空闲"——因为我们根本没维护"哪些机房已被占用"的索引!

🚨 这就是真实工程师天天遇到的 bug:业务状态没有集中表达 → 判断时只能 O(n) 遍历整个 reservations,写起来啰嗦还容易漏。

第三次给 CampusSystem 长字段(这次是 bug 逼出来的) 。bug 暴露了一个新需求:"快速判断某个机房有没有被预约"。这正是 set<int> 大显身手的场景:只关心"在不在集合里",不关心 value、O(log n) 查询。

📁 CampusSystem.h 追加:

private:
    // ... 已有字段 ...
    std::set<int> reservedRooms;        // ⭐ 第 4 个容器:已被占用的机房编号
1
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

🧪 第四次编译运行 —— 验证 bug 已修复。再做同样操作:预约 101 → 再预约 101

预期输出:

[预约] 提交成功,预约号 1(待审核)
[预约] 该机房已被预约      ← bug 已修复
1
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 个方法
1
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;
}
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

🧪 第五次编译运行 —— 验证取消后机房恢复空闲

操作:预约 101 → 取消预约号 1 → 浏览机房(应该看到 101 变"空闲")→ 再预约 101(应该成功)

预期输出:

[预约] 提交成功,预约号 1(待审核)
[取消] 已取消
(机房列表显示 101 空闲)
[预约] 提交成功,预约号 2(待审核)
1
2
3
4

💡 stop and think:这个函数动了几个数据结构?

  • reservations[i].status ← 修改 vector 中的元素状态
  • reservedRooms ← 删掉这个机房编号

两个状态必须同步改——和 reserveRoom 是镜像操作。这种"成对的状态修改"在工业代码里出现频率极高,比如订单创建/取消、库存出入库、用户关注/取消关注。

📚 lambda 表达式拆解:

[resId, &sid]              // 捕获列表:值捕获 resId,引用捕获 sid
(const Reservation& r)     // 参数:每次遍历传入的元素
{ return r.resId == ...; } // 函数体:返回 bool(谓词函数)
1
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);
1
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;
}
1
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 可以有多条
1
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 . &amp;&amp; git commit -m "stage5: student business"      │
│                                                              │
│ 💡 本阶段最大的领悟:                                             │
│   "管理器类的字段不是设计出来的,是每个业务一个一个长出来的"      │
│   这正是 §5.2 那个 1 字段 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)│
└──────────────────────────────────────────────────┘
1
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";
        }
    }
}
1
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
1
2

操作:选 2 教师 → 输入 T001 密码 456 → 看到 4 项占位菜单

预期输出:

--- 教师 李老师 ---
1. 待审预约  2. 审核预约  3. 演讲评分  4. 查看排名  0. 退出登录
1
[Teacher] listPending 占位
1
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
1
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;
}
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
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;
}
1
2
3
4
5
6
7
8

🧪 第二次编译运行(端到端验证:学生提交 → 教师审核)

操作:

  1. 登录学生 S001 → 选 2 → 预约机房 101 日期 2026-06-01 → 退出
  2. 登录教师 T001 → 选 1 → 看到一条 Pending 预约
  3. → 选 2 → 输入预约号 1 → 输入 1 通过

预期输出(教师侧):

=== 待审核预约 ===
  预约 1 | 学生 S001 | 机房 101 | 日期 2026-06-01

预约号: 1
通过(1)还是拒绝(0): 1
[审核] 预约 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
1
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";
}
1
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;
}
1
2
3
4
5
6
7
8

🧪 第三次编译运行

操作:先用学生 S001 报演讲(场景:选 4 → "AI伦理" → round 1)→ 切教师 T001 → 选 3 → 输入 S001 / 1 / 95

预期输出:

[评分] S001 第 1 轮 - 95 分
1

# 7.5 业务三:lambda 排名

🔑 本节是 lambda 排序的真正登场——前面用过 lambda 做谓词(find_if),这次用 lambda 做比较器(sort)。 🌱 给 CampusSystem 追加方法

📁 CampusSystem.h 追加:

public:
    void rankSpeechesByScore(int round) const;     // ⭐ 业务方法 4:lambda 排序高光
1
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";
    }
}
1
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;
}
1
2
3
4
5
6

🧪 第四次编译运行

操作:让多个学生报名第 1 轮 → 教师分别评分 → 查看第 1 轮排名

预期输出:

=== 第 1 轮排名 ===
  1. S003 - 量子计算 - 98 分
  2. S001 - AI伦理 - 95 分
  3. S002 - 未来教育 - 87 分
1
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 . &amp;&amp; git commit -m "stage6: teacher business"        │
│  💡 本阶段最大领悟:                                              │
│    "多角色协同的本质是视角切换,不是数据复制——同一份 reservations,│
│     学生看到的是'我的预约',教师看到的是'要审的预约'"             │
└──────────────────────────────────────────────────┘
1
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 算法收官    │
└──────────────────────────────────────────────────┘
1
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";
        }
    }
}
1
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. 退出登录
1
2

✅ Admin 多态分发链路通了。

# 8.3 业务一:添加机房

🌱 给 CampusSystem 追加方法

📁 CampusSystem.h 追加:

public:
    bool addRoom(const Computer& c);     // ⭐ Admin 业务方法
1
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;
}
1
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;
}
1
2
3
4
5
6
7
8
9

🧪 第二次编译运行(端到端验证:管理员加机房 → 学生看到 → 学生预约)

操作:

  1. 登录 A001 → 选 1 → 输入 102 / 30 / "i9+RTX5090"
  2. 退出,登录 S001 → 选 1(浏览) → 看到 102 → 选 2 预约 102

预期输出:

--- 管理员 王管理员 ---
1. 添加机房  ...
1
机房编号: 102
容量: 30
配置: i9+RTX5090
[Admin] 添加机房 102 成功

(切换学生 S001 后)
=== 机房列表 ===
编号  容量    配置                状态
------------------------------------------
101   50      i7+RTX4060          空闲
102   30      i9+RTX5090          空闲
1
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;
}
1
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
1
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";
}
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

📁 Admin.cpp 接 case 3:

case 3: sys->statistics(); break;
1

🧪 第三次编译运行(统计输出验证)

操作:先用 §06 + §07 + §8.3 + §8.4 创建多个用户/机房/预约/演讲后 → 登录 Admin → 选 3

预期输出:

=== 数据统计 ===
用户总数: 5
机房总数: 2
  学生: 3 | 教师: 1 | 管理员: 1
预约总数: 4
  待审: 1 | 已审: 3
演讲报名: 3 人次
  第 1 轮: 2 | 第 2 轮: 1
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 . &amp;&amp; git commit -m "stage7: admin business"          │
│  💡 本阶段最大领悟:                                              │
│    "STL 算法 + lambda = 没有 for 循环的代码——声明你要什么,      │
│     不是描述怎么算"                                              │
└──────────────────────────────────────────────────┘
1
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 + 外键校验│
└──────────────────────────────────────────────────┘
1
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
1
2
3
4

问题:

  1. 每行字段类型完全不同,列定义膨胀——为了适配最长的行,每行都得有 12 列
  2. 空字段一大堆——一行 USER 后面 7 个字段全空
  3. 解析时要先看 TYPE 字段决定后续含义——逻辑分支爆炸
  4. 一个文件 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();
1
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";
}
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
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

操作:编译运行 → 登录 → 退出 → 查看生成的 users.csv:

cat users.csv
1

预期内容:

A,A001,王管理员,789
S,S001,张三,123
T,T001,李老师,456
1
2
3

✅ 看到三行数据 = save 链路通了。注意是按 id 字典序排列的——因为 users 是 map(红黑树有序)。

再次启动程序,输入 S001 / 123 登录——应该立刻成功,不需要重新 addUser。

✅ 冷启动恢复跑通 = users 持久化第一版完成。

# 9.4 toCsv 多态对比 if-else

ofs << user->toCsv() << "\n";   // ⭐ 一行通吃三种身份
1

如果不用多态:

// ❌ 反例
if (user->roleTag() == 'S') {
    ofs << "S," << user->getId() << "," << user->getName() << "...";
} else if (user->roleTag() == 'T') {
    // 重复写法
} else if (user->roleTag() == 'A') {
    // 又重复
}
1
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";    // ⭐ 第二个文件
1
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";
}
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
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";
1
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";
}
1
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
    // ...
}
1
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 个用户
1
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";
}
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
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 条孤儿)
1
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;
}
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

🧪 完整闭环验证(首次跑、二次跑)

第一次启动(无任何 CSV 文件):

[Load] 文件不存在: users.csv(首次启动正常)
[Load] rooms.csv 不存在
[Load] reservations.csv 不存在
1
2
3

操作流程:

  1. 进入选择 3(管理员)登录失败 → 看到提示"账号不存在"
  2. 退出,手动 add 一个 Admin 账号(首次启动专用代码,可以加 if 判断 users.empty 时插入种子账号)
  3. 重启 → 用种子 Admin 登录 → addRoom + addUser 一通操作
  4. 退出 → CSV 文件全部生成

第二次启动:

[Load] 已加载 5 个用户
[Load] 已加载 2 个机房
[Load] 已加载 3 条预约(跳过 0 条孤儿)
1
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 . &amp;&amp; git commit -m "stage8: persistence done"        │
│  💡 本阶段最大领悟:                                              │
│    "加载顺序就是数据库的 ACID 之 'C'(一致性)的微观体现——        │
│     被引用方先加载,索引重建放在最后"                             │
└──────────────────────────────────────────────────┘
1
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 方法
            └─
1
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与内存数据库 会做四件升级:

  1. CSV → JSON:嵌套数据结构、std::variant 表达多态字段
  2. 裸指针 → unique_ptr:全面 RAII,告别手动 delete
  3. 错误码 → 异常体系:自定义异常类继承 std::runtime_error
  4. 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();
1
2
3
4
5
6
7

这就是命令模式的 lambda 版本——案例 06 的 KV 引擎大量使用这种模式。


  • ⬅ 上一案例:02.银行账户管理系统 (opens new window)
  • ➡ 下一案例:04.JSON与内存数据库 —— 现代内存模型 + 智能指针 + 异常体系
上次更新: 2026/06/10, 11:13:41
银行账户管理系统
Json与内存数据库

← 银行账户管理系统 Json与内存数据库→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式