Effective Java:Ch2_建立銷燬物件:Item2_當建構函式引數過多時考慮使用builder
靜態工廠和建構函式都有一個限制:可選引數數量很大時,他們都不能很好地擴充套件。考慮一下這個例子:用一個類來表示袋裝食品上的營養成分標籤,這些標籤有幾個必選欄位:每份的含量、每罐的份數、每份的卡路里;還有超過20個可選欄位:總脂肪含量、飽和脂肪含量、轉化脂肪含量、膽固醇含量、鈉含量等等。大多數產品只有少數幾個可選欄位是非零值。
你將為這種類編寫怎樣的建構函式或者靜態工廠呢?程式設計師習慣上會使用telescoping constructor模式,提供一個值包含必選引數的建構函式,以及一個包含一個可選引數的建構函式、一個包含二個可選引數的建構函式、等等,最後一個建構函式包含所有可選引數。【例】就像下面的示例那樣,簡單起見,我們只展示4個可選引數的情況。
// 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模式雖然可行,但是當引數過多時就很難編寫客戶端程式碼,而且更加難以閱讀。讀者會奇怪這些引數都表示什麼含義,必須仔細地數著引數才能弄明白。一長串型別相同的引數會導致微妙的bug。如果客戶端意外弄反了兩個引數的順序,編譯器不會報錯,但是程式在執行時會出現錯誤的行為(Item40)。
遇到多個建構函式引數的第二種方法是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可能處於不一致狀態。類不能通過檢查建構函式引數的有效性來保證一致性。如果嘗試使用處於不一致狀態的物件,就會導致錯誤,而且產生這些錯誤的程式碼大相徑庭,導致很難除錯。相關的另一個缺點是,JavaBean模式阻止了把類變為“不可變”(Item15)的可能性,而且要求程式設計師付出額外努力來保證執行緒安全。
有一種辦法可以降低這個缺點:當物件構建完成後,手工“凍結”該物件,並且不允許使用未凍結的物件。不過這種方法不靈便,在實踐中很少使用。更重要的是,由於編譯器不能確保程式設計師在使用物件前先呼叫其凍結方法,所以這種方法可能導致執行時錯誤。
幸運的是,還有第三種方法結合了telescoping constructor模式的安全性以及JavaBeans模式的可讀性。這是Builder模式的一種形式。客戶端並不直接構造需要的物件,而是先呼叫一個包含所有必選引數的建構函式(或靜態工廠),得到一個builder object;然後,客戶端在該builder object上呼叫類似setter的方法來設定各個感興趣的可選引數;最後,客戶端呼叫無引數的build方法生成物件(不可變物件)。——build相當於凍結方法
builder是所要構建的類的一個靜態成員類(Item22)。【例】如下是它的示例:
//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{
//必選引數
private final int servingSize;
private final int servings;
//可選引數
private final int calories = 0;
private final int fat = 0;
private final int carbohydrate = 0;
private final int sodium = 0;
//Builder建構函式設定所有必選引數
public Builder(int servingSize, int servings){
this.servingSize = servingSize;
this.servings = servings;
}
//Builder類setter方法設定可選引數
public Builder calories(int v){
this.calories = v;
return this;
}
public Builder fat(int v){
this.fat = v;
return this;
}
public Builder carbohydrate(int v){
this.carbohydrate = v;
return this;
}
public Builder sodium(int v){
this.sodium = v;
return this;
}
//builde()返回需要構建的物件
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方法),並且所有的引數值得預設值都放在一個地方。builder的setter方法返回builder自身,所以setter的呼叫可以連結起來。客戶端程式碼就像下面這樣:
NutritionFacts coca = new NutritionFacts.Builder(240,8)
.calories(100).sodium(35).build();
這種客戶端程式碼易於編寫,而且更重要的是易於閱讀。Builder模式模擬了Ada和Python中的具名可選方法(named optional parameters)。
builder 就像構造方法一樣,能在其引數上強加約束。build方法能檢查這些約束。有一點很重要:要在從builder向物件中拷貝引數完成後檢查這些約束,而且要在物件域上檢查,而不是builder域上檢查(Item39)。如果違反了約束,build方法應該跑出IllegalStateException(Item60),該exception的詳細資訊應該能標明違反了哪個約束(Item63)。 對多個引數強加約束的另外一種方法是,在setter方法中包含約束要求的所有引數。如果約束不滿足,則setter丟擲IllegalArgumentException。這種方法的優點是能在非法引數傳入時及時發現約束失敗,而不必等到呼叫build()才會發現。
builder比建構函式的另一個小優點是它能有多個varargs方法引數,而建構函式與普通方法一樣只能有一個varargs方法引數。由於builder用獨立的方法來設定每個引數,所以你想要多少個varargs方法引數,他們就能有多少個varargs方法引數,最多可以每個setter都有一個varargs方法引數。
Builder模式是很靈活的,一個builder可用來構建多個物件。builder的引數可以再建立物件過程中進行調整以便改變物件。Builder可以自動填寫某些欄位,例如每次建立物件時自動增加的序列號欄位。
設定了引數的builder是一個很好的抽象工廠(Abstract Factory),換句話說,客戶端可以將這種builder傳給一個方法,然後該方法為客戶端建立一個或者多個物件。如果要這麼做,你需要有一個型別來代表這個builder,如果你在用JDK1.5或之後的版本,那一個泛型型別(Item26)就能滿足所有的builder,而不需考慮他們構建的物件型別是什麼。
//A builder for objects of type T
public interface Builder<T>{
public T build();
}
注意NutritionFacts.Builder類需要實現介面Builder<NutritionFacts>。
帶有Builder示例的方法通常限制builder的型別引數使用一種有限制的萬用字元型別(bounded wildcard type, Item28)。【例】例如,下面是一個構建Tree示例的方法,使用客戶端提供的Builder示例來建立每個節點。
Tree buildTree(Builder<? extens Node> nodeBuilder){...}
【例】Java中Class物件是抽象工廠的一個典型實現,其newInstance()方法充當部分build()方法。
private T newInstance0()
throws InstantiationException, IllegalAccessException
{
...
Constructor<T> tmpConstructor = cachedConstructor;
// Security check (same as in java.lang.reflect.Constructor)
int modifiers = tmpConstructor.getModifiers();
if (!Reflection.quickCheckMemberAccess(this, modifiers)) {
Class caller = Reflection.getCallerClass(3);
if (newInstanceCallerCache != caller) {
Reflection.ensureMemberAccess(caller, this, null, modifiers);
newInstanceCallerCache = caller;
}
}
// Run constructor
try {
return tmpConstructor.newInstance((Object[])null);
} catch (InvocationTargetException e) {
Unsafe.getUnsafe().throwException(e.getTargetException());
// Not reached
return null;
}
}
這種用法隱含著許多問題,newInstance()方法總是嘗試去呼叫類的無引數建構函式,而這個建構函式也許根本不存在。這種情況下你根本不會看到編譯期錯誤,而在執行時客戶端程式碼必須處理InstantiationException或者IllegalAccessException,這樣既不美觀也不方便。同樣,newInstance()方法會傳播無引數建構函式的所有異常,即便newInstance()沒有寫對應的throws字句。換句話說,Class.newInstance破壞了編譯期異常檢測。而上文提到的Builder介面,則改正了這些不足。Builder模式當然也有缺點。1)為了建立一個物件,你必須首先建立它的builder。雖然建立builder的開銷在實踐中可能不那麼明顯,但在某些注重效能的情況下可能會有問題。2)Builder模式比telescoping constructor模式要更冗長,所以只有當引數足夠多時才應該使用它,比如4個或更多。不過,要記住你在將來也許會想增加引數,如果你一開始就使用建構函式或靜態工廠,那當引數數目失控時就得增加builder了,此時被廢棄的建構函式或靜態工廠就會像個怪物一樣杵在那兒。所以,通常最好一開始就使用builder。
總之,當所設計的類的建構函式或靜態工廠擁有過多引數時,Builder模式是個不錯的選擇,尤其當大多數引數時可選的時候。與傳統的telescoping constructor模式相比,使用builder模式客戶端程式碼會更加易讀更加易寫;同時builder比JavaBeans模式更安全。