Effective Java 3rd 條目19 為繼承設計和文件化,否則禁止它
條目18使你意識到子類化一個“不熟悉的”類的危險性,該類不是為繼承設計和文件化。那麼一個類是為繼承設計和文件化,是什麼意思呢?
首先,類必須清晰地文件化覆寫任何方法的影響。換句話說,類必須文件化可覆寫方法的它的自用性(self-use)。對於每個公開或者受保護的方法,文件必須表明這方法呼叫了哪個可覆寫方法、是什麼順序,而且每個呼叫的結果怎麼影響了後續處理。(對於可覆寫的(overridable),我們的意思是,非final的而且公開或者受保護的。)更通常地,一個類必須文件化可能呼叫一個可覆寫方法的任何情形。例如,呼叫可能來自於後臺執行緒或者靜態初始化器。
一個呼叫可覆寫方法的方法,在它文件描述的最後,包含了這些呼叫的描述。這個描述是在規範的一個特殊部分,標記為“實現要求”,這通常由javadoc的@implSpec標籤產生。這個部分描述了這個方法的內部執行。以下是一個例子,從java.util.AbstractCollection規範中拷貝來的:
java public boolean remove(Object o)
從這個集合中移除特定元素的單個例項,如果它是存在的(可選操作)。更正式地講,移除一個滿足Objects.equals(o, e)的元素e,如果這個集合包含一個或者多個這樣的元素。返回真,如果這個集合包含了特定的元素(或者相當於如果這個集合由於這個呼叫而改變了)。
實現要求:這個實現對集合迭代,尋找特定元素。如果它發現了這個元素,那麼他使用迭代的remove方法從這個集合中移除這個元素。注意到,如果這個集合的iterator方法返回的迭代器,沒有實現remove方法而且這個集合包含了指定物件,那麼這個實現丟擲了UnsupportedOperationException。
這個文件毫無疑問表明,覆寫iterator方法將會影響remove方法的行為。而且精確地描述了Iterator方法返回的iterator的行為怎麼樣影響了remove方法的行為。這與條目18中情形相反,程式設計師僅僅子類化HashSet,不會表明覆寫add方法是否會影響addAll方法的行為。
但是這並不違反這個格言:一個好的API文件應該描述一個給定的方法是做什麼的而不是它怎麼幹的?是的,確實如此!這是繼承違反了封裝這個事實的不幸後果。為了文件化一個類以致它可以安全地子類化,你必須描述實現細節,否則不會被指明。
Java8中添加了@implSpec標籤,在Java9中用得比較多。這個標籤應該預設開啟,但是就像在Java9中,javadoc工具仍舊忽略了它,除非你傳遞命令列開關-tag “apiNote:a:API Note:”。
為繼承設計包含了超過僅僅自用文件化模式。為了讓程式設計師有效率而沒有過多痛苦地編寫子類,一個類可能不得不,以明智地選出的受保護方法或者極少情況下受保護的域的方式,提供它內部構造的鉤子。比如,考慮java.util.AbstractList的removeRange方法:
protected void removeRange(int fromIndex, int toIndex)
從這個列表中移除所有的元素,它的索引從fromIndex(包含)到toIndex(不包含)。移動後續元素到左邊(減少它們的索引)。這個呼叫使得這個列表縮短了(toIndex - fromIndex)個元素。如果toIndex == fromIndex,這個操作沒有任何效果。
這個方法由這個列表和它的子列表上的clear操作呼叫。為了利用列表的內部實行,覆寫這個方法可顯著地改進列表和它的子列表上clear操作的效能。
實行要求:這個實現獲得一個在fromIndex之前放置的列表迭代器,而且重複地呼叫ListIterator.next,隨後是ListIterator.remove,直至真個範圍都被移除了。注意:如果ListIterator.remove要求線性時間,那麼這個實現需要二次時間。
引數:
fromIndex 將要移除的第一個元素的索引
toIndex 將要移除的最後一個元素之後的索引
List實現的終端使用者對這個方法是不感興趣的。它僅僅是為了子類提供子列表上的快速clear方法變得容易。如果沒有removeRange方法,當呼叫子列表上的clear方法,或者從新編寫整個子列表(不是一個容易的任務)時,子類將會不得不用二次效能應付。
那麼,當你為繼承設計一個類時,你怎麼決定暴露哪些受保護成員呢?不幸的是,這沒有靈丹妙藥。你能做的最好方法是,努力思考,採用最好的猜測,然後通過編寫子類測試它。你應該暴露儘可能少的受保護成員,因為每個成員都代表對實現細節的承諾。另外一方面,你不能暴露太少,因為缺少受保護成員可以導致一個類幾乎不可用於繼承。
測試為繼承而設計的類的唯一方式是編寫子類。如果你忽略了一個關鍵的受保護成員,編寫子類的嘗試將會讓這個忽略異常明顯。相反地,如果編寫多個子類而一個也沒有使用受保護方法,那麼你大概應該讓它變成私有的。經驗表明,三個子類測試一個可擴充套件類通常是足夠的。這些子類的一個或者多個應該由其他人而不是超類作者編寫。
當你為一個可能廣泛使用的類設計繼承時,要認識到,你永久承諾了你文件化的自用模式和受保護方法和域的實現決定。這些承諾可能在後續釋出中使得類改進效能或者功能是困難的或者是不可能的。所以,你必須在釋出它之前通過編寫子類測試你的類。
而且注意到,繼承要求的特定文件使得通常文件變得凌亂,這個通常文件是為建立類的例項而且呼叫它們方法的程式設計師而設計的。在我編寫的時候,幾乎沒有什麼工具把普通API文件從實現子類的程式設計師感興趣的資訊分離出去。
為了允許繼承,一個類必須遵從再多幾個限制。構造子必須不直接或者間接地呼叫可覆寫方法。如果你違反了這個規則,會導致程式失敗。超類構造子在子類構造子之前執行,所以在子類構造子執行之前將會呼叫超類的覆寫方法。如果這個覆寫方法依賴於子類構造子執行的任何初始化,那麼這個方法不會像預期的行為。為了使得這個具體,以下是一個違反這個規則的類:
public class Super {
// 已破壞 - 構造子呼叫了一個可覆寫的方法
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
以下是覆寫了這個可覆寫方法的子類,這個方法由Super的唯一構造子錯誤地呼叫:
public final class Sub extends Super {
// 空白符號常量,由構造子設定
private final Instant instant;
Sub() {
instant = Instant.now();
}
// 超類構造子呼叫的覆寫方法
@Override public void overrideMe() {
System.out.println(instant);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
你可能期待這個程式兩次打印出instant,但是它只在第一次列印,因為在Sub構造子有機會初始化instant域之前Super呼叫了overrideMe。注意到,這個程式在兩個不同的狀態觀察一個final域!同時注意到,如果overrideMe呼叫了instant上的任意方法,那麼當Super構造子呼叫overrideMe時它將會丟擲NullPointerException。這個程式不會丟擲NullPointerException的唯一原因是,以實際情況來說,println方法容忍null引數。
注意,對一個構造子來說,呼叫私有方法、final方法和靜態方法是安全的,因為這些方法都不是可覆寫的。
當為繼承設計時,Cloneable和Serializable介面提出了特殊的困難。一個為繼承設計的類實現這兩個介面之任何一個,通常都不是一個好主意,因為它們給想擴充套件類的程式設計師增加了巨大負擔。然而,可以採取的特殊行動是,讓子類實現這些介面而不會強制它們這麼做。這些行動在條目13和條目86裡面描述。
如果你決定在為繼承設計的類中實現Cloneable或者Serializable,你應該清楚,因為clone和readObject方法行為很像構造子,一些相似的限制也是適用的:clone和readObject都不應該直接或者間接地呼叫可覆寫的方法。在readObject的情形,覆寫方法將會在子類狀態反系列化之前執行。在clone的情形,覆寫方法將會在子類clone方法有機會確定clone狀態之前執行。這兩個情形之一出現,一個程式失敗可能會跟隨而來。在clone情形,失敗可能損害原來的物件和克隆物件。比如,如果覆寫方法假設他修改了物件深層結構的clone的拷貝,但是這個拷貝還沒完成,那麼這個失敗會發生。
最後,如果你決定在為繼承設計的類中實現Serializable,而且這個類有readResolve或者writeReplace方法,你必須使得readResolve或者writeReplace方法是受保護的而不是私有的。如果這些方法是私有的,那麼子類會默默地忽略它們。這是另外一個情形,為了允許繼承,實現細節成為類API的一部分。
到現在為止,為繼承設計一個類需要巨大的努力而且在類上造成了很大的限制,這是明顯的。這不是一個可以輕鬆承受的決定。有些情形,清楚地表明是在做正確事情,比如抽象類,包括介面的骨架實現(skeletal implementation)(條目20)。有另外的情形,清楚地表明是在做錯誤事情,比如不可變類(條目17)。
但是普通的具體類怎麼樣呢?習慣上,它們不是final也不是為子類化而設計或者文件化的,但是事情狀態是危險的。在這樣的類中每次改變發生,擴充套件這個類的子類都有可能破壞。這不僅僅是一個理論問題,在修改一個非final具體類(這個類不是為繼承而設計和文件化)的內部結構之後,收到子類化相關的錯誤報告,這不是不常見的。
這個問題的最好解決方法是禁止類的子類化,這些類不是為安全子類化而設計和文件化的。有兩種方式防止子類化,兩者中比較容易的是宣告類為final。替代方案是讓所有構造子是私有的或者包私有的,而且新增公共靜態工廠代替構造子。這個替代方案,提供了內部使用子類的靈活性,在條目17中討論了。兩個方案之一都是可接受的。
這個建議可能有點爭議,因為許多程式設計師已經習慣了為了新增功能(比如插樁、通知和同步或者限制功能)而子類化普通具體類。如果一個類實現了某個介面,這個介面規定了它的必要部分,比如Set、List或者Map,那麼你對禁止子類化不會感到內疚。就像在條目18討論的,包裝類模式為增強功能提供了一個更優的繼承替代方案。
如果一個具體類沒有實現一個標準介面,那麼你由於禁止繼承可能給某些程式設計師帶來不方便。如果你感覺你必須從這樣的類繼承,一個合理方案是確保這個類從未呼叫它的任意可覆寫方法,而且文件化這個事實。換句話說,完全消除這個類的可覆寫方法的自用。如果這樣做了,你將會建立一個相當安全子類化的類。覆寫一個方法將永遠不會影響任何其他方法的行為。
你可以機械地消除一個類的可覆寫方法的自用,而沒有改變它的行為。把每個可覆寫方法的主體移動到一個私有“輔助(helper)方法”,而且讓每個可覆寫方法呼叫它的私有輔助方法。然後用一個直接呼叫可覆寫方法的私有輔助類代替一個可覆寫方法的每個自用。
總之,為繼承設計一個類是艱難的工作。你必須文件化所有它的自用模式,而且一旦你文件化它們,你必須在這個類的生命週期內承諾它們。如果你未能這麼做,那麼子類可能變得依賴於超類的實現細節,而且如果超類的實現改變了,那麼子類可能遭到破壞。為了讓其他人編寫有效率的子類,你可能也的不得不匯出一個或者多個受保護方法。除非你知道有一個真實的子類需求,你大概最好以宣告你的類為final或者確保沒有可訪問構造子的方式,禁止繼承。