举例介绍重构(译)
原文地址:http://taha-sh.com/blog/introduce-refactoring-by-example
PS:红色字体为楼主YY,与原文无关
重构是让代码(保持)简洁、设计良好、规范的关键。如果你曾经想知道那些设计良好的软件项目是如何来的,那么答案通常就是重构。
在本篇教程中,我将展示如何通过重构(技巧)来改善你的代码设计。(一开始)我将创建一个设计很差的原始项目,然后通过一步步的重构技术来改进。
如果你只是刚接触重构,本教程可以看作介绍重构的一个入门。考虑到文章后续会逐一详细介绍重构的内容,所以不要担心那些现在还不是很清晰的概念。
如果你之前已经使用过重构,不凡把本教程当做一个复习,也许,你可以会有新的收获:-D 博主表示收获满满,所以,跟着博主的脚本,一起享受重构带来的乐趣吧 O(∩_∩)
虽然例子是使用PHP来介绍的,但是对于OOP语言来说,都是通用的。因此,非PHP开发人员同样可以看得懂例子。
什么是重构
下面是官方给出的定义:
重构是以不改变代码外部行为(为前提),并且能改善代码内部结构的一种改变软件系统的方式
换句话说,重构是用来改善软件设计的同时,又不影响它的行为(逻辑/功能)。重构是在你编写完你的代码和测试之后才会被启用的。我意思是说你刚开始只是设计了一个欠考虑的设计,然后通过重构可以让(原有的)代码变得更加良好。
重构与测试
简言之,没有进行测试的重构是无效的。很明显,因为(不进行测试)我们如何知道改变代码之后,是否破坏了原有的代码(功能)。答案自然是不能咯。所以测试可以作为改变之后的反馈(手段)。自然测试可以告诉我们修改(代码)之后是否代码还是可以用的。
注意到重构作为TTD循环(RED,GREEN,REFACTOR)的第三个阶段。所以需要注意的是接下来,我们将在(下面)例子中使用TTD来测试我们写的(项目代码)。
例子
对于我来说,我发现举例说明比通过几个抽象概念来解释来的更直观了。我们将构建一个简单的工程项目,然后我们将通过应用一些重构技术,看下(它)可以让项目的设计得到多大的改善。
我们将构建一个字符串计算器的项目(它与时下流行的字符计算机Kata不同了),字符计算器可以接收字符类型的表达式(比如 "2+3+4"),然后解析字符串,并且进行必要的计算,然后返回最后中的结果。
但是本计算器只支持单一类型的计算运算符。话句话说,你不能在一个表达式中混合不同的运算符,比如("3+4*2")。
对于重构本身来讲,教材中的例子太简单了,但是当为一个好的重构练习来看,是再好不过了。
配置项目
容许我插句话,如果看不懂配置项目这块的内容或则急着进入重构的世界,请直接跳到说明,因为项目配置只是为了让你可以将作者的项目跑起来而已
首先,我们先开始配置项目目录。创建一个新的目录,在目录底下创建src和tests文件夹。
然后,通过composer执行以下命名来获取PHPUnit
composer require phpunit/phpunit --dev
然后呢,我们需要创建自己的配置。所以,在项目根目录下,创建一个phpunit.xml文件,配置如下
<phpunit colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" bootstrap="vendor/autoload.php"> <testsuites> <testsuite name="default"> <directory>tests</directory> </testsuite> </testsuites> </phpunit>
最后,我们必须注册一个PSR-4加载器(如果你不熟悉它,可以通过本教程)。我们的composer.json配置如下
{
"require-dev": {
"phpunit/phpunit": "~4.4"
},
"autoload": {
"psr-4": {
"Acme\\": "src"
}
}
}
然后运行 composer dump-autoload 命令,加载PSR-4加载器。我们的命名空间是Acme ,它将放在src下
说明
因为本教程并没有关于测试(关于测试的教程,请参考作者的另外一篇文章),这里我就不做过多介绍了。
现在,在你的tests目录下创建一个StringCalculatorTest.php,内容如下
<?php use Acme\StringCalculator; class StringCalculatorTest extends PHPUnit_Framework_TestCase { /** * @test */ function it_adds_numbers() { $calculator = new StringCalculator; $result = $calculator->calculate('2+2+2'); $this->assertEquals(6, $result); $result = $calculator->calculate('2 + 2 + 2'); $this->assertEquals(6, $result); } }
现在,我们可以通过但测试来让我们的计算器执行任务了。我们创建一个StringCalculator 对象,调用它提供计算表达式的calculate()方法。代码中包含了两个断言,其中一个是测试运算符之间存在空格情况下,计算器仍然可以正常执行。
如果我们运行测试,很抱歉,将提示StringCalculator 类不存在,所以,下一步,我们将在src下,创建StringCalculator.php
<?php namespace Acme; class StringCalculator { public function calculate($expression) { $numbers = preg_split("/[^\d\w\s]/", $expression); $numbers = array_map("trim", $numbers); preg_match("/\d+\s?+([^\w\d\s])/", $expression, $operation); if ($operation[1] === '+') { return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); } } }
然后,运行他,是可以正常通过测试的。
计算方法操作执行操作如下:首先解析表达式(通过正则表达式)提取运算符(比如 +)和需要运算的数字。然后检查运算符并计算最后的结果。
第一次重构
我认为是时候开始我们的第一次重构了。如果你注意到在calculate()方法中存在解析字符串的话,那么,自然而然,我们认为这是一个 Extract Method(提取方法) 的一个迹象。因此,我们在StringCalculator 类中创建一个新的私有方法,叫做parseExpression($expression) ,拷贝代码到方法下
private function parseExpression($expression) { $numbers = preg_split("/[^\d\w\s]/", $expression); $numbers = array_map("trim", $numbers); preg_match("/\d+\s?+([^\w\d\s])/", $expression, $operation); return [$numbers, $operation[1]]; }
然后我们的calculate()方法将会变成这样
public function calculate($expression) { list($numbers, $operation) = $this->parseExpression($expression); if ($operation === '+') { return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); } }
不要低估了上面简单的重构,因为代码的移动让我们的代码更加具有可读性(方法责任单一)。我意思是只要花费不到两分钟的时间,就可以让代码看上去更加美好。
改变之后,执行测试用例,看下代码是否还是正常执行。正常是成功的(GREEN),如果失败(RED)了,那么将查找具体的原因。
Extract Method 是博主接触重构后,学会的第一招,其实,我想说,这个重构手法,你最好能轻松掌握,它没有什么特殊的技巧,说白了,就是将一个行数
很多的方法,拆解成多个小的方法,一个方法,做一个简单的事情就可以了,之余如何划分,后面会有介绍,叫做 单一责任原则
乘法
现在,我们需要支持乘法计算了,同样的,执行测试代码
/** * @test */ function it_multiplies_numbers() { $calculator = new StringCalculator; $result = $calculator->calculate('2*2*2'); $this->assertEquals(8, $result); $result = $calculator->calculate('2 * 3 * 4'); $this->assertEquals(24, $result); }
由于乘法运算符还未实现,测试会失败。因此,我们调整下代码
public function calculate($expression) { list($numbers, $operation) = $this->parseExpression($expression); if ($operation === '+') { return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); } else if ($operation === '*') { return array_reduce($numbers, function($carry, $item) { return $carry * $item; }, 1); } }
再次执行,通过!
二次重构
calculate()方法看上去还是有一些凌乱,我们就必须再次重构它。再次使用 Extract Method ,提取负责计算的部分内容到performOperation($type, $number) 方法中
private function performOperation($operation, $numbers) { if ($operation === '+') { return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); } else if ($operation === '*') { return array_reduce($numbers, function($carry, $item) { return $carry * $item; }, 1); } }
修改后的calculate()方法如下
public function calculate($expression) { list($numbers, $operation) = $this->parseExpression($expression); return $this->performOperation($operation, $numbers); }
执行测试用例,通过。另外一个事,我们可以做的,就是用switch替代if表达式。
private function performOperation($operation, $numbers) { switch ($operation) { case '+': return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); break; case '*': return array_reduce($numbers, function($carry, $item) { return $carry * $item; }, 1); break; } }
将操作符替换成常量
通常,我不喜欢在我的代码中存在字面值(魔法值),所以,我们需要将这些字符或则数字用常量值替换掉
字面值,你可以理解成临时定义的一堆没有受到管理的变量,这其实是一个开发习惯而已,跟重构,其实,关系不大
class StringCalculator { const ADDITION = '+'; const MULTIPLICATION = '*'; //... private function performOperation($operation, $numbers) { switch ($operation) { case static::ADDITION: return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); break; case static::MULTIPLICATION: return array_reduce($numbers, function($carry, $item) { return $carry * $item; }, 1); break; } } }
改动之后,我们再也不用担心这些字面上的值了。运行测试用例,还是可以通过的 ^_^
Replace Conditional with Polymorphism
重构中最伟大的一个手法叫做"Replace Conditional with Polymorphism"。定义如下:
当你需要通过判断条件,来决定使用对象的某一个类型的时候,那么你需要将各个条件分离开,独立成一个个子类方法,同时让基类的原始方法变成抽象方法。
如果对OO思想,还有动态编程理解不够的话,真心觉得一定要有人手把手跟你解释了--博主插了句很扫兴的话 orz
听起来是不是很费解,没有关系的。当我们有特定的行为需要依赖对象的一种类型(子类)的时候(举个例子,运算符类型:加法、乘法),我们需要将运算方法抽象出来(比如叫perform()),并且将加法、乘法等云算法各自独立出来,这样每个运算符子类都覆盖并实现这个抽象的方法。说的再好,不如看下代码吧
首先,抽象一个全新的类 Operation,Operation.php的代码如下:
<?php namespace Acme\Operations; abstract class Operation { abstract function perform($numbers); }
接着,在src/Operations目录下创建Addition.php
<?php namespace Acme\Operations; class Addition extends Operation { function perform($numbers) { return array_reduce($numbers, function($carry, $item) { return $carry + $item; }); } }
同样的,我们在创建在Operations目录下创建Multiplication.php
<?php namespace Acme\Operations; class Multiplication extends Operation { function perform($numbers) { return array_reduce($numbers, function($carry, $item) { return $carry * $item; }, 1); } }
是时候开始使用它们了。我们使用工厂模式来创建具体的操作类,并抽象一个perform($numbers)方法,来看下Operation.php的写法吧,真实太赞了
abstract class Operation { public static function make($type) { switch ($type) { case \Acme\StringCalculator::ADDITION: return new Addition; break; case \Acme\StringCalculator::MULTIPLICATION: return new Multiplication; break; default: throw new \Exception('Not supported Operation'); break; } } abstract function perform($numbers); }
Operation类做了两个事情,第一,负责创建具体的子类(具体的运算符),第二提供一个统一的实现方法入口,接下来看下如何调用了
private function performOperation($operation, $numbers) { $operation = \Acme\Operations\Operation::make($operation); return $operation->perform($numbers); }
能有这样的效果,应该感谢多态这个东西的存在吧 。如果一切顺利的话,你的测试用例还是能通过的。
封装、继承、多态,OOP(面相对象的三个核心思想),多态是基于继承的机制
移动操作常量到合适的位置
在重构你的代码的时候,你通常会注意到,一些数据(常量或则变量)在其他类中被使用的次数比自己本类中还要多。有时候,我们称之为Feature Envy (依恋情结) 。举例来说,在我们代码中
操作符常量就是如此。注意到在当前的StringCalculator 类中,我们并没有引用ADDITION,MULTIPLICATION对象,但是他们却在 Operation 类中被引用,因此,最好的办法,就是将常量
移动到Operation类下
这个重构手法,也非常的常见,说白了,就是方法跟方法使用的变量定义不在一个类中,那么就需要考虑将他们放在一起,否则,方法就要整天思恋调用的那波变量是否还在了。所以,
博主觉得Feature Envy 叫好听点,叫做相思难忘,说难听点叫做移情别恋,身在曹营心在汉 orz
<?php namespace Acme\Operations; abstract class Operation { const ADDITION = '+'; const MULTIPLICATION = '*'; public static function make($type) { switch ($type) { case static::ADDITION: return new Addition; break; case static::MULTIPLICATION: return new Multiplication; break; default: throw new \Exception('Not supported Operation'); break; } } abstract function perform($numbers); }
使用自定义异常类
通过,在我的应用项目中,我更喜欢使用自定义的异常来代替通用的异常。因为创建根据各种错误自定义异常类可以看做是一个最佳实践(经验),原因在于自定义异常的最大好处是你可以根据错误命名异常名字,这样保证
我们不需要顾及哪些笼统的错误信息带来的不便。另外,虽然说我们可以简单的通过继承通用的异常来完成自定义异常来满足正常需求,但是,我们同样可以在自定义异常类中增加新的功能。
所以,接下来,我们在src下创建一个新的目录 Exceptions ,用来存放自定义异常类。我们的自定义异常类叫做 UnsupportedOperationException 。看下UnsupportedOperationException.php的代码吧
<?php namespace Acme\Exceptions; class UnsupportedOperationException extends \Exception {}
注意到,我们仅仅是继承了通用的异常类,但是他已经可以满足我们的需求了。
接下来,让我们替换通用的异常查询吧
default: throw new UnsupportedOperationException; break;
当然,别忘记在文件顶部使用它
use Acme\Exceptions\UnsupportedOperationException;
运行下单元测试
/** * @test * @expectedException Acme\Exceptions\UnsupportedOperationException */ function it_disallows_unsupported_operations() { $calculator = new StringCalculator; $result = $calculator->calculate('3%3'); }
注意到,我们使用了@excetedException注解标签,这样,保证单元测试可以正常通过
提取一个解析类
如果你观察过StringCalculator 类,你将注意到它违背了单一责任原则(有且只有一个原因导致一个类发生变化)。StringCalculator 类目前有两个职责,一个是运算,另外一个是解析表达式。很明显,
解析表达式并不是StringCalculator 类应有的职责。所以,最好的办法有专门的类来负责解析表达式。所以,我们在src目录下创建一个ExpressionParser类,下面是ExpressionParser.php的代码
<?php namespace Acme; class ExpressionParser { private $operation; private $numbers; public function parse($expression) { $this->operation = $this->extractOperation($expression); $this->numbers = $this->extractNumbers($expression); return $this; } public function getOperation() { return $this->operation; } public function getNumbers() { return $this->numbers; } private function extractOperation($expression) { preg_match("/\d+\s?+([^\w\d\s])/", $expression, $operation); return $operation[1]; } private function extractNumbers($expression) { $numbers = preg_split("/[^\d\w\s]/", $expression); return array_map("trim", $numbers); } }
然后通过构造方法注入到StringCalculator类中。
class StringCalculator { private $parser; function __construct(ExpressionParser $parser) { $this->parser = $parser; } //...
然后,去除掉parseExpression()方法,修改后的calculate()方法如下
public function calculate($expression) { $operation = $this->parser->parse($expression)->getOperation(); $numbers = $this->parser->parse($expression)->getNumbers(); return $this->performOperation($operation, $numbers); }
然后,更新下单元测试用例。首先,我们将初始化StringCalculator 类放到setup()方法中
<?php use Acme\StringCalculator; use Acme\ExpressionParser; class StringCalculatorTest extends PHPUnit_Framework_TestCase { function setup() { $this->calculator = new StringCalculator(new ExpressionParser); } /** * @test */ function it_adds_numbers() { $result = $this->calculator->calculate('2+2+2'); $this->assertEquals(6, $result); $result = $this->calculator->calculate('2 + 2 + 2'); $this->assertEquals(6, $result); } /** * @test */ function it_multiplies_numbers() { $result = $this->calculator->calculate('2*2*2'); $this->assertEquals(8, $result); $result = $this->calculator->calculate('2 * 3 * 4'); $this->assertEquals(24, $result); } /** * @test * @expectedException Acme\Exceptions\UnsupportedOperationException */ function it_disallows_unsupported_operations() { $result = $this->calculator->calculate('3%3'); } }
一切顺利的话,单元测试通过 ^_^
结果
一个好的疑问:"什么是被认为是一个好的设计呢?"好吧,我们需要从已知的标准来度量。举个例子,我通常喜欢使用SOLID原则的作为标准来度量我的设计。举个例子,我们的设计,是否遵循了单一责任原则?
我们可以观察每个类和方法,是否只有一个职责(不会有一个以上的原因导致代码发生变化)
当然,如果你知道开闭原则 (对拓展开放,对修改关闭),我们来看下,如果需要新增一个新的表达式,是如何遵循开发原则的。下面,我们新加一个减法的运算符
新功能,减法
首先,我们测试下减法表达式
/** * @test */ function it_subtracts_numbers() { $result = $this->calculator->calculate('5 - 3 - 1'); $this->assertEquals(1, $result); }
运行后,一定是失败的。那么,我们按下面的步骤让单元测试可以正常跑过
1.在Operations目录下创建一个Subtraction 类
2.让Subtraction 继承Operation抽象类,并实现perform()方法
3.在Operation类中新增一个减法的常量
4.将Subtraction 类增加到工厂集合中
现在,让我们看下Subtraction.php的代码先
<?php namespace Acme\Operations; class Subtraction extends Operation { function perform($numbers) { $initial = array_shift($numbers); return array_reduce($numbers, function($carry, $item) { return $carry - $item; }, $initial); } }
接着,在Operation类中,添加常量值 const SUBTRACTION = ‘-’,看下这时候Operation类的代码
<?php namespace Acme\Operations; use Acme\Exceptions\UnsupportedOperationException; abstract class Operation { const ADDITION = '+'; const MULTIPLICATION = '*'; const SUBTRACTION = '-'; public static function make($type) { switch ($type) { case static::ADDITION: return new Addition; break; case static::MULTIPLICATION: return new Multiplication; break; case static::SUBTRACTION: return new Subtraction; break; default: throw new UnsupportedOperationException; break; } } abstract function perform($numbers); }
执行测试代码,通过。
好吧,恭喜你,完成了我们重构的代码之旅 ^_^
结论
我喜欢现在你们可以看到重构的威力。通过重构,可以让糟糕的设计代码变成良好并且规范。需要注意的是,重构其实并没有想象中那么难。同时,我们的代码的可读性大大提高了,重构真的是需要把握的一门技能。
同时,我们必须提醒下,本教程是重构的一个入门。因此,很多不懂的概念与知识点,在后面的教程中,我还会带来更加详细的文章介绍他们。
请快给留言吧 O(∩_∩)O哈哈~