静态工厂和构造函数有一个共同的限制:对于大量可选参数它们都不能很好的扩展。考虑这样一种情况:用一个类来表示包装食品上的营养成分标签。这些标签有几个字段是必须的——每份含量、每罐含量(份数)、每份的卡路里,二十个以上的可选字段——总脂肪量、饱和脂肪量、转化脂肪、胆固醇、钠等等。大多数产品中这些可选字段中的仅有几个是非零值。
你应该为这样的一个类写什么样的构造函数或静态工厂?习惯上,程序员使用重载构造函数模式,在这种模式中只给第一个构造函数提供必要的参数,给第二个构造函数提供一个可选参数,给第三个构造函数提供两个可选参数,以此类推,最后的构造函数具有所有的可选参数。下面是一个实践中的例子。为了简便,只显示了四个可选字段:
// Telescoping constructor pattern - does not scale well!
public class NutritionFacts {
private final int servingSize; // (mL) required
private final int servings; // (per container) required
private final int calories; // (per serving) optional
private final int fat; // (g/serving) optional
private final int sodium; // (mg/serving) optional
private final int carbohydrate; // (g/serving) optional
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。 使用“仅”六个参数可能看起来不那么糟糕,但随着参数数量的增加,它很快就会失控。
简而言之,重载构造函数模式有效,但是当有许多参数时很难编写客户端代码,并且更难以阅读它。 读者不知道所有这些值是什么意思,必须仔细计算参数才能找到答案。 相同类型参数的长序列可能会导致细微的错误。 如果客户端意外地反转了两个这样的参数,编译器就不会报错,但程序在运行时会出错。
当您在构造函数中遇到许多可选参数时,第二种方法是JavaBeans模式,您可以在其中调用无参数构造函数来创建对象,然后调用setter方法来设置每个必需参数和每个感兴趣的可选参数:
// JavaBeans Pattern - allows inconsistency, mandates mutability
public class NutritionFacts {
// Parameters initialized to default values (if any)
private int servingSize = -1; // Required; no default value
private int servings = -1; // Required; no default value
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public NutritionFacts() { }
// Setters
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模式排除了使类不可变的可能性,并且需要程序员付出额外的努力以确保线程安全。
可以通过在对象构造完成时手动“冷冻”对象并且不允许其在冷冻之前使用来减少这些缺点,但是这种方式是笨重的,并且在实践中很少使用。 此外,它可能在运行时导致错误,因为编译器无法确保程序员在使用它之前调用对象上的冻结方法。
幸运的是,还有第三种方法将重载构造函数模式的安全性与JavaBeans模式的可读性相结合。它就是构建器模式。
它不直接构建需要的对象,而是使用客户端所有必需参数调用构造函数(或静态工厂),并获取构建器对象。 然后客户端在构建器对象上调用类似setter的方法来设置每个感兴趣的可选参数。 最后,客户端调用无参数的构造方法来生成对象,该对象通常是不可变的。 构建器通常是它构建的类的静态成员类。 以下是它在实践中的表现:
// Builder 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 static class Builder {
// Required parameters
private final int servingSize;
private final int servings;
// Optional parameters - initialized to default values
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类是不可变的,所有参数默认值都在一个地方。 构建器的setter方法返回构建器本身,以便可以链接调用,从而生成流畅的API。 以下是客户端代码的外观:
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
此客户端代码易于编写,更重要的是易于阅读。 Builder模式模拟Python和Scala中的命名可选参数。 为简洁起见,省略了有效性检查。 要尽快检测无效参数,请在构建器的构造函数和方法中检查参数有效性。 检查构建方法调用的构造函数中涉及多个参数的不变量。 要确保这些不变量不受攻击,请在从构建器复制参数后对对象字段执行检查(第50项)。 如果检查失败,则抛出IllegalArgumentException(Item 72),其详细消息指示哪些参数无效(Item 75)。
Builder模式非常适合类层次结构。 使用并行的构建器层次结构,每个构建器都嵌套在相应的类中。 抽象类有抽象构建器; 例如,考虑表示各种披萨的层次结构根的抽象类
// Builder pattern for class hierarchies
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();
// Subclasses must override this method to return "this"
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // See Item 50
}
}
请注意,Pizza.Builder是具有递归类型参数的通用类型(第30项)。 这与抽象自我方法一起允许方法链在子类中正常工作,而不需要强制转换。 Java缺乏自我类型这一事实的解决方法被称为模拟自我类型习语。
这是Pizza的两个具体子类,其中一个代表标准的纽约式披萨,另一个代表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; // Default
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;
}
}
请注意,每个子类的构建器中的构建方法被声明为返回正确的子类:NyPizza.Builder的构建方法返回NyPizza,而Calzone.Builder中的构建方法返回Calzone。 这种技术,其中声明子类方法返回在超类中声明的返回类型的子类型,称为协变返回类型。 它允许客户使用这些构建器而无需进行铸造。 这些“分层构建器”的客户端代码基本上与简单的NutritionFacts构建器的代码相同。 为简洁起见,下面显示的示例客户端代码假定枚举常量上的静态导入:
NyPizza pizza = new NyPizza.Builder(SMALL)
.addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
.addTopping(HAM).sauceInside().build();
构建器相对于构造函数的一个小优点是构建器可以具有多个varargs参数,因为每个参数都在其自己的方法中指定。 或者,构建器可以将传递给方法的多个调用的参数聚合到单个字段中,如之前的addTopping方法中所示。
Builder模式非常灵活。 可以重复使用单个构建器来构建多个对象。 可以在构建方法的调用之间调整构建器的参数,以改变创建的对象。 构建器可以在创建对象时自动填充某些字段,例如每次创建对象时增加的序列号。
Builder模式也有缺点。 要创建对象,必须先创建其构建器。 虽然在实践中创建此构建器的成本不太可能明显,但在性能关键的情况下可能会出现问题。 此外,Builder模式比伸缩构造函数模式更冗长,因此只有在有足够的参数使其值得(例如四个或更多)时才应使用它。 但请记住,您可能希望将来添加更多参数。 但是如果你从构造函数或静态工厂开始并在类进化到参数数量失控的地方时切换到构建器,那么过时的构造函数或静态工厂就像拇指一样伸出来。 因此,首先从构造器开始通常会更好。
总之,在设计构造函数或静态工厂具有多个参数的类时,Builder模式是一个不错的选择,特别是如果许多参数是可选的或类型相同的话。 与使用重叠构造函数相比,客户端代码更易于使用构建器进行读写,构建器比JavaBeans更安全。
网友评论