为什么建造者模式常用来创建不可变对象?
简化版
建造者模式适合创建不可变对象,因为 Builder 可以在构建阶段临时保存可变参数,build() 后返回字段固定的最终对象。这样既保留了灵活配置能力,又避免对象创建后被随意修改。
详细版
不可变对象通常要求:
- 字段使用
private final; - 不提供修改字段的 setter;
- 构造时完成所有必要校验;
- 对可变引用做防御性拷贝;
- 对外返回集合时避免暴露内部可变集合。
如果字段很多,直接用构造方法创建不可变对象会很痛苦:
new UserProfile("Tom", 18, "BJ", true, List.of("Java"), 3, null);
Builder 可以把可变过程放在 Builder 里,把不可变结果放在 Product 里:
UserProfile profile = UserProfile.builder()
.name("Tom")
.age(18)
.city("北京")
.tags(List.of("Java", "AI"))
.build();
最终对象:
public class UserProfile {
private final String name;
private final int age;
private final List<String> tags;
private UserProfile(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.tags = List.copyOf(builder.tags);
}
}
这里 List.copyOf() 很关键,它避免外部传入的集合后续修改影响对象内部状态。
所以建造者模式常和不可变对象一起出现:Builder 负责灵活,Product 负责稳定。
完整版教学
一、不可变对象为什么重要
不可变对象创建完成后状态不再变化。它的好处非常明显:
- 逻辑更容易推理;
- 不会被其他代码悄悄改掉;
- 多线程读取时更安全;
- 可以放心作为缓存 key 或值对象;
- 错误更容易定位,因为状态变化入口更少。
比如订单查询条件、HTTP 请求配置、权限规则、金额对象、时间范围对象,都很适合做成不可变对象。
二、构造方法创建不可变对象的痛点
不可变对象通常没有 setter,所以所有字段都要通过构造方法传进去。
字段少时没问题:
new Money("CNY", 100);
字段多时就很难维护:
new ReportConfig("日报", true, false, 1000, "xlsx", null, true, 30);
这个调用有明显风险:
- 参数含义不清楚;
- 布尔参数尤其难读;
- 可选参数也必须占位;
- 添加新参数会影响多个构造方法;
- 校验逻辑可能散落在构造器里。
Builder 正好补上这个缺口。
三、Builder 如何兼顾灵活和不可变
Builder 本身是可变的。调用方可以一步步设置字段:
Builder builder = ReportConfig.builder();
builder.title("日报");
builder.format("xlsx");
builder.includeChart(true);
但最终对象是不可变的:
public class ReportConfig {
private final String title;
private final String format;
private final boolean includeChart;
private ReportConfig(Builder builder) {
this.title = builder.title;
this.format = builder.format;
this.includeChart = builder.includeChart;
}
}
可以理解为:Builder 是施工现场,Product 是交付成品。施工现场可以变化,交付成品应该稳定。
四、防御性拷贝不能忽略
很多人创建不可变对象时只写 final,但如果字段是集合、数组、日期这类可变引用,final 只能保证引用不变,不能保证引用指向的对象内容不变。
错误示例:
private UserProfile(Builder builder) {
this.tags = builder.tags;
}
如果外部继续修改原集合,UserProfile 内部也会受影响。
更稳的写法:
private UserProfile(Builder builder) {
this.tags = List.copyOf(builder.tags);
}
public List<String> getTags() {
return tags;
}
如果是数组,可以使用 Arrays.copyOf()。如果是复杂可变对象,需要根据业务决定深拷贝策略。
五、从外部可变 List 到内部不可变快照
调用方把 ArrayList 传给 Builder 后仍持有该引用。若 Product 直接保存引用,之后 tags.add() 会穿透 final 字段改变对象;在构造边界执行 List.copyOf,Product 才获得与外部后续修改隔离的快照。
List tags = new ArrayList(["Java"])
builder.tags(tags)
build(): 校验 name/age
Product 构造: this.tags = List.copyOf(builder.tags)
返回 profile
外部执行 tags.add("AI")
原 tags 变为 [Java, AI]
profile.tags 仍为 [Java]
getter 返回不可变 List
多线程只读共享更容易推理
这条时间线把“可变构建阶段”和“稳定成品阶段”分开。Builder 可以反复接收参数,但 Product 只有在所有规则通过后才出现;若 Product 在第一步就被 new 出来再逐项修改,就仍然存在半初始化对象泄漏的窗口。
六、构建契约与对象不变量
| 评审维度 | 本题结论 |
|---|---|
| 必填信息 | 姓名等身份字段在 build() 前必须存在。 |
| 默认值 | 可选集合默认空集合,避免 Product 内部 null 分支。 |
| 跨字段规则 | 年龄范围、字段组合等在完整参数可见时检查。 |
| 可变引用处理 | 输入集合/数组做防御性拷贝,输出也不泄漏可变引用。 |
| Builder 生命周期 | Builder 可变且局部;Product 创建后不再依赖 Builder 状态。 |
| Product 交付保证 | 所有字段 private final,外界无法观察到后续输入引用变化。 |
build() 不是形式上的结束标记,而是对象从“参数集合”变成“合法业务值”的原子边界。单字段输入可以提前拒绝明显错误,跨字段规则必须等信息齐全后判断,Product 私有构造器还应保留必要兜底,避免未来新增创建入口绕过不变量。
七、与构造器、JavaBean 和工厂的选择边界
- 字段少且全部必填时,短构造器或 record 通常比 Builder 更直接。
- 字段多、可选项多、同类型参数易错时,命名步骤能显著提升调用可读性。
- JavaBean setter 适合某些框架绑定,但对象可能在设置完成前就被观察到。
- Builder 关注“同一种复杂对象怎样组装”;工厂模式关注“创建哪一种产品实现”。
- 两者可以组合:工厂选择具体产品族或 Builder,Builder 再完成复杂组装。
- 经典 Director 只有在固定步骤序列需要复用或存在多种表示时才有价值。
- 链式
return this只是语法,校验、默认值、拷贝和收口才是设计语义。 - 若 Builder 比 Product 规则还复杂,应重新拆分对象职责,而不是继续堆方法。
字段少的不可变值对象可直接用 record/短构造器;Builder 适合参数多且可选规则复杂的不可变对象。
八、交付前的代码与测试检查
- 缺少每个必填字段分别调用
build(),应得到明确且稳定的异常。 - 完全不设置可选字段,核对默认值来自唯一权威位置。
- 传入最小值、最大值和越界值,确认范围判断没有反向或 off-by-one。
- 构造两个互相冲突的字段组合,确认只在信息完整时执行跨字段校验。
- 传入集合或数组后修改原引用,已构建 Product 不应跟着变化。
- 若 getter 返回可变数据,再尝试修改返回值,内部状态仍应保持不变。
- 连续调用同一个 Builder 两次,确认语义是明确允许复制还是文档明确禁止复用。
- 两线程共享一个 Builder 做压力测试应被禁止或证明安全,不能靠偶然结果。
- 若使用 Lombok,检查生成代码、默认值、
@Singular、构造器可见性和框架兼容。 - 若迁移旧 API,比较默认值、异常类型、序列化字段和所有旧调用点行为。
记忆钩子:final 固定的是引用,防御性拷贝固定的才是对象看到的内容。
九、常见误区与追问
- 误区:所有字段 final 就完成深度不可变。 字段指向的集合、数组或日期仍可能可变。
- 误区:只在 getter 返回 unmodifiableView 就够了。 外部若还持有原输入集合,仍可从另一入口修改底层内容。
- 误区:Builder 不可变才能创建不可变对象。 Builder 可以是局部可变施工区,关键是 Product 与它彻底隔离。
- 追问:List.copyOf 是深拷贝吗? 它复制容器结构,但不会递归复制可变元素。
- 追问:元素本身可变怎么办? 需要不可变元素、逐项复制或明确所有权协议。
- 追问:不可变对象一定线程安全吗? 自身状态安全发布后便于共享,但其引用的外部资源和方法副作用仍需判断。
十、加强记忆
Builder 和不可变对象是一对好搭档:Builder 负责接收复杂配置,build() 负责校验和拷贝,Product 负责保持稳定状态。只写 final 不够,遇到集合和数组还要注意防御性拷贝。