Day15 的 contract 是寫給人看的:precondition、postcondition 寫在 docstring 裡,靠 client 自己讀、自己遵守。
不過,code 裡本來就有一部分 contract 不必靠人記得。
method 的名稱、參數與回傳型別,都是 client 可以直接遵守的承諾,對於動態型別語言 e.g., python Day9 提到的 type checker 就是為此而生。而在 C++、Java 這類靜態型別語言裡,這項檢查更直接由 compiler 負責,不符合的程式根本編譯不過。
換句話說,每一個 class 本身就是一份 contract。既然如此,為什麼許多語言還要另外提供 interface、abstract class?
假設帳目可以存進 SQLite,也可以存成一般的檔案,於是有了 SQLiteStorage 與 FileStorage 兩個 class。它們都有 save() 與 list(),做法卻完全不同。
如果 client 直接使用其中一個:
def show_entries(storage: SQLiteStorage) -> None:
for entry in storage.list():
print(entry)
show_entries() 依賴的就不是「儲存」這件事,而是綁定在 SQLite 上的做法。即使它只用到 list(),FileStorage 也傳不進來:
show_entries(FileStorage("entries/")) # Pyright:"FileStorage" 無法指派給 "SQLiteStorage"
所以對於 client 來說,都必須實作對應的方法去使用每種不同的 storage。
而這樣 implementer 跟 client 之間就不是一個好的 boundary 了,
問題也不只在型別上。想知道 SQLiteStorage 承諾了什麼,client 得打開這個 class,而裡面的 contract 與實作是寫在一起的:
class SQLiteStorage:
def list(self) -> list[Entry]:
"""回傳所有已儲存的帳目"""
rows = self._db.execute("SELECT * FROM entries ORDER BY rowid")
... n rows ...
def save()
當今天實作簡短時還好,但只要實作繁瑣,client 就被迫閱讀大量他不需要理解的實作細節,這對於 client 也是一種負擔。
由此可見
若依賴一個具體實作,client 使用起來不靈活,並且增加負擔
Abstraction,中文是「抽象」,泛指忽略細節、只留下重點。並且透過這些特性,解決了上面的問題。
抽象在 code 裡的形式:一個只有 contract、沒有實作的型別。 它不必真的做任何事,只負責說明「能做什麼」,「怎麼做」則交給其他 class。
Java、Go 稱它為 interface;Python 則可以用 abstract class 表達:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, entry: Entry) -> None:
"""儲存一筆帳目。
Precondition: entry.id 不可為空字串。
Postcondition: 儲存後,list() 的結果會包含這筆帳目。
"""
@abstractmethod
def list(self) -> list[Entry]:
"""回傳所有已儲存的帳目,不保證順序。"""
ABC 與 @abstractmethod 單純宣告 storage 具有哪些 method 不在這裡實作。
後續再由 SQLiteStorage 與 FileStorage 各自繼承 Storage,補上自己的做法。
client 則改為標註 Storage:
def show_entries(storage: Storage) -> None:
for entry in storage.list():
print(entry)
show_entries() 不再需要知道帳目存在哪裡、怎麼存,兩種 storage 都能傳進來。它需要讀的也只剩 Storage 中定義的 method 即可。
Python 也可以用 typing.Protocol 寫出同樣的 contract,差別在於 implementer 不必繼承,只要具備相同的 method 即可。
寫成 abstract class 之後,type checker 就能替邊界的兩側把關。
這裡舉個例子:
在 SQLiteStorage 中或許還有 SQLite 才有的 method,例如整理資料庫檔案的 vacuum()。
但由於這不存在於 Storage 的 contract 上,所以今天 client 想直接使用,會出現以下問題:
def show_entries(storage: Storage) -> None:
storage.vacuum() # Pyright:無法存取類別 "Storage" 的屬性 "vacuum"
另一側也是如此:
如果 implementer 漏實作了 list(),Pyright 會在建立物件的地方指出來,執行時 Python 也會直接報錯。
Client 只能使用 contract 寫明的東西,implementer 必須提供 contract 寫明的全部東西。
而對 coding agent 而言,這條界線特別有用。
Day9 提到,型別資訊能在寫之前提供介面資訊,在寫之後提供修正線索。寫之前,agent 只需讀進 Storage 這十幾行,不必把 SQL 或檔案格式都讀進 context。寫之後,它若呼叫了 contract 沒有的 method,type checker 會當場擋下。
而 contract 固定之後,boundary 兩側也能分開進行:依照 Storage 寫 client 的 agent,不必等 FileStorage 完成,也不必讀它的 code。
不過,工具能檢查的,只有寫進型別的部分。那麼,型別能寫下多少 contract?
比想像中多。Day15 的 get_today(),precondition 是「tz 是合法的時區名稱」。參數型別是 str 時,這個條件只能寫在文件裡;如果換成標準庫的 ZoneInfo:
from zoneinfo import ZoneInfo
def get_today(tz: ZoneInfo) -> str: ...
get_today(ZoneInfo("Asia/Taipei")) # OK
get_today("Asia/Taipei") # Pyright:str 無法指派給 ZoneInfo
ZoneInfo("Asia/Taipe") # 執行時丟出 ZoneInfoNotFoundError
不合法的時區名稱,在建立 ZoneInfo 時就會失敗,根本到不了 get_today();type checker 則確保傳進來的一定是 ZoneInfo。這條 precondition,就從文件移進了型別。
但行為寫不進去。list() 不保證順序、儲存後查得到這筆帳目、儲存失敗時丟出 StorageError,-> list[Entry] 一樣也說不出來;Python 的函式簽名,也沒有地方宣告會丟出哪些 exception。所以 Storage 裡的 docstring 一行都不能少,這些承諾仍要靠 Day15 的 spec 寫下,再由 test 驗證。
寫得進型別的條件,交給工具檢查;寫不進型別的行為,仍要靠 spec 與 test。
既然一般的 class 就已經是 contract,也就不是每個 class 都需要再抽出一個 abstract class。只有一種做法、短期內也不會有第二種時,client 直接依賴那個 class 就夠了;硬是多加一層,只是多了一個名稱要維護。
值得抽出來的,是同一件事已經有、或很快會有不只一種做法,像 SQLite 與一般檔案;或是實作的細節多到 client 讀了反而容易誤會。兩者的共同點是:client 不需要知道,這件事是怎麼做出來的。
至於 abstraction 本身的成本,以及抽得太早、太多會發生什麼事,留給階段三討論。
abc — Abstract Base Classes