第2階段我們很大的程度都在講 程式碼與程式碼 之間的關係,像是 boundary / contract / abstract ...
有所謂的關係 就存在 Dependency
前面幾天已經數次提到「依賴」這個詞。
如果一段 code 必須知道另一段 code 的某些資訊,才能正確工作,我們就可以說它依賴那些資訊。
例如:
def show_entries(storage: SQLiteStorage) -> None:
for entry in storage.list():
print(entry)
show_entries() 至少知道兩件事:
SQLiteStorage 的型別。list()。因此這兩件事只要改變,show_entries() 就可能需要跟著修改。
從 change 的角度來看,dependency 就是一條 change 可能傳播的路徑:
SQLiteStorage 改變
↓
show_entries() 可能需要跟著改
Dependency 本身不是問題。程式不可能完全沒有 dependency;show_entries() 想取得帳目,本來就必須依賴某些東西。
真正值得注意的是:
client 到底依賴了 implementer 的哪些資訊。
如果 client 只依賴穩定、必要的 contract,implementer 裡面的改動大多可以停在 boundary 裡;反之 client 依賴了具體實作的細節,那些細節一變,change 就容易沿著 dependency 傳出去。
依賴越多,對方改變時越容易受到影響;這種彼此綁定的程度,通常稱為 coupling。
而 object 之間建立 dependency,常見的方式有兩種:inheritance 與 composition。
昨天的 Storage 可以這樣實作:
class SQLiteStorage(Storage):
def save(self, entry: Entry) -> None: ...
def list(self) -> list[Entry]: ...
這裡表達的是 is-a:
SQLiteStorage is a Storage
概念大概是 Storage 能做到的事情 SQLiteStorage 都也要能做到。
Inheritance 不是單純取得幾個 method,而是讓子類(SQLiteStorage)加入父類(Storage)的 type hierarchy。
因此父類提供的 interface、行為與規則,都會成為子類的一部分;子類對父類形成的 dependency 也比較廣。
由於是 is a 的關係,當今天父類改變,子類就很有可能需要做出對應的改動
如果只是為了使用某個功能而繼承,就容易出現奇怪的關係:
class ReportService(Logger): ...
這等於宣告 ReportService is a Logger,但它真正想做的可能只是使用 Logger。
另一種方式,是直接持有需要的 object:
class ReportService:
def __init__(self, logger: Logger):
self.logger = logger
def generate(self) -> None:
self.logger.log("report generated")
這裡表達的是 has-a / uses-a:
ReportService has a Logger
ReportService 不需要成為 Logger,只需要知道完成工作所需的那部分 contract。
而只要 contract 相同,也可以替換 implementation:
ReportService(ConsoleLogger())
ReportService(FileLogger())
因此 composition 通常把 dependency 限制在比較小的範圍。
由於是 has a 的關係,當今天工具改變,使用者只需要變動用到工具的地方即可
| Inheritance | Composition | |
|---|---|---|
| 關係 | A is-a B |
A has-a B |
| Dependency | A 的型別建立在 B 上 | A 只使用 B 的部分能力 |
| Coupling | 通常較高 | 通常較低 |
| 替換 implementation | 較困難 | 較容易 |
| 適合情況 | A 真的是一種 B | A 只是需要 B 幫忙完成工作 |
兩者都會產生 dependency,差別在於 A 必須知道 B 多少事情。
因此軟體設計裡常看到一句:
Favor composition over inheritance.
意思不是不要使用 inheritance,而是不要只是為了 reuse code,就把兩個 class 綁進同一個 hierarchy。
可以先問:
A 真的是一種 B,還是 A 只是需要 B?
這也能重新看昨天的 abstraction:
class SQLiteStorage(Storage): ...
SQLiteStorage is a Storage,所以 inheritance 合理。
但真正使用 storage 的 client:
class LedgerService:
def __init__(self, storage: Storage):
self.storage = storage
則是:
LedgerService has a Storage
LedgerService 不需要知道是 SQLite、File 還是其他 implementation,只需要依賴 Storage 這份 contract。
所以好的設計並不是消除 dependency,而是讓 dependency 停在真正需要的地方:
只依賴完成工作所需要的資訊,不要因為方便而知道更多。
較少的 dependency,代表完成一個修改時,需要理解的 context 也比較少。
如果 LedgerService 只依賴 Storage 的 contract,coding agent 要修改 service 時,只需要知道 Storage 提供哪些能力,不必再讀 SQLiteStorage 裡的 SQL、connection 或 schema。
這和前面談 boundary 的目標是一樣的:讓 change 只需要理解局部資訊,也盡量只影響局部。
對 coding agent 來說,dependency 越少、越明確,就越容易:
因此降低 coupling 的價值,不只是讓 class diagram 比較漂亮,而是:
讓一次 change 所需要理解的範圍,以及可能影響的範圍,都盡可能縮小。