翻译
静态工厂方法和构造函数都有一个限制:递增的大量的可选参数场景他们没法适应。考虑这样一个场景:一个代表营养物质标签的类出现在一大堆食物上;这些标签有一些需要的字段,重量,单个容器的重量,单份的卡路里,更多超过20个可选的字段, 总脂肪,饱和脂肪,反式脂肪酸,胆固醇,NA等等,对一些可选的字段来说,很多有非0值,。
这种类你应该写哪种构造函数或者静态工厂方法?通常,程序员使用伸缩构造模式,你提供一个构造函数只有必须的参数,另外只有单个可选参数,有些有两个可选参数等等,最终在一个构造函数中拥有所有的参数。下面是它看起来的样子,对清酒,只有四个可选的字段;
public class NutritionFacts{
private final int serverSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public NutritiionFacts(int serverSize, int servings){
this(servingSize,servings,0);
}
public NutritiionFacts(int serverSize, int servings,int calories){
this(servingSize,servings,calories,0);
}
public NutritiionFacts(int serverSize, int servings,int calories,int fat){
this(servingSize,servings,calories,fat,0);
}
public NutritiionFacts(int serverSize, int servings,int calories,int fat,int sodium){
this(servingSize,servings,calories,fat,sodium,0);
}
public NutritiionFacts(int serverSize, int servings,int calories,int fat,int sodium,
int carbohydrate)
{
this.serverSize = serverSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbhydrate;
}
}
当你需要创建一个实例,你可以使用最短参数的构造函数,列举你需要设置的所有的参数;
NutritionFacts cocoCola = new NutritionFacts(240,8,100,0,35,27);
通常这个构造函数需要很多的你不想设置的参数;但是你被强制的传了一个值,这个场景下,你传了一个fat =0 ;只有6个参数看起来还不太糟糕,但是当参数剧增就失控了。
简而言之,伸缩构造模式可以工作,但是当参数增多的时候,客户端代码很难写,并且很难阅读;阅读者必须在意值代表的含义并且小心的清点数量。长列的不同类型的参数可以导致微妙的bug,如果客户端把两个参数翻转过来了,编译器不会抱怨,但是程序会运行不当,第二个可选的处理方式是采用JavaBean的方式,你先使用构造函数创建一个对象,然后调用set方法去设置每个你感兴趣的参数。
public class NutritionFacts {
private int serverSize=-1;
private int servings=-1;
private int calories = 0;
private int fat=0;
private int sodium =0 ;
private int carbohydrate = 0;
public NutritionFacts(){}
public void setServerSize(int serverSize) {
this.serverSize = serverSize;
}
public void setServings(int servings) {
this.servings = servings;
}
public void setCalories(int calories) {
this.calories = calories;
}
public void setFat(int fat) {
this.fat = fat;
}
public void setSodium(int sodium) {
this.sodium = sodium;
}
public void setCarbohydrate(int carbohydrate) {
this.carbohydrate = carbohydrate;
}
}
这种方式没有伸缩构造方式的缺点,它比较简单,一点点代码,创建一个对象,最终的代码很容易阅读;
public static void main(String[] args) {
NutritionFacts nutritionFacts = new NutritionFacts();
nutritionFacts.setServerSize(240);
nutritionFacts.setServings(8);
nutritionFacts.setCalories(100);
nutritionFacts.setSodium(35);
nutritionFacts.setCarbohydrate(27);
}
但是很不幸,javaBean的方式有很严重的缺点。因为构造函数被分割成了多次调用;JavaBean在构造过程中可能处于不一致的状态。这个类没有一个可选的仅仅通过检查构造参数的合法性来确保一致性。尝试使用一个当它处于一个不一致状态的对象可能导致失败,相比移除包含bug的代码 ,更增加调试的难度。 一个关联的缺点是JavaBean模式使得类可变,增加程序员保证线程安全的的工作。
可以消除这些缺点通过手动冻结对象,当它的构造函数完成前不允许它使用直到解冻。但是这个处理方式应用不广泛并且很少在实际中使用。更进一步,可能引起运行时错误,因为编译器无法保证程序员在使用它之前调用对象的冻结方法。幸运的是,有第三种选择,结合伸缩构造的安全性和JavaBean方式的可读性,它是建造者模式。
不是客户端使用所有的参数调用构造方法或者静态工厂方法直接创建想要的对象,得到一个建造者对象,然后客户端调用类似于Setter方法,而是在builder对象上设置想要的参数,最后,客户端调用无参数的build方法去生成对象,它通常是不可变的,Builder通常是一个静态类,下面是实际看起来的样子。
public class NutritionFacts2 {
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 NutritionFacts2 build() {
return new NutritionFacts2(this);
}
}
private NutritionFacts2(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
NutritionFacts2类是不可变的,所有的参数默认值都是在一个地方,Builder的setter方法返回Builder自己,所以调用可以形成链条,类似于流API,客户端代码如下:
public static void main(String[] args) {
NutritionFacts2 cocaCola = new NutritionFacts2.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
}
客户端代码可读性,可写性都很好,建造者模式模仿了python和scala的可选参数。为了简洁省去了合法性检查,为了防备非法参数,检查参数的合法性放在Builder的构造函数和方法中,检查构造方法的多个参数的变量值放在build方法。为了确保不变性攻击,在复制Builder的参数到对象之后然后再对象的字段上做检查。如果检查失败,抛出IllegealArgumentException, 详细信息记录那个参数是非法的;
建造者模式非常适合层次结构分明的类;使用并发层级的建造者 ,每一个嵌套在相应的类中,抽象类有抽象的建造者,具体类有具体的建造者,举个例子, 有个根级别的抽象类代表不同种类的pizza ;
package com.effective;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
/**
* @author <a href="mailto:fuchun.li@lifesense.com">fuchun.li</a>
* @description todo
* @date 2019年01月22日 2:30 PM
* @Copyright (c) 2018, lifesense.com
*/
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();
protected abstract T self();
}
Pizza(Builder<?> builder){
toppings = builder.toppings.clone();
}
}
* 注意,Pizza.Builder是一个通过递归的类型参数的泛型,也包含了抽象的self方法,
* 允许方法链在子类中适当的工作,而不用进行类型转换,这些工作基于这个事实:
* java缺乏自类型,这里有两个具体的类子类,一个代表标准的纽约风格披萨,
* 另外一个是馅饼风味,前者有必须的size字段,后者需要你指出是否需要有酱料;
package com.effective;
import java.util.Objects;
/**
* @author <a href="mailto:fuchun.li@lifesense.com">fuchun.li</a>
* @description todo
* @date 2019年01月22日 2:42 PM
* @Copyright (c) 2018, lifesense.com
*/
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
NyPizza build() {
return new NyPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
}
package com.effective;
/**
* @author <a href="mailto:fuchun.li@lifesense.com">fuchun.li</a>
* @description todo
* @date 2019年01月22日 2:46 PM
* @Copyright (c) 2018, lifesense.com
*/
public class Calzone extends Pizza {
private final boolean sauceInside;
public static class Builder extends Pizza.Builder<Builder>{
private boolean sauceInside = false;
public Builder sauceInside(){
this.sauceInside = true;
return this;
}
@Override
Calzone build() {
return new Calzone(this);
}
@Override
protected Builder self() {
return this;
}
}
Calzone(Builder builder) {
super(builder);
sauceInside = builder.sauceInside;
}
public static void main(String[] args) {
NyPizza pizza = new NyPizza.Builder(NyPizza.Size.SMALL)
.addTopping(Topping.SAUSAGE)
.addTopping(Topping.ONION)
.build();
Calzone calzone = new Builder()
.addTopping(Topping.HAM)
.sauceInside()
.build();
}
}
- 注意: 每个子类的build方法申明为返回准确的子类,NyPizza.Builder 返回NyPizza , 而,
- Calzone.Builder返回Calzone ; 这个技巧, 在子类方法中返回返回类型的子类被申明在父类中,
- 就是闻名的 协变返回类型 ; 它允许 客户端使用这些构造器而不需要类型转换,
- 客户端代码对这种层级建造者基本是相同的,就像NutritionFacts建造者,这个例子,
- 客户端代码展示了另外一个简洁枚举常量的静态引入。
一个建造者对比构造函数的小优势是:建造者可以有多个可变参数,因为每个参数被指定到了自己的方法,或者,建造器可以多次调用方法然后把参数聚合到一个字段,就像之前展示的addTopping方法。
建造器模式非常灵活,一个简单的建造器可以重复的建造多个对象,建造器的参数可以放到调用build方法之间,来区分创建的对象,一个建造器可以自动在创建对象的时候,填充一些字段,比如当对象每次被创建的时候递增的序列号。
建造者模式同样也有缺点;为了创建对象,你首先必须创建builder,而创建builder的代价在实际中不可能无法察觉。在性能非常关键的场景可能是一个问题,同样,建造者模式更冗长相比于伸缩构造方式,所以它用在只有足够的多参数值得使用它的时候,(超过四个的参数),但是记住你可能需要在未来添加更多的参数,但是如果你一开始就是用构造器或者静态工厂方法,当你的类遭遇过多参数失控的时候,切换到了建造者模式,淘汰的构造函数和静态工厂方法将会像疼痛的拇指一样伸展出来,所以,通常一开始就使用建造者是更好的选择。
总结:当你设计一个有超过5个参数的类,建造者模式是一个好的选择,特别是很多的参数是可选的或者是相同的类型,相比于伸缩构造方法,客户端代码非常容易阅读,非常容易书写;相比于JavaBean方式,它更安全。
快速记忆
我的看法
建造器去创建对象的优点是,可写性好,可读性好,好扩展,线程安全,特别适合层次分明的类进行建造;缺点是代码冗长,性能敏感的场景需要考量,建议在参数超过四个以上,可变参数比较多的时候使用。