閒談軟體設計:State 與語言

更新於 發佈於 閱讀時間約 10 分鐘

永遠有狀態

會想寫這篇是因為先前看到一篇網路文章討論用物件管理狀態的問題,看完後我只能說,不論用 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

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

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:這個部分可能因為不同的設定,行為不一樣,有的是直接找零然後回到 {等待狀態})。

avatar-img
53會員
104內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
Spirit的沙龍 的其他內容
針對這議題,從 devOps 的角度看,團隊應抱有持續不斷地改進的精神,努力降低上版的風險,最後,哪一天上版就僅僅是風險控管的問題了。風險控管除了考量到損失,當然還要考慮到團隊要怎麼 on-call,on-call 的資源夠不夠應付上版後的突發狀況,才能做出適當的決策。
這文章來自網友在 在 Medium 上的留言 (有人幫忙想題目也挺不錯的),問到:Singleton 對於好的架構來說是否能避免就避免呢?我簡單地回了一下我的想法 ,但 Singleton 其實很有趣,所以就寫篇文章來聊聊吧!
真的要符合 single responsibility,通常會得到很多很小的類別或是函式,各別完成一個小的功能,然後在某個地方被聚合起來完成一個使用案例 (use case),而不是一個很大的類別,包山包海,然後最後變成一個狀態超複雜,超級難測試的類別。
這是幾年來我對於軟體架構師的心路歷程,上述不保證讓你成為軟體架構師,但希望會對軟體工程師職涯有幫助。也希望台灣的軟體公司能稍微多注重一下軟體架構,甚至能像 91App 不只工程師團隊,還有軟體架構團隊。
我個人是盡可能不寫 switch statement,但觀察這幾年程式語言的趨勢,會發現許多語言把 switch statement 擴充成為實作 pattern matching 的工具,說不定以後 switch statement 會越來越廣泛使用也說不定。
長遠的角度來看,內部函式庫還是值得投資的公司資產,只是它需要時間、人力與管理才能做得好。若有不錯的內部函式庫也可以回饋給open-source社群,畢竟,現在開發軟體已經不太可能沒有用到任何open-source的東西。雖然說是將公司資產以 open-source 釋出,但換取的利益卻不見得是零。
針對這議題,從 devOps 的角度看,團隊應抱有持續不斷地改進的精神,努力降低上版的風險,最後,哪一天上版就僅僅是風險控管的問題了。風險控管除了考量到損失,當然還要考慮到團隊要怎麼 on-call,on-call 的資源夠不夠應付上版後的突發狀況,才能做出適當的決策。
這文章來自網友在 在 Medium 上的留言 (有人幫忙想題目也挺不錯的),問到:Singleton 對於好的架構來說是否能避免就避免呢?我簡單地回了一下我的想法 ,但 Singleton 其實很有趣,所以就寫篇文章來聊聊吧!
真的要符合 single responsibility,通常會得到很多很小的類別或是函式,各別完成一個小的功能,然後在某個地方被聚合起來完成一個使用案例 (use case),而不是一個很大的類別,包山包海,然後最後變成一個狀態超複雜,超級難測試的類別。
這是幾年來我對於軟體架構師的心路歷程,上述不保證讓你成為軟體架構師,但希望會對軟體工程師職涯有幫助。也希望台灣的軟體公司能稍微多注重一下軟體架構,甚至能像 91App 不只工程師團隊,還有軟體架構團隊。
我個人是盡可能不寫 switch statement,但觀察這幾年程式語言的趨勢,會發現許多語言把 switch statement 擴充成為實作 pattern matching 的工具,說不定以後 switch statement 會越來越廣泛使用也說不定。
長遠的角度來看,內部函式庫還是值得投資的公司資產,只是它需要時間、人力與管理才能做得好。若有不錯的內部函式庫也可以回饋給open-source社群,畢竟,現在開發軟體已經不太可能沒有用到任何open-source的東西。雖然說是將公司資產以 open-source 釋出,但換取的利益卻不見得是零。
你可能也想看
Google News 追蹤
Thumbnail
嘿,大家新年快樂~ 新年大家都在做什麼呢? 跨年夜的我趕工製作某個外包設計案,在工作告一段落時趕上倒數。 然後和兩個小孩過了一個忙亂的元旦。在深夜時刻,看到朋友傳來的解籤網站,興致勃勃熬夜體驗了一下,覺得非常好玩,或許有人玩過了,但還是想寫上來分享紀錄一下~
Thumbnail
雖說世事無常,無條件的愛卻是恆常的。 這邏輯確實有些匪夷所思,但要是能把層次區分來看,又清楚明瞭了。 無論現象如何流轉,某個允許現象得以存在的空間始終存在。 如果可以窺探層次之間是如何轉換的,或許可以觸摸到創造的知識與力量。 定律、定理、邏輯、理性、源碼、信念、真理...,類似這些概念構築了
Thumbnail
這篇內容,將會講解什麼是腳本函式,以及與腳本函式相關的知識。包括腳本的簡介、使用函式(或全域變數)的注意事項、定義全域變數、定義函式、什麼是宣告、局部變數的應用。
Thumbnail
這篇內容,將會講解什麼是變數範圍,以及與變數範圍相關的知識。包括變數範圍的簡介、實體變數、全域變數、局部變數、常數。
Thumbnail
本文介紹了在網站開發中如何運用狀態機的原則和設計方法。通過具體案例分析,以及狀態和數據的區分,詳細介紹了狀態機的設計原則和應用。讀者可以通過本文瞭解如何將狀態機應用於實際的網站開發中。
pure function 是甚麼呢? 最主要兩大特點 : 淺顯易懂的說法就是 : 對於有相同的輸入,就會有相同的輸出。 無副作用 : 不會去修改或依賴外部的狀態。 舉一個例子 : function add(a, b) { return a + b; } function裡面他帶入的
Thumbnail
這一節談的是用物件導向程式設計(object-oriented programming, OOP)的方式來實作隨機漫步。
※ 非同步概念總複習 為什麼要使用 Promise? 在 JavaScript 開發中,處理非同步操作是常見需求,涉及如文件讀寫、數據庫查詢或網路請求等耗時任務。傳統的回調方式可能導致代碼結構混亂,稱為「回調地獄」,難以維護和理解。 Promise 是解決這問題的方法。它是一個物件(objec
Thumbnail
昨天寫完<要一直動,才會有安定、安穩的生活>時,想到所謂的「動」、「靜」其實和表面上看到的不一樣,所以又來說一說。   「動」和「改變」可能是同一個意思,人們在動的時候是一直地在變換位置、動作或是方向的啊!   動就是改變,不能以看到的現狀來評斷它現在是在動,還是靜。   從靜止的狀態開
函數式編程跟物件導向一個很大的差異在於對資料可變性(mutability)的態度,函數式編程不鼓勵修改原有的資料,有些語言甚至沒有修改的概念;而物件導向專注於狀態的改變,物件作為閉包就已經假設資料是可變的。這種對於可變性的態度注定物件導向比較容易得到關注,因為這個模型比較符合電腦底層的運作邏輯,而我
前幾篇文章討論了類型系統的合理性,而這會影響我們對於變數與函式是什麼的理解。其中泛型是當中很重要的一個元素,很多討論都是基於泛型的使用。泛型會大大地增加類型系統的複雜度,因此有些語言選擇不提供泛型(go),但缺少泛型又會使簡單的容器都無法用類型精確描述。泛型的強大必須結合有紀律的類型系統才能顯現,但
Thumbnail
嘿,大家新年快樂~ 新年大家都在做什麼呢? 跨年夜的我趕工製作某個外包設計案,在工作告一段落時趕上倒數。 然後和兩個小孩過了一個忙亂的元旦。在深夜時刻,看到朋友傳來的解籤網站,興致勃勃熬夜體驗了一下,覺得非常好玩,或許有人玩過了,但還是想寫上來分享紀錄一下~
Thumbnail
雖說世事無常,無條件的愛卻是恆常的。 這邏輯確實有些匪夷所思,但要是能把層次區分來看,又清楚明瞭了。 無論現象如何流轉,某個允許現象得以存在的空間始終存在。 如果可以窺探層次之間是如何轉換的,或許可以觸摸到創造的知識與力量。 定律、定理、邏輯、理性、源碼、信念、真理...,類似這些概念構築了
Thumbnail
這篇內容,將會講解什麼是腳本函式,以及與腳本函式相關的知識。包括腳本的簡介、使用函式(或全域變數)的注意事項、定義全域變數、定義函式、什麼是宣告、局部變數的應用。
Thumbnail
這篇內容,將會講解什麼是變數範圍,以及與變數範圍相關的知識。包括變數範圍的簡介、實體變數、全域變數、局部變數、常數。
Thumbnail
本文介紹了在網站開發中如何運用狀態機的原則和設計方法。通過具體案例分析,以及狀態和數據的區分,詳細介紹了狀態機的設計原則和應用。讀者可以通過本文瞭解如何將狀態機應用於實際的網站開發中。
pure function 是甚麼呢? 最主要兩大特點 : 淺顯易懂的說法就是 : 對於有相同的輸入,就會有相同的輸出。 無副作用 : 不會去修改或依賴外部的狀態。 舉一個例子 : function add(a, b) { return a + b; } function裡面他帶入的
Thumbnail
這一節談的是用物件導向程式設計(object-oriented programming, OOP)的方式來實作隨機漫步。
※ 非同步概念總複習 為什麼要使用 Promise? 在 JavaScript 開發中,處理非同步操作是常見需求,涉及如文件讀寫、數據庫查詢或網路請求等耗時任務。傳統的回調方式可能導致代碼結構混亂,稱為「回調地獄」,難以維護和理解。 Promise 是解決這問題的方法。它是一個物件(objec
Thumbnail
昨天寫完<要一直動,才會有安定、安穩的生活>時,想到所謂的「動」、「靜」其實和表面上看到的不一樣,所以又來說一說。   「動」和「改變」可能是同一個意思,人們在動的時候是一直地在變換位置、動作或是方向的啊!   動就是改變,不能以看到的現狀來評斷它現在是在動,還是靜。   從靜止的狀態開
函數式編程跟物件導向一個很大的差異在於對資料可變性(mutability)的態度,函數式編程不鼓勵修改原有的資料,有些語言甚至沒有修改的概念;而物件導向專注於狀態的改變,物件作為閉包就已經假設資料是可變的。這種對於可變性的態度注定物件導向比較容易得到關注,因為這個模型比較符合電腦底層的運作邏輯,而我
前幾篇文章討論了類型系統的合理性,而這會影響我們對於變數與函式是什麼的理解。其中泛型是當中很重要的一個元素,很多討論都是基於泛型的使用。泛型會大大地增加類型系統的複雜度,因此有些語言選擇不提供泛型(go),但缺少泛型又會使簡單的容器都無法用類型精確描述。泛型的強大必須結合有紀律的類型系統才能顯現,但