创建型模式:建造者模式与原型模式
引言
上一节我们学习了三种工厂模式——它们解决的核心问题是「创建什么类型的对象」。但在实际开发中,创建对象的复杂性不只体现在「选哪个类」,还可能体现在:
- 对象本身结构复杂——由多个部件组合而成,部件之间还有装配顺序和约束关系
- 创建过程代价昂贵——频繁
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() 方法的语义保证:
x.clone() != x——克隆对象与原对象不是同一个对象(不同的内存地址)x.clone().getClass() == x.getClass()——克隆对象与原对象的类型一样- 如果
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 模式 | 策略模式、简单工厂模式、工厂方法模式、抽象工厂模式、建造者模式、原型模式 |