本文轉載自 酷 殼 – CoolShell 陳皓。
所謂箭頭型代碼,基本上來說就是下面這個圖檔所示的情況。

那麼,這樣“箭頭型”的代碼有什麼問題呢?看上去也挺好看的,有對稱美。但是……
關于箭頭型代碼的問題有如下幾個:
1)我的顯示器不夠寬,箭頭型代碼縮進太狠了,需要我來回拉水準滾動條,這讓我在讀代碼的時候,相當的不舒服。
2)除了寬度外還有長度,有的代碼的if-else裡的if-else裡的if-else的代碼太多,讀到中間你都不知道中間的代碼是經過了什麼樣的層層檢查才來到這裡的。
總而言之,“箭頭型代碼”如果嵌套太多,代碼太長的話,會相當容易讓維護代碼的人(包括自己)迷失在代碼中,因為看到最内層的代碼時,你已經不知道前面的那一層一層的條件判斷是什麼樣的,代碼是怎麼運作到這裡的,是以,箭頭型代碼是非常難以維護和Debug的。
上面這段代碼,可以把條件反過來寫,然後就可以把箭頭型的代碼解掉了,重構的代碼如下所示:
這種代碼的重構方式叫 Guard Clauses
Martin Fowler 的 Refactoring 的網站上有相應的說明《Replace Nested Conditional with Guard Clauses》。
Coding Horror 上也有一篇文章講了這種重構的方式 —— 《Flattening Arrow Code》
StackOverflow 上也有相關的問題說了這種方式 —— 《Refactor nested IF statement for clarity》
這裡的思路其實就是,讓出錯的代碼先傳回,前面把所有的錯誤判斷全判斷掉,然後就剩下的就是正常的代碼了。
有些人說,continue 語句破壞了閱讀代碼的通暢,我覺得他們一定沒有好好讀這裡面的代碼,其實,我們可以看到,所有的 if 語句都是在判斷是否出錯的情況,是以,在維護代碼的時候,你可以完全不理會這些 if 語句,因為都是出錯處理的,而剩下的代碼都是正常的功能代碼,反而更容易閱讀了。當然,一定有不是上面代碼裡的這種情況,那麼,不用continue ,我們還能不能重構呢?
當然可以,抽成函數:
你發出現,抽成函數後,代碼比之前變得更容易讀和更容易維護了。不是嗎?
有人說:“如果代碼不共享,就不要抽取成函數!”,持有這個觀點的人太死讀書了。函數是代碼的封裝或是抽象,并不一定用來作代碼共享使用,函數用于屏蔽細節,讓其它代碼耦合于接口而不是細節實作,這會讓我們的代碼更為簡單,簡單的東西都能讓人易讀也易維護。這才是函數的作用。
還有人問,原來的代碼如果在各個 if 語句後還有要執行的代碼,那麼應該如何重構。比如下面這樣的代碼。
上面這段代碼中的那些 do_after_condX() 是無論條件成功與否都要執行的。是以,我們拉平後的代碼如下所示:
你會發現,上面的 do_after_condX 出現了兩份。如果 if 語句塊中的代碼改變了某些do_after_condX依賴的狀态,那麼這是最終版本。
但是,如果它們之前沒有依賴關系的話,根據 DRY 原則,我們就可以隻保留一份,那麼直接掉到 if 條件前就好了,如下所示:
此時,你會說,我靠,居然,改變了執行的順序,把條件放到 do_after_condX() 後面去了。這會不會有問題啊?
其實,你再分析一下之前的代碼,你會發現,本來,cond1 是判斷 do_before_cond1() 是否出錯的,如果有成功了,才會往下執行。而 do_after_cond1() 是無論如何都要執行的。從邏輯上來說,do_after_cond1()其實和do_before_cond1()的執行結果無關,而 cond1 卻和是否去執行 do_before_cond2() 相關了。如果我把斷行變成下面這樣,反而代碼邏輯更清楚了。
于是乎,在未來維護代碼的時候,維護人一眼看上去就明白,代碼在什麼時候會執行到哪裡。 這個時候,你會發現,把這些語句塊抽成函數,代碼會幹淨的更多,再重構一版:
上面,我給出了兩個版本的for-loop,你喜歡哪個?我喜歡第二個。這個時候,因為for-loop裡的代碼非常簡單,就算你不喜歡 continue ,這樣的代碼閱讀成本已經很低了。
接下來,我們再來看另一個示例。下面的代碼的僞造了一個場景——把兩個人拉到一個一對一的聊天室中,因為要檢查雙方的狀态,是以,代碼可能會寫成了“箭頭型”。
重構上面的代碼,我們可以先分析一下上面的代碼,說明了,上面的代碼就是對 PeerA 和 PeerB 的兩個狀态 “連上”, “未連上” 做組合 “狀态” (注:實際中的狀态應該比這個還要複雜,可能還會有“斷開”、“錯誤”……等等狀态), 于是,我們可以把代碼寫成下面這樣,合并上面的嵌套條件,對于每一種組合都做出判斷。這樣一來,邏輯就會非常的幹淨和清楚。
對于 if-else 語句來說,一般來說,就是檢查兩件事:錯誤 和 狀态。
對于檢查錯誤來說,使用 Guard Clauses 會是一種标準解,但我們還需要注意下面幾件事:
1)當然,出現錯誤的時候,還會出現需要釋放資源的情況。你可以使用 goto fail; 這樣的方式,但是最優雅的方式應該是C++面向對象式的 RAII 方式。
2)以錯誤碼傳回是一種比較簡單的方式,這種方式有很一些問題,比如,如果錯誤碼太多,判斷出錯的代碼會非常複雜,另外,正常的代碼和錯誤的代碼會混在一起,影響可讀性。是以,在更為高組的語言中,使用 try-catch 異常捕捉的方式,會讓代碼更為易讀一些。
對于檢查狀态來說,實際中一定有更為複雜的情況,比如下面幾種情況:
1)像TCP協定中的兩端的狀态變化。
2)像shell各個指令的指令選項的各種組合。
3)像遊戲中的狀态變化(一棵非常複雜的狀态樹)。
4)像文法分析那樣的狀态變化。
對于這些複雜的狀态變化,其本上來說,你需要先定義一個狀态機,或是一個子狀态的組合狀态的查詢表,或是一個狀态查詢分析樹。
寫代碼時,代碼的運作中的控制狀态或業務狀态是會讓你的代碼流程變得混亂的一個重要原因,重構“箭頭型”代碼的一個很重要的工作就是重新梳理和描述這些狀态的變遷關系。
好了,下面總結一下,把“箭頭型”代碼重構掉的幾個手段如下:
1)使用 Guard Clauses 。 盡可能的讓出錯的先傳回, 這樣後面就會得到幹淨的代碼。
2)把條件中的語句塊抽取成函數。 有人說:“如果代碼不共享,就不要抽取成函數!”,持有這個觀點的人太死讀書了。函數是代碼的封裝或是抽象,并不一定用來作代碼共享使用,函數用于屏蔽細節,讓其它代碼耦合于接口而不是細節實作,這會讓我們的代碼更為簡單,簡單的東西都能讓人易讀也易維護,寫出讓人易讀易維護的代碼才是重構代碼的初衷!
3)對于出錯處理,使用try-catch異常處理和RAII機制。傳回碼的出錯處理有很多問題,比如:A) 傳回碼可以被忽略,B) 出錯處理的代碼和正常處理的代碼混在一起,C) 造成函數接口污染,比如像atoi()這種錯誤碼和傳回值共用的糟糕的函數。
4)對于多個狀态的判斷群組合,如果複雜了,可以使用“組合狀态表”,或是狀态機加Observer的狀态訂閱的設計模式。這樣的代碼即解了耦,也幹淨簡單,同樣有很強的擴充性。
5) 重構“箭頭型”代碼其實是在幫你重新梳理所有的代碼和邏輯,這個過程非常值得為之付出。重新整思路去想盡一切辦法簡化代碼的過程本身就可以讓人成長。