目錄
十三、使類和成員的可訪問性最小化
十四、在公有類中使用訪問方法而非公有域
十五、使可變性最小化toString
十六、複合優先於繼承
十七、要麼爲繼承而設計,並提供文檔說明,要麼就禁止繼承
十三、使類和成員的可訪問性最小化
信息隱藏是軟件程序設計的基本原則之一,面向對象又爲這一設計原則提供了有力的支持和保障。這裏我們簡要列出幾項受益於該原則的優勢:
1.更好的解除各個模塊之間的耦合關係。由於模塊間的相互調用是基於接口契約的,每個模塊只是負責完成自己內部既定的功能目標和單元測試,一旦今後出現性能優化或需求變更時,我們首先需要做的便是定位需要變動的單個模塊或一組模塊,然後再針對各個模塊提出各自的解決方案,分別予以改動和內部測試。這樣便大大降低了因代碼無規則交叉而帶來的潛在風險,同時也縮減了開發週期。
2.最大化並行開發。由於各個模塊之間保持着較好的獨立性,因此可以分配更多的開發人員同時實現更多的模塊,由於每個人都是將精力完全集中在自己負責和擅長的專一領域,這樣不僅提高了軟件的質量,也大大加快了開發的進度。
3.性能優化和後期維護。一般來說,局部優化的難度和可行性總是要好於來自整體的優化,事雖如此,然而我們首先需要做的卻是如何定位需要優化的局部,在設計良好的系統中,完成這樣的工作並非難事,我們只需針對每個涉及的模塊做性能和壓力測試,之後再針對測試的結果進行分析並拿到相對合理的解決方案。
4.代碼的高可複用性。在軟件開發的世界中,提出了衆多的設計理論,設計原則和設計模式,之所以這樣,一個非常現實的目標之一就是消除重複代碼,記得《重構》中有這樣的一句話:“重複代碼,萬惡之源”。可見提高可用代碼的複用性不僅對編程效率和產品質量有着非常重要的意義,對日後產品的升級和維護也是至關重要的。說一句比較現實的話,一個設計良好的產品,即使因爲某些原因導致失敗,那麼產品中應用到的一個個獨立、可用和高效的模塊也爲今後的東山再起提供了一個很好的技術基礎。
類具有公有的靜態final數組域,或者返回這種域的訪問方法,這幾乎總是錯誤的,如:
public static final Thing[] VALUES = { ... };
即便Thing數組對象本身是final的,不能再被賦值給其他對象,然而數組內的元素是可以改變的,這樣便給外部提供了一個機會來修改內部數據的狀態,從而在主類未知的情況下破壞了對象內部的狀態或數據的一致性。其修改方式如下:1.使公有數組變成私有的,並增加一個公有的不可變列表。
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = Collection.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
2.使公有數組變成私有的,並添加一個公有方法,它返回私有數組的一個備份。private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
總而言之,你應該儘可能地降低可訪問性。你在仔細地設計了一個最小的公有API之後,應該防止把任何散亂的類、接口和成員變成API的一部分。除了公有靜態final域的特殊情形之外,公有類都不應該包含公有域。並且要確保公有靜態final域所引用的對象都是不可變的。
1.對於公有類而言,由於存在大量的使用者,因此修改API接口將會給使用者帶來極大的不便,他們的代碼也需要隨之改變。如果公有類直接暴露了域字段,一旦今後需要針對該域字段添加必要的約束邏輯時,唯一的方法就是爲該字段添加訪問器接口,而已有的使用者也將不得不更新其代碼,以避免破壞該類的內部邏輯。
2.對於包級類和嵌套類,公有的域方法由於只能在包內可以被訪問,因而修改接口不會給包的使用者帶來任何影響。
3.對於公有類中的final域字段,提供直接訪問方法也會帶來負面的影響,只是和非final對象相
比可能會稍微好些,如final的數組對象,即便數組對象本身不能被修改,但是他所包含的數組成員還是可以被外部改動的,針對該情況建議提供API接口,在該接口中可以添加必要的驗證邏輯,以避免非法數據的插入,如:
public <T> boolean setXxx(int index, T value) {
if (index > myArray.length)
return false;
if (!(value instanceof LegalClass))
return false;
...
return true;
}
十五、使可變性最小化
不可變類只是實例不能被修改的類。每個實例中包含的所有信息都必須在創建該實例的時候就提供,並在對象的整個生命週期內都會固定不變,如String、Integer等。不可變類比可變類更加易於設計、實現和使用,而且線程安全。
使類成爲不可變類應遵循以下五條規則:
1.不要提供任何會修改對象狀態的方法
2.保證類不會被擴展,即聲明爲final類,或將構造函數定義爲私有後加入靜態工廠函數
3.使所有的域都是final的
4.使所有的域都成爲私有的
5.確保在返回任何可變域時,返回該域的deep copy
final class Complex {
private final double re;
private final double im;
public Complex(double re,double im) {
this.re = re;
this.im = im;
}
public double realPart() {
return re;
}
public double imaginaryPart() {
return im;
}
public Complex add(Complex c) {
return new Complex(re + c.re,im + c.im);
}
public Complex substract(Complex c) {
return new Complex(re - c.re, im - c.im);
}
... ...
}
不可變對象還有一個對象重用的優勢,這樣可以避免創建多餘的新對象,如:public static final Complex ZERO = new Complex(0,0);
public static final Complex ONE = new Complex(1,0);
使用者可以重複使用上面定義的兩個靜態final類,而不需要在每次使用時都創建新的對象。
對於不可變對象還有比較重要的優化技巧,既某些關鍵值的計算,如hashCode,可以在對象構造時或留待某特定方法(Lazy Initialization)第一次調用時進行計算並緩存到私有域字段中,之後再獲取該值時,可以直接從該域字段獲取,避免每次都重新計算。這樣的優化主要是依賴於不可變對象的域字段在構造後即保持不變的特徵。
十六、複合優先於繼承
由於繼承需要透露一部分實現細節,因此不僅需要超類本身提供良好的繼承機制,同時也需要提供更好的說明文檔,以便子類在覆蓋超類方法時,不會引起未知破壞行爲的發生。需要特別指出的是對於跨越包邊界的繼承,很可能超類和子類的實現者並非同一開發人員或同一開發團隊,因此對於某些依賴實現細節的覆蓋方法極有可能會導致預料之外的結果,還需要指出的是,這些細節對於超類的普通用戶來說往往是不看見的,因此在未來的升級中,該實現細節仍然存在變化的可能,這樣對於子類的實現者而言,在該細節變化時,子類的相關實現也需要做出必要的調整,見如下代碼:
//這裏我們需要擴展HashSet類,提供新的功能用於統計當前集合中元素的數量
//實現方法是新增一個私有域變量用於保存元素數量,並每次添加新元素的方法中
//更新該值,再提供一個公有的方法返回該值
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet() {}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override
public boolean add(E e) {
++addCount;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
該子類覆蓋了HashSet中的兩個方法add和addAll,而且從表面上看也非常合理,然而他卻不能正常的工作,見下面的測試代碼:public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println("The count of InstrumentedHashSet is " + s.getAddCount());
}
//The count of InstrumentedHashSet is 6
從輸出結果中可以非常清楚的看出,我們得到的結果並不是我們期望的3,而是6。這是什麼原因所致呢?在HashSet的內部,addAll方法是基於add方法來實現的,而HashSet的文檔中也並未列出這樣的細節說明。因此我們用另外一種辦法實現:在新的類中增加一個私有域,它引用現有類的一個實例。這種設計被稱作“複合”,因爲現有的類變成了新類的一個組件。新類中的每個實例方法都可以調用被包含的現有類實例中對應的方法,並返回它的結果,這被稱爲轉發,新類中的方法被稱爲轉發方法。下面的例子用複合/轉發的方法代替InstrumentedHashSet類。注意這個實現分爲兩部分:類本身和可重用的轉發類。//轉發類
class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) {
this.s = s;
}
public int size() {
return s.size();
}
public void clear() {
s.clear();
}
public boolean add(E e) {
return s.add(e);
}
public boolean addAll(Collection<? extends E> c) {
return s.addAll(c);
}
... ...
@Override
pubic boolean equals(Object o) {
return s.equals(o);
}
@Override
pubic int hashCode() {
return s.hashCode();
}
@Override
pubic String toString() {
return s.toString();
}
}
//包裝類
class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@Override
public boolean add(E e) {
++addCount;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
Set接口的存在使InstrumentedSet類的設計成爲可能,因爲Set接口保存了HashSet類的功能特性。除了獲得健壯性之外,這種設計也帶來了格外的靈活性。這個包裝類nstrumentedSet<E>可以用來包裝任何Set實現,如TreeSet和HashSet。
繼承的功能非常強大,但是也存在許多問題,因爲它違背了封裝原則。只有當子類真正是超類的子類型時,即兩者之間確實存在“is-a”關係時,才適合用繼承。即便如此,如果子類和超類處在不同的包中,並且超類並不是爲了繼承而設計的,那麼繼承將會導致脆弱性。爲了避免這種脆弱性,可以用複合和轉發機制來代替繼承,尤其是當存在適當的接口可以實現包裝類的時候。包裝類不僅比子類更加健壯,而且功能也更加強大。
十七、要麼爲繼承而設計,並提供文檔說明,要麼就禁止繼承
對於專門爲了繼承而設計的類,該類的文檔必須精確地描述覆蓋每個方法所帶來的影響,即該類必須由文檔說明它可覆蓋的方法的自用性。對於每個公有的或受保護的方法或者構造器,它的文檔必須指明該方法或者構造器調用了哪些可覆蓋的方法,是以說明順序調用的,每個調用的結果又是如何影響後續的處理過程的。
類還必須遵守其他一些約束。構造器決不能調用可被覆蓋的方法,無論是直接調用還是間接調用。如果違反了這條規則,很有可能導致程序失敗。超類的構造器在子類的構造器之前運行,所以,子類中覆蓋版本的方法將會再子類的構造器運行之前就先被調用。如果該覆蓋版本的方法依賴於子類構造器所執行的任何初始化工作,該方法將不會如預期般地執行。如下面的例子:
public class SuperClass {
public SuperClass() {
overrideMe();
}
public void overrideMe() {
}
}
public final class SubClass extends SuperClass {
private final Date d;
SubClass() {
d = new Date();
}
@Override
public void overrideMe() {
System.out.println(d);
}
public static void main(String[] args) {
SubClass sub = new SubClass();
sub.overrideMe();
}
}
程序會打印日期兩次,但是第一次打印出的是null,因爲overrideMe方法被SuperClass構造器調用的時候,構造器SubClass還沒有機會初始化d變量。要注意,如果overrideMe已經調用了d的任何方法,當SuperClass構造器調用overrideMe的時候,調用就好拋出NullPointerException異常。該程序沒有拋出NullPointerException異常是因爲println方法對於處理null參數有着特殊的規定。
如果類是爲了繼承而被設計的,Cloneable和Serializable接口無論實現哪一個都不是好主意。若非要實現,則因爲clone和readObject方法在行爲上非常類似於構造器,所以在這些方法中也不能調用可覆蓋的方法。具體的實現手段在第11條和第74條。