第2條:遇到多個構造器引數時要考慮用構建器
靜態工廠和構造器有個共同的侷限性:它們都不能很好地擴充套件到大量的可選引數。考慮用一個類表示包裝食品外面顯示的營養成份標籤。這些標籤中有幾個域是必需的:每份的含量、每罐的含量以及每份的卡路里,還有超過20個可選域:總脂肪量、飽和脂肪量、轉化脂肪、膽固醇、鈉等等。大多數產品都只有幾個可選域中會有非零的值。
對於這樣的類,應該用哪種構造器或者靜態方法來編寫呢?程式設計師一向習慣採用telescoping constructor模式,在這種模式下,你提供一個只有必要引數的構造器,第二個構造器有一個可選引數,第三個有兩個可選引數,依此類推,最後一個構造器包含所有可選引數。下面有個示例,為了簡單起見,它只顯示四個可選域:
// 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; // optional
private final int fat; // (g) optional
private final int sodium; // (mg) optional
private final int carbohydrate; // (g) 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。如果”僅僅”是這6個引數,看起來還不算太糟,問題是隨著引數數目的增加,它很快就失去了控制。
一句話:telescoping constructor模式可行,但是當有許多引數的時候,客戶端程式碼會很難編寫,並且仍然較難以閱讀。如果讀者想知道那些值是什麼意思,必須很仔細地數著這些引數來探個究竟。一長串相同的型別引數會導致一些微妙的錯誤。如果客戶端不小心顛倒了這兩種引數,編譯器也不會出錯,但是程式在執行時會出現錯誤的行為。
遇到許多構造器引數的時候,還有第二種代替辦法,即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; // " " " "
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模式自身有著很嚴重的缺點。因為構造過程被分到了幾個呼叫中,在構造過程中JavaBean可能處於不一致的狀態。類無法僅僅通過檢驗構造器引數的有效性來保證一致性。試圖使用處於不一致狀態的物件,將會導致失敗,這種失敗與包含錯誤的程式碼大相徑庭,因此它除錯起來十分困難。與此相關的另一點不足在於,JavaBeans模式防止了把類做成不可變的可能(見第15條),這就需要程式設計師付出格外努力來確保它的執行緒安全。
當物件的構造完成,並且不允許在解凍之前使用時,通過手工”凍結”物件,可以彌補這些不足,但是這種方式十分笨拙,在實踐中很少使用。此外,它甚至會在執行時導致錯誤,因為編譯器無法確保程式設計師會在使用之前先在物件上呼叫freeze方法。
幸運的是,還有第三種替代方法,既能保證像telescoping constructor模式那樣的安全性,也能保證像JavaBeans模式那麼好的的可讀性。這就是Builder模式[Gamma95,p.97]的一種形式。不直接生成想要的物件,而是讓客戶端利用所有必要的引數呼叫構造器(或者靜態工廠),得到一個builder物件。然後客戶端在builder物件上呼叫類似於setter的方法,來設定每個相關的可選引數。最後,客戶端呼叫無參的build方法來生成不可變的物件。這個builder是它構建的類的靜態成員類(見第22條)。下面就是它的示例:
// 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;
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.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 carbohydrate = 0;
private int sodium = 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 carbohydrate(int val) {
carbohydrate = val;
return this;
}
public Builder sodium(int val) {
sodium = val;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
}
注意NutritionFacts是不可變的,所有的預設引數值都單獨放在一個地方。builder的setter方法返回builder本身,以便可以把呼叫連結起來。下面就是客戶端程式碼:
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).
calories(100).sodium(35).carbohydrate(27).build();
這樣的客戶端程式碼很容易編寫,更為重要的是,易於閱讀。builder模式模擬了具名的可選引數,就像Ada和Python中的一樣。
builder像個構造器一樣,可以在它的引數中強加約束條件。build方法可以檢驗這些約束條件。將引數從builder拷貝到物件中之後,並在物件域而不是builder域(見第39條)中對它們進行檢驗,這個很重要。如果違反了任何約束條件,build方法就應該丟擲IllegalStateException(見第60條)。異常的詳細方法應該顯示出違反了哪個約束條件。
對多個引數強加約束條件的另一種方法是,用setter方法提供某個約束條件必須持有的所有引數。如果該約束條件沒有得到滿足,setter方法就會丟擲IllegalArgumentException。這有個好處,就是一旦傳遞了無效的引數,立即就會發現約束條件失敗,而不是等著呼叫build方法。
與構造器相比,builder的一點微略優勢在於,builder可以有多個varargs引數。構造器就像方法一樣,只能有一個varargs引數。因為builder利用單獨的方法來設定每個引數,你想要多少個varargs引數,它們就可以有多少個,直到每個setter方法都有一個varargs引數。
Builder模式十分靈活,可以利用單個builder構建多個物件。builder的引數可以在建立物件期間進行調整,也可以隨著不同的物件而改變。builder可以自動填充某些域,例如每次建立物件時自動增加序列號。
設定了引數的builder生成了一個很好的抽象工廠(Abstract Factory)[Gamma95,p.87]。換句話說,客戶端可以將這樣一個builder傳給方法,使該方法能夠為客戶端建立一個或者多個物件。要使用這種用法,需要有個型別來表示builder。如果使用的是發行版本1.5或者更新的版本,一個單獨的泛型(見第26條)就能滿足所有的builder,無論它們在構建哪種型別的物件:
// A builder for objects of type T
public interface Builder {
public T build();
}
注意,可以宣告NutritionFacts.Builder類來實現Builder 。
帶有Builder例項的方法通常利用有限制的萬用字元型別(bounded wildcard type,見第28條)來約束構建器的型別引數。例如,下面就是構建每個節點的方法,它利用一個客戶端提供的Builder例項構建樹:
Tree buildTree(Builder nodeBuilder) { ... }
Java中傳統的抽象工廠實現是Class物件,用newInstance方法充當build方法的一部分。這種用法隱含著許多問題。newInstance方法總是企圖呼叫類的無參構造器,這個構造器甚至可能根本不存在。如果類沒有可以訪問的無參構造器,你也不會收到編譯時錯誤。相反,客戶端程式碼必須在執行時處理InstantiationException或者IllegalAccessException,這樣既不雅觀也不方便。newInstance方法還會傳播由無參構造器丟擲的任何異常,即使newInstance缺乏相應的throws子句。換句話說,Class.newInstance破壞了編譯時的異常檢查。上面講過的Builder介面彌補了這些不足。
Builder模式的確也有它自身的不足。為了建立物件,必須先建立它的構建器。雖然建立構建器的開銷在實踐中可能不那麼明顯,但是在某些十分注重效能的情況下,可能就是個問題了。Builder模式還比telescoping constructor模式更加冗長,因此它只在有足夠引數的時候才使用,比如4個或者更多個引數。但是記住,將來你可能需要新增引數。如果一開始就使用構造器或者靜態工廠,等到類需要多個引數時才新增構建器,就會無法控制,那些過時的構造器或者靜態工廠顯得十分不協調。因此,通常最好一開始就使用構建器。
簡而言之,如果類的構造器或者靜態工廠中具有多個引數,設計這種類時,Builder模式就是種不錯的選擇,特別是當大多數引數都是可選的時候。與使用傳統的telescoping constructor模式相比,使用Builder模式的客戶端程式碼將更易於閱讀和編寫,builders也比JavaBeans更加安全。