介面就是契約
上一節的 第一行寫著 。 長這樣:
public interface Account {
/** 存入 amount。前置條件:amount >= 0 */
void deposit(int amount);
/** 提出 amount。前置條件:0 <= amount <= getBalance() */
void withdraw(int amount);
/** @return 目前餘額,後置條件:回傳值 >= 0 */
int getBalance();
}
介面是一份契約:列出一組方法的簽名(名稱、參數、回傳型別)與規格(前置/後置條件,寫在註解裡), 但沒有任何實作——沒有欄位、沒有方法本體。 一個類別宣告 ,就是承諾提供契約裡的每一個方法,少一個都無法編譯。
幾條語法規則:
介面裡的方法自動是 且抽象的(沒有本體,以分號結尾),不必也不該再寫 。
實作類別的方法必須是 ,並建議加上 —— 這個標註讓編譯器幫你檢查「我真的在實作介面的方法」,拼錯名字時會立刻報錯,而不是默默變成一個無關的新方法。
介面不能被 : 是編譯錯誤,因為它沒有任何實作。
一個類別可以同時實作多個介面:。
為什麼要多這一層?——解耦
裡有一個只認識 的函式:
static void handleAccount(Account a) { // 參數型別是介面,不是某個具體類別
System.out.println(" 餘額:" + a.getBalance());
a.deposit(500);
System.out.println(" 存入 500 後:" + a.getBalance());
}
於是 與 都合法, 而 的內部長得完全不一樣(它有 欄位、開戶送 10000 點、每次存入加送 1%)。 執行結果:
BankAccount:
餘額:0
存入 500 後:500
PointsAccount:
餘額:10000
存入 500 後:10505
解耦(decoupling): 對實作零依賴。日後新增 ,這個函式一個字都不必改。
可替換:測試時可以塞一個假的 進去(第 [sec:ws7] 節的 就是這麼做的)。
分工:介面是團隊的「合約書」。你寫使用端、同學寫實作端,只要雙方都對著同一份介面,最後一定能接起來 ——這正是 Week 6/7 Workshop 聊天專案的做法。
抽象資料型別
一個 ADT 是「一組操作」加上「這些操作的行為規格」,完全不提及資料如何儲存。 在 Java 裡,介面是表達 ADT 的自然工具; 實作該介面的每一個類別則是一種——ADT 的一種具體實現。
一句話:ADT 說「做什麼」,資料結構說「怎麼做」。
案例:聊天紀錄的兩種實作
考慮聊天室要保存的訊息紀錄。它的 ADT 非常簡單:
public interface ChatHistory {
void addMessage(String msg); // 新增一則訊息
void display(); // 最新在前,逐行印出
int size(); // 目前保存的訊息數量
}
同一份契約,我們寫出兩種截然不同的資料結構。
實作一:環狀緩衝區
固定容量的陣列,滿了以後新訊息覆蓋最舊的那一則。索引用 取餘數「繞回」開頭, 所以叫。
private final String[] arr; // 固定大小
private int nextAvailable; // 下一則要寫入的索引
private int size; // 目前保存的數量(<= arr.length)
@Override
public void addMessage(String msg) {
this.arr[this.nextAvailable] = msg;
this.nextAvailable = (this.nextAvailable + 1) % this.arr.length; // 繞回
if (this.size < this.arr.length) {
this.size = this.size + 1; // 還沒滿才 +1;滿了就是覆蓋
}
}
@Override
public void display() {
int idx = (this.nextAvailable - 1 + this.arr.length) % this.arr.length; // 最新
for (int i = 0; i < this.size; i++) {
System.out.println(this.arr[idx]);
idx = (idx - 1 + this.arr.length) % this.arr.length; // 往回退一格
}
}
Java 的 對負數會得到負數: 是 ,拿去當索引會直接 。先加一個 保證被除數非負, 才是我們要的「從 0 退到 4」。 (或者用 ,第 [sec:hash] 節算雜湊桶時會再用到。)
實作二:鏈結串列
不限數量、永不覆蓋。新訊息永遠接在最前面(prepend),所以串列天生就是「最新在前」的順序。
public class LinkedListChatHistory implements ErasableChatHistory {
private static class Node { // 靜態巢狀類別:外界看不到
final String msg;
final Node rest;
Node(String msg, Node rest) { this.msg = msg; this.rest = rest; }
}
private Node head = null; // null 代表空串列
private int size = 0;
@Override
public void addMessage(String msg) {
this.head = new Node(msg, this.head); // 新節點指向舊 head,再成為新 head
this.size = this.size + 1;
}
@Override
public void display() {
Node current = this.head;
while (current != null) {
System.out.println(current.msg);
current = current.rest;
}
}
@Override public int size() { return this.size; }
@Override public void erase() { this.head = null; this.size = 0; }
}
同一段程式碼,兩種實作
static void simulateChat(ChatHistory history) { // 只認識 ChatHistory
history.addMessage("Alice: 嗨!");
history.addMessage("Bob: 你好~");
history.addMessage("Alice: 週六要一起讀 Java 嗎?");
history.addMessage("Bob: 好啊,下午兩點圖書館見。");
System.out.println(" (共 " + history.size() + " 則,最新在前)");
history.display();
}
simulateChat(new LinkedListChatHistory()); // 4 則全留
simulateChat(new CircularBufferChatHistory(3)); // 只留最近 3 則
| 實作 | 空間 | 取捨 | ||
|---|---|---|---|---|
| 環狀緩衝區 | \(\bigO{1}\) | \(\bigO{\text{capacity}}\) | 固定 | 舊訊息會遺失;記憶體可預測 |
| 鏈結串列 | \(\bigO{1}\) | \(\bigO{n}\) | 隨 \(n\) 成長 | 全部保留;每個節點多一個參照的開銷 |
宣告型別決定你能呼叫什麼
實作的其實是 ——一個擴充了 的介面:
public interface ErasableChatHistory extends ChatHistory {
void erase(); // 在 ChatHistory 的三個方法之外,再多要求一個
}
於是同一個物件,用不同型別的變數接住,能呼叫的方法就不同:
ChatHistory h1 = new LinkedListChatHistory();
h1.addMessage("hello");
// h1.erase(); // 編譯錯誤!h1 的「宣告型別」是 ChatHistory,契約裡沒有 erase()
ErasableChatHistory h2 = new LinkedListChatHistory();
h2.erase(); // OK
編譯器只看變數的宣告型別(靜態型別)來決定「哪些方法存在」,不看它實際指向什麼物件。 這是刻意的: 的使用者只被承諾了 的三個操作, 所以我們可以隨時把 換成環狀緩衝區而不會弄壞任何程式碼。 第 [sec:subtyping] 節會看到:方法存不存在由宣告型別決定,但執行哪個版本由執行期型別決定。
最經典的 ADT:List
是「有順序、允許重複、大小可變」的集合——最後一點正是它與陣列的根本差別。 一個最小的 List ADT:
public interface SimpleList {
int size();
Object get(int index); // 前置條件:0 <= index < size()
void add(Object o); // 加到尾端;後置條件:size() 增加 1
void remove(Object o); // 移除第一個 equals(o) 的元素;不存在則不動作
}
元素型別暫時用 (Java 裡所有物件的共同祖先,所以什麼都能放); 第 [sec:generics] 節會用泛型把它升級成 ,讓編譯器幫我們記住裡面放的是什麼。
用鏈結串列實作它。這裡的重點不是演算法,而是封裝把「使用串列」變得多簡單: 使用者不必知道 的存在,不必自己區分「空串列」與「非空串列」,不必記得維護 。
@Override
public void remove(Object o) {
if (this.head == null) return; // 空串列
if (Objects.equals(this.head.value, o)) { // 移除的是第一個節點
this.head = this.head.next;
this.size = this.size - 1;
return;
}
Node prev = this.head;
while (prev.next != null) {
if (Objects.equals(prev.next.value, o)) {
prev.next = prev.next.next; // 跳過被移除的節點
this.size = this.size - 1;
return;
}
prev = prev.next;
}
}
在 為 時會爆 。 會先處理 null(兩個都 null 回 true,只有一個 null 回 false), 是比較兩個可能為 null 的物件時的標準寫法。
巢狀類別:把實作細節藏得更深
是「只有 自己需要知道」的東西,所以我們把它宣告在類別裡面並標為 。 Java 有兩種寫法:
@lL0.36L0.36@ & 靜態巢狀類別() & 內部類別()
與外層物件的關係 & 獨立;只是「放在裡面」的普通類別 & 每個 都隱含綁定一個外層物件,可直接讀外層的欄位
何時用 & 節點不需要碰外層狀態時(預設選擇) & 需要存取外層的 欄位或方法時
範例 & &
兩者在本節的用途下行為相同;課程講義用的是內部類別,實務上若不需要外層狀態,加 更省記憶體也更清楚。
練習
寫一個 ,內部用 與 。 在陣列滿時把容量加倍(); 需要把後面的元素往前搬。 寫完後把 裡的 換成你的類別——其他程式碼一個字都不必改, 這就是 ADT 的威力。第 [sec:amortised] 節會分析為什麼「加倍」是正確的策略。
以下敘述哪些正確?
可以編譯。
一個類別可以同時 。
型別的變數一定可以呼叫 。
把 的 改名成 ,會影響使用 的程式。
(a) 介面不能 。(b) 可以實作多個介面,只要全部方法都提供。 (c) ,所以包含 。 (d) 是 實作細節,外界根本看不到——這正是封裝的意義。