静态工厂和构造器有一个共同的局限:它们都不能很好地扩展到大量的可选参数。考虑用一个类来表示食品包装上的营养成分标签的情况。这些标签中有一些是必需的属性:食用份量(serving size),每罐份量(servings per container)以及每份的卡路里(calories per serving),还有超过 20 个可选的属性:总脂肪量(total fat)、饱和脂肪量(saturated fat)、转化脂肪(trans fat)、胆固醇(cholesterol)、钠(sodium)等等。大多数产品仅仅只有少部分可选属性有非零值。
对于这样的类,应该编写什么样的构造器或静态工厂呢? 传统的写法,程序员会使用重叠构造器模式(telescoping constructor pattern),在这种模式下,你提供的第一个构造器只需带有必要参数,而第二个构造器可以带有一个可选参数,第三个构造器带有两个可选参数,以此类推,最后一个构造器囊括所有可选参数。下面是一个示例,为了简单起见,只展示了四个可选参数:
// 重叠构造器模式 - 不能很好地扩展!
public class NutritionFacts {
private final int servingSize; // (毫升) 必需
private final int servings; // (每罐) 必需
private final int calories; // (每份) 可选
private final int fat; // (克每份) 可选
private final int sodium; // (毫克每份) 可选
private final int carbohydrate; // (克每份) 可选
public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}
public NutritionFacts(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
当你想要创建实例时,可以使用参数列表最短的构造器,其包含了所有你想要设置的参数。
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0,35, 27);
通常,调用该构造器将需要许多你不想要设置的参数,但无论如何你都要为它们传递一个值。在这个例子中,我们为 fat 这个变量传递了一个为 0 的值。“仅仅” 6 个参数,这看起来还不算太糟,但随着参数数量的增加,这将很快失控。
简而言之,重叠构造器模式可行,但是当参数很多时,客户端代码会很难编写,并且仍然难以阅读。读者会留下疑问:这些值到底是什么意思,并且必须仔细的数着这些参数来探个究竟。一长串相同类型的参数会造成一些微妙的错误。如果客户端意外的弄反了其中两个参数,编译器不会报错,但是在运行时程序会出现错误的行为。
当遇到一个有很多参数的构造器时,第二个备选方案就是 JavaBeans 模式,在这个模式中,你可以调用一个无参的构造器来创建对象,并在之后使用 setter 方法来设置必需必要参数以及感兴趣的可选参数。
// JavaBeans 模式 - 允许不一致性、任务可变性
public class NutritionFacts {
// 参数初始化为默认值(如果有的话)
private int servingSize = -1; // 必需;没有默认值
private int servings = -1; // 必需;没有默认值
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public NutritionFacts() {
}
// Setter 方法
public void setServingSize(int val) {
servingSize = val;
}
public void setServings(int val) {
servings = val;
}
public void setCalories(int val) {
calories = val;
}
public void setFat(int val) {
fat = val;
}
public void setSodium(int val) {
sodium = val;
}
public void setCarbohydrate(int val) {
carbohydrate = val;
}
}
这个模式没有重叠构造器模式的缺点。说明白点,就是创建一个实例很容易,并且生成的代码易于读懂。
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
遗憾的是,JavaBeans 模式本身有着严重的缺点。由于构造过程被拆分到了多个调用中,因而,JavaBean 可能在构造中途处于不一致状态。类没有办法仅仅通过校验构造参数的有效性来保证一致性。尝试在对象处于不一致状态时使用可能会导致失败,这种失败与包含错误的代码大相径庭,因此难以调试。与之相关的另一个缺点在于, JavaBeans 模式阻止了把类变成不可变的可能性(第 17 条), 这就需要程序员付出更多的努力来确保线程安全。
It is possible to reduce these disadvantages by manually “freezing” the object when its construction is complete and not allowing it to be used until frozen, but this variant is unwieldy and rarely used in practice. Moreover, it can cause errors at runtime because the compiler cannot ensure that the programmer calls the freeze method on an object before using it.
可以通过在构建完成时手动“冻结(freezing)”对象来弥补这些缺陷,并且在“冻结”之前不允许该对象被使用, 但是这种方式十分笨拙,并且在实践中很少使用。此外,它还会在运行时造成错误,因为编译器不能确定程序员会在使用之前先在对象上调用 freeze 方法。
幸运的是,还有第三种备选方案,它兼具了重叠构造器模式的安全性和 JavaBeans 模式的可读性,这就是 Builder 模式 [Gamma95] 的一种形式。与直接创建想要的对象的方式不同,客户端调用带有所有必需参数的构造器(或者静态工厂)并获得一个 builder 对象。然后客户端在 builder 对象上调用类似于 setter 的方法为感兴趣的可选参数赋值。最后,客户端调用无参的 build 方法来生成对象,该对象通常是不可变的。Builder 通常是它构建的类的一个静态成员类(第 24 条),下面是它的示例:
// Builder 模式
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// 必需参数
private final int servingSize;
private final int servings;
// 可选参数,初始化默认值
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val) {
calories = val;
return this;
}
public Builder fat(int val) {
fat = val;
return this;
}
public Builder sodium(int val) {
sodium = val;
return this;
}
public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
NutritionFacts 类是不可变的,并且所有默认参数值都在一个地方。builder 的 settter 方法会返回 builder 本身,以便可以链接调用,形成流式 API。下面是客户端代码:
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();
这样的客户端代码易于编写,更重要的是易于阅读。Builder 模式模拟了 Python 和 Scala 中的命名可选参数。
为了简洁,省略了有效性检查。若要尽快检测出无效参数,请在 builder 的构造器和方法中检查参数有效性。检查由 build 方法调用的构造器中涉及多个参数的不变量。为了确保这些不变量不受攻击,可以将 builder 中的参数拷贝到对象之后,对对象字段进行校验 (第 50 条)。如果校验失败,应该抛出 IllegalArgumentException 异常(第 72 条),其详细信息指明了哪个参数是无效的(第 75 条)。
Builder 模式十分适合于类层次结构(class hierarchy)。 使用 builder 的并行层次结构,每个 builder 都嵌套在相应的类中。抽象类有抽象的 builder;具体类有具体的 builder。例如,考虑一个在层级结构根部的用来表示多种类型的披萨的抽象类:
// 类层级结构的 builder 模式
public abstract class Pizza {
public enum Topping {
HAM, MUSHROOM, ONION, PEPPER, SAUSAGE
}
final Set<Topping> toppings;
abstract static class Builder<T extends Builder<T>> {
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
public T addTopping(Topping topping) {
toppings.add(Objects.requireNonNull(topping));
return self();
}
abstract Pizza build();
// 子类必须覆盖这个方法用来返回 “this”
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // 参考第 50 条
}
}
需要注意的是 Pizza.Builder
是一个带有递归类型参数的泛型类型(第 30 条)。这与抽象的 self 方法一起,允许方法链在子类中工作,而不需要强制转换。对于 Java 缺乏自身类型(self type)的事实,这个解决方法被认为是模拟自身类型的习惯用法。
这里有两个 Pizza 类的具体子类,其中一个代表标准的纽约风格(New-York-style)披萨,另一个则是半圆形烤馅饼(calzone)。前者有一个必需的尺寸参数,而后者则可以让你指定酱汁应该在外面还是里面:
public class NyPizza extends Pizza {
public enum Size {SMALL, MEDIUM, LARGE}
private final Size size;
public static class Builder extends Pizza.Builder<Builder> {
private final Size size;
public Builder(Size size) {
this.size = Objects.requireNonNull(size);
}
@Override
public NyPizza build() {
return new NyPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
private NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
}
public class Calzone extends Pizza {
private final boolean sauceInside;
public static class Builder extends Pizza.Builder<Builder> {
private boolean sauceInside = false; // 默认
public Builder sauceInside() {
sauceInside = true;
return this;
}
@Override
public Calzone build() {
return new Calzone(this);
}
@Override
protected Builder self() {
return this;
}
}
private Calzone(Builder builder) {
super(builder);
sauceInside = builder.sauceInside;
}
}
值得注意的是,每个子类的 builder 类的 build 方法被声明为返回正确的子类:NyPizza.Builder 的 build 方法返回 NyPizza 对象,而在 Calzone.Builder 中的 builder 方法则是返回 Calzone 对象。这种声明子类方法返回超类中声明的返回类型的子类型的技术,称为协变返回类型(covariant return typing)。它允许客户端使用这些 builder 而不需要强制转换。
这些 “层级 builder” 的客户端代码基本上与简易的 NutritionFacts 的 builder 代码相同。为简洁起见,下面展示的示例客户端代码假定枚举常量静态导入:
NyPizza pizza = new NyPizza.Builder(SMALL)
.addTopping(SAUSAGE)
.addTopping(ONION)
.build();
Calzone calzone = new Calzone.Builder()
.addTopping(HAM)
.sauceInside()
.build();
Builder 相对于构造器一个略微优势在于 builder 可以有多个可变参数(varargs),因为每个参数都可以在其自己的方法中被指定。或者,builder 可以将传递给方法的多个调用的参数聚合到单个字段中,如前面的 addTopping 方法所示。
Builder 模式相当的灵活,单个 builder 可以重复用来创建多个对象。可以在 build 方法的调用间调整 builder 的参数,以改变创建的对象。Builder 可以在创建对象时自动填充某些字段,例如每次创建对象时增加的序列号。
Builder 模式同样有缺陷。为了创建一个对象,你必须先创建它的 builder。尽管在实践中并不太可能注意到创建 builder 的成本,但在性能关键的情况下会成为一个问题。此外,Builder 模式比重叠构造器模式更加的冗长,因此只有在有足够的参数(例如四个或更多)使其值得使用时才应使用它。但请记住,将来你可能添加更多的参数。但如果一开始使用构造器或者静态工厂,当类发展到参数数量失控时才切换到 Builder 模式,那么过时的构造器或者静态工厂将会显得十分突兀。因此,通常最好一开始就是用 builder 。
总之,当待设计类的构造器或静态工厂具有多个参数时,Builder 模式会是个好的选择,特别是如果很多参数是可选的或是类型相同的。带有 builder 的客户端代码比带有重叠构造器的代码更容易阅读和编写,并且 builder 比 JavaBeans 要安全得多。
[Gamma95] Gamma, Erich, RichardHelm, Ralph Johnson, and John Vlissides. 1995. Design Patterns: Elements of Reusable Object-Oriented Software. Reading, MA: Addison-Wesley. ISBN: 0201633612.
翻译:Inno
校对:Angus