创建型模式:建造者模式与原型模式

引言

上一节我们学习了三种工厂模式——它们解决的核心问题是「创建什么类型的对象」。但在实际开发中,创建对象的复杂性不只体现在「选哪个类」,还可能体现在:

  • 对象本身结构复杂——由多个部件组合而成,部件之间还有装配顺序和约束关系
  • 创建过程代价昂贵——频繁 new 一个初始化成本很高的对象是浪费

本节介绍的两种创建型模式分别瞄准了这两个问题:建造者模式将复杂对象的组装过程从对象本身中剥离出来,交给专门的「施工队」;原型模式则绕过构造函数,通过克隆已有对象来快速创建新实例。

建造者模式

为什么需要建造者模式

想象你在买一辆汽车。作为消费者,你关心的是品牌和型号——「我要一辆宝马 X5」,而不是「请先装好发动机,再装方向盘,然后装轮胎,最后喷漆」。汽车由方向盘、轮胎、发动机等众多部件组成,但你不需要了解它们的装配顺序和细节,你拿到手的是一辆已经组装完毕的完整汽车。

软件开发中同样存在大量这样的「复杂对象」。它们拥有一系列成员属性,其中有些是引用类型的成员对象。更重要的是,这些对象往往带有约束条件

  • 某些属性没有赋值,对象就不能作为完整产品使用
  • 某些属性的赋值必须按照特定顺序——一个属性没有赋值之前,另一个属性可能无法赋值

如果让客户端直接处理这些部件的组装逻辑,不仅代码复杂,而且组装过程散落在各处难以维护。建造者模式的思路是:将这些部件的组合过程「外部化」到一个专门的建造者对象中,建造者返还给客户端的是一个已经建造完毕的完整产品对象,客户端无须关心产品包含哪些部件以及它们的组装方式。

模式定义

建造者模式

建造者模式(Builder Pattern)将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示

Separate the construction of a complex object from its representation so that the same construction process can create different representations.

别名:生成器模式。属于对象创建型模式。

这个定义中有两个关键点。「构建与表示分离」意味着组装步骤(怎么建)和最终产品(建出什么)是解耦的;「同样的构建过程创建不同的表示」则意味着,相同的装配流程配合不同的建造者,能产出完全不同的产品——就像同一条汽车生产线,换上不同的零件供应商,就能组装出不同品牌的汽车。

模式结构

classDiagram
    class Director {
        -builder : Builder
        +construct() Product
    }
    class Builder {
        <<abstract>>
        #product : Product
        +buildPartA()* void
        +buildPartB()* void
        +buildPartC()* void
        +getResult() Product
    }
    class ConcreteBuilder {
        +buildPartA() void
        +buildPartB() void
        +buildPartC() void
        +getResult() Product
    }
    class Product {
        -partA : String
        -partB : String
        -partC : String
    }

    Director o--> Builder : builder
    Builder <|-- ConcreteBuilder
    ConcreteBuilder ..> Product : creates

四个角色各司其职:

  • Builder(抽象建造者):为创建产品各个部件指定抽象接口,声明 buildPartX() 方法用于构建各部件,getResult() 方法用于返回完成的产品
  • ConcreteBuilder(具体建造者):实现抽象建造者的接口,完成各部件的具体构造和装配,同时定义并持有它所创建的产品对象
  • Director(指挥者):负责安排复杂对象的建造次序,调用建造者的各个 buildPartX() 方法按正确的顺序组装产品。它的作用有两个:一是隔离客户与生产过程,二是控制产品的生成过程
  • Product(产品):被构建的复杂对象,包含多个组成部件

代码分析

先看产品类——一个包含多个部件的复杂对象:

1
2
3
4
5
6
public class Product {
    private String partA;  // 可以是任意类型
    private String partB;
    private String partC;
    // 各部件的 getter/setter 省略
}

抽象建造者定义了构建各部件的方法和返回产品的方法。注意 product 在声明时就被初始化为一个空的 Product 对象——它就像一个「空壳」,后续的 buildPartX() 方法会逐步填充它的各个部件:

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class Builder {
    // 初始化一个空壳产品,后续 buildPartX() 逐步填充其属性
    protected Product product = new Product();

    public abstract void buildPartA();
    public abstract void buildPartB();
    public abstract void buildPartC();

    public Product getResult() {
        return product;
    }
}

指挥者负责编排建造流程——按什么顺序调用哪些构建方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Director {
    private Builder builder;

    public Director(Builder builder) {
        this.builder = builder;
    }

    public void setBuilder(Builder builder) {
        this.builder = builder;
    }

    public Product construct() {
        builder.buildPartA();
        builder.buildPartB();
        builder.buildPartC();
        return builder.getResult();
    }
}

客户端代码非常简洁——只需选择具体建造者的类型,交给指挥者即可:

1
2
3
Builder builder = new ConcreteBuilder();
Director director = new Director(builder);
Product product = director.construct();

客户端不需要知道产品内部有哪些部件、按什么顺序组装。更换 ConcreteBuilder 就能得到不同的产品——这就是「同样的构建过程创建不同的表示」。

实例:KFC 套餐

KFC 的套餐是一个典型的「复杂对象」:它包含主食(汉堡、鸡肉卷等)和饮料(果汁、可乐等)等组成部分。不同套餐有不同的组成,而 KFC 的服务员可以根据顾客的要求,一步一步装配这些组成部分,构造一份完整的套餐返回给顾客。

classDiagram
    class Meal {
        -food : String
        -drink : String
        +setFood(String food) void
        +setDrink(String drink) void
        +getFood() String
        +getDrink() String
    }
    class MealBuilder {
        <<abstract>>
        #meal : Meal
        +buildFood()* void
        +buildDrink()* void
        +getMeal() Meal
    }
    class SubMealBuilderA {
        +buildFood() void
        +buildDrink() void
    }
    class SubMealBuilderB {
        +buildFood() void
        +buildDrink() void
    }
    class KFCWaiter {
        -mb : MealBuilder
        +setMealBuilder(MealBuilder mb) void
        +construct() Meal
    }

    MealBuilder <|-- SubMealBuilderA
    MealBuilder <|-- SubMealBuilderB
    KFCWaiter o--> MealBuilder : mb
    MealBuilder o--> Meal : meal

    note for KFCWaiter "mb.buildFood();\nmb.buildDrink();\nreturn mb.getMeal();"

在这个例子中:

  • Meal 是产品——包含食物和饮料两个部件
  • MealBuilder 是抽象建造者——定义 buildFood()buildDrink() 方法
  • SubMealBuilderA/B 是具体建造者——每个代表一种套餐方案,决定具体选什么食物和饮料
  • KFCWaiter 是指挥者——服务员知道组装步骤(先准备食物再准备饮料),但不关心具体选的是什么

顾客只需告诉服务员「我要 A 套餐」(选择具体建造者),服务员按流程组装后递上一份完整的套餐。

优缺点

优点:

优点 说明
封装性好 客户端不必知道产品内部组成的细节,产品本身与创建过程解耦
独立可替换 每个具体建造者相对独立,可以方便地替换或增加新的具体建造者
精细控制 将创建步骤分解在不同方法中,使得创建过程更清晰,更方便程序控制
符合开闭原则 增加新的具体建造者无须修改原有代码,指挥者针对抽象建造者编程

缺点:

  • 建造者模式创建的产品一般具有较多的共同点,组成部分相似。如果产品之间的差异性很大,则不适合使用建造者模式,使用范围受到限制
  • 如果产品内部变化复杂,可能需要定义很多具体建造者类来实现不同的变化,导致系统变得庞大

适用场景

  • 产品对象有复杂的内部结构,包含多个成员属性
  • 产品对象的属性相互依赖,需要指定生成顺序
  • 对象的创建过程独立于创建该对象的类——创建过程封装在指挥者中而非建造者中
  • 需要隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品

模式简化

在简单场景下,建造者模式可以适度精简:

  • 省略抽象建造者:如果系统中只需要一个具体建造者,可以直接省略抽象层
  • 省略指挥者:在只有一个具体建造者且抽象建造者已省略的情况下,还可以省略指挥者角色,让 Builder 同时扮演指挥者和建造者的双重角色——即在 Builder 内部提供一个 construct() 方法来编排构建步骤,客户端直接调用该方法即可

现代变体:流式建造者

课件中介绍的是经典 GoF 建造者模式,包含完整的 Director-Builder 结构。而在实际工程中,还有一种更常见的变体——流式建造者(Fluent Builder),它通过方法链实现更简洁的 API:

1
2
3
4
5
6
7
8
9
10
// 传统方式:需要 Director 编排
Director director = new Director(new BMWBuilder());
Car car = director.construct();

// 流式建造者:方法链直接构建
Car car = new Car.Builder()
    .engine("V8")
    .wheels(4)
    .color("black")
    .build();

流式建造者的核心技巧是每个 setter 方法返回 this,从而支持链式调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Car {
    private final String engine;
    private final int wheels;
    private final String color;

    private Car(Builder builder) {
        this.engine = builder.engine;
        this.wheels = builder.wheels;
        this.color = builder.color;
    }

    public static class Builder {
        private String engine;
        private int wheels;
        private String color;

        public Builder engine(String engine) {
            this.engine = engine;
            return this;  // 返回 this 支持链式调用
        }

        public Builder wheels(int wheels) {
            this.wheels = wheels;
            return this;
        }

        public Builder color(String color) {
            this.color = color;
            return this;
        }

        public Car build() {
            return new Car(this);
        }
    }
}

这种变体省略了外部 Director,将构建逻辑内嵌到 Builder 的 build() 方法中。StringBuilder 在方法链风格上类似流式建造者(每次 append() 返回 this),Lombok 的 @Builder 注解也能自动生成这种结构。

经典建造者 vs 流式建造者

  • 经典建造者(GoF):强调 Director 和 Builder 的分离,Director 控制构建顺序,适合构建步骤固定且有严格顺序要求的场景
  • 流式建造者:省略 Director,客户端直接调用 Builder 的方法链,适合属性可选、顺序无关的场景(如配置对象)

两者解决的核心问题相同——分离复杂对象的构建过程,只是在「谁来控制构建顺序」这一点上有所不同。

建造者模式与抽象工厂模式的比较

这两个模式都是创建型模式,但关注点截然不同:

维度 抽象工厂模式 建造者模式
关注点 创建什么(产品族) 怎么创建(组装过程)
返回内容 一系列相关产品 一个组装好的完整产品
客户端交互 直接调用工厂方法获取产品 通过指挥者间接调用建造者
类比 汽车配件生产工厂 汽车组装工厂

抽象工厂像是一个生产配件的工厂——你调用 createEngine() 得到发动机,调用 createWheel() 得到轮胎,但这些配件怎么组装成一辆完整的车,它不管。建造者模式则是一个组装工厂——你告诉它要什么型号,它按步骤把配件装好,给你一辆完整的汽车。

应用场景

JavaMail:发送邮件时需要一步一步构造一个完整的邮件对象——设置发件人、收件人、主题、正文、发送时间等,每一步都是在「装配部件」:

1
2
3
4
5
6
7
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("sender@test.com"));
message.setRecipient(Message.RecipientType.TO, to);
message.setSubject("标题");
message.setText("正文");
message.setSentDate(new Date());
message.saveChanges();

游戏开发:地图包括天空、地面、背景等部分,人物角色包括人体、服装、装备等部分。通过不同的具体建造者,可以用相同的构建流程创建不同类型的地图或人物。

原型模式

从「复制粘贴」说起

日常使用电脑时,Ctrl+C / Ctrl+V 大概是最常用的操作之一。复制得到的内容和原始内容一模一样,但它们是两个独立的副本——修改副本不会影响原件。

在软件开发中也经常遇到类似的需求:你已经有了一个配置好的对象,现在需要创建一个几乎一样的新对象。如果这个对象的创建过程很复杂(比如需要数据库查询、网络请求或大量初始化计算),每次都从零开始 new 就太浪费了。更聪明的做法是——复制一个已有的对象,再按需微调

这就是原型模式的核心思想。

模式定义

原型模式

原型模式(Prototype Pattern)是一种对象创建型模式,用原型实例指定创建对象的种类,并且通过复制这些原型创建新的对象。

Specify the kind of objects to create using a prototypical instance, and create new objects by copying this prototype.

原型模式允许一个对象再创建另外一个可定制的对象,无须知道任何创建的细节。工作原理很简单:将一个原型对象传给那个要发动创建的对象,后者通过请求原型拷贝自身来完成创建过程。

模式结构

classDiagram
    class Client {
        -prototype : Prototype
        +operation() void
    }
    class Prototype {
        <<abstract>>
        +clone()* Prototype
    }
    class ConcretePrototypeA {
        +clone() Prototype
    }
    class ConcretePrototypeB {
        +clone() Prototype
    }

    Client o--> Prototype : prototype
    Prototype <|-- ConcretePrototypeA
    Prototype <|-- ConcretePrototypeB

    note for ConcretePrototypeA "return copy of self;"

只有三个角色,非常简洁:

  • Prototype(抽象原型类):定义克隆自身的方法接口
  • ConcretePrototype(具体原型类):实现 clone() 方法,返回自身的一个副本
  • Client(客户类):持有一个原型对象的引用,通过调用 clone() 来获取新对象

Java 中的克隆机制

Java 天然支持原型模式——所有类都继承自 java.lang.Object,而 Object 提供了 clone() 方法。不过要使用它,类必须实现 Cloneable 标识接口(一种没有任何方法声明的接口,仅作为「我支持克隆」的标记),否则调用 clone() 会抛出 CloneNotSupportedException

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PrototypeDemo implements Cloneable {
    // ...

    public Object clone() {
        Object object = null;
        try {
            object = super.clone();
        } catch (CloneNotSupportedException e) {
            System.err.println("Not support cloneable");
        }
        return object;
    }
}

clone() 方法的语义保证:

  1. x.clone() != x——克隆对象与原对象不是同一个对象(不同的内存地址)
  2. x.clone().getClass() == x.getClass()——克隆对象与原对象的类型一样
  3. 如果 equals() 定义恰当,x.clone().equals(x) 应该成立

浅克隆与深克隆

一个类的成员可能包含基本类型和引用类型。克隆时,如何处理引用类型的成员对象,决定了两种截然不同的克隆方式:

浅克隆与深克隆

浅克隆(Shallow Clone):复制对象本身,但其引用类型的成员对象不被复制——克隆对象和原对象共享同一个成员对象。Java 的 Object.clone() 默认实现的就是浅克隆。

深克隆(Deep Clone):复制对象本身的同时,对象包含的引用也被递归复制——克隆对象和原对象的成员对象完全独立。

用一张图来理解区别:

flowchart LR
    subgraph 浅克隆
        direction TB
        A1["原对象"] -->|引用| S1["共享的成员对象"]
        A2["克隆对象"] -->|引用| S1
    end
    subgraph 深克隆
        direction TB
        B1["原对象"] -->|引用| S2["成员对象 A"]
        B2["克隆对象"] -->|引用| S3["成员对象 A'(副本)"]
    end

    classDef obj fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    classDef shared fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
    classDef copy fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px

    class A1,A2,B1,B2 obj
    class S1 shared
    class S2,S3 copy

浅克隆的危险在于:修改克隆对象的成员对象时,原对象也会受到影响,因为它们共享同一个引用。

实例一:邮件复制(浅克隆)

某邮件系统需要提供「复制邮件」功能:对于已创建好的邮件对象,通过复制方式创建新的邮件对象,修改副本不影响原始邮件。邮件对象包含发送者、接收者、标题、内容、日期、附件等内容。

在本实例中,使用浅克隆——复制邮件(Email)本身,但不复制附件(Attachment)。

classDiagram
    class Object {
        +clone() Object
    }
    class Cloneable {
        <<interface>>
    }
    class Email {
        -attachment : Attachment
        +Email()
        +clone() Object
        +display() void
        +getAttachment() Attachment
    }
    class Attachment {
        +download() void
    }

    Object <|-- Email
    Cloneable <|.. Email
    Email o--> Attachment : attachment

由于是浅克隆,原始邮件和克隆邮件的 attachment 字段指向同一个 Attachment 对象。这意味着如果你修改了克隆邮件的附件内容,原始邮件的附件也会跟着变——这就是浅克隆的「共享引用」特性。

在使用浅克隆时,必须清楚哪些成员对象是共享的。如果业务上要求副本和原件完全独立,就需要使用深克隆。

实例二:游戏 NPC 复制(深克隆)

游戏开发中需要创建多个相似的 NPC 角色。为了方便创建,先创建一个 NPC 角色作为原型,其余角色通过复制原型后加上些微变化而来。这些复制来的 NPC 与原 NPC 具有相同的初始装备和状态,但相互独立——当一个 NPC 的装备被损坏或加强时,不应影响其他 NPC。

这个场景就需要深克隆:不仅复制 NPC 对象本身,还要递归复制它持有的装备、状态等成员对象,确保每个 NPC 的内部状态完全独立。

实现深克隆:序列化方式

手动实现深克隆需要为每个引用类型的成员递归调用 clone(),代码繁琐且容易遗漏。一种更通用的方式是通过序列化(Serialization)实现深克隆。所谓序列化,就是将一个对象转换为字节流的过程,反序列化则是从字节流中恢复对象。如果我们把对象序列化到内存中的字节数组,再从中反序列化出来,得到的就是一个全新的、与原对象完全独立的深拷贝:

1
2
3
4
5
6
7
8
9
10
11
public Object deepClone() throws IOException, ClassNotFoundException {
    // 将对象写入字节流
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(bos);
    oos.writeObject(this);

    // 从字节流中读出新对象
    ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
    ObjectInputStream ois = new ObjectInputStream(bis);
    return ois.readObject();
}

使用序列化实现深克隆时,所有涉及的类都必须实现 Serializable 接口。这种方式虽然性能不如手动克隆,但胜在简洁通用,不会遗漏任何引用类型的成员。

优缺点

优点:

优点 说明
简化创建 当创建新对象代价较大时,通过已有实例复制可以提高创建效率
动态增减 可以在运行时动态增加或减少产品类
结构简化 无需工厂类层次,创建结构更简洁
状态保存 可以使用深克隆保存对象的状态,用于实现撤销操作等功能

缺点:

  • 需要为每一个类配备一个 clone() 方法,而且这个方法需要对类的功能进行通盘考虑。对已有类进行改造时必须修改其源代码,违背了开闭原则
  • 实现深克隆时需要编写较为复杂的代码,特别是当对象之间存在多重嵌套引用时

适用场景

了解了优缺点,可以明确原型模式最适合以下场景:

  • 创建新对象成本较大:新的对象可以通过复制已有对象获得,如果是相似对象,还可以对其属性稍作修改
  • 保存对象状态:对象的状态变化很小或对象本身占内存不大时,可以使用原型模式配合备忘录模式保存历史状态。反之,如果对象的状态变化很大或占用内存很大,那么采用状态模式会比原型模式更合适
  • 避免分层次的工厂类:当类的实例对象只有一个或很少的几个组合状态时,通过复制原型对象可能比构造函数或工厂方法更方便

模式扩展

带原型管理器的原型模式

在需要管理多种原型对象时,可以引入一个原型管理器(PrototypeManager),用一个 Hashtable 存储各种原型对象,客户端通过键值获取对应的原型并克隆:

classDiagram
    class Client
    class Prototype {
        <<abstract>>
        +clone()* Prototype
    }
    class ConcretePrototypeA {
        +clone() Prototype
    }
    class ConcretePrototypeB {
        +clone() Prototype
    }
    class PrototypeManager {
        -prototypeTable : Hashtable
        +add(String key, Prototype prototype) void
        +get(String key) Prototype
    }

    Prototype <|-- ConcretePrototypeA
    Prototype <|-- ConcretePrototypeB
    Client ..> PrototypeManager
    PrototypeManager o--> Prototype

客户端不直接持有原型对象,而是通过管理器按名字获取,这在需要大量不同类型原型的系统中非常实用。

相似对象的复制

很多时候,复制得到的对象与原型并不完全相同——它们的某些属性存在差异。典型场景如批量创建学生对象:专业、学院、学校等信息都相同,只有性别、姓名和年龄不同。这时可以通过原型模式复制出一个「模板对象」,再修改个别属性,比逐个字段 new 要简洁得多。

实际应用

原型模式在 Java 生态中有广泛应用:

  • 日常操作:软件中的复制(Ctrl+C)和粘贴(Ctrl+V)操作就是原型模式的直观体现
  • Struts2(一个早期流行的 Java Web 框架):为了保证线程安全性,Action 对象的创建使用了原型模式。访问一个已存在的 Action 对象时通过克隆创建新对象,每个 Action 都有自己的成员变量,避免了 Struts1 因单例模式导致的并发和同步问题
  • Spring:用户可以将 Bean 的作用域配置为 prototype,每次获取的都是通过克隆生成的新实例,修改时不会影响原有实例对象

创建型模式全景

至此,我们已经学习了五种创建型模式。它们都在解决同一个根本问题——将对象的创建从使用中分离,但各自的侧重点不同:

模式 核心问题 关键机制
简单工厂 根据参数决定创建哪种对象 静态方法 + 条件判断
工厂方法 将创建延迟到子类 多态
抽象工厂 创建一族相关产品 多态 + 产品族
建造者 分步组装复杂对象 Director + Builder 分离
原型 通过复制已有对象快速创建 clone

这五种模式并不互斥,经常可以组合使用。例如,抽象工厂可以用建造者模式来组装它创建的复杂产品;工厂方法可以返回一个原型对象的克隆。选择哪种模式,取决于创建的复杂度在哪里——是「选什么类」(工厂系列)、「怎么装配」(建造者)、还是「怎么快速复制」(原型)。

设计工具箱更新:

层次 内容
OO 基础 抽象、封装、多态、继承
OO 原则 封装变化、面向接口编程、组合优于继承
OO 模式 策略模式、简单工厂模式、工厂方法模式、抽象工厂模式、建造者模式原型模式