Character 2 Creating and Destroying objects
構造方法參數較多時考慮builder模式
問題場景
靜態工廠和構造器共有一個限制:在可選參數很多的情況下,這兩個都不能夠很好的scale。
考慮一個代表在袋裝食物上的營養標籤的類,這些標籤有一些必須要求的field,建議攝入量,每罐的量以及每份的卡路里,還有超過20個可選的field,總脂肪、飽和脂肪、反式脂肪、膽固醇、鈉等等。大多數產品只包含這些可選字段中的少數,且具有非零值(大部分爲null)
對於上面所述的class,你會採用什麼樣的構造器或者靜態工廠方法呢?
方法一:telescoping constructor
傳統上,程序員使用 telescope constructor (可伸縮模式),也就是提供一個只有必須參數的構造方法函數,然後提供只有一個可選參數的構造函數,只有兩個可選參數的構造函數並以此類推一直到包含所有可選參數的構造函數。實際上如下所示,爲了簡單,這裏只顯示四個可選屬性:
// telscoping constructor pattern -- no scale well
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 NutritionFacrs(int servingSize, int servings, int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionsFacts(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。只有六個參數看起來不那麼糟糕,但是隨着參數的增加就會失控。
簡而言之,這種telescopeing constructor 模式是有效的,但是當有很多參數的時候,是很難寫出客戶端代碼並且很難讀。閱讀者看到這樣的代碼之後,只剩下迷惑這些的意義只能通過數參數的個數明白意思。一長串相同類型的參數可能會導致一些bug,如果客戶端不小心寫反了兩個參數,編譯器不會報錯,但是程序會出現非預期的行爲(見51條)
方法二:JavaBeans
當面對構造方法中有很多可選參數時,第二種方法可以考慮JavaBeans模式,在這種模式中,調用沒有參數的構造方法去創建一個對象,然後調用setter方法來設置每個必須參數和可選參數:
//JavaBeans pattern - allow inconsistency, mandates mutability
public class NutritionFacts {
// parameters initialized to default values(if any)
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() {}
// 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;}
}
這種模式不存在上述telescoping constructor模式的缺點。有點冗長,但是創建實例很簡單,並且易於閱讀代碼。
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
不幸的是,JavaBeans模式本身有很嚴重的缺點,由於構造方法被分割成了多次調用,所以在構造過程中,JavaBeans可能處於不一致狀態。這個類通過檢查構造函數參數的有效性並不能加強一致性。在不一致的狀態下調用對象可能導致失敗,這些錯誤和平常代碼的bug很不同,很難調試。相關的另一個缺點就是JavaBeans模式排除了讓類不可變的可能性(可見17),並且爲了確保線程安全需要增加工作。
通過在對象構建完成時freezing對象,並且不允許解凍之前使用,可以減少上述缺點,但是這種方法在實際中很難使用。同時,更容易引起錯誤,因爲編譯器無法確保程序員會在使用對象之前調用freeze方法。
方法三:builder
幸運的是這裏還有第三種方法,結合了telescopeing constructor的安全性以及JavaBeans的可讀性。那就是builder (Gramma95)模式。客戶端不直接構造對象,而是調用一個包含所有必須參數構造方法(或靜態工廠)獲得一個builder對象。然後客戶端調用builder對象的與setter相似的方法去設置可選參數。最後,客戶端調用無參數的build方法去產生一個不可改變的對象。builder是典型的靜態成員類(item24).下面是示例:
// 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 類是不可變的,所有的是參數默認值都放在一個地方。Builder的setter方法返回的是builder本身,這樣就可以鏈式調用,從而行程一個流程的API,客戶端的代碼如下:
NutritionFacts cocaCola = new NutritiionFacts.Builder(240,8).calories(100).sodium(35).carbohydrate(27).build();
上述客戶端的代碼很容易編寫,更重要的是易於閱讀。採用Builder模式模擬的可選參數在python和scala中發現。
爲了簡潔,有效性檢查被省略。爲了儘快檢測出無效參數,在builder的構造方法和函數中檢查參數有效性,包括多個參數的不變性。爲了確保這些不變性不受到攻擊,在從builder中copy參數後對對象屬性進行check(item 50).如果檢查失敗,就報出IllegalArgumentException(item 72),詳細信息表明那些參數無效(item 75)。
Builder模式非常適合層次結構。使用平行層次的builder, 每個builder嵌套在相應的類。抽象類有抽象的builder,具體類有具體的builder。例如考慮一個代表各種披薩的根層次結構的抽象類:
// builder pattern for class hierarchies -- more
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()
//subclass must override this method return "this"
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // see item 50
}
}
注意到Pizza.Builder是一個遞歸參數(item 30)的泛型類型。這和抽象的self方法一起,允許方法鏈在子類中可以work,不需要強制類型轉換。Java缺乏自我類型的這種變通解法被稱爲模擬自我類型(simulated-self-type)。
這裏有兩個具體的Pizza類,一個是標準的紐約風格披薩,另一個是乳酪披薩。前者需要尺寸參數,後者允許指定醬汁是否在裏面或者外面。
import java.util.Objects;
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;
}
}
請注意,每個子類 builder 中的 build 方法被聲明爲返回正確的子類:NyPizza.Builder 的 build 方法返回 NyPizza,而 Calzone.Builder 中的 build 方法返回 Calzone。 這種技術,其一個子類的方法被聲明爲返回在超類中聲明的返回類型的子類型,稱爲協變返回類型(covariant return typing)。 它允許客戶端使用這些 builder,而不需要強制轉換。
這些分層 builder(hierarchical builders)的客戶端代碼基本上與簡單的 NutritionFacts builder 的代碼相同。爲了簡潔起見,下面顯示的示例客戶端代碼假設枚舉常量的靜態導入:
NyPizza pizza = new NyPizza.Builder(SMALL)
.addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
.addTopping(HAM).sauceInside().build();
builder 對構造方法的一個微小的優勢是,builder 可以有多個可變參數,因爲每個參數都是在它自己的方法中指定的。或者,builder 可以將傳遞給多個調用的參數聚合到單個屬性中,如前面的 addTopping 方法所演示的那樣。
Builder 模式非常靈活。 單個 builder 可以重複使用來構建多個對象。 builder 的參數可以在構建方法的調用之間進行調整,以改變創建的對象。 builder 可以在創建對象時自動填充一些屬性,例如每次創建對象時增加的序列號。
Builder 模式也有缺點。爲了創建對象,首先必須創建它的 builder。雖然創建這個 builder 的成本在實踐中不太可能被注意到,但在看中性能的場合下這可能就是一個問題。而且,builder 模式比伸縮構造方法模式更冗長,因此只有在有足夠的參數時才值得使用它,比如四個或更多。但是請記住,你可能在以後會想要添加更多的參數。但是,如果你一開始是使用的構造方法或靜態工廠,當類演化到參數數量失控的時候再轉到 Builder 模式,過時的構造方法或靜態工廠就會面臨尷尬的處境。因此,通常最好從一開始就創建一個 builder。
總而言之,當設計類的構造方法或靜態工廠的參數超過幾個時,Builder 模式是一個不錯的選擇,特別是許多參數是可選的或相同類型的。builder 模式客戶端代碼比使用伸縮構造方法(telescoping constructors)更容易讀寫,並且 builder 模式比 JavaBeans 更安全。