在學習程式語言的過程中,第一個接觸的流程語法就是if...else,簡單易懂,容易與生活上的經驗做結合,不用多做解釋,因而在遇到需要判斷條件時,就會信手拈來,加以使用,然而,往往在事後檢閱程式碼時,發現到程式碼中充滿了一連串的if...else,而且,無論是形成巢狀、或者是形成瀑布,在視覺、可讀性或者未來除錯上,都會造成一定程度的干擾。
if...else的本質
if...else簡單嗎?如果看過無數次的程式碼中,充滿了無數個if...else,或許就不再覺得簡單了。試著在網路上搜尋「avoid if else」,或者是搜尋「避免 if else」,都能找到數量龐大的文件,有的是尋求,有的是說明如何能不使用if...else。但這是怎麼回事?使用if...else錯了嗎?
程式的本質就是要能根據條件的不同,進行不同的處理,就算是重複執行或者是遞迴,本質上也是需要條件判斷,因而if...else的存在當然是必要的,錯的是我們太熟悉if...else,也因而常漫不經心地使用if...else,而沒能去思考既然名為if...else,實際上它在使用上的本質,就是具有成立與不成立的二元性,如果程式碼中出現了巢狀,或者是瀑布式的if...else排列,若能觀察出條件判斷上重複的二元性到底為何,就會有改用其他設計,避免使用if...else的可能。
在物件導向的世界中,常見的建議之一,就是避免使用if...else來判斷物件的型態,像是在Java中,若是在一連串的if...else中使用了instanceof,根據物件的不同型態進行不同處理的話,九成的情況下,都是不好的訊號,如果這些物件的型態具有繼承關係,稍有經驗的開發者,就會試著使用多型來避免使用if...else。
有時型態的判斷可能是隱性的,例如根據不同的列舉值,進行不同的處理——雖然列舉通常會使用switch來比對,但是,switch本質上等同於使用if/elif...else來對值進行判斷,如果列舉值來源與某個型態有關係,那麼switch的使用可能就是不必要的,在《重構:改善既有程式的設計》中第一章就提到:「在另一個物件的屬性基礎上運用switch,並不是什麼好主意」,這時可以如書中的說明,考慮改用多型設計,從而避免使用switch來進行比對。
運用設計模式
多型是物件導向中的設計元素之一,從多型出發可以導出許多的設計模式,儘管設計模式在今日有時被認為過時,然而在避免if...else這部份來說,設計模式中確實是有幾個可做為思考的方向。
例如,有時if...else會針對某些值來比對,然後決定採取某個策略,像是根據指定的名稱來決定音訊格式的轉換,而未來可能增加音訊格式,這時可考慮Strategy模式的方向。
此時,實際的轉換實作是針對各個策略物件。如果要隱藏策略物件的生成細節的話,可結合Factory method來生成策略物件,至於方法中生成策略物件的方式,若是Java的話,可考慮使用Reflection,或者是使用Map,這時音訊格式的名稱可以為鍵(key),用於取回不同的策略實例。
如果用if...else做了值的比對,在各自做了某些處理後改變狀態,若狀態很多,或者是狀態之間切換方式複雜,那麼可以考慮State模式的方向,讓每個狀態各自成為一個物件,負責自己該狀態時如何進行服務,也知道切換至下一個狀態的方式。
Chain of Responsibility模式,也是一個可以考慮的模式。一個可套用的情境是「如果OOO成立,就XXX」瀑布地重複地出現,這時可以建立不同職責物件,各自負擔「如果OOO成立,就XXX」這樣的職責,然後依需求組織這些職責物件,組織的方式可以是個簡單的清單,然後使用foreach的方式迭代執行,或者是讓每個職責物件知道下一個職責物件會是哪個。
而這就像是個生產線,產品到我面前時,再判斷自己是否該執行工作。
有時對物件型態的判斷可能難以避免,與其寫一堆if...else並結合instanceof之類的分支,不如考慮Visitor模式的做法,將這些分支集中在一個Visitor來管理,運用重載的原理,讓編譯器來協助做型態的判斷,而不是自行撰寫if...else,未來如果需要新增或修改特定的物件實作,也可以集中在Visitor中進行,而不是撰寫更多的if...else分支。
Monad!
現在這個時間點,談到Monad,應該比較不會讓開發者驚悚了吧!隨著函數式典範的元素,逐漸出現在各主流語言之中,開發者或多或少都聽過Monad,或者儘管不瞭解Monad,透過觀察重複的模式,將程式碼流程重構至更高階的設計,也都有了Monad的概念存在,在這些Monad中,有些就包裝了if...else。
如果if...else涉及null值的判斷,且形成巢狀或瀑布式的流程,那麼,可以考慮Optional這樣的Monad,如此一來,除了能避免對空值的認知不同而帶來的問題之外,還可以使用map或flatMap,來解決巢狀或瀑布式的流程(可以參考我先前專欄〈遲到的Optional?〉)。
現在許多語言,多半使用例外處理機制,在函數式語言中,執行結果對或錯,則是傳回一個Either,呼叫者必須比對Either中Right與Left,決定進行哪個動作。在Scala這門語言也有Either,而且更進一步地有著Try這個型態,也可以透過map、flatMap來形成一種鏈狀操作,執行後可能是獲得最後一個操作結果,也可能是鏈狀操作過程中發生錯誤的一個Failure實例(可以參考我先前專欄〈函數式風格錯誤處理〉)。
然而,在Go中,反其而行地,函式有可能發生錯誤時,如果認為是個呼叫者可以處理或必須處理的錯誤,必須傳回error,這類函式通常傳回兩個值(Go允許這麼做),因此Go中常見ok, err = some(param)來拆解傳回值,然後使用if去判斷err是否為nil,決定是否處理錯誤或者進行正常流程,這使得初學者很容易寫出一堆if...else。另一方面,也確實有人試著在Go中實作Monad,來解決這類問題,例如〈maybe.go〉這個Gist。
使用if...else要謹慎
使用Monad的概念來處理if...else,是函數式的風格,並非適用於if...else判斷是否有錯誤的全部場合,實際上在Go中,if...return處理錯誤才是慣用法,鼓勵在檢查出錯誤並處理善後之後,直接return(錯誤),進一步的商務流程是撰寫在return之後,而不是else之中,官方文件也隨處可見這樣的寫法,例如〈Effective Go〉中的if說明。
避免使用else的原因在於,這可以避免不必要的縮排,而且在if...return之後的程式碼,就必然是沒有任何錯誤的情況下才會執行的邏輯,而不是依賴if中的錯誤處理結果來撰寫後續邏輯,對於可讀性或日後的程式碼除錯來說,都會有幫助。
因此,就算是有瀑布式的if...return在處理錯誤,只要它們實際上是在處理不同的錯誤,在Go中也是合理的作法,當全部的錯誤判斷結束後,真正的商務處理,建議封裝為一個函式來進行呼叫,這形成了Go的特有風格。
因此,重點在於避免隨意地使用if...else,因為,這往往會出現重複的if...else判斷。如果觀察到if...else重複地出現,無論是瀑布式或是巢狀,就要察覺這是一種訊號。如果確實是在做不同的事,最簡單的做法,是將這些不同的事,抽取至不同的函式之中,並取個適當的名稱來突顯意圖。
因此,一旦觀察到有重複的模式出現,則可思考多型、設計模式或者像是Monad等更高階的設計概念,來避免這類惱人的if...else。