到目前為止,我們談 boundary 時舉的例子,都是一個 function 或一個 class:get_today()、Storage、Square。
但 Day14 提過,boundary 是一層套著一層的。公司對外有窗口,部門之間也只透過各自的窗口往來。當 codebase 長大,我們平常真正面對的單位,往往不是單一的 class,而是一整個部門:負責儲存的、產生報表的、寄送通知的。
這一層的 boundary,就是今天的主題:module。
Module 是「一群 code,對外以同一個名稱被使用」的單位。不同語言的叫法不同:Python 與 Go 稱為 package,Java 有 package 也有 module,Rust 有 module 與 crate,傳統的 C++ 則是一組 header 與 .cpp 檔。名稱不同,扮演的角色卻是一樣的。
把記帳 app 的儲存功能整理成一個 module,裡面大概會有這些東西:
storage
├── Storage 儲存帳目的 contract
├── SQLiteStorage 用 SQLite 實作 Storage
└── row_to_entry() 把資料庫的 row 轉成 Entry
storage 的 client,是 report 這類其他的 module;implementer,則是 storage 裡的每一個檔案。
與 class 相比,module 裡多了一層分工。class 的 boundary 就是它的 method;module 裡則有許多 class 與 function,其中一些是給外面用的,例如 Storage、SQLiteStorage,另一些只為了內部彼此合作而存在,例如 row_to_entry(),只有 SQLiteStorage 會用到它。
因此,module 的 boundary 要回答的問題是:外面可以使用哪些名稱?
假設這條線沒有劃出來,module 裡的每個名稱外面都用得到。以 Python 為例:
# report/monthly.py
from ledger.storage.schema import row_to_entry
report 想自己查詢資料、算出每月支出,發現 row_to_entry() 正好能用,就直接拿來用了。程式能跑,test 也會通過。
但從這一刻起,資料表的結構就不再只是 storage 裡的事。哪天 storage 想調整 schema,或是把轉換的邏輯搬到別的檔案,report 都會跟著壞掉。這就是 unwanted dependency,只是發生在更大的尺度上。
而且,它比 class 層級的外溢更難察覺。修改 row_to_entry() 的人,若不搜尋整個 codebase,就不會知道有其他 module 依賴了它;但「不必讀完整個 codebase 也能安全修改」,正是我們建立 boundary 的原因。
Module 越大,裡面能被意外依賴的東西就越多;沒有劃出公開的範圍,boundary 就只存在於資料夾的名稱上。
所以 module 要做的第一件事,是把裡面的名稱分成兩類:public 給 client 使用,屬於 contract 的一部分;internal 只供 module 內部合作,implementer 可以隨時修改。
各語言劃這條線的方式不同,做的卻是同一件事:
| 語言 | 如何標示 public | 由誰守住 |
|---|---|---|
| Go | 名稱大寫開頭才公開;internal/ 底下的 package,只有它的上層目錄能 import |
compiler |
| Rust | 預設 private,加上 pub 才公開 |
compiler |
| Java | public 與 package-private;module-info.java 只 exports 指定的 package |
compiler |
| C++ | header 放對外的宣告,.cpp 放實作;C++20 起可用 export 標示 |
部分靠 compiler,部分靠慣例 |
| Python | 名稱以 _ 開頭表示 internal |
慣例 |
差別只在於這條線由誰來守。在 Go、Rust 這類語言裡,越界的使用根本編譯不過;Python 則只有慣例,row_to_entry() 所在的檔案改名為 _schema.py,是在告訴外面:這裡的東西不是給你用的。
不論哪一種語言,公開的名稱最好都集中在同一個窗口。Rust 常在 module 的入口用 pub use 重新匯出,Python 則用 __init__.py:
# ledger/storage/__init__.py
"""帳目的儲存。
對外只提供 Storage、StorageError 與各種 Storage 實作;
資料表的結構與轉換屬於內部細節,不保證穩定。
"""
from ledger.storage.base import Storage, StorageError
from ledger.storage.sqlite import SQLiteStorage
__all__ = ["SQLiteStorage", "Storage", "StorageError"]
client 從此只需要寫 from ledger.storage import Storage,不再知道 Storage 定義在哪個檔案,也不知道 storage 裡有幾個檔案。於是 implementer 想拆檔、改名,甚至重新安排整個資料夾,只要窗口匯出的名稱不變,client 一行都不用改。就像部門內部重新分組,只要窗口還在,其他部門就不受影響。
這也讓 module 的 contract 有了明確的位置。窗口上的說明,例如 Go 的 package 註解、Python __init__.py 的 docstring,就是這一層的 spec;test 也應該只透過公開的名稱驗證 module,Go 甚至有慣例,把 test 寫在另一個只看得到公開名稱的 _test package 裡。
Module 的 contract,就是它對外公開的名稱與說明;其餘的一切,都是 implementer 可以自由改動的內部。
在 Python 這類只靠慣例的語言裡,底線終究只是提醒。語言不會阻止任何人 import _schema,忙著完成任務的人或 agent,也不一定會注意到那個底線。
這與階段一面對的問題相同,解法也一樣:把規則寫成工具能執行的 constraint。以 Python 來說,可以使用 import-linter,在設定檔裡寫下:storage 裡面的 module,只有 storage 自己可以 import。
# pyproject.toml
[tool.importlinter]
root_package = "ledger"
[[tool.importlinter.contracts]]
name = "storage 的內部只能由 storage 自己使用"
type = "protected"
protected_modules = ["ledger.storage.*"]
allowed_importers = ["ledger.storage", "ledger.storage.*"]
設定之後執行 lint-imports,from ledger.storage._schema import row_to_entry 就會被指出違反了這條規則;而 from ledger.storage import Storage 走的是窗口,不受影響。有趣的是,import-linter 也把這些規則稱為 contract。
把它加進 pre-commit 與 CI 之後,Python 的 module boundary,就從命名慣例,變成了每次修改都會被檢查的驗證,與 Go、Rust 的 compiler 站在同一個位置。
回頭看,module 做的事與前幾天的 boundary 並無不同:分出裡與外,讓外面只依賴 contract。差別在於尺度。class 的 boundary 是幾個 method,module 的 boundary 則是從一整群 class 與 function 裡,挑出哪些名稱可以跨出去。
而不論語言強制與否,挑出哪些名稱公開,終究是 implementer 的決定。Go 不會替我們決定哪個名稱該大寫,Rust 也不會替我們決定哪裡該加 pub;語言與工具能做的,是在我們劃下這條線之後,替我們守住它。
它背後的理念是:一個 module,對外只留一扇門。 所有的往來都經過這扇門,門後怎麼分工、怎麼調整,都是 module 自己的事。
對 coding agent 而言,這扇門讓它面對一個陌生的 module 時,只需要讀窗口:說明寫了這個 module 提供什麼,公開的名稱列出了能用的東西,門後的實作都不必讀進 context。
但 agent 也特別容易從側門進去。它往往靠搜尋 codebase 找到可用的 function,看到 row_to_entry() 正好符合需求,就直接拿來用,test 也會通過。這時不論是 compiler 還是 lint-imports 擋下這次修改,錯誤訊息都會直接指出它穿過了哪一條 boundary,agent 就能改用公開的方法,不必等到 code review 才被發現。
Module 的 boundary 被守住之後,人與 agent 都只需要認得那扇門,就能安全地使用、也安全地修改一整個 module。
但 codebase 裡的 module 不只一個。當許多 module 彼此依賴,它們之間的 dependency 會組成一張圖;明天,我們來看這張圖的形狀,如何決定 local reasoning 能不能成立。