1. 题目拆解:这15分到底在考什么
先看题目标题:“7-2 jmu-Java-03面向对象-06-继承覆盖综合练习-Person、Student、Employee、Company (15分)”。这是一道典型的Java面向对象阶段性综合题,来自课程的中期练习,总分15分,说明它既不是纯概念题,也不是大项目,而是把你学过的继承、方法覆盖、构造器调用、对象数组、以及简单的容器类设计串在一起的一次实战。
拆开来看,题目考察的核心点其实非常明确:
- 继承体系的设计:Person是基类,Student和Employee从Person派生,Company负责管理一组Employee对象。
- 方法覆盖(Override):子类要重写父类的toString、equals等方法,而且格式要严格按照题目要求来。
- 构造器链:子类构造器必须显式或隐式调用父类构造器,参数传递顺序要正确。
- 对象数组与容器类:Company内部要维护一个Employee数组或者ArrayList,并实现添加、统计、遍历输出等功能。
- 多态的应用:在测试代码中,通常会用Person类型的引用指向Student、Employee对象,调用同名方法时表现出不同行为。
很多同学看到题目第一反应是“类都写出来了,照着填就行”,但真正拿满15分的人并不多。原因很典型:题目明确要求输出的字符串格式,不能用默认的toString,更不能自由发挥。
我批过很多版本的代码,最常翻车的点就两个:一是toString格式跟题目要求不一致,哪怕多一个空格都算错;二是子类构造器没有调用super(name, age),导致父类属性没初始化,运行结果全是默认值。
所以这篇博文,我会把这道题的完整解题路径走一遍,从类设计到代码实现,再到测试程序的编写思路,最后把常见的报错和扣分点列成一张速查表,方便你对照自查。
2. 整体设计思路:先画类图,再写代码
2.1 四个类的关系
拿到这种题,第一步不是急着敲代码,而是先在草稿纸上把类图画出来。标准的关系是这样的:
Person:基类,包含姓名(name)、年龄(age)两个私有属性,提供带参构造器、getter/setter,以及重写后的toString方法。Student:继承Person,增加学号(id或stuId)和成绩(score)两个私有属性,提供带参构造器,重写toString。Employee:继承Person,增加工号(empId)和薪水(salary)两个私有属性,提供带参构造器,重写toString和equals。Company:包含一个Employee集合(数组或ArrayList),提供添加员工、按薪水排序、统计平均薪水、输出全部员工等方法。
这里要特别注意:Student和Employee并不相互关联,它们唯一的共同点是都继承自Person。而Company只和Employee打交道,和Student没有关系。搞清楚这一点,后面写代码就不会纠结“学生信息和公司信息怎么互相调用”这种问题了。
2.2 为什么推荐用ArrayList而不是数组
题目并没有强制要求Company内部用什么数据结构来存员工。有的同学习惯用Employee[]数组,但数组在添加元素时需要手动管理下标,还要考虑扩容问题,代码既繁琐又容易越界。我建议直接用ArrayList<Employee>,理由有三个:
- 添加员工时,直接
add即可,不需要关心下标。 - 遍历时可以用增强for循环,代码可读性好。
- 排序时,
Collections.sort直接支持List,不需要自己写冒泡排序。
当然,用数组也不是不行,如果题目明确要求了数组操作,那就要按题目来。但一般来说,集合类更安全,代码量也更少。
提示:如果题目给的测试代码中已经用到了
Company的addEmployee方法,那么你只需要保证这个方法的签名(方法名、参数类型、返回值)和题目一致即可,内部用ArrayList还是数组完全由你决定。
2.3 方法覆盖的几个硬性要求
方法覆盖不是随便写一个同名方法就完事了,要满足Java语法层面的约束:
- 方法签名要一致:方法名、参数列表必须完全相同。如果参数列表不同,那就成了重载(overload),而不是覆盖(override)。
- 返回值类型要兼容:从Java 5开始,子类覆盖父类方法时,返回值可以是父类返回值的子类型(协变返回)。
- 访问权限不能更低:父类方法是public,子类覆盖时必须也是public,否则会编译报错。
@Override注解建议加上:加上之后,如果签名写错,编译器会直接给你报错提示,比运行时才发现问题要快得多。
这些规则本身不难,但综合题里很容易混在一起出状况。比如有人写equals时漏了参数类型,写成public boolean equals(Student s),结果方法变成了重载,程序调用Object的equals做比较,判断逻辑完全失效。这种错误非常隐蔽,编译器不报错,运行结果看起来好像也“有输出”,但就是不对。
3. 核心类实现:每个方法的细节都不容马虎
3.1 Person:基类的职责边界
Person是继承体系的根基,它只需要维护所有“人”共有的属性,也就是姓名和年龄。代码很简单,但有两个细节值得说:
public class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "Person [name=" + name + ", age=" + age + "]"; } }第一,toString的格式必须精确到标点符号。题目给的期望输出是什么样的,你就得写成什么样。比如Person [name=张三, age=20],中括号两侧有没有空格、逗号后面有几个空格、age后面有没有多余字符,全都要跟预期输出一致。这看起来是小事,但在线判题(OJ)系统比对输出时,一个空格都不会放过。
第二,基类不一定需要重写equals。如果题目没有明确要求Person覆盖equals,就不要画蛇添足。这道题的主角是Employee,equals逻辑只跟工号相关,放在Employee里实现就足够了。
3.2 Student:继承的标配操作
Student继承Person后,除了继承来的name和age,还需要学号和成绩。构造器要调用父类构造器来初始化姓名和年龄,这是最标准的操作:
public class Student extends Person { private String id; private double score; public Student(String name, int age, String id, double score) { super(name, age); this.id = id; this.score = score; } public String getId() { return id; } public void setId(String id) { this.id = id; } public double getScore() { return score; } public void setScore(double score) { this.score = score; } @Override public String toString() { return "Student [person=" + super.toString() + ", id=" + id + ", score=" + score + "]"; } }这里的super.toString()很有意思。因为题目要求既要输出继承来的姓名和年龄,又要输出子类自己的学号和成绩,所以最干净的做法是把父类的toString结果作为子类toString的一部分拼接进去。这样写的好处在于:如果父类的输出格式变了,子类自动跟着变,不用改两处。
成绩的精度问题也要留意。如果score是浮点数,OJ比对时通常允许一定误差。但如果题目要求保留固定小数位数,那就得用String.format("%.2f", score)来处理,不能直接裸拼接double。
3.3 Employee:覆盖equals是重点加分项
Employee类本身不难,难在equals方法的设计。先看基础实现:
public class Employee extends Person { private String empId; private double salary; public Employee(String name, int age, String empId, double salary) { super(name, age); this.empId = empId; this.salary = salary; } public String getEmpId() { return empId; } public void setEmpId(String empId) { this.empId = empId; } public double getSalary() { return salary; } public void setSalary(double salary) { this.salary = salary; } @Override public String toString() { return "Employee [person=" + super.toString() + ", empId=" + empId + ", salary=" + salary + "]"; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } Employee other = (Employee) obj; return empId.equals(other.empId); } }equals逻辑按工号比较,也就是两个员工只要工号相同就视为同一个人,不管姓名年龄是否不同。这是符合现实业务习惯的:工号是唯一标识。
写equals时有一个经典坑:getClass() != obj.getClass()和obj instanceof Employee是有区别的。前者要求两个对象必须是同一个类的实例才能相等,后者则允许子类对象和父类对象“相等”。从对称性角度讲,用getClass更严谨。比如如果以后有个类继承了Employee,用instanceof判断可能会导致a.equals(b)为true但b.equals(a)为false,破坏equals的对称性契约。所以,推荐在覆盖equals时用getClass判断类型。
另外,永远不要忘了@Override注解。equals的形参必须是Object obj,如果写成Employee obj,那就不是覆盖了。我见过好几个同学在考场里犯这个错误,编译不报错、代码也运行了,但比较逻辑根本没生效,白白丢分。
3.4 Company:容器类设计的三个要点
Company类承担了“管理”的职责。它不继承任何人,而是持有员工列表。核心代码骨架如下:
import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; public class Company { private String name; private List<Employee> employees; public Company(String name) { this.name = name; this.employees = new ArrayList<>(); } public String getName() { return name; } public void setName(String name) { this.name = name; } public void addEmployee(Employee e) { employees.add(e); } public List<Employee> getEmployees() { return employees; } public void removeEmployee(Employee e) { employees.remove(e); } public double getAverageSalary() { if (employees.isEmpty()) { return 0.0; } double sum = 0; for (Employee e : employees) { sum += e.getSalary(); } return sum / employees.size(); } public void sortBySalary() { Collections.sort(employees, new Comparator<Employee>() { @Override public int compare(Employee e1, Employee e2) { return Double.compare(e1.getSalary(), e2.getSalary()); } }); } public void printEmployees() { for (Employee e : employees) { System.out.println(e); } } }第一个要点:初始化集合的时机。employees一定要在构造器里初始化,不能在声明处简单写个new ArrayList<>()就不管了。这里用new ArrayList<>()当然可以,但技能点在于不要让外部通过getEmployees拿到空列表后直接NPE。初始化完成后,后面所有方法都能安全地调用。
第二个要点:排序比较器用Double.compare。如果你用(int)(e1.getSalary() - e2.getSalary()),当薪水为浮点数且差值小于1时,会直接截断为0,导致两个不同薪水的人被判定为“相等”,排序结果就乱了。用Double.compare可以彻底避免这个问题。
第三个要点:remove方法要趁早想清楚语义。employees.remove(e)调用的其实是ArrayList内部通过equals来查找并删除元素。因为Employee已经覆盖了equals(按工号比较),所以remove时只要传入一个拥有相同工号的新Employee对象,就能删掉对应的员工。这个特性不要求,但理解了能帮助你在复杂场景中游刃有余。
提示:Comparator接口在Java 8之后可以用Lambda简化写作
employees.sort(Comparator.comparingDouble(Employee::getSalary))。不过在线课程OJ往往使用Java 8或更高版本,使用lambda写法更简洁,但为了更好的通用性和可读性,匿名内部类也是完全没问题的选择。两种写法看你个人习惯。
4. 测试程序该怎么写:这是最容易忽略的隐藏分
4.1 构造测试数据的思路
有些练题平台只要求你提交Person、Student、Employee、Company四个类,测试程序由平台自己生成。但也有很多课程要求你同时写一个测试类(比如Main类),并在其中演示各种功能。这种情况下,测试程序的逻辑编排直接决定了你这道题的完整度。
推荐按照以下顺序来组织测试代码:
- 创建两个Person对象,输出toString验证基类。
- 创建两个Student对象,输出toString验证子类。
- 创建三个Employee对象,输出toString验证员工信息。
- 测试equals,判断工号相同的两个员工是否相等。
- 创建Company对象,添加员工,输出平均工资、排序结果、员工列表。
- 尝试删除一个员工,再次遍历输出,验证remove逻辑是否正确。
这种顺序从简单到复杂,逐步验证每个类的功能,哪里出错了也能快速定位。
4.2 一个精简的Main测试示例
public class Main { public static void main(String[] args) { Person p = new Person("张三", 20); System.out.println(p); Student s1 = new Student("李四", 21, "20230101", 95.5); Student s2 = new Student("王五", 22, "20230102", 88.0); System.out.println(s1); System.out.println(s2); Employee e1 = new Employee("赵六", 30, "E001", 7500.0); Employee e2 = new Employee("孙七", 28, "E002", 6800.5); Employee e3 = new Employee("周八", 35, "E003", 8200.0); System.out.println(e1); System.out.println(e2); System.out.println(e3); Employee eDuplicate = new Employee("赵六", 40, "E001", 8000.0); System.out.println("e1 equals eDuplicate: " + e1.equals(eDuplicate)); System.out.println("e1 equals e2: " + e1.equals(e2)); Company company = new Company("某技术公司"); company.addEmployee(e1); company.addEmployee(e2); company.addEmployee(e3); System.out.println("平均工资: " + company.getAverageSalary()); company.sortBySalary(); company.printEmployees(); company.removeEmployee(eDuplicate); System.out.println("删除后员工人数: " + company.getEmployees().size()); company.printEmployees(); } }注意这里特意构造了一个工号相同但年龄、薪水都不同的eDuplicate对象。删除它时,元素能成功从ArrayList中移除,这直接证明了equals覆盖生效。
4.3 为什么多态在本题中无处不在
你可能会问,代码里到处是Employee类型的引用,多态体现在哪里?仔细看排序和遍历的过程:printEmployees()内部调用System.out.println(e),这里的e声明为Employee类型,但真正执行时,JVM会通过动态绑定调用Employee覆盖后的toString方法,而不是Person的toSttoString方法。这就是多态在起作用的实际场景。
同理,equals方法虽然定义在Employee中,但通过Object类型的参数调用时,也能正确触发Employee的覆盖版本。多态不是一种“炫技”,它是Java类型系统在运行时做方法分派的基本机制,理解它的最好方式就是在综合练习题里反复观察这些调用细节。
提示:从在线判题的角度讲,如果平台只要求提交四个业务类,那么就不要在Main类里写任何与题目无关的输入输出,避免干扰判断。如果你还需要提交Main类,请确认题目要求的输入格式和输出格式,确保程序能按预期读取参数和数据。
5. 常见问题与排查技巧:在线判题最容易踩的6个坑
5.1 编译报错:找不到符号或构造器错误
这类错误十有八九是构造器问题。子类构造器没有显式调用super(name, age)时,编译器会默认调用父类的无参构造器。但Person类一旦显式定义了带参构造器,就不会再自动生成无参构造器了,于是编译报错就出现了。解决办法很简单:在Student和Employee的构造器第一行显式写上super(name, age);。
还有一种情况是调用父类构造器的参数顺序写错。比如题目要求构造器签名是Employee(String name, int age, String empId, double salary),那你调用super时就该是super(name, age),不是super(age, name),这属于基本功,但人一紧张就容易写反。
5.2 格式错误:toString输出不匹配
这是最隐蔽也最高频的扣分点。解决思路不是肉眼盯着代码看,而是把题目给出的Sample Output复制过来,和你程序的实际输出做一次严格的逐字符比对。最实用的方式是用文本比对工具,或者在命令行里把输出重定向到两个文件,再用diff命令直接对比。
实操建议:
- 在本地运行测试程序,把输出复制进一个txt文件。
- 把题目样例输出复制进另一个txt文件。
- 用文本编辑器的“文件比较”功能或命令行diff工具,查看差异。
一般差异都集中在空格、方括号、逗号、引号这些细节上。比如Person [name=张三, age=20]和Person [ name = 张三 , age = 20 ],肉眼看起来差不多,但OJ判的就是一模一样。
5.3 equals方法失效:是重载不是覆盖
如果你发现e1.equals(anotherEmployee)永远返回false,即使工号相同也一样,那大概率是equals写成了重载。原因是方法的形参类型写成了Employee而不是Object。
这种错误编译器不会报错,因为它认为你是在给子类新增一个equals(Employee)方法,和Object类里的equals(Object)完全不相干。验证方法很简单:在equals方法上面加上@Override注解,如果编译器开始报“方法不会覆盖任何方法”,那就说明写错了。
另一种情况是忘了做类型转换。在判断类型一致之后,必须把Object obj向下转型为Employee,才能访问obj.empId。如果直接写obj.empId,编译也过不了。
5.4 排序结果不对:浮点数比较精度问题
用Collections.sort做员工排序时,很多人习惯写(int)(e1.getSalary() - e2.getSalary())。对于薪水一般都是几千、几万的浮点数来说,这个写法大多数时候看起来没问题,但一旦出现两个薪水差0.3这种情况,强制转型后差值直接变成0,排序就乱了。
正确做法是使用Double.compare(e1.getSalary(), e2.getSalary())。这个方法内部按IEEE 754标准比较两个double,处理了NaN、正负零等边界情况,返回值也是一个规范的int,比较器的契约完全满足。
5.5 平均工资为0或异常
如果Company里的employees列表没初始化,或者你忘了调用addEmployee,那么getAverageSalary计算时要么NPE要么返回0。另一种情况是员工都添加成功了,但计算总数时用了employees.size(),这没问题;如果你手动维护了一个count变量,就容易出现累加错误。
为了避免这种问题,最稳妥的方式是每次计算平均值都从集合当前状态出发,不单独保存员工数量。
5.6 ArrayList的remove和contains行为需要equals配合
默认情况下,ArrayList的contains和remove方法是靠对象的equals方法来判断元素是否存在的。如果你的Employee类没有覆盖equals,那么即使你传入一个工号相同的新对象,contains也返回false,remove也不会删除任何元素。
这道题其实隐含地测试了这一点:如果你在测试程序里创建一个工号相同的新Employee对象,调用remove,发现员工还在列表里,那就是equals没有正确覆盖。把equals实现好之后,这个行为就会符合预期。
提示:不要在equals里用
Objects.equals而不用null判断。虽然Objects.equals内部做了空值处理,但从代码可读性和教学考核的角度看,手动处理null能让阅卷人看出你的逻辑是清晰的。当然你可以用Objects.equals(empId, other.empId)来简化代码,思路是一样的。
6. 进阶思考:如何让方案更有设计感
6.1 抽象类和接口该不该用
题目没有要求抽象类,但作为练习你可以思考:Person类需不需要设计成abstract?从现实语义来说,一个“纯粹的Person”其实很少存在,实际业务中的人要么是学生、要么是员工、要么是其他角色。如果把这个类设计成abstract,就不能直接new Person(...)了,这会限制测试程序的写法,可能和题目意图不符。
所以,在题目的约束下,保持Person为具体类是最稳妥的做法。如果你是为了练习设计能力,可以在自己的工程里尝试把Person改成abstract,看看会给测试代码带来什么影响,这会加深你对抽象类应用边界的理解。
6.2 继承体系设计的常见误区
很多初学者在设计继承体系时,会把“所有类都往一个基类上挂”当成唯一解法。比如这个题目里,试图让Student和Employee都出现在同一个Company里,于是就想给它们设计一个公共父类。但业务上,公司管理的是员工,而不是学生,所以把Company设计成只持有Employee更贴近现实模型。
这种“组合优先于继承”的思路,是面向对象设计里非常重要的原则。能用组合的地方不要去强行继承,否则会让类层次变得臃肿且难以维护。这道题虽然只是个小练习,但它在设计上已经隐含了这个思想。
6.3 从15分题到真实项目的能力迁移
严格来说,这道15分的题目只是“最小可行继承练习”,但它覆盖的能力点可以平移到很多真实场景:
- 设计一个基类(比如BaseEntity),统一处理创建时间、更新时间等公共字段。
- 子类继承并在toString或JSON序列化时扩展额外字段。
- 容器类对外提供增删改查接口,内部用集合持有数据,使用equals完成业务去重。
如果你能把这道题的逻辑吃透,后面写分层架构里常见的DTO、Entity、VO时,会发现很多设计套路都是相通的。
7. 完整参考代码汇总
为了方便你对照和验证,我把四个类和一个测试类的完整代码放在下面。你可以直接复制编译,也可以在此基础上做扩展练习。
// Person.java public class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "Person [name=" + name + ", age=" + age + "]"; } }// Student.java public class Student extends Person { private String id; private double score; public Student(String name, int age, String id, double score) { super(name, age); this.id = id; this.score = score; } public String getId() { return id; } public void setId(String id) { this.id = id; } public double getScore() { return score; } public void setScore(double score) { this.score = score; } @Override public String toString() { return "Student [person=" + super.toString() + ", id=" + id + ", score=" + score + "]"; } }// Employee.java public class Employee extends Person { private String empId; private double salary; public Employee(String name, int age, String empId, double salary) { super(name, age); this.empId = empId; this.salary = salary; } public String getEmpId() { return empId; } public void setEmpId(String empId) { this.empId = empId; } public double getSalary() { return salary; } public void setSalary(double salary) { this.salary = salary; } @Override public String toString() { return "Employee [person=" + super.toString() + ", empId=" + empId + ", salary=" + salary + "]"; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } Employee other = (Employee) obj; return empId.equals(other.empId); } }// Company.java import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; public class Company { private String name; private List<Employee> employees; public Company(String name) { this.name = name; this.employees = new ArrayList<>(); } public String getName() { return name; } public void setName(String name) { this.name = name; } public void addEmployee(Employee e) { employees.add(e); } public List<Employee> getEmployees() { return employees; } public void removeEmployee(Employee e) { employees.remove(e); } public double getAverageSalary() { if (employees.isEmpty()) { return 0.0; } double sum = 0; for (Employee e : employees) { sum += e.getSalary(); } return sum / employees.size(); } public void sortBySalary() { Collections.sort(employees, new Comparator<Employee>() { @Override public int compare(Employee e1, Employee e2) { return Double.compare(e1.getSalary(), e2.getSalary()); } }); } public void printEmployees() { for (Employee e : employees) { System.out.println(e); } } }// Main.java(测试程序) public class Main { public static void main(String[] args) { Person p = new Person("张三", 20); System.out.println(p); Student s1 = new Student("李四", 21, "20230101", 95.5); Student s2 = new Student("王五", 22, "20230102", 88.0); System.out.println(s1); System.out.println(s2); Employee e1 = new Employee("赵六", 30, "E001", 7500.0); Employee e2 = new Employee("孙七", 28, "E002", 6800.5); Employee e3 = new Employee("周八", 35, "E003", 8200.0); System.out.println(e1); System.out.println(e2); System.out.println(e3); Employee eDuplicate = new Employee("赵六", 40, "E001", 8000.0); System.out.println("e1 equals eDuplicate: " + e1.equals(eDuplicate)); System.out.println("e1 equals e2: " + e1.equals(e2)); Company company = new Company("某技术公司"); company.addEmployee(e1); company.addEmployee(e2); company.addEmployee(e3); System.out.println("平均工资: " + company.getAverageSalary()); company.sortBySalary(); company.printEmployees(); company.removeEmployee(eDuplicate); System.out.println("删除后员工人数: " + company.getEmployees().size()); company.printEmployees(); } }我自己实际跑下来的输出是这样(具体以你的环境和JDK版本为准):
Person [name=张三, age=20] Student [person=Person [name=李四, age=21], id=20230101, score=95.5] Student [person=Person [name=王五, age=22], id=20230102, score=88.0] Employee [person=Person [name=赵六, age=30], empId=E001, salary=7500.0] Employee [person=Person [name=孙七, age=28], empId=E002, salary=6800.5] Employee [person=Person [name=周八, age=35], empId=E003, salary=8200.0] e1 equals eDuplicate: true e1 equals e2: false 平均工资: 7500.166666666667 Employee [person=Person [name=孙七, age=28], empId=E002, salary=6800.5] Employee [person=Person [name=赵六, age=30], empId=E001, salary=7500.0] Employee [person=Person [name=周八, age=35], empId=E003, salary=8200.0] 删除后员工人数: 2 Employee [person=Person [name=孙七, age=28], empId=E002, salary=6800.5] Employee [person=Person [name=周八, age=35], empId=E003, salary=8200.0]排序输出是按照薪水升序排列的,删除员工时通过equals匹配工号成功删掉了e1,由于e1和eDuplicate工号相同,删除后剩下两条记录。整个逻辑闭环,验证了继承、覆盖、多态、容器操作等所有核心知识点。
这道题虽然分值不高,但把Java面向对象的三大特性、集合框架的基础操作以及Object类关键方法的覆盖习惯全部串了一遍。我在实际批改和带新手的过程中,反复看到的就是toString格式失误和equals重载陷阱这两个问题。理解原理、规范实现、严格测试,这三步走完了,15分自然是你的,而且这些习惯会在后续的项目开发里持续给你回报。