设计模式(五):建造者模式——当构造函数长出第七个参数
一、模式原理
定义:建造者模式将一个复杂对象的构建过程与它的表示分离,使得同样的构建过程可以创建不同的表示。
这个定义需要拆开看,因为它包含两层常被混为一谈的含义:
- 构建过程与表示分离:对象“怎么一步步装配起来”的逻辑,不应该塞进对象自己的构造函数里;
- 同样的过程、不同的表示:把装配步骤抽象出来(如
buildPartA()、buildPartB()),换一个 Builder 实现,就能装配出不同的产品。
意图:当创建一个对象需要多步、顺序敏感、且中间状态不应被外界看到时,把这段过程封装到一个专门的构建者里,让使用方只需声明“我要什么”,而不必重复编写装配细节。
核心思想可以概括为一句话:把“对象的最终形态”和“抵达这个形态的步骤”解耦,并保证使用方拿到的永远是构建完成、状态合法的对象。
GoF 原版的结构里有四个角色:Builder(声明构建步骤)、ConcreteBuilder(实现步骤、产出产品)、Director(编排步骤顺序)、Product(最终对象)。但必须先说清楚一个现实:在现代工程里,Director 这一角色在大多数场景中已经消失,建造者演变成了“链式调用 + 终结方法”的流畅接口(fluent builder)。这也是本篇要重点辨析的地方——GoF 版和现代版共享同一个意图,但结构与适用场景已经分叉。
代价一句话:你换来的是“构建过程可复用、可选参数可表达、非法中间状态不外泄”,付出的是多一个类(或一套方法链)、以及对象在构建期间是可变的。
二、先看一个真实痛点
订单对象的字段从 5 个涨到了 13 个:商品列表、收货地址、优惠券、发票信息、备注、期望配送时间、是否加急、礼品包装……其中只有前 4 个是必填,其余都可选。
于是构造函数变成了这样:
public Order(List<Item> items, Address addr, Coupon coupon, Invoice invoice,
String remark, LocalDateTime expectedAt, boolean urgent,
boolean giftWrap, ...) { ... }
调用方是这样写的:
new Order(items, addr, coupon, null, null, null, false, false, ...);
问题有三层,一层比一层难修:
- 可读性崩塌:一长串
null和false排在一起,谁也不知道第七个参数是什么,IDE 的提示也救不了评审时的眼睛。 - 可选参数的组合爆炸:想提供重载构造函数?13 个字段能组合出几十个重载,最后必然退化成“全参构造 + 一堆 null”。
- 最隐蔽的一层——中间状态外泄:如果改用 setter 逐个赋值,对象在
new Order()之后、最后一个 setter 调用之前,会处于字段不完整、约束不成立的状态。这期间如果有别的代码拿到这个引用(比如注册进某个缓存、或传给另一个线程),就可能读到半成品——这和单例那篇里“拿到未构造完的对象”是同一种病。
建造者模式回应的就是这三层压力:把“填参数”的过程移到一个临时对象上,只在使用方确认填写完毕后,一次性产出状态合法的最终对象。
三、本质与结构速写
本质:用一个临时的、可变的构建者收集参数,由终结方法一次性校验并产出不可变的最终对象。
两个层次要分清:
GoF 版(Director 编排) :适用于构建步骤固定、产品表示可变的场景。比如“生成一份文档”的过程固定为“加标题→加正文→加页脚”,但换成 HTML Builder 产出 HTML,换成 Markdown Builder 产出 Markdown。变化点在“表示”,不在“步骤”。
现代版(链式 Builder) :适用于参数多、可选参数多、需要表达约束的场景。变化点在“这一次要传哪些参数”。绝大多数业务代码属于这一类,Director 不需要存在。
无论哪种,有三条不变量是审查时必须检查的:
- 使用方永远拿不到未完成的产品:产品只在
build()中产出; build()集中做校验:必填项缺失、互斥参数冲突,都在这一刻报错,而不是散落在各个 setter 里;- 产出后不可变:产品一旦交给使用方,就不应再被修改——否则“状态合法”的保证只在一瞬间成立。
四、Java 实现:链式 Builder 是主流,Director 是特例
4.1 现代主流:静态内部类 Builder + 终结方法
public final class Order {
private final List<Item> items;
private final Address address;
private final Coupon coupon; // 可空
private final boolean urgent;
// ... 其余字段全部 final
private Order(Builder b) {
this.items = List.copyOf(b.items);
this.address = b.address;
this.coupon = b.coupon;
this.urgent = b.urgent;
}
public static Builder builder() { return new Builder(); }
public static final class Builder {
private final List<Item> items = new ArrayList<>();
private Address address;
private Coupon coupon;
private boolean urgent;
public Builder addItem(Item item) { items.add(item); return this; }
public Builder address(Address a) { this.address = a; return this; }
public Builder coupon(Coupon c) { this.coupon = c; return this; }
public Builder urgent(boolean u) { this.urgent = u; return this; }
public Order build() {
// 校验集中在这里,而不是散落在各个 setter
if (items.isEmpty()) throw new IllegalStateException("order needs at least one item");
if (address == null) throw new IllegalStateException("address is required");
return new Order(this);
}
}
}
使用方:
Order order = Order.builder()
.addItem(item1)
.addItem(item2)
.address(addr)
.urgent(true)
.build();
这种写法为什么成为 Java 主流?因为它只用一个额外类同时解决了三个问题:可读性(方法名即参数名)、可选性(不调即默认)、中间状态不外泄(Order 构造在 build() 里才发生,且字段全 final)。
几个 Java 特有的细节:
Builder是可变对象,不要跨线程共享。它是“一次性的收集器”,用完即弃。- 防御性拷贝:
List.copyOf(b.items)防止使用方在build()之后修改原列表。这是“产出后不可变”的实际落点。 - Lombok 的
@Builder能自动生成这套代码,但它不生成校验——build()里的业务约束仍然要手写。这是 AI 和代码生成工具共同的盲区(下面细说)。 build()的异常类型要统一,否则调用方无法用一套方式处理构建失败。
4.2 GoF 版:Director 仍然有价值的场景
public interface DocumentBuilder {
void addTitle(String t);
void addBody(String body);
void addFooter(String f);
Document build();
}
public class Director {
public Document construct(DocumentBuilder b, DocData data) {
b.addTitle(data.title());
b.addBody(data.body());
b.addFooter(data.footer());
return b.build();
}
}
这里 Director 的价值是步骤顺序被固定下来:任何新 Builder 都按同一顺序被驱动。如果“标题必须在正文前”是一条业务规则,把它写进 Director 比写进每个 Builder 更安全。只有当“构建步骤本身”是需要复用的知识时,Director 才值得存在。
五、Python 实现:多数时候不需要 Builder
5.1 关键字参数 + dataclass:语言已经吸收了大半
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Order:
items: list[Item]
address: Address
coupon: Coupon | None = None
urgent: bool = False
# ... 其余可选字段都有默认值
def __post_init__(self):
# 校验集中在这里
if not self.items:
raise ValueError("order needs at least one item")
使用方:
order = Order(items=[i1, i2], address=addr, urgent=True)
这就是 Python 版的“建造者”——关键字参数解决了可读性,默认值解决了可选性,frozen=True 解决了不可变,__post_init__ 解决了校验时机。四个问题全部被语言特性吸收,连 Builder 类都不需要。这正是总览篇说的“模式被语言吸收”:意图还在,结构消失了。
注意 __post_init__ 是校验的正确落点——它在对象构造完成、但在使用方拿到引用之前执行。如果校验写在每个字段的 setter 里,就回到了“中间状态外泄”的老问题。
5.2 需要分步构建时:链式方法返回新对象
当参数确实需要“逐步收集”(比如从表单分步填写、或来自多个来源),可以写一个轻量的 fluent builder。Python 里更推荐每步返回新对象而不是原地修改,避免可变中间态:
@dataclass(frozen=True)
class OrderBuilder:
_items: tuple[Item, ...] = ()
_address: Address | None = None
_urgent: bool = False
def add_item(self, item: Item) -> "OrderBuilder":
return replace(self, _items=self._items + (item,))
def address(self, a: Address) -> "OrderBuilder":
return replace(self, _address=a)
def build(self) -> Order:
if not self._items: raise ValueError("need at least one item")
if self._address is None: raise ValueError("address required")
return Order(list(self._items), self._address, urgent=self._urgent)
dataclasses.replace 让每一步都产出新的不可变 builder,天然线程安全。代价是每次 replace 都复制一次——对于字段少、构建次数不高的场景完全可接受;如果构建频率极高、字段又很多,才需要考虑原地修改的版本。
5.3 GoF 版在 Python 里长什么样
如果需要 Director 的“步骤固定”能力,Python 里同样可以写,但通常用一个函数就够了:
def construct(builder, data):
builder.add_title(data.title)
builder.add_body(data.body)
builder.add_footer(data.footer)
return builder.build()
函数即 Director。除非步骤本身需要多态扩展,否则没必要为它建一个类。
六、双语言对照表
| 维度 | Java | Python |
|---|---|---|
| 典型实现 | 静态内部类 Builder + 链式方法 + build();Lombok @Builder |
dataclass + 关键字参数 + __post_init__;需要分步时用 replace 链 |
| 可读性靠什么 | 方法名即参数名 | 关键字参数名 |
| 可选参数靠什么 | 不调用即默认 | 字段默认值 |
| 不可变性靠什么 | final 字段 + 防御性拷贝 |
frozen=True |
| 校验时机 | build() 内集中校验 |
__post_init__ |
| 样板代码量 | 较高(每个字段两个方法) | 极低(声明即完成) |
| Director 是否常见 | 少数场景(步骤固定、表示可变) | 很少,通常退化为一个函数 |
| 主要风险 | Builder 可变、忘记校验、防御性拷贝遗漏 | 关键字拼写错误运行期才暴露、__post_init__ 被误用做副作用 |
| 现代替代 | Lombok/Record + 紧凑构造器、@Builder |
attrs/pydantic(带校验的声明式模型) |
记忆锚点:建造者 = 先收集、后校验、一次产出;它保证使用方永远拿不到半成品。
七、现代视角:AI 生成建造者时,审查什么
建造者是 AI 生成质量“看起来很高、实际漏关键点”的典型代表——因为它的结构极其模板化,而校验和不可变性恰恰是模板之外的东西。
诊断阶段,先判断压力类型。 参数多但都是必填?那可能只是需要重构参数对象(把 13 个参数按职责拆成 3 个值对象),建造者解决不了职责混乱。可选参数多、且有互斥/依赖关系?这才是建造者的主场。AI 看到长参数列表就会生成 Builder,但它不会先问“这些参数是不是本来就该分组”。
生成阶段,用约束而非结构下指令。 “让使用方无法拿到未校验完成的订单,必填缺失和加急与普通配送互斥的规则必须在产出时统一报错,产出的订单不可再修改”——这个描述会同时约束住 build() 的位置、校验的集中性和字段的不可变性。只说“用建造者模式”,AI 常常生成一个字段可变的 Order,外加一个不做任何校验的 build()。
审查阶段,建造者的检查清单:
- 最终对象在
build()之后还能被修改吗?集合字段做了防御性拷贝吗? - 校验是否集中在终结方法?有没有散落在各个 setter 里,导致中间态可被观察到?
- 必填/互斥/依赖关系的错误,是构建期报错还是拖到运行期?
- Builder 是否被跨线程复用?(它是可变收集器,不该共享)
- 是否只是给一个“参数确实该分组”的对象硬套了 Builder?字段能否先拆成几个值对象?
一个务实判断:Java 14+ 的 record 配合紧凑构造器、以及 Lombok/Pydantic 这类声明式模型工具,已经把“参数多 + 校验 + 不可变”的大部分场景从手写 Builder 中解放出来。手写链式 Builder 真正还必要的场景是:构建步骤有顺序语义、需要在构建过程中累积可变集合、或者要产出多种表示(对应 GoF 版) 。单纯的“参数多”,优先考虑拆参数对象,而不是加 Builder。
八、三十秒自测
- GoF 版建造者和现代链式 Builder,变化的维度分别是什么?为什么说 Director 在后者中消失了?
- 为什么校验要集中在
build()/__post_init__,而不是每个 setter 里?如果散落在 setter 里,最坏会发生什么? - 在你的项目里找一处“构造函数参数超过五个、且一半传
null”的地方,判断它该用建造者、还是该先把参数拆成值对象。
- 点赞
- 收藏
- 关注作者
评论(0)