面向对象设计的三个抓手:封装、组合与多态
继承
HashSet写一个「统计添加了多少个元素」的集合,调用addAll加入 3 个元素,计数器显示 6。原因是HashSet.addAll在内部逐个调用了add,而add已经被重写过。改用组合包装一个Set,计数是 3,换成TreeSet也不用改一行计数逻辑。封装、组合、多态不是三个需要背下来的「特性」,而是三种控制变化的手段。
本文用同一个折扣场景,比较数据袋加 if、继承树、组合加多态几种写法,并讨论 Java 21 的密封类型和模式匹配什么时候比多态更合适。实验环境:JDK 21.0.5。
一、先说结论
- 封装控制状态规则的变化:先设计对象能做什么,再决定它持有什么数据。只有 getter/setter 的对象,规则会散落到所有调用方。
- 组合控制实现细节的变化:复用一个类的能力时,持有它而不是继承它。继承复用会让子类依赖父类的实现细节,实测
addAll被重复计数。 - 多态控制类型数量的变化:调用方依赖稳定的协议,新增实现不必修改调用方。
- 类型集合封闭时,密封类型加穷举
switch更直接,并不违反开闭原则;类型集合开放、由外部扩展时,才需要多态。 - 继承仍然有用,前提是子类真的能替换父类(见 里氏替换原则),并且父类是为继承而设计的。
二、封装:用行为守住不变量
2.1 数据袋的问题
public class Order {
private String status;
private BigDecimal amount;
private BigDecimal refunded;
// getter、setter
}
// 退款逻辑写在 Service 里
if (!"PAID".equals(order.getStatus()) && !"SHIPPED".equals(order.getStatus())) throw …;
if (order.getRefunded().add(req.amount()).compareTo(order.getAmount()) > 0) throw …;
order.setRefunded(order.getRefunded().add(req.amount()));
if (order.getRefunded().compareTo(order.getAmount()) == 0) order.setStatus("REFUNDED");这里的「不变量」是:退款总额不能超过订单金额,全额退款后状态必须变成已退款。它们只存在于这段 Service 代码里。另一个地方(比如客服后台)直接调用 setRefunded,规则就被绕过了。
2.2 让对象自己守住规则
public class Order {
private OrderStatus status;
private final Money amount;
private Money refunded = Money.ZERO;
public void refund(Money value) {
if (!status.refundable()) throw new IllegalStateException("当前状态不能退款:" + status);
Money next = refunded.plus(value);
if (next.greaterThan(amount)) throw new IllegalArgumentException("退款超过订单金额");
refunded = next;
if (refunded.equals(amount)) status = OrderStatus.REFUNDED;
}
public Money refundable() { return amount.minus(refunded); }
}变化之后,规则只有一个修改点,调用方也无法绕过它。判断封装是否到位,有两个简单的信号:
- 外部代码有没有在对象之外做「如果它的状态是 X,就改它的 Y」。有,说明行为应该搬进对象。
- setter 是不是在任何状态下都能调用。能,说明对象没有守住自己的规则。
封装也不意味着消灭所有 getter。对外展示、序列化、查询需要读取数据,这是正常的。要收紧的是修改:状态变化应该通过表达业务意图的方法完成。
三、组合:复用能力,不承诺替换关系
3.1 实测:继承复用的脆弱性
《Effective Java》用一个例子说明继承复用的风险。在 JDK 21 上复现:
class CountingSet<E> extends HashSet<E> {
int added;
@Override public boolean add(E e) { added++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
}
CountingSet<String> s = new CountingSet<>();
s.addAll(List.of("a", "b", "c")); // added = 6HashSet 的 addAll 继承自 AbstractCollection,内部逐个调用 add。子类重写了 add,于是每个元素被数了两次。这个行为是父类的实现细节,文档并不承诺它,将来版本也可能改变。子类的正确性依赖了一个它无法控制的东西。
改成组合:
class CountingSet<E> {
private final Set<E> delegate;
private int added;
CountingSet(Set<E> delegate) { this.delegate = delegate; }
boolean add(E e) { added++; return delegate.add(e); }
boolean addAll(Collection<? extends E> c) { added += c.size(); return delegate.addAll(c); }
}| 写法 | addAll 3 个元素后的计数 |
|---|---|
继承 HashSet | 6 |
组合 HashSet | 3 |
组合 TreeSet | 3,换实现不用改计数逻辑 |
组合只依赖被包装对象的公开契约,所以不受它内部实现的影响,还可以在运行时选择不同的实现。
3.2 什么时候继承是合理的
《设计模式》一书把「优先使用对象组合,而不是类继承」列为面向对象设计的基本原则之一。这不是说继承不能用,而是继承同时做了两件事:复用代码和声明子类型关系。只想要前者时,用组合;两者都需要时,继承才合适:
- 子类在任何使用父类的地方都能正确工作;
- 父类是为继承而设计的,文档说明了哪些方法可以被重写、重写后会被谁调用(比如
AbstractList明确说明了需要实现哪些方法); - 父类和子类由同一个团队维护,可以同步演化。
四、多态:稳定协议与可变实现
4.1 同一个折扣场景的三种写法
写法一:数据加 if
BigDecimal apply(String type, BigDecimal value, BigDecimal price) {
if ("PERCENT".equals(type)) return price.multiply(BigDecimal.ONE.subtract(value));
if ("FIXED".equals(type)) return price.subtract(value).max(BigDecimal.ZERO);
return price;
}类型是字符串,拼错了只能在运行时发现;新增一种折扣要改所有这样的分支。
写法二:继承树
abstract class Discount { abstract BigDecimal apply(BigDecimal price); }
class PercentDiscount extends Discount { … }
class MemberPercentDiscount extends PercentDiscount { … } // 会员再叠加一层
class MemberPercentWithCapDiscount extends MemberPercentDiscount { … } // 再加一个封顶用继承表达「叠加」,组合方式一多,类的数量就会爆炸,而且每一层都依赖上一层的实现细节。
写法三:接口加组合
interface DiscountRule { BigDecimal apply(BigDecimal price); }
record Percent(BigDecimal rate) implements DiscountRule {
public BigDecimal apply(BigDecimal p) { return p.multiply(BigDecimal.ONE.subtract(rate)); }
}
record Cap(DiscountRule inner, BigDecimal maxOff) implements DiscountRule { // 组合:给任意规则加封顶
public BigDecimal apply(BigDecimal p) { return p.subtract(p.subtract(inner.apply(p)).min(maxOff)); }
}Cap 可以包装任何一种规则,不需要为每种组合写一个子类。调用方只依赖 DiscountRule,新增规则不用修改它。
4.2 封闭集合:密封类型加穷举 switch
多态把「每种类型怎么处理」分散到各个实现类里。这在类型经常由外部新增时很合适,但如果类型集合是自己掌控、很少变化的,把处理逻辑集中在一处反而更清楚。Java 21 的密封类型和 switch 模式匹配正好支持这种写法:
sealed interface Discount permits Percentage, FixedAmount, NoDiscount {}
record Percentage(BigDecimal rate) implements Discount {}
record FixedAmount(BigDecimal amount) implements Discount {}
record NoDiscount() implements Discount {}
static BigDecimal apply(Discount d, BigDecimal price) {
return switch (d) { // 没有 default:编译器检查是否穷举
case Percentage p -> price.multiply(BigDecimal.ONE.subtract(p.rate()));
case FixedAmount f -> price.subtract(f.amount()).max(BigDecimal.ZERO);
case NoDiscount n -> price;
};
}给密封接口新增一个 BuyNGetOne 之后,没有更新的 switch 直接编译失败:
error: the switch expression does not cover all possible input values
return switch (d) {
^这是它和写法一的根本区别:漏处理的类型在编译期暴露,而不是在运行时掉进 default。所以它并不违反开闭原则:新增类型时需要修改的地方由编译器全部找出来,不会遗漏。
| 场景 | 选择 |
|---|---|
| 类型集合由自己掌控,很少增加;需要对同一组类型做多种不同的处理(计算、展示、序列化) | 密封类型 + 穷举 switch |
| 类型由外部模块或插件新增;每种类型的处理逻辑主要只有一种 | 接口 + 多态 |
| 需要把多个规则叠加、包装 | 接口 + 组合(装饰) |
注意 switch 里不要写 default,否则编译器就无法帮你检查穷举。
五、三者怎样一起工作
回到订单场景,一段设计良好的代码通常同时用到三者:
Order封装了状态和退款规则,外部只能通过refund修改;- 计价服务组合了多个
DiscountRule,通过构造器接收,而不是继承某个基类; - 调用方依赖
DiscountRule这个多态协议,新增规则不需要修改它。
这三者分别对应 设计原则 里的几条:封装让单一职责有了落脚点,组合与多态是开闭原则和依赖倒置的实现手段。
六、常见误区
- 「封装就是字段私有加 getter/setter」:这只是把字段换了一种方式公开。封装的重点是让对象用行为守住规则。
- 「继承是为了复用代码」:继承首先声明了子类型关系;只为复用代码,用组合。
- 「多态就是继承」:接口、Lambda、组合都能实现多态,继承只是其中一种方式。
- 「用
switch处理类型就违反了开闭原则」:在封闭的类型集合上做穷举switch,新增类型时编译器会指出所有需要修改的地方。 - 「面向对象就不该有
if」:少量、稳定的分支用if最清楚,引入多态要有真实的变化作为理由。
小结
封装、组合和多态分别控制三类变化:状态规则、实现细节、类型数量。选择哪一种,取决于你预期哪里会变。Java 21 的密封类型又提供了一个新选项:对自己掌控的封闭类型集合,集中的穷举 switch 往往比分散的多态更容易阅读和修改。
配套实验
- codesphere-labs/design/encapsulation-composition-polymorphism:继承
HashSet的计数错误与密封类型的编译期检查(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料
- Gamma 等,《Design Patterns: Elements of Reusable Object-Oriented Software》第 1 章(Favor object composition over class inheritance)
- Joshua Bloch,《Effective Java》第 3 版,条目 18「复合优先于继承」、条目 19「要么为继承而设计并提供文档,要么就禁止继承」
- JEP 409: Sealed Classes
- JEP 441: Pattern Matching for switch