編寫高質量程式碼:改善Java程式的151個建議(第5章:陣列和集合___建議65~69)

阿赫瓦里發表於2016-09-20

建議65:避開基本型別陣列轉換列表陷阱

   我們在開發中經常會使用Arrays和Collections這兩個工具類和列表之間轉換,非常方便,但也有時候會出現一些奇怪的問題,來看如下程式碼: 

1 public class Client65 {
2     public static void main(String[] args) {
3         int data [] = {1,2,3,4,5};
4         List list= Arrays.asList(data);
5         System.out.println("列表中的元素數量是:"+list.size());
6     }
7 }

  也許你會說,這很簡單,list變數的元素數量當然是5了。但是執行後列印出來的列表數量為1。

  事實上data確實是一個有5個元素的int型別陣列,只是通過asList轉換成列表後就只有一個元素了,這是為什麼呢?其他4個元素到什麼地方去了呢?

  我們仔細看一下Arrays.asList的方法說明:輸入一個變長引數,返回一個固定長度的列表。注意這裡是一個變長引數,看原始碼:

  public static <T> List<T> asList(T... a) {
        return new ArrayList<>(a);
    }

  asList方法輸入的是一個泛型變長引數,我們知道基本型別是不能泛型化的,也就是說8個基本型別不能作為泛型引數,要想作為泛型引數就必須使用其所對應的包裝型別,那前面的例子傳遞了一個int型別的陣列,為何程式沒有報編譯錯誤呢?

  在Java中,陣列是一個物件,它是可以泛型化的,也就是說我們的例子是把一個int型別的陣列作為了T的型別,所以在轉換後在List中就只有一個型別為int陣列的元素了,我們列印出來看看,程式碼如下: 

1 public class Client65 {
2     public static void main(String[] args) {
3         int data [] = {1,2,3,4,5};
4         List list= Arrays.asList(data);
5         System.out.println("元素型別是:"+list.get(0).getClass());
6         System.out.println("前後是否相等:"+data.equals(list.get(0)));    
7     }
8 }

  輸出的結果是: 元素型別是:class [I   前後是否相等:true

  很明顯,放在列表中的元素時一個int陣列,可能有人要問了,為什麼"元素型別:"後的class是"[I"?我們並沒有指明是陣列(Array)型別呀!這是因為JVM不可能輸出Array型別,因為Array是屬於java.lang.reflect包的,它是通過反射訪問陣列元素的工具類。在Java中任何一個一維陣列的型別都是 " [I " ,究其原因就是Java並沒有定義陣列這一個類,它是在編譯器編譯的時候生成的,是一個特殊的類,在JDK的幫助中也沒有任何陣列類的資訊。

  弄清楚了問題,修改也很easy,直接使用包裝類即可,部分程式碼如下: 

Integer data [] = {1,2,3,4,5};

  把int替換為Integer即可讓輸出元素數量為5.需要說明的是,不僅僅是int型別的陣列有這個問題,其它7個基本型別的陣列也存在相似的問題,這就需要大家注意了,在把基本型別陣列轉換為列表時,要特別小心asList方法的陷阱,避免出現程式邏輯混亂的情況。

  注意:原始型別陣列不能作為asList的輸入引數,否則會引起程式邏輯混亂。

建議66:asList方法產生的List的物件不可更改

  上一個建議指出了asList方法在轉換基本型別陣列時存在的問題,接著我們看一下asList方法返回的列表有何特殊的地方,程式碼如下:  

 1 public class Client66 {
 2     public static void main(String[] args) {
 3         // 五天工作制
 4         Week days[] = { Week.Mon, Week.Tue, Week.Wed, Week.Thu, Week.Fri };
 5         // 轉換為列表
 6         List<Week> list = Arrays.asList(days);
 7         // 增加週六為工作日
 8         list.add(Week.Sat);
 9         /* do something */
10     }
11 }
12 enum Week {
13     Sun, Mon, Tue, Wed, Thu, Fri, Sat
14 }

  很簡單的程式呀,預設宣告的工作日(workDays)是從週一到週五,偶爾週六也會算作工作日加入到工作日列表中,不過,這段程式執行時會不會有什麼問題呢?編譯沒有任何問題,但是一執行,卻出現瞭如下結果:

  

UnsupportedOperationException,不支援的操作,居然不支援list的add方法,這是什麼原因呢?我們看看asList方法的原始碼:

 public static <T> List<T> asList(T... a) {
        return new ArrayList<>(a);
    }

  直接new了一個ArrayList物件返回,難道ArrayList不支援add方法,不可能呀!可能,問題就出現在這個ArrayList類上,此ArrayList非java.util.ArrayList,而是Arrays工具類的一個內部類,其建構函式如下所示: 

 1 private static class ArrayList<E> extends AbstractList<E>
 2         implements RandomAccess, java.io.Serializable
 3     {
 4         private static final long serialVersionUID = -2764017481108945198L;
 5         private final E[] a;
 6 
 7         ArrayList(E[] array) {
 8             if (array==null)
 9                 throw new NullPointerException();
10             a = array;
11         }
12      /*其它方法略*/   
13 }    

  這個ArrayList是一個靜態私有內部類,除了Arrays能訪問外,其它類都不能訪問,仔細看這個類,它沒有提供add方法,那肯定是父類AbstractList提供了,來看程式碼:  

1  public boolean add(E e) {
2         add(size(), e);
3         return true;
4     }
5 
6  public void add(int index, E element) {
7         throw new UnsupportedOperationException();
8     }

  父類確實提供了,但沒有提供具體的實現,所以每個子類都需要自己覆寫add方法,而Arrays的內部類ArrayList沒有覆寫,因此add一個元素就報錯了。

  我們深入地看看這個ArrayList靜態內部類,它僅僅實現了5個方法:

  1. size:元素數量
  2. get:獲得制定元素
  3. set:重置某一元素值
  4. contains:是否包含某元素
  5. toArray:轉化為陣列,實現了陣列的淺拷貝

  把這幾個方法的原始碼展示一下: 

 1  //元素數量
 2        public int size() {
 3             return a.length;
 4         }
 5 
 6         public Object[] toArray() {
 7             return a.clone();
 8         }
 9         //轉化為陣列,實現了陣列的淺拷貝
10         public <T> T[] toArray(T[] a) {
11             int size = size();
12             if (a.length < size)
13                 return Arrays.copyOf(this.a, size,
14                                      (Class<? extends T[]>) a.getClass());
15             System.arraycopy(this.a, 0, a, 0, size);
16             if (a.length > size)
17                 a[size] = null;
18             return a;
19         }
20         //獲得指定元素
21         public E get(int index) {
22             return a[index];
23         }
24         //重置某一元素
25         public E set(int index, E element) {
26             E oldValue = a[index];
27             a[index] = element;
28             return oldValue;
29         }
30 
31         public int indexOf(Object o) {
32             if (o==null) {
33                 for (int i=0; i<a.length; i++)
34                     if (a[i]==null)
35                         return i;
36             } else {
37                 for (int i=0; i<a.length; i++)
38                     if (o.equals(a[i]))
39                         return i;
40             }
41             return -1;
42         }
43         //是否包含元素
44         public boolean contains(Object o) {
45             return indexOf(o) != -1;
46         }

  對於我們經常使用list.add和list.remove方法它都沒有實現,也就是說asList返回的是一個長度不可變的列表,陣列是多長,轉換成的列表也就是多長,換句話說此處的列表只是陣列的一個外殼,不再保持列表的動態變長的特性,這才是我們關注的重點。有些開發人員喜歡這樣定義個初始化列表: 

    List<String> names= Arrays.asList("張三","李四","王五");

  一句話完成了列表的定義和初始化,看似很便捷,卻隱藏著重大隱患---列表長度無法修改。想想看,如果這樣一個List傳遞到一個允許新增的add操作的方法中,那將會產生何種結果,如果有這種習慣的javaer,請慎之戒之,除非非常自信該List只用於只讀操作。

建議67:不同的列表選擇不同的遍歷演算法

   我們思考這樣一個案例:統計一個省的各科高考平均值,比如數學平均分是多少,語文平均分是多少等,這是每年招生辦都會公佈的資料,我們來想想看該演算法應如何實現。當然使用資料庫中的一個SQL語句就可能求出平均值,不過這不再我們的考慮之列,這裡還是使用純Java的演算法來解決之,看程式碼:  

 1 public class Client67 {
 2     public static void main(String[] args) {
 3         // 學生數量 80萬
 4         int stuNum = 80 * 10000;
 5         // List集合,記錄所有學生的分數
 6         List<Integer> scores = new ArrayList<Integer>(stuNum);
 7         // 寫入分數
 8         for (int i = 0; i < stuNum; i++) {
 9             scores.add(new Random().nextInt(150));
10         }
11         // 記錄開始計算 時間
12         long start = System.currentTimeMillis();
13         System.out.println("平均分是:" + average(scores));
14         long end = System.currentTimeMillis();
15         System.out.println("執行時間:" + (end - start) + "ms");
16     }
17 
18     public static int average(List<Integer> scores) {
19         int sum = 0;
20         // 遍歷求和
21         for (int i : scores) {
22             sum += i;
23         }
24         return sum / scores.size();
25     }
26 }

  把80萬名學生的成績放到一個ArrayList陣列中,然後通過foreach方法遍歷求和,再計算平均值,程式很簡單,輸出結果:

  平均分是:74
     執行時間:11ms

  僅僅計算一個算術平均值就花了11ms,不要說什麼其它諸如加權平均值,補充平均值等演算法,那花的時間肯定更長。我們仔細分析一下average方法,加號操作是最基本的,沒有什麼可以優化的,剩下的就是一個遍歷了,問題是List的遍歷可以優化嗎?

  我們嘗試一下,List的遍歷還有另外一種方式,即通過下標方式來訪問,程式碼如下:

1 public static int average(List<Integer> scores) {
2         int sum = 0;
3         // 遍歷求和
4         for (int i = 0; i < scores.size(); i++) {
5             sum += scores.get(i);
6         }
7         return sum / scores.size();
8     }

  不再使用foreach遍歷,而是採用下標方式遍歷,我們看看輸出結果:

          平均分是:74
             執行時間:8ms
  執行時間已經下降,如果資料量更大,會更明顯。那為什麼我們使用下標方式遍歷陣列可以提高的效能呢?

  這是因為ArrayList陣列實現了RandomAccess介面(隨機存取介面),這樣標誌著ArrayList是一個可以隨機存取的列表。在Java中,RandomAccess和Cloneable、Serializable一樣,都是標誌性介面,不需要任何實現,只是用來表明其實現類具有某種特質的,實現了Cloneable表明可以被拷貝,實現了Serializable介面表明被序列化了,實現了RandomAccess介面則表明這個類可以隨機存取,對我們的ArrayList來說也就標誌著其資料元素之間沒有關聯,即兩個位置相鄰的元素之間沒有相互依賴和索引關係,可以隨機訪問和存取。我們知道,Java的foreach語法時iterator(迭代器)的變形用法,也就是說上面的foreach與下面的程式碼等價:

for (Iterator<Integer> i = scores.iterator(); i.hasNext();) {
            sum += i.next();
        }

  那我們再想想什麼是迭代器,迭代器是23中設計模式中的一種,"提供一種方法訪問一個容器物件中的各個元素,同時又無須暴露該物件的內部細節",也就是說對於ArrayList,需要先建立一個迭代器容器,然後遮蔽內部遍歷細節,對外提供hasNext、next等方法。問題是ArrayList實現RandomAccess介面,表明元素之間本來沒有關係,可是,為了使用迭代器就需要強制建立一種相互"知曉"的關係,比如上一個元素可以判斷是否有下一個元素,以及下一個元素時什麼等關係,這也就是foreach遍歷耗時的原因。

  Java的ArrayList類加上了RandomAccess介面,就是在告訴我們,“ArrayList是隨機存取的,採用下標方式遍歷列表速度回更快”,接著又有一個問題,為什麼不把RandomAccess介面加到所有List的實現類上呢?

  那是因為有些List的實現類不是隨機存取的,而是有序存取的,比如LinkedList類,LinkedList類也是一個列表,但它實現了雙向連結串列,每個資料節點都有三個資料項:前節點的引用(Previous Node)、本節點元素(Node Element)、後繼結點的引用(Next Node),這是資料結構的基本知識,不多講了,也就是說在LinkedList中的兩個元素本來就是有關聯的,我知道你的存在,你也知道我的存在。那大家想想看,元素之間已經有關聯了,使用foreach也就是迭代器方式是不是效率更高呢?我們修改一下例子,程式碼如下:

 1 public static void main(String[] args) {
 2         // 學生數量 80萬
 3         int stuNum = 80 * 10000;
 4         // List集合,記錄所有學生的分數
 5         List<Integer> scores = new LinkedList<Integer>();
 6         // 寫入分數
 7         for (int i = 0; i < stuNum; i++) {
 8             scores.add(new Random().nextInt(150));
 9         }
10         // 記錄開始計算 時間
11         long start = System.currentTimeMillis();
12         System.out.println("平均分是:" + average(scores));
13         long end = System.currentTimeMillis();
14         System.out.println("執行時間:" + (end - start) + "ms");
15     }
16 
17     public static int average(List<Integer> scores) {
18         int sum = 0;
19         // 遍歷求和
20         for (int i : scores) {
21             sum += i;
22         }
23         return sum / scores.size();
24     }

輸出結果為:  平均分是:74 執行時間:12ms 。執行效率還好。但是比ArrayList就慢了,但如果LinkedList採用下標方式遍歷:效率會如何呢?我告訴你會很慢。直接分析一下原始碼: 

1 public E get(int index) {
2         checkElementIndex(index);
3         return node(index).item;
4     }

  由node方法查詢指定下標的節點,然後返回其包含的元素,看node方法: 

 1 Node<E> node(int index) {
 2         // assert isElementIndex(index);
 3 
 4         if (index < (size >> 1)) {
 5             Node<E> x = first;
 6             for (int i = 0; i < index; i++)
 7                 x = x.next;
 8             return x;
 9         } else {
10             Node<E> x = last;
11             for (int i = size - 1; i > index; i--)
12                 x = x.prev;
13             return x;
14         }
15     }

  看懂了嗎?程式會先判斷輸入的下標與中間值(size右移一位,也就是除以2了)的關係,小於中間值則從頭開始正向搜尋,大於中間值則從尾節點反向搜尋,想想看,每一次的get方法都是一個遍歷,"效能"兩字從何說去呢?  

  明白了隨機存取列表和有序存取列表的區別,我們的average方法就必須重構了,以便實現不同的列表採用不同的遍歷方式,程式碼如下: 

 1 public static int average(List<Integer> scores) {
 2         int sum = 0;
 3 
 4         if (scores instanceof RandomAccess) {
 5             // 可以隨機存取,則使用下標遍歷
 6             for (int i = 0; i < scores.size(); i++) {
 7                 sum += scores.get(i);
 8             }
 9         } else {
10             // 有序存取,使用foreach方式
11             for (int i : scores) {
12                 sum += i;
13             }
14         }
15         return sum / scores.size();
16     }

  如此一來,列表的遍歷就可以"以不變應萬變"了,無論是隨機存取列表還是有序列表,它都可以提供快速的遍歷。

  注意:列表遍歷不是那麼簡單的,適時選擇最優的遍歷方式,不要固化為一種。  

建議68:頻繁插入和刪除時使用LinkList

   上一個建議介紹了列表的遍歷方式,也就是“讀” 操作,本建議將介紹列表的"寫"操作:即插入、刪除、修改動作。

  (1)、插入元素:列表中我們使用最多的是ArrayList,下面來看看它的插入(add方法)演算法,原始碼如下:

 1 public void add(int index, E element) {
 2         //檢查下標是否越界
 3         rangeCheckForAdd(index);
 4         //若需要擴容,則增大底層陣列的長度
 5         ensureCapacityInternal(size + 1);  // Increments modCount!!
 6         //給index下標之後的元素(包括當前元素)的下標加1,空出index位置
 7         System.arraycopy(elementData, index, elementData, index + 1,
 8                          size - index);
 9         //賦值index位置元素
10         elementData[index] = element;
11         //列表長度加1
12         size++;
13     }

  注意看arrayCopy方法,只要插入一個元素,其後的元素就會向後移動一位,雖然arrayCopy是一個本地方法,效率非常高,但頻繁的插入,每次後面的元素都要拷貝一遍,效率就更低了,特別是在頭位置插入元素時,現在的問題是,開發中確實會遇到要插入的元素的情況,哪有什麼更好的方法解決此效率問題嗎?

  有,使用LinkedList即可。我麼知道LinkedList是一個雙向列表,它的插入只是修改了相鄰元素的next和previous引用,其插入演算法(add方法)如下:

  

1 public void add(int index, E element) {
2         checkPositionIndex(index);
3 
4         if (index == size)
5             linkLast(element);
6         else
7             linkBefore(element, node(index));
8     }
 1  void linkLast(E e) {
 2         final Node<E> l = last;
 3         final Node<E> newNode = new Node<>(l, e, null);
 4         last = newNode;
 5         if (l == null)
 6             first = newNode;
 7         else
 8             l.next = newNode;
 9         size++;
10         modCount++;
11     }
 1 void linkBefore(E e, Node<E> succ) {
 2         // assert succ != null;
 3         final Node<E> pred = succ.prev;
 4         final Node<E> newNode = new Node<>(pred, e, succ);
 5         succ.prev = newNode;
 6         if (pred == null)
 7             first = newNode;
 8         else
 9             pred.next = newNode;
10         size++;
11         modCount++;
12     }

這個方法,第一步檢查是否越界,下來判斷插入元素的位置與列表的長度比較,如果相等,呼叫linkLast,否則呼叫linkBefore方法。但這兩個方法的共同點都是雙向連結串列插入演算法,把自己插入到連結串列,然後再把前節點的next和後節點的previous指向自己。想想看,這樣插入一個元素的過程中,沒有任何元素會有拷貝過程,只是引用地址變了,那效率自然就高了。

  (2)、刪除元素:插入瞭解清楚了,我們再來看看刪除動作。ArrayList提供了刪除指定位置上的元素,刪除指定元素,刪除一個下標範圍內的元素集等刪除動作。三者的實現原理基本相似,都是找索引位置,然後刪除。我們以最常用的刪除指定下標的方法(remove方法)為例來看看刪除動作的效能到底如何,原始碼如下:  

 1 public E remove(int index) {
 2         //下標校驗
 3         rangeCheck(index);
 4         //修改計數器加1
 5         modCount++;
 6         //記錄要刪除的元素
 7         E oldValue = elementData(index);
 8         //有多少個元素向前移動
 9         int numMoved = size - index - 1;
10         if (numMoved > 0)
11             //index後的元素向前移動一位
12             System.arraycopy(elementData, index+1, elementData, index,
13                              numMoved);
14         //列表長度減1
15         elementData[--size] = null; // Let gc do its work
16         //返回刪除的值
17         return oldValue;
18     }

  注意看,index位置後的元素都向前移動了一位,最後一個位置空出來了,這又是一個陣列拷貝,和插入一樣,如果資料量大,刪除動作必然會暴露出效能和效率方面的問題。ArrayList其它的兩個刪除方法與此類似,不再贅述。

  我麼再來看LinkedList的刪除動作。LinkedList提供了非常多的刪除操作,比如刪除指定位置元素,刪除頭元素等,與之相關的poll方法也會執行刪除動作,下面來看最基本的刪除指定位置元素的方法remove,原始碼如下: 

 public E remove(int index) {
        checkElementIndex(index);
        return unlink(node(index));
    }
 1  E unlink(Node<E> x) {
 2         // assert x != null;
 3         final E element = x.item;
 4         final Node<E> next = x.next;
 5         final Node<E> prev = x.prev;
 6 
 7         if (prev == null) {
 8             first = next;
 9         } else {
10             prev.next = next;
11             x.prev = null;
12         }
13 
14         if (next == null) {
15             last = prev;
16         } else {
17             next.prev = prev;
18             x.next = null;
19         }
20 
21         x.item = null;
22         size--;
23         modCount++;
24         return element;
25     }
 1 private static class Node<E> {
 2         E item;
 3         Node<E> next;
 4         Node<E> prev;
 5 
 6         Node(Node<E> prev, E element, Node<E> next) {
 7             this.item = element;
 8             this.next = next;
 9             this.prev = prev;
10         }
11     }

  這也是雙向連結串列標準刪除演算法,沒有任何耗時的操作,全部都是引用指標的變更,效率自然高了。

  (3)、修改元素:寫操作還有一個動作,修改元素值,在這一點上LinkedList輸給了ArrayList,這是因為LinkedList是按順序儲存的,因此定位元素必然是一個遍歷過程,效率大打折扣,我們來看Set方法的程式碼:  

1   public E set(int index, E element) {
2         checkElementIndex(index);
3         //定位節點
4         Node<E> x = node(index);
5         E oldVal = x.item;
6         //節點元素替換
7         x.item = element;
8         return oldVal;
9     }

  看似很簡潔,但是這裡使用了node方法定位元素,上一個建議中我們已經說明了LinkedList這種順序存取列表的元素定位方式會折半遍歷,這是一個極耗時的操作,而ArrayList的修改動作則是陣列元素的直接替換,簡單高效。

  在修改動作上,LinkedList比ArrayList慢很多,特別是要進行大量的修改時,兩者完全不在一個數量級上。

  上面通過分析原始碼完成了LinkedList與ArrayList之間的PK,其中LinkedList勝兩局:刪除和插入效率高ArrayList勝一局:修改元素效率高。總體來說,在寫方面LinkedList佔優勢,而且在實際使用中,修改是一個比較少的動作。因此有大量寫的操作(更多的是插入和刪除),推薦使用LinkedList。不過何為少量?何為大量呢?

  這就依賴於我們們在開發中系統了,具體情況具體分析了。

建議69:列表相等只關心元素資料

   我們來看一個比較列表相等的例子,程式碼如下:

 1 public class Client69 {
 2     public static void main(String[] args) {
 3         ArrayList<String> strs = new ArrayList<String>();
 4         strs.add("A");
 5         
 6         Vector<String> strs2 = new Vector<String>();
 7         strs2.add("A");
 8         
 9         System.out.println(strs.equals(strs2));
10     }
11 }

  兩個類都不同,一個是ArrayList,一個是Vector,那結果肯定不相等了。真是這樣嗎?但其實結果為true,也就是兩者相等。

  我們分析一下,兩者為何相等,兩者都是列表(List),都實現了List介面,也都繼承了AbstractList抽象類,其equals方法是在AbstractList中定義的,我們來看原始碼:

 1 public boolean equals(Object o) {
 2             if (o == this)
 3                 return true;
 4             //是否是列表,注意這裡:只要實現List介面即可
 5             if (!(o instanceof List))
 6                 return false;
 7             //通過迭代器訪問List的所有元素
 8             ListIterator<E> e1 = listIterator();
 9             ListIterator e2 = ((List) o).listIterator();
10             //遍歷兩個List的元素
11             while (e1.hasNext() && e2.hasNext()) {
12                 E o1 = e1.next();
13                 Object o2 = e2.next();
14                 //只要存在著不相等就退出
15                 if (!(o1==null ? o2==null : o1.equals(o2)))
16                     return false;
17             }
18             //長度是否也相等
19             return !(e1.hasNext() || e2.hasNext());
20         }

  看到沒,這裡只要實現了List介面就成,它不關心List的具體實現類,只要所有元素相等,並且長度也相等就表明兩個List是相等的,與具體的容量型別無關。也就是說,上面的例子雖然一個是Arraylist,一個是Vector,只要裡面的元素相等,那結果也就相等。

  Java如此處理也確實是在為開發者考慮,列表只是一個容器,只要是同一種型別的容器(如List),不用關心,容器的細節差別,只要確定所有的元素資料相等,那這兩個列表就是相等的,如此一來,我們在開發中就不用太關注容器的細節了,可以把注意力更多地放在資料元素上,而且即使中途重構容器型別,也不會對相等的判斷產生太大的影響。

  其它的集合型別,如Set、Map等於此相同,也是隻關心集合元素,不用考慮集合型別。

  注意:判斷集合是否相等時只須關注元素是否相等即可。

相關文章