面向对象:继承、组合与多态
这一课你将能做到
- 用判题平台
CfUser、CfLesson、CfSubmission三大领域对象讲清"是什么"、"有什么"、"能做什么" - 判断一段关系该用继承还是组合,并用 Java 17 的
sealed和record表达受限继承与值对象 - 写出"一个接口多种实现"的多态结构,让
JudgeEngine、SubmissionValidator都能被替换
为什么要学
面向对象不是语法课,是"怎么把现实世界拆成代码"的课。判题平台里有用户、课程、提交记录、练习记录,它们之间既有"是一个"(ACSubmission 是 Submission),也有"有一个"(Submission 有代码、有状态、有提交时间)。用错继承会导致类树失控,用错组合会导致贫血对象。本课帮你建立判断标准。
先建立图像
继承(is-a) 组合(has-a)
───────────── ─────────────
JavaSubmission Submission
is a has a CodeSnippet
Submission + has a UserRef
+ has a JudgeResult
多态:JudgeEngine 接口
/ \
JavaJudge PythonJudge
同一个 judge() 不同行为
跟着做
步骤 1:区分继承与组合
// 继承:只有当"子类完全能替代父类"时才用
public abstract class CfUser {
private final Long id;
private final String nickname;
private final LocalDateTime registerAt;
public abstract boolean canAccess(CfLesson lesson);
}
public class FreeUser extends CfUser {
@Override
public boolean canAccess(CfLesson lesson) {
return lesson.isFree();
}
}
public class VipUser extends CfUser {
private final LocalDateTime vipExpireAt;
@Override
public boolean canAccess(CfLesson lesson) {
if (vipExpireAt.isBefore(LocalDateTime.now())) return false;
return true; // VIP 可看全部
}
}
// 组合:Submission 有多个部分,不是"是一种"的关系
public record SubmissionMeta(long userId, long lessonId, LocalDateTime submittedAt) {}
public class Submission {
private final Long id;
private final SubmissionMeta meta;
private CodeSnippet code;
private JudgeResult result;
private SubmissionStatus status;
public void assignResult(JudgeResult result) {
this.result = result;
this.status = result.isAccepted() ? SubmissionStatus.ACCEPTED : SubmissionStatus.REJECTED;
}
}
步骤 2:用 sealed 限制继承
Java 17 的 sealed 让继承白名单化:
public abstract sealed class Lesson
permits FreeLesson, VipLesson, TeamLesson {
private final Long id;
private final String title;
public abstract boolean isAccessibleBy(CfUser user);
}
public final class FreeLesson extends Lesson {
@Override public boolean isAccessibleBy(CfUser user) { return true; }
}
public final class VipLesson extends Lesson {
@Override public boolean isAccessibleBy(CfUser user) { return user.isVip(); }
}
public final class TeamLesson extends Lesson {
@Override public boolean isAccessibleBy(CfUser user) { return user.inSameTeam(this.teamId()); }
}
编译器确保只有这三类 Lesson,别处不能随意 extends Lesson,继承树永远可控。
步骤 3:多态实战——Validator 链
public interface SubmissionValidator {
ValidationResult validate(Submission submission);
}
@Component
public class SyntaxValidator implements SubmissionValidator {
@Override
public ValidationResult validate(Submission s) {
return s.getCode().isBlank() ? ValidationResult.fail("代码不能为空") : ValidationResult.ok();
}
}
@Component
public class LengthValidator implements SubmissionValidator {
@Override
public ValidationResult validate(Submission s) {
return s.getCode().length() > 100_000
? ValidationResult.fail("代码过长")
: ValidationResult.ok();
}
}
@Service
public class SubmissionService {
private final List<SubmissionValidator> validators;
public SubmissionService(List<SubmissionValidator> validators) {
this.validators = validators;
}
public void submit(Submission submission) {
for (SubmissionValidator v : validators) {
ValidationResult r = v.validate(submission);
if (!r.ok()) throw new IllegalArgumentException(r.reason());
}
// ... 入库
}
}
Spring 自动注入所有 SubmissionValidator 实现,加新校验规则 = 新建一个 @Component,不改动 SubmissionService。
步骤 4:record 做值对象
判题结果是不变的值:
public record JudgeResult(boolean accepted, long timeMs, long memoryKb, String output) {
public boolean isAccepted() { return accepted; }
}
record 自动生成 equals、hashCode、toString、不可变字段,天然适合值对象。
讲清楚要点
- 继承 = is-a,组合 = has-a:"Java提交 是 提交"用继承;"提交 有 代码"用组合。
- 优先组合:继承是强耦合——父类改签名,所有子类都要跟。组合可以运行时替换行为。
- sealed 控制继承:Java 17 用
sealed/permits/final/non-sealed组合,让继承树在编译期就有边界。 - 多态是接口的能力:不是父类指针指向子类对象这么简单,而是"调用方不关心具体类型,只关心契约"。
- record 表达不可变值对象:DTO、结果集、坐标、金额都适合用 record,省去样板代码。
你可能会懵的地方
- 什么时候该用继承? 只有当子类"完整替代父类"(LSP 成立)且变化方向单一(Lesson 类型)时才用。如果行为差异很大、变化方向多,用策略接口 + 组合。
- sealed 会不会限制扩展?
会——这正是目的。如果你希望"只有这三种 Lesson",sealed 就是设计意图的代码化。以后真需要第四种,改
permits就行,编译器会告诉你所有 switch 需要补 case。 - 组合和依赖注入什么关系?
组合是"持有一个对象",DI 是"把对象从外部塞进来"。Spring 让你不用
new,但关系本质还是组合。 - record 和 class 怎么选? 不变、值语义、无行为(或极少行为)→ record;有状态变化、需要自定义 setter、复杂继承 → class。
- 多态和重载的区别? 多态(重写)是运行时根据对象实际类型决定行为;重载是编译期根据参数类型决定。多态是设计,重载是语法糖。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
class Dog extends Animal 但 Dog 不能飞 | 违反 LSP | 抽 Flyable 接口,能飞的才实现 |
class Utils extends BaseService | 工具类不该继承 Service | 组合:Utils 持有 Service 实例 |
所有字段都 public | 没有封装 | private + 必要 getter,业务方法封装规则 |
用 record 当 Entity(带 @Id) | record 不可变,不适合有生命周期的实体 | Entity 用 class,DTO/值对象用 record |
| 不用接口直接用实现类 | 失去多态和替换能力 | List<JudgeEngine> 注入,而不是 JavaJudgeEngine |
卡点急救
- 不知道该继承还是组合:问"子类是父类吗?"如果犹豫,先用组合;继承是最后手段。
- 类树越改越深:把"差异化行为"抽成接口,用组合持有接口引用,而不是每层都重写方法。
- Java 17 新特性不会用:
sealed解决"怕别人乱继承";record解决"DTO 写太多样板"。先从这两个场景用起。
面试常问
- Java 里多态是怎么实现的?
答:编译看类型(接口或父类),运行看实际对象。JVM 通过虚方法表(vtable)动态绑定。判题平台里
List<JudgeEngine>注入后,运行时根据实际注入的实现类调用对应 judge()。 - 组合优于继承,给一个场景?
答:
Submission有代码、有结果、有元信息,这些部分独立变化,用组合。如果用JavaSubmission extends Submission、PythonSubmission extends Submission,将来要加"同时支持多语言提交"就炸了。 - sealed class 解决了什么问题?
答:解决了"继承树失控"。之前
switch (lesson)要补 default,sealed + permits 让编译器知道所有子类,switch 可以 exhaustive,同时防止外部乱扩展。
自测三问
FreeLesson、VipLesson、TeamLesson为什么适合用继承(sealed)?如果未来出现"按学习进度解锁的课程",你会怎么设计?Submission为什么适合用组合?如果改成JavaSubmission extends Submission会有什么坑?- 把
JudgeResult改成 record 后,哪些好处是自动获得的?
小结一句话
继承表达"是什么"(严格受控),组合表达"有什么"(灵活优先),多态让调用方只认契约不认实现。
延伸
- 《设计模式》GoF,第 1 章:组合优于继承
- 《Effective Java》Joshua Bloch,第 18/20 条
- JEP 360: Sealed Classes
- JEP 395: Records
- 下一课:编程范式——函数式与反应式在判题平台的应用。