建造者模式在面试里常被问"和工厂有什么区别",但真正能区分开的是另一件事:建造者把"创建一个复杂对象的过程"从对象本身挪到了外部,并且要求这个过程分步可控、随时可校验。很多人以为它就是给构造函数拆参数,其实那只是表象;真正值钱的地方是"分步 + 校验 + 复用 + 不可变"这四件事。
先把结论放在前面:建造者模式(Builder)解决的是构造参数过多、参数组合爆炸、对象分步初始化三类问题。核心手法是把一个类拆成"不可变的构建者"与"最终产物"两半:构建者每个 setter 返回 this 支持链式调用,build() 时统一做参数校验并产出对象。 判断该不该用的标准很直接:构造函数超过 4 个参数、或者参数之间存在互斥与依赖关系、或者对象需要分多步才能填满。
机制拆解
以 Android 里的 AlertDialog.Builder 或网络请求构造器为例,看清每一步在解决什么。第一步,私有化带参构造,把构造函数藏起来,强制外部走构建者——这样就断掉了"有人直接 new 出一个参数不完整的对象"这条路径。第二步,字段在构建者与产物各存一份,构建者可改、产物只读;build() 里的 new 用构建者的当前值填充,产物此后不可变。 第三步,每个 setter 返回 this,让调用可以写成一行链式代码,可读性来自"一行只做一件事"的顺序表达。第四步,校验集中在 build(),也就是对象即将诞生的那一刻——此时所有参数都已就位,是做一致性校验的唯一正确时机。
第五步,默认值写在本类而非调用方。这一点是本篇标题的由来:默认值写在 Builder 里,是这个类自己的领域知识;写在每个调用点,就是把领域知识抄成了散落各处的重复代码。Android 里最典型的例子是 Retrofit 的 Builder、Glide 的 RequestOptions、以及各种 Dialog 参数默认值。
这些坑的正确绕法
最常见的坑是 build() 里没做必填校验,非法配置一路带到运行期深处才爆。 表现是空指针或下标越界出现在离赋值点很远的地方,堆栈看不出是哪个参数缺了。根因是构建过程被拆成多步后,校验如果分散在各 setter 里,会漏;即使不漏,也无法表达"这几个参数互斥、这几个必须同时出现"这种跨参数规则。 规矩是:必填校验、互斥校验、范围校验一律写在 build() 开头,失败要抛带明确信息的异常,异常信息里要写清是哪个参数不合法。
其次是把 Builder 建成可变又允许复用,同一个构建者产出两个"配置互相污染"的对象。 典型 bug 是先 new Builder().setA(1).build(),之后又 .setB(2).build() 一次,第二个对象里 a 也变成了 1 之外的预期值。根因是构建者自身被复用。 两种修法:要么每次 new 一个构建者(Android 里最常见),要么提供一个 copy()/不可变构建者;Java 里常见的 StringBuilder 之所以可复用,是因为它的每次 append 本身就是预期行为,而领域构建者不是。
还有一个更隐蔽的坑:给可变对象加了一个 Builder,但没有真正做不可变。 表现是 build() 之后仍能改产物的字段(集合没做防御性拷贝、字段不是 final),于是"不可变对象"名存实亡,跨线程共享时又冒出难以复现的问题。 修法是字段逐个 final、返回集合时用 Collections.unmodifiableList 或 Kotlin 的 toList() 做一次拷贝。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// Builder:私有构造 + 链式 setter + build() 集中校验 + 产物不可变
public final class Request {
private final String url; // 字段逐个 final,构造后不可变
private final int timeoutMs;
private final Map<String, String> headers;
private final byte[] body;
private Request(Builder b) {
// 构造函数私有,外部只能走 Builder
this.url = b.url;
this.timeoutMs = b.timeoutMs;
this.headers = Collections.unmodifiableMap(new HashMap<>(b.headers)); // 防御性拷贝
this.body = b.body == null ? null : b.body.clone();
}
public static final class Builder {
private String url; // 领域默认值写在本类,不散落到调用方
private int timeoutMs = 10_000;
private final Map<String, String> headers = new LinkedHashMap<>();
private byte[] body;
public Builder url(String u) {
this.url = u; return this; } // 返回 this 支持链式
public Builder timeoutMs(int t) {
if (t <= 0) throw new IllegalArgumentException("timeout 必须 > 0");
this.timeoutMs = t; return this;
}
public Builder header(String k, String v) {
this.headers.put(k, v); return this; }
public Request build() {
// 校验集中在这里,一步到位
if (url == null || url.isEmpty()) {
throw new IllegalStateException("必填项 url 未设置");
}
if (url.startsWith("http://")) {
// 互斥/约束规则只在这里能表达
throw new IllegalStateException("仅允许 https: " + url);
}
if (body != null && body.length > MAX_BODY) {
throw new IllegalStateException("body 超过上限 " + MAX_BODY);
}
return new Request(this); // 一次性固化
}
}
// 用法:顺序即语义,读起来像一句话
Request r = new Request.Builder().url("https://api/x").header("A", "1").build();
}
这段代码值得盯三处:第一处,字段全 final + 集合防御性拷贝,让产物真正不可变;第二处,build() 里同时做必填、协议约束、体积上限三类跨参数校验——这些规则在 setter 阶段无法表达;第三处,默认值 timeoutMs = 10_000 定义在本类,调用方不用重复,也不必知道默认值是多少。
这题在面试里怎么问、怎么答
"建造者和工厂模式有什么区别?"分三层:①建造者分步、关注怎么拼;工厂一步到位,关注造哪一个;②建造者常用于同一类型有多种参数组合,工厂常用于多种类型的选择(此时建造者常被用作工厂内部实现);③建造者产出的是同一种类型,工厂产出的是接口下的不同实现。 Android 上的例子:Retrofit.Builder 是建造者(拼接口实现与配置),ListAdapter 的多类型工厂是工厂。
"建造者什么时候反而是负担?"给判据:参数只有两三个且互不相关时,建造者只是多写三倍代码;对象需要分步创建、中途可能被访问时,建造者隐藏了中间态会让人困惑("到底哪一步之后才算有效");不可变要求不强时,它带来的 final 与拷贝成本没有回报。
"和 Kotlin 的命名参数/default 参数相比呢?"Kotlin 的 data class + 默认参数 + 具名调用在很多场景下更简洁,不必写 Builder。差别在三点:Kotlin 默认参数不适用于 Java 调用方、参数超过十几个时可读性也会崩、且默认参数无法表达"参数组合互斥"这类跨参数规则。 Android 里给 Java 调用的组件库(第三方 SDK)仍要提供 Builder。
"使用中遇到过什么问题?"案例一:某个配置类允许 setA 与 setB 同时设置,但两者冲突时运行期抛错;定位到校验分散在各 setter,只查了单参数合法性;修复为把互斥规则集中到 build() 并补上组合参数的单测。案例二:一个 Builder 被复用来造两个不同配置的请求,第二个请求带上了第一个的残留值;修复为每次新建构建者,并把返回集合改为防御性拷贝。
再补一个工程上值得讲清的点:Android 上的对话框、通知、请求构造几乎都是 Builder,它们的默认值就是"领域知识的沉淀处"。 举例:如果播放器的默认缓冲策略、默认超时、默认重试次数写在 SDK 的 Builder 里,业务方只在特殊场景覆盖;当产品同学要改默认值时只改一处,且升级 SDK 后新默认值自动生效。 反之如果默认值散在十几个业务页面的构造代码里,改一次要翻十几个文件,还容易漏。这是建造者模式在工程上最实在的价值——比"链式调用好看"重要得多。
给正在准备面试的你
把建造者画成一张"分步固化"的示意图:左边一列是构建者里的可变状态(url、timeout、headers,箭头指向每一步的 setter),右边一列是产物里的 final 字段。中间画一个大箭头标 build(),箭头上写三行字——必填校验、跨参数约束、一次性固化。产物那边画一个锁形图标标"不可变",并注明"集合做防御性拷贝"。 再在图下方写一句判据:"参数 > 4、参数间有约束、需要分步填满"三条满足两条就用 Builder。面试中建造者的问题,基本都能从这张图推导出答案。
再补工程案例与踩坑——应用落点是把项目里参数超过四个的构造调用统一改走 Builder,把 build() 前的校验集中化并补上"缺必填、传互斥值、超范围"三条单测,同时检查所有返回集合的地方是否做了防御性拷贝。
复习时别孤立刷题:单例模式六种写法——单例解决唯一性、建造者解决构造过程的可控性,两者都在解决"对象的诞生方式"。
划两句重点:私有构造 + 链式 setter + build() 集中校验 + 产物不可变(final + 防御性拷贝);默认值写在 Builder 里是领域知识;构建者不可复用,产物不可变。
下一篇聊 Java 阶段面试通关地图:六十个考点串联复盘——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:单例模式六种写法:线程安全与懒加载的平衡
下一篇预告:Java-阶段面试通关地图:六十个考点串联复盘
有任何问题欢迎在评论区留言交流。