Java 基础体系 · 第 4/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 对象模型:类、接口、继承、组合、不可变性和设计边界

1. 先建立对象模型:类型、对象、引用和身份

Java 中的类型描述一组允许的值以及这些值支持的操作。类、接口、数组、枚举和 record 都属于引用类型;intboolean 等属于基本类型。

对象是运行时实体,通常具有:

  1. 身份:它与其他对象是否为同一个实体;
  2. 状态:对象中保存的数据;
  3. 行为:通过方法对状态进行读取或改变。

引用不是对象本身,而是指向对象的值。变量可以保存引用,也可以保存 null

String a = new String("java");
String b = a;
String c = new String("java");

System.out.println(a == b);      // true:两个引用指向同一个对象
System.out.println(a == c);      // false:通常是两个不同对象
System.out.println(a.equals(c)); // true:String 按字符内容比较

== 对引用比较的是对象身份;equals 比较什么由具体类型的契约决定。Object.equals 默认等价于身份比较,但 StringInteger 等类重写了它。

可以把引用赋值理解为:

变量 a ─────► 对象 O1
变量 b ─────► 对象 O1
变量 c ─────► 对象 O2

因此,修改对象的一个引用可观察到的状态,其他指向同一对象的引用也会观察到变化:

final class Counter {
    private int value;

    void increment() {
        value++;
    }

    int value() {
        return value;
    }
}

Counter x = new Counter();
Counter y = x;

y.increment();
System.out.println(x.value()); // 1

这里 final Counter x 只保证 x 这个变量不能改为指向别的对象,不保证 Counter 对象不可变。

1.1 静态类型和运行时类型

Animal animal = new Dog();
animal.speak();

这里:

  • Animalanimal静态类型,编译器据此检查能否调用 speak
  • Dog 是对象的运行时类型,实例方法通常据此进行动态分派。

如果 Animal 没有声明 run,即使 Dogrun,下面代码也无法编译:

animal.run(); // 编译错误:Animal 类型中没有 run

若需要访问 Dog 特有操作,必须进行类型检查和转换:

if (animal instanceof Dog dog) {
    dog.run();
}

instanceof 模式匹配同时完成检查和变量绑定。它不能把不相关的类型强行转换为目标类型;类型系统会在编译期拒绝明显不可能的转换。

1.2 null 是引用值,不是对象

String name = null;
name.length(); // NullPointerException

null 没有对象身份,也没有方法。以下操作会触发空指针异常:

  • 调用实例方法;
  • 访问实例字段;
  • null 进行自动拆箱;
  • null 调用某些需要对象的操作。
Integer count = null;
int n = count; // 自动拆箱时抛出 NullPointerException

这也是 API 边界需要明确约定的原因:一个参数是否允许为 null,不能仅靠调用方猜测。


2. 类:对象状态和行为的封装单元

类声明了对象的:

  • 实例字段;
  • 实例方法;
  • 构造器;
  • 访问控制;
  • 继承关系;
  • 实现的接口。

一个简单的类如下:

public final class Money {
    private final long cents;
    private final String currency;

    public Money(long cents, String currency) {
        if (cents < 0) {
            throw new IllegalArgumentException("cents must be non-negative");
        }
        this.currency = java.util.Objects.requireNonNull(currency);
        this.cents = cents;
    }

    public long cents() {
        return cents;
    }

    public String currency() {
        return currency;
    }

    public Money add(Money other) {
        java.util.Objects.requireNonNull(other);
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(cents + other.cents, currency);
    }

    @Override
    public boolean equals(Object obj) {
        return obj instanceof Money other
                && cents == other.cents
                && currency.equals(other.currency);
    }

    @Override
    public int hashCode() {
        return java.util.Objects.hash(cents, currency);
    }

    @Override
    public String toString() {
        return cents + " " + currency;
    }
}

这个类表达了一个不变量:

cents >= 0
currency != null

构造器负责首次建立不变量;后续方法不能破坏它。对象模型中的封装不是简单地把字段设为 private,而是让对象负责维护自身状态的合法性。

2.1 构造器不是普通方法

构造器:

  • 名称必须与类名相同;
  • 没有返回类型;
  • 不能被继承;
  • 用于创建并初始化对象;
  • 若构造失败,对象不会作为一个正常构造完成的实例返回给调用方。
class User {
    private final String name;

    User(String name) {
        this.name = java.util.Objects.requireNonNull(name);
    }
}

创建对象时,逻辑上会经历以下过程:

  1. 为对象分配存储;
  2. 实例字段获得默认值,例如 0falsenull
  3. 执行超类构造过程;
  4. 执行当前类的实例字段初始化器和实例初始化块;
  5. 执行当前构造器主体。

继承场景下,子类构造器必须直接或间接调用超类构造器:

class Animal {
    Animal(String name) {
        // ...
    }
}

class Dog extends Animal {
    Dog() {
        super("unknown"); // 必须显式调用,因为 Animal 没有无参构造器
    }
}

构造器中调用可被子类重写的实例方法通常是危险的,因为子类字段初始化可能尚未完成:

class Base {
    Base() {
        print(); // 可能动态分派到 Derived.print()
    }

    void print() {}
}

class Derived extends Base {
    private String value = "ready";

    @Override
    void print() {
        System.out.println(value); // 可能打印 null
    }
}

这不是因为 value 的声明失效,而是因为超类构造器运行时,子类字段初始化器还没有执行。构造阶段应尽量只使用已经建立的不变量。


3. 访问控制和封装边界

Java 的主要访问级别如下:

修饰符 可访问范围
private 当前顶层类或声明它的类内部
无修饰符 同一 package
protected 同一 package,以及其他 package 中的子类访问规则
public 只要模块和其他访问条件允许,均可访问

访问控制不是安全沙箱。它首先是编译期的 API 边界;反射、模块系统和运行时权限还会影响实际可访问性。

字段通常应设为 private,通过方法暴露受控操作:

public final class Temperature {
    private double celsius;

    public Temperature(double celsius) {
        setCelsius(celsius);
    }

    public double celsius() {
        return celsius;
    }

    public void setCelsius(double celsius) {
        if (celsius < -273.15) {
            throw new IllegalArgumentException("below absolute zero");
        }
        this.celsius = celsius;
    }
}

这里 setCelsius 不是单纯的字段赋值,而是状态转移:

旧状态 celsius
    │
    ├─ 新值 < -273.15 ──► 拒绝,旧状态保持
    │
    └─ 新值合法 ────────► 更新为新状态

如果直接公开字段,调用方可以绕过这个状态转换,封装就失效了。


4. 继承:建立“是一个”关系

继承允许一个类基于另一个类定义。class Child extends Parent 表示 ChildParent 的子类型,通常表达“Child 是一种 Parent”。

class Animal {
    void speak() {
        System.out.println("some sound");
    }
}

class Dog extends Animal {
    @Override
    void speak() {
        System.out.println("woof");
    }
}

继承带来三种常见能力:

  1. 复用超类的实现;
  2. 向上转型,把子类对象当作超类使用;
  3. 重写实例方法,实现多态。
Animal animal = new Dog();
animal.speak(); // woof

4.1 重载与重写不是一回事

重载发生在编译期:方法名相同但参数列表不同。

重写发生在继承关系中:子类为超类的可继承实例方法提供新的实现。

class Printer {
    void print(Object x) {
        System.out.println("Object");
    }

    void print(String x) {
        System.out.println("String");
    }
}

Object value = "text";
new Printer().print(value); // Object

这里调用哪个重载版本由变量的静态类型 Object 决定。

class Parent {
    void print() {
        System.out.println("parent");
    }
}

class Child extends Parent {
    @Override
    void print() {
        System.out.println("child");
    }
}

Parent p = new Child();
p.print(); // child

这里调用哪个实现由对象运行时类型决定。

简化理解为:

重载选择:编译期,根据静态类型和参数类型
重写分派:运行期,根据接收者对象的运行时类型

静态方法不参与这种实例方法动态分派;静态方法属于类,调用时应通过类名表达:

class A {
    static void f() { System.out.println("A"); }
}

class B extends A {
    static void f() { System.out.println("B"); }
}

A x = new B();
x.f(); // A,静态方法按声明类型解析

这类代码容易造成误读,应避免用实例引用调用静态方法。

4.2 子类型必须满足可替换性

继承的核心边界不是“代码能否复用”,而是子类型能否在不破坏客户端正确性的情况下替换父类型。

设父类型允许的输入集合为 PP,子类型允许的输入集合为 CC,父类型产生的结果集合为 RPR_P,子类型产生的结果集合为 RCR_C。一个常用的可替换性条件是:

C 至少接受 P 接受的所有输入:P ⊆ C
C 返回的结果不能超出父类型承诺:R_C ⊆ R_P

直觉是:

  • 子类不能无故收紧父类的前置条件;
  • 子类不能削弱父类保证的后置条件;
  • 父类建立的不变量,子类也必须保持。

反例:

class Bird {
    void fly() {
        // 合约:所有 Bird 都能飞
    }
}

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException();
    }
}

如果调用方接收 Bird 后合理地调用 fly(),传入 Penguin 就失败。问题不是企鹅“实现得不完整”,而是 Bird 的抽象边界错误:会走路的动物和会飞的动物不应由同一个强制父类方法表达。

更合理的建模是:

interface Animal {
    void eat();
}

interface FlyingAnimal {
    void fly();
}

final class Penguin implements Animal {
    @Override
    public void eat() {}
}

final class Eagle implements Animal, FlyingAnimal {
    @Override
    public void eat() {}

    @Override
    public void fly() {}
}

4.3 继承的限制

Java 类是单继承的:

class Child extends ParentA, ParentB { } // 非法

一个类只能有一个直接超类,但可以实现多个接口:

class Service implements Closeable, Runnable {
    @Override
    public void close() {}

    @Override
    public void run() {}
}

类不能重写:

  • final 方法;
  • private 方法;
  • 静态方法的“实例版本”。

类也不能继承 final 类:

final class SecurityToken {}
class SpecialToken extends SecurityToken {} // 编译错误

final 类常用于表达“不允许通过继承扩展此实现”。String 就是典型例子,但 final 本身不等于不可变;一个 final 类仍可拥有可变字段。


5. 接口:能力契约,而不是共享对象状态

接口主要描述一组可供客户端依赖的操作契约:

interface PaymentMethod {
    PaymentResult pay(Money amount);
}

类可以实现多个接口:

final class CardPayment implements PaymentMethod {
    @Override
    public PaymentResult pay(Money amount) {
        return PaymentResult.success();
    }
}

接口变量表达的是“调用方需要什么能力”,而不是“调用方知道具体实现是什么”:

PaymentMethod method = new CardPayment();
PaymentResult result = method.pay(new Money(1000, "CNY"));

接口中的字段隐含为 public static final;接口不能声明普通实例字段来保存每个实现对象的状态。接口可以声明:

  • 抽象实例方法;
  • default 实例方法;
  • static 方法;
  • private 方法;
  • 常量字段。

default 方法提供可继承的默认行为:

interface Named {
    String name();

    default String displayName() {
        return name().trim();
    }
}

如果两个接口提供冲突的默认方法,实现类必须显式解决:

interface A {
    default String value() {
        return "A";
    }
}

interface B {
    default String value() {
        return "B";
    }
}

final class C implements A, B {
    @Override
    public String value() {
        return A.super.value(); // 明确选择 A 的实现
    }
}

类自身的实现优先于接口默认方法;两个接口的默认方法冲突时,不能让编译器猜测。

5.1 接口和抽象类的边界

抽象类可以拥有:

  • 实例字段;
  • 构造器;
  • 受保护的共享实现;
  • 部分已实现的方法;
  • 一个明确的类继承链。

接口更适合表达:

  • 跨不同继承体系共享的能力;
  • 调用方真正需要的最小契约;
  • 可替换的外部实现。

例如,ArrayListHashSet 都可以实现 Collection,但它们的内部状态和算法完全不同。调用方若只需要遍历和查询,就不应该依赖具体集合类。

接口也不是“必须没有实现”。接口默认方法适合稳定的通用行为,但若默认行为依赖大量共享状态,抽象类或组合对象通常更清晰。


6. 组合:通过对象协作构造行为

组合是一个对象持有另一个对象,并把工作委托给它。它表达“有一个”关系,而不是“是一个”。

interface DiscountPolicy {
    Money apply(Money original);
}

final class NoDiscount implements DiscountPolicy {
    @Override
    public Money apply(Money original) {
        return original;
    }
}

final class Order {
    private final DiscountPolicy discountPolicy;

    Order(DiscountPolicy discountPolicy) {
        this.discountPolicy = java.util.Objects.requireNonNull(discountPolicy);
    }

    Money finalPrice(Money original) {
        return discountPolicy.apply(original);
    }
}

数据流是:

调用方
  │ original Money
  ▼
Order.finalPrice
  │ 委托
  ▼
DiscountPolicy
  │ 返回
  ▼
最终 Money

折扣策略可以替换,而 Order 不需要继承多个不同的订单基类:

final class TenPercentDiscount implements DiscountPolicy {
    @Override
    public Money apply(Money original) {
        return new Money(original.cents() * 90 / 100, original.currency());
    }
}

组合的关键优势是变化隔离

  • 继承把父类实现和子类绑定在编译结构中;
  • 组合把协作对象作为依赖,可以通过构造器替换;
  • 组合对象可以在运行时选择不同策略;
  • 被组合对象的生命周期和可见性可以独立管理。

但组合也有边界。若一个对象只是把所有方法原样转发给另一个对象,可能形成无意义的包装层;若协作对象过多,组合根本身会承担过多协调职责。组合不是“越多越好”,而是应让每个对象拥有清晰的不变量和责任。

6.1 继承与组合的反例对比

错误地用继承表达“拥有”:

class Stack extends ArrayList<String> {
    void push(String value) {
        add(value);
    }

    String pop() {
        return remove(size() - 1);
    }
}

Stack 继承 ArrayList 后,调用方仍可使用 add(index, value)remove(Object) 等操作,破坏栈的约束。它并不是一个可以安全替换 ArrayList 的类型。

组合可以隐藏不需要暴露的操作:

final class StringStack {
    private final java.util.ArrayList<String> values = new java.util.ArrayList<>();

    void push(String value) {
        values.add(value);
    }

    String pop() {
        if (values.isEmpty()) {
            throw new java.util.NoSuchElementException("empty stack");
        }
        return values.remove(values.size() - 1);
    }

    int size() {
        return values.size();
    }
}

此时 ArrayList 是实现细节,客户端只能使用栈允许的操作。组合并不自动保证正确性,但它减少了暴露非法操作的机会。


7. 不可变性:不是 final 字段的同义词

不可变对象是指:对象构造完成并对外可见后,其逻辑状态不能再改变。

一个不可变类通常需要同时满足:

  1. 类不能被不受控地继承,常见做法是声明 final
  2. 表示状态的字段为 private final
  3. 构造器建立全部不变量;
  4. 不提供改变状态的方法;
  5. 不泄露可变内部对象;
  6. 不把外部可变对象直接保存为内部表示;
  7. 返回的复合对象也经过保护;
  8. equalshashCode 等基于稳定状态实现。

下面的类看起来使用了 final,但实际上不是不可变的:

final class MutableHolder {
    private final java.util.List<String> values;

    MutableHolder(java.util.List<String> values) {
        this.values = values; // 保存了调用方传入的可变引用
    }

    java.util.List<String> values() {
        return values; // 又把内部引用暴露出去
    }
}

调用方可以通过两个方向修改内部状态:

var source = new java.util.ArrayList<>(java.util.List.of("a"));
var holder = new MutableHolder(source);

source.add("b");       // 修改 holder 内部状态
holder.values().clear(); // 同样修改 holder 内部状态

正确做法是防御性复制,并且返回不可修改视图或不可变副本:

public final class ImmutableTags {
    private final java.util.List<String> tags;

    public ImmutableTags(java.util.Collection<String> tags) {
        java.util.Objects.requireNonNull(tags);
        this.tags = java.util.List.copyOf(tags);
    }

    public java.util.List<String> tags() {
        return tags;
    }
}

List.copyOf 会创建一个不可修改的列表结果,并拒绝 null 元素;它不等价于把任意对象“深度冻结”。如果元素本身可变,元素仍可能变化:

final class MutableName {
    String value;
}

var name = new MutableName();
name.value = "before";

var list = java.util.List.copyOf(java.util.List.of(name));
name.value = "after"; // 列表结构没变,但元素对象变了

因此应区分:

浅不可变:容器结构不能改变,元素对象仍可能改变
深不可变:从根对象可达的全部相关状态都不能改变

Java 标准库通常提供的是浅层保护;深不可变需要元素类型本身也满足不可变约束,或者进行递归复制。

7.1 不可变对象的状态转移

不可变对象不修改自身,而是返回新对象:

Money a = new Money(1000, "CNY");
Money b = a.add(new Money(500, "CNY"));

System.out.println(a); // 1000 CNY
System.out.println(b); // 1500 CNY

状态变化从:

a: 1000 CNY

变成了创建另一个对象:

a: 1000 CNY
b: 1500 CNY

这使得共享更容易推理:持有 a 的代码不会因为其他代码“修改了同一个 Money”而观察到意外变化。

7.2 final 与并发可见性

final 字段还有 Java 内存模型层面的构造安全语义:如果构造过程没有把 this 泄露出去,其他线程在获得对象引用后,对正确初始化的 final 字段具有特殊保证。

但这不意味着整个对象天然线程安全:

final class Config {
    private final java.util.List<String> values;

    Config(java.util.List<String> values) {
        this.values = values; // 列表仍可被其他线程修改
    }
}

final 只保护引用变量不被重新赋值,不保护被引用对象的内部状态。线程安全还需要考虑:

  • 可变状态是否存在;
  • 状态访问是否同步;
  • 对象是否安全发布;
  • 复合操作是否原子;
  • 被引用的对象是否也满足并发要求。

不可变对象通常可以安全共享,但前提是它的整个可观察状态确实不可变,且构造期间没有把未完成对象发布出去。


8. equalshashCode 和对象身份边界

值对象通常按逻辑状态比较,而实体对象通常按身份或业务标识比较。

若重写 equals,必须同时满足 hashCode 契约:

a.equals(b) == true  ⇒  a.hashCode() == b.hashCode()

反过来不要求成立。哈希冲突是允许的。

错误示例:

final class Point {
    private final int x;
    private final int y;

    @Override
    public boolean equals(Object obj) {
        return obj instanceof Point p && x == p.x && y == p.y;
    }

    // 未重写 hashCode
}

两个逻辑相等的 Point 可能有不同哈希值,放入 HashSet 后会出现违反预期的查找行为。

更危险的是,把可变字段用于哈希后再修改:

final class MutableKey {
    int id;

    @Override
    public boolean equals(Object o) {
        return o instanceof MutableKey k && id == k.id;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(id);
    }
}

var key = new MutableKey();
key.id = 1;

var set = new java.util.HashSet<MutableKey>();
set.add(key);

key.id = 2;
System.out.println(set.contains(key)); // 可能为 false

对象仍在集合中,但它的哈希桶位置是按旧值确定的。这个失败不是 HashSet 随机,而是键的哈希相关状态在插入后发生了变化。

因此,作为哈希键的对象,其参与 equalshashCode 的状态在作为键期间必须保持稳定。不可变值对象天然适合这一边界。


9. Record:受约束的数据载体

record 是一种特殊的类声明,适合表达主要由固定组件构成的数据。它隐含生成:

  • private final 组件字段;
  • 规范构造器;
  • 与组件同名的访问器;
  • equals
  • hashCode
  • toString
public record UserId(long value) {
    public UserId {
        if (value <= 0) {
            throw new IllegalArgumentException("value must be positive");
        }
    }
}

这里的紧凑构造器仍然是在构造阶段验证输入。UserId 的组件 value 是不可变的,因此这个例子是不可变值对象。

但 record 不是自动深度不可变:

record Names(java.util.List<String> values) {}

var source = new java.util.ArrayList<>(java.util.List.of("A"));
var names = new Names(source);

source.add("B"); // names.values() 现在也能看到 "B"

如果 record 组件是集合,应在构造器中复制:

public record SafeNames(java.util.List<String> values) {
    public SafeNames {
        values = java.util.List.copyOf(values);
    }
}

record 不能继承另一个类,隐式继承 java.lang.Record,但可以实现接口。它适合数据语义,不代表所有包含数据的类都应改写为 record;需要可变生命周期、复杂身份语义、受控构造流程或继承层次时,普通类更合适。


10. Sealed 类型:把继承边界显式化

sealed 类型限制允许的直接子类型:

public sealed interface Shape
        permits Circle, Rectangle {
}

public record Circle(double radius) implements Shape {
}

public record Rectangle(double width, double height) implements Shape {
}

每个直接子类型必须声明为:

  • final:不再允许继续继承;
  • sealed:继续限制子类型;
  • non-sealed:重新开放继承。
public sealed interface Node
        permits Leaf, Branch {
}

public final class Leaf implements Node {
}

public non-sealed class Branch implements Node {
}

sealed 的价值是把领域层次的封闭性写入类型系统。编译器知道直接子类型集合后,结合模式匹配可以进行穷尽性检查:

static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
    };
}

switch 没有 default,仍可通过编译,是因为 Shape 的许可子类型已经覆盖了情况。若后来增加新的许可子类型,相关 switch 可能需要重新处理。

sealed 不是运行时权限系统,也不是对所有间接实现都完全禁止;它约束的是直接继承或实现关系,并受模块、package 和可访问性规则约束。它表达的是“这个类型层次由哪些参与者组成”,而不是“对象状态不可变”。


11. 泛型、接口和数组协变的边界

对象模型经常通过泛型表达容器关系。Java 泛型通常是不变的:

List<String> strings = new ArrayList<>();
List<Object> objects = strings; // 编译错误

如果这段赋值合法,就可以执行:

objects.add(42);

从而把整数放入只允许字符串的列表。因此,List<String> 不是 List<Object> 的子类型。

需要只读生产能力时使用上界通配符:

static double sum(java.util.List<? extends Number> values) {
    double total = 0;
    for (Number value : values) {
        total += value.doubleValue();
    }
    return total;
}

? extends Number 表示“某个未知的 Number 子类型”。可以安全读取为 Number,但不能向其中写入任意 Number,因为实际类型可能是 Integer

static void addIntegers(java.util.List<? extends Number> values) {
    // values.add(1); // 编译错误
}

需要写入某种类型时使用下界:

static void addDefaults(java.util.List<? super Integer> values) {
    values.add(1);
    values.add(2);
}

? super Integer 可能是 List<Integer>List<Number>List<Object>,写入 Integer 都安全;读取时只能可靠地当作 Object

这就是 PECS:

Producer Extends:生产 T 的来源使用 ? extends T
Consumer Super:消费 T 的目标使用 ? super T

数组则不同:

String[] strings = new String[1];
Object[] objects = strings; // 合法:数组协变

objects[0] = 42; // ArrayStoreException

数组协变把一部分类型检查推迟到了运行期;泛型不变性则更多在编译期阻止这类错误。API 设计时不能把“数组可协变”误推导成“泛型容器也可协变”。


12. 继承、组合和接口的选择推导

可以按以下问题逐步确定边界。

12.1 问题一:是否满足真正的“是一个”

Child 作为 Parent 使用时,所有合法的 Parent 操作对 Child 都成立,继承或接口子类型关系才有候选价值。

客户端只知道 Parent
客户端调用 Parent 合约允许的操作
Child 能完成这些操作,并保持 Parent 不变量

只要其中一个关键操作必须抛出“不支持”,或者子类需要削弱父类保证,继承关系就值得怀疑。

12.2 问题二:变化是否来自算法或策略

若变化只是某个算法、规则、存储方式或外部服务实现,组合通常更适合:

Order ──持有──► DiscountPolicy

而不是:

DiscountedOrder extends Order

前者允许同一个订单对象在构造时注入不同策略,后者会把策略种类变成继承层次。

12.3 问题三:调用方真正依赖多少操作

如果调用方只需要:

interface Clock {
    java.time.Instant now();
}

就不要让它依赖一个同时负责配置、网络连接、日志和缓存的大型具体类。较小的接口能减少替换成本,也能使违反契约的实现更容易被发现。

不过,接口拆分也有成本:过度拆分会产生大量只包含一个方法的类型,使领域概念变得碎片化。接口边界应围绕稳定的客户端需求,而不是机械地按方法数量拆分。


13. 继承和组合中的异常边界

异常也是对象契约的一部分。父类型若承诺某操作在输入合法时成功,子类型不应在相同输入上无故引入新的失败路径。

例如:

interface Cache {
    String get(String key);
}

如果调用方理解为“未命中返回 null”,一个实现却在未命中时抛出 NoSuchElementException,即使方法签名完全一致,也改变了契约。

组合中同样要处理委托失败:

final class PaymentService {
    private final PaymentMethod method;

    PaymentService(PaymentMethod method) {
        this.method = java.util.Objects.requireNonNull(method);
    }

    PaymentResult pay(Money amount) {
        try {
            return method.pay(amount);
        } catch (java.net.ConnectException e) {
            return PaymentResult.retryableFailure("payment provider unavailable");
        }
    }
}

这里必须明确:

  • 哪些异常表示可重试;
  • 哪些异常表示业务拒绝;
  • 底层异常是否应该直接泄露;
  • 重试是否可能造成重复扣款;
  • 委托对象的状态是否已经改变。

如果支付请求已经被服务端接受,但响应丢失,简单重试可能产生重复扣款。对象边界不能只看 Java 方法调用是否返回,还要看外部系统状态是否已经改变。


14. 一个完整的小例子:接口、组合、不可变对象和 sealed 结果

下面的代码把几种边界放在一起:

import java.util.Objects;

public class ObjectModelDemo {
    public static void main(String[] args) {
        PaymentGateway gateway = new FakeGateway();
        PaymentService service = new PaymentService(gateway);

        PaymentRequest request = new PaymentRequest(
                new Money(1_500, "CNY"),
                "order-1001"
        );

        PaymentResult result = service.pay(request);

        String message = switch (result) {
            case PaymentResult.Success s ->
                    "success: " + s.transactionId();
            case PaymentResult.Rejected r ->
                    "rejected: " + r.reason();
        };

        System.out.println(message);
    }

    public record Money(long cents, String currency) {
        public Money {
            if (cents <= 0) {
                throw new IllegalArgumentException("cents must be positive");
            }
            currency = Objects.requireNonNull(currency);
        }
    }

    public record PaymentRequest(Money amount, String orderId) {
        public PaymentRequest {
            amount = Objects.requireNonNull(amount);
            orderId = Objects.requireNonNull(orderId);
        }
    }

    public sealed interface PaymentResult
            permits PaymentResult.Success, PaymentResult.Rejected {

        record Success(String transactionId)
                implements PaymentResult {
            public Success {
                transactionId = Objects.requireNonNull(transactionId);
            }
        }

        record Rejected(String reason)
                implements PaymentResult {
            public Rejected {
                reason = Objects.requireNonNull(reason);
            }
        }

        static Success success(String id) {
            return new Success(id);
        }

        static Rejected rejected(String reason) {
            return new Rejected(reason);
        }
    }

    public interface PaymentGateway {
        PaymentResult charge(PaymentRequest request);
    }

    public static final class PaymentService {
        private final PaymentGateway gateway;

        public PaymentService(PaymentGateway gateway) {
            this.gateway = Objects.requireNonNull(gateway);
        }

        public PaymentResult pay(PaymentRequest request) {
            Objects.requireNonNull(request);
            return gateway.charge(request);
        }
    }

    public static final class FakeGateway
            implements PaymentGateway {

        @Override
        public PaymentResult charge(PaymentRequest request) {
            return PaymentResult.success("tx-" + request.orderId());
        }
    }
}

编译运行:

javac ObjectModelDemo.java
java ObjectModelDemo

预期输出:

success: tx-order-1001

关键数据流是:

PaymentRequest
      │
      ▼
PaymentService
      │ 组合并委托
      ▼
PaymentGateway
      │
      ▼
sealed PaymentResult
      │
      ▼
switch 模式匹配

各部分职责不同:

  • MoneyPaymentRequest 是经过校验的不可变数据;
  • PaymentGateway 是可替换的接口边界;
  • PaymentService 通过组合持有网关;
  • PaymentResult 用 sealed 层次限制结果类型;
  • switch 根据结果类型进行穷尽处理;
  • FakeGateway 可以替换真实外部支付实现。

这个例子没有让 PaymentService extends FakeGateway,因为服务并不是一种网关;它只是使用网关完成更高层的业务操作。


15. 设计边界中的常见失败表现

15.1 把实现继承当作复用工具

症状:

  • 子类必须了解父类大量受保护状态;
  • 父类修改后多个子类同时出现回归;
  • 子类继承了不需要的公开方法;
  • 子类需要覆盖方法以抛出“不支持”。

诊断方式是先写出父类对调用方的完整契约,再验证每个子类是否能无条件满足,而不是只检查“代码是否能编译”。

15.2 把 final 误认为深度不可变

症状:

private final List<Item> items;

但外部修改传入列表,或者通过 getter 修改返回列表。诊断时沿着对象引用图检查:

根对象
  └─ 字段引用
       └─ 集合
            └─ 元素对象
                 └─ 可变字段

每一层都要判断是否存在可达的修改路径。只有根字段 final,无法推出整张引用图不可变。

15.3 用可变对象作为哈希键

症状包括:

  • HashMap.get(key) 返回 null,但遍历 map 又能看到该键;
  • HashSet.contains(key) 在插入后突然返回 false
  • 问题只在修改字段后出现。

检查 equalshashCode 所依赖的字段是否在放入集合后发生变化。恢复通常需要移除旧键、恢复键状态,或重建集合;仅重新调用 contains 不会修复错误的桶位置。

15.4 接口契约过宽

症状:

  • 实现类大量方法只抛 UnsupportedOperationException
  • 测试必须覆盖许多与当前客户端无关的方法;
  • 替换实现困难。

这说明接口可能混合了多个客户端角色。应从调用方实际使用的操作出发拆分能力,但要保持领域概念可理解,避免把每个方法都变成独立接口。

15.5 暴露内部可变状态

症状:

return internalMap;

调用方可能绕过当前类的校验、并发控制和生命周期管理。修复方式取决于契约:

  • 返回不可修改视图;
  • 返回防御性副本;
  • 暴露只读接口;
  • 提供具体查询和命令方法,而不是整个集合;
  • 若需要快照,明确返回某一时刻的副本。

视图和副本不是同一件事:视图通常仍反映源集合后续变化,副本则拥有独立结构。


16. 规范保证、常见实现和工程判断

需要区分三类结论。

Java 语言和标准库规范保证包括:

  • 类的单继承;
  • 接口可多实现;
  • 引用类型的静态检查与运行时类型检查;
  • 实例方法的动态分派规则;
  • equalshashCode 的契约;
  • record、sealed 类型的语言语义;
  • 泛型类型检查和类型擦除相关规则。

常见 JVM 实现行为包括:

  • 对象和字段通常按堆上的某种布局存储;
  • 可能使用对象头、指针压缩、逃逸分析和标量替换;
  • 可能以内联优化动态分派;
  • 可能不为某些实际逃逸的对象分配独立堆内存。

这些布局和优化不是 Java 源码层面可以依赖的对象模型。不要根据某个 JVM 版本、垃圾收集器或处理器上的字段排列推导业务正确性。

工程判断则包括:

  • 是否使用继承;
  • 是否使用组合;
  • 是否将类型设计为 final
  • 是否用 record 表达值;
  • 是否将层次设计为 sealed;
  • 是否允许 null
  • 是否选择可变还是不可变状态。

这些判断不能由单一规则自动决定,必须回到对象的身份、生命周期、不变量、协作关系和变化来源。


17. 最终边界:让类型结构匹配领域事实

一个可靠的 Java 对象模型至少要能回答以下问题:

  1. 这个对象是按身份识别,还是按值比较?
  2. 哪些状态必须始终成立?
  3. 谁负责创建和验证状态?
  4. 哪些操作是对象真正允许的公开行为?
  5. 子类型是否能满足父类型的全部契约?
  6. 这个关系是“是一个”还是“有一个”?
  7. 变化来自类型本身,还是来自可替换的策略和依赖?
  8. 返回对象后,调用方能否绕过不变量修改内部状态?
  9. 对象作为并发共享数据时,状态是否稳定且安全发布?
  10. 该类型层次应该开放扩展、限制扩展,还是完全禁止继承?

类提供状态和行为的封装,接口提供能力契约,继承提供受约束的子类型关系,组合提供对象协作和变化隔离,不可变性提供稳定的值语义与更容易推理的共享方式,record 和 sealed 则把常见的数据边界与层次边界直接写入类型系统。真正的设计质量不在于使用了多少面向对象术语,而在于这些机制是否准确表达了对象的状态、责任和替换条件。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。