類與接口是Java語言的核心,設計出更加有用、健壯和靈活的類與接口很重要。
13、使類和成員的可訪問性最小化
設計良好的模塊會隱藏起所有的實現細節,僅使用API與其他模塊進行通信。這個概念稱為信息隱藏或封裝,是軟件設計的基本原則之一。信息隱藏可以是實現系統各模塊的解耦,以使這些模塊可以獨立的開發、測試、優化。信息隱藏還提高了軟件的可重用性,降低了構建大型系統的風險。
java中實體的可訪問性由實體聲明的位置以及訪問修飾符(private、不寫、protected、public)共同決定。盡可能的降低每個類及成員的訪問級別。由于共有類是包的導出API的一部分,可以被客戶端程序直接調用,對這種類的修改可能會影響程序向前的兼容性。而包級私有的類(默認訪問級別)是包實現的一部分,不會對外提供接口,在以后的發現版本中可以對其放心修改。同樣對于公有類,其public或protected成員是類的導出API的一部分,必須永遠得到支持
若子類覆蓋了超類中的方法,則子類中的訪問級別不能比超類低。否則使用多態時會報錯。
若類實現了一個接口,則接口中的所有方法在子類中都必須是公有的。接口中所有方法隱藏含有public的訪問級別
final域指向可變對象:需要指出的是final的是引用不是對象本身。雖然引用本身不能被修改,但它指向的對象可以被修改。對于私有的final域,其對象不能被外部類訪問和修改從而保證了這個對象不可變的能力。而public final域沒有這個能力。所以包含公有域的類不是線程安全的。對于不可變對象,其是線程安全的,無需擔心線程間對象不一致。
長度非零的數組總是可變的(例如,將元素null),所以,若類具有公有的靜態final數組域,或返回這種域的方法,則客戶端將能夠修改其中的內容。這是一個安全漏洞。
public static final Thing[] VALUES = { ... };
修改方法:
//公有數組變私有
private static final Thing[] PRIVATE_VALUES = { ... };
//方法1,增加一個公有的不可變列表
public static final List<Thing> VALUES =
Collection.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
//方法2,返回私有數組的拷貝
public static final Thing[] values() {
return PRIVATE_VALUES.clone; //注意要完全拷貝
}
確保公有靜態final域所引用的對象都是不可變的
14、公有類中使用訪問方法而非公有域
如果類可以在包外對其進行訪問,應該提供訪問方法(getter或setter方法),以保留將來改變該類內部實現的靈活性。若共有類暴露了它的數據域,由于程序的向前兼容,將來要改變它是不可能的。
對于私有嵌套類或包級私有的類,允許直接暴露它的數據域。因為它們的方位都被限制在了包內部。
公有類永遠都不應該暴露可變的域,暴露不可變的域危害較小
15、使可變性最小化
不可變類是實例創建后不能被修改的類。java類庫中的不可變了包括String、基本數據類型的包裝類、BigInteger和BigDecimal。不可變類易于設計和實現且更加安全。
使類不可變,要遵循的規則:
- 不要提供任何會修改對象狀態的方法(setter方法)
- 保證類不會被擴展,使類成為final的
- 使所有的域都是private final的
- 確保對于任何可變組件的互斥訪問
例如:
public 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); //新建實例
}
}
在這個類中,兩個復數相加返回一個新的Complex實例,而不是修改這個實例。大多數不可變類都使用這種模式,它被稱為函數的做法
不可變對象的優點:
- 不可變對象是線程安全的,函數的做法這種模式使得不可變類對象只有一種狀態,即被創建時的狀態。
- 不可變對象可以被自由的共享。不需要為不可變對象提供clone方法或拷貝構造器,因為不可變對象是不變的,不存在被其他引用修改的可能。基于這個原因客戶端應該盡可能的重用不可變對象(可將常用對象聲明為公有的靜態final常量或為不可變類提供靜態工廠方法)。
不可變對象的缺點:對于每個不同的值都需要一個單獨的對象,創建這些對象的代價可能很高,特別是對于大型對象。
若使用不可變對象執行一個多步驟的操作,每個步驟都將產生一個新對象,除了最后的結果外的其它對象都將被丟棄,此時程序的性能會比較低。處理這個問題的辦法是為這個不可變類提供一個可變配套類。例如,不可變類String的公有配套類StringBuilder是可變的。
為了確保不可變性,不可變類絕對不允許自身被子類化。實現這個限制的方法有①、使類成為final的;②、讓類所有的構造器變為私有的或包級私有的并添加公有的靜態工廠方法來代替構造器。
例如:
public class Complex {
private final double re;//實部
private final double im;
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
public double realPart() { return re; }
public double imaginaryPart() { return im; }
public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
}
對于處在包外的客戶端而言,上面這個類實際上是final的,因為缺少public或protected構造器的類不能被繼承。(子類的創建會先調用父類的構造器)。另外使用靜態工廠方法的優點見第一條1、考慮用靜態工廠方法代替構造器
忠告:
不要為每個get方法編寫一個相應的set方法,除非有很好的理由讓類成為可變的類
如果類不能被做成不可變的,應該盡可能的限制它的可變性,降低對象可以存在的狀態數,這樣可以更容易的分析該對象的行為,同時降低出錯的可能性。
16、復合優先于繼承
繼承是實現代碼重用的有力手段,但它違背了封裝原則,使用不當將會導致軟件變得很脆弱。在包內使用繼承是非常安全的,因為這些代碼一般由一個程序員來編寫。使用專門為繼承設計的類也是安全的,然而對于普通的具體類進行跨包的繼承是非常危險的(不包括接口的繼承和實現),這是因為子類依賴于其超類的具體實現,當超類的實現隨版本變化時,子類可能會遭到破壞。
例如:為了程序調優需要查詢HashSet類自從被建立以來一共添加過多少個元素
public class MyHashSet<E> extends HashSet<E>{
private static final long serialVersionUID = 1L;
private int addCount = 0;
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
MyHashSet<String> mHashSet = new MyHashSet<>();
mHashSet.addAll(Arrays.asList("aaa","bbb","ccc"));
System.out.println(mHashSet.getAddCount()); //輸出為6
}
}
程序的輸出為6,而正確的結果應該為3。哪里出錯了呢?在HashSet的內部,addAll方法是基于它的add方法來實現的,所以在MyHashSet中調用addAll方法首先將addCount增加3,然后調用父類HashSet的addAll方法,根據多態其將會調用MyHashSet的add方法。因此每個元素被增加了兩次。雖然這個錯誤可以通過重寫addAll方法來遍歷集合消除,但是我們不能保證下個版本中HashSet的實現保持不變,MyHashSet這個類是非常脆弱的。
使用復合(composition)可以有效的解決這個問題,在新的類中增加一個私有域來引用現有類的一個實例,在這個類中的實例方法通過調用被包含類的實例方法返回結果。這樣的類不依賴于現有類具體的實現細節,即使現有的類添加了新的方法,也不會影響新的類。
利用復合實現MyHashSet:
public class MyHashSet<E>{
private int addCount = 0;
private Set<E> mSet = null;
public MyHashSet(Set<E> set) {
this.mSet = set;
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return mSet.addAll(c);
}
public boolean add(E e) {
addCount++;
return mSet.add(e);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
HashSet<String> hashSet = new HashSet<>();
MyHashSet<String> mHashSet = new MyHashSet<>(hashSet);
mHashSet.addAll(Arrays.asList("aaa","bbb","ccc"));
System.out.println(mHashSet.getAddCount()); //輸出為3
}
}
利用復合能夠很好的實現現有類與新建類之間的解耦,增加了程序的靈活性與健壯性
注意:
只有當子類真正是超類的子類型時,才適用于繼承。即只有當兩者之間確實存在“is-a”的關系時,才用繼承。
繼承機制會把超類API中的所有缺陷傳播到子類中去,而復合則允許設計新的API來隱藏這些缺陷。
17、要么為繼承而設計,并提供文檔說明,要么就禁止繼承
對于專門為了繼承而設計并且具有良好文檔說明的類,繼承它是安全的。該類的文檔必須精確的描述覆蓋每個方法所帶來的影響,更一般的類必須在文檔中說明,在哪些情況下它會調用可覆蓋方法。如果方法調用到了可覆蓋方法,在它的文檔注釋的末尾應該包含關于這些調用的描述信息,通常這樣開頭“This implementation... Note that...”
好的API文檔應該描述一個給定的方法做了什么工作,而不是描述它是如何做到的。
上面的做法違背了這個原則,這正是繼承破壞了封裝性的后果。
注意:能被繼承的類中構造器決不能調用可被覆蓋的方法,無論是直接調用還是間接調用。
超類的構造器在子類的構造器之前運行,子類中覆蓋的方法將會在子類的構造器運行之前被調用(多態),這可能會導致運行失敗。
例如:
public final class Sub extends Super {
private final Date date;
Sub() {
date = new Date();
}
@Override
public void overrideMe() {
System.out.println(date.getTime());
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
class Super {
public Super() {
overrideMe();
}
public void overrideMe(){
}
}
這段程序將拋出NullPointerException
異常,因為overrideMe
方法被Super
構造器調用時,Sub構造器還沒有初始化date,此時date為null。
在一個為了繼承而設計的類中實現Cloneable接口或Serializable接口時,也應該遵循這樣的規則,即無論clone還是readObject中都不可以調用可覆蓋的方法,不管直接還是間接調用。因為clone和readObject的行為類似于構造器。
對于那些并非為了安全的進行子類化而設計和編寫文檔的類,應該禁止子類化。一個好的替代方案是使用包裝類來對現有類進行擴展(要實現接口)。若具體的類沒有實現接口而這個類必須允許被繼承,一個合理的方法是確保這個類永遠也不會調用它的任何可覆蓋的方法,即消除這個類中可覆蓋方法的自用性。
消除可覆蓋類自用性的方法:
- 將每個可覆蓋方法的代碼體移到一個私有的輔助方法中
- 讓每個可覆蓋方法調用它的私有輔助方法
- 在類中使用輔助方法代替可覆蓋方法完成自我調用
例如:
class Super {
public Super() {
helperMethod();//使用輔助方法代替可覆蓋方法
}
public void overrideMe(){
helperMethod();
}
//輔助方法
private void helperMethod() {
//具體實現
....
}
}
18、接口優于抽象類
接口和抽象類的區別:①、抽象類允許包含某些方法的實現,接口不允許;②、為實現由抽象類定義的類型,類必須成為抽象類的一個子類,而java中只允許單繼承,抽象類作為類型定義受到極大限制。對于接口任何類都能實現。
接口優于抽象類:
接口比抽象類更容易被使用。java是單繼承的。
接口是定義mixin(混合類型)的理想選擇。mixin類型:類除了實現它的“基本類型”之外,還可以實現這個mixin類型,以表明它提供了某些可供選擇的(額外的)行為。例如:Comparable是個mixin接口。抽象類不能被用于定義mixin。
接口允許我們構造非層次結構的類型框架。一個類可實現多個接口,也可以定義一個接口來繼承多個接口。
接口可以使用包裝類模式為現有類增加功能,而抽象類只能用繼承來增加功能
雖然接口不允許包含方法的實現,但接口可以為類的實現提供幫助。通過為每個重要接口提供一個抽象的骨架實現類,可以把接口和抽象類的優點結合起來。接口定義類型,骨架類定義接口基本的實現。例如,Collections框架的每個重要接口都有一個骨架實現類,包括AbstractCollection、AbstractSet、AbstractList、AbstractMap。骨架實現類有助于接口的實現,實現了這個接口的類可以把對于接口方法的調用,轉發到一個內部私有類的實例上,這個內部私有類擴展了骨架實現類。這種方法被稱作模擬多重繼承,這項技術具有多重繼承的絕大多數優點,同時避免了相應的缺陷。
編寫骨架實現類步驟:
- 認真研究接口,并確定哪些方法是最基本的,其他方法可以根據它們來實現。這些基本方法將成為骨架實現類的抽象方法。
- 為接口中其他方法提供具體的實現。
例如:
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
//基本方法
public abstract K getKey();
public abstract V getValue();
public V setValue(V value) {
throw new UnsupportedOperationException();
}
@Override public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return eq(getKey(), e.getKey()) && eq(getValue(), e.getValue());
}
private static boolean eq(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
}
@Override public int hashCode() {
return (getKey() == null ? 0 : getKey().hashCode()) ^
(getValue() == null ? 0 : getValue().hashCode());
}
@Override public String toString() {
return getKey() + "=" + getValue();
}
}
其中將基本方法getKey()
和getValue()
作為骨架實現類的抽象方法。骨架實現類是為繼承而設計的,所以應該遵循第17條的規則。
簡單實現類同樣是為了繼承而設計,并且是實現了接口,區別在于它不是抽象的,而是最簡單的實現。
要想在公有接口中增加方法,而不破壞實現這個接口的所有現有類,這是不可能的。因此設計接口時要非常謹慎。接口一旦被公開發行,并且被廣泛實現,再想改變這個接口幾乎是不可能的。接口通常是定義允許多個實現的類型的最佳途徑。但當演變的容易性比靈活性和功能更為重要的時候,應該使用抽象類。因為抽象類的演變比接口的演變要容易的多。
19、接口只用于定義類型
接口應該只被用來定義類型,為任何其他目的而使用接口都是不當的。
常量接口是對接口的不良使用。實現常量接口,會導致把類內部的實現細節泄漏到類的導出API中。為非final類實現常量接口,它的子類的命名空間就會被接口的常量“污染”。
導出常量的合理方案:
若導出常量與某個現有類或接口密切相關,應該把這些常量添加到這個類或接口中。
若導出常量最好被看做枚舉類型的成員,就應該使用枚舉類型。
否則使用不是實例化的工具類。
public final class ConstantsUtility {
private ConstantsUtility() {}
public static final double PI = 3.1415926;
public static final int INIT_NUM = 1000;
}
若大量利用工具類導出的常量,可使用靜態導入機制避免用類名來修飾常量名。(jdk1.5后才引入)
import static com.alent.ConstantsUtility;
public class Test {
double circleArea(double radius) {
return PI*radius*radius;
}
}
20、類層次優于標簽類
標簽類過于冗長、容易出錯并且效率低下。
例如:
class Figure {
enum Shape { RECTANGLE, CIRCLE};
final Shape shape;
double length;
doube width;
double radius;
Figure(double radius) {
this.shape = Shape.CIRCLE;
this.radius = radius;
}
Figure(double length, double width) {
this.shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
double area() {
switch(shape) {
case RECTANGLE:
return length*width;
case CIRCLE:
return Math.PI*(radius*radius);
default:
throw new AssertionError();
}
}
}
這種標簽類,破壞了可讀性;域不能做出final的,除非構造器初始化了不相關的域。
使用類層次
interface Figure {
double area();
}
class Circle implements Figure {
final double radius;
Circle(double radius) {
this.radius = radius;
}
double area() {
return Math.PI*(radius*radius);
}
}
class Rectangle implements Figure {
final double length;
final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double area() {
return length*width;
}
}
類層次實現的優點:每個類型的實現都配有自己的類,這些類沒有受到不相關的數據域的拖累,所有的域都是final的。類層次可以反映類型之間本質上的層次關系,有助于增強靈活性,并進行更好的編譯時類型檢查。
21、用函數對象表示策略
函數指針、代理、lambda表達式允許程序把“調用特殊函數的能力”存儲起來并進行傳遞。這種機制通常用于允許函數的調用者通過傳入第二個函數,來指定自己的行為。(策略模式)
java沒有函數指針,但可以用對象引用實現同樣的功能。定義一個對象,它的方法執行其他對象(這個對象被顯式傳遞給這些方法)上的操作。這中對象被稱為函數對象。
public interface Comparator<T> {
public int compare(T t1, T t2);
}
class StringLengthComparator implements Comparator<String> {
}
//具體的策略類往往使用匿名類聲明
Arrays.sort(stringArray, new Comparator<String>() {
public int compare(String s1, String s2){
return s1.length() - s2.length();
}
});
使用匿名內部類方式時,每次執行調用時都會創建一個新的實例。若它被重復執行,可將函數對象存儲到一個私有的靜態final域里,并重用它。
例如:
class Host {
private static class StrLenCmp implements Comparator<String>, Serializable{
public int compare(String s1, String s2){
return s1.length() - s2.length();
}
}
public static final Comparator<String> STRIGN_LENGTH_COMPARATOR = new StrLenCmp();
}
簡而言之,函數指針的主要用途是實現策略模式。為了在Java中實現這種模式,要聲明一個接口來表示該策略,并為每個具體策略聲明一個實現了該接口的類。當一個具體策略只被使用一次時,通常使用匿名類來聲明和實例化一個具體策略類。當一個具體策略是設計用來重復使用的時候,通常將類實現為私有的靜態成員類,并通過公有的靜態final域被 導出,其類型為該策略接口。
22、優先考慮靜態成員類
嵌套類是指被定義在另一個類內部的類。嵌套類存在的目的:為它的外圍類提供服務。嵌套類有四種:靜態成員類、非靜態成員類、匿名類和局部類。除了靜態成員類,其它三種都被稱為內部類。
1)、靜態成員類最好把它看做普通的類,它可以訪問外圍類的所有成員,包括那些聲明為私有的成員。靜態成員類是外圍類的一個靜態成員,常作為公有的輔助類。
2)、非靜態成員類的每個實例都隱含著與外圍類的一個實例相關聯,在非靜態成員類的實例方法內部能夠調用外圍實例上的方法。在沒有外圍實例的情況下,不可能創建非靜態成員類的實例。當非靜態成員類的實例被創建時,它與外圍實例間的關聯關系隨之被建立,而且這種關聯關系以后不能被修改。
非靜態成員類常用來定義一個Adapter。例如,Set和List集合接口的實現使用非靜態成員類實現迭代器。
public class MySet<E> extends AbstractSet<E> {
....
public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator implements Iterator<E> {
....
}
}
若聲明的成員類不需要訪問外圍類,就應該把它聲明為靜態成員類。私有靜態成員類常用來代表外圍類所代表對象的組件。例如,Map內部的Entry類,對應于Map中的鍵值對,若將Entry聲明為非靜態成員類,則每個Entry對象都將會包含一個指向該Map的引用,這將浪費時間和空間。
3)、匿名類使用有很多限制。①、無法聲明一個匿名類來實現多個接口或擴展一個類;②、匿名類出現在表達式中,所以必須保持簡潔(10行或更少),否則將影響可讀性。
匿名類的應用:①、創建函數對象;②、創建過程對象;③、用在靜態工廠方法的內部
4)、局部類,在可以聲明局部變量的地方(方法內部)都可以聲明局部類。很少使用。
如果一個嵌套類需要在單個方法之外仍然可見,就不能使用局部類。如果嵌套類的每個實例都需要一個指向其外圍實例的引用,應該使用非靜態成員類,否則就做成靜態成員類。