Design-Pattern-Builder

本篇简单描述生成器模式(Builder)的结构和特征,并着重讨论为什么要使用生成器模式的思考。

场景

生成器模式是一种创建型模式,目的是创建一个对象。通常创建对象,可以通过new关键字调用构造函数,并在构造函数中传入需要的参数。这在对象结构简单时十分容易。但当类的成员变量变得众多类型复杂,且某些参数可以不是必输的时候,会变得复杂。在一些语言,例如Python中,可以通过提供方法的默认参数,避开这个问题,来满足不同场景下的构造函数调用。

1
2
3
4
5
6
7
8
9
class MyClass:
def __init__(self, name="default_name", age=0):
self.name = name
self.age = age

obj1 = MyClass()
obj2 = MyClass("Draco")
obj3 = MyClass(age=35)
obj4 = MyClass("Draco", 35)

Java不支持方法默认参数,但可以通过函数重载,定义多个不同参数数量,种类组合的构造函数,来适应这种参数非必输时,使用构造函数来创建对象。

1
2
3
4
MyClass() { ... }
MyClass(String name) { ... }
MyClass(Integer age) { ... }
MyClass(String name, Integer age) { ... }

但这会出现一些问题。当对象有n个成员变量需要初始化,那么需要 \(2^n\) 个构造函数。这种指数增长的构造函数显然是不合理的。并且当相邻两个参数为相同类型时,去掉其中任意一个参数会导致方法签名完全一致,编译器无法区分两个方法的区别,会显示编译错误。

1
2
3
4
MyClass() { ... }
MyClass(String name) { ... }
MyClass(String age) { ... } // 编译错误,重复定义
MyClass(String name, String age) { ... }

又或者不提供所有的构造函数,只提供一个全参的构造函数,如果某个参数无需初始化时,则在构造时将对应参数位置传入null

1
2
3
MyClass(String name, String address, Integer age) { ... }
// 不需要初始化的参数对应位置传入null
MyClass myObject = new MyClass("Draco", null, 35);

当对象的成员变量较多时,会产生一个参数列表非常长的构造函数调用。程序员需要记住每个位置的参数的含义,并在构造时确保不出错。当然现代IDE都提供了参数提示的功能,在输入参数时敲入逗号,或者鼠标悬停至参数位置时,会提供参数列表变量名和类型的提示。但超长的构造函数总归是不方便也不优雅的。

至于为什么不通过新建对象后,重复调用set方法,这部分原因放到后文再来谈。

为了解决上述问题,引入了生成器模式

最简单的生成器模式

类似于工厂模式生成器模式也使用了一个独立的类,将对象的创建过程从产品类中抽取出来。生成器模式提供了逐步构建复杂对象的方法。将构造函数的一次性调用,拆解成了对生成器设置方法的多次调用。

这个生成器类可以定义在独立的.java文件中。也可以定义在产品类中,通常是public static class的形式。生成器类通常拥有和原产品类相同的,或其中一部分成员变量,用于作为调用产品类构造函数的参数。在生成器类中,提供了设置成员变量的方法,通常这个方法的返回值类型会被设置为生成器类本身。这样生成器类对象,可以链式调用设置生成器中的成员变量,如此可以逐步构建复杂对象,而不是直接调用构造函数本身。最后生成器类还提供了生成方法,调用产品类构造函数,返回产品类对象。在生成方法中,还可以包含一些校验逻辑。

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
37
// 使用lombok注解生成全参构造函数
@AllArgsConstructor
public class MyClass {
private String name;
private Integer age;
private String address;
...
// 通过静态内部类形式定义生成器类
public static class MyClassBuilder {
// 具有产品类全部或部分成员变量
private String name;
private Integer age;
private String address;
// 提供set方法,设置成员变量
public MyClassBuilder setName(String name) {
this.name = name;
return this;
}
...
// 提供生成方法,返回产品对象
public MyClass build() {
if(StringUtil.isEmpty(name)) {
throw new RuntimeException("姓名不得为空");
}
// 调用产品类构造函数
return new MyClass(name, age, address);
}
}
}
// 客户端代码
public static void main() {
MyClassBuilder myClassBuilder = new MyClassBuilder();
MyClass myObject = myClassBuilder.setName("DracoYu")
.setAge(35)
.setAddress("Hangzhou")
.build();
}

以上便是生成器模式的最简化的结构。究其核心部分,是和产品类相同的成员变量,提供了设置成员变量的设置方法,以及创建对象的生成方法。这样的结构在第三方库中可以经常看到,但这和大部分教材中描述的生成器模式还是有些区别。接下来描述教材中的生成器模式,并描述相对于最简单的生成器模式,多了些什么东西。

复杂些的生成器模式

在上一节中,描述了最简单的生成器结构,创建了一种产品类。对于单个产品类来说,这样子的结构是足够且合适的。

但设计模式考虑代码拓展性,如果多个产品类具有类似的成员变量和构建过程,则这些产品类的构建过程可以被抽象为生成器接口/抽象类。

1
2
3
4
5
6
7
public interface Builder {
Builder setName(String name);
Builder setAge(Integer age);
Builder setSalary(Double salary);
Builder setAddress(String address);
Person build();
}

当多个产品类实现了共同的接口或继承于共同的父类,那么在生成器中可以提供build生成方法,并返回接口/父类类型。如果没有共同的接口或父类,则build方法需要在每个具体builder中单独定义。

定义完生成器接口后,需要针对每一个产品类,定义对应的具体生成器类,这点和工厂方法模式很像。区别是工厂方法通常只声明了创建抽象产品的方法,而在生成器接口中通常还声明了多个产品共同构建过程的设置方法。

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
public class StaffBuilder implements Builder {
@override
builder setName(String name){
...
}
...
@override
Person build() {
// 返回具体产品类对象,其中Staff类实现了Person接口
return new Staff(name, age, salary, address);
}
}

public class Staff implements Person {
...
}

// 客户端代码
public static void main() {
Builder staffBuilder = new StaffBuilder();
Person staff = staffBuilder.setName("DracoYu")
.setAge(35)
.setSalary(0)
.setAddress("Hangzhou")
.build();
}

此时,客户端通过实例化具体的生成器类,并调用其中由生成器接口声明的设置方法和生成方法,获取产品类对象。

但此刻,客户端类仍体现出对生成器接口过度的依赖:客户端需要了解生成器接口声明的设置方法。如果多处对生成器类的设置方法调用,具有相似的结构,则可以将这些对于生成器设置方法的调用,抽取到专门的类当中,这个类通常称为主管类(director)

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
public class Director {
// 可以使用成员变量来管理生成器对象
private Builder builder;
// 通过构造函数注入生成器对象
public Director(Builder builder) {
this.builder = builder;
}
// 由于上述示例代码每次调用设置方法都不相同,此处没有遵循上面的例子
public void construct() {
builder.buildPartA();
builder.buildPartB();
builder.buildPartC();
}
// 另一套设置方法的调用
public void anotherConstruct() {
builder.buildPartA();
builder.buildPartC();
builder.buildPartD();
}
}

// 客户端代码
public static void main() {
Builder builderA = new BuilderA();
Director director = new Director(BuilderA);
director.construct();
Product product = builderA.build();
}

至此,整个生成器模式的结构应该和大部分设计模式教材中一致,总结一下其中包含的部分。

  • 产品:需要创建的对象,此处不分区抽象/具体产品。
  • 生成器接口/抽象生成器:抽象了多种产品构建过程中的相同部分,声明为设置方法。设置方法通常返回生成器接口本身。
  • 具体生成器:一个具体生成器对应一个具体产品,实现了生成器接口中声明的设置方法。并提供生成方法创建具体的产品对象。
  • 主管类:通过生成器接口,调用了生成器的具体设置方法。

生成器模式的各种变体

由于生成器模式有不同的展现方式,在网上查资料时,虽然各种文章都在描述生成器模式,但感觉每篇文章讲的都不是同一个东西。这可能与原版生成器模式(四人帮提出的23种设计模式)过于复杂,在实践过程中产生了各种简化版本和变体有关。也可能是因为builder这个概念被滥用,而导致发生了众多歧义。笔者结合自己看过的文章,以及查阅JDK和各种第三方库中的生成器源代码,来对不同实现做个列举。

  • 简单生成器:大部分情况下,产品类不需要拓展,因此也不需要生成器接口,主管类也可以省略。此时只新增了一个生成器类,这个类可以通过静态内部类形式定义在产品类本身代码中。简单生成器是实践中最常见的形式
  • 产品类:多个具体产品类可以实现相同接口,也可以不实现。只要有相似的构建过程,完全不相同的产品类也可以使用相同的生成器接口。这是因为生成器接口只定义了构建过程,而具体实现由具体生成器类实现。
  • 生成器接口/抽象生成器:如果生成的产品实现了同一接口,可以像工厂方法那样定义生成方法返回产品接口类型。如果没有,则可以让具体生成器定义生成方法的返回类型。此时主管类中也无法通过生成器接口调用生成方法,转由客户端来调用。
  • 具体生成器:具体生成器可以拥有和产品类相同的成员变量,也可以用产品作为成员变量。如果以产品作为成员变量,需要在构造时或提供初始化方法新建产品对象,并赋值给成员变量。调用生成方法时将成员变量返回即可。
  • 主管类:大部分情况下可以省略,对设置方法的调用通常转由客户端来完成。一方面是因为大部分设置方法的调用是定制的,无法确定每个产品设置方法的调用顺序,次数,以及参数,难以抽取公共方法。另一方面客户端类对生成器接口的依赖是可以被接受的。

为什么使用生成器模式

笔者在学习完生成器模式后,很长一段时间无法体会到其实用性。浏览网上众多资料,给出的原因大多归于这么几类:

  1. 避免过于复杂的构造函数调用,提供了逐步构建复杂对象的方法
  2. 链式调用

对于原因1,另一个想当然的解决方法就是调用空参构造函数,并在新建完对象后,反复调用java bean规范的set方法,便可以达成逐步构建复杂对象的目的。对于原因2,可以手动将set方法设置为返回对象本身(这会破坏java bean规范),便可实现链式调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyClass {
private String name;
private Integer age;
private String address;
// set方法返回对象本身
public MyClass setName(String name) {
this.name = name;
return this;
}
...
}
// 客户端代码
public static void main() {
MyClass myObject = new MyClass();
// 满足逐步构建、链式调用
myObject.setName("Draco")
.setAge(35)
.setAddress("Hangzhou")
}

如此来看生成器模式仿佛是多余的,所有的特性都可以通过其他更简便的形式来实现。

会产生这样的想法,是因为上述例子中,将产品的设置方法、生成方法都做了简化。上述生成器中的设置方法,除了返回生成器本身,和java beanset方法无异。而实际情况中,设置方法可能复杂包含逻辑的。例如设置方法会对输入进行校验,可以联动调整多个属性,或者向一个Collection中反复添加元素。这些功能不是简单的setter能实现的。如果在产品类中添加太多创建对象的逻辑规则,则又违反了表现和构建相分离的原则(单一职责原则)。

另一个十分重要的原因是,在JDK和许多第三方库中使用了不可变对象。不可变对象(immutable objects)是指一旦创建后,其状态就不能被修改的对象。这意味着对象的所有成员变量都是不可变的,它们的值在对象的整个生命周期内保持不变。

不可变对象有许多优秀特性,例如线程安全,无副作用等。不过这不是本篇讨论的重点。要创建一个不可变对象,最简单的是将所有成员变量声明为 privatefinal,并且不提供修改成员变量的公共方法(setter)。由于成员变量被声明为final,则必须要在构造函数中进行初始化,否则会报编译错误。不可变对象无法先新建对象,再调用设置方法的形式去为对象赋值

从这个视角看,生成器替不可变对象暂存了成员变量,并提供了基于成员变量的一些设置方法、校验规则。如果生成器的目的是创建不可变对象,则无法使用产品类作为成员变量的变体。

一个体现了这个原因,最常用的生成器便是StringBuilder。在java中,String类为不可变类,所有的String对象在实例后就不会再变化,所有涉及字符串操作,都是通过创建新String对象来实现。例如

1
String number = "1" + "2";

如果暂不考虑常量池,这行代码一共存在了三个String对象,分别为"1""2""12"。如果每一次字符串操作都会新实例化一个String,多次的字符串操作所带来的申请、初始化内存操作是性能低下的。String本身是个字节数组,StringBuilder通过一个字节数组成员变量,并提供了对字节数组增删改的操作,使得进行字符串操作时,无需每次操作都生成一个String对象。最后,调用toString()方法,来将生成器中的数据用来构造String对象。

此外,生成器模式还体现了一种,维护对象原子性、一致性、有效性的作用。如果采用新建对象再设置的方式,则对象可能处于无效状态。例如

1
2
3
Rectangle r = new Rectange(); // r is invalid, not good
r.setWidth(2); // r is invalid, not good
r.setHeight(3); // r is valid

创建了一个长方形对象。对于一个长方形来说,长和宽都是必须的,不存在没有长或者没有宽的长方形。新建对象后再设置的过程中,不可避免对象会处于这种无效的状态。为了避免这种无效的状态,必须要在构造函数中提供所有必须的参数。

而在生成器的生成方法中,可以通过参数校验,来限制这种无效对象的产生,保证产生的不可变对象都是有效的。

总结

生成器模式是一种创建型设计模式,用于将复杂对象的构建过程与其表示分离,以便可以按步骤构建对象,同时隐藏其构建细节。

最常见、最简单的生成器模式只包含一个产品类,一个生成器类,并且通常将生成器类以静态内部类定义在产品类中。

生成器类通过提供设置方法和生成方法,提供了逐步构建复杂对象的方法。

生成器模式可以通过新增抽象产品、抽象生成器、主管类来进行拓展。

笔者认为生成器模式最重要的目的是保证对象的原子性、一致性、有效性。通常和不可变对象一起使用。


Design-Pattern-Builder
http://dracoyus.github.io/2024/03/13/Design-Pattern-Builder/
作者
DracoYu
发布于
2024年3月13日
许可协议