Skip to content

面向对象设计的三个抓手:封装、组合与多态 ​

继承 HashSet 写一个「统计添加了多少个元素」的集合,调用 addAll 加入 3 个元素,计数器显示 6。原因是 HashSet.addAll 在内部逐个调用了 add,而 add 已经被重写过。改用组合包装一个 Set,计数是 3,换成 TreeSet 也不用改一行计数逻辑。封装、组合、多态不是三个需要背下来的「特性」,而是三种控制变化的手段。

本文用同一个折扣场景,比较数据袋加 if、继承树、组合加多态几种写法,并讨论 Java 21 的密封类型和模式匹配什么时候比多态更合适。实验环境:JDK 21.0.5。

一、先说结论 ​

  • 封装控制状态规则的变化:先设计对象能做什么,再决定它持有什么数据。只有 getter/setter 的对象,规则会散落到所有调用方。
  • 组合控制实现细节的变化:复用一个类的能力时,持有它而不是继承它。继承复用会让子类依赖父类的实现细节,实测 addAll 被重复计数。
  • 多态控制类型数量的变化:调用方依赖稳定的协议,新增实现不必修改调用方。
  • 类型集合封闭时,密封类型加穷举 switch 更直接,并不违反开闭原则;类型集合开放、由外部扩展时,才需要多态。
  • 继承仍然有用,前提是子类真的能替换父类(见 里氏替换原则),并且父类是为继承而设计的。
封装用行为守住不变量组合复用能力,不承诺替换关系多态稳定协议 + 可变实现隔离:状态规则的变化例:可退款金额的计算隔离:实现细节的变化例:换一个 Set 实现隔离:类型数量的变化例:新增一种折扣失效信号到处都在调 setStatus失效信号重写父类方法后行为异常失效信号每加一种类型改多处 switch同一段代码通常同时用到三者:封装好的对象,通过组合装配,对外暴露多态的协议
图 1 · 三个手段对应三类变化:封装让状态规则只在一处改,组合让能力可以独立替换,多态让新增实现不必修改调用方

二、封装:用行为守住不变量 ​

2.1 数据袋的问题 ​

java
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 让对象自己守住规则 ​

java
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 上复现:

java
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 = 6

HashSet 的 addAll 继承自 AbstractCollection,内部逐个调用 add。子类重写了 add,于是每个元素被数了两次。这个行为是父类的实现细节,文档并不承诺它,将来版本也可能改变。子类的正确性依赖了一个它无法控制的东西。

改成组合:

java
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 个元素后的计数
继承 HashSet6
组合 HashSet3
组合 TreeSet3,换实现不用改计数逻辑

组合只依赖被包装对象的公开契约,所以不受它内部实现的影响,还可以在运行时选择不同的实现。

3.2 什么时候继承是合理的 ​

《设计模式》一书把「优先使用对象组合,而不是类继承」列为面向对象设计的基本原则之一。这不是说继承不能用,而是继承同时做了两件事:复用代码和声明子类型关系。只想要前者时,用组合;两者都需要时,继承才合适:

  • 子类在任何使用父类的地方都能正确工作;
  • 父类是为继承而设计的,文档说明了哪些方法可以被重写、重写后会被谁调用(比如 AbstractList 明确说明了需要实现哪些方法);
  • 父类和子类由同一个团队维护,可以同步演化。

四、多态:稳定协议与可变实现 ​

4.1 同一个折扣场景的三种写法 ​

写法一:数据加 if

java
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;
}

类型是字符串,拼错了只能在运行时发现;新增一种折扣要改所有这样的分支。

写法二:继承树

java
abstract class Discount { abstract BigDecimal apply(BigDecimal price); }
class PercentDiscount extends Discount { … }
class MemberPercentDiscount extends PercentDiscount { … }        // 会员再叠加一层
class MemberPercentWithCapDiscount extends MemberPercentDiscount { … } // 再加一个封顶

用继承表达「叠加」,组合方式一多,类的数量就会爆炸,而且每一层都依赖上一层的实现细节。

写法三:接口加组合

java
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 模式匹配正好支持这种写法:

java
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 直接编译失败:

text
error: the switch expression does not cover all possible input values
        return switch (d) {
               ^

这是它和写法一的根本区别:漏处理的类型在编译期暴露,而不是在运行时掉进 default。所以它并不违反开闭原则:新增类型时需要修改的地方由编译器全部找出来,不会遗漏。

类型集合封闭:sealed + switchsealed interface Discountpermits 三种折扣switch (d) { case … } 没有 default新增子类型:未处理的 switch 编译失败类型集合开放:接口 + 多个实现interface DiscountRule行为放在每个实现里调用方只依赖接口,不认识具体类型新增实现:调用方不用改JDK 21 实测:给密封接口新增 BuyNGetOne 后,没有更新的 switch 直接编译失败
图 2 · 类型集合由自己掌控、很少增加时,密封类型加穷举 switch 更直接,漏处理会在编译期报错;类型由外部扩展、经常增加时,才需要把行为放进接口实现里
场景选择
类型集合由自己掌控,很少增加;需要对同一组类型做多种不同的处理(计算、展示、序列化)密封类型 + 穷举 switch
类型由外部模块或插件新增;每种类型的处理逻辑主要只有一种接口 + 多态
需要把多个规则叠加、包装接口 + 组合(装饰)

注意 switch 里不要写 default,否则编译器就无法帮你检查穷举。

五、三者怎样一起工作 ​

回到订单场景,一段设计良好的代码通常同时用到三者:

  • Order 封装了状态和退款规则,外部只能通过 refund 修改;
  • 计价服务组合了多个 DiscountRule,通过构造器接收,而不是继承某个基类;
  • 调用方依赖 DiscountRule 这个多态协议,新增规则不需要修改它。

这三者分别对应 设计原则 里的几条:封装让单一职责有了落脚点,组合与多态是开闭原则和依赖倒置的实现手段。

六、常见误区 ​

  • 「封装就是字段私有加 getter/setter」:这只是把字段换了一种方式公开。封装的重点是让对象用行为守住规则。
  • 「继承是为了复用代码」:继承首先声明了子类型关系;只为复用代码,用组合。
  • 「多态就是继承」:接口、Lambda、组合都能实现多态,继承只是其中一种方式。
  • 「用 switch 处理类型就违反了开闭原则」:在封闭的类型集合上做穷举 switch,新增类型时编译器会指出所有需要修改的地方。
  • 「面向对象就不该有 if」:少量、稳定的分支用 if 最清楚,引入多态要有真实的变化作为理由。

小结 ​

封装、组合和多态分别控制三类变化:状态规则、实现细节、类型数量。选择哪一种,取决于你预期哪里会变。Java 21 的密封类型又提供了一个新选项:对自己掌控的封闭类型集合,集中的穷举 switch 往往比分散的多态更容易阅读和修改。


配套实验

可以用 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

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权