配合分支:day29/start,Repo: 按我
雖然昨天將測試補完,不過出現了一些討人厭的地方。像是製作測試用的RecordingDiaryStore,只希望讀取,不要寫入。但因為契約,需要實作兩者。
但 interface 不需要包山包海,實務上來說 interface 可以切得更細,不必去實作沒必要的契約。第二點是 class 可以一次實作多個 interface 契約(這招叫做組合 Composition)。所以今天就來調整 DiaryStore,將讀取抽出去,改叫 DiaryReader。讓需要「讀取」日記的 class 只需要實作 Reader 這個契約就好了。
移除 DiaryStore 的讀取:
interface DiaryStore {
- fun load(): List<DiaryEntry>? // <= 移除
fun save(entries: List<DiaryEntry>)
}
接著新增一個全新的 interface,用來作讀取的契約。
interface DiaryReader {
fun load(): List<DiaryEntry>?
}
interface 也可以進行繼承,將 DiaryStore 繼承 DiaryReader,這樣原來實作 DiaryStore 的 class 就不用調整。
現在更新 DiaryExporter 以前在建構子塞 DiaryStore 進來,現在調整成 DiaryReader。調整成 diaryReader 去讀取內容即可,這樣測試那邊建立的假 class 也可以變的更乾淨。

就不需要額外實作一個尷尬的 save()。
今天做的事情也有個名字,叫做介面隔離原則。經典的說法是:不要強迫別人實作用不到的方法(clients should not be forced to depend on methods they do not use)。翻成白話就是:人家只是要借個 load(),就不要逼他連 save() 都一起寫下去。
回頭看昨天的 ReadOnlyDiaryStore 為何尷尬?DiaryExporter 從頭到尾只搓過 load(),它建構子傳入的是 DiaryStore,而 DiaryStore 規定要有 load() 和 save() 兩個方法。於是測試用的假 class 明明只想餵假資料,卻被逼著多寫一個 save(),還得在裡頭丟 Error 出去,才能確定沒人偷叫它。用不到的是 DiaryExporter,卻由假 class 來收拾殘局。
今天把 load() 再抽出去 DiaryReader,再讓 DiaryStore 去繼承它,分工就變成:
DiaryExporter 傳入 DiaryReader,看得到的只有 load()
PhotoDiaryViewModel 要新增日記、改心情,會寫入,繼續拿 DiaryStore
LocalDiaryStorage 一行都沒改,它實作的 DiaryStore 本來就同時是 DiaryReader,同一個物件兩邊都能塞想驗證 DiaryExporter 手上真的只剩讀取?把建構子的 DiaryReader 偷偷改回 DiaryStore,再跑一次測試,ReadOnlyDiaryStore 那邊會直接 Failed,因為它已經沒有 save() 可以用了。改回去才會 Pass。
Day 27 和今天都在抽 interface,但方向不同:
DiaryExporter 只需要會讀,那就只給它讀。不是要「interface 越多越好」。
需要有人真的只用一半,才把那一半切出來給他。
聽到「interface 要切細」,第一個反應可能是:那我每個 Function 都開一個 interface 不就無敵了?拜託住手。今天沒有順手弄出 DiaryWriter,因為整個 App 沒有任何人「只寫不讀」,切出來就是沒人聽的滯銷唱片(不可能不知道 CD 📀 吧⋯⋯),純粹超前部署。
什麼時候切才有理?跟昨天的 OCP 一樣,要等事情真的發生:有人拿著整個 DiaryStore 卻只用 load(),而且多出來的 save() 已經開始礙事(像昨天那個丟 Error 的假 class)。在那之前,load() 和 save() 擠在同一個 interface 裡完全 OK,從 Day 18 用到 Day 28 不是也都沒事?