iT邦幫忙

2021 iThome 鐵人賽

DAY 2
0
Software Development

From State Machine to XState系列 第 2

Day02 - 觀察:自由的程式碼?有什麼蛛絲馬跡、現象?

自由的潛在風險 -( 問題發生的原因 )

前一天的故事及開發範例,我們發現隨著新需求的出現,我們必須回去修改既有的邏輯,這樣子的情形,並不符合開放封閉原則,也很容易漏思考一些東西。

由於程式碼的執行順序是從上至下,從外到裡。
理所當然的,排在前面 if / else statement 有命中,就會先進去裡面執行,而忽略 else 之後的內容。

然而這看起來卻只是問題的表象

“Fix the cause, not the symptom.” – Steve Maguire

過於自由帶來的負擔 - ( 問題可能產生的後果 )

或許你會說,錯誤有什麼關係、下次改版修回來就好了啊,QA 也應該要測到,或者 PM 新需求也應該想清楚或是要記得回去把舊規格書補好。

但假設你今天開發的是一個低容錯率的系統,如:

  • 飛機操縱系統,一飛上天,可是幾百條人命在空中的;
  • 金融系統,一個小錯誤可能就會瞬間損失大量現金等等...。

隨著不斷加入的新功能,我們或許會越改越沒信心,很怕改一些東西,其他地方會不會壞掉?(改 A 壞 B)

https://ithelp.ithome.com.tw/upload/images/20210917/20130721zJ0BdQ3LRE.jpg

看醫生時,我們希望醫生不要治標不治本;
身為程式碼醫生,我們也要避免治標不治本。

問題的本質是什麼?

所以追根溯源,究竟問題的本質是什麼?為什麼有這個現象發生?這是今天要來探討的

問題可能產生的後果

下面的舉例,或許是一些常見的組合?

if(isLogin && !isLoading && hasData) // 登入、資料載完、有資料,畫出特定 UI
if(isLogin && isBuyer && isPaid) // 登入、身份是買家、已付費,執行特定動作

透過

  1. 不斷新增 flag 放入 if/ else 中或是
// From 舊功能
if(a && b)
// To 新功能
if(a && b && c)
  1. 不斷增添 if / elseelse if的開發特性
// From 舊功能
if(a && b) 
else
// To 新功能
if(a && b)
else if (新條件)
else if (超新條件)
else

就會造成新功能開發時,就要往上、往前去進行檢查

除了可讀性會越來越差之外,程式碼意義是由許多破碎的小單元組成,是隱含的、命令式的!

  • 什麼是if(flagA && flagB && flagC && flagD)? (背後代表的意義)
  • 為什麼是if(flagA && flagB && flagC && flagD)? (組合產生的原因,像昨天的一些防禦、防呆:跳躍不能直接進到匍匐)

充滿命令式(imperative) 的邏輯越來越多(當你 flag 越多),發生錯誤時的除錯也會越來越困難。

除錯難易度上升

每多一個 flag ,可能存在的狀況就 x 2 倍,除錯難度提高

if(flagA) doSomething()

舉例 flagA,遇到錯誤時,我們就是只有 2 種可能 flagA is true or flagA is false,此時我們只要去檢查 flagA 為啥會判斷錯誤即可

if(flagA || flagB) doSomething()

今天多了 1 個 flagB,發生錯誤時的檢查,變成 4 種可能

A B A or B
true true true
true false true
false true true
false false false

再多一個 flag 這裡的邏輯組合性,就變成 https://chart.googleapis.com/chart?cht=tx&chl=2%5E3 ,8 種可能。

再換一個角度想想,其實這些 flag 是不是都代表一些小狀態,我們想透過這些小狀態組合出一個大的狀態。我們可以說將這些小狀態堆疊出大狀態的過程是,由小至大,由下而上(抽象層次)。

隨著功能、需求不斷新增,我們需要的 flag 可能就越來越多,程式碼就越來越容易出 bug。

狀態的描述性下降

可讀性降低,因為沒辦法直觀的一眼看出,要花時間思考

if(isLogin&&!isLoading&&hasData) // 登入、資料載完、有資料,畫出特定 UI
if(isLogin&&isBuyer&&isPaid) // 登入、身份是買家、已付費,執行特定動作

其實 isLogin&&!isLoading&&hasData 就只是想表達有「登入載完資料後的畫面」,
這已經是 happy path,但你還要去思考 not Login + loading + noData ... 這 2 x 2 x 2 總個 8 種可能。

扣掉第一種,其他七種狀態的意義是什麼?或是說你可能不需要那麼多種,但防呆判斷有沒有可能漏掉

這也代表著我們狀態的描述性是很差的

過於自由奔放的 code ,冗余的負擔

同樣以上面例子,或許在你的系統內 loading 就只有一種整頁是滿版轉圈圈的畫面,只要 isLoading === true,其他 isLogin, hasData 都是多餘的。

  (isLogin && !isLoading && hasData) ? <DataTable /> : <EmptyWrapper />

這樣是不是也增進未來閱讀的負擔,要多停下思考

比如 isLoading: true, isLogin: false, hasData: true 這個語句可能對你的系統是無意義的

沒看過的人可能還要思考在 isLoading: true, isLogin: false, hasData: true 的意義是什麼?
正在 Loading 但有資料、卻沒登入???這什麼意思?

但假設設計師今天僅來得及先出 2 種畫面,loading:<LoadingSpinner /> 及 loaded:<DataTable /> ,一時專案趕或是為求程式碼簡潔,直接抽換掉後面的元件

  (isLogin && !isLoading && hasData) ? <DataTable /> : <LoadingSpinner />

有發現哪裡怪怪的嗎?

isLogin isLoading hasData 意義
true true true Loading 除了isLoading 另外2 flag 冗余
true true false Loading 除了isLoading 另外2 flag 冗余
true false true Loaded /(List View) 冗余(假如設計師沒出 2 種區別,通通用 Table 裝起來而已)
true false false Loaded / (Empty View) 冗余(假如設計師沒出 2 種區別,通通用 Table 裝起來而已)
... ... ... 無意義
... ... ... 無意義

如果依照這個邏輯寫下去,當登入後、拿回 API response 時,是空的沒有資料,現在這組邏輯就會永遠只看到轉圈圈 <LoadingSpinner /> ,就變成是等設計師出完空資料畫面<EmptyWrapper />之前,要拔掉 hasData 這個 flag ,因為這階段根本不需要它的存在。

有發現哪裡怪怪的嗎?

其實這個邏輯語句太強了,只關注到 3 個 flag 都是 true 的結果,當不是 true 時的意義沒有被正確定義時,用三元運算後面回傳什麼都很怪

(isLogin && !isLoading && hasData)hasData===false 回傳 <LoadingSpinner /> ... 怪

(isLogin && !isLoading && hasData)isLogin===false 回傳 <LoadingSpinner /><EmptyWrapper /> ... 可能都怪 (應該要 redirect 到登入頁)

為了避免這個情形,如果又改使用 巢狀 if else if{if{}else{}},開發起來很難讀,隨著需求增刪也要來來回回檢查

邏輯的連動性、相依性

同理在思考狀態轉換時,很容易 miss 小東西,比如說有個 isLogin, isAdmin 的 flag 組合,照理說當 isAdmin 是 true 時,isLogin 一定要是 true,但我們在開發時,很有可能漏加到。

isLogin isAdmin 意義
false true Buggy 不應該存在,但現實很容易就會漏改

溝通成本及可能性

可讀性下降,溝通成本提高

上面一堆 flag 組合起來的結果,光是工程師們都要花一段時間消化跟思考...
試問如何快速跟新人交接?
或是當 bug 發生時,我們如何跟工程師以外的人解釋?
而且除錯時,也很難快速發生問題、或很難跟 PM 討論需求釐清是否存在邏輯矛盾或是漏處理到的 case。

問題的來源 - 不斷累積、增加的 flag 及 if/else 區塊

新功能的開發,我們能不能避免建立過多的 flag、減低巢狀、一長串的邏輯判斷?
有沒有辦法更從使用者的角度出發?
(雖然是依據規格設計,但前面我們是以程式碼的邏輯出發,長出一串邏輯判斷)

我們再換個角度來看看需求,這次多靠近使用者觀點一點。

獨立存在的狀態 (同一時間,只會有一個狀態,不使用多個 flag 判斷)

先不管攻擊、防禦等等,以角色移動而言,仔細觀察我們的 站、跳躍、匍匐前進(左右負責控制移動的方向),其實跳躍、匍匐前進等等都不會同時存在,我們是不是可以視彼此為一種獨立存在的狀態。

isJumping, isCrawling 其實也只是為了判斷以上的行為而存在的中介狀態(暫時存在的狀態)。

以一張電商的訂單而言,買家提交訂單(給賣家先確認庫存),賣家確認有貨可成立點選同意,訂單進入等待付款,付款完成收帳後進入等待發貨倉儲人員配貨、安排物流,送達目的地後等待取件,取件完成後訂單交易完成

等待發貨、等待取件不會同時存在(就像是跳躍跟俯臥不會同時存在),看起來我們需求中,訂單的每個階段,好像都能視為一種狀態。

舊狀態 → 新狀態,有明確的轉移路徑

還沒付款不能跳到等待發貨的狀態,

俯臥不能直接跳躍(要先站起來)

我們觀察到,狀態與狀態間、有明確的轉移路徑(或是你刻意要限制明確的路徑)

狀態轉移 合理性
站 → 跳 → 站 O合理
站 → 俯 → 站 O合理
跳 → 俯 → 站 X不合理
俯 → 跳 → 站 X不合理
狀態轉移 合理性
提交 → 待付款 → 待發貨→ 待取件→ 交易完成 O合理
提交 → 待取件→ 交易完成 → 待付款 → 待發貨 X不合理
提交 → 待發貨 → 待取件 → 交易完成 → 待付款 X不合理

我們現在發現了這幾個有趣的現象,明天一起繼續往下探索吧!


小結

解釋了昨天的解決方案帶來了什麼困擾及難題

  • 狀態不具描述性、可讀性低
  • 違反開放封閉原則
  • 透過一系列 if / else 判斷狀態、進行防呆,當新增、修改功能時
    1. 要回去改動舊功能的程式碼
    2. 每新增一個狀態 flag ,除錯複雜度就大幅提升
    3. 狀態描述性下降、可讀性降低
    4. 溝通成本提升
    5. 必須注意邏輯之間的連動性、相依性
    6. 容易漏思考東西
    7. 容易產生冗余的邏輯語句
  • 換個角度觀察
    1. 獨立的狀態
    2. 舊狀態 → 新狀態,有明確的轉移路徑

參考文獻


上一篇
Day01 - 緣起:怎麼了?為什麼?如何掌握過於自由的程式碼?
下一篇
Day03 - 個體、對象以及狀態
系列文
From State Machine to XState31

1 則留言

0
TD
iT邦新手 4 級 ‧ 2021-09-18 14:45:37

哇這就是我現在遇到的問題!

Ken Chen iT邦新手 5 級 ‧ 2021-09-18 16:13:59 檢舉

讓我們一起看下去~ XDD

我要留言

立即登入留言