Day17 談到 dependency:一段 code 必須知道另一段 code 的某些資訊,才能正確工作,而 change 會沿著 dependency 傳播。
到目前為止,我們看到的 dependency 都寫在明面上。LedgerService 需要 Storage,就寫在 constructor 的參數裡;show_entries() 需要什麼,看參數型別就知道。Day16 也提過,這些寫在 signature 上的部分,可以交給 type checker 檢查。
但回到 get_today():
get_today(ZoneInfo("Asia/Taipei")) # 今天執行 -> "2026/09/27"
get_today(ZoneInfo("Asia/Taipei")) # 明天執行 -> "2026/09/28"
參數一模一樣,結果卻不同。因為它的結果,還取決於一個沒有寫在參數裡的東西:系統時鐘。
也就是說,一個 function 的輸入與輸出,並不一定都寫在 signature 上。這就是今天的主題:藏在 signature 底下的 state 與 side effect。
Signature 描述了 function 的輸入與輸出:輸入是參數,輸出是回傳值。
但 function 還有另一條與外界往來的路。它可以偷偷讀取外界的資料,也可以偷偷改變外界:
這邊先定義 state 代表執行 function 時,主 or 被動取得的資訊
以下分別來看。
State 是程式「記住」的資料:在一次呼叫結束後仍然存在,並且會影響之後的結果。
常見的 state 包括 object 的欄位、全域變數、資料庫與檔案的內容;系統時鐘、環境變數,乃至其他系統的資料,這些程式外部的狀態,同樣是 state。
State 本身並不是問題,記帳 app 要記得帳目,本來就需要 state。問題出在 function 自己讀了它,caller 從外面看不見,也無法指定。
get_today() 就是如此。名稱雖然說了它與「今天」有關,但「現在」是什麼時候,由它自己讀系統時鐘決定,caller 無法指定。Day14 提到 get_today() 要處理跨日,test 卻無法指定「台北午夜前後」這個時間點,結果還會隨著執行的時間改變。而且這個隱藏的輸入會往外擴散:任何呼叫 get_today() 的 function,例如統計本月支出,也都跟著多了一個看不見的輸入。
Side effect 是 function 除了回傳值以外,對外界造成的改變:修改 object 的欄位或全域變數、寫入資料庫或檔案、寄出通知,或是修改傳進來的參數。
Side effect 本身也不是問題,記帳 app 終究要把帳目存起來,一個叫 save() 的 function 寫入資料庫,正是它該做的事。問題同樣出在 function 改了外界,從外面卻看不出來。
例如 Day15 提到的 save(),如果它除了儲存,還順手寄出通知:
def save(self, entry: Entry) -> None: ...
signature 只說了「儲存一筆帳目」,寄出通知這件事,從外面完全看不出來。於是 client 無法判斷,呼叫一次與呼叫一百次有沒有差別;每跑一次 test,也就真的寄出一封通知。修改傳進來的參數也是一樣:function 在裡面把參數的 list 排序了,caller 手上那一份也會跟著改變。
整理起來:
| 寫在 signature 上 | 不在 signature 上 | |
|---|---|---|
| 輸入 | 參數 | 偷偷讀取 state |
| 輸出 | 回傳值 | 偷偷修改 state(side effect) |
State 是資料,side effect 是動作;兩者帶來的問題相同:function 有一部分的輸入與輸出,從外面看不見。
Day14 說,boundary 的價值在於 client 只看外面,就能正確使用。這個前提是:外面寫的,就是 client 需要知道的全部。
隱藏的輸入與輸出,打破了這個前提。client 想知道 function 讀了什麼、改了什麼,只能打開實作;如果實作又呼叫了其他 function,還得一路讀下去,因為它們可能藏在任何一層。
Coding agent 在邊界的兩側都會受影響。身為 client,agent 依據 signature 判斷怎麼使用,看不見的 side effect 它也無從得知,於是在批次匯入時呼叫一千次 save(),就寄出了一千封通知。身為 implementer,在 function 裡順手讀一個全域設定、改了傳進來的 list,signature 不會改變,type checker 也不會發現,change 就這樣繞過了 contract。
隱藏的輸入與輸出,讓 dependency 繞過了 contract:client 只能打開實作,才看得見 function 讀了什麼、改了什麼。
反過來說,如果一個 function 不讀任何 state,所有輸入都來自參數;也不改任何 state,所有輸出都透過回傳值,它就是 pure function。
Pure function 的 signature 說出了全部的事情。不論何時呼叫、呼叫幾次,同樣的輸入都會得到同樣的輸出;test 也只需要準備輸入與預期的輸出。
但程式不可能全部是 pure function。記帳 app 終究要知道今天是哪一天,也終究要把帳目存起來。因此,目標不是消除 state 與 side effect,而是:
讓它們看得見:不再藏在 function 裡,而是交給呼叫它的一方。
get_today() 的問題在於,它自己去讀了系統時鐘。那就把「現在」改成由外面傳進來;既然它不再自己決定「今天」,名稱也一併改掉:
def to_local_date(now: datetime, tz: ZoneInfo) -> str:
return now.astimezone(tz).strftime("%Y/%m/%d")
時鐘從隱藏的輸入,變成了 signature 上的參數,to_local_date() 也成了 pure function。跨日終於能測了:UTC 9/27 16:30,在台北已經是隔天。
now = datetime(2026, 9, 27, 16, 30, tzinfo=timezone.utc)
assert to_local_date(now, ZoneInfo("Asia/Taipei")) == "2026/09/28"
讀取系統時鐘的 datetime.now() 依然存在,只是移到了外層,由呼叫它的一方負責。
Day17 的 LedgerService 其實是同樣的做法:它不在裡面自己建立 SQLiteStorage,而是由外面傳入一個 Storage。這種把依賴從外面傳進來的做法,稱為 dependency injection。test 時,只要傳入一個把帳目存在記憶體裡的 Storage,就能在不碰資料庫的情況下驗證 LedgerService。
Side effect 也是同樣的思路。與其讓 save() 順手寄通知,不如讓它只負責儲存,要不要通知,交給呼叫它的一方決定,這也正是 Day15 說的工具獨立性。
原本:
def save(self, entry: Entry) -> None: ... # 儲存,並順手寄出通知
拆分後:
def save(self, entry: Entry) -> None: ... # 只負責儲存
def notify(self, entry: Entry) -> None: ... # 只負責寄出通知
拆開之後,save() 只做名稱說的事,也就不再有隱藏的 side effect;要不要寄通知,由 caller 明確呼叫,從呼叫的地方就看得見。
這兩種做法,都得靠寫 code 的一方自己遵守,coding agent 也不例外:一不留神,就又在 function 裡順手讀了時鐘、改了參數。
好在 state 與 side effect 的來源,有一部分擋得住。參數標成 Sequence 而不是 list,type checker 就會擋下對它的修改;lint 規則也能禁止特定 module 呼叫 datetime.now() 或使用 global。正如 Day9 提到的修正線索,agent 讀到錯誤訊息,就知道該把時鐘改成參數傳入,或回傳一份新的 list,而不是修改傳進來的那一份。
還有一種 side effect 特別隱蔽:被修改的 state,屬於另一個 boundary。
前面 test 用的 in-memory Storage,如果 list() 直接回傳內部的 list:
class InMemoryStorage(Storage):
def list(self) -> list[Entry]:
return self._entries
client 拿到的,就是 storage 裡面的那一份資料:
entries = storage.list()
entries.clear() # storage 裡的帳目也跟著消失了
client 只是想清空自己手上的 list,卻在沒有經過任何 method 的情況下,改動了 boundary 裡面的資料。更麻煩的是,storage 原本靠 save() 守住的規則,例如 id 不可為空,client 也能透過這份 list 直接繞過。
Boundary 不只要決定外面能知道什麼,也得守住裡面的資料始終成立的條件。明天,我們就來談這個條件:representation invariant。