编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

      • README
      • 学生成绩管理系统
        • 渐进学习节奏
        • 案例元信息
        • 目录快速导航
        • 01.系统需求
          • 1.1 需求介绍
          • 1.2 功能要求
          • 1.3 涉及知识点
        • 02.菜单骨架
          • 2.1 第一个 main
          • 2.2 主菜单 while 循环
          • 2.3 退出分支
        • 03.数据结构定义
          • 3.0 灵魂三问
          • 3.1 三个并行数组
          • 3.2 初始化假数据
        • 04.添加学生
          • 4.1 实现 addStudent
          • 4.2 测试添加
          • 4.3 字符串相等陷阱
        • 05.删除学生
          • 5.1 末尾填洞法
          • 5.2 测试删除
        • 06.查找学生
          • 6.1 -1 哨兵约定
          • 6.2 测试查找
        • 07.修改成绩
          • 7.1 实现 updateScore
          • 7.2 测试修改
        • 08.统计计算
          • 8.0 灵魂三问
          • 8.1 单人总分平均分
          • 8.2 单科平均分
          • 8.3 找全班第一名
          • 8.4 单科及格人数
        • 09.排序与展示
          • 9.1 手写冒泡排序
          • 9.2 同步交换陷阱
          • 9.3 表格化输出 + 可变参数
        • 10.输入校验
          • 10.0 灵魂三问
          • 10.1 readInt 工具方法
          • 10.2 重载 readDouble 与 readNonEmptyString
          • 10.3 主菜单全部接入
        • 11.项目总结分析
          • 11.1 代码整体结构
          • 11.2 核心原理
          • 11.3 优缺点分析
        • 12.项目技术思考
          • 12.1 为何不用 class
          • 12.2 卷一章节回扣表
        • 13.衔接与延伸
          • 13.1 与下一案例的差异
          • 13.2 三个延伸挑战
      • 银行账户管理系统
      • 校园身份预约系统
      • Json与内存数据库
      • 订单票务购买系统
      • 迷你KV存储引擎器
    • 专栏博客

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 综合案例
杨充
2026-05-28
目录

学生成绩管理系统

# 第一章:Java 学生成绩管理系统

本章是综合案例的第一关——用入门教程第 1-6 章(基础语法 / 数据类型 / 运算符 / 字符串和数组 / 流程语句 / 函数方法)完成一个菜单驱动的成绩管理系统。代码故意保留 "过程式 Java" 风格——static 方法 + 并行数组,是为了在第 02 案例"银行账户管理"里看到class 化 + 多态后的同一问题,体会封装、继承、异常体系带来的价值。

学习方式:本案例完全面向小白,按"功能拆分 → 写代码 → 跑日志 → 看原理 → 避陷阱"五步法循环。每个功能 30 分钟内可完成,全部 6 个阶段 3 小时跑通。建议边读边敲,不要复制粘贴——肌肉记忆是新手最有效的学习方式。


# 渐进学习节奏

先读这段,再开始敲代码!本案例严格按照真实工程师的开发节奏推进,不会一上来甩你 350 行代码让你抄。我们的节奏是这样的:

阶段 ① 跑通 Hello(02 节)
   └ Step 1.1: main + Scanner → 编译 → 跑通
   └ Step 1.2: 主菜单 while + switch → 编译 → 选 0 能退出
   └ Step 1.3: 7 个菜单项打 TODO 占位

阶段 ② 数据结构定义(03 节)
   └ Step 2.1: 三个并行数组 + count 字段
   └ Step 2.2: initData() 灌假数据 → 编译 → 看到 5 行学生

阶段 ③ 增删查改(04-07 节)
   └ Step 3.1: addStudent → 编译 → 加一个看 count
   └ Step 3.2: deleteStudent → 末尾填洞法
   └ Step 3.3: findStudent → -1 sentinel 约定
   └ Step 3.4: updateScore → 命中下标改

阶段 ④ 统计计算(08 节)
   └ Step 4.1~4.5: 5 个统计方法

阶段 ⑤ 排序与展示(09 节)
   └ Step 5.1~5.5: 冒泡 + 并行数组同步交换

阶段 ⑥ 输入校验(10 节)
   └ Step 6.1~6.4: readInt/readDouble 健壮性收尾
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

🎯 每个 Step 必须做的三件事:

  1. 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
  2. 写一小段代码就编译运行一次(看到 ✏️ 标志立刻动手)
  3. 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)

⚠️ 新手最容易犯的错:抄完 350 行代码 → 编译报 50 个错 → 不知道从哪开始查 → 放弃。每加 30 行就编译一次——错了你就只需要查这 30 行,定位极快。

🎯 本案例的三处"灵魂三问"(动手前先想清楚):

  • §03 数据结构定义前:为什么不用 class?为什么三个并行数组而不是二维数组?为什么 MAX_STUDENTS = 50 写成 static final?
  • §08 统计计算前:为什么平均分用 double 不用 int?为什么循环里要用 ++count 而不是 count = count + 1?为什么及格判断要用 >= 60 而不是 > 59?
  • §10 输入校验前:为什么不用 try-catch(剧透:第 10 章才学)?Scanner 用 nextLine 不用 nextInt?为什么用包装类 Integer.parseInt 解析?

⚠️ 本案例的两处"陷阱预警"(新手最容易踩,亲眼看一次记一辈子):

  • §04 字符串相等比较:name1 == name2 不是 name1.equals(name2) —— 这是 Java 新手 90% 都会踩的坑
  • §09 排序时丢字段:只交换 scores[][] 不同步交换 names[] 和 ids[] —— 数据立刻错乱

# 案例元信息

项目 说明
难度 ★☆☆☆☆(入门第一站)
预估时长 3 小时(跟打 1.5h + 自测 1.5h)
前置章节 入门第 1 章 基础语法、第 2 章 数据类型、第 3 章 运算符、第 4 章 字符串和数组、第 5 章 流程语句、第 6 章 函数方法
覆盖知识点 static final、基本类型、String.equals、二维数组、switch 字符串、while/for、静态方法 + 重载 + 可变参数、Scanner、String.format
最终产物 单一可执行 StudentScore.class,控制台菜单驱动
代码规模 约 350 行 / 单文件 StudentScore.java
JDK 版本 JDK 17(兼容 JDK 11/21)

命名说明:本案例主线是"学生成绩管理"(添加 / 显示 / 删除 / 查找 / 修改 / 统计 / 排序)。如果你更关心"通讯录",可以把字段 chinese/math/english 换成 phone/address,其他逻辑完全相同。


# 目录快速导航

点击以下条目即可跳转到对应节。【🔑 重点节】推荐优先阅读。

  • 渐进学习节奏 【🔑 必读】
  • 案例元信息
  • 01.系统需求
    • 1.1 需求介绍
    • 1.2 功能要求
    • 1.3 涉及知识点
  • 02.菜单骨架 【阶段①骨架】
    • 2.1 第一个 main
    • 2.2 主菜单 while 循环
    • 2.3 退出分支
  • 03.数据结构定义 【阶段②数据】
    • 3.0 灵魂三问
    • 3.1 三个并行数组
    • 3.2 初始化假数据
  • 04.添加学生 【阶段③CRUD】
    • 4.1 实现 addStudent
    • 4.2 测试添加
    • 4.3 字符串相等陷阱 【⚠️ 陷阱】
  • 05.删除学生
    • 5.1 末尾填洞法
    • 5.2 测试删除
  • 06.查找学生
    • 6.1 -1 哨兵约定
    • 6.2 测试查找
  • 07.修改成绩
    • 7.1 实现 updateScore
    • 7.2 测试修改
  • 08.统计计算 【阶段④】
    • 8.0 灵魂三问
    • 8.1 单人总分平均分
    • 8.2 单科平均分
    • 8.3 找全班第一名
    • 8.4 单科及格人数
  • 09.排序与展示 【阶段⑤】
    • 9.1 手写冒泡排序
    • 9.2 同步交换陷阱 【⚠️ 陷阱】
    • 9.3 表格化输出 + 可变参数
  • 10.输入校验 【阶段⑥】
    • 10.0 灵魂三问
    • 10.1 readInt 工具方法
    • 10.2 重载 readDouble 与 readNonEmptyString
    • 10.3 主菜单全部接入
  • 11.项目总结分析
    • 11.1 代码整体结构
    • 11.2 核心原理
    • 11.3 优缺点分析
  • 12.项目技术思考
    • 12.1 为何不用 class
    • 12.2 卷一章节回扣表
  • 13.衔接与延伸
    • 13.1 与下一案例的差异
    • 13.2 三个延伸挑战

# 01.系统需求

# 1.1 需求介绍

本系统是一个班级成绩管理工具,单文件、单进程、内存数据——开机即运行、关机即丢数据(持久化是 02 案例的事)。它能记录最多 50 个学生的语文 / 数学 / 英语三科成绩,提供菜单驱动的增删查改和常用统计。

# 1.2 功能要求

代码的主要功能是管理一个学生成绩表,支持以下操作:

  1. 添加学生:用户输入学号、姓名、三科成绩,加入成绩表
  2. 显示学生:表格化打印所有学生
  3. 删除学生:按学号删除(用末尾填洞法避免大量移动)
  4. 查找学生:按学号查 → 返回索引 / -1
  5. 修改成绩:按学号 + 科目编号修改单科分数
  6. 统计计算:单人总分 / 单人平均 / 单科平均 / 全班第一名 / 单科及格人数
  7. 排序:按总分降序排
  8. 退出程序

# 1.3 涉及知识点

入门章节 知识点 在本案例的落地
第 1 章 package / import / static final MAX_STUDENTS = 50 常量、Scanner 全局对象
第 2 章 int / double / String 基本类型 id / 分数 / 姓名
第 2 章 Integer.parseInt / Double.parseDouble 解析用户输入字符串
第 3 章 算术 / 比较 / 逻辑运算符 总分 / 及格判断 / 范围校验
第 4 章 String.equals / String.format 姓名比较 / 表格对齐
第 4 章 二维数组 double[][] 班级三科分数表
第 5 章 switch 字符串、while、增强 for 菜单分发、主循环、遍历
第 6 章 静态方法 + 重载 + 可变参数 业务方法、readInt(String) 与 readInt(String, int, int)

# 02.菜单骨架

┌─ 🎯 阶段 ① 目标 ────────────────────────────────────────┐
│ 完成什么:能跑出 7 项菜单 + 选 0 能退出                    │
│ 不做什么:不写任何业务(全是 TODO 占位)                   │
│ 验收标准:编译通过 → 看到菜单 → 选 1~6 看到占位 → 选 0 退出 │
│ 预计耗时:30 分钟                                          │
│ 关键思路:先把骨架立起来,业务后面再填                      │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7

🔑 为什么先写菜单?因为菜单是所有功能的入口——没有菜单就没法触发任何业务测试。先把 "main → switch → 业务方法" 这条管道立起来,后面填业务逻辑就有地方挂载了。

# 2.1 第一个 main

🎯 Step 1.1:写空 main + Scanner,跑通主流程。

新建文件 StudentScore.java:

import java.util.Scanner;

public class StudentScore {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.println("学生成绩管理系统启动");
        sc.close();
    }
}
1
2
3
4
5
6
7
8
9

✏️ 立刻编译运行:

javac StudentScore.java
java StudentScore
1
2

预期输出:

学生成绩管理系统启动
1

✅ 看到这行就 OK。验证了 JDK 安装、编译命令、Scanner 导入 三个最基础的事。 ❌ 如果报 command not found: javac,是 JDK 没装;如果报 cannot find symbol Scanner,是漏了 import java.util.Scanner;。

# 2.2 主菜单 while 循环

🎯 Step 1.2:加菜单显示 + while + switch 字符串分发。

把 main 替换为:

public static void main(String[] args) {
    Scanner sc = new Scanner(System.in);

    while (true) {
        showMenu();
        String choice = sc.nextLine().trim();      // 注意:用 nextLine 不用 nextInt
        switch (choice) {
            case "1": System.out.println("[TODO] 添加学生"); break;
            case "2": System.out.println("[TODO] 显示学生"); break;
            case "3": System.out.println("[TODO] 删除学生"); break;
            case "4": System.out.println("[TODO] 查找学生"); break;
            case "5": System.out.println("[TODO] 修改成绩"); break;
            case "6": System.out.println("[TODO] 统计计算"); break;
            case "7": System.out.println("[TODO] 排序展示"); break;
            case "0": System.out.println("再见 👋"); sc.close(); return;
            default : System.out.println("无效选项,请输入 0-7");
        }
    }
}

static void showMenu() {
    System.out.println();
    System.out.println("=========== 学生成绩管理 ===========");
    System.out.println("  1. 添加学生");
    System.out.println("  2. 显示学生");
    System.out.println("  3. 删除学生");
    System.out.println("  4. 查找学生");
    System.out.println("  5. 修改成绩");
    System.out.println("  6. 统计计算");
    System.out.println("  7. 排序展示");
    System.out.println("  0. 退出");
    System.out.println("===================================");
    System.out.print("请选择: ");
}
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

✏️ 立刻编译运行:

javac StudentScore.java && java StudentScore
1

依次输入 1、2、9、0:

=========== 学生成绩管理 ===========
  1. 添加学生
  ... 7. 排序展示
  0. 退出
===================================
请选择: 1
[TODO] 添加学生
请选择: 9
无效选项,请输入 0-7
请选择: 0
再见 👋
1
2
3
4
5
6
7
8
9
10
11

✅ 7 个分支 + default + 0 退出全跑通。

🔑 为什么 String choice = sc.nextLine() 不用 int choice = sc.nextInt()?因为 nextInt() 读到非数字会抛 InputMismatchException,新手都不会处理;而 nextLine() 永远拿一行字符串,再用 equals 比较——永远不崩溃。这是 Java 命令行最稳的输入套路,也是 §10 输入校验工具方法的根基。

🔑 为什么 switch 直接 case "1" 字符串而不是 1 数字?JDK 7 起 switch 支持字符串(入门第 5 章重点),用字符串配合 nextLine() 可以把"输入解析"和"分支判断"统一在一个层级。

# 2.3 退出分支

🎯 Step 1.3:阶段①小结。

退出已经在 case "0" 里:sc.close(); return;——return 让 main 方法结束,整个程序退出。注意 return 必须在 sc.close() 之后,否则编译器会警告"unreachable code"或者实际未关闭。

┌─ 📌 阶段 ① 小结 ────────────────────────────────────────┐
│ ✅ 你已经完成的:                                          │
│   • Java 单文件项目最小骨架(main + import)               │
│   • Scanner.nextLine + switch 字符串分发                   │
│   • 7 业务 + default + 0 退出共 9 条分支跑通                │
│                                                            │
│ ⏸ 还没做的(阶段 ② 见):                                  │
│   • 三个并行数组(names/ids/scores)数据结构                │
│   • 6 大业务方法                                            │
│                                                            │
│ 📌 进入下阶段前务必:                                       │
│   git add . && git commit -m "stage1: menu skeleton"       │
│                                                            │
│ 💡 此刻的领悟:                                             │
│   "Java 第一行代码不是 hello world,而是 main + Scanner +    │
│    while + switch 这条菜单管道——立起来再填业务"             │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 03.数据结构定义

┌─ 🎯 阶段 ② 目标 ────────────────────────────────────────┐
│ 完成什么:定义全局数据 + 灌 5 条假数据                     │
│ 不做什么:不实现增删查改(阶段 ③ 才做)                    │
│ 验收标准:编译通过 + 选 2 能看到 5 个内置学生               │
│ 预计耗时:30 分钟                                          │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6

# 3.0 灵魂三问

🎯 Step 2.0:敲键盘前先想清楚要存什么。

❓ 问题一:为什么不用 class Student?

答:因为入门第 7 章(类和对象)才学 class。本案例只用第 1-6 章的内容,所以必须用"过程式 Java"风格——static 字段 + static 方法。这正是给 02 案例制造升级动机的伏笔——你会在 02 亲眼看到"同一份逻辑,class 化之后到底好在哪"。

来看反例 —— "新手以为"必须有 class":

// ❌ 反例:新手第一反应
class Student {
    String name;
    int id;
    double[] scores;
}
Student[] students = new Student[50];
1
2
3
4
5
6
7

短期能跑,但是问题来了:

  1. 第 7 章构造方法 / 第 8 章继承多态都还没学 —— 你写了 class 也只是"带字段的 struct",价值发挥不出来
  2. 没机会体会"过程式 vs OOP"的对比 —— 02 案例升级时你感觉不到落差
  3. 学习节奏混乱 —— 入门教程明明只到第 6 章,你跨章使用知识点 = 知识盲区

✅ 正确做法:严格按教程章节渐进。第 1-6 章只有"基本类型 + 数组 + 静态方法",所以本案例就用这三件事,刻意把 OOP 的香味留给 02 案例去揭晓。

❓ 问题二:为什么三个并行数组而不是一个二维数组?

来看反例 —— "看上去更紧凑"的二维数组:

// ❌ 反例:所有数据塞一个 String[][]
String[][] data = new String[50][5];   // [姓名, 学号, 语文, 数学, 英语]
1
2

问题:

  1. 类型不统一 —— 学号是 int、分数是 double、姓名是 String,全塞 String[][] 要每次 Integer.parseInt、Double.parseDouble,性能浪费 + 容易出错
  2. 取值代码丑 —— Double.parseDouble(data[i][2]) 比 scores[i][0] 难读 10 倍
  3. 统计计算时各种类型转换 —— 平均分要 parseDouble 求和再除以 3

✅ 正确做法:类型不同的字段用独立数组——

static int[]      ids    = new int[MAX_STUDENTS];      // 学号 int
static String[]   names  = new String[MAX_STUDENTS];   // 姓名 String
static double[][] scores = new double[MAX_STUDENTS][3]; // 三科分数 double
1
2
3

这种"类型分开 + 用同一下标 i 关联"的做法叫并行数组(parallel arrays)。它是 OOP 之前最经典的数据组织方式——写一遍并行数组的痛,你才能在 02 案例的 class Account 里感受到对象封装的甜。

❓ 问题三:为什么 MAX_STUDENTS = 50 必须 static final?

写法 编译期常量 可被修改 内存
int MAX_STUDENTS = 50(成员) ❌ ✅ 任意改 实例字段
static int MAX_STUDENTS = 50(类变量) ❌ ✅ 任意改 类静态区
✅ static final int MAX_STUDENTS = 50 ✅ ❌ 编译期不可改 类静态区

static 表示"类级别共享"(不需要 new 一个对象就能用);final 表示"赋值后不可改"。两个加一起 = Java 标准的"全局常量"——编译器会做"常量折叠"优化,运行时直接把值替换进字节码,比变量访问还快。

🔑 三问连起来的领悟:每个看似简单的设计决定,背后都对应一两个语言特性的最佳实践。把这三问刻在脑子里,写代码不会瞎写。

# 3.1 三个并行数组

🎯 Step 2.1:定义全局静态字段。

在 class StudentScore 里、main 上方插入:

// ============ 全局静态字段 ============
static final int MAX_STUDENTS = 50;
static final String[] SUBJECT_NAMES = { "语文", "数学", "英语" };
static final int SUBJECT_COUNT = 3;

static int[]      ids    = new int[MAX_STUDENTS];
static String[]   names  = new String[MAX_STUDENTS];
static double[][] scores = new double[MAX_STUDENTS][SUBJECT_COUNT];
static int        count  = 0;          // 当前学生数量

static final Scanner SC = new Scanner(System.in);   // 全局 Scanner,免得到处传
1
2
3
4
5
6
7
8
9
10
11

把 main 里原先的 Scanner sc = new Scanner(System.in); 删掉(已被全局 SC 替代),所有 sc 改 SC、sc.close() 移到 case "0" 末尾。

💡 为什么 SUBJECT_NAMES 用 String[] 而不是 enum? 因为 enum 在入门第 7 章后才常用,且本案例追求最小语言特性。String[] + 索引(0=语文 / 1=数学 / 2=英语)是过程式风格的标准做法。

# 3.2 初始化假数据

🎯 Step 2.2:写 initData() 灌 5 条数据,让"显示学生"有内容可显示。

static void initData() {
    int[] initIds = { 1001, 1002, 1003, 1004, 1005 };
    String[] initNames = { "张三", "李四", "王五", "赵六", "钱七" };
    double[][] initScores = {
        { 85.0, 90.0, 78.0 },
        { 72.5, 88.0, 95.5 },
        { 60.0, 55.0, 70.0 },
        { 95.0, 92.0, 88.0 },
        { 45.0, 50.0, 60.0 },
    };
    for (int i = 0; i < initIds.length; i++) {
        ids[i] = initIds[i];
        names[i] = initNames[i];
        for (int j = 0; j < SUBJECT_COUNT; j++) {
            scores[i][j] = initScores[i][j];
        }
    }
    count = initIds.length;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在 main 进入 while 循环之前调用一次:initData();

顺手把 case "2" 改成真显示:

case "2":
    if (count == 0) {
        System.out.println("(暂无学生)");
    } else {
        for (int i = 0; i < count; i++) {
            System.out.printf("%d  %s  语:%.1f 数:%.1f 英:%.1f%n",
                    ids[i], names[i], scores[i][0], scores[i][1], scores[i][2]);
        }
    }
    break;
1
2
3
4
5
6
7
8
9
10

✏️ 立刻编译运行:

javac StudentScore.java && java StudentScore
1

输入 2 看:

请选择: 2
1001  张三  语:85.0 数:90.0 英:78.0
1002  李四  语:72.5 数:88.0 英:95.5
1003  王五  语:60.0 数:55.0 英:70.0
1004  赵六  语:95.0 数:92.0 英:88.0
1005  钱七  语:45.0 数:50.0 英:60.0
1
2
3
4
5
6

💡 %n 不是 \n:%n 跨平台(Windows 是 \r\n、Mac/Linux 是 \n,String.format 会自动选对应字符);\n 永远是 \n。Java 输出回车一律用 %n,这是工业代码的细节修养。

┌─ 📌 阶段 ② 小结 ────────────────────────────────────────┐
│ ✅ 你已经完成的:                                          │
│   • 三个并行数组 + count 字段                              │
│   • 5 条假数据 + 显示功能                                  │
│   • 全局 SC(避免到处传 Scanner)                          │
│                                                            │
│ 🔑 关键代码模式:                                           │
│   for (int i = 0; i &lt; count; i++) {                        │
│       // 通过下标 i 同时访问 ids[i] / names[i] / scores[i] │
│   }                                                        │
│                                                            │
│ 📌 进入下阶段前务必:                                       │
│   git add . &amp;&amp; git commit -m "stage2: data + initData"     │
│                                                            │
│ 💡 此刻的领悟:                                             │
│   "并行数组就是用同一个下标 i 把多个数组绑成'一个虚拟对象'  │
│    —— 这是 class 出现之前的人类智慧"                        │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 04.添加学生

┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────────┐
│ 完成什么:CRUD 4 个核心方法(add/delete/find/update)       │
│ 不做什么:不做统计 / 排序(阶段 ④⑤ 做)                    │
│ 验收标准:能加 → 删 → 查 → 改、6 大菜单 70% 跑通             │
│ 预计耗时:45 分钟                                           │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6

# 4.1 实现 addStudent

🎯 Step 3.1:写 addStudent 方法 + 边界判断。

static void addStudent() {
    if (count >= MAX_STUDENTS) {
        System.out.println("(成绩表已满,无法添加)");
        return;
    }
    System.out.print("请输入学号: ");
    int id = Integer.parseInt(SC.nextLine().trim());

    // 学号查重 —— 复用查找逻辑(先临时写一次,§06 抽方法)
    for (int i = 0; i < count; i++) {
        if (ids[i] == id) {
            System.out.println("(学号已存在,无法添加)");
            return;
        }
    }

    System.out.print("请输入姓名: ");
    String name = SC.nextLine().trim();

    System.out.print("请输入语文成绩: ");
    double chinese = Double.parseDouble(SC.nextLine().trim());
    System.out.print("请输入数学成绩: ");
    double math    = Double.parseDouble(SC.nextLine().trim());
    System.out.print("请输入英语成绩: ");
    double english = Double.parseDouble(SC.nextLine().trim());

    ids[count] = id;
    names[count] = name;
    scores[count][0] = chinese;
    scores[count][1] = math;
    scores[count][2] = english;
    count++;
    System.out.println("✅ 添加成功,当前共 " + count + " 名学生");
}
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

把 case "1" 改成 addStudent();。

# 4.2 测试添加

✏️ 立刻编译运行:

请选择: 1
请输入学号: 1006
请输入姓名: 孙八
请输入语文成绩: 88
请输入数学成绩: 77
请输入英语成绩: 66
✅ 添加成功,当前共 6 名学生

请选择: 1
请输入学号: 1001
(学号已存在,无法添加)
1
2
3
4
5
6
7
8
9
10
11

第二次输入相同学号能被拦下——查重生效。

# 4.3 字符串相等陷阱

🎯 Step 3.2:⚠️ 故意造 BUG → 修复。

把 §4.1 的查重改成"按姓名查重"试试看:

// ⚠️ 反例:新手第一反应这样写
for (int i = 0; i < count; i++) {
    if (names[i] == name) {           // ⚠️ 这里用了 ==
        System.out.println("(姓名已存在)");
        return;
    }
}
1
2
3
4
5
6
7

跑一下:

请选择: 1
请输入学号: 1007
请输入姓名: 张三
请输入语文成绩: 90
...
✅ 添加成功    ← ⚠️ 居然添加成功了!明明已经有"张三"了
1
2
3
4
5
6

为什么 == 失败?因为 Java 中 String 是对象,== 比较的是引用地址(两个对象在内存里的位置),不是字符串内容。Scanner.nextLine() 每次返回的都是新建的 String 对象,地址肯定不等于已存的 names[i]。

✅ 修复:改用 equals 比较内容。

for (int i = 0; i < count; i++) {
    if (name.equals(names[i])) {         // ✅ 用 equals 比内容
        System.out.println("(姓名已存在)");
        return;
    }
}
1
2
3
4
5
6

⚠️ 新手永远写错的细节:name.equals(names[i]) 不要写成 names[i].equals(name)——后者如果 names[i] == null 会抛 NPE。让"已知非空的对象"在前是 Java 防御性编程的第一条铁律(卷一第 10 章会再讲一次)。

💡 更稳的写法:Objects.equals(name, names[i])——java.util.Objects 工具类的静态方法,两边任意一个为 null 都安全,永不 NPE。生产代码推荐这个。

🔑 == 仍然有它合法的用途:基本类型(int / double / boolean)必须用 ==;null 检查(x == null)也必须用 ==;常量池里的字面量字符串比较 "a" == "a" 也是 true(编译器优化)。关键判断点:是否引用类型 + 是否需要比内容。

📚 延伸阅读:String 不可变性 / 字符串常量池 / intern() 方法——这些细节本案例不深究,等 02 案例的"对象比较"再展开。

本案例最后 §4.1 我们用学号 int 比较(== 合法),所以反例只是教学用——记住这个坑就够了。


# 05.删除学生

# 5.1 末尾填洞法

🎯 Step 3.3:实现 deleteStudent。

static void deleteStudent() {
    System.out.print("请输入要删除的学号: ");
    int id = Integer.parseInt(SC.nextLine().trim());
    int idx = findIndex(id);                 // 复用查找
    if (idx == -1) {
        System.out.println("(无此学号)");
        return;
    }
    // 末尾填洞法:把最后一个学生搬到 idx 位置,count--
    int last = count - 1;
    ids[idx] = ids[last];
    names[idx] = names[last];
    for (int j = 0; j < SUBJECT_COUNT; j++) {
        scores[idx][j] = scores[last][j];
    }
    // 可选:清空末尾引用,让 GC 回收 names[last](防止内存泄漏)
    names[last] = null;
    count--;
    System.out.println("✅ 删除成功,当前共 " + count + " 名学生");
}

static int findIndex(int id) {
    for (int i = 0; i < count; i++) {
        if (ids[i] == id) return i;
    }
    return -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

把 case "3" 改成 deleteStudent();。

🔑 末尾填洞法 vs 整体前移:传统数组删除元素后要把后面所有元素前移 1 位(O(n) 复杂度);末尾填洞法只搬 1 次(O(1) 复杂度)——前提是不要求保持顺序。这里我们排序是另一个独立动作(§09 才做),删除时打乱顺序无所谓。

💡 names[last] = null; 是 Java 特有的细节——C/C++ 不需要这一步,因为内存自管。Java 中 String 是对象,如果不置 null,被搬走的位置仍然持有引用,GC 永远不会回收原始 names[last] 指向的对象——这是经典的"无意识内存泄漏"。养成这个习惯。

# 5.2 测试删除

请选择: 3
请输入要删除的学号: 1003
✅ 删除成功,当前共 5 名学生

请选择: 2
1001  张三  ...
1002  李四  ...
1005  钱七  ...      ← 末尾的钱七搬到了王五原来的位置
1004  赵六  ...
1
2
3
4
5
6
7
8
9

注意 1005 的位置——末尾填洞法的副作用就是顺序被打乱。如果业务要求保留顺序,得用整体前移法(02 案例的 vector<Account> 默认就是这种)。


# 06.查找学生

# 6.1 -1 哨兵约定

🎯 Step 3.4:findIndex 上面已经写过了。这里做菜单层。

static void findStudent() {
    System.out.print("请输入要查找的学号: ");
    int id = Integer.parseInt(SC.nextLine().trim());
    int idx = findIndex(id);
    if (idx == -1) {
        System.out.println("(无此学号)");
        return;
    }
    System.out.printf("学号:%d  姓名:%s  语:%.1f 数:%.1f 英:%.1f  总分:%.1f%n",
            ids[idx], names[idx],
            scores[idx][0], scores[idx][1], scores[idx][2],
            scores[idx][0] + scores[idx][1] + scores[idx][2]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

把 case "4" 改成 findStudent();。

🔑 为什么 findIndex 找不到时返回 -1 而不是抛异常?

  1. 第 10 章才学异常 —— 本案例不能用
  2. -1 哨兵约定是 C/C++ 时代的传统,沿袭到 Java —— String.indexOf 找不到也是返回 -1
  3. 02 案例升级时会换成异常 —— AccountNotFoundException,正好对应入门第 10 章

⚠️ -1 哨兵的代价:调用方永远必须先判断 == -1,如果忘了,下一行 ids[idx] 会变成 ids[-1] 直接抛 ArrayIndexOutOfBoundsException。这正是为什么后续案例升级到 Optional<Integer> / 异常的根本原因——让"找不到"无法被忽略。

# 6.2 测试查找

请选择: 4
请输入要查找的学号: 1001
学号:1001  姓名:张三  语:85.0 数:90.0 英:78.0  总分:253.0

请选择: 4
请输入要查找的学号: 9999
(无此学号)
1
2
3
4
5
6
7

# 07.修改成绩

# 7.1 实现 updateScore

🎯 Step 3.5:

static void updateScore() {
    System.out.print("请输入要修改的学号: ");
    int id = Integer.parseInt(SC.nextLine().trim());
    int idx = findIndex(id);
    if (idx == -1) {
        System.out.println("(无此学号)");
        return;
    }
    System.out.println("选择科目: 1=语文  2=数学  3=英语");
    System.out.print("请选择: ");
    int subject = Integer.parseInt(SC.nextLine().trim());
    if (subject < 1 || subject > 3) {
        System.out.println("(科目编号无效)");
        return;
    }
    System.out.print("请输入新分数: ");
    double newScore = Double.parseDouble(SC.nextLine().trim());

    double oldScore = scores[idx][subject - 1];
    scores[idx][subject - 1] = newScore;     // ⚠️ 注意是 idx,不是 count
    System.out.printf("✅ %s 的%s成绩从 %.1f 改为 %.1f%n",
            names[idx], SUBJECT_NAMES[subject - 1], oldScore, newScore);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

把 case "5" 改成 updateScore();。

⚠️ 修改下标陷阱:上面写 scores[idx][subject - 1] —— idx 是 findIndex 找到的旧位置。如果误写成 scores[count][subject - 1](笔者第一版就踩过),新值会落到"还没分配的下一个槽位",老成绩原封不动看起来"修改无效"。修改 = 覆盖原位置,不是追加新位置。

# 7.2 测试修改

请选择: 5
请输入要修改的学号: 1001
选择科目: 1=语文  2=数学  3=英语
请选择: 2
请输入新分数: 99
✅ 张三 的数学成绩从 90.0 改为 99.0

请选择: 2
1001  张三  语:85.0 数:99.0 英:78.0
1
2
3
4
5
6
7
8
9
┌─ 📌 阶段 ③ 小结 ────────────────────────────────────────┐
│ ✅ 你已经完成的:                                           │
│   • addStudent —— 边界 + 查重                              │
│   • findIndex —— -1 哨兵约定                                │
│   • deleteStudent —— 末尾填洞法                             │
│   • findStudent —— 复用 findIndex                           │
│   • updateScore —— 注意 idx 而非 count                      │
│                                                             │
│ 🔑 关键习惯:                                                │
│   • 字符串比较一律 equals,永远不要 ==                       │
│   • 引用置 null 帮助 GC(删除元素后)                        │
│   • findIndex 是 §05/§06/§07 共用的核心 —— 复用大于复制      │
│                                                             │
│ 📌 git commit -m "stage3: CRUD"                            │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 08.统计计算

┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────────┐
│ 完成什么:5 个统计方法(单人总分平均 / 单科平均 / 第一名 / 及格人数)│
│ 不做什么:不做排序(阶段 ⑤ 做)                            │
│ 验收标准:选 6 进入子菜单,5 个统计都出数                   │
│ 预计耗时:30 分钟                                          │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6

# 8.0 灵魂三问

🎯 Step 4.0:

❓ 问题一:为什么平均分用 double 不用 int?

来看反例:

int total = 85 + 90 + 78;      // 253
int avg   = total / 3;          // ⚠️ 整数除法,结果是 84,不是 84.33!
1
2

问题:Java 的 int / int 是整数除法,小数部分直接砍掉。

✅ 正确做法:

double avg = (double) total / 3;        // ✅ 强转其中一个为 double
// 或者用 1.0 触发隐式提升:
double avg2 = total / 3.0;              // ✅ 一边是 double,整体提升
1
2
3

记住:只要有一边是浮点,整个表达式都是浮点。

❓ 问题二:为什么 ++count 而不是 count = count + 1?

来看三种写法:

count = count + 1;        // 写法 A:读 count → 加 1 → 写回,3 步
count += 1;               // 写法 B:复合赋值,简洁 10%
++count;                  // 写法 C:自增,最简洁
1
2
3

三者功能完全等价——但工业代码倾向 ++count,因为:

  1. 更短 —— 工程上越简洁越好读
  2. 无歧义 —— 不会写错变量名(count = cont + 1 就是常见 typo)
  3. 自增是一条 JVM 字节码指令 iinc —— 性能最优(虽然现代 JIT 会优化掉差异,但好的习惯本身就是价值)

💡 ++count 和 count++ 的区别:单独成语句时没区别(都是自增 1);在表达式中:++count 先加再用、count++ 先用再加。本案例只用单语句形式,怎么写都行——但习惯上选前缀。

❓ 问题三:为什么及格判断要用 >= 60 而不是 > 59?

if (score >= 60) { /* 及格 */ }     // ✅ 推荐
if (score > 59)  { /* 及格 */ }     // ⚠️ 整数情况等价,浮点情况翻车
1
2

关键:浮点。当 score = 59.5:

  • >= 60 :false(不及格)✅
  • > 59 :true(及格)❌ —— 业务错误!

铁律:业务边界值 60 怎么写代码就怎么写,不要"等价变换"。"60 分及格"就 >= 60,不要拐弯。

# 8.1 单人总分平均分

🎯 Step 4.1:

static double calcTotal(int idx) {
    double total = 0;
    for (int j = 0; j < SUBJECT_COUNT; j++) {
        total += scores[idx][j];
    }
    return total;
}

static double calcAverage(int idx) {
    return calcTotal(idx) / SUBJECT_COUNT;
}
1
2
3
4
5
6
7
8
9
10
11

# 8.2 单科平均分

🎯 Step 4.2:

static double calcSubjectAvg(int subject) {       // subject: 0/1/2
    if (count == 0) return 0;
    double total = 0;
    for (int i = 0; i < count; i++) {
        total += scores[i][subject];
    }
    return total / count;
}
1
2
3
4
5
6
7
8

⚠️ 除零陷阱:count == 0 时如果不提前返回,total / count 会 → 0 / 0 = NaN(double 不会抛除零异常,会得到 NaN)。NaN 在打印时显示 "NaN",看起来很怪。显式守卫才稳。

# 8.3 找全班第一名

🎯 Step 4.3:手写"打擂台"算法(卷一第 4 章重点)。

static int findTopStudent() {
    if (count == 0) return -1;
    int topIdx = 0;
    double topTotal = calcTotal(0);
    for (int i = 1; i < count; i++) {
        double cur = calcTotal(i);
        if (cur > topTotal) {           // ⚠️ 严格大于:相同分数时第一个夺冠
            topTotal = cur;
            topIdx = i;
        }
    }
    return topIdx;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

💡 打擂台的循环不变量:进入 i = k 这一轮时,topIdx 一定是 [0..k-1] 范围内的最大者。这种"每一轮都有明确的状态保证"的思维方式叫循环不变量——是写正确循环的核心心法。

# 8.4 单科及格人数

🎯 Step 4.4:

static int countPassed(int subject) {
    int n = 0;
    for (int i = 0; i < count; i++) {
        if (scores[i][subject] >= 60) {       // ✅ 用 >=,按 §8.0 问题三铁律
            n++;
        }
    }
    return n;
}
1
2
3
4
5
6
7
8
9

接入菜单(统计子菜单):

case "6":
    if (count == 0) { System.out.println("(暂无学生)"); break; }
    System.out.println("--- 统计 ---");
    int top = findTopStudent();
    System.out.printf("第一名: %s (总分 %.1f)%n", names[top], calcTotal(top));
    for (int j = 0; j < SUBJECT_COUNT; j++) {
        System.out.printf("%s 平均分: %.2f, 及格人数: %d/%d%n",
                SUBJECT_NAMES[j], calcSubjectAvg(j), countPassed(j), count);
    }
    break;
1
2
3
4
5
6
7
8
9
10

测试:

请选择: 6
--- 统计 ---
第一名: 赵六 (总分 275.0)
语文 平均分: 71.50, 及格人数: 4/5
数学 平均分: 75.00, 及格人数: 3/5
英语 平均分: 78.30, 及格人数: 4/5
1
2
3
4
5
6
┌─ 📌 阶段 ④ 小结 ────────────────────────────────────────┐
│ ✅ 5 个统计方法跑通                                        │
│ 🔑 关键概念:整数除法陷阱 / NaN 守卫 / 打擂台 / 循环不变量   │
│ 📌 git commit -m "stage4: stats"                          │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5

# 09.排序与展示

┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────────┐
│ 完成什么:手写冒泡 + 同步交换 + 表格输出                   │
│ 不做什么:不用 Arrays.sort(要等 14 章泛型 / 17 章 Lambda)│
│ 验收标准:选 7 看到按总分降序的对齐表格                     │
│ 预计耗时:45 分钟                                          │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6

# 9.1 手写冒泡排序

🎯 Step 5.1:手写冒泡,体会"原地排序"。

static void sortByTotalDesc() {
    for (int i = 0; i < count - 1; i++) {
        for (int j = 0; j < count - 1 - i; j++) {
            if (calcTotal(j) < calcTotal(j + 1)) {
                swapAll(j, j + 1);
            }
        }
    }
}
1
2
3
4
5
6
7
8
9

# 9.2 同步交换陷阱

🎯 Step 5.2:⚠️ 故意造 BUG → 修复。

新手第一版常这样写:

// ❌ 反例:只交换 scores 不同步交换 names/ids
if (calcTotal(j) < calcTotal(j + 1)) {
    double[] tmp = scores[j];
    scores[j] = scores[j + 1];
    scores[j + 1] = tmp;
}
1
2
3
4
5
6

跑一下排序后选 2 显示:

1001  张三   语:95.0 数:92.0 英:88.0    ← ⚠️ 张三的成绩变成赵六的了!
1002  李四   语:85.0 数:90.0 英:78.0    ← 李四的成绩变成张三的了!
...
1
2
3

问题:scores 数组动了、names 和 ids 没动 → 学号、姓名、分数全错位 = 灾难。

✅ 修复:抽出 swapAll(i, j) 静态方法一次换三个数组:

static void swapAll(int a, int b) {
    int tmpId = ids[a];     ids[a] = ids[b];     ids[b] = tmpId;
    String tmpName = names[a]; names[a] = names[b]; names[b] = tmpName;
    double[] tmpScore = scores[a]; scores[a] = scores[b]; scores[b] = tmpScore;
}
1
2
3
4
5

🔑 这是并行数组的最大代价——每次顺序变化都要"三换一"。漏一行就数据错乱。记住这个痛——02 案例换成 Account 对象后,"swapAll" 就退化成 Account tmp = arr[a]; arr[a] = arr[b]; arr[b] = tmp; 一行三换 → 两行就够,封装的好处第一次实锤。

# 9.3 表格化输出 + 可变参数

🎯 Step 5.3:

static void printSeparator(char... chars) {     // 可变参数 char...
    StringBuilder sb = new StringBuilder();
    for (char c : chars) {
        for (int i = 0; i < 12; i++) sb.append(c);
    }
    System.out.println(sb);
}

static void printTable() {
    if (count == 0) {
        System.out.println("(暂无学生)");
        return;
    }
    printSeparator('=', '-', '=', '-', '=');
    System.out.printf("%-6s %-10s %8s %8s %8s %8s %8s%n",
            "学号", "姓名", "语文", "数学", "英语", "总分", "平均");
    printSeparator('-', '-', '-', '-', '-');
    for (int i = 0; i < count; i++) {
        System.out.printf("%-6d %-10s %8.1f %8.1f %8.1f %8.1f %8.2f%n",
                ids[i], names[i],
                scores[i][0], scores[i][1], scores[i][2],
                calcTotal(i), calcAverage(i));
    }
    printSeparator('=', '-', '=', '-', '=');
}
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

把 case "7" 改成 sortByTotalDesc(); printTable();。

测试:

请选择: 7
============------------============------------============
学号    姓名           语文     数学     英语     总分     平均
------------------------------------------------------------
1004   赵六           95.0     92.0     88.0    275.0    91.67
1002   李四           72.5     88.0     95.5    256.0    85.33
1001   张三           85.0     90.0     78.0    253.0    84.33
1003   王五           60.0     55.0     70.0    185.0    61.67
1005   钱七           45.0     50.0     60.0    155.0    51.67
============------------============------------============
1
2
3
4
5
6
7
8
9
10

💡 %-6s vs %6s:%-6s 左对齐留 6 位、%6s 右对齐留 6 位。表格"标题左对齐、数字右对齐"是国际通用约定,本案例严格遵守。

💡 char... 可变参数:调用方可以传任意个 char——printSeparator('=', '-') 也行、printSeparator('*') 也行。可变参数 = "数组语法糖",函数内部 chars 实际就是 char[],但调用方写法更自然。

┌─ 📌 阶段 ⑤ 小结 ────────────────────────────────────────┐
│ ✅ 冒泡排序 + 同步交换 + 表格化输出                        │
│ 🔑 关键认知:并行数组的"swapAll" 是封装缺失的代价           │
│ 📌 git commit -m "stage5: sort + table"                   │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5

# 10.输入校验

┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────────┐
│ 完成什么:readInt/readDouble/readNonEmptyString 工具方法   │
│ 不做什么:不用 try-catch(10 章才学)                      │
│ 验收标准:输入 "abc" 不崩溃、输入超范围会重新提问           │
│ 预计耗时:30 分钟                                          │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6

# 10.0 灵魂三问

🎯 Step 6.0:

❓ 问题一:为什么不直接用 try-catch?

入门第 10 章才学异常处理。本案例所有方法都不能写 throws / try-catch——必须用"全字符判断"绕过。这是刻意的"知识点禁运区",让你亲身体会到没有异常的痛——02 案例你会爱上 try-catch。

❓ 问题二:Scanner 用 nextLine 不用 nextInt?

// ⚠️ 反例
int n = sc.nextInt();          // 输入 "abc" → InputMismatchException 崩溃
1
2
// ✅ 正解
String line = sc.nextLine().trim();           // 永远拿一行字符串,不会崩
// 然后手动判断是否数字 + 范围
1
2
3

nextInt() 还有个坑:它不消耗换行符!下一个 nextLine() 会读到空行。这种混用是 Java 新手最大的雷区——全场只用 nextLine() 一种方法就什么事都没有。

❓ 问题三:为什么用 Integer.parseInt 而不是 (int) "5"?

(int) "5" 编译失败——Java 不允许 String 强转基本类型。必须用包装类 Integer.parseInt(s)——这正是入门第 2 章"包装类"的实战入口。同理 Double.parseDouble(s)、Boolean.parseBoolean(s)。

💡 parseInt 解析失败也会抛 NumberFormatException——但这个异常我们用"判断全字符是数字"的方式提前拦截,就不会触发。这是"防御式编程"的精髓:不要等异常出现再处理,要让异常根本没机会触发。

# 10.1 readInt 工具方法

🎯 Step 6.1:

static int readInt(String prompt, int min, int max) {
    while (true) {
        System.out.print(prompt);
        String line = SC.nextLine().trim();
        if (!isInteger(line)) {
            System.out.println("(请输入整数)");
            continue;
        }
        int v = Integer.parseInt(line);
        if (v < min || v > max) {
            System.out.printf("(请输入 %d~%d 的整数)%n", min, max);
            continue;
        }
        return v;
    }
}

static boolean isInteger(String s) {
    if (s == null || s.isEmpty()) return false;
    int i = 0;
    if (s.charAt(0) == '-' || s.charAt(0) == '+') {
        if (s.length() == 1) return false;
        i = 1;
    }
    for (; i < s.length(); i++) {
        if (!Character.isDigit(s.charAt(i))) return false;
    }
    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

💡 Character.isDigit 而不是 c >= '0' && c <= '9':前者跨语言(支持 Unicode 数字 '٣' '三'),后者只支持 ASCII。Java 默认走 Unicode,写 Character.isDigit 是地道写法。本案例不强求,但记住这条口诀。

# 10.2 重载 readDouble 与 readNonEmptyString

🎯 Step 6.2:

static int readInt(String prompt) {            // 重载:不带范围版本
    return readInt(prompt, Integer.MIN_VALUE, Integer.MAX_VALUE);
}

static double readDouble(String prompt, double min, double max) {
    while (true) {
        System.out.print(prompt);
        String line = SC.nextLine().trim();
        if (!isDouble(line)) {
            System.out.println("(请输入数字)");
            continue;
        }
        double v = Double.parseDouble(line);
        if (v < min || v > max) {
            System.out.printf("(请输入 %.1f~%.1f 的数字)%n", min, max);
            continue;
        }
        return v;
    }
}

static boolean isDouble(String s) {
    if (s == null || s.isEmpty()) return false;
    boolean dot = false;
    int i = 0;
    if (s.charAt(0) == '-' || s.charAt(0) == '+') {
        if (s.length() == 1) return false;
        i = 1;
    }
    for (; i < s.length(); i++) {
        char c = s.charAt(i);
        if (c == '.') {
            if (dot) return false;     // 不能有两个点
            dot = true;
        } else if (!Character.isDigit(c)) {
            return false;
        }
    }
    return true;
}

static String readNonEmptyString(String prompt) {
    while (true) {
        System.out.print(prompt);
        String s = SC.nextLine().trim();
        if (!s.isEmpty()) return s;
        System.out.println("(输入不能为空)");
    }
}
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

🔑 readInt(String) 重载 readInt(String, int, int) —— 方法重载 的最朴素用法(卷一第 6 章核心)。它让调用方可以"按需带范围"——不带范围时用 Integer.MIN_VALUE / MAX_VALUE。重载就是 API 的"方便版"——同一意图、不同便利度。

# 10.3 主菜单全部接入

🎯 Step 6.3:把 §04 / §05 / §06 / §07 里所有 Integer.parseInt(SC.nextLine().trim()) 替换为 readInt(...)、Double.parseDouble(...) 替换为 readDouble(...)、SC.nextLine().trim() 字符串替换为 readNonEmptyString(...)。

例如 §4.1 addStudent 改造后:

static void addStudent() {
    if (count >= MAX_STUDENTS) { System.out.println("(成绩表已满)"); return; }

    int id = readInt("请输入学号 (1000-9999): ", 1000, 9999);
    if (findIndex(id) != -1) { System.out.println("(学号已存在)"); return; }

    String name = readNonEmptyString("请输入姓名: ");
    double chinese = readDouble("请输入语文成绩 (0-100): ", 0, 100);
    double math    = readDouble("请输入数学成绩 (0-100): ", 0, 100);
    double english = readDouble("请输入英语成绩 (0-100): ", 0, 100);

    ids[count] = id;
    names[count] = name;
    scores[count][0] = chinese;
    scores[count][1] = math;
    scores[count][2] = english;
    count++;
    System.out.println("✅ 添加成功,当前共 " + count + " 名学生");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

测试:

请输入学号 (1000-9999): abc
(请输入整数)
请输入学号 (1000-9999): 99
(请输入 1000~9999 的整数)
请输入学号 (1000-9999): 1006
请输入姓名:
(输入不能为空)
请输入姓名: 孙八
请输入语文成绩 (0-100): 200
(请输入 0.0~100.0 的数字)
请输入语文成绩 (0-100): 88
...
1
2
3
4
5
6
7
8
9
10
11
12

输入再野蛮也不会崩溃——这就是健壮性的最低标准。

┌─ 📌 阶段 ⑥ 小结 ────────────────────────────────────────┐
│ ✅ 工具方法 readInt/readDouble/readNonEmptyString          │
│ 🔑 关键认知:防御式编程 —— 让异常没机会触发                 │
│ 🔑 重载 = 同名方法,签名不同,便利度递增                    │
│ 📌 git commit -m "stage6: input validation"               │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6

# 11.项目总结分析

# 11.1 代码整体结构

StudentScore.java (单文件 ~ 350 行)
├── 全局静态字段
│   ├── MAX_STUDENTS / SUBJECT_NAMES / SUBJECT_COUNT  (常量)
│   ├── ids[] / names[] / scores[][]                  (并行数组)
│   ├── count                                          (计数器)
│   └── SC                                            (全局 Scanner)
│
├── main —— 菜单循环 + switch 分发
├── showMenu —— 打印菜单
│
├── CRUD 业务方法
│   ├── addStudent
│   ├── deleteStudent
│   ├── findStudent
│   └── updateScore
│
├── 辅助方法
│   ├── findIndex —— 共用 -1 哨兵
│   ├── swapAll   —— 并行数组同步交换
│   └── initData
│
├── 统计方法
│   ├── calcTotal / calcAverage
│   ├── calcSubjectAvg
│   ├── findTopStudent
│   └── countPassed
│
├── 排序与展示
│   ├── sortByTotalDesc —— 冒泡排序
│   ├── printTable      —— 表格化
│   └── printSeparator  —— 可变参数 char...
│
└── 输入校验工具
    ├── readInt / readInt(prompt)        —— 重载
    ├── readDouble
    ├── readNonEmptyString
    ├── isInteger
    └── isDouble
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

# 11.2 核心原理

  1. 菜单驱动:while + switch 实现"无限循环 + 分支分发"。
  2. 并行数组:用同一个下标 i 关联多个独立数组,模拟"对象数组"。
  3. 末尾填洞法:删除 O(1) 复杂度(不保序)。
  4. -1 哨兵约定:用特殊返回值表示"找不到"——后续案例用 Optional / 异常替代。
  5. 打擂台算法:找极值的最朴素手法。
  6. 冒泡 + swapAll:原地排序 + 并行数组同步交换。
  7. 防御式输入:isInteger / isDouble 提前拦截,让 parseInt 永不抛异常。
  8. 方法重载:readInt(String) 与 readInt(String, int, int) —— 同名不同签名。

# 11.3 优缺点分析

优点

  • 完整覆盖入门第 1-6 章:每个知识点都有真实业务场景
  • 结构清晰:方法职责单一、命名一致
  • 健壮性 OK:输入校验完备,野蛮输入不崩溃
  • 教学层次分明:阶段渐进、灵魂三问、造 BUG 修复

缺点

  • 数据不持久化:关机即丢(02 用 CSV 解决)
  • 没有 OOP:并行数组同步交换易出错(02 用 class Account 解决)
  • 错误处理裸:返回值表错误,调用方易忽略(02 用异常解决)
  • 容量写死 50:扩容必须改源码(03 用 ArrayList 解决)
  • 手写排序低效:O(n²)(03 用 Arrays.sort 解决)

# 12.项目技术思考

# 12.1 为何不用 class

新手疑问:第 7 章学了 class 之后回头看本案例,会觉得"为什么不用 class Student 啊,多不优雅"。

答:故意的。用 class 当然更优雅,但这就剥夺了你在 02 案例里"亲眼看到 class 的好处"的机会。教学体系是这样设计的:

  1. 本案例(01) = 用第 1-6 章的语言能力解题——你会感受到痛:并行数组同步交换、错误码 -1、容量写死……
  2. 下一案例(02) = 引入第 7-10 章后再解同一类问题——你会感受到爽:class 封装、vector 动态、异常处理……
  3. 痛 → 爽 的对比 = 真正理解"为什么 OOP 重要"

直接给你 class 版本,你只会觉得"哦原来是这样";但痛过一次再升级,你会觉得"啊原来 class 救了我"。这种对比式认知是工业教学的精华。

# 12.2 卷一章节回扣表

完成本案例后,你应该能够回答:

入门章节 本案例哪里用了? 你应该掌握
第 1 章 基础语法 static final 常量、单文件 class、main 方法 单文件最小项目结构
第 2 章 数据类型 int / double / String、Integer.parseInt 基本类型 vs 包装类
第 2 章 Integer.parseInt / Double.parseDouble 解析用户输入字符串
第 3 章 算术 / 比较 / 逻辑运算符 总分 / 及格判断 / 范围校验
第 4 章 字符串和数组 String.equals、String.format、二维数组、String.split 隐含 == 和 equals 的本质区别
第 4 章 二维数组 String[][] / double[][] 班级分数表
第 5 章 switch 字符串、while、增强 for 菜单分发、主循环、遍历
第 5 章 if-else / switch 字符串 菜单分发
第 5 章 while / for / 增强 for 主菜单循环 / 遍历分数
第 5 章 break / continue 退出主循环 / 跳过非法输入
第 6 章 静态方法 + 重载 + 可变参数 业务方法、readInt(String) 与 readInt(String, int, int)

如果上面任何一行你说不清楚,回去复习对应章节——本案例就是它的实战检验。


# 13.衔接与延伸

# 13.1 与下一案例的差异

下一案例 02.银行账户管理系统 (opens new window) 会做 6 件升级,请留意对比:

维度 本案例 01 下一案例 02
数据载体 三个并行数组 + count class Account + Account[](02 末尾换 ArrayList)
字段封装 全局 static 字段裸读裸写 private + getter/setter,封装
数据类型 单一学生 抽象基类 Account + 三个派生(普通 / VIP / 储蓄)—— 多态首秀
错误处理 返回 -1 / 打印提示 自定义异常体系 BankException 子树
持久化 ❌ 关机即丢 ✅ CSV 文件读写 + try-with-resources
项目结构 单文件 5 包分层(entity / exception / service / dao / cli)
章节覆盖 第 1-6 章 第 7-10、12 章(OOP + 异常 + IO)

换句话说,02 让你第一次亲身体会到"封装 + 继承 + 多态 + 异常 + 文件"比"裸数组 + 静态方法"多出的价值。

# 13.2 三个延伸挑战

完成主流程后,尝试把下列三项自己加上:

挑战 A(基础)· 增加"按学号排序"功能

仿照 §9.1 的 sortByTotalDesc,再加一个 sortByIdAsc(),菜单加一项 8. 按学号升序。改一处冒泡的 < 为 >、改用 ids[j] 作比较键即可。目标:体会"排序逻辑通用、排序键可换"——这正是 03 案例 Comparator 的伏笔。

挑战 B(进阶)· 输出 Markdown 表格

把 printTable 的分隔符换成:

| 学号 | 姓名 | 语文 | 数学 | 英语 | 总分 | 平均 |
|------|------|------|------|------|------|------|
| 1004 | 赵六 | 95.0 | 92.0 | 88.0 | 275.0 | 91.67 |
1
2
3

复制到任何 Markdown 渲染器(如 GitHub README、印象笔记、博客)都能渲染成漂亮的表格。目标:体会 String.format 的格式化能力 + 让程序产物对外可用。

挑战 C(现代化)· 用 Stream + Lambda 重写统计

剧透一下 03 案例的写法:

// 单科平均分(Stream 版)—— 03 案例的预热
import java.util.Arrays;
double avg = Arrays.stream(scores)
    .mapToDouble(row -> row[subject])
    .average()
    .orElse(0);

// 找第一名
int top = java.util.stream.IntStream.range(0, count)
    .reduce((a, b) -> calcTotal(a) >= calcTotal(b) ? a : b)
    .orElse(-1);
1
2
3
4
5
6
7
8
9
10
11

不需要看懂细节——只是预告"原来还可以这么写"。03 案例会把它讲透。


小结:挑战 A 对应"通用排序键"思想(→ 03 Comparator)、挑战 B 对应"输出可用产物"工程意识(→ 04 JSON)、挑战 C 对应"流式 API"现代风格(→ 03 Stream / 06 命令编排)。做完三道挑战,你就已经具备开始 02 案例的所有前置能力。


  • ➡ 下一案例:02.银行账户管理系统 (opens new window) —— 从并行数组升级到 class + 多态三态,从 -1 哨兵升级到自定义异常体系,从内存数据升级到 CSV 文件持久化
上次更新: 2026/06/10, 11:13:41
README
银行账户管理系统

← README 银行账户管理系统→

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