设计模式(五):建造者模式——当构造函数长出第七个参数

举报
小不点和猫 发表于 2026/11/10 08:47:35 2026/11/10
【摘要】 一、模式原理定义:建造者模式将一个复杂对象的构建过程与它的表示分离,使得同样的构建过程可以创建不同的表示。这个定义需要拆开看,因为它包含两层常被混为一谈的含义:构建过程与表示分离:对象“怎么一步步装配起来”的逻辑,不应该塞进对象自己的构造函数里;同样的过程、不同的表示:把装配步骤抽象出来(如 buildPartA()、buildPartB()),换一个 Builder 实现,就能装配出不同...

一、模式原理

定义:建造者模式将一个复杂对象的构建过程与它的表示分离,使得同样的构建过程可以创建不同的表示。

这个定义需要拆开看,因为它包含两层常被混为一谈的含义:

  • 构建过程与表示分离:对象“怎么一步步装配起来”的逻辑,不应该塞进对象自己的构造函数里;
  • 同样的过程、不同的表示:把装配步骤抽象出来(如 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, ...);

问题有三层,一层比一层难修:

  1. 可读性崩塌:一长串 null 和 false 排在一起,谁也不知道第七个参数是什么,IDE 的提示也救不了评审时的眼睛。
  2. 可选参数的组合爆炸:想提供重载构造函数?13 个字段能组合出几十个重载,最后必然退化成“全参构造 + 一堆 null”。
  3. 最隐蔽的一层——中间状态外泄:如果改用 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()。

审查阶段,建造者的检查清单:

  1. 最终对象在 build() 之后还能被修改吗?集合字段做了防御性拷贝吗?
  2. 校验是否集中在终结方法?有没有散落在各个 setter 里,导致中间态可被观察到?
  3. 必填/互斥/依赖关系的错误,是构建期报错还是拖到运行期?
  4. Builder 是否被跨线程复用?(它是可变收集器,不该共享)
  5. 是否只是给一个“参数确实该分组”的对象硬套了 Builder?字段能否先拆成几个值对象?

一个务实判断:Java 14+ 的 record 配合紧凑构造器、以及 Lombok/Pydantic 这类声明式模型工具,已经把“参数多 + 校验 + 不可变”的大部分场景从手写 Builder 中解放出来。手写链式 Builder 真正还必要的场景是:构建步骤有顺序语义、需要在构建过程中累积可变集合、或者要产出多种表示(对应 GoF 版) 。单纯的“参数多”,优先考虑拆参数对象,而不是加 Builder。

八、三十秒自测

  1. GoF 版建造者和现代链式 Builder,变化的维度分别是什么?为什么说 Director 在后者中消失了?
  2. 为什么校验要集中在 build() / __post_init__,而不是每个 setter 里?如果散落在 setter 里,最坏会发生什么?
  3. 在你的项目里找一处“构造函数参数超过五个、且一半传 null”的地方,判断它该用建造者、还是该先把参数拆成值对象。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。