Effective Java讀書筆記六:方法(38-44)

衣舞晨風發表於2017-02-05

第38條:檢查引數的有效性

絕大多數方法和構造器對於傳遞給它們的引數值都會有些限制。比如,索引值必須大於等於0,且不能超過其最大值,物件不能為null等。這樣就可以在導致錯誤的源頭將錯誤捕獲,從而避免了該錯誤被延續到今後的某一時刻再被引發,這樣就是加大了錯誤追查的難度。就如同編譯期能夠報出的錯誤總比在執行時才發現要更好一些。事實上,我們不僅僅需要在函式的內部開始出進行這些通用的引數有效性檢查,還需要在函式的文件中給予明確的說明,如在引數非法的情況下,會丟擲那些異常,或導致函式返回哪些錯誤值等,見如下程式碼示例:

/**
     * Returns a BigInteger whose value is(this mod m). This method
     * differs from the remainder method in that it always returns a
     * non-negative BigInteger.
     * @param m the modulus, which must be positive.
     * @return this mod m.
     * @throws ArithmeticException if m is less than or equal to 0.
*/
     public BigInteger mod(BigInteger m) {
         if (m.signum() <= 0)
             throw new ArithmeticException("Modulus <= 0: " + m);
         ... //Do the computation.
     }

是不是我們為所有的方法均需要做出這樣的有效性檢查呢?對於未被匯出的方法,如包方法等,你可以控制這個方法將在哪些情況下被呼叫,因此這時可以使用斷言來幫助進行引數的有效性檢查,如:

private static void sort(long a[],int offset,int length) {
          assert(a != null);
          assert(offset >= 0 && offset <= a.length);
          assert(length >= 0 && length <= a.length - offset);
          ... //Do the computation
      }

和通用的檢查方式不同,斷言在其條件為真時,無論外部包得客戶端如何使用它。斷言都將丟擲AssertionError。它們之間的另一個差異在於如果斷言沒有起到作用,即-ea命令列引數沒有傳遞給java直譯器,斷言將不會有任何開銷,這樣我們就可以在除錯期間加入該命令列引數,在釋出時去掉該命令列選項,而我們的程式碼則不需要任何改動。

需要強調的是,對於有些函式的引數,其在當前函式內並不使用,而是留給該類其他函式內部使用的,比較明顯的就是類的建構函式,建構函式中的很多引數都不一樣用於構造器內,只是在構造的時候進行有些賦值操作,而這些引數的真正使用者是該類的其他函式,對於這種情況,我們就更需要在構造的時候進行引數的有效性檢查,否則一旦將該問題釋放到域函式的時候,再追查該問題的根源,將不得不付出更大的代價和更多的除錯時間。

對該條目的說法確實存在著一種例外情況,在有些情況下有效性檢查工作的開銷是非常大的,或者根本不切實際,因為這些檢查已經隱含在計算過程中完成了,如Collections.sort(List),容器中物件的所有比較操作均在該函式執行時完成,一旦比較操作失敗將會丟擲ClassCastException異常。因此對於sort來講,如果我們提前做出有效性檢查將是毫無意義的。

第39條:必要時進行保護性拷貝

如果你的物件沒有做很好的隔離,那麼對於呼叫者而言,則有機會破壞該物件的內部約束條件,因此我們需要保護性的設計程式。該破壞行為一般由兩種情況引起,首先就是惡意的破壞,再有就是呼叫者無意識的誤用,這兩種條件下均有可能給你的類帶來一定的破壞性,見如下程式碼:

public final class Period {
        private final Date start;
        private final Date end;
        public Period(Date start,Date end) {
            if (start.compareTo(end) > 0) {
                throw new IllegalArgumentException(start + "After " + end);
            this.start = start;
            this.end = end;
        }
        public Date start() {
            return start;
        }
        public Date end() {
            return end;
        }
    }

從表面上看,該類的實現確實對約束性的條件進行了驗證,然而由於Date類本身是可變了,因此很容易違反這個約束,見如下程式碼:

public void testPeriod() {
         Date start = new Date();
         Date end = new Date();
         Period p = new Period(start,end);
         end.setYear(78);  //該修改將直接影響Period內部的end物件。
     }

為了避免這樣的攻擊,我們需要對Period的建構函式進行相應的修改,即對每個可變引數進行保護性拷貝。

public Period(Date start,Date end) {
         this.start = new Date(start.getTime());
         this.end = new Date(end.getTime());
         if (start.compareTo(end) > 0) {
             throw new IllegalArgumentException(start + "After " + end);
     }

需要說明的是,保護性拷貝是在堅持引數有效性之前進行的,並且有效性檢查是針對拷貝之後的物件,而不是針對原始物件的。這主要是為了避免在this.start = new Date(start.getTime())到if (start.compareTo(end) > 0)這個時間視窗內,引數start和end可能會被其他執行緒修改。

現在建構函式已經安全了,後面我們需要用同樣的方式繼續修改另外兩個物件訪問函式。

    public Date start() {
         return new Date(start.getTime());
     }
    public Date end() {
         return new Date(end.getTime());
     }

經過這一番修改之後,Period成為了不可變類,其內部的“週期的起始時間不能落後於結束時間”約束條件也不會再被破壞。

引數的保護性拷貝並不僅僅針對不可變類。每當編寫方法或者構造器時,如果它要允許客戶提供的物件進入到內部資料結構中,則有必要考慮一下,客戶提供的物件進入到內部資料結構中,則有必要考慮一下,客戶提供的物件是否有可能是可變的。如果是,就要考慮你的類是否能夠容忍物件進入資料結構之後發生變化。如果答案是否定的,就必須對該物件進行保護性拷貝,並且讓拷貝之後的物件而不是原始物件進入到資料結構中。

例如,如果你正在考慮使用有客戶提供的物件引用作為內部Set例項的元素,或者作為內部Map例項的鍵(Key),就應該意識到,如果這個物件在插入之後再被修改,Set或者Map的約束條件就會遭到破壞。

第40條:謹慎設計方法簽名

  1. 謹慎地選擇方法的名稱
  2. 避免過長的引數列表,目標是四個引數或者更少,如果多於四個了就該考慮重構這個方法了(把方法分解多個小方法、建立輔助類、從物件構建到方法呼叫都採用Builder模式)。
  3. 對於引數型別、要優先使用介面而不是類。如果使用的是類而不是介面,則限制了客戶端只能傳入特定的實現,如果碰巧輸入的資料是以其他的形式存在,就會導致不必要的、可能非常昂貴的拷貝操作。
  4. 對於boolean引數,優先使用兩個元素的列舉型別

第41條:慎用過載

下面的例子根據一個集合是Set、List還是其他的集合型別,來對它進行分類:

  public class CollectionClassfier { 
            public static String classify(Set<?> s) { 
                return "Set"; 
            } 
            public static String classify(List<?> l) { 
                return "List"; 
            } 
            public static String classify(Collection<?> c) { 
                return "Unknown collection"; 
            } 
            public static void main(String[] args) { 
                Collection<?>[] collections = {new HashSet<String>(), new ArrayList<BigInteger>(), new HashMap<String,String>().values()}; 
                for (Collection<?> c : collections) 
                    System.out.println(classify(c)); 
            } 
        } 

這裡你可能會期望程式列印出Set、List、Unknown Collection,然而實際上卻不是這樣,輸出的結果是3 個”Unknown Collection”。
因為classify方法被過載了,需要呼叫哪個函式是在編譯期決定的,for中的三次迭代引數的編譯型別是相同的:

Collection<?>

對於過載方法的選擇是靜態的,而對於被覆蓋的方法的選擇則是動態的。選擇被覆蓋的方法的正確版本是在執行時進行的,選擇的依據是被呼叫的方法所在物件的執行時型別。這裡重新說明一下,當一個子類包含的方法宣告與其祖先類中的方法宣告具有同樣的的簽名時,方法就被覆蓋了。如果例項方法在子類中被覆蓋了,並且這個方法是在該子類的例項上被呼叫的,那麼子類中的覆蓋方法將會執行,而不管該子類例項的編譯時型別到底是什麼。

        class Wine{ 
            String name() {return "wine"; } 
        } 
        class SparklingWine extends Wine{ 
            @Override String name(){return "sparkling wine"; } 
        } 
        class Champagne extends Wine{ 
            @Override String name(){return "Champagne"; } 
        } 
        public class Overriding{ 
            public static void main(String[] args){ 
                Wine[] = {
                new Wine(), 
                new SparklingWine(), 
                new Champagne() }; 
            } 
            for(Wine wine : wines){ 
                System.out.println(wine.name()); 
            } 
        } 

正如你所預期的那樣,這個程式列印出“wine, sparkling wine, champagne”,當呼叫被覆蓋的方法時,物件的編譯時型別不會影響到哪個方法將被執行。最為具體的那個覆蓋版本總是會得到執行。

對於開始的集合輸出類的最佳修正方案是,用單個方法來替換這三個過載的classity方法,如下:

public static String classify(Collection<?> c) { 
            return c instanceof Set ? "Set" : c instanceof List ? "List" : "Unknown Collection"; 
        } 

因此,應該避免胡亂地使用過載機制。
一、安全而保守的策略是,永遠不要匯出兩個具有相同引數數目的過載方法。比如兩個過載函式均有一個引數,其中一個是整型,另一個是Collection<?>,對於這種情況,int 和Collection<?>之間沒有任何關聯,也無法在兩者之間做任何的型別轉換,否則將會丟擲ClassCastException 的異常,因此對於這種函式過載,我們是可以準確確定的。反之,如果兩個引數分別是int 和short,他們之間的差異就不是這麼明顯。
二、如果方法使用可變引數,保守的策略是根本不要過載它。
三、對於構造器,你沒有選擇使用不同名稱的機會,一個類的多個構造器總是過載的,但是構造器也不可能被覆蓋。
四、在Java 1.5 之後,需要對自動裝箱機制保持警惕。
演示如下:

 public class SetList { 
            public static void main(String[] args) { 
                Set<Integer> s = new TreeSet<Integer>(); 
                List<Integer> l = new ArrayList<Integer>(); 
                for (int i = -3; i < 3; ++i) { 
                    s.add(i); 
                    l.add(i); 
                } 
                for (int i = 0; i < 3; ++i) { 
                    s.remove(i); 
                    l.remove(i); 
                } 
                System.out.println(s + " " + l); 
            } 
        } 

在執行該段程式碼前,我們期望的結果是Set 和List 集合中大於等於的元素均被移除出容器,然而在執行後卻發現事實並非如此,其結果為:[-3,-2,-1] [-2,0,2]。這個結果和我們的期望還是有很大差異的,為什麼Set 中的元素是正確的,而List 則不是,是什麼導致了這一結果的發生呢?

下面給出具體的解釋:

s.remove(i)呼叫的是Set 中的remove(E),這裡的E 表示Integer,Java 的編譯器會將i 自動裝箱到Integer 中,因此我們得到了想要的結果。

l.remove(i)實際呼叫的是List 中的remove(int index)過載方法,而該方法的行為是刪除集合中指定索引的元素。這裡分別對應第0 個,第1 個和第2 個。

為了解決這個問題,我們需要讓List 明確的知道,我們需要呼叫的是remove(E)過載函式,而不是其他的,這樣我們就需要對原有程式碼進行如下的修改:

public class SetList { 
            public static void main(String[] args) { 
                Set<Integer> s = new TreeSet<Integer>(); 
                List<Integer> l = new ArrayList<Integer>(); 
                for (int i = -3; i < 3; ++i) { 
                    s.add(i); 
                    l.add(i); 
                } 
                for (int i = 0; i < 3; ++i) { 
                    s.remove(i); 
                    l.remove((Integer)i); //or remove(Integer.valueOf(i)); 
                } 
                System.out.println(s + " " + l); 
            } 
        } 

總結,對於多個具有相同引數數目的方法來說,應該儘量避免過載方法。我們應當保證:當傳遞同樣的引數時,所有過載方法的行為必須一致。

第42條:慎用可變引數

可變陣列機制是通過先建立一個陣列,陣列的大小為在呼叫位置所傳遞的引數數量,然後將引數值傳到陣列中,最後將陣列傳遞給方法。

有的時候在重視效能的情況下,使用可變引數機制要特別小心。可變引數方法的每次呼叫都會導致進行一次陣列分配和初始化。如果確定確實無法承受這一成本,但又需要可變引數的靈活性,還有一種模式可以彌補這一不足。假設確定對某個方法95%的呼叫會有3個或者更少的引數,就宣告該方法的5個過載,每個過載方法帶有0個至3個普通引數,當引數的數目超過3個時,就使用一個可變引數方法:

public void foo() {}
public void foo(int a1) {}
public void foo(int a1,int a2) {}
public void foo(int a1,int a2,int a3) {}
public void foo(int a1,int a2,int a3,int...rest) {}

所有呼叫中只有5%引數數量超過3個的呼叫需要建立陣列。就像大多數的效能優化一樣,這種方法通常不恰當,但是一旦真正需要它時,還是非常有用處的。

在定義引數數目不定的方法時,可變引數方法是一種很方便的方式,但是它們不應該過度濫用。如果使用不當,會產生混亂的結果。

第43條:返回零長度的陣列或者集合,而不是null

有時候會有人認為:null返回值比零長度資料更好,因為它避免了分配陣列所需要的開銷。
這種觀點是站不住腳的,原因有兩點。

  1. 在這個級別上擔心效能問題是不明智的,除非分析表明這個方法正是造成效能問題的真正源頭。
  2. 對於不返回任何元素的呼叫,每次都返回同一個零長度陣列是有可能的,因為零長度陣列是不可變的,而不可變物件有可能被自由地共享。
private static final Cheese[] EMPTY_CHEESE_ARRAY= new Cheese[0];

相比於陣列,集合亦是如此。
在Collections中有專門針對List,Set,Map的空的實現。如:

Collections.emptyList()
Collections.emptySet();
Collections.emptyMap();

第44條:為所有匯出的API元素編寫文件註釋

《Effective Java中文版 第2版》PDF版下載:
http://download.csdn.net/detail/xunzaosiyecao/9745699

作者:jiankunking 出處:http://blog.csdn.net/jiankunking

相關文章