iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

AI時代下的軟體工程系列 第 20 篇

Day20: Module:對外只留一扇門

  • 分享至 

  • xImage
  •  

到目前為止,我們談 boundary 時舉的例子,都是一個 function 或一個 class:get_today()、Storage、Square。

但 Day14 提過,boundary 是一層套著一層的。公司對外有窗口,部門之間也只透過各自的窗口往來。當 codebase 長大,我們平常真正面對的單位,往往不是單一的 class,而是一整個部門:負責儲存的、產生報表的、寄送通知的。

這一層的 boundary,就是今天的主題:module。

What is 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 要回答的問題是:外面可以使用哪些名稱?

當 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 就只存在於資料夾的名稱上。

Public 與 internal

所以 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 真正想表達的事

回頭看,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 能不能成立。

Reference


上一篇
Day19: Representation Invariant:關起門來的規矩
系列文
AI時代下的軟體工程 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言