我的答案是,超過兩個 else 的 if ,或者是超過兩個 case 的 switch 。可是在代碼中大量使用 if else 和 switch case 是很正常的事情吧?錯!絕大多數分支超過兩個的 if else 和 switch case 都不應該以寫入程式碼( hard-coded )的形式出現。
複雜分支從何而來
首先我們要討論的第一個問題是,為什麼遺留代碼裡面往往有那麼多複雜分支。這些複雜分支在代碼的首個版本中往往是不存在的,假設做設計的人還是有點經驗的話,他應該預見將來可能需要進行擴充的地方,並且預留抽象介面。
但是代碼經過若干個版本的迭代以後,尤其是經過若干次需求細節的調整以後,複雜分支就會出現了。需求的細節調整,往往不會反映到 UML 上,而會直接反映到代碼上。例如說,原本訊息分為聊天訊息和系統訊息兩類,設計的時候自然會把這設計為訊息類的兩個子類。但接著有一天需求發生細節調整了,系統訊息裡面有一部分是重要的,它們的標題要顯示為紅色,這時候程式員往往會做如下修改:
在系統訊息類上面加一個 important 屬性
在相應的 render 方法裡面加入一個關於 important 屬性的分支,用於控制標題顏色
程式員為什麼會作出這樣的修改?有可能因為他沒意識到應該抽象。因為需求說的是「系統訊息裡面有一部分是重要的」,對於接受命令式程式設計語言訓練比較多的程式員來說,他或許首先想到的是標誌位──一個標誌位就可以區分重要跟不重要。他沒想到這個需求可以用另一種方式來解讀,「系統訊息分為重要和不重要兩種類別」。這樣子解讀,他就知道應該對系統訊息進行抽象了。
當然也有可能,程式員知道可以抽象,但基於某些原因,他選擇了不這樣做。很常見的一種情況就是有人逼著程式員,以犧牲代碼品質來換取項目進展速度──加入一個屬性和一個分支,遠比抽象重構要簡單得多,如果要做10個這種形式的修改,是做10個分支快還是做10個抽象快?區別顯而易見。
當然, if else 多了,就有聰明人站出來說「不如我們改成 switch case 」吧。在某些情況下,這確實能夠提升代碼可讀性,假設每一個分支都是互斥的話。但是當 switch case 的數量也多起來以後,代碼一樣會變得不可讀。
複雜分支有何壞處
複雜分支有什麼壞處?讓我從百度 Hi 網頁版的老代碼裡面截取一段出來做個例子。 複製代碼 代碼如下:switch (json.result) {
case "ok":
switch (json.command) {
case "message":
case "systemmessage":
if (json.content.from == ""
&& json.content.content == "kicked") {
/* disconnect */
} else if (json.command == "systemmessage"
|| json.content.type == "sysmsg") {
/* render system message */
} else {
/* render chat message */
}
break;
}
break;
這段代碼要看懂不難,因此我提一個簡單問題,以下這個 JSON 命中哪個分支: 複製代碼 代碼如下:{
"result": "ok",
"command": "message",
"content": {
"from": "CatChen",
"content": "Hello!"
}
}
你很容易就能得到正確答案:這個 JSON 命中 /* render chat message */ (顯示聊天訊息)這個分支。那麼我想瞭解一下,你是如何作出這個判斷的?首先,你要看它是否命中 case "ok": 分支,結果是命中了;然後,你要看它是否命中 case "message": 分支,結果也是命中了,所以 case "systemmessage": 就不用看了;接下來,它不命中 if 裡面的條件;並且,它也不命中 else if 裡面的條件,所以它命中了 else 這個分支。
看出問題來了嗎?為什麼你不能看著這個 else 就說出這個 JSON 命中這個分支?因為 else 本身不包含任何條件,它只隱含條件!每一個 else 的條件,都是對它之前的每一個 if 和 else if 進行先非後與運算的結果。也就是說,判斷命中這個 else ,相當於判斷命中這樣一組複雜的條件: 複製代碼 代碼如下:!(json.content.from == "" && json.content.content == "kicked") && !(json.command == "systemmessage" || json.content.type == "sysmsg")
再套上外層的兩個 switch case ,這個分支的條件就是這樣子的: 複製代碼 代碼如下:json.result == "ok" && (json.command == "message" || json.command == "systemmessage") && !(json.content.from == "" && json.content.content == "kicked") && !(json.command == "systemmessage" || json.content.type == "sysmsg")
這裡面有重複邏輯,省略後是這樣子的: 複製代碼 代碼如下:json.result == "ok" && json.command == "message" && !(json.content.from == "" && json.content.content == "kicked") && !(json.content.type == "sysmsg")
我們花了多大力氣才從簡簡單單的 else 這四個字母中推匯出這樣一長串邏輯運算運算式來?況且,不仔細看還真的看不懂這個運算式說的是什麼。
這就是複雜分支難以閱讀和管理的地方。想象你面對一個 switch case 套一個 if else ,總共有3個 case ,每個 case 裡面有3個 else ,這就夠你研究的了──每一個分支,條件中都隱含著它所有前置分支以及所有祖先分支的前置分支先非後與的結果。
如何避免複雜分支
首先,複雜邏輯運算是不能避免的。重構得到的結果應該是等價的邏輯,我們能做的只是讓代碼變得更加容易閱讀和管理。因此,我們的重點應該在於如何使得複雜邏輯運算變得易於閱讀和管理。
抽象為類或者工廠
對於習慣於做物件導向設計的人來說,可能這意味著將複雜邏輯運算打散並分布到不同的類裡面: 複製代碼 代碼如下:switch (json.result) {
case "ok":
var factory = commandFactories.getFactory(json.command);
var command = factory.buildCommand(json);
command.execute();
break;
}
這看起來不錯,至少分支變短了,代碼變得容易閱讀了。這個 switch case 只管狀態代碼分支,對於 "ok" 這個狀態代碼具體怎麼處理,那是其他類管的事情。 getFactory 裡面可能有一組分支,專註於建立這條指令應該選擇哪一個工廠的選擇。同時 buildCommand 可能又有另外一些瑣碎的分支,決定如何構建這條指令。
這樣做的好處是,分支之間的嵌套關係解除了,每一個分支只要在自己的上下文中保持正確就可以了。舉個例子來說, getFactory 現在是一個具名函數,因此這個函數內的分支只要實現 getFactory 這個名字暗示的契約就可以了,無需關注實際調用 getFactory 的上下文。
抽象為模式比對
另外一種做法,就是把這種複雜邏輯運算轉述為模式比對: 複製代碼 代碼如下:Network.listen({
"result": "ok",
"command": "message",
"content": { "from": "", "content": "kicked" }
}, function(json) { /* disconnect */ });
Network.listen([{
"result": "ok",
"command": "message",
"content": { "type": "sysmsg" }
}, {
"result": "ok",
"command": "systemmessage"
}], function(json) { /* render system message */ });
Network.listen({
"result": "ok",
"command": "message",
"content": { "from$ne": "", "type$ne": "sysmsg" }
}, func tion(json) { /* render chat message */ });
現在這樣子是不是清晰多了?第一種情況,是被踢下線,必須匹配指定的 from 和 content 值。第二種情況,是顯示系統訊息,由於系統訊息在兩個版本的協議中略有不同,所以我們要捕捉兩種不同的 JSON ,匹配任意一個都算是命中。第三種情況,是顯示聊天訊息,由於在老版本協議中系統訊息和踢下線指令都屬於特殊的聊天訊息,為了相容老版本協議,這兩種情況要從顯示聊天訊息中排除出去,所以就使用了 "$ne" (表示 not equal )這樣的尾碼進行匹配。
由於 listen 方法是上下文無關的,每一個 listen 都獨立聲明自己匹配什麼樣的 JSON ,因此不存在任何隱含邏輯。例如說,要捕捉聊天訊息,就必須顯式聲明排除 from == "" 以及 type == "sysmsg" 這兩種情況,這不需要由內容相關的 if else 推斷得出。
使用模式比對,可以大大提高代碼的可讀性和可維護性。由於我們要捕捉的是 JSON ,所以我們就使用 JSON 來描述每一個分支要捕捉什麼,這比一個長長的邏輯運算運算式要清晰多了。同時在這個 JSON 上的每一處修改都是獨立的,修改一個條件並不影響其他條件。
最後,如何編寫一個這樣的模式比對模組,這已經超出了本文的範圍。