認識 Collection 架構
September 29, 2022針對收集物件的需求,Java SE 提供了Collection API,而 Lambda 新特性的加入,對 Collection API 在運用時增添不少幫助,瞭解 Collection API,是瞭解 Lambda 應用時不錯的起點。
API 架構
Collection API 架構設計如下:
收集物件的行為,像是新增物件的 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 文件,會發現有以下繼承與實作架構:
Java SE API 不僅提供許多已實作類別,也考慮到自行擴充 API 的需求,以收集物件的基本行為來說,提供 java.util.AbstractCollection
實作了 Collection
基本行為,java.util.AbstractList
實作了 List
基本行為,必要時可以繼承 AbstractCollection
實作自己的 Collection
,繼承 AbstractList
實作自己的 List
,這會比直接實作 Collection
或 List
介面方便許多。
有時為了只表示我們感興趣的介面或類別,會簡化繼承與實作架構圖。例如:
這樣的表示方式,可以更清楚地明瞭哪些類別實作了哪個介面、繼承了哪個類別,或哪些介面又繼承自哪個介面,至於詳細的繼承與實作架構,可在 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
介面定義了 add
、remove
、set
等許多依索引操作的方法。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
新增物件之後,將會形成以下的鏈狀結構:
每次 add
物件時,才會建立新的 Node
來保存物件,不會事先耗費記憶體,若呼叫 size
,則從第一個物件,逐一參考下一個物件並計數,則可取得收集的物件長度。若想呼叫 get
指定索引取得物件,從第一個物件,逐一參考下一個物件並計數,則可取得指定索引之物件。
可以看出,想要指定索引隨機存取物件時,鏈結方式都得使用從第一個元素開始查找下一個元素的方式,會比較沒有效率,像排序就不適合使用鏈結實作的 List
,想像一下,如果排序時,剛好必須將索引 0 與索引 10000 的元素調換,效率會不會好呢?
鏈結的每個元素會參考下一個元素,這有利於調整索引順序,例如,若在已收集 100 物件的 SimpleLinkedList
,實作可指定索引的 add
方法,將物件新增到索引 0 位置,則概念上如下圖:
新增的物件將建立 Node
實例封裝,而 first
(或上一節點的 next
)重新參考至新建的 Node
物件,新建 Node
的 next
參考至下一 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;
}
}
String
的 split
方法,可以指定切割字串的方式,在這邊指定以空白切割,split
會傳回 String[]
,包括切割的每個字串,接著將 String[]
的每個字串加入 Set
的實作 HashSet
中,由於 Set
的特性是不重複,因此若有相同單字,則不會再重複加入,最後只要呼叫Set的size()方法,就可以知道收集的字串個數,HashSet
的 toString
實作,則會包括收集的字串。一個執行的範例如下:
請輸入英文: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
為例,會使用物件的 hashCode
與 equals
來判斷物件是否相同,HashSet
的實作概念是,在記憶體中開設空間,每個空間會有個雜湊編碼(Hash code):
這些空間稱為雜湊桶(Hash bucket),如果物件要加入 HashSet
,會呼叫物件的 hashCode
取得雜湊碼,並嘗試放入對應號碼的雜湊桶中,如果雜湊桶中沒物件,則直接放入,如上圖所示;如果雜湊桶中有物件呢?會再呼叫物件的 equals
進行比較:
如果同一個雜湊桶中已有物件,呼叫該物件 equals
與要加入的物件比較結果為 false
,表示兩個物件非重複物件,可以收集,如果是 true
,表示兩個物件是重複物件,則不予收集。
事實上不只有 HashSet
,Java 許多場合要判斷物件是否重複時,都會呼叫 hashCode
與 equals
方法,因此規格書中建議,兩個方法必須同時實作。以先前範例而言,若實作了 hashCode
與 equals
方法,重複的 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
則直接利用 Objects
的 hashCode
再作運算。執行結果如下,可看出不再收集重複的 Student
物件:
[(Justin, B835031), (Monica, B835032)]
如果 Student
會作為純綷的資料載體,可以使用 record
來定義,編譯器能自動生成 hashCode
、equals
以及 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
,也具有 Collection
的 add
、remove
、element
等方法,然而 Queue
定義了自己的 offer
、poll
與 peek
等方法,最主要的差別之一在於,add
、remove
、element
等方法操作失敗時會拋出例外,而 offer、poll
與 peek
等方法操作失敗時會傳回特定值。
如果物件有實作 Queue
,並打算以佇列方式使用,且佇列長度受限,通常建議使用 offer
、poll
與 peek
等方法。offer
方法用來在佇列後端加入物件,成功會傳回 true
,失敗則傳回 false
。poll
方法用來取出佇列前端物件,若佇列為空則傳回 null
。peek
用來取得(但不取出)佇列前端物件,若佇列為空則傳回 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
定義 addFirst
、removeFirst
、getFirst
、addLast
、removeLast
、getLast
等方法,操作失敗時會拋出例外,而 offerFirst
、pollFirst
、peekFirst
、offerLast
、pollLast
、peekLast
等方法,操作失敗時會傳回特定值。
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