ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

学生类Student设计实践:封装、防御性拷贝与完整测试

学生类Student设计实践:封装、防御性拷贝与完整测试

“设计一个学生类 Student,并进行测试”,这句话几乎出现在每一本面向对象教材的第一章,也几乎出现在每一场入门笔试的第一题里。题目看着简单,但我带新人时发现,十个写完的人里大概只有两三个能交出一份自己回看时不会脸红的答案。多数人的版本是:五六个public字段裸奔,一串机械生成的 getter/setter,最后一个main方法里new一个对象、System.out.println打印一遍,然后说“测试通过了”。代码确实跑起来了,但它没验证任何东西,也没保护任何东西——年龄能被改成 -5,成绩数组能被外部随手清空,学号能被中途改掉,最后出问题的地方离真正的错误现场隔了十万八千里。

这篇内容围绕的就是这件小事:从零设计一个学生类 Student,并且认认真真把测试做完整。核心关键词是学生类 Student和测试,我会从属性清单怎么定、封装边界画在哪里、代码怎么写、测试用例怎么设计、坑怎么排,一路讲到扩展方向。适合三类人看:刚学完语法、第一次接触“类”这个概念的初学者;写了几年业务代码但一直靠框架自动生成 getter/setter、没认真想过封装的中年人;以及需要一份可对照样例来讲课或带人的朋友。全文以 Java 为主线,最后给一份 Python 对照实现,其他语言的思路完全通用。

1. 动手之前:把需求拆成一份能落地的清单

1.1 一个学生到底该有哪些属性

新手最容易犯的第一个错误,是把“学生”这个词联想到的所有东西都塞进类里。学号、姓名、性别、年龄、班级、年级、各科成绩、总分、平均分、排名、家长电话、家庭住址、身份证号、宿舍号、班主任、入学日期、是否住校……写完之后字段列表能有二十行,看着很“完整”,但这个类其实什么都没表达清楚。

正确的做法是先做一次分类。我在纸上通常分四栏:基础信息(学号、姓名、性别、出生日期、班级)、业务数据(各科成绩、已修学分)、派生数据(总分、平均分、等级、排名)、越界信息(家庭住址、身份证号、家长联系方式)。前两栏才是这个类该持有的状态;第三栏是从状态算出来的结果,不该占字段;第四栏属于别的职责范围,硬塞进来只会让这个类变成一个谁都不敢改的“上帝类”。

为什么派生数据不能存字段?我举个真实场景。你在Student里同时存了scores、total、average三个字段,某天新增了一个“补考成绩录入”的入口,写这段代码的人只更新了scores,忘了同步total。于是数据库里出现了一条平均分 85、各科成绩加起来只有 240 的记录。这种 bug 找起来极其痛苦,因为出错的地方和报错的地方根本不是同一段代码。能算出来的东西就别存,这是我最愿意反复讲的一条原则。平均分要用的时候遍历一遍算,十门课的开销对现代机器来说可以忽略不计;真到了性能瓶颈,再考虑加缓存字段并保证同步,而不是一开始就给自己埋雷。

学号的类型也值得单独说两句。很多教材例子把学号写成int,我觉得这是个坏示范。学号带前导零怎么存?007存成7,打印出来和别人对不上。学号超过十位怎么办?int上限二十一亿,某些学校的学号早就超了。学号里出现字母或者短横线怎么处理?所以学号用 String,这是行业里几乎统一的实践。姓名也是 String,注意不要用char[]或者别的什么花活。

年龄这件事有个细节:年龄不是静态的,它每天都在变。如果你的系统要长期保存学生数据,存birthDate(出生日期,LocalDate类型)比存age更稳。年龄在展示的时候用Period.between(birthDate, LocalDate.now()).getYears()算出来。不过作为入门练习,字段就一个age也完全可以,我在下面的实现里用age是为了让校验逻辑更直观,你在真实项目里记得换成日期。

1.2 类型和取值范围:先把约束写死,再写代码

字段清单列出来之后,第二件事是给每个字段定类型和合法区间。这一步很多人直接跳过,结果就是校验逻辑散落在业务代码的各个角落,今天这个接口校验了年龄,明天那个接口忘了校验。

字段类型约束这么定的理由
idString非空、去空格后长度 4~20学号可能带前导零或字母,长度有现实上限
nameString非空、去空格后长度 1~30姓名不允许空白,长度限制防脏数据
ageint6 ~ 100学生年龄的合理下界和上界,挡住 0 和负数
gender枚举 GenderMALE / FEMALE / UNKNOWN用枚举避免“1 男 2 女”这种魔法数字
scoresMap<String, Double>科目名非空、分值 0~100科目数量不固定,用键值对更自然

性别这个字段特别值得展开。你会看到大量教材写private String gender;然后赋值"男",或者写private int gender;然后赋值1。前者的毛病是字符串可以写成“男”“男性”“M”“male”,数据库里能凑出七八种写法,做统计时痛不欲生;后者的毛病是别人看代码时完全不知道2代表什么。用枚举是成本最低的解法,Gender.MALE一眼就懂,编译器还能帮你挡住非法值。

成绩为什么用Map而不是数组?因为现实里的科目数量是动态的,用数组你得先问“一共考几门”,而这个问题在不同学期答案不一样。用Map<String, Double>按科目名存,新增科目就是一次put,遍历统计也不用关心下标。这里我用LinkedHashMap而不是HashMap,是为了让打印时的科目顺序和录入顺序一致,调试时看着舒服——这种小选择在真实项目里很常见,别小看它。

1.3 方法设计:类不能只会存,还要会做事

字段定好了,接下来定这个类对外提供什么能力。我习惯按四个层次过一遍:

  1. 构造:怎么把一个对象造出来,造的时候要不要校验。
  2. 读取:外部需要看哪些字段,用哪种形式给。
  3. 修改:哪些字段可以改,改了要不要校验,哪些字段一旦创建就不许改。
  4. 行为:这个类自己应该会算什么、会输出什么。

第一层的关键决定是“构造时校验还是 setter 时校验”。我的答案是两边都要,并且把校验逻辑抽成私有方法复用,绝不复制粘贴两遍。第二层要注意别把内部容器直接返回出去,这个坑后面单独讲。第三层要敢于不给 setter——学号就是典型,一个学生的学号在生命周期内不应该变,那就不提供setId,把id声明成final。这不是“少写一个方法”的问题,而是把一条业务规则固化进代码结构里,从此以后没人能绕过它。第四层最容易漏,很多人写完 getter/setter 就收手了,忘了getTotal()、getAverage()、getLevel()这类方法本来就该属于这个类,而不是散在调用方的业务代码里。

还有一条经验:不要把main方法写在Student类里当入口。教材里经常这么干,图省事,但这么做的后果是把“被测对象”和“测试代码”塞进了同一个文件,后面想复用这个类就得连测试一起带走。测试代码单独放一个类,哪怕只是一个StudentTest,这个习惯从第一天就该养成。

2. 封装:所有细节都发生在边界上

2.1 private 加 getter/setter 不是走过场

很多人对封装的理解停留在“字段前面加 private,然后 IDE 一键生成 getter/setter”。这套操作做完了,本质上和 public 字段没有任何区别——外部照样能读能写,唯一的差别是多了几行代码。真正的封装是在边界上做拦截。

举一个具体的例子。假设age有 public setter 但没校验,某段代码写成student.setAge(-5)。这时候什么都没发生,对象进入了一个非法状态。等到三天后跑统计报表,算平均年龄时得到一个荒谬的数字,你去排查,报错位置在报表模块,真正的问题在录入模块。中间隔了三天和几百行代码。而在 setter 里加一行判断,错误会在写错的那一秒就炸出来,堆栈直接指向肇事代码。这就是校验的价值——把错误暴露在离它最近的地方。

IllegalArgumentException是这里最合适的异常类型:参数不合法,就是调用方的问题,不是被调用方的问题。别用RuntimeException一把梭,也别用System.out.println("年龄不合法")然后return——那等于什么都没做,对象还是进了非法状态。

2.2 哪些字段坚决不给 setter

我列一下自己在这个类里的取舍:

  • id:final,不给 setter。学号是身份标识,改了就不是同一个学生了。
  • name:给 setter,但带非空校验。现实中确实有改名的情况。
  • age:给 setter,带范围校验。
  • gender:给 setter,带 null 校验。
  • scores:不给setScores(Map)。只提供putScore(科目, 分数)和removeScore(科目)这样的细粒度操作,每次操作单独校验。

最后一条特别关键。如果给了setScores,调用方传一个null进来,整个对象就废了;传一个包含 -10 分的 map 进来,前面的单科校验全被绕过。细粒度操作的代价是调用方要多写几行,收益是每一次状态变更都是一次可校验、可追踪的事件。这个权衡在真实项目里几乎总是值得的。

还有一个常见误区是“所有字段都给 setter 才叫完整”。恰恰相反,setter 越少,对象越稳定,越不容易被外部改成奇怪的状态。我在评审代码时看到一堆自动生成的 setter,第一反应就是“这些真的都需要吗”。

2.3 防御性拷贝:新手最容易漏的一刀

这是我认为整个练习里最有含金量的一个知识点。看这段代码:

public Map<String, Double> getScores() { return scores; // 危险写法 }

调用方拿到scores的引用之后,可以直接student.getScores().clear(),把这个学生的成绩清空,而完全不需要经过任何校验。封装在这里彻底失效。数组字段同理,public double[] getScores() { return scores; }外面拿到引用就能随便改下标。

正确做法是返回一份拷贝:

public Map<String, Double> getScores() { return Collections.unmodifiableMap(new LinkedHashMap<>(scores)); }

这里做了两层保护。new LinkedHashMap<>(scores)复制了一份数据,调用方改副本不影响原件;Collections.unmodifiableMap再套一层只读视图,调用方连改副本都不行,只能读。如果业务上确实需要“拿到一份可以自由修改的副本”,去掉第二层就行,第一层必须保留。

构造方法也要注意同样的问题。如果你的构造方法签名是public Student(String id, String name, Map<String, Double> scores),直接把参数引用赋给字段,那么调用方在构造之后修改自己手里的那个 map,你的对象内部状态会跟着变。正确写法是在构造方法里也做一次拷贝。这条规则我一般总结成一句话:凡是接收或返回可变容器的地方,都问一句“引用会不会被外部留着”。

2.4 构造方法的重载与初始化顺序

实际使用中经常需要“只填基本信息,成绩后面再录”的场景,于是会有构造方法重载:

public Student(String id, String name) { this(id, name, 18, Gender.UNKNOWN); } public Student(String id, String name, int age, Gender gender) { this.id = requireText(id, "学号"); this.name = requireText(name, "姓名"); setAge(age); setGender(gender); }

这里有个必须提醒的点:重载构造方法之间用this(...)串联,不要复制校验逻辑。我在实际项目里见过两个构造方法各自写了一遍年龄校验,某次需求变更只改了一处,结果两条创建路径的行为不一致,线上出现了同一批数据一半合法一半非法的诡异现象。

另外,构造方法里调用可被重写的方法(比如setAge不是private也不是final)在有继承体系时是有风险的,因为子类重写的方法会在父类构造期间被调用,此时子类字段还没初始化。这个坑在入门阶段可以先记结论:构造方法里只调用 private 或 final 方法。我在示例里把setAge声明成public是为了演示,你在设计可被继承的类时,把校验逻辑抽成private static void checkAge(int age)更稳妥。

3. 完整实现:一份可以直接跑的代码

3.1 类的完整骨架

先把主类写出来。我刻意加了常量、抽了校验方法、做了防御性拷贝,这些都是上面讨论的落地。

import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; public class Student { private static final int MIN_AGE = 6; private static final int MAX_AGE = 100; private static final double MIN_SCORE = 0.0; private static final double MAX_SCORE = 100.0; private final String id; private String name; private int age; private Gender gender; private final Map<String, Double> scores = new LinkedHashMap<>(); public Student(String id, String name) { this(id, name, 18, Gender.UNKNOWN); } public Student(String id, String name, int age, Gender gender) { this.id = requireText(id, "学号"); this.name = requireText(name, "姓名"); this.age = checkedAge(age); this.gender = gender == null ? Gender.UNKNOWN : gender; } public String getId() { return id; } public String getName() { return name; } public void setName(String name) { this.name = requireText(name, "姓名"); } public int getAge() { return age; } public void setAge(int age) { this.age = checkedAge(age); } public Gender getGender() { return gender; } public void setGender(Gender gender) { this.gender = gender == null ? Gender.UNKNOWN : gender; } public void putScore(String course, double score) { String key = requireText(course, "科目名"); if (Double.isNaN(score) || score < MIN_SCORE || score > MAX_SCORE) { throw new IllegalArgumentException("成绩必须在 0 到 100 之间,传入:" + score); } scores.put(key, score); } public Double getScore(String course) { return scores.get(course); } public void removeScore(String course) { scores.remove(course); } public Map<String, Double> getScores() { return Collections.unmodifiableMap(new LinkedHashMap<>(scores)); } public int getCourseCount() { return scores.size(); } public double getTotal() { double sum = 0.0; for (double s : scores.values()) { sum += s; } return sum; } public double getAverage() { return scores.isEmpty() ? 0.0 : getTotal() / scores.size(); } public String getLevel() { if (scores.isEmpty()) { return "暂无成绩"; } double avg = getAverage(); if (avg >= 90) { return "优秀"; } if (avg >= 80) { return "良好"; } if (avg >= 70) { return "中等"; } if (avg >= 60) { return "及格"; } return "不及格"; } private static String requireText(String value, String field) { if (value == null || value.trim().isEmpty()) { throw new IllegalArgumentException(field + "不能为空"); } String trimmed = value.trim(); if (trimmed.length() > 30) { throw new IllegalArgumentException(field + "长度不能超过 30,传入:" + trimmed.length()); } return trimmed; } private static int checkedAge(int age) { if (age < MIN_AGE || age > MAX_AGE) { throw new IllegalArgumentException("年龄必须在 " + MIN_AGE + " 到 " + MAX_AGE + " 之间,传入:" + age); } return age; } @Override public String toString() { return String.format("Student{id='%s', name='%s', age=%d, gender=%s, 科目数=%d, 平均分=%.2f, 等级=%s}", id, name, age, gender, getCourseCount(), getAverage(), getLevel()); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof Student)) { return false; } Student other = (Student) o; return id.equals(other.id); } @Override public int hashCode() { return Objects.hash(id); } }

枚举单独一个文件,或者写成内部枚举都行:

public enum Gender { MALE, FEMALE, UNKNOWN }

这套代码有几个刻意的设计决定值得说明。requireText和checkedAge都是private static,因为它们是纯函数,不依赖实例状态,声明成静态让语义更清楚。常量的作用不只是“好看”,它让“年龄区间改了”这种需求只需要改一行,而不是全局搜索数字100然后挨个判断该不该改。getLevel()里先判空再算平均,避免没有成绩时返回一个莫名其妙的“不及格”——从业务上讲,没有成绩不等于不及格,这两者是不同的状态,代码里必须区分开。

3.2 等级换算与浮点比较的细节

等级划分的阈值是 90 / 80 / 70 / 60,这个规则来自常见的百分制评价习惯(各机构可能有差异,你按实际情况调整常量即可)。写法上我用的是从高到低依次判断,if (avg >= 90)在最前面。很多人喜欢从低到高写:

if (avg < 60) return "不及格"; else if (avg < 70) return "及格"; ...

这两种写法在这种场景下都正确,但我更推荐从高到低,因为它的阅读顺序和“优秀的人最多还是最少”无关,纯粹是判断次数更少——大部分学生平均分在 70 到 90 之间,从高往低走两步就命中,从低往高要走四步。这种微小的优化在实际项目里当然无所谓,但它体现的思路(把高频分支往前放)是有价值的。

浮点比较是另一个必须提的点。double的==比较不可靠,0.1 + 0.2 != 0.3这个经典问题在 Java、Python、JavaScript 里都存在。有人会问:我这里只是和 90、80 这种整数阈值比,会不会出问题?大多数情况下不会,因为整数值在double里是精确表示的。但如果你的及格线是 59.5,或者平均分算出来是89.99999999999999,那就可能出问题。稳妥的做法是封装一个比较方法:

private static final double EPS = 1e-9; private static boolean gte(double a, double b) { return a - b > -EPS; }

然后在判断里用gte(avg, 90)。这个1e-9的选择逻辑是:double 的有效精度大约 15~17 位十进制,对 0 到 100 这个量级的数,1e-9 的容差足够覆盖累加误差,又远小于任何有业务意义的差异。金额计算场景应该直接用BigDecimal,那是另一套规则,这里不展开但你要知道边界在哪。

如果做的是加权平均(比如平时分占 30%、期末占 70%),计算式是total += score * weight,最后sum / weightSum。权重加起来必须是 1(或者统一除以权重总和),这个校验要在录入时做,别等到算出来发现平均分只有 30 分才想起来查。

3.3 toString、equals、hashCode 三件套

toString()的价值在于调试。没有它的时候,打印一个对象得到的是Student@1b6d3586,你完全不知道里面装的什么。有它之后,日志里直接能看到所有关键信息。我习惯把所有非敏感的、体积不大的字段都放进去,同时把派生值(平均分、等级)也算进去,因为排查问题时这些数字就是第一现场。

equals和hashCode更容易出问题。我这里的判断规则是:学号相同就是同一个学生。这个选择决定了对象在HashSet、HashMap里的行为。如果你用所有字段一起比较,那么同一个学生改了一次姓名之后就不再等于自己,放进Set会变成两条记录,这是很隐蔽的 bug。

重写equals必须重写hashCode,而且要用同样的字段集。我这里两边都只用id,保持一致。如果equals用id、hashCode用id + name,会出现“两个对象相等但哈希值不同”的情况,HashMap直接失效——这个 bug 极难排查,因为代码看起来完全正常。写完三件套,我建议立刻写一个测试用例专门验证“相等的两个对象 hashCode 必须相等”,这是最划算的一道保险。

3.4 测试类:先用 main 方法跑通

入门阶段用main方法做验证完全可以接受,关键是要有断言意识——不能靠肉眼扫一眼输出觉得“差不多对”。

public class StudentTest { private static int passed = 0; private static int failed = 0; public static void main(String[] args) { Student s = new Student("2024001", "A同学", 19, Gender.MALE); check("基本属性", "2024001".equals(s.getId())); check("姓名去空格", "A同学".equals(new Student("2024002", " A同学 ").getName())); s.putScore("数学", 92); s.putScore("语文", 85); s.putScore("英语", 78); check("科目数量", s.getCourseCount() == 3); check("总分", Math.abs(s.getTotal() - 255.0) < 1e-9); check("平均分", Math.abs(s.getAverage() - 85.0) < 1e-9); check("等级为良好", "良好".equals(s.getLevel())); check("覆盖同一科目", coverScoreCase()); check("非法年龄被拦截", throwsCase(() -> new Student("2024003", "B同学", 200, Gender.MALE))); check("非法成绩被拦截", throwsCase(() -> new Student("2024004", "C同学").putScore("数学", 120))); check("空姓名被拦截", throwsCase(() -> new Student("2024005", " "))); check("成绩表不可被外部修改", immutableCase()); check("equals 与 hashCode 一致", equalsCase()); System.out.println("通过 " + passed + " 项,失败 " + failed + " 项"); if (failed > 0) { throw new AssertionError("存在未通过的测试"); } } private static boolean coverScoreCase() { Student s = new Student("2024010", "D同学"); s.putScore("数学", 60); s.putScore("数学", 95); return s.getCourseCount() == 1 && Math.abs(s.getAverage() - 95.0) < 1e-9; } private static boolean immutableCase() { Student s = new Student("2024011", "E同学"); s.putScore("数学", 88); try { s.getScores().put("语文", 99); return false; } catch (UnsupportedOperationException e) { return true; } } private static boolean equalsCase() { Student a = new Student("2024012", "F同学", 20, Gender.FEMALE); Student b = new Student("2024012", "G同学", 21, Gender.MALE); return a.equals(b) && a.hashCode() == b.hashCode() && !a.equals(null); } private static boolean throwsCase(Runnable r) { try { r.run(); return false; } catch (IllegalArgumentException e) { return true; } } private static void check(String name, boolean condition) { if (condition) { passed++; System.out.println("[PASS] " + name); } else { failed++; System.out.println("[FAIL] " + name); } } }

这份测试类里有两个小技巧值得单独说。throwsCase接收一个Runnable,用 lambda 表达式把“期望抛异常”的逻辑包起来,这样十个异常用例不用堆十段 try-catch。equalsCase里那个a.equals(b)是真的会返回 true 的——虽然两个对象的姓名和年龄不同,但学号相同,按我们的规则就是同一个人。这个用例本质上是在用测试固化设计决策:以后谁想改成“所有字段比较”,测试立刻变红。

3.5 Python 版本对照

Python 里没有private关键字,靠命名约定(前缀下划线)和@property实现类似效果:

class Gender: MALE = "MALE" FEMALE = "FEMALE" UNKNOWN = "UNKNOWN" class Student: MIN_AGE, MAX_AGE = 6, 100 def __init__(self, sid: str, name: str, age: int = 18, gender: str = Gender.UNKNOWN): self._id = self._require_text(sid, "学号") self._name = self._require_text(name, "姓名") self.age = age self.gender = gender self._scores = {} @staticmethod def _require_text(value, field): if not isinstance(value, str) or not value.strip(): raise ValueError(f"{field}不能为空") return value.strip() @property def sid(self): return self._id @property def name(self): return self._name @name.setter def name(self, value): self._name = self._require_text(value, "姓名") @property def age(self): return self._age @age.setter def age(self, value): if not isinstance(value, int) or not (self.MIN_AGE <= value <= self.MAX_AGE): raise ValueError(f"年龄必须在 {self.MIN_AGE} 到 {self.MAX_AGE} 之间,传入:{value}") self._age = value def put_score(self, course, score): if not (0 <= score <= 100): raise ValueError(f"成绩必须在 0 到 100 之间,传入:{score}") self._scores[self._require_text(course, "科目名")] = float(score) @property def scores(self): return dict(self._scores) @property def total(self): return sum(self._scores.values()) @property def average(self): return self.total / len(self._scores) if self._scores else 0.0 @property def level(self): if not self._scores: return "暂无成绩" avg = self.average for threshold, label in ((90, "优秀"), (80, "良好"), (70, "中等"), (60, "及格")): if avg >= threshold: return label return "不及格" def __str__(self): return f"Student(sid={self._id!r}, name={self._name!r}, age={self._age}, 平均分={self.average:.2f}, 等级={self.level})" def __eq__(self, other): return isinstance(other, Student) and self._id == other._id def __hash__(self): return hash(self._id)

Python 版的几个对应关系:__str__对应toString,__eq__/__hash__对应equals/hashCode,@property对应 getter,@age.setter对应 setter 加校验。scores属性返回dict(self._scores)就是防御性拷贝,外部改这个副本不会影响内部。如果你用@dataclass,记住它默认生成的__eq__会比较所有字段,需要eq=False然后自己写,或者用field(compare=False)排除掉不该参与比较的字段——这个坑很多人踩过。

4. 测试这件事,怎么才算真的测了

4.1 手工 main 方法的三个硬伤

用main方法验证不是不行,但它有三个绕不过去的问题。第一,不可回归。今天加了getLevel(),明天改了校验逻辑,你还得手动跑一遍、肉眼对比输出,跑了三次之后就懒得跑了,然后就出事了。第二,输出靠肉眼判断。打印一堆Student{...}然后自己看“这个平均分好像是 85 吧”,这不是测试,这是阅读理解。第三,异常会把整个流程打断。一个用例抛出未捕获的异常,后面的用例全都不执行,你以为测了十个,其实只跑了三个。

解决这三个问题的成本极低:把每个检查点写成断言,最后统计通过/失败数量,有失败就抛出异常让整个流程失败。上面那份StudentTest就是这么做的,二十行代码换来的是“一键可回归”。

4.2 换成正式的单元测试框架

真正写项目时,直接上 JUnit 5,别自己造轮子:

import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class StudentTest { private Student student; @BeforeEach void setUp() { student = new Student("2024001", "A同学", 19, Gender.MALE); } @Test @DisplayName("正常成绩录入后平均分计算正确") void averageIsCorrect() { student.putScore("数学", 92); student.putScore("语文", 85); student.putScore("英语", 78); assertEquals(255.0, student.getTotal(), 1e-9); assertEquals(85.0, student.getAverage(), 1e-9); assertEquals("良好", student.getLevel()); } @Test @DisplayName("年龄越界时抛出 IllegalArgumentException") void invalidAgeThrows() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> student.setAge(200)); assertTrue(ex.getMessage().contains("年龄")); } @Test @DisplayName("返回的成绩表是只读副本") void scoresAreImmutable() { student.putScore("数学", 90); assertThrows(UnsupportedOperationException.class, () -> student.getScores().put("语文", 100)); } @Test @DisplayName("相同学号的对象相等且哈希一致") void equalityByStudentId() { Student same = new Student("2024001", "另一个名字", 22, Gender.FEMALE); assertEquals(student, same); assertEquals(student.hashCode(), same.hashCode()); } }

@BeforeEach的作用是每个测试方法执行前都重新造一个干净的对象,避免用例之间互相污染。这一点非常关键——我见过太多“测试单独跑能过、一起跑就挂”的情况,根源都是共享了状态。@DisplayName写的是人话,测试报告出来时你能立刻知道哪条规则挂了,而不只是看到一个方法名。

4.3 边界值用例该怎么设计

测试用例不是越多越好,是越“刁钻”越好。针对这个类,我按下面这张表设计,每一条都对应一个可能出错的地方:

用例输入期望结果覆盖的风险点
年龄下界age = 6创建成功边界包含性
年龄下界减一age = 5抛 IllegalArgumentException<写成<=的笔误
年龄上界age = 100创建成功边界包含性
年龄上界加一age = 101抛异常同上
年龄为负age = -1抛异常负数未拦截
姓名为 nullname = null抛异常空指针防护
姓名全空格name = " "抛异常trim 后再判断
成绩为 0score = 0.0录入成功0 分是合法成绩
成绩为 100score = 100.0录入成功满分是合法成绩
成绩为 100.01score = 100.01抛异常上界外
成绩为 NaNscore = Double.NaN抛异常NaN 的比较永远为 false
无成绩时求平均不录入任何科目返回 0.0除零保护
无成绩时求等级不录入任何科目返回“暂无成绩”状态区分
重复录入同一科目两次 put 同一科目科目数为 1,取最后一次覆盖语义
外部改返回的 mapgetScores().put(...)抛 UnsupportedOperationException防御性拷贝
同 id 不同姓名两个对象equals 为 true,hashCode 相同三件套一致性

这里最值得一提的是Double.NaN那条。NaN和任何数比较都返回 false,所以score < 0 || score > 100这个判断对NaN会返回 false,等于放行了一个非法值。如果你不做Double.isNaN(score)这层判断,一个NaN成绩进去之后,总分全变NaN,等级判断全部落空,最后返回“不及格”,排查起来能把人逼疯。这种“看起来不可能发生”的输入恰恰是测试最该覆盖的。

4.4 测试代码本身的质量

测试代码也是代码,同样需要维护。我的几条经验:一个测试方法只验证一件事,别在一个方法里既测平均分又测异常;测试方法名要能当文档读,averageIsCorrect比test1强一百倍;不要在测试里写复杂的逻辑,测试本身出 bug 比被测代码出 bug 更难发现;断言消息要带上下文,assertEquals(85.0, avg)挂了之后你只知道期望 85,但不知道实际是多少,用assertEquals(85.0, avg, "三门课 92/85/78 的平均分")会舒服很多。

5. 踩过的坑与排查实录

5.1 空指针的三种典型触发点

NullPointerException是这类练习里出现频率最高的异常,来源基本集中在三处。第一处是构造时传了null的姓名,然后 setter 里直接name.trim(),在 trim 那一行炸掉。解法是先判null再 trim,顺序不能反。第二处是getScore(course)返回了Double,调用方直接拆箱参与运算,遇到不存在的科目就炸。解法有两个:要么返回double并用0.0兜底(但要明确“没考”和“考了 0 分”的区别),要么在文档里写清楚可能返回null并让调用方判空。第三处是scores字段忘了初始化,putScore里直接scores.put(...)就炸。用private final Map<...> scores = new LinkedHashMap<>();在声明处初始化,这个坑就堵死了。

5.2 浮点精度与统计不一致

有人反馈“明明三门课 92、85、78,平均分应该是 85,怎么显示 84.99999999999999”。原因很简单:92 + 85 + 78 = 255,255 / 3 = 85,这个具体例子其实是精确的,但换成 91、84、79 就会出现小数位。解决办法有两个层次:展示层用String.format("%.2f", avg)格式化,把视觉上的差异抹掉;判断层用容差比较(前文的EPS方案),别用==。如果业务上有明确的“保留一位小数后四舍五入”的规则(比如等级评定按显示值算),那就在getAverage里用BigDecimal.setScale(1, RoundingMode.HALF_UP)算完再返回,把规则固化在方法里,别让每个调用方各自处理。

5.3 静态字段误用导致的“串数据”

static是个很容易误用的修饰符。假设你把成绩 map 写成private static Map<String, Double> scores = new HashMap<>();,那么所有Student实例共享同一个 map。创建两个学生,录第一个人的成绩,第二个人的成绩里也会出现——因为压根就是同一份数据。这个 bug 的表现极具迷惑性:单元测试单独跑通过,两个对象一起跑就串了。判断标准很简单:这个字段描述的是“某个学生”还是“所有学生”。学号、姓名、成绩都是前者,不加static;MIN_AGE、MAX_SCORE这类全类共享的常量才加static(配合final)。

5.4 常见问题速查表

现象最可能的根因处理方式
平均分永远是 0成绩根本没录入,或用了int除法检查putScore是否被调用,除法操作数至少一个是double
修改返回的集合后内部数据变了getter 直接返回了内部引用返回unmodifiableMap(new LinkedHashMap<>(...))
两个不同学生equals判定相等只比较了部分字段或学号重复检查学号唯一性与equals字段集
对象放进HashSet后出现重复重写了equals没重写hashCode两者用同一字段集同步重写
等级判断总是“不及格”阈值写反或平均分是NaN打印中间值,检查输入是否含NaN
构造就抛异常传入的姓名带空格且校验在 trim 之前先trim再判空
打印出Student@1b6d3586没重写toString补上toString,包含关键字段
测试单独跑过、一起跑挂测试用例共享了静态状态用@BeforeEach每轮重建对象

5.5 我个人的两条排查习惯

第一条:先打印中间值,再猜原因。新手遇到问题喜欢盯着代码“推理”,推十分钟不如在可疑位置加一行System.out.println来得快。第二条:从边界开始测,别一上来就测那个最正常的例子。正常的例子大概率是对的,边界才藏着问题。我排查线上问题时也是这个顺序——先看最极端的输入,再看平均值。

6. 从练习题到能用的代码,还差哪几步

6.1 用集合管理一批学生

单个Student类只是零件,真正有价值的场景是“一个班级”。这时候需要一个管理类:

import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional; public class StudentManager { private final List<Student> students = new ArrayList<>(); public void add(Student student) { if (student == null) { throw new IllegalArgumentException("学生对象不能为空"); } boolean exists = students.stream().anyMatch(s -> s.getId().equals(student.getId())); if (exists) { throw new IllegalArgumentException("学号已存在:" + student.getId()); } students.add(student); } public Optional<Student> findById(String id) { return students.stream().filter(s -> s.getId().equals(id)).findFirst(); } public List<Student> sortedByAverageDesc() { List<Student> copy = new ArrayList<>(students); copy.sort(Comparator.comparingDouble(Student::getAverage).reversed()); return copy; } public double classAverage() { return students.stream().mapToDouble(Student::getAverage).average().orElse(0.0); } }

这里有几个设计决定值得解释。add里检查学号重复,是为了维护“学号唯一”这条业务规则——这条规则如果不在这一层挡,就得靠数据库唯一索引兜底,而等到数据库报错时用户体验已经很差了。findById返回Optional而不是null,是为了强迫调用方处理“找不到”的情况,这是比返回null更现代的做法。sortedByAverageDesc先复制再排序,避免把内部列表的顺序改掉——这和前面防御性拷贝是同一个道理。classAverage用stream一行算完,注意orElse(0.0),空班级不会除零。

6.2 排序与比较器的选择

按平均分排序看起来很直接,但有几个陷阱。Comparator.comparingDouble(Student::getAverage)默认是升序,要降序得加reversed()。如果两个学生平均分相同,排序结果是不稳定的——List.sort用的是稳定排序,这里“不稳定”的意思是两份原始顺序不同的列表排出来顺序不同。如果业务上要求“平均分相同按学号升序”这种二级排序,就写comparingDouble(...).reversed().thenComparing(Student::getId)。顺序不能写反,.reversed()只作用于它前面那一段,这个细节我踩过,排查了半天才发现是把reversed()写到了链式调用的最后,导致二级排序也被反转了。

如果让Student实现Comparable,我建议慎重。Comparable表达的是“天然顺序”,而学生这种东西没有天然的先后——按学号排还是按成绩排,取决于具体场景。所以更适合用外部的Comparator,需要什么顺序就传什么比较器,别把某一种排序规则硬编码进类里。

6.3 留给下一步的扩展点

这个类往后能长成什么样,取决于你想练哪块。想做持久化,就加上把对象转成 JSON 的能力,注意final字段和没有无参构造会跟很多序列化框架打架,通常得补一个无参构造或者用框架提供的注解。想做界面,就把toString换成结构化的数据输出,让界面层去负责渲染。想做统计,就在管理类里加分布计算(各等级人数、最高最低分、标准差),这时候你会发现当初“派生数据不入字段”的决定有多正确——所有统计都从原始成绩现场算,永远不会出现数据不一致。

最后一个建议:把这份代码真实地敲一遍,别复制粘贴。这个练习的价值不在于最终那个文件长什么样,而在于敲的过程中你会不断遇到“这个字段该不该给 setter”“这里要不要判空”“这个异常该抛还是该返回”这类小决定。每做一次决定,你对“什么是好的类设计”的理解就具体一分。我自己在带人时有个观察:能把Student这个练习认真做到有校验、有防御性拷贝、有边界测试的人,后面接手真实业务代码时,出的低级 bug 明显更少。这不是因为这个类有多难,而是因为它把面向对象里最容易含糊的那几个点——封装边界、状态一致性、可测试性——全都浓缩在了一百行代码里。

返回列表