重構遺留程式碼(3):複雜的條件語句

xiaoP_dZ發表於2014-11-23

舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。

我喜歡以看待散文的方式看待程式碼。非常難以理解的、長的、雜亂的、一堆外來詞拼接出來的語句。雖然有時確實需要這樣的語句,但是多數時候,僅僅需要使用由簡單詞彙構成的短句。原始碼也正是這樣。複雜的條件語句很難理解。而冗長的方法就像無窮無盡的長句一樣。

從散文到程式碼

這裡有一個散文的例子來熱身一下。首先,這是一個包含所有資訊的長句,醜陋的那個。

如果伺服器的房間溫度低於5度,並且溼度上升到超過百分之五十但是保持在百分之八十以下,同時氣壓是穩定的,那麼在網路和伺服器管理領域有著三年以上工作經驗的感測器技術人員John就會被通知,他必須在半夜醒來,穿好衣服,出門,開車或者在他沒有車的情況下叫一輛計程車,到公司,進入房間,開啟空調,一直等到溫度上升至10度、溼度回落至百分之二十。

如果你不用重讀就能明白、理解並且記住這個段落,我給你發個獎章(當然是虛擬的)。在一個複雜的句子裡面包含冗長、糾纏不清的段落是非常難以理解的,不幸的是,由於我不知道足夠的外來英語詞彙,所以沒能讓這個句子更難懂。

簡化

讓我們來尋找簡化這個句子的方法。整個句子的第一部分直到“then”都是一個條件。是的,原句確實很複雜,但我們可以把它歸結為:如果環境條件預示著一種風險,那麼應該做一些事情來應對在複雜句中,應對的方式是需要通知滿足許多條件的某個人:即通知三級以上的技術支援人員。最後,複雜句中描述了一個從清醒到處理完事故的完整流程:期望環境引數恢復到正常水平。我們把分析的結果放到一起就是。

現在,修改後的長度僅僅是原文的20%。雖然我們不清楚細節,但是多數情況下,這些細節是不被關心的。原始碼也正是如此。你有多少次關心過logInfo("Some message")的具體細節;方法的實現細節?可能只有在實現的過程中會關心一次。之後就只是將訊息寫入“info”分類中。或者當使用者購買你的產品之一的時候,你在乎怎麼為他開發票嗎?不會的。你只關心當有人買下的它的時候,從庫存裡發出,併為客戶開具發票。這樣的例子是無窮無盡的。這也是我們正確編寫軟體的基礎。

複雜的條件語句

在本節中,我們試著把散文哲學應用到我們的小遊戲中。一步一步來。從複雜的條件語句開始。為了熱身,我們先看一下簡單的程式碼。

GameRunner.php檔案中的第20行是這樣子的:

如果把這句換成散文,是什麼樣子呢?如果一個介於最小answerID和最大answerID之間的隨機數與wrongAnswer的ID相同,那麼……

這並不是十分複雜,但我們仍可以讓其變得更簡單。換成“如果選擇了錯誤的答案,那麼……”怎麼樣?更好了,不是嗎?

提取方法重構

我們需要找到一種方法,步驟或者技術來把條件陳述轉移到別的地方。目的地可以是一個方法。或者在我們的例子中,因為程式碼並不在類中,那麼就放到一個函式中。把轉移到函式或者方法中的行為叫做“提取方法”重構。下面是Martin Fowler在他精彩的《Refactoring: Improving the Design of Existing Code(重構:改善既有程式碼的設計)》一書中定義了實施這種重構的幾個步驟。如果你尚未讀過這本書,那你應該把它加入到你的待讀列表中。這是作為現代程式設計師的必讀書之一。

根據我們的教程,我稍微簡化了一下原始的步驟,從而更適合我們的教程來使用。

  1. 建立一個方法,方法的名字應該描述了方法的功能而不是如何實現。
  2. 從待提取的地方拷貝程式碼到方法中。請注意,這是拷貝,還不要刪除原來的程式碼。
  3. 檢視提取出來的程式碼中所有的區域性變數。這些變數都必須作為方法的引數。
  4. 檢視提取出來的程式碼中是否有臨時變數。如果有的話,在方法中宣告並且刪除額外的引數。
  5. 把變數作為引數傳遞給目標方法。
  6. 用目標方法的呼叫替代提取出來的程式碼。
  7. 執行測試。

這樣就基本完成了這個過程。但實際上,可能除了重新命名之外,提取方法重構可以說是使用最頻繁的重構方式了。所以,你必須理解它的機制。

幸運的是,現代的IDEs,如PHPStorm 提供了非常好的重構工具,正如我們已經在PHPStorm教程中見過的:PHPStorm: When the IDE Really Matters。所以我們可以利用手中的工具來實現重構,而不是完全的手工實現這個過程。這不容易出錯,並且非常非常快。

僅需要選中所需的程式碼部分,然後右鍵點選。

IDE會自動分析得出,需要三個引數來執行我們的程式碼,然後生成下面的解決方案。

儘管這段程式碼在語法上是正確的,但會中斷測試。在各種呈現給我們的紅色,藍色和黑色的輸出中,我們可以找到錯誤的原因:


簡單來說,編譯器認為我們想對一個函式宣告兩次。但怎麼會出現這個問題?這個函式僅僅在GameRunner.php中宣告瞭一次!

看一下測試程式碼。generateOutput()方法對我們的GameRunner.php做了require()。而這方法至少被呼叫了兩次。這就是錯誤的原因。

現在遇到了一個困境。由於隨機數生成器的播種問題,我們需要控制值來呼叫我們的程式碼。

但是PHP中沒有辦法對一個函式宣告兩次,所以我們需要其他的解決方案。我們開始感受到金牌大師測試的負擔。執行整個系統20000次,每次改變程式碼的一個小部分,這不是長久之計。這種測試除了佔用大量時間外,還迫使我們改變程式碼以適應測試。這是壞測試的訊號。需要修改程式碼,同時保證測試通過,但是修改應該有理由去修改,而且這個理由只能來自於原始碼。

說的足夠多了,現在需要的是一個解決方案,即使是一個臨時的方案對目前也是可以的。我們的下一課將會涉及向單元測試的遷移。

一個解決問題的方法是把GameRunner.php中所有其他部分的程式碼都放在一個函式中,比如run()

如此就可以進行測試了,但是請明確,修改之後從控制檯直接執行程式碼就無法執行遊戲了。我們在程式碼的行為上做了輕微的改變。以此為代價換來了可測試性,最初我們是不願這麼做的。現在,如果想從控制檯執行這段程式碼,就需要另外一個PHP檔案,這個檔案要引用或者包含需執行的程式碼,然後顯式地呼叫run方法。這不是一個大的改變,但是需要記住,特別是有第三方參與者需要使用現有的程式碼。

另一方面,我們可以僅在測試中引入檔案。

然後在generateOutput()方法內呼叫run()

目錄結構、檔案和命名

也許這是一個好機會來考慮目錄和檔案的結構。GameRunner.php中不再有複雜的條件語句,但是在繼續回到Game.php檔案前,不能留下個亂攤子。GameRunner.php不再能直接執行,我們需要用外部的方法呼叫來實現可測試,但這破壞了外部介面。而這麼做的原因是,我們有可能在測試錯誤的東西。

我們的測試呼叫了GameRunner.php檔案中的run()方法,這個方法引入了Game.php,並執行這個遊戲,生成一個新的金牌大師檔案。要是我們引入另外一個檔案呢?我們讓GameRunner.php呼叫run()方法來實現遊戲的執行,且不做其他操作。那麼要是程式碼中既沒有邏輯會出錯也不需要測試,這時將當前程式碼引動到另一個檔案中會怎樣?

現在就是一個完全不同的故事了。測試僅僅通過runner來訪問程式碼,基本上,測試就是runner了。當然,新的GameRunner.php也僅僅呼叫一個方法來執行遊戲了。它是一個真正的runner,除了呼叫run()方法外,不做其他任何事情。沒有邏輯也就意味著不需要測試了。

講到這兒,我們需要問自己一些其他的問題。我們真的需要RunnerFunctions.php嗎?我們不能僅僅把這個方法轉移到Game.php中?我們或許可以這樣做,但是以我們目前的理解,函式應該放在哪裡呢?所以目前理解的還不夠,在未來的課程中,將會學習到如何放置方法。

我們也在嘗試按照程式碼行為給檔案命名。只是一堆對於runner來說為了滿足其需要,我們認為這個時候應該放在一起的功能集合。將來的某個時候這會變成一個類嗎?可能會。也可能不會。目前來講,暫時是不需要了。

清理RunnerFunctions

再看一下RunnerFunctions.php檔案,有些由我們引入產生雜亂的問題。

我們在run()方法中定義了:

這些定義只有一個原因存在,並且只在一個地方使用。那為什麼不在方法中定義他們並且完全拋棄引數呢?

好的,現在測試可以通過了,而且程式碼更漂亮了,但是還是不足夠好。

反向條件語句

對於人類的思維來講,更容易理解正向推理。所以如果你能避免使用反向條件語句,你就應該這麼做。在我們目前的例子中,方法是用來驗證答案是否錯誤。當需要的時候檢查有效性並否定它的方法更容易理解。

我們使用了重新命名方法重構。同樣,如果純手工來是實現很麻煩,但是在任何IDE中就如同按下CTRL+r或者在選單中選擇合適的選項那麼容易。為了使測試通過,我們同樣需要把條件語句更新為取反的使用方式。

這讓我們能夠進一步的瞭解條件語句。在if語句中使用!實際上有幫助。它突出強調了一些條件是否定的。那我們是否按程式反轉條件,從而避免使用取反呢?當然可以。

現在已經沒有使用!的邏輯了,也沒有在命名和返回錯誤情況時的使用反義詞彙了。所有以上的步驟讓我們的條件變得非常,非常容易理解。

Game.php中的條件語句

我們已經把RunnerFunctions.php簡化到極致。現在來對Game.php操作。查詢條件語句有很多方法,如果你願意的話,可以簡單通過看代買來掃描一下。這樣比較慢,但有個好處,能夠迫使你按順序理解程式碼。

第二種查詢條件語句的方法也很顯然,搜尋“if”或者“if(”即可。如果使用IDE內建的工具格式化過程式碼,那就可以確認所有的條件語句都具有相同的格式。我的格式是,“if”和括號之間有一個空格。再者,如果使用內建的工具搜尋,那麼所有找到的結果都會高亮顯示,在我這裡是黃色。

現在我們看到了所有查詢的內容都高亮顯示,像聖誕樹一般。我們一個一個處理它們。我們知道怎麼去做,做到可以用的技術,現在是時間處理它們了。

這看起來似乎相當合理。我們可以把它提取為一個方法,但是能不能找到一個好的名字讓這個判定變得一目瞭然?

我打賭90%的程式設計師可以理解上面這個if語句。我們試著把注意力集中到當前方法所做的事上。並且我們的大腦也連線進該問題的領域。我們不願意為了理解一個僅僅檢查數是否是奇數的方法,而“開啟另一個執行緒”去計算一個表示式的值。這是很多讓我們分心的小原因中的一個,這些最終會毀掉整個邏輯推論過程。所以,我說讓我們把它提取出來。

這樣就好多了,因為它是關於問題的領域,並且不需要額外的大腦能量。

這看起來是一個好的備選方案。作為一個數學表示式來說,它不是很難理解,但是同樣,它需要額外的加工。我問我自己,如果當前玩家位置到達了邊界,這意味著什麼?難道我們不能用更簡潔的方式來表達它?我們大概可以。

這樣就好多了,但是if中實際上發生了什麼?玩家在告示板開始的地方時被重新定位的。遊戲中開始了新的一圈。要是在以後,我們有了新的方式來開始新的一圈怎麼辦?當我們在私有方法中修改了底層邏輯時,if語句是否需要修改?當然不是!所以,讓我們把這個方法以if語句的含義、語句中發生的行為來,而不是根據我們檢查的內容。

當試著命名方法和變數的時候,要經常想想程式碼的行為而不是它代表的狀態或者條件。一旦正確掌握了這個,那麼程式碼需要重新命名的地方就會顯著減少。但是即使是一個經驗豐富的程式設計師,在找到合適的名字之前,也要重新命名三到五次。所以不要害怕頻繁點選CTRL+r來重新命名。所以在你沒有檢查你新增的方法的命名並使你的程式碼如散文一般之前,不要將你程式碼提交到工程的CVS上。重新命名是如此容易的,所以你可以嘗試多個名字,並且只用點選一下按鈕,就可以還原。

90行處的if語句和我們之前遇到的一樣。我們只要重用之前提取的方法即可。看,冗餘剔除了!別忘了隨時進行測試,即使你是使用IDE神奇的工具來進行重構。這是我們下一個要注意的地方,神奇的工具,有時也會出錯。檢視下65行。

我們宣告瞭一個變數,並且只在作為新提取的方法的引數使用了一次。這種情況,強烈建議把變數放入方法內部。

同時不要忘記在if語句中呼叫方法時就不需要任何引數了。

askQuestion()方法中的if語句看起來是沒有任何問題的,和currentCategory()中的一樣。

這裡看起來有一點複雜,但是還在可接受範圍內,有著足夠的表現力。

我們可以對這個進行改進。這是玩家走出邊界的情況,與之前是明顯的對比。但是根據我們之前所學習的,我們不希望看到狀態。

這樣就好多了,並且我們在172、189和203行處重用它。兩處、三處、四處冗餘程式碼都被排除了!

測試都通過了,所有的if語句都排除了複雜性。

最後的思考

從重構條件語句中還能學習到其他的課程。首先,對於理解程式碼意圖有很大的幫助。其次,如果能夠根據意圖準確的命名方法,就能夠避免將來對名字不必要的更改。在邏輯上發現重複比發現完全重複的程式碼要更難。或許你認為我們應該持續的排除冗餘,但是我更願意有能以我的性命相托的單元測試來發現冗餘。金牌大師程式是不錯的,但頂多是一個完全網,而非降落傘。

感謝閱讀並且請繼續關注我們下一個教程,我們將介紹第一個單元測試。

相關文章