设计模式预计阅读 19 分钟7441 字3577 次浏览

设计模式修炼之路 ④ 建造者模式——告别重叠构造器

重叠构造器的坑、经典四角色建造者、Effective Java 式链式 Builder

目录

上一篇《设计模式修炼之路-工厂模式》里,我们把"造对象"这件事从调用方手里收进了工厂:调用方只管报菜名,工厂负责端菜。但工厂模式有个隐藏前提——对象在工厂里一次成型,交付到你手上时就已经是成品了。

可有一类对象天生没法"一次成型":参数多、大部分可选、还想让人一眼看懂。上一篇结尾埋了个伏笔:new Pizza(10, true, false, null, "BIG")——写完这行代码三天后再回头看,谁能说清第二个 true 是芝士还是香肠?建造者模式(Builder Pattern)就是为这种场景而生的,它同样是 GoF 23 种模式之一,和工厂模式同属创建型模式。

一、什么是建造者模式?

先看定义:

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

翻译成人话:把"造一个复杂对象"拆成一步一步来,每一步的具体做法可以替换,步骤的编排由专人负责。

拿装修房子类比:你(调用方)跟装修公司说"我要北欧风",设计师出图纸定流程——先水电、再瓦工、最后木工(指挥者);工人按图纸干活(建造者),水电工瓦工木工各有班组(具体建造者);几个月后交付的房子就是产品。换一套图纸,同一批工人能装出美式风——流程没变,表示变了,这就是定义后半句的意思。

经典建造者模式有四个角色:

  • Product(产品):最终要被组装出来的复杂对象;
  • Builder(抽象建造者):声明组装产品的各个步骤;
  • ConcreteBuilder(具体建造者):实现每个步骤的具体做法;
  • Director(指挥者):编排步骤的执行顺序,对调用方屏蔽组装细节。

不过先打个预防针:教科书上的四角色形态,在实际业务代码里反而不多见;你天天写的其实是它的现代变体——链式 Builder(《Effective Java》第 2 条力荐的写法)。两种形态本篇都会实现,先从反面教材看起。

二、建造者模式的优缺点

优点

  1. 参数可读性拉满。每个参数都有名字,.cheese(true) 一眼看懂;对比 new Pizza(12, true, false, true, "烧烤酱") 里第二个 true 是什么,要靠数。
  2. 易于扩展。新增一个可选参数,Builder 里加个方法就行,已有的调用代码一行不用改。
  3. 构建过程可复用。经典形态里,同一个指挥者流程换个建造者就能产出不同产品;链式形态里,Builder 对象本身也能复用。
  4. 产品可以做成不可变。字段全 final,构造完成后谁也改不了,天生线程安全。

缺点

  1. 代码量几乎翻倍。每个产品类都要配一个 Builder,样板代码不少——好在 Lombok 的 @Builder 注解能一键生成。
  2. 对象"迟到"了。必须等 build() 调用完对象才真正存在,之前都处于"配置阶段",不像构造函数一步到位。
  3. 经典形态类数量膨胀。一种产品要配一堆具体建造者,和工厂方法一个毛病。

三、建造者模式的应用场景

建造者模式在 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》直接把它列为反面教材,病根有三:

  1. 参数语义全靠位置硬记。new TelescopingPizza(9, true, true) 里第二个 true 是芝士还是香肠?只能去数参数表,写的人在读,读的人也在数。
  2. 相邻同类型参数传反了编译器不报错。两个 boolean 换个位置,程序照常编译照常运行,产出一份"芝士变香肠"的披萨,bug 能不能被发现全看运气。
  3. 中间参数跳不过去。只想要蘑菇不想要香肠?没有 (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)——当"造一个对象"的成本太高时,最经济的办法往往是:找一个现成的,复制一份。克隆羊多利的故事,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论