1. 程式人生 > >Effective Java:Ch2_建立銷燬物件:Item2_當建構函式引數過多時考慮使用builder

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模式更安全。