Effective Java理解筆記系列-第2條-何時考慮用構建器?
為什么寫這系列博客?
在閱讀《Effective Java》這本書時,我發現有許多地方需要仔細認真地慢慢閱讀并且在必要時查閱相關資料才能徹底搞懂,相信有些讀者在閱讀此書時也有類似感受;同時,在解決疑惑的過程中,還存在著有些內容不容易查找、查找到的解答質量不高等問題,于是我決定把我閱讀此書收獲到的東西寫成博客,期望能夠解答某些讀者之困惑。
為了方便大家閱讀時按章節查找,我會按照原書籍寫作順序來劃分博客章節。博客中主要包含以下內容:
- 我對原文內容的理解(再加工)
- 一些補充知識(需要理解這些知識才能真正理解該章節內容)
何時考慮用構建器?
類中有幾個必選參數,且存在大量可選參數時。
- 大量指至少有4個
- 可選指大部分實例只在某幾個可選域存在非零值,其他都是零。
如:
public class NutritionFacts{
private final int servingSize;//每份含量,必選
private final int servings;//每罐含量,必選
private final int calories;//卡路里,可選
private final int fat;//總脂肪含量,可選
private final int saturatedFat;//飽和脂肪含量,可選
private final int sodium;//鈉含量,可選
private final int cholesterol;//膽固醇,可選
}
有以下幾種解決方案:
重疊構造器
設置多個構造方法,并依次增加入參數量,構造方法內部自動調用參數多一個的構造方法,直到調用到最后一個全參數的構造方法。
代碼如下(我又額外增加了 飽和脂肪含量 和 膽固醇含量 這兩個域):
public class NutritionFacts{
private final int servingSize;//每份含量,必選
private final int servings;//每罐含量,必選
private final int calories;//卡路里,可選
private final int fat;//總脂肪含量,可選
private final int saturatedFat;//飽和脂肪含量,可選
private final int sodium;//鈉含量,可選
private final int cholesterol;//膽固醇含量,可選
public NutritionFacts(int servingService,int servings){
this(servingService,servings,0);
}
public NutritionFacts(int servingService,int servings,int calories){
this(servingService,servings,calories,0);
}
public NutritionFacts(int servingService,int servings,int calories,int fat){
this(servingService,servings,calories,fat,0);
}
public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat){
this(servingService,servings,calories,fat,saturatedFat,0);
}
public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat,int sodium){
this(servingService,servings,calories,fat,saturatedFat,sodium,0);
}
public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat,int sodium,int cholesterol){
this.servingService = servingService;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.saturatedFat = saturatedFat;
this.sodium = sodium;
this.cholesterol = cholesterol;
}
}
使用時,選擇包含想傳遞參數的最短的那個構造器就可以了。如:我想傳遞calories和fat字段,那么下面的構造函數即可,其他可選參數會自動被設置為0。
NutritionFacts test = new NutritionFacts(240,8,5,4);
缺點如下:
- 冗余傳參
假如,客戶端只需要設置最后兩個可選參數sodium和cholesterol,但是卻需要調用最后一個構造方法,并將所有其他可選參數傳入0。
new NutritionFacts(240,8,0,0,0,240,25);
這樣的方式需要客戶端傳入并不需要設置的參數,代碼冗余不優雅。
- 類型相同的相鄰參數易傳錯
如果搞混了兩個有相同數據類型又緊挨著的可選域的值,編譯時不會出錯,但運行時會出現錯誤行為。
new NutritionFacts(240,8,0,240,50,0,0);//本來想傳入這種
new NutritionFacts(240,8,0,50,240,0,0);//實際卻傳入這種
- 編寫代碼和閱讀代碼均須數數(未使用IDEA時)
編寫代碼時需要通過數數來確定傳入的參數在第一個,同理,閱讀代碼時也需要數數來確定到底傳入了哪些可選參數。
new NutritionFacts(240,8,0,1,0,240,0);
如果使用了IDEA,則數數問題則可以解決:IDEA會在值前提示我們是哪個域:
//"host:"和"port:"是idea添加的提示
Socket client = new Socket(host:"127.0.0.1", port:6666);
這里補充一個基礎知識:
有些人會疑惑,給可選域設置一個初始值0不就可以了嗎,這樣就不會出現冗余傳參的問題。但實際上,這種寫法是無法通過編譯的,因為final修飾的實例域的初始化器和初始化代碼塊是優先于構造函數執行的(初始化器指 用 = 直接賦值,初始化代碼塊指用大括號括起來的 各實例域 = 賦值的代碼),final修飾的實例域在初始化器初始化后,就不能再通過構造函數進行修改了,所以設置的初始化值無效,而且也達不到后續改變需要的可選參數為非0的目的。
繼續思考,那么不設置為final域,這樣就可以設置默認的初始化值了,這樣就引出了下一種方式,JavaBean方式。
public class NutritionFacts{
private final int calories = 0;//卡路里,可選,初始化為0,省略其他實例域
//省略其他構造函數
public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat,int sodium,int cholesterol){
this.servingSize = servingService;
this.servings = servings;
this.calories = calories;//這行編譯器會報錯
this.fat = fat;
this.saturatedFat = saturatedFat;
this.sodium = sodium;
this.cholesterol = cholesterol;
}
}
JavaBeans方式
先創建對象,再調用set方法賦值。
代碼如下:
public class NutritionFacts{
private int servingSize;//每份含量,必選,通過構造函數設置
private int servings;//每罐含量,必選,通過構造函數設置
private int calories;//卡路里,可選
private int fat;//總脂肪含量,可選
private int saturatedFat;//飽和脂肪含量,可選
private int sodium;//鈉含量,可選,
private int cholesterol;//膽固醇含量,可選
public NutritionFacts(int servingSize,int servings){
this.servingSize = servingSize;
this.servings = servings;
}
public void setcalories(int calories){
this.calories = calories;
}
//以下set函數省略,同上
}
使用時,先創建對象,再依次調用set方法設置需要設置的值即可。
這種方式因為可以按需設置了,所以不僅解決了代碼閱讀和編寫時數參數的問題,還解決了冗余參數的問題;但是卻使類從不可變類變成了可變類(因為提供了set函數),可能會帶來線程安全問題。
原書中還提到了一個缺點:
遺憾的是,JavaBeans模式自身有著很嚴重的缺點。因為構造過程被分到了幾個調用中,在構造過程中JavaBeans可能處于不一致狀態。類無法僅僅通過檢驗構造器參數的有效性來保證一致性。
“JavaBeans可能處于不一致狀態”是什么意思呢?
我認為,理解這句話需要先理解它后面這句話,即“類無法僅僅通過檢驗構造器參數的有效性來保證一致性”:
這句話其實給出了作者認為的一致性的含義,即,所有參數都校驗通過所創建的對象就是符合一致性的。那么怎樣做參數校驗?作者也給出了答案,即“僅僅通過檢驗構造器參數”,意思是,通過構造器方式設置可選參數時,通過構造器這一個方法做參數校驗即可,但是JavaBeans模式需要調用多個set方法,如果在set函數中的某些方法遺漏了參數校驗代碼,那么創建出的對象會出現某個或某幾個字段的值不符合規則,但是其他值卻符合規則的情況,此種情況即是不一致。
所以解決不一致的方式就是需要在set方法中加入參數校驗代碼,保證當某個傳遞進來的參數不符合規則時可以立即報錯。
雖然不一致問題可以解決,但是從不可變類變成可變類這個問題卻無法解決。
構建器模式
通過一個構建器類,先設置參數值,最后再創建對象。
public class NutritionFacts{
private final int servingSize;//每份含量,必選
private final int servings;//每罐含量,必選
private final int calories;//卡路里,可選
private final int fat;//總脂肪含量,可選
private final int saturatedFat;//飽和脂肪含量,可選
private final int sodium;//鈉含量,可選
private final int cholesterol;//膽固醇含量,可選
public static class NutritionFactsBuilder{
//注意:這里的final并不是一定需要的
//寫上final,我認為可以在編程時讓編譯器幫助我們檢查是否初始化
private final int servingSize;//每份含量,必選
private final int servings;//每罐含量,必選
//注意:這里的設置初始值為0也不是必要的,但是可以增加代碼可讀性。
//了解Java的人會清楚這里會設置為默認值0
//但是這樣寫可以讓不了解Java的人也清楚的知道默認值被設置為了0
private int calories = 0;//卡路里,可選
private int fat = 0;//總脂肪含量,可選
private int saturatedFat = 0;//飽和脂肪含量,可選
private int sodium = 0;//鈉含量,可選
private int cholesterol = 0;//膽固醇含量,可選
public NutritionFactsBuilder(int servingSize,int servings){
this.servingSize = servingSize;
this.servings = servings;
}
public NutritionFactsBuilder calories(int calories){
this.calories = calories;
}
public NutritionFactsBuilder fat(int fat){
this.fat= fat;
}
//以下省略其他可選字段方法
public NutritionFacts build(){
return new NutritionFacts(this);
}
}
public NutritionFacts(NutritionFactsBuilder builder){
this.servingService = builder.servingService;
this.servings = builder.servings;
this.calories = builder.calories;
this.fat = builder.fat;
this.saturatedFat = builder.saturatedFat;
this.sodium = builder.sodium;
this.cholesterol = builder.cholesterol;
}
}
缺點:編寫冗長,為了創建對象需要先創建一個構建器,某些注重性能的情況下有問題
上述這種構建器模式,其實就是簡單工廠模式,即客戶端依賴具體類NutritionFactsBuilder來創建NutritionFacts,不符合針對接口編程這個設計原則,所以書中提到了這種模式的優化方式---抽象工廠模式,即通過創建一個接口Builder,提供build()方法,讓NutritionFactsBuilder實現這個接口,這樣客戶端就可以面向接口Builder編程,如果修改了具體實現,則除了創建新的具體實現Builder以外,客戶端其他的代碼都不需要修改。
public interface Builder<T>{
public T build();
}
這里補充下工廠模式的最后一種:工廠方法模式。我在網絡上查閱資料時發現許多人會把這個模式和抽象工廠模式搞混,其實這兩者并不相同。
簡而言之,工廠方法模式更適合用來控制一個方法的整體業務流程。整體業務流程由具體代碼以及各個業務方法調用組成,而其中某些業務方法是需要由不同的子類來實現的,所以工廠方法模式編寫時并不是定義一個抽象的接口(抽象工廠模式),而是利用抽象父類來限定一個方法的整體業務流程,然后提供一個或多個抽象的protected業務方法由子類繼承父類來重寫,以此實現上述目的。
補充
最后,書中還提到了Class的newInstance這個方法,這個方法在Java9中被標記為過時,而且在第三版書籍中已經被去除,雖然已被刪除及標記過時,但了解它的原理也是有必要的,因為如果能夠理解站在當時的視角為什么會寫這段文字,又理解它為什么會被刪除,對鞏固Java基礎大有裨益。
缺點逐句解讀:
該方法總是試圖調用無參構造函數:然而可能類中并不存在無參構造函數
如果用new的方式創建,不存在無參構造函數卻想要調用無參構造函數時,編譯器會檢測出來,但是使用newInstance,需要等到運行期才能發現此事。
運行時處理:Instantiation Exception 和 IllegalAccessException(這兩個都是受檢異常)
我查閱了JDK1.6版本的源碼,關于這兩個異常的注釋如下:
IllegalAccessException – if the class or its nullary constructor is not accessible.
InstantiationException – if this Class represents an abstract class, an interface, an array class, a primitive type, or void; or if the class has no nullary constructor; or if the instantiation fails for some other reason.
第一點所說的不存在無參構造函數的情況,是屬于會拋出InstantiationException的情況之一。該方法的簽名用throws 關鍵字明確拋出異常,需要調用者處理。
public T newInstance()
throws InstantiationException, IllegalAccessException
客戶端代碼必須在運行時處理IllegalAccessException和InstantiationException,這樣既不雅觀也不方便
關鍵字還是運行時!雖然客戶端會編寫try-catch代碼來處理這兩個異常,但是很明顯,這兩個異常仍然是在運行期間才會發生并被處理的,如果不用newInstance,編譯器就會發現你想訪問的類是noAccess的或者 你調用的無參構造函數并不存在 或者 你試圖創建了一個抽象類 一個接口 等等之類的問題。
上面這幾句其實說的是一件事,就是運用newInstance會把問題推后到運行期而非在編譯期解決,作者認為是不好的。關于書中作者對編譯期提前發現問題如此執著的原因,我猜可能在于有些軟件并不能非常輕松的在本地運行起來(雖然我還沒有接觸過),在本地運行如此不易的情況下,編譯期能及時發現問題就顯得難能可貴了。
newInstance方法還會傳播由無參構造器拋出的任何異常,newInstance缺乏相應的throws語句
這個問題比較重要,也是后來這個方法過時的原因。
調用構造器時,如果發生非受檢異常,newInstance方法直接向上傳播并且不寫throws語句沒有任何問題,但是如果發生受檢異常,這就代表著需要調用者處理該異常,newInstance方法直接拋出卻不寫throws語句破壞了原來的目的:失去了編譯器強制異常檢測的功能。
可以看到,后來的替代者(如下)的newInstance方法可以不僅僅調用無參構造函數,并且還提供了一個由構造函數拋出的異常的包裝異常InvocationTargetException來解決受檢異常被拋出卻無throws語句的問題。
這樣一來,原來的newInstance就可以安心退休了。
InvocationTargetException – if the underlying constructor throws an exception.
//這樣調用
getDeclaredConstructor(Class<?>... parameterTypes).newInstance(Object ... initargs)
//關注InvocationTargetException
@CallerSensitive
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{

浙公網安備 33010602011771號