iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

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

Day18: State & Side Effect:隱藏在 function 底下的魔鬼

  • 分享至 

  • xImage
  •  

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 之外的輸入與輸出

Signature 描述了 function 的輸入與輸出:輸入是參數,輸出是回傳值。

但 function 還有另一條與外界往來的路。它可以偷偷讀取外界的資料,也可以偷偷改變外界:

這邊先定義 state 代表執行 function 時,主 or 被動取得的資訊

  • function 偷偷讀 state,就是隱藏的輸入。
  • function 偷偷改 state,就是隱藏的輸出,也就是 side effect。

以下分別來看。

State:隱藏的輸入

State 是程式「記住」的資料:在一次呼叫結束後仍然存在,並且會影響之後的結果。

常見的 state 包括 object 的欄位、全域變數、資料庫與檔案的內容;系統時鐘、環境變數,乃至其他系統的資料,這些程式外部的狀態,同樣是 state。

State 本身並不是問題,記帳 app 要記得帳目,本來就需要 state。問題出在 function 自己讀了它,caller 從外面看不見,也無法指定。

get_today() 就是如此。名稱雖然說了它與「今天」有關,但「現在」是什麼時候,由它自己讀系統時鐘決定,caller 無法指定。Day14 提到 get_today() 要處理跨日,test 卻無法指定「台北午夜前後」這個時間點,結果還會隨著執行的時間改變。而且這個隱藏的輸入會往外擴散:任何呼叫 get_today() 的 function,例如統計本月支出,也都跟著多了一個看不見的輸入。

Side effect:隱藏的輸出

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 有一部分的輸入與輸出,從外面看不見。

為什麼這會讓 boundary 失效

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 讀了什麼、改了什麼。

Pure 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 拆出來

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,而不是修改傳進來的那一份。

當 state 屬於別人

還有一種 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。

Reference


上一篇
Day17: Dependency:不要太依賴我了 (Composition vs Inheritance)
系列文
AI時代下的軟體工程 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言