设计模式修炼之路 ④ 建造者模式——告别重叠构造器
重叠构造器的坑、经典四角色建造者、Effective Java 式链式 Builder
目录
上一篇《设计模式修炼之路-工厂模式》里,我们把"造对象"这件事从调用方手里收进了工厂:调用方只管报菜名,工厂负责端菜。但工厂模式有个隐藏前提——对象在工厂里一次成型,交付到你手上时就已经是成品了。
可有一类对象天生没法"一次成型":参数多、大部分可选、还想让人一眼看懂。上一篇结尾埋了个伏笔:new Pizza(10, true, false, null, "BIG")——写完这行代码三天后再回头看,谁能说清第二个 true 是芝士还是香肠?建造者模式(Builder Pattern)就是为这种场景而生的,它同样是 GoF 23 种模式之一,和工厂模式同属创建型模式。
一、什么是建造者模式?
先看定义:
将一个复杂对象的构建过程与它的表示分离,使得同样的构建过程可以创建不同的表示。
翻译成人话:把"造一个复杂对象"拆成一步一步来,每一步的具体做法可以替换,步骤的编排由专人负责。
拿装修房子类比:你(调用方)跟装修公司说"我要北欧风",设计师出图纸定流程——先水电、再瓦工、最后木工(指挥者);工人按图纸干活(建造者),水电工瓦工木工各有班组(具体建造者);几个月后交付的房子就是产品。换一套图纸,同一批工人能装出美式风——流程没变,表示变了,这就是定义后半句的意思。
经典建造者模式有四个角色:
- Product(产品):最终要被组装出来的复杂对象;
- Builder(抽象建造者):声明组装产品的各个步骤;
- ConcreteBuilder(具体建造者):实现每个步骤的具体做法;
- Director(指挥者):编排步骤的执行顺序,对调用方屏蔽组装细节。
不过先打个预防针:教科书上的四角色形态,在实际业务代码里反而不多见;你天天写的其实是它的现代变体——链式 Builder(《Effective Java》第 2 条力荐的写法)。两种形态本篇都会实现,先从反面教材看起。
二、建造者模式的优缺点
优点
- 参数可读性拉满。每个参数都有名字,
.cheese(true)一眼看懂;对比new Pizza(12, true, false, true, "烧烤酱")里第二个 true 是什么,要靠数。 - 易于扩展。新增一个可选参数,Builder 里加个方法就行,已有的调用代码一行不用改。
- 构建过程可复用。经典形态里,同一个指挥者流程换个建造者就能产出不同产品;链式形态里,Builder 对象本身也能复用。
- 产品可以做成不可变。字段全 final,构造完成后谁也改不了,天生线程安全。
缺点
- 代码量几乎翻倍。每个产品类都要配一个 Builder,样板代码不少——好在 Lombok 的
@Builder注解能一键生成。 - 对象"迟到"了。必须等 build() 调用完对象才真正存在,之前都处于"配置阶段",不像构造函数一步到位。
- 经典形态类数量膨胀。一种产品要配一堆具体建造者,和工厂方法一个毛病。
三、建造者模式的应用场景
建造者模式在 JDK 和主流框架里出镜率极高:
- JDK 里:
StringBuilder的append()每次都返回 this,一个字符一个字符把字符串"垒"出来,最后toString()交付——标准的链式建造;Stream.Builder更是把add()/build()写在了脸上。 - Lombok 里:
@Builder注解,一行注解生成整个链式 Builder,实际项目里的绝对主力。 - OkHttp 里:
new Request.Builder().url(url).header(k, v).build(),HTTP 请求的每个部件都是配出来的。 - Spring Security 里:
HttpSecurity那一长串.authorizeHttpRequests(...).formLogin(...),把安全配置这道"大菜"一步步垒起来。
什么时候该用:构造参数多(个人经验,4 个以上就该警惕)、其中大部分可选、希望产品不可变、或者同一套组装流程要产出多种"配置"时。反过来,一两个参数的小对象直接构造函数伺候,硬上 Builder 属于模式上瘾。
四、建造者模式的实现方式
老规矩,示例代码放在 design-pattern 项目里,本篇新增 builder 包:
design-pattern/src/main/java
├── singleton // 单例模式(已完结)
├── factory // 工厂模式(已完结)
├── builder // 建造者模式
│ ├── TelescopingPizza.java // 反面教材:重叠构造器
│ ├── Pizza.java // 链式 Builder 版披萨
│ ├── BuilderTest.java // 测试入口
│ └── classic // 经典四角色
│ ├── Meal.java // 产品:套餐
│ ├── MealBuilder.java // 抽象建造者
│ ├── KidsMealBuilder.java / AdultMealBuilder.java // 具体建造者
│ └── KFCWaiter.java // 指挥者
└── prototype // 原型模式(下一篇)
场景统一用快餐:先造披萨,再造套餐。
1. 反面教材:重叠构造器
参数一多,最容易写出"重叠构造器"(telescoping constructor)——参数个数从少到多叠一串构造函数,层层转发:
public class TelescopingPizza {
private final int size;
private final boolean cheese;
private final boolean pepperoni;
private final boolean mushroom;
private final String sauce;
public TelescopingPizza(int size) {
this(size, false);
}
public TelescopingPizza(int size, boolean cheese) {
this(size, cheese, false);
}
public TelescopingPizza(int size, boolean cheese, boolean pepperoni) {
this(size, cheese, pepperoni, false);
}
public TelescopingPizza(int size, boolean cheese, boolean pepperoni, boolean mushroom) {
this(size, cheese, pepperoni, mushroom, "番茄酱");
}
public TelescopingPizza(int size, boolean cheese, boolean pepperoni, boolean mushroom, String sauce) {
this.size = size;
this.cheese = cheese;
this.pepperoni = pepperoni;
this.mushroom = mushroom;
this.sauce = sauce;
}
@Override
public String toString() {
return size + "寸披萨[酱料=" + sauce + ", 芝士=" + cheese + ", 香肠=" + pepperoni + ", 蘑菇=" + mushroom + "]";
}
}
测试代码:
private static void telescoping() {
TelescopingPizza pizza = new TelescopingPizza(9, true, true);
System.out.println("用重叠构造器造的披萨:" + pizza);
}
运行结果:
用重叠构造器造的披萨:9寸披萨[酱料=番茄酱, 芝士=true, 香肠=true, 蘑菇=false]
能跑,但《Effective Java》直接把它列为反面教材,病根有三:
- 参数语义全靠位置硬记。
new TelescopingPizza(9, true, true)里第二个 true 是芝士还是香肠?只能去数参数表,写的人在读,读的人也在数。 - 相邻同类型参数传反了编译器不报错。两个 boolean 换个位置,程序照常编译照常运行,产出一份"芝士变香肠"的披萨,bug 能不能被发现全看运气。
- 中间参数跳不过去。只想要蘑菇不想要香肠?没有 (size, cheese, mushroom) 这个构造函数,只能老老实实传
(9, true, false, true),把不想要的也"点"一遍。
有同学会说:那拆成"无参构造 + 一堆 setter"(JavaBeans 模式)行不行?可读性是有了,但对象从 new 出来到 setter 调完之间处于半成品状态,谁都能拿到引用改它两笔;而且字段没法用 final 修饰,不可变性彻底告吹。
两种写法各病一头:重叠构造器可读性差但对象一次性造好,JavaBeans 可读性好但对象是"半成品"。建造者模式表示:两个病一起治。先看 GoF 教科书上的标准答案,再看现代的链式写法。
2. 经典四角色建造者
GoF 当年发明建造者模式时,头疼的其实不是参数太多,而是另一件事:同一套组装流程,要能装出不同的成品。需求场景:KFC 卖套餐,流程永远是"主食 + 饮料",但儿童套餐和成人套餐的内容完全不同。
先看产品,就是个简单 POJO(getter/setter 略):
public class Meal {
private String food;
private String drink;
@Override
public String toString() {
return "套餐[主食=" + food + ", 饮料=" + drink + "]";
}
}
抽象建造者,声明组装步骤:
public abstract class MealBuilder {
protected Meal meal = new Meal();
public abstract void buildFood();
public abstract void buildDrink();
public Meal getMeal() {
return meal;
}
}
具体建造者,各自实现每一步的做法:
public class KidsMealBuilder extends MealBuilder {
@Override
public void buildFood() {
meal.setFood("迷你汉堡一份");
}
@Override
public void buildDrink() {
meal.setDrink("橙汁一杯");
}
}
AdultMealBuilder 同理:香辣鸡腿堡一份 + 可乐一大杯。最后是指挥者,编排步骤顺序:
public class KFCWaiter {
private MealBuilder mealBuilder;
public void setMealBuilder(MealBuilder mealBuilder) {
this.mealBuilder = mealBuilder;
}
public Meal construct() {
mealBuilder.buildFood();
mealBuilder.buildDrink();
return mealBuilder.getMeal();
}
}
测试代码:
private static void classicBuilder() {
KFCWaiter waiter = new KFCWaiter();
waiter.setMealBuilder(new KidsMealBuilder());
Meal kidsMeal = waiter.construct();
System.out.println(kidsMeal);
waiter.setMealBuilder(new AdultMealBuilder());
Meal adultMeal = waiter.construct();
System.out.println(adultMeal);
}
运行结果:
套餐[主食=迷你汉堡一份, 饮料=橙汁一杯]
套餐[主食=香辣鸡腿堡一份, 饮料=可乐一大杯]
对着四角色过一遍:Meal 是产品,MealBuilder 是抽象建造者,KidsMealBuilder/AdultMealBuilder 是具体建造者,KFCWaiter 是指挥者。注意调用方全程只跟 waiter 打交道,连 Meal 的 set 方法都没碰过——"怎么造"被彻底封装;而 waiter 持有的是抽象的 MealBuilder,换一个具体建造者,同一套 construct() 流程就端出了完全不同的套餐——"同样的构建过程,不同的表示",定义就这么活了。
不过经典形态解决的是"流程复用",对第一小节"参数爆炸"的三个病并没有直接治。日常开发里真正天天在用的,是下面这一版。
3. 链式 Builder(Effective Java 式)
现代建造者把指挥者这个角色砍了——调用方自己当指挥,想配什么配什么,配完喊一声 build():
public class Pizza {
private final int size;
private final boolean cheese;
private final boolean pepperoni;
private final boolean mushroom;
private final String sauce;
private Pizza(Builder builder) {
this.size = builder.size;
this.cheese = builder.cheese;
this.pepperoni = builder.pepperoni;
this.mushroom = builder.mushroom;
this.sauce = builder.sauce;
}
@Override
public String toString() {
return size + "寸披萨[酱料=" + sauce + ", 芝士=" + cheese + ", 香肠=" + pepperoni + ", 蘑菇=" + mushroom + "]";
}
public static class Builder {
private final int size; // 必选参数,final 保证必须在构造时传入
private boolean cheese;
private boolean pepperoni;
private boolean mushroom;
private String sauce = "番茄酱"; // 可选参数,给默认值
public Builder(int size) {
this.size = size;
}
public Builder cheese(boolean value) {
this.cheese = value;
return this;
}
public Builder pepperoni(boolean value) {
this.pepperoni = value;
return this;
}
public Builder mushroom(boolean value) {
this.mushroom = value;
return this;
}
public Builder sauce(String value) {
this.sauce = value;
return this;
}
public Pizza build() {
return new Pizza(this);
}
}
}
测试代码:
private static void fluentBuilder() {
Pizza pizza = new Pizza.Builder(12)
.cheese(true)
.mushroom(true)
.sauce("烧烤酱")
.build();
System.out.println("用链式Builder造的披萨:" + pizza);
}
运行结果:
用链式Builder造的披萨:12寸披萨[酱料=烧烤酱, 芝士=true, 香肠=false, 蘑菇=true]
逐个拆解设计意图:
- 必选参数收进 Builder 的构造函数,还声明成 final——不传尺寸连 Builder 都拿不到,"必选项忘传"这种事故从根上杜绝;
- 可选参数给默认值,想配哪个点哪个,重叠构造器"中间参数跳不过去"的病治好了;
- 每个配置方法都 return this,于是可以一路点下去,这就是"链式";每个参数前面都有方法名,两个 boolean 换了位置代码直接不编译,"同类型参数传反"的病也治好了;
- Pizza 的构造函数是 private 的,外界只能通过 build() 拿到成品;配置阶段你手里握的是 Builder,半成品状态暴露不出去,build() 完事的那一刻对象一次性定型——JavaBeans"半成品"的病同样治好了;
- 字段全部 final,造出来就是只读的,天生线程安全。
要是还想加参数校验,往 build() 里塞:尺寸不合法、酱料为空,直接抛 IllegalArgumentException,"不合格产品不出厂"。
实际项目里,这一套样板代码基本都交给 Lombok 的 @Builder 生成,但手写过一遍,你才知道注解背后到底发生了什么。
五、工厂模式和建造者模式怎么选
同为创建型模式,俩人常被搞混,其实分工清清楚楚:
| 工厂模式 | 建造者模式 | |
|---|---|---|
| 关注点 | 造**什么**(产品选型) | **怎么**造(组装配置) |
| 对象成型时机 | 工厂一次成型 | 分步配置,build() 时成型 |
| 擅长场景 | 同族产品按需创建 | 多参数复杂对象的精细配置 |
| 典型 API | `ShapeFactory.getShape("CIRCLE")` | `new Pizza.Builder(12).cheese(true).build()` |
| 一句话 | 报菜名,直接上菜 | 报需求,现场拼装 |
选型建议同样朴素:参数少而固定,直接构造函数;重点在"选哪个",用工厂;重点在"怎么配",用建造者。两者的关注点是正交的,组合使用也很常见——工厂负责选型,Builder 负责按需精装,很多框架的配置类就是这么干的。
写在最后
回顾这一篇:重叠构造器参数靠位置硬记、同类型参数传反不报错、中间参数跳不过去;经典四角色用"产品、抽象建造者、具体建造者、指挥者"把组装流程和具体做法分离,同一套流程装出不同的成品;链式 Builder 砍掉指挥者、调用方亲自上阵,必选参数进构造函数、可选参数带名字链式配置、build() 一次成型,三个老病全治好。日常开发里,Lombok @Builder 一键生成的大概率就是这最后一版。
示例代码都在 design-pattern 项目的 builder 包里(classic 子包是经典四角色),和前几篇的代码同处一个项目,方便对照练习。
创建型模式还剩最后一位选手:原型模式(Prototype)——当"造一个对象"的成本太高时,最经济的办法往往是:找一个现成的,复制一份。克隆羊多利的故事,下一篇见。