2023-09-02

解剖长方法并进行提取的遗留代码重构 – 第10部分


解剖长方法并进行提取的遗留代码重构 - 第10部分

在我们系列的第六部分中,我们讨论了通过利用结对编程和从不同级别查看代码来攻击长方法。我们不断地放大和缩小,观察命名、形式和缩进等小事情。

今天,我们将采取另一种方法:我们假设我们独自一人,没有同事或搭档来帮助我们。我们将使用一种名为“Extract Until you Drop”的技术,将代码分解为非常小的片段。我们将尽一切努力使这些部分尽可能容易理解,以便未来的我们或任何其他程序员将能够轻松理解它们。


提取直到放弃

我第一次从 Robert C. Martin 那里听说这个概念。他在他的一个视频中提出了这个想法,作为一种重构难以理解的代码的简单方法。

基本思想是获取小的、可理解的代码片段并提取它们。如果您识别出可以提取的四行或四个字符,这并不重要。当您确定可以封装在更清晰的概念中的内容时,您就可以进行提取。您在原始方法和新提取的片段上继续此过程,直到找不到可以封装为概念的代码片段。

当您独自工作时,此技术特别有用。它迫使您同时考虑小代码块和大代码块。它还有另一个很好的效果:它让你思考代码——很多!除了上面提到的提取方法或变量重构之外,您还会发现自己重命名变量、函数、类等等。

让我们看一个来自互联网的随机代码的示例。 Stackoverflow 是查找小代码片段的好地方。这是确定数字是否为质数的方法:

//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;
    }
}
登录后复制

此时,我不知道这段代码是如何工作的。我在写这篇文章的时候刚刚在网上找到了它,我会和你一起发现它。接下来的过程可能不是最干净的。相反,它将反映我的推理和重构,而无需预先规划。

重构素数检查器

根据维基百科:

素数(或素数)是大于 1 的自然数,除了 1 和它本身之外没有正因数。 块引用>

正如您所看到的,这是解决简单数学问题的简单方法。它返回 truefalse,所以它也应该很容易测试。

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

该测试通过。我的下一个直觉是添加更多的素数,而不是用非素数编写另一个测试。

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));
}
登录后复制

就这么过去了。但这又如何呢?

function testItCanRecognizeNonPrimes() {
    $this->assertFalse(isPrime(6));
}
登录后复制

这意外失败:6 不是素数。我期待该方法返回 false。我不知道该方法是如何工作的,也不知道 $pf 参数的目的 – 我只是希望它根据其名称和描述返回 false 。我不知道为什么它不起作用也不知道如何修复它。

这是一个相当令人困惑的困境。我们应该做什么?最好的答案是编写能够通过大量数字的测试。我们可能需要尝试和猜测,但至少我们会对这个方法的作用有一些了解。然后我们就可以开始重构它了。

function testFirst20NaturalNumbers() {
    for ($i=1;$i<20;$i++) {
		echo $i . ' - ' . (isPrime($i) ? 'true' : 'false') . "/n";
	}
}
登录后复制

输出一些有趣的东西:

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 为止全部为真,然后交替直到 19。但是这种模式会重复吗?尝试运行 100 个数字,您会立即发现它不是。实际上,它似乎适用于 40 到 99 之间的数字。在 30-39 之间,它通过指定 35 作为质数而失败了一次。在 20-29 范围内也是如此。 25 被认为是素数。

这个练习最初是用一个简单的代码来演示一种技术,但事实证明比预期的要困难得多。我决定保留它,因为它以典型的方式反映了现实生活。

有多少次你开始做一项看起来很简单的任务,却发现它极其困难?

我们不想修复代码。无论该方法做什么,它都应该继续这样做。我们希望重构它以使其他人更好地理解它。

由于它不能以正确的方式告诉质数,我们将使用我们在第一课中学到的相同的 Golden Master 方法。

function testGenerateGoldenMaster() {
    for ($i=1;$i<10000;$i++) {
		file_put_contents(__DIR__ . '/IsPrimeGoldenMaster.txt', $i . ' - ' . (isPrime($i) ? 'true' : 'false') . "/n", FILE_APPEND);
	}
}
登录后复制

运行一次即可生成 Golden Master。它应该跑得很快。如果您需要重新运行它,请不要忘记在执行测试之前删除该文件。否则输出将附加到之前的内容。

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.');
	}
}
登录后复制

现在为金牌大师编写测试。这个解决方案可能不是最快的,但它很容易理解,并且如果破坏某些东西,它会准确地告诉我们哪个数字不匹配。但是我们可以将两个测试方法提取到 private 方法中,有一点重复。

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";
	}
}
登录后复制

现在我们可以移至生产代码了。该测试在我的计算机上运行大约两秒钟,因此是可以管理的。

竭尽全力提取

首先我们可以在代码的第一部分提取一个 isDivisible() 方法。

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;
}
登录后复制

当我们开始使用这段代码时,我们发现它是粗心地对齐的。大括号有时位于行的开头,有时位于行的末尾。

有时,制表符用于缩进,有时使用空格。有时操作数和运算符之间有空格,有时没有。不,这不是专门创建的代码。这就是现实生活。真实的代码,而不是一些人为的练习。

//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 中的一些函数式编程概念来提取它,但也许稍后。我们可以先做一些简单的事情。

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

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))?

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 循环?也许这就是我们的函数名称应该回答的问题。

[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 循环中看到带有 returnif 语句,就像我们的 checkDivisorsBetween() 方法一样,我就会考虑应用一种或两种技术。

function checkDivisorsBetween($start, $end, $num, $divisors = null) {
    for ($i = $start; $i < $end; $i++) {
		if (isDivisible($num, $divisors ? $divisors[$i] : $i)) {
			return false;
		}
	}
	return true;
}
登录后复制

但是我们为什么要经历如此复杂的思考过程呢?最烦人的原因是这个方法做了两个不同的事情:循环和决定。我只想让它循环并将决定留给另一种方法。一个方法应该总是只做一件事并且做得很好。

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;
}
登录后复制

我们的第一次尝试是将条件和返回语句提取到变量中。目前,这是本地的。但代码不起作用。实际上 for 循环使事情变得相当复杂。我有一种感觉,一点递归会有所帮助。

function checkRecursiveDivisibility($current, $end, $num, $divisor) {
    if($current == $end) {
		return true;
	}
}
登录后复制

当我们考虑递归性时,我们必须始终从特殊情况开始。我们的第一个例外是当我们到达递归末尾时。

function checkRecursiveDivisibility($current, $end, $num, $divisor) {
    if($current == $end) {
		return true;
	}

	if (isDivisible($num, $divisor)) {
		return false;
	}
}
登录后复制

我们会破坏递归的第二个例外情况是当数字可整除时。我们不想继续了。这就是所有例外情况。

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 崩溃。所以这似乎又是一个死胡同。但如果它能发挥作用,那将是对原始逻辑的一个很好的替代。


挑战

我在写《金主》的时候,故意忽略了一些东西。假设测试没有涵盖应有的代码。你能找出问题所在吗?如果是,您会如何处理?


最终想法

“提取直到放弃”是剖析长方法的好方法。它迫使您思考小段代码,并通过将它们提取到方法中来赋予这些代码段目的。我发现令人惊奇的是,这个简单的过程加上频繁的重命名,可以帮助我发现某些代码可以完成我从未想过的事情。

在我们的下一个也是最后一个关于重构的教程中,我们将把这种技术应用到问答游戏中。我希望您喜欢这个有点不同的教程。我们没有谈论教科书上的示例,而是使用了一些真实的代码,并且我们必须与每天面临的实际问题作斗争。

以上就是解剖长方法并进行提取的遗留代码重构 – 第10部分的详细内容,更多请关注php中文网其它相关文章!

https://www.php.cn/faq/601077.html

发表回复

Your email address will not be published. Required fields are marked *