JAVA中使用到繼承就會有兩個無法迴避的缺點:
- 打破了封裝性,迫使開發者去了解超類的實現細節,子類和超類耦合。
- 超類更新後可能會導致錯誤。
繼承打破了封裝性
關於這一點,下面是一個詳細的例子(來源於Effective Java第16條)
public class MyHashSet<E> extends HashSet<E> {
private int addCount = 0;
public int getAddCount() {
return addCount;
}
@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);
}
}
複製程式碼
這裡自定義了一個HashSet
,重寫了兩個方法,它和超類唯一的區別是加入了一個計數器,用來統計新增過多少個元素。
寫一個測試來測試這個新增的功能是否工作:
public class MyHashSetTest {
private MyHashSet<Integer> myHashSet = new MyHashSet<Integer>();
@Test
public void test() {
myHashSet.addAll(Arrays.asList(1,2,3));
System.out.println(myHashSet.getAddCount());
}
}
複製程式碼
執行後會發現,加入了3個元素之後,計數器輸出的值是6。
進入到超類中的addAll()
方法就會發現出錯的原因:它內部呼叫的是add()
方法。所以在這個測試裡,進入子類的addAll()
方法時,數器加3,然後呼叫超類的addAll()
,超類的addAll()
又會呼叫子類的add()
三次,這時計數器又會再加三。
問題的根源
將這種情況抽象一下,可以發現出錯是因為超類的可覆蓋的方法存在
(即超類裡可覆蓋的方法呼叫了別的可覆蓋的方法),這時候如果子類覆蓋了其中的一些方法,就可能導致錯誤。
比如上圖這種情況,Father
類裡有可覆蓋的方法A
和方法B
,並且A
呼叫了B
。子類Son
重寫了方法B
,這時候如果子類呼叫繼承來的方法A
,那麼方法A
呼叫的就不再是Father.B()
,而是子類中的方法Son.B()
。如果程式的正確性依賴於Father.B()
中的一些操作,而Son.B()
重寫了這些操作,那麼就很可能導致錯誤產生。
關鍵在於,子類的寫法很可能從表面上看來沒有問題,但是卻會出錯,這就迫使開發者去了解超類的實現細節,從而打破了物件導向的封裝性,因為封裝性是要求隱藏實現細節的。更危險的是,錯誤不一定能輕易地被測出來,如果開發者不瞭解超類的實現細節就進行重寫,那麼可能就埋下了隱患。
超類更新時可能產生錯誤
這一點比較好理解,主要有以下幾種可能:
- 超類更改了已有方法的簽名。會導致編譯錯誤。
- 超類新增了方法:
- 和子類已有方法的簽名相同但返回型別不同,會導致編譯錯誤。
- 和子類的已有方法簽名相同,會導致子類無意中複寫,回到了第一種情況。
- 和子類無衝突,但可能會影響程式的正確性。比如子類中元素加入集合必須要滿足特定條件,這時候如果超類加入了一個無需檢測就可以直接將元素插入的方法,程式的正確性就受到了威脅。
設計可繼承的類
設計可以用來繼承的類時,應該注意:
- 對於存在自用性的可覆蓋方法,應該用文件精確描述呼叫細節。
- 儘可能少的暴露受保護成員,否則會暴露太多實現細節。
- 構造器不應該呼叫任何可覆蓋的方法。
詳細解釋下第三點。它實際上和
裡討論的問題很相似,假設有以下程式碼:
public class Father {
public Father() {
someMethod();
}
public void someMethod() {
}
}
複製程式碼
public class Son extends Father {
private Date date;
public Son() {
this.date = new Date();
}
@Override
public void someMethod() {
System.out.println("Time = " + date.getTime());
}
}
複製程式碼
上述程式碼在執行測試時就會丟擲NullPointerException
:
public class SonTest {
private Son son = new Son();
@Test
public void test() {
son.someMethod();
}
}
複製程式碼
因為超類的建構函式會在子類的建構函式之前先執行,這裡超類的建構函式對someMethod()
有依賴,同時someMethod()
被重寫,所以超類的建構函式裡呼叫到的將是Son.someMethod()
,而這時候子類還沒被初始化,於是在執行到date.getTime()
時便丟擲了空指標異常。
因此,如果在超類的建構函式裡對可覆蓋的方法有依賴,那麼在繼承時就可能會出錯。
結論
慎重使用繼承,複合優先於繼承。
使用繼承時重寫超類中存在自用性的可覆蓋方法可能會出錯,即使不進行重寫,超類更新時也可能會引入錯誤。
如果使用繼承和複合皆可,那麼優先使用複合,上述關於繼承的缺點都可以用複合來避免。
如果要使用繼承,那麼應該精心設計超類,並提供詳細文件。