舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。
- 重構遺留程式碼(1):金牌大師
- 重構遺留程式碼(2):魔術字串和常量
- 重構遺留程式碼(3):複雜的條件語句
- 重構遺留程式碼(4):第一個單元測試
- 重構遺留程式碼(5):遊戲的可測試方法
- 重構遺留程式碼(6):進攻複雜的方法
- 重構遺留程式碼(7):識別表示層
- 重構遺留程式碼(8):一個整潔架構的依賴反轉
- 重構遺留程式碼(9):分析 Concerns
在我們本系列的第六部分,我們談到了通過結對程式設計和從不同層次看程式碼來處理長方法。我們連續地放大與縮小,既觀察了命名,也觀察了表單和縮排這樣細小的方面。
今天,我們將繼續另一個方法:我們將假設我們是孤獨的,沒有同事或者結對者幫助我們。我們將使用一種稱為“提取直到你放棄”的技術,將程式碼分割成非常小塊。我們將盡一切努力使得這些程式碼塊儘可能得容易理解,以便將來我們,或者任何其他的程式設計師能夠很容易得理解。
提取直到你放棄
我第一次聽說這個概念是從Robert C. Martin那兒。在他的一段視訊中表述了這個概念,作為重構很難理解的程式碼的一種簡單方式。
基本思想是選取小段的,能夠理解的程式碼片段然後提取它們。你確定了能被提取的四行或者四個字元是沒關係的。當你確定了那些能被封裝成比較清晰的抽象概念時,你就提取。在原方法側和新提取的片段側你持續這個過程,直到你發現沒有程式碼塊能被封裝。
這個技術在你獨自工作時是特別有用的。它迫使你去思考小段程式碼和更大段的程式碼。它還有另一個精妙的作用:它讓你思考程式碼-很多!除了上面提到的提取方法或變數重構,你將發現自己在重新命名變數,函式,類和更多。
讓我們看個來自網際網路的一些隨機程式碼的例子。Stackoverflow是個發現小段程式碼的好地方。這是一個確定數字是否為素數的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
//Check if a number is prime function isPrime($num, $pf = null) { if(!is_array($pf)) { for($i=2;$i<intval(sqrt($num));$i++) { if($num % $i==0) { return false; } } return true; } else { $pfCount = count($pf); for($i=0;$i<$pfCount;$i++) { if($num % $pf[$i] == 0) { return false; } } return true; } } |
此刻,我不知道這段程式碼是如何工作的。我只是在寫這篇文章的時候在網際網路上找到了它,並且我將和你一起發現它。接下來的過程可能不是最清晰的。相反,當它沒有事先計劃得發生時,它將反映我的推理和重構。
重構素數檢查器
根據Wikipedia:素數是比1大並且除了1和它本身外沒有正約數的自然數。
正如你看到的,這是個關於簡單的數學問題的簡單的方法。它將返回true或者false,所以它也該容易測試。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class IsPrimeTest extends PHPUnit_Framework_TestCase { function testItCanRecognizePrimeNumbers() { $this->assertTrue(isPrime(1)); } } // Check if a number is prime function isPrime($num, $pf = null) { // ... the content of the method as seen above } |
當我們只處理示例程式碼時,進行下去最簡單的方式是將所有東西放入測試檔案。這種方式,我們不必考慮建立什麼檔案,它們所屬的方向,或者怎麼將它們包含在其他檔案中。這只是個簡單的例子用來為我們自己熟悉之前我們在問答遊戲方法之一上所採用的技術。所以,所有都放入測試檔案,你可以起你希望的名字。我已經選擇了IsPrimeTest.php.
測試通過了。我下一個直覺是增加更多些素數然後寫另一個沒有素數的測試。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function testItCanRecognizePrimeNumbers() { $this->assertTrue(isPrime(1)); $this->assertTrue(isPrime(2)); $this->assertTrue(isPrime(3)); $this->assertTrue(isPrime(5)); $this->assertTrue(isPrime(7)); $this->assertTrue(isPrime(11)); } |
這個通過了。但下面這個怎麼樣?
1 2 3 4 5 |
function testItCanRecognizeNonPrimes() { $this->assertFalse(isPrime(6)); } |
這不出人意料:6不是素數。我期望方法返回false。我不知道方法如何工作的,或者$pf引數的目的-基於它的名字和描述,我只是希望它返回false。我不知道它為什麼沒起作用或者如何修正它。
這是個相當令人費解的難題。我們該怎麼做?最佳答案是寫測試程式傳遞大量數字。我們可能需要試試並且猜測,但至少我們將對方法做了什麼有些概念。然後我們可以開始重構它了。
1 2 3 4 5 6 7 8 9 |
function testFirst20NaturalNumbers() { for ($i=1;$i<20;$i++) { echo $i . ' - ' . (isPrime($i) ? 'true' : 'false') . "n"; } } |
輸出了寫有趣的內容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
1 - true 2 - true 3 - true 4 - true 5 - true 6 - true 7 - true 8 - true 9 - true 10 - false 11 - true 12 - false 13 - true 14 - false 15 - true 16 - false 17 - true 18 - false 19 - true |
在這兒開始出現了一種模式。直到9都為true,之後直到19,true和false交替出現。但這個模式是重複的嗎?試著執行100個數字,你將立刻發現它不是的。它實際上似乎對在40到99之間的數字起作用。在30-39之間它失誤了一次,把35作為了素數。同樣在20-29範圍內也有個true,25被認為是素數。
這個練習開始作為簡單程式碼來演示一項技術證明是比預期難得多。我決定依然保持這個練習,因為它以典型的方式反映了真實的生活。
有多少次你開始一個看起來簡單只是發現它是十分困難的任務?
我們不想修正程式碼。無論方法做了什麼,它應該繼續去做。我們想要重構它以使得其他人更好地理解。
因為它沒有以正確的方式列印素數,我們將使用在教程1中學到的金牌大師方法。
1 2 3 4 5 6 7 8 9 |
function testGenerateGoldenMaster() { for ($i=1;$i<10000;$i++) { file_put_contents(__DIR__ . '/IsPrimeGoldenMaster.txt', $i . ' - ' . (isPrime($i) ? 'true' : 'false') . "n", FILE_APPEND); } } |
執行一次這個程式生成金牌大師。它執行起來應該很快。如果你需要重新執行它,別忘了在你執行測試程式之前刪除檔案。否則輸出將被附加到之前的內容。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function testMatchesGoldenMaster() { $goldenMaster = file(__DIR__ . '/IsPrimeGoldenMaster.txt'); for ($i=1;$i<10000;$i++) { $actualResult = $i . ' - ' . (isPrime($i) ? 'true' : 'false'). "n"; $this->assertTrue(in_array($actualResult, $goldenMaster), 'The value ' . $actualResult . ' is not in the golden master.'); } } |
現在為金牌大師寫測試程式。這個解決方案可能不是最快的,但它容易理解並且它將準確地告訴我們如果破壞了什麼那個數字不能匹配。但在兩個測試程式中有個可以將其提取到一個私有方法中的小段重複。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class IsPrimeTest extends PHPUnit_Framework_TestCase { function testGenerateGoldenMaster() { $this->markTestSkipped(); for ($i=1;$i<10000;$i++) { file_put_contents(__DIR__ . '/IsPrimeGoldenMaster.txt', $this->getPrimeResultAsString($i), FILE_APPEND); } } function testMatchesGoldenMaster() { $goldenMaster = file(__DIR__ . '/IsPrimeGoldenMaster.txt'); for ($i=1;$i<10000;$i++) { $actualResult = $this->getPrimeResultAsString($i); $this->assertTrue(in_array($actualResult, $goldenMaster), 'The value ' . $actualResult . ' is not in the golden master.'); } } private function getPrimeResultAsString($i) { return $i . ' - ' . (isPrime($i) ? 'true' : 'false') . "n"; } } |
現在我們可以繼續看產品程式碼了。在我的電腦上,測試執行了大約2秒,所以它是可控的。
盡我們所能提取
首先我們可以在程式碼的第一部分提取一個isDivisible()方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
if(!is_array($pf)) { for($i=2;$i<intval(sqrt($num));$i++) { if(isDivisible($num, $i)) { return false; } } return true; } |
這將使我們在第二部分像這樣重用這段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
} else { $pfCount = count($pf); for($i=0;$i<$pfCount;$i++) { if(isDivisible($num, $pf[$i])) { return false; } } return true; } |
一旦我們開始處理這段程式碼,我們能看到它粗心大意的對齊方式。大括號有時在行首,有時在末尾。有時,tabs被用來做縮排,有時做為空格。有時在運算元和操作符之間有空格,有時沒有。不,這不是特別製作的程式碼。這是真實的生活。真實的程式碼,而不是一些人造的練習。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
//Check if a number is prime function isPrime($num, $pf = null) { if (!is_array($pf)) { for ($i = 2; $i < intval(sqrt($num)); $i++) { if (isDivisible($num, $i)) { return false; } } return true; } else { $pfCount = count($pf); for ($i = 0; $i < $pfCount; $i++) { if (isDivisible($num, $pf[$i])) { return false; } } return true; } } |
這看起來好多了。立即地,兩個if表示式看起來很熟悉。但我們不能提取它們,因為return表示式。如果我們不返回我們將打破邏輯。
如果提取的方法會返回個布林值並且我們比較它來決定是否應該或者不該從isPrime()返回,那將完全沒幫助。也許有種方式通過某些在PHP中的函數語言程式設計概念來提取它,但也許稍晚些。首先我們可以做些更簡單的事。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function isPrime($num, $pf = null) { if (!is_array($pf)) { return checkDivisorsBetween(2, intval(sqrt($num)), $num); } else { $pfCount = count($pf); for ($i = 0; $i < $pfCount; $i++) { if (isDivisible($num, $pf[$i])) { return false; } } return true; } } function checkDivisorsBetween($start, $end, $num) { for ($i = $start; $i < $end; $i++) { if (isDivisible($num, $i)) { return false; } } return true; } |
提取整個for迴圈更容易些,但當我們試圖在第二部分的if中重用我們提取的方法時,我們看到它不起作用了。有這個我們幾乎一無所知的$pf變數存在。
它似乎通過一組特定的因子來檢查數字是否可整除,而不是把所有數字放入由intval(sqrt($num))決定的另一個神奇的值中。也許我們可以將$pf重新命名為$divisors。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
function isPrime($num, $divisors = null) { if (!is_array($divisors)) { return checkDivisorsBetween(2, intval(sqrt($num)), $num); } else { return checkDivisorsBetween(0, count($divisors), $num, $divisors); } } function checkDivisorsBetween($start, $end, $num, $divisors = null) { for ($i = $start; $i < $end; $i++) { if (isDivisible($num, $divisors ? $divisors[$i] : $i)) { return false; } } return true; } |
這是做到這點的方法之一。我們增加了第四個,可選的引數到檢查方法中。如果它有值,我們就用它,否則我們就用$i。
我們還能提取其他什麼嗎?這段程式碼怎麼樣:intval(sqrt($num))?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function isPrime($num, $divisors = null) { if (!is_array($divisors)) { return checkDivisorsBetween(2, integerRootOf($num), $num); } else { return checkDivisorsBetween(0, count($divisors), $num, $divisors); } } function integerRootOf($num) { return intval(sqrt($num)); } |
這不是更好嗎?有點。如果後來的人不知道intval()和sqrt()在做什麼,這樣就更好點,但這樣並沒有是的邏輯更容易理解。為什麼我們在特定數字上結束for迴圈?也許這是函式名應該回答的問題。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[PHP]//Check if a number is prime function isPrime($num, $divisors = null) { if (!is_array($divisors)) { return checkDivisorsBetween(2, highestPossibleFactor($num), $num); } else { return checkDivisorsBetween(0, count($divisors), $num, $divisors); } } function highestPossibleFactor($num) { return intval(sqrt($num)); }[PHP] |
這更好了,因為它解釋了為什麼我們停在這兒。也許將來我們會發明一個不同的公式來決定那個數字。命名也引入了一點不一致。我們呼叫數字因素,就是除數的同義詞。也許我們應該選擇一個並且只使用它。我將讓你把重新命名重構作為練習。
問題是,我們能進一步提取嗎?好了,我們必須試試直到我們放棄。在幾段之前我提到PHP中的函數語言程式設計。在PHP中有兩個我們能很容易得應用的主要的函數語言程式設計特性:第一類函式與遞迴。無論什麼時候我看到在for迴圈中帶return的if表示式,就像checkDivisorsBetween()方法,我想著適用一個或兩個技術都用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function checkDivisorsBetween($start, $end, $num, $divisors = null) { for ($i = $start; $i < $end; $i++) { if (isDivisible($num, $divisors ? $divisors[$i] : $i)) { return false; } } return true; } |
但為什麼我們要通過這樣一個複雜的思維過程?最煩人的原因是這個方法有兩個不同的東西:它迴圈了,並且它確定結果了。我想要它值迴圈而將確定結果留給另一個方法。一個方法始終應該只做一件事並且做好它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function checkDivisorsBetween($start, $end, $num, $divisors = null) { $numberIsNotPrime = function ($num, $divisor) { if (isDivisible($num, $divisor)) { return false; } }; for ($i = $start; $i < $end; $i++) { $numberIsNotPrime($num, $divisors ? $divisors[$i] : $i); } return true; } |
我們第一個嘗試是提取條件和return表示式到一個變數中。目前來說,這是本地的。但程式碼不工作了。事實上,for迴圈把事情弄得有點複雜。我有種感覺,一小段遞迴會有幫助。
1 2 3 4 5 6 7 8 9 |
function checkRecursiveDivisibility($current, $end, $num, $divisor) { if($current == $end) { return true; } } |
當我們考慮遞迴的時候我們必須開始考慮異常情況。我們的第一個異常是當到達遞迴的末尾時。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function checkRecursiveDivisibility($current, $end, $num, $divisor) { if($current == $end) { return true; } if (isDivisible($num, $divisor)) { return false; } } |
我們第二個會打斷遞迴的例外情況是當數字是可整除的時,我們不希望繼續。這就是所有的例外情況。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
ini_set('xdebug.max_nesting_level', 10000); function checkDivisorsBetween($start, $end, $num, $divisors = null) { return checkRecursiveDivisibility($start, $end, $num, $divisors); } function checkRecursiveDivisibility($current, $end, $num, $divisors) { if($current == $end) { return true; } if (isDivisible($num, $divisors ? $divisors[$current] : $current)) { return false; } checkRecursiveDivisibility($current++, $end, $num, $divisors); } |
這是對我們的問題做的另一種使用遞迴的嘗試,但不幸的是,在PHP中遞迴10.000次將導致我係統中PHP或者PHPUnit崩潰。所以這看起來是另一個死衚衕。但如果它可以一直工作,本是對原邏輯一個很好的替換。
挑戰
當我寫金牌大師測試程式的時候,我刻意得忽略了些東西。讓我們只說是測試程式沒有儘可能得覆蓋它們應該覆蓋的程式碼。你能發現這個問題嗎?如果能,你該怎麼處理它?
最後的思考
“提取直到你放棄”是一個解析長方法的好方式。它迫使你考慮小段程式碼並且通過將它們提取到方法中來給予那段程式碼以目的。這一伴隨著頻繁重新命名的簡單程式,如何幫助我發現某些程式碼做了我認為不可能的事,這本身是令我驚異的。
在我們的下一篇也是最後一篇關於重構的教程中,我們將對問答遊戲使用這種技術。我希望你喜歡這個原來是有一點不同的教程。不是談論教科書的例子,我們處理了一些真實的程式碼並且我們必須與每天面對的真實問題戰鬥。