Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼里方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。
條目2:當構造方法參數過多時使用builder模式
靜態工廠和構造方法都有一個限制:它們不能很好地擴展到很多可選參數的情景。請考慮一個代表包裝食品上的營養成分標簽的例子。這些標簽有幾個必需的屬性——每次建議的攝入量,每罐的份量和每份卡路里 ,以及超過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; // (per serving) optional
private final int fat; // (g/serving) optional
private final int sodium; // (mg/serving) optional
private final int carbohydrate; // (g/serving) 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值。 『只有』六個參數可能看起來並不那么糟糕,但隨着參數數量的增加,它會很快失控。
簡而言之,可伸縮構造方法模式是有效的,但是當有很多參數時,很難編寫客戶端代碼,而且很難讀懂它。讀者不知道這些值是什么意思,並且必須仔細地計算參數才能找到答案。一長串相同類型的參數可能會導致一些細微的bug。如果客戶端意外地顛倒了兩個這樣的參數,編譯器並不會抱怨,但是程序在運行時會出現錯誤行為(條目51)。
當在構造方法中遇到許多可選參數時,另一種選擇是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; // Required; no default value
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; }
}
這種模式沒有伸縮構造方法模式的缺點。有點冗長,但創建實例很容易,並且易於閱讀所生成的代碼:
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
不幸的是,JavaBeans模式本身有嚴重的缺陷。由於構造方法在多次調用中被分割,所以在構造過程中JavaBean可能處於不一致的狀態。該類沒有通過檢查構造參數參數的有效性來執行一致性的選項。在不一致的狀態下嘗試使用對象可能會導致與包含bug的代碼大相徑庭的錯誤,因此很難調試。一個相關的缺點是,JavaBeans模式排除了讓類不可變的可能性(條目17),並且需要在程序員的部分增加工作以確保線程安全。
當它的構造完成時,手動“凍結”對象,並且不允許它在解凍之前使用,可以減少這些缺點,但是這種變體在實踐中很難使用並且很少使用。 而且,在運行時會導致錯誤,因為編譯器無法確保程序員在使用對象之前調用freeze
方法。
幸運的是,還有第三種選擇,它結合了可伸縮構造方法模式的安全性和javabean模式的可讀性。 它是Builder模式[Gamma95]的一種形式。客戶端不直接調用所需的對象,而是調用構造方法(或靜態工廠),並使用所有必需的參數,並獲得一個builder對象。然后,客戶端調用builder對象的setter
相似方法來設置每個可選參數。最后,客戶端調用一個無參的build
方法來生成對象,該對象通常是不可變的。Builder通常是它所構建的類的一個靜態成員類(條目24)。以下是它在實踐中的示例:
// 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 NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
這個客戶端代碼很容易編寫,更重要的是易於閱讀。 Builder模式模擬Python和Scala中的命名可選參數。
為了簡潔起見,省略了有效性檢查。 要盡快檢測無效參數,檢查builder的構造方法和方法中的參數有效性。 在build
方法調用的構造方法中檢查包含多個參數的不變性。為了確保這些不變性不受攻擊,在從builder復制參數后對對象屬性進行檢查(條目 50)。 如果檢查失敗,則拋出IllegalArgumentException
異常(條目 72),其詳細消息指示哪些參數無效(條目 75)。
Builder模式非常適合類層次結構。 使用平行層次的builder,每個嵌套在相應的類中。 抽象類有抽象的builder; 具體的類有具體的builder。 例如,考慮代表各種比薩餅的根層次結構的抽象類:
// Builder pattern for class hierarchies
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
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();
// Subclasses must override this method to return "this"
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // See Item 50
}
}
請注意,Pizza.Builder
是一個帶有遞歸類型參數( recursive type parameter)(條目 30)的泛型類型。 這與抽象的self
方法一起,允許方法鏈在子類中正常工作,而不需要強制轉換。 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”的客戶端代碼基本上與簡單的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模式是一個不錯的選擇,特別是如果許多參數是可選的或相同類型的。客戶端代碼比使用伸縮構造方法(telescoping constructors)更容易讀寫,並且builder比JavaBeans更安全。