跳到内容

8.2 Builder、Prototype 与实例作用域

工匠们为复杂对象写出了越来越长的构造函数,复制和全局实例又带来新的生命周期问题。

Factory 解决“选择哪种实现”,Builder 解决“怎样把一个复杂对象组装完整”,Prototype 解决“怎样从现有对象派生新对象”。Singleton 则不是组装算法,而是对实例作用域施加限制。

Builder:把可选配置与最终不变量分开

构造器参数很多并不自动需要 Builder。若参数都必填,一个参数对象或记录类型可能更诚实。Builder 更适合:

  • 可选项较多;
  • 创建过程分多步收集信息;
  • 构建前需要跨字段校验;
  • 希望最终对象保持不可变。
java
public final class TournamentPlan {
    private final String name;
    private final int capacity;
    private final java.time.Duration matchDuration;
    private final boolean ranked;
    private final Integer minimumLevel;

    private TournamentPlan(Builder b) {
        this.name = b.name;
        this.capacity = b.capacity;
        this.matchDuration = b.matchDuration;
        this.ranked = b.ranked;
        this.minimumLevel = b.minimumLevel;
    }

    public static Builder builder(String name, int capacity) {
        return new Builder(name, capacity);
    }

    public static final class Builder {
        private final String name;
        private final int capacity;
        private java.time.Duration matchDuration =
                java.time.Duration.ofMinutes(15);
        private boolean ranked;
        private Integer minimumLevel;

        private Builder(String name, int capacity) {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("name is blank");
            }
            if (capacity < 2) {
                throw new IllegalArgumentException("capacity < 2");
            }
            this.name = name;
            this.capacity = capacity;
        }

        public Builder matchDuration(java.time.Duration value) {
            this.matchDuration = java.util.Objects.requireNonNull(value);
            return this;
        }

        public Builder rankedFromLevel(int level) {
            this.ranked = true;
            this.minimumLevel = level;
            return this;
        }

        public TournamentPlan build() {
            if (matchDuration.isZero() || matchDuration.isNegative()) {
                throw new IllegalStateException("match duration must be positive");
            }
            if (ranked && minimumLevel < 1) {
                throw new IllegalStateException("minimum level < 1");
            }
            return new TournamentPlan(this);
        }
    }
}

必填参数在 builder() 入口出现;可选参数有领域化名称;最终对象只在 build() 验证通过后产生。

Builder 自身通常可变且不保证线程安全,不应跨线程共享。构建完成后也不要让最终对象保留对 Builder 可变集合的引用;使用 List.copyOf 等方式取得快照。

Builder 不是 setter 的另一种拼法

以下 Builder 仍能制造非法对象:

java
builder.name(null).capacity(-1).build();

如果所有字段都可任意设置,build() 也不校验,它只是把 setter 改成链式语法。真正价值在于:

  • 区分必填与可选;
  • 用方法名表达有效组合;
  • 在一个位置维护跨字段不变量;
  • 隐藏中间态,不让半成品进入业务系统。

当构建顺序本身必须受类型约束时,可以使用 staged builder,但接口数量和泛型复杂度会增加。多数业务对象用运行时校验已经足够。

Prototype:先定义复制语义

“复制”至少有三种可能:

  • 浅复制:新外层对象,共享内部引用;
  • 深复制:递归复制整张可达对象图;
  • 领域复制:只复制被定义为模板的值,重置 ID、时间戳和运行状态。

业务系统通常需要第三种,而不是无差别深拷贝。

java
public record TournamentTemplate(
        String name,
        int capacity,
        java.time.Duration matchDuration,
        java.util.List<RewardRule> rewardRules) {

    public TournamentTemplate {
        rewardRules = java.util.List.copyOf(rewardRules);
    }

    public TournamentTemplate withName(String newName) {
        return new TournamentTemplate(
                newName, capacity, matchDuration, rewardRules);
    }

    public Tournament instantiate(TournamentId newId,
                                  java.time.Instant createdAt) {
        return Tournament.draft(
                newId, name, capacity, matchDuration,
                rewardRules, createdAt);
    }
}

不可变 RewardRule 可以安全共享,不必为了“深拷贝”制造重复对象;实体 ID 和创建时间则在实例化时重新生成。复制策略直接写在类型 API 中,比通用 clone() 更容易审查。

Java 的 Cloneable 只是一种标记,并不声明公开 clone() 方法;Object.clone() 默认执行字段级浅复制。对拥有可变引用、资源句柄或继承层次的对象,显式复制构造器、命名复制方法或不可变值通常更安全。

序列化再反序列化不是通用深拷贝方案:它把复制语义绑定到序列化格式,还会带来性能、版本、类型和安全问题。

Singleton:先问“在哪个范围唯一”

“系统只有一个实例”必须说明范围:

text
每次方法调用?每个请求?每个线程?
每个 DI 容器?每个 class loader?每个 JVM 进程?
整个集群?整个租户?

Java 静态字段最多约束到加载该类的 class loader;多进程部署中每个进程仍有自己的实例。它不能实现集群级唯一,也不能替代分布式锁或数据库唯一约束。

全局访问与单实例是两件事

java
public final class RegistrationService {
    private final Clock clock;

    public RegistrationService(Clock clock) {
        this.clock = clock;
    }
}

装配层可以只创建一个 Clock 或服务实例,再注入所有调用者;业务代码无需 GlobalClock.getInstance()。这样既控制实例数量,又保持依赖显式和可测试。

全局 Singleton 常见问题包括:

  • 隐藏依赖和初始化顺序;
  • 可变状态在测试间泄漏;
  • 并发规则不清楚;
  • 以后需要多租户、多配置或多个连接池时难拆;
  • 生命周期与关闭顺序难管理。

日志 facade、枚举常量或无状态共享对象可能适合全局入口,但应把可变配置和资源生命周期放在明确边界。

Java 中线程安全初始化不等于线程安全对象

java
public enum ProcessIdentity {
    INSTANCE;

    private final java.util.UUID id = java.util.UUID.randomUUID();

    public java.util.UUID id() { return id; }
}

枚举可以提供 JVM/class-loader 范围内可靠的初始化与序列化语义,但如果 Singleton 内部持有可变 Map、计数器或连接,仍需单独设计同步、可见性和关闭行为。“单例创建是线程安全的”不能推出“所有方法都线程安全”。

五种模式的选择表

模式主要变化点典型风险
Factory Method父类流程中的产品创建步骤为简单选择引入不必要继承
Abstract Factory整族产品的具体实现给产品族增加新种类代价高
Builder复杂对象的组装与校验半成品泄露、只换皮的 setter
Prototype从现有值派生对象浅/深/领域复制语义不清
Singleton某一范围内的实例数量全局状态、隐藏依赖、误判作用域

模式名不是目标。先写清楚变化点、生命周期、所有权和不变量,再选择最小的创建机制。

练习

选择一个真实对象,分别回答:

  1. 哪些参数必填,哪些可选?
  2. 直接构造为何不足?
  3. 如果使用 Builder,哪些非法组合必须在 build() 拒绝?
  4. 如果从模板复制,哪些字段共享、复制或重置?
  5. 如果只创建一个实例,这个“一”具体属于哪个范围?

如果这些问题答不清楚,先不要选择模式。

参考资料

Built with VitePress | Software Systems Atlas