2023-11-17|閱讀時間 ‧ 約 31 分鐘

閒談軟體設計:State 與語言

永遠有狀態

會想寫這篇是因為先前看到一篇網路文章討論用物件管理狀態的問題,看完後我只能說,不論用 OOP 還是 FP 語言,終究得處理狀態,程式也許沒有狀態,但系統有狀態。因為,如果系統沒有狀態,那會非常難用,沒有購物車記住選購的東西,那網頁購物系統會多難用應該不難想像吧!

先出個題目給大家,隨處可見的自動販賣機,能投幣、選飲料和退幣,就這三個功能,想想看會產生多複雜的狀態圖 (state chart),等等公布答案。

Pure function 沒有 side effect,但作為一個系統,總有個地方保存狀態,並將狀態的一部分作為參數傳入 pure function 計算取得到下個狀態,然後保存起來;stateless request handler 沒有保存狀態,得以分散到不同的伺服器實體執行,但系統仍會將執行後的結果 (狀態) 以某種方式 (例如:資料庫) 儲存起來,當下個 request 進來時,再將狀態讀取出來。這兩個例子中,狀態不是存在 function 或是 handler 中,而是在別的地方。

提到 side effect,讓我想起之前跟指導教授討論論文時 (我的論文是視覺化程式語言,和 FP 有很多相似處),當時的我也把 side effect 當成壞東西,但老師提了個問題讓我思考一下,side effect 真的是壞東西嗎?如果沒有 side effect,我們現在的電腦系統還能運行嗎?我想了一下,組合語言的 JMP 指令 (改寫 program counter內容) 會無法運作了,那 function call 和 return 就做不出來,這時才瞭解到,side effect 不全然是壞事,不預期的 side effect 才是壞事。

Immutability

最近隨著 FP 的流行,immutability 一直被提倡,物件有狀態,會被修改好像是一種惡,但真是如此?immutability 很好,但所謂的狀態就是會隨著操作變動,差別只在於變動發生在哪裡?

先忘掉物件這個詞,從記憶體的角度來看,FP 做法比較像 Figure 1 的左邊,橫軸代表時間演進,記憶體中有個區塊紀錄資料 A,有個指標指向它,代表系統目前的狀態為 A,經過一個操作,產生另一個區塊紀錄資料 A',指標改指向新區塊,狀態因此變成 A',再一次操作,一個區塊產生,指標改指向 A’’ 區塊,A 和 A' 在每次操作都沒有變動,因此它們是 immutable。

Figure 1 — FP way and OOP way

而 OOP 的方式則是像 Figure 1 的右邊,一開始有個區塊記錄狀態 A,然後有個指標指向該區塊,進行操作後,同個記憶體區塊儲存的狀態變成 A’(虛線表示從現在時間往回看該狀態已不存在),再一次操作後,同個記憶體區塊中狀態變成 A'',因此物件是 mutable。若從記憶體的角度看,一個是變動指向狀態的指標,一個是變動裝載狀態的容器 [1]。

事實上,OOP 當初被提出,有個很重要的基礎:把狀態與修改狀態的方法封裝在一起,希望改善過去大量修改 global variables 引起的不受控狀態變化。既然如此,那為什麼現在會覺得物件有狀態是件壞事呢?個人覺得是毫無設計的 setter 被濫用,物件的狀態以不受控的方式被修改。事實上透過 setter 修改狀態跟存取 global variables 是差不多的,囉嗦且沒有保護到狀態。

Kent Beck 的《Implementation Patterns》書中提到:

比起 getting method,我更不願意讓 setting method 可見。setting method 是根據實作來命名的,而不是根據意圖。

那什麼是根據意圖設計的 method?以剛剛提到的自動販賣機為例,為投入的金額提供 setter 就不是一個好設計,雖然 UI (或任何使用物件的第三方) 在投入時,取得目前金額加上投入的金額再呼叫 setter 更新資料讓狀態是對的,但如果 UI 沒有這樣做,直接呼叫 setter 設定一個錯誤的金額,這時狀態的變化就是不預期的。

以自動販賣機為例

別小看自動販賣機 (有點後悔當初選它作為例子),平常的操作 (happy path) 只是複雜狀態轉換中的一小部分,就如下圖中深藍色的路徑:機器插上電,因為有東西可以選購,所以從 {初始狀態} 進入 {等待狀態},投入第一個硬幣後進到 {投幣狀態},但金額可能還不夠 [2],所以繼續投幣,但不論投幾個,仍停留在 {投幣狀態},假設金額夠了,選取飲料後,飲料掉下來,但可能因為餘額還夠選購其他飲料,所以仍停在 {投幣狀態} [3],按下退幣,機器把餘額找回後回到 {等待狀態}

但這只是一個 happy path,若考慮零錢箱空了或是滿了,架上商品都賣完了等諸多情況,或是不足額時按下選擇飲料該怎麼辦?於是一個複雜的狀態圖就出現了 (實際上這已經不知道是第幾個版本了,但我也不敢保證下圖是完整的)。一般來說,當思考的越完整,系統就能夠越強健 (robust)。

Figure 2 — Slot machine state chart

說這麼多,那怎樣才是比較好的設計?首先,定義系統功能,若將建立物件的動作視為 power on ,銷毀物件視為 power off,排除後,要提供給消費者的功能有:投幣 insert(coin)、選擇飲料 select(slot)、退幣 refund(),提供給管理者的功能有:補貨 refill(drink, slot, amount) 和整理零錢箱 setupCoinBox(coin, amount)。這五個組合自動販賣機的主要介面。如果考慮到不同使用者能看到不同的介面,可拆成兩個介面 (SlotMachineManageableSlotMachine),但由同一個類別同時實作兩個介面。

定義介面時要思考參數的型別,例如:介面是否可以接受各種面額的銅板?要支援各種國家的貨幣嗎?有些語言有支援無號數 (unsigned),有些語言則沒有 (很可惜 Java 8 有些 API 輔助但不算有支援),那要不要限制只能使用正整數 (unsigned integer) 或是更嚴格地只能使用列舉 (enum),就像 Coin 定義了銅板的列舉,這邊稍微簡化一下,貨幣都是新台幣。

有些參數可能無法列舉或不好列舉,例如有多少貨架 (slot),每個貨架能放多少飲料,它們可能有最大值或最小值,但用列舉會不太實際,最重要的是參數與回傳值能不能被修改。再來是思考,要提供什麼資訊給外界使用,像是有多少貨架,每個貨架上是什麼飲料,要多少錢等等。

到目前為止,都還沒有談到怎麼設計狀態該怎麼儲存,但有了這些介面,再設計狀態的資料結構會比較容易。這大概是我這幾年習慣先設計 interface 再實作的原因之一:先讓自己思考這個物件需要提供什麼服務給外部使用,getter 和 setter 都是其次。

每個貨架的資訊,用一個 SlotInfo 介面表示,仔細看會發現,這個介面只有 getter,並沒有 setter,因為不希望讓取得貨架資訊物件的人可以任意修改內容。飲料資訊也是類似的情況,Drink 是一個 immutable 類別,在建構子傳入必要的資訊後,再也沒有其他函式可以改變內部的狀態。

最後,開始實作 SlotMachine 和 ManageableSlotMachine 介面,這時才開始思考怎麼儲存狀態所需要的資訊,為了處理可變狀態,DefaultSlotMachine建立一個只有內部才能存取 (private class) 的 RefillableSlot 類別,它實作 SlotInfo 介面,因此在 DefaultSlotMachine 的第 70 行可以直接將物件丟出去,但對外部來說並不知道它是可變的。

從上例可以看到,內部狀態怎麼儲存和公開的介面不同,內部資料結構沒有提供 getter 也沒有提供 setter,只能透過公開介面以規範的方式存取。也就是說,內部實作不管怎麼變,公開的介面維持不變,對使用者來說,它就是投幣自動販賣機。

進階使用案例

假設我們想用 state pattern 來處理不同狀態下,refund()select(slot) 或 insert(coin) 的不同行為,沒問題,儘管嘗試,由於不是透過 setter 讓外部更新狀態,內部怎麼改都行,只要維持公開介面不變,滿足介面的合約 (預期行為) 就行,這才是 OOP 想做到的事情。

事實上,用 FP 寫程式其實也是如此,先思考有什麼行為,狀態的資料結構會是函式參數的一部分,這不見得是一件壞事,因為寫單元測試時,資料的 set up 相對容易,但也不完全是好事,因為當想改變資料結構時,同時會影響到使用者。

由於內部狀態被保護,所以在測試時確實很難像 FP 那樣,可以將任意狀態當作參數測試函式的行為,但同樣地,這也不見得是一件壞事,測試程式碼可以自己設計合適的資料結構表示狀態,只是需要提供一個像下例的輔助函式將測試資料的資料結構轉換成待測物件。

這同樣也適用備份跟還原狀態上,假設要每個行為後的狀態記錄下來,當要還原到前一個動作的狀態,可以將紀錄的狀態還原回來。因此,並不是因為用了 OOP 或是封裝就無法做到時光機,只是需要額外的程式。

小結

選擇語言是一種 trade-off,試想我們有多常用到這功能,若沒有,我們也不需要花這個功不是嗎?所以並不是哪一種 paradigm 就無法做到什麼事,而是要花多少時間才能做到想做的事。生產力 (productivity) 也不是唯一要考慮的事情,可維護性、可讀性、效率等,都會影響到選擇,這也是為什麼有人喜歡 FP,有人喜歡 OOP,有人喜歡動態型別語言,有人喜歡靜態行別語言了。


註釋

註 1:左邊因為過去的所有狀態都保留下來 (其實還是要另外處理,不然記憶體空間會被回收才對),但在 debug 時有前後可以對照,確實清楚許多。
註 2:如果架上所有飲料價格都一樣,拆出足額與不足額狀態,確實能增加對系統的理解,但實際上價格可能不一樣,很難用一個系統狀態表示 (倒是可以作為 slot 的狀態)。
註 3:這個部分可能因為不同的設定,行為不一樣,有的是直接找零然後回到 {等待狀態})。

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.