認識 Collection 架構

September 29, 2022

針對收集物件的需求,Java SE 提供了Collection API,而 Lambda 新特性的加入,對 Collection API 在運用時增添不少幫助,瞭解 Collection API,是瞭解 Lambda 應用時不錯的起點。

API 架構

Collection API 架構設計如下:

認識 Collection 架構

收集物件的行為,像是新增物件的 add 方法,移除物件的 remove 方法等,都是定義在 java.util.Collection。既然可以收集物件,也要能逐一取得物件,這就是 java.lang.Iterable 定義的行為,它定義了 iterator 方法傳回 java.util.Iterator 實作物件,可以讓你逐一取得收集之物件。

收集物件的共同行為定義在 Collection,然而收集物件會有不同的需求。如果希望收集時記錄每個物件的索引順序,並可依索引取回物件,這樣的行為定義在 java.util.List 介面。如果希望收集的物件不重複,具有集合的行為,由 java.util.Set 定義。如果希望收集物件時可以佇列方式,收集的物件加入至尾端,取得物件時可以從前端,可以使用 java.util.Queue。如果希望可以對Queue的兩端進行加入、移除等操作,則可以使用 java.util.Deque

收集物件時,會依需求使用不同的介面實作物件,舉例來說,如果想收集時具有索引順序,實作方式之一就是使用陣列,而以陣列實作 List 的就是 java.util.ArrayList,如果查看 API 文件,會發現有以下繼承與實作架構:

認識 Collection 架構

Java SE API 不僅提供許多已實作類別,也考慮到自行擴充 API 的需求,以收集物件的基本行為來說,提供 java.util.AbstractCollection 實作了 Collection 基本行為,java.util.AbstractList 實作了 List 基本行為,必要時可以繼承 AbstractCollection 實作自己的 Collection,繼承 AbstractList 實作自己的 List,這會比直接實作 CollectionList 介面方便許多。

有時為了只表示我們感興趣的介面或類別,會簡化繼承與實作架構圖。例如:

認識 Collection 架構

這樣的表示方式,可以更清楚地明瞭哪些類別實作了哪個介面、繼承了哪個類別,或哪些介面又繼承自哪個介面,至於詳細的繼承與實作架構,可在 API 文件上查詢。

具有索引的 List

List 是一種 Collection,作用是收集物件,並以索引方式保留收集的物件順序,其實作類別之一是 java.util.ArrayList,其實作原理大致如〈定義與使用泛型〉的 ArrayList 範例。

例如可用 java.util.ArrayList 改寫〈final/Object/instanceof〉的 Guest 類別,而作用相同:

package cc.openhome;

import java.util.*;
import static java.lang.System.out;

public class Guest {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        collectNameTo(names);
        out.println("訪客名單:");
        printUpperCase(names);
    }

    static void collectNameTo(List<String> names) {
        Scanner scanner = new Scanner(System.in);
        String name;
        while(true) {
            out.print("訪客名稱:");
            name = scanner.nextLine();
            if(name.equals("quit")) {
                break;
            }
            names.add(name);
        }
    }

    static void printUpperCase(List<String> names) {
        for(int i = 0; i < names.size(); i++) {
            String name = names.get(i);
            out.println(name.toUpperCase());
        }        
    }
}

你可以查看API文件,可發現 List 介面定義了 addremoveset 等許多依索引操作的方法。java.util.LinkedList 也實作了 List 介面,可以將上面的範例中 ArrayList 換為 LinkedList 而結果不變,什麼時候該用 ArrayList?何時該用 LinkedList 呢?

java.util.ArrayList 實作時,內部就是使用陣列來保存收集之物件,考慮是否使用 ArrayList,就等於考慮是否要使用到陣列的特性。

陣列在記憶體中會是連續的線性空間,根據索引隨機存取時速度快,如果操作上有這類需求時,像是排序,就可使用 ArrayList,可得到較好的速度表現。

陣列在記憶體中會是連續的線性空間,如果需要調整索引順序時,會有較差的表現。例如若在已收集 100 物件的 ArrayList,使用可指定索引的 add 方法,將物件新增到索引 0 位置,那麼原先索引 0 的物件必須調整至索引 1、索引 1 的物件必須調整至索引 2、索引 2 的物件必須調整至索引 3…依此類推,使用 ArrayList 做這類的操作並不經濟。

陣列的長度固定也是要考量的問題,在 ArrayList 內部陣列長度不夠時,會建立新陣列,並將舊陣列的參考指定給新陣列,這也是必需耗費時間與記憶體的操作,為此,ArrayList 有個可指定容量(Capacity)的建構式,如果大致知道將收集的物件範圍,事先建立足夠長度的內部陣列,可以節省以上所描述之成本。

LinkedList 在實作 List 介面時,採用了鏈結(Link)結構,若你不是很瞭解何謂鏈結,可參考底下的 SimpleLinkedList 範例:

package cc.openhome;

public class SimpleLinkedList<E> {
    private class Node {
        Node(E elem) {
            this.elem = elem;
        }
        E elem;
        Node next;
    }
    
    private Node first;

    public void add(E elem) {
        Node node = new Node(elem);        
        if(first == null) {
            first = node;
        }
        else {
            append(node);
        }
    }

    private void append(Node node) {
        Node last = first;
        while(last.next != null) {
            last = last.next;
        }
        last.next = node;
    }
    
    public int size() {
        int count = 0;
        Node last = first;
        while(last != null) {
            last = last.next;
            count++;
        }
        return count;
    }
    
    public E get(int index) {
        checkSize(index);
        return findElemOf(index);        
    }

    private void checkSize(int index) throws IndexOutOfBoundsException {
        int size = size();
        if(index >= size) {
            throw new IndexOutOfBoundsException(
                    String.format("Index: %d, Size: %d", index, size));
        }
    }

    private E findElemOf(int index) {
        int count = 0;
        Node last = first;
        while(count < index) {
            last = last.next;
            count++;
        }
        return last.elem;
    }
}

SimpleLinkedList 內部使用 Node 封裝新增的物件,每次 add 新增物件之後,將會形成以下的鏈狀結構:

認識 Collection 架構

每次 add 物件時,才會建立新的 Node 來保存物件,不會事先耗費記憶體,若呼叫 size,則從第一個物件,逐一參考下一個物件並計數,則可取得收集的物件長度。若想呼叫 get 指定索引取得物件,從第一個物件,逐一參考下一個物件並計數,則可取得指定索引之物件。

可以看出,想要指定索引隨機存取物件時,鏈結方式都得使用從第一個元素開始查找下一個元素的方式,會比較沒有效率,像排序就不適合使用鏈結實作的 List,想像一下,如果排序時,剛好必須將索引 0 與索引 10000 的元素調換,效率會不會好呢?

鏈結的每個元素會參考下一個元素,這有利於調整索引順序,例如,若在已收集 100 物件的 SimpleLinkedList,實作可指定索引的 add 方法,將物件新增到索引 0 位置,則概念上如下圖:

認識 Collection 架構

新增的物件將建立 Node 實例封裝,而 first(或上一節點的 next)重新參考至新建的 Node 物件,新建 Nodenext 參考至下一 Node 物件。因此,若你收集的物件經常會有變動索引的情況,也許考慮鏈結方式實作的 List 會比較好,像是隨時會有客戶端登入或登出的客戶端 List,使用 LinkedList 會有比較好的效率。

內容不重複的 Set

同樣是收集物件,在收集過程中若有相同物件,則不再重複收集,若你有這類需求,可以使用 Set 介面的實作物件。例如,若有一個字串,當中有許多的英文單字,你希望知道不重複的單字有幾個,那麼可以如下撰寫程式:

package cc.openhome;

import java.util.*;

public class WordCount {
    public static void main(String[] args) {
        String line = input("請輸入英文:");
        Set<String> words = tokens(line);
        System.out.printf("不重複單字有 %d 個:%s%n",
                words.size(), words);
    }

    static String input(String prompt) {
        System.out.print(prompt);
        Scanner scanner = new Scanner(System.in);
        String line = scanner.nextLine();
        return line;
    }
    
    static Set<String> tokens(String line) {
        String[] tokens = line.split(" ");
        Set<String> words = new HashSet<>();
        for(String token : tokens) {
            words.add(token);
        }
        return words;
    }
}

Stringsplit 方法,可以指定切割字串的方式,在這邊指定以空白切割,split 會傳回 String[],包括切割的每個字串,接著將 String[] 的每個字串加入 Set 的實作 HashSet 中,由於 Set 的特性是不重複,因此若有相同單字,則不會再重複加入,最後只要呼叫Set的size()方法,就可以知道收集的字串個數,HashSettoString 實作,則會包括收集的字串。一個執行的範例如下:

請輸入英文:This is a dog that is a cat where is the student
不重複單字有 9 個:[that, cat, is, student, a, the, where, dog, This]

再來看以下的範例:

package cc.openhome;

import java.util.*;

class Student {
    private String name;
    private String number;
    Student(String name, String number) {
        this.name = name;
        this.number = number;
    }
    
    @Override
    public String toString()  {
        return String.format("(%s, %s)", name, number);
    }
}

public class Students {
    public static void main(String[] args) {
        Set<Student> set = new HashSet<>();
        set.add(new Student("Justin", "B835031"));
        set.add(new Student("Monica", "B835032"));
        set.add(new Student("Justin", "B835031"));
        System.out.println(set);
    }
}

程式中使用 Set 收集了 Student 物件,其中故意重複加入了相同的學生資料,然而在執行結果中看到,Set 並沒有將重複的學生資料排除:

[(Monica, B835032), (Justin, B835031), (Justin, B835031)]

這是理所當然的結果,因為你並沒有告訴 Set,什麼樣的 Student 實例才算是重複,以 HashSet 為例,會使用物件的 hashCodeequals 來判斷物件是否相同,HashSet 的實作概念是,在記憶體中開設空間,每個空間會有個雜湊編碼(Hash code):

認識 Collection 架構

這些空間稱為雜湊桶(Hash bucket),如果物件要加入 HashSet,會呼叫物件的 hashCode 取得雜湊碼,並嘗試放入對應號碼的雜湊桶中,如果雜湊桶中沒物件,則直接放入,如上圖所示;如果雜湊桶中有物件呢?會再呼叫物件的 equals 進行比較:

認識 Collection 架構

如果同一個雜湊桶中已有物件,呼叫該物件 equals 與要加入的物件比較結果為 false,表示兩個物件非重複物件,可以收集,如果是 true,表示兩個物件是重複物件,則不予收集。

事實上不只有 HashSet,Java 許多場合要判斷物件是否重複時,都會呼叫 hashCodeequals 方法,因此規格書中建議,兩個方法必須同時實作。以先前範例而言,若實作了 hashCodeequals 方法,重複的 Student 將不會被收集:

package cc.openhome;

import java.util.*;

class Student {
    private String name;
    private String number;
    Student(String name, String number) {
        this.name = name;
        this.number = number;
    }

    @Override
    public int hashCode() {
        // Objects 有 hash 方法可以使用
        // 以下可以簡化為 return Objects.hash(name, number);
        int hash = 7;
        hash = 47 * hash + Objects.hashCode(this.name);
        hash = 47 * hash + Objects.hashCode(this.number);
        return hash;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final Student other = (Student) obj;
        if (!Objects.equals(this.name, other.name)) {
            return false;
        }
        if (!Objects.equals(this.number, other.number)) {
            return false;
        }
        return true;
    }
    
    @Override
    public String toString()  {
        return String.format("(%s, %s)", name, number);
    }
}

public class Students {
    public static void main(String[] args) {
        Set<Student> set = new HashSet<>();
        set.add(new Student("Justin", "B835031"));
        set.add(new Student("Monica", "B835032"));
        set.add(new Student("Justin", "B835031"));
        System.out.println(set);
    }
}

在這邊定義學生的姓名與學號相同,表示為相同 Student 物件,hashCode 則直接利用 ObjectshashCode 再作運算。執行結果如下,可看出不再收集重複的 Student 物件:

[(Justin, B835031), (Monica, B835032)]

如果 Student 會作為純綷的資料載體,可以使用 record 來定義,編譯器能自動生成 hashCodeequals 以及 toString 等方法:

package cc.openhome;

import java.util.*;

record Student(String name, String number) {} 

public class Students {
    public static void main(String[] args) {
        var students = new HashSet();
        students.add(new Student("Justin", "B835031"));
        students.add(new Student("Monica", "B835032"));
        students.add(new Student("Justin", "B835031"));
        System.out.println(students);
    }
}

以上範例執行結果如下,可看出沒有重複的物件,也使用了預設的 toString 作為顯示之用:

[Student[name=Justin, number=B835031], Student[name=Monica, number=B835032]]

支援佇列操作的 Queue

如果希望收集物件時可以佇列方式,收集的物件加入至尾端,取得物件時可以從前端,則可以使用 Queue 介面的實作物件。Queue 繼承自 Collection,也具有 Collectionaddremoveelement 等方法,然而 Queue 定義了自己的 offerpollpeek 等方法,最主要的差別之一在於,addremoveelement 等方法操作失敗時會拋出例外,而 offer、pollpeek 等方法操作失敗時會傳回特定值。

如果物件有實作 Queue,並打算以佇列方式使用,且佇列長度受限,通常建議使用 offerpollpeek 等方法。offer 方法用來在佇列後端加入物件,成功會傳回 true,失敗則傳回 falsepoll 方法用來取出佇列前端物件,若佇列為空則傳回 nullpeek 用來取得(但不取出)佇列前端物件,若佇列為空則傳回 null

先前提過 LinkedList,它不僅實作了 List 介面,也實作了 Queue 的行為,可將 LinkedList 當作佇列來使用。例如:

package cc.openhome;

import java.util.*;

interface Request {
    void execute();
}

public class RequestQueue {

    public static void main(String[] args) {
        Queue<Request> requests = new LinkedList<>();
        offerRequestTo(requests);
        process(requests);
    }

    static void offerRequestTo(Queue<Request> requests) {
        // 模擬將請求加入佇列
        for (int i = 1; i < 6; i++) {
            requests.offer(
                    () -> System.out.printf("處理資料 %f%n", Math.random())
            );
        }
    }
    // 處理佇列中的請求
    static void process(Queue<Request> requests) {
        while(requests.peek() != null) {
            Request request = requests.poll();
            request.execute();
        }
    }
}

一個執行結果如下:

處理資料 0.302919
處理資料 0.616828
處理資料 0.589967
處理資料 0.475854
處理資料 0.274380

經常地,你也會想對佇列的前端與尾端進行操作,在前端加入物件與取出物件,在尾端加入物件與取出物件,Queue 的子介面 Deque 就定義了這類行為,Deque 定義 addFirstremoveFirstgetFirstaddLastremoveLastgetLast 等方法,操作失敗時會拋出例外,而 offerFirstpollFirstpeekFirstofferLastpollLastpeekLast 等方法,操作失敗時會傳回特定值。

Queue 的行為與 Deque 的行為有所重複,有幾個操作是等義的:

Queue 方法 Deque 等義方法
add addLast
offer offerLast
remove removeFirst
poll pollFirst
element getFirst
peek peekFirst

java.util.ArrayDeque 實作了 Deque 介面,以下範例是使用 ArrayDeque 來實作容量有限的堆疊:

package cc.openhome;

import java.util.*;
import static java.lang.System.out;

public class Stack<E> {
    private Deque<E> deque = new ArrayDeque<>();
    private int capacity;
    
    public Stack(int capacity) {
        this.capacity = capacity;
    }
    
    public boolean push(E elem) {
        if(isFull()) {
            return false;
        }
        return deque.offerLast(elem);
    }

    private boolean isFull() {
        return deque.size() + 1 > capacity;
    }
    
    public E pop() {
        return deque.pollLast();
    }
    
    public E peek() {
        return deque.peekLast();
    }
    
    public int size() {
        return deque.size();
    }
    
    public static void main(String[] args) {
        Stack<String> stack = new Stack<>(5);
        stack.push("Justin");
        stack.push("Monica");
        stack.push("Irene");
        out.println(stack.pop());
        out.println(stack.pop());
        out.println(stack.pop());
    }
}

堆疊結構是先進後出,執行結果最後才顯示 Justin:

Irene
Monica
Justin

分享到 LinkedIn 分享到 Facebook 分享到 Twitter