面向对象的第一课,十有八九是从“设计一个学生类Student,并进行测试”这道题开始的。我带过不少刚入行的同学,也在面试里问过类似的题,发现一个挺有意思的现象:绝大多数人能在一分钟内把代码敲出来,但真正被追问“你为什么这么设计”“你怎么证明它是对的”的时候,能答清楚的人不到三成。这道题表面上考的是语法,实际上考的是抽象能力、封装意识和验证习惯这三样东西,而这三样恰恰是后面写任何业务代码都绕不开的地基。这篇内容我会把这个练习从头拆一遍,包括字段怎么定、方法怎么划边界、参数校验放在哪一层、测试用例怎么设计,以及我在评审代码时最常看到的那几个坑。适合刚学面向对象的朋友,也适合工作几年但一直靠IDE一键生成getter/setter、从没认真想过为什么的人。
1. 先想明白:这个练习到底在练什么
1.1 为什么几乎所有教材都拿Student类开刀
教材选它当第一个例子不是偷懒,而是因为这个概念在现实里足够具体,抽象起来几乎没有歧义。一个学生身上有姓名、有学号、有年龄、有成绩,这些东西天然对应“属性”;一个学生能参加考试、能算总分、能判断是否及格,这些天然对应“方法”。换成“订单类”或者“账户类”,初学者得先理解业务规则才能动手,认知负担一下就上去了。
你可以把一个类理解成一张纸质档案表:表头那一行就是字段,表格里能填的内容就是属性值,而“拿着这张表能做什么事”(比如算出总分、判断能不能评优)就是方法。这张表一旦印好,格式就固定了,谁填都得按这个格式来——这就是类约束对象的能力。理解了这个类比,后面所有的封装、继承、多态都是在这张表上做文章。
更关键的是,Student类恰好踩中了几个能自然延伸的知识点。你只要把成绩字段加进去,就必然要面对“成绩不能是负数也不能超过100”这样的约束,于是封装和参数校验有了落点;你只要再加一个“研究生”的需求,继承和多态就有了用武之地;你只要想同时管理几十个学生,集合和排序就顺理成章。这种“一个例子能串起半本教材”的特性,是它被反复选用的真正原因。
1.2 标题里藏着的三个真实考察点
我把这道题拆成三层来看,每一层对应一种能力,而且难度是递增的。
第一层是抽象:你能不能从“学生”这个模糊的现实概念里,挑出真正需要进程序的字段。这一层最容易犯的错是贪多,恨不得把身高、体重、家庭住址、父母电话全塞进去,结果类越来越臃肿,真正要用的没几个。判断标准其实很简单——当前需求用不到、未来大概率也用不到的字段,一律不写。宁可后面加,也不要一开始就堆。
第二层是封装:你能不能把字段藏起来,只通过受控的入口去读写。这一层的典型反例是“所有字段public”,写起来确实爽,但只要有任何一个地方把成绩赋成了-5,你就得翻遍整个工程去找是谁干的。封装的价值不在于形式上的private,而在于你把“什么值是合法的”这条规则收拢到了一个地方。
第三层是验证:你能不能用可重复执行的方式证明这个类是对的。绝大多数人做到第二层就停了,写个main方法打印几行,肉眼看看输出没问题就交差。但手工验证的问题在于——它不可重复、不可回归,你今天改了一行代码,昨天验证过的东西得重新看一遍。测试这件事,只有写成代码,它才有长期价值。
1.3 不同语言落地时的差异对照
这道题在Java、Python、C++、C#里写法差别不小,很多坑其实是语言特性带来的。先看一张对照表,心里有个谱:
| 关注点 | Java | Python | C++ | C# |
|---|---|---|---|---|
| 字段私有化手段 | private 修饰符 | 命名约定_name+@property | private 区段 + 头文件声明 | private 字段 + 属性 |
| 只读字段 | final | @property只给 getter | const 成员 + 初始化列表 | 只有 getter 的属性 |
| 相等性比较 | 重写 equals/hashCode | 重写__eq__和__hash__ | 重载operator== | 重写 Equals/GetHashCode |
| 打印输出 | 重写 toString | 重写__str__/__repr__ | 重载operator<< | 重写 ToString |
| 参数校验的惯用位置 | 构造方法 + setter | __init__+ property setter | 构造方法 + setter | 构造方法 + 属性 setter |
这张表里最值得留意的是“只读字段”这一行。学号这种东西,一旦生成就不该再变,Java用final、C++用const、Python用只读property,都是同一个意思。很多人的代码里学号带个setter,理由是“万一录错了要改”——录错了应该删掉重建,而不是让一个唯一标识符在系统里漂移,这是设计原则问题,不是方便不方便的问题。
2. 需求拆解:字段和方法到底怎么定
2.1 字段取舍:哪些该进,哪些坚决不进
先列一版最常见的初学者字段清单:学号、姓名、性别、年龄、班级、成绩、电话、住址、身份证号、入学时间。看着挺全,但这里面至少有三个值得商榷。
年龄不该直接存。年龄是一个随时间变化的值,你今年存18,明年它还是18,数据就失真了。正确做法是存出生日期,需要年龄时用当前日期减一下算出来。这个思路在业务系统里极其常见——凡是能由其他字段推导出来的值,一般不单独存,除非有明确的性能要求。同理,“平均分”“总分”这类字段也不该进Student,它们应该由方法算出来。
身份证号、家庭住址这类敏感信息要慎入。练习阶段无所谓,但你要养成一个意识:一个类里放的字段越敏感,它被日志打印、被序列化、被随手toString出去的风险就越大。我在实际项目里见过不止一次因为调试时打了完整对象日志,把用户信息写进日志文件的事故。
性别字段用什么类型值得想一想。用String存“男/女”是最省事的,但拼错一个字符就多出一个新类别;用枚举更稳,代价是代码量上去了。练习阶段用String没问题,但要知道枚举才是生产环境的常规做法。
我的建议是这版字段清单:学号(String)、姓名(String)、年龄(int,或者换成出生日期)、成绩(double或int)。四个字段足够把封装、校验、测试全都演练一遍,多一个都是负担。
2.2 学号为什么用String而不是int
这个问题我被问过很多次,值得单独说。用int存学号,表面上没问题,但有三个隐患。
第一是前导零丢失。真实的学号经常是“20240001”这种定长编码,一旦转成int,前面的0就没了,再转回字符串变成“20240001”还好,但如果是“0012”这种,直接变“12”,跟其他系统对接时会对不上。第二是长度溢出。18位数字超出了int的范围,用long能撑住,但再长呢?第三是语义错误。学号是标识符,不是数量,你不会拿两个学号相加,也不会比较谁大谁小——虽然字典序排序有时候有用,但那也是字符串排序。标识符用String是通用做法,同时也为将来可能出现的字母前缀(比如某些编码规则里带院系字母)留了余地。
同理,电话号码、身份证号、订单号,全都是String。这条经验我建议直接背下来。
2.3 方法设计:从构造到行为的分层
方法这块我一般分成四组来设计,每组有各自的职责边界,混在一起就容易出问题。
第一组是构造方法。它负责把一个对象的初始状态建立起来,并且保证建立出来的对象是合法的。注意“合法”这两个字——如果构造出来的对象成绩是-10,那这个构造方法就是失职的。所以校验逻辑必须进构造方法,不能指望调用方自觉。
第二组是访问器。getter和setter,负责受控读写。这里的关键是setter里必须有和构造方法一致的校验规则,否则等于开了个后门。
第三组是业务方法。比如isPassed()判断是否及格、getGradeLevel()返回等级、getAge()由出生日期算年龄。这类方法把“规则”从调用方搬进了类内部,是封装价值最直接的体现。
第四组是对象通用方法。toString()、equals()、hashCode(),这三个东西不写也能跑,但一旦你把Student放进集合、做去重、打日志,问题就全冒出来了。
2.4 封装边界:setter不是越多越好
IDE里一个快捷键就能生成全套getter/setter,太顺手了,顺手到很多人从来不想“这个字段到底该不该有setter”。我的判断标准是三条:
- 字段是对象的身份标识(学号),只给getter,不给setter,构造时定死。
- 字段的变更需要校验(成绩、年龄),给setter,但里面必须带校验。
- 字段是纯派生值(年龄由生日算),压根不存,只给getter方法。
按这个标准走一遍,你会发现真正需要setter的字段可能只有两个。这就是为什么我说“无脑生成”是有害的——它把设计决策这件事给跳过了。
提示:如果你发现某个类的setter数量远多于业务方法数量,先别急着写下去,回头看看是不是有一堆字段其实根本不需要修改,或者根本不该存在。
3. 动手实现:一份可以直接跑的完整代码
3.1 Java版基础实现
先看主线实现。这段代码不长,但每一行都有考虑,我逐段解释。
public class Student { private final String studentId; private String name; private int age; private double score; public Student(String studentId, String name, int age, double score) { if (studentId == null || studentId.trim().isEmpty()) { throw new IllegalArgumentException("学号不能为空"); } this.studentId = studentId.trim(); setName(name); setAge(age); setScore(score); } public String getStudentId() { return studentId; } public String getName() { return name; } public void setName(String name) { if (name == null || name.trim().isEmpty()) { throw new IllegalArgumentException("姓名不能为空"); } this.name = name.trim(); } public int getAge() { return age; } public void setAge(int age) { if (age < 6 || age > 100) { throw new IllegalArgumentException("年龄不在合理范围内: " + age); } this.age = age; } public double getScore() { return score; } public void setScore(double score) { if (score < 0 || score > 100) { throw new IllegalArgumentException("成绩必须在0到100之间: " + score); } this.score = score; } public boolean isPassed() { return score >= 60; } public String getGradeLevel() { if (score >= 90) return "优秀"; if (score >= 80) return "良好"; if (score >= 70) return "中等"; if (score >= 60) return "及格"; return "不及格"; } @Override public String toString() { return "Student{id='" + studentId + "', name='" + name + "', age=" + age + ", score=" + score + "}"; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Student student = (Student) o; return studentId.equals(student.studentId); } @Override public int hashCode() { return studentId.hashCode(); } }几个细节值得单独拎出来说。构造方法里没有直接this.name = name,而是调用了setName(name),目的是让校验规则只写一份,构造和修改走同一条路径。这个做法叫“构造方法复用setter”,好处是规则统一,坏处是如果setter被子类重写,构造阶段可能会调到子类版本带来意外——所以更稳妥的写法是把校验抽成一个私有的静态方法,构造和setter都调它。练习阶段用前者没问题,但要知道这个坑的存在。
equals只比较学号,这是个设计选择。两个学号相同的对象,即使姓名成绩不同,在业务上也应该视为同一个人,这是“业务主键相等”的思路。如果你的场景要求所有字段都一致才算相等,那就全字段比较,但记得hashCode要和equals保持一致,否则放进HashSet会出现“明明有重复却去不掉”的诡异现象。
3.2 Python版本对照与写法差异
Python没有访问修饰符,靠命名约定和property来模拟封装,写起来短很多,但坑一点不少。
class Student: def __init__(self, student_id: str, name: str, age: int, score: float): if not student_id or not student_id.strip(): raise ValueError("学号不能为空") self._student_id = student_id.strip() self.name = name self.age = age self.score = score @property def student_id(self): return self._student_id @property def name(self): return self._name @name.setter def name(self, value): if not value or not value.strip(): raise ValueError("姓名不能为空") self._name = value.strip() @property def age(self): return self._age @age.setter def age(self, value): if not 6 <= value <= 100: raise ValueError(f"年龄不在合理范围内: {value}") self._age = value @property def score(self): return self._score @score.setter def score(self, value): if not 0 <= value <= 100: raise ValueError(f"成绩必须在0到100之间: {value}") self._score = value def is_passed(self) -> bool: return self._score >= 60 def __eq__(self, other): if not isinstance(other, Student): return NotImplemented return self._student_id == other._student_id def __hash__(self): return hash(self._student_id) def __repr__(self): return (f"Student(id={self._student_id!r}, name={self._name!r}, " f"age={self._age}, score={self._score})")Python这里最容易踩的是__eq__和__hash__的联动。你只重写__eq__不重写__hash__,这个类就变成不可哈希的,往set里一放直接报错。反过来只重写__hash__不改__eq__,去重逻辑还是错的。这两个方法必须成对处理,Java里也是一样的道理。
另外注意__eq__里返回NotImplemented而不是False,这是Python的协议约定,让解释器有机会去尝试对方的反射比较。返回False虽然大多数时候也能跑,但在混合类型比较的场景下行为会不对。
3.3 参数校验该放在哪一层
这是个有争议的话题,我的观点是:校验分两层,格式和范围校验放在类内部,业务规则校验放在服务层。
类内部该管的是“这个字段本身是否合法”:成绩在0到100之间、姓名非空、年龄在合理区间。这些规则和业务无关,换了任何系统都成立,所以属于类自己的责任。
服务层该管的是“这个对象在当前场景下是否允许存在”:比如“同一个学号不能重复注册”“转专业的学生成绩不能为空”这类规则,依赖外部数据,不该塞进Student类里。硬塞进去的后果是类越来越重,测试越来越难写,因为每个测试都得准备好一整套外部环境。
判断标准可以简化成一句话:如果这条规则只依赖对象自己的字段就能判断,放类里;如果需要查数据库、查配置、问别的对象,放外面。
4. 测试环节:从“跑通”到真正验证
4.1 手工测试的问题在哪
绝大多数人的第一步是写个main方法:
public static void main(String[] args) { Student s = new Student("2024001", "某同学", 18, 85.5); System.out.println(s); System.out.println(s.isPassed()); }跑一下,控制台打印正常,就算完事。这个做法本身没问题,作为开发过程中的快速自检非常高效,我到现在也天天这么干。但它有三个绕不过去的问题。
第一,没有断言。打印出来的东西对不对,全靠你自己看。人眼在连续看十行输出之后就开始疲劳了,第11行的异常值很可能被忽略掉。第二,不会自动回归。你明天改了setScore的实现,昨天那份main方法里的输出变了,但只要你还记得去跑一遍、跑完还记得看结果,才能发现问题——这两个“只要”在实际工作中几乎不可能保证。第三,覆盖不全。手工测试天然倾向于测正常路径,边界值和异常路径最容易被跳过,而恰恰是这两类最容易出bug。
4.2 用单元测试框架写可复现的验证
换成JUnit 5,同样的验证就变成了可执行、可回归的资产。先看正常路径:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class StudentTest { @Test void shouldCreateValidStudent() { Student s = new Student("2024001", "某同学", 18, 85.5); assertEquals("2024001", s.getStudentId()); assertEquals("某同学", s.getName()); assertEquals(18, s.getAge()); assertEquals(85.5, s.getScore(), 0.001); } @Test void shouldRejectScoreOutOfRange() { assertThrows(IllegalArgumentException.class, () -> new Student("2024001", "某同学", 18, 101)); assertThrows(IllegalArgumentException.class, () -> new Student("2024001", "某同学", 18, -0.1)); } @Test void shouldRejectBlankName() { assertThrows(IllegalArgumentException.class, () -> new Student("2024001", " ", 18, 85)); assertThrows(IllegalArgumentException.class, () -> new Student("2024001", null, 18, 85)); } @Test void shouldTreatSameIdAsEqual() { Student a = new Student("2024001", "某同学", 18, 85); Student b = new Student("2024001", "另一个人", 20, 70); assertEquals(a, b); assertEquals(a.hashCode(), b.hashCode()); } @Test void shouldDistinguishDifferentIds() { Student a = new Student("2024001", "某同学", 18, 85); Student b = new Student("2024002", "某同学", 18, 85); assertNotEquals(a, b); } }注意assertEquals(85.5, s.getScore(), 0.001)里的第三个参数。浮点数比较不能直接用相等,因为二进制表示的原因,0.1 + 0.2不等于0.3。这个规则在测试里必须遵守,否则你会遇到“代码明明没问题测试却红了”的情况,然后花半小时怀疑人生。
Python用pytest写起来更简洁:
import pytest def test_reject_score_out_of_range(): with pytest.raises(ValueError): Student("2024001", "某同学", 18, 101) with pytest.raises(ValueError): Student("2024001", "某同学", 18, -1) def test_same_id_is_equal(): a = Student("2024001", "某同学", 18, 85) b = Student("2024001", "另一个人", 20, 70) assert a == b assert hash(a) == hash(b)4.3 测试用例设计:等价类与边界值
写测试最怕的是“想到哪写到哪”,测了十几个用例,边界上一个没覆盖。用等价类划分加边界值分析这套方法,能保证你用最少的用例覆盖最多的风险。
拿成绩字段举例,规则是0到100之间合法。划分下来是这样:
| 用例类型 | 输入值 | 预期结果 | 覆盖意图 |
|---|---|---|---|
| 正常值 | 85 | 创建成功 | 常规路径 |
| 有效下边界 | 0 | 创建成功 | 边界内侧 |
| 有效上边界 | 100 | 创建成功 | 边界内侧 |
| 无效下边界 | -0.1 | 抛异常 | 边界外侧 |
| 无效上边界 | 100.1 | 抛异常 | 边界外侧 |
| 极端异常值 | -999 | 抛异常 | 远离边界的无效值 |
| 特殊值 | NaN | 抛异常 | 容易漏掉的特殊输入 |
边界值为什么必须单独测:因为绝大多数bug都出在>和>=写反、<和<=混淆这类地方。你把0和100测了,才能确认边界是“包含”还是“不包含”,光测个85是永远发现不了这个问题的。
NaN这个用例值得特别说一句:double类型的NaN是个神奇的值,它和任何数比较都返回false,所以score < 0 || score > 100这个判断对NaN是失效的——两个比较都是false,校验直接放行。这是一个真实存在的漏洞,我在评审代码时见过。修法是加一句Double.isNaN(score)的判断,或者干脆用!(score >= 0 && score <= 100)这种写法,让NaN自然落进异常分支。
注意:测试用例的命名要能说明意图。
test1、test2这种名字三个月后你自己都看不懂,而shouldRejectScoreOutOfRange一眼就知道在测什么。这是小事,但坚持下来能省很多时间。
5. 常见问题与排查技巧实录
5.1 那些年踩过的坑
第一个坑:equals重写了,hashCode忘了。这两个方法有个契约:equals判定相等的两个对象,hashCode必须相同。只改一个,把对象放进HashSet或者当HashMap的key时,就会出现“明明两个对象相等,集合里却存了两份”的现象。这种bug特别隐蔽,因为单看代码逻辑完全正确,只有跑起来才发现不对。IDE生成的时候记得两个一起生成。
第二个坑:用 == 比较字符串。学号去重的时候写if (a.getStudentId() == b.getStudentId()),小数据量下可能碰巧是对的(字符串常量池),数据一多就崩。字符串内容比较永远是equals。
第三个坑:静态字段误用。有次看到一段代码,字段声明写了private static String name;,结果创建三个学生,打印出来名字全是最后一个。静态字段属于类不属于对象,所有实例共享一份。这个错误在初学者代码里出现频率相当高,而且很难自查出来,因为代码能编译能运行,只是结果不对。
第四个坑:setter绕过校验。构造方法里做了成绩范围校验,setter里图省事写成this.score = score;,于是s.setScore(-50)畅通无阻。修法就是构造和setter走同一个校验方法。
第五个坑:toString泄露敏感信息。练习里无所谓,但如果字段里有手机号、身份证号,直接在toString里拼出来,一旦被打进日志就收不回来了。生产环境里这类字段要么脱敏,要么干脆不进toString。
5.2 问题排查速查表
| 现象 | 可能原因 | 定位方法 | 解决方式 |
|---|---|---|---|
| 集合去重失效 | 只重写equals未重写hashCode | 打印两个对象的hashCode对比 | 两个方法成对重写 |
| 所有对象字段值相同 | 字段误加static | 检查字段声明有无static | 去掉static |
| 成绩校验形同虚设 | 输入为NaN | 打印入参,观察比较结果 | 补NaN判断或改判断写法 |
| 修改字段后校验失效 | setter未复用校验逻辑 | 搜索所有赋值点 | 抽出统一校验方法 |
| 浮点比较测试失败 | 直接用==比较double | 打印两者差值 | 改用误差范围比较 |
| 构造成功但对象状态异常 | 构造方法未校验 | 逐个字段打印初始值 | 校验逻辑前置到构造 |
| 子类对象构造报错 | 构造中调用了可重写方法 | 打断点看调用栈 | 改为调用私有静态方法 |
5.3 代码评审视角:Student类里不该出现什么
从评审的角度看,一个“干净”的Student类应该满足几条:字段全部私有;没有无参构造之外的隐式公开构造;没有把内部集合直接暴露出去的getter(这条在加了课程列表之后才会遇到);没有System.out.println;没有业务无关的工具方法。
最后一条值得展开。我见过把calculateAverage()、sortStudents()、exportToExcel()全塞进Student类的代码,理由都是“和学生有关”。但Student这个类应该只对自己的单个实例负责,排序是集合的事,导出是序列化的事,平均分是统计的事。判断一个方法该不该在类里,问一句“这个方法需要几个Student对象”就够了:只需要一个,可以放;需要多个,放到别处去。这个标准简单粗暴但非常好用。
6. 从这个练习往下走的几个方向
6.1 需求升级:从单个对象到集合管理
单类写熟了之后,最自然的下一步是管理一批学生。这一步会把你从“类设计”带到“数据结构选择”。
import java.util.*; public class StudentManager { private final Map<String, Student> byId = new LinkedHashMap<>(); public void add(Student student) { if (byId.containsKey(student.getStudentId())) { throw new IllegalStateException("学号重复: " + student.getStudentId()); } byId.put(student.getStudentId(), student); } public Student findById(String id) { return byId.get(id); } public List<Student> sortByScoreDesc() { List<Student> list = new ArrayList<>(byId.values()); list.sort(Comparator.comparingDouble(Student::getScore).reversed()); return list; } public double averageScore() { return byId.values().stream() .mapToDouble(Student::getScore) .average() .orElse(0.0); } }这里用LinkedHashMap而不是ArrayList是有考虑的:按学号查找是最高频的操作,用Map能做到常数时间;同时需要保持插入顺序,所以用LinkedHashMap而不是HashMap。如果只用ArrayList,每次findById都得遍历一遍,学生数量上去之后性能差别很明显。
averageScore()用了orElse(0.0),处理空集合的情况。空集合求平均,数学上是未定义,返回0是一种约定,但你要在文档里写清楚,否则调用方会以为真的平均分是0。这个细节在实际项目里经常引发争议。
6.2 进阶方向:不可变对象与继承的引入时机
不可变对象这个方向值得试一次。把Student所有字段改成final,去掉全部setter,需要修改时返回一个新对象:
public Student withScore(double newScore) { return new Student(this.studentId, this.name, this.age, newScore); }好处是线程安全、可以放心共享、作为Map的key不会出问题。代价是每次修改都创建新对象,内存开销大一些。实际项目里,值对象、配置对象、消息体这类东西非常适合做成不可变,而像Student这种需要频繁修改状态的实体,用可变对象更合适。知道这个取舍比会写这个模式更重要。
继承的引入要有节制。加一个“研究生”类继承Student,看起来天经地义,但你要想清楚:研究生的差异是多了几个字段,还是行为不一样?如果只是多一个导师字段,用组合(Student里放一个Mentor字段)比继承更灵活;只有当行为真的需要多态——比如“计算评优资格”的规则在本科生和研究生之间完全不同——继承才是对的选择。这个判断我建议在练习阶段就养成习惯,因为工作里因为“看着像父子关系就继承”导致的类层次混乱,是最难重构的技术债之一。
提示:如果你打算动手扩展这个练习,我的建议顺序是先加集合管理,再加不可变版本做对比,最后才碰继承。顺序反过来容易一上来就被类层次绕晕,反而看不清封装的本质。